diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..6dce3d64 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,126 @@ +name: Build + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + release: + types: [ published ] + workflow_dispatch: + inputs: + publish: + description: 'Publish packages to NuGet' + type: boolean + default: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore (with retry) + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 15 + command: | + dotnet restore src/PleasantUI/PleasantUI.csproj + dotnet restore src/PleasantUI.DataGrid/PleasantUI.DataGrid.csproj + dotnet restore src/PleasantUI.MaterialIcons/PleasantUI.MaterialIcons.csproj + dotnet restore src/PleasantUI.ToolKit/PleasantUI.ToolKit.csproj + + - name: Build + run: | + dotnet build src/PleasantUI/PleasantUI.csproj --configuration Release --no-restore + dotnet build src/PleasantUI.DataGrid/PleasantUI.DataGrid.csproj --configuration Release --no-restore + dotnet build src/PleasantUI.MaterialIcons/PleasantUI.MaterialIcons.csproj --configuration Release --no-restore + dotnet build src/PleasantUI.ToolKit/PleasantUI.ToolKit.csproj --configuration Release --no-restore + + - name: Verify packages exist + run: | + echo "=== Searching for all .nupkg files under src/ ===" + find src/ -name "*.nupkg" 2>/dev/null | sort + + FAILED=0 + for project in PleasantUI PleasantUI.DataGrid PleasantUI.MaterialIcons PleasantUI.ToolKit; do + FOUND=$(find "src/$project" -name "*.nupkg" 2>/dev/null | head -1) + if [ -z "$FOUND" ]; then + echo "ERROR: No .nupkg found anywhere under src/$project" + echo "--- Directory listing of src/$project/bin ---" + find "src/$project/bin" -type f 2>/dev/null || echo "(bin dir not found)" + FAILED=1 + else + echo "OK: Found package for $project at $FOUND" + fi + done + + if [ "$FAILED" -eq 1 ]; then + exit 1 + fi + + - name: Upload NuGet packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: | + src/PleasantUI/bin/Release/*.nupkg + src/PleasantUI.DataGrid/bin/Release/*.nupkg + src/PleasantUI.MaterialIcons/bin/Release/*.nupkg + src/PleasantUI.ToolKit/bin/Release/*.nupkg + if-no-files-found: error + + publish: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') + timeout-minutes: 15 + permissions: + id-token: write + + steps: + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: nupkgs + + - name: Verify downloaded packages + run: | + count=$(find nupkgs -name "*.nupkg" | wc -l) + echo "Found $count package(s)" + if [ "$count" -eq 0 ]; then + echo "ERROR: No packages to publish" + exit 1 + fi + find nupkgs -name "*.nupkg" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Publish to NuGet (Trusted Publishing, with retry) + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + for pkg in $(find nupkgs -name "*.nupkg"); do + echo "Pushing $pkg" + if [ -n "${{ secrets.NUGET_KEY }}" ]; then + dotnet nuget push "$pkg" --source https://api.nuget.org/v3/index.json --api-key "${{ secrets.NUGET_KEY }}" --skip-duplicate + else + dotnet nuget push "$pkg" --source https://api.nuget.org/v3/index.json --skip-duplicate + fi + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b9ee28e4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,676 @@ +name: Release + +on: + push: + branches: [ "main", "master" ] + paths: + - 'build/Package.props' + workflow_dispatch: + inputs: + force: + description: 'Force release even if tag exists' + type: boolean + default: false + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + models: read + pull-requests: read + packages: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Pull latest changes + run: | + git fetch origin + git reset --hard origin/${{ github.ref_name }} + echo "HEAD is now: $(git log -1 --oneline)" + + - name: Extract and validate version + id: version + run: | + if [ ! -f build/Package.props ]; then + echo "ERROR: build/Package.props not found" + exit 1 + fi + + VERSION=$(grep -oP '(?<=)[0-9]+\.[0-9]+\.[0-9]+[^<]*' build/Package.props | head -1) + + if [ -z "$VERSION" ]; then + echo "ERROR: Could not extract a valid PackageVersion from build/Package.props" + cat build/Package.props + exit 1 + fi + + if [[ "$VERSION" =~ [[:space:]] ]]; then + echo "ERROR: Extracted version '$VERSION' contains whitespace" + exit 1 + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Detected version: $VERSION" + + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> $GITHUB_OUTPUT + else + echo "prerelease=false" >> $GITHUB_OUTPUT + fi + + - name: Check if tag already exists + id: tag_check + run: | + git fetch --tags --force + TAG="v${{ steps.version.outputs.version }}" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + if [ "${{ github.event.inputs.force }}" == "true" ]; then + echo "exists=false" >> $GITHUB_OUTPUT + echo "Tag $TAG exists but force=true — deleting and re-creating." + git push origin ":refs/tags/$TAG" || true + else + echo "exists=true" >> $GITHUB_OUTPUT + echo "Tag $TAG already exists. Skipping release." + fi + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Tag $TAG does not exist — proceeding." + fi + + - name: Setup .NET + if: steps.tag_check.outputs.exists == 'false' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore (with retry) + if: steps.tag_check.outputs.exists == 'false' + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 15 + command: | + dotnet restore src/PleasantUI/PleasantUI.csproj + dotnet restore src/PleasantUI.DataGrid/PleasantUI.DataGrid.csproj + dotnet restore src/PleasantUI.MaterialIcons/PleasantUI.MaterialIcons.csproj + dotnet restore src/PleasantUI.ToolKit/PleasantUI.ToolKit.csproj + + - name: Build + if: steps.tag_check.outputs.exists == 'false' + run: | + dotnet build src/PleasantUI/PleasantUI.csproj --configuration Release --no-restore + dotnet build src/PleasantUI.DataGrid/PleasantUI.DataGrid.csproj --configuration Release --no-restore + dotnet build src/PleasantUI.MaterialIcons/PleasantUI.MaterialIcons.csproj --configuration Release --no-restore + dotnet build src/PleasantUI.ToolKit/PleasantUI.ToolKit.csproj --configuration Release --no-restore + + - name: Verify packages exist + if: steps.tag_check.outputs.exists == 'false' + run: | + echo "=== Searching for all .nupkg files under src/ ===" + find src/ -name "*.nupkg" 2>/dev/null | sort + + FAILED=0 + for project in PleasantUI PleasantUI.DataGrid PleasantUI.MaterialIcons PleasantUI.ToolKit; do + FOUND=$(find "src/$project" -name "*.nupkg" 2>/dev/null | head -1) + if [ -z "$FOUND" ]; then + echo "ERROR: No .nupkg found anywhere under src/$project" + find "src/$project/bin" -type f 2>/dev/null || echo "(bin dir not found)" + FAILED=1 + else + echo "OK: Found package for $project at $FOUND" + fi + done + + if [ "$FAILED" -eq 1 ]; then exit 1; fi + + - name: Collect commits and build context chunks + if: steps.tag_check.outputs.exists == 'false' + id: collect + run: | + # ── Find previous tag ───────────────────────────────────────────────── + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + echo "Previous tag: '${PREV_TAG}'" + + if [ -n "$PREV_TAG" ]; then + RANGE="${PREV_TAG}..HEAD" + else + FIRST=$(git rev-list --max-parents=0 HEAD) + RANGE="${FIRST}..HEAD" + fi + echo "range=$RANGE" >> $GITHUB_OUTPUT + echo "Diff range: $RANGE" + + # ── Commit log ──────────────────────────────────────────────────────── + COMMITS=$(git log "$RANGE" --pretty=format:"[%h] %s (%an, %ad)" --date=short) + [ -z "$COMMITS" ] && COMMITS="(no commits in range — possibly initial release)" + + # ── Changed files (excl. samples/) ──────────────────────────────────── + FILE_LIST=$(git diff --name-only "$RANGE" | grep -v '^samples/' | head -200) + STAT_SUMMARY=$(git diff --shortstat "$RANGE" | tail -1) + + # ── Public API additions ─────────────────────────────────────────────── + API_SURFACE=$(git diff "$RANGE" -- '*.cs' \ + | grep '^+[^+]' \ + | grep -v '^+.*\/\/' \ + | grep -E '\s(public|protected)\s' \ + | grep -E '(class |interface |enum |record |struct |void |bool |int |string |Task|AvaloniaProperty|StyledProperty|DirectProperty|AttachedProperty|RoutedEvent)' \ + | sed 's/^+//' \ + | sed 's/^[[:space:]]*//' \ + | sort -u \ + | head -200) + + # ── Shared system prompt — all printf, no heredoc ────────────────────── + printf '%s\n' \ + 'You are a senior technical writer analysing a PleasantUI release diff batch.' \ + 'PleasantUI is a cross-platform UI theme and control library for Avalonia (.NET), inspired by Microsoft Fluent Design / WinUI.' \ + '' \ + '## Your task' \ + 'Extract EVERY meaningful change from the diffs below. Output structured bullet points — NOT final release notes.' \ + 'This output will be merged with other batches, so completeness matters more than brevity.' \ + 'Do NOT drop or summarise away any finding. One bullet per distinct change.' \ + '' \ + '## Rules' \ + '- Each diff is a COMPLETE per-file diff — read it fully before drawing conclusions.' \ + '- New control/feature: name, what it does, key public API (properties, events, methods).' \ + '- New property/event: name, type, one-line purpose.' \ + '- Bug fix: describe the symptom that was fixed, not just the commit wording.' \ + '- Breaking change: old API → new API, one line each.' \ + '- If a file has no meaningful public-facing change (e.g. only whitespace/comments), skip it.' \ + '- Do NOT invent anything not evidenced by the diff.' \ + '' \ + '## Output format (strict)' \ + 'Group findings under these exact headings (omit empty ones):' \ + '**New Controls / Features**' \ + '**Improvements**' \ + '**Bug Fixes**' \ + '**Breaking Changes**' \ + '' \ + 'Each bullet: `- [FileName] `' \ + '' \ + '---' \ + > /tmp/system-prompt.txt + + # ── Prompt 1: commits + files + API surface (always fits, no diff) ───── + { + cat /tmp/system-prompt.txt + printf '\n' + printf 'Version: v%s\n' "${{ steps.version.outputs.version }}" + printf 'Release type: %s\n\n' \ + "$( [ '${{ steps.version.outputs.prerelease }}' = 'true' ] && printf 'PRE-RELEASE (alpha/beta/rc — may contain incomplete features and known issues)' || printf 'STABLE RELEASE' )" + printf '## Commit Log\n```\n%s\n```\n\n' "$COMMITS" + printf '## All Changed Files (excl. samples/)\n```\n%s\n\nSummary: %s\n```\n\n' \ + "$FILE_LIST" "$STAT_SUMMARY" + if [ -n "$API_SURFACE" ]; then + printf '## New / Modified Public API\n```csharp\n%s\n```\n' "$API_SURFACE" + fi + } > /tmp/prompt-1.txt + + # ── Split diff per-file into batches of ~700 lines each ─────────────── + # We collect complete per-file diffs so AI always sees a whole file diff, + # never a mid-function cut. Max 5 diff batches (prompts 2-6). + CHANGED_SRC=$(git diff --name-only "$RANGE" \ + | grep -v '^samples/' \ + | grep -E '\.(cs|axaml)$') + + mkdir -p /tmp/prompts + cp /tmp/prompt-1.txt /tmp/prompts/ + + BATCH_NUM=2 + BATCH_LINES=0 + BATCH_CONTENT="" + MAX_BATCH_LINES=700 + MAX_BATCHES=5 + + for FILE in $CHANGED_SRC; do + FILE_DIFF=$(git diff "$RANGE" --unified=3 -- "$FILE" 2>/dev/null) + [ -z "$FILE_DIFF" ] && continue + + FILE_LINES=$(printf '%s\n' "$FILE_DIFF" | wc -l) + + # If adding this file would overflow the batch, flush current batch first + if [ $BATCH_LINES -gt 0 ] && [ $(( BATCH_LINES + FILE_LINES )) -gt $MAX_BATCH_LINES ]; then + if [ $BATCH_NUM -le $(( MAX_BATCHES + 1 )) ]; then + PART_NUM=$(( BATCH_NUM - 1 )) + { + cat /tmp/system-prompt.txt + printf '\nThis is diff batch %d. Analyse each file diff completely and list your findings.\n\n' "$PART_NUM" + printf '%s\n' "$BATCH_CONTENT" + } > "/tmp/prompts/prompt-${BATCH_NUM}.txt" + echo "Batch ${BATCH_NUM}: ${BATCH_LINES} lines -> /tmp/prompts/prompt-${BATCH_NUM}.txt" + BATCH_NUM=$(( BATCH_NUM + 1 )) + fi + BATCH_LINES=0 + BATCH_CONTENT="" + fi + + # Accumulate into current batch (skip if we already hit max batches) + if [ $BATCH_NUM -le $(( MAX_BATCHES + 1 )) ]; then + BATCH_CONTENT=$(printf '%s\n\n### File: %s\n```diff\n%s\n```\n' \ + "$BATCH_CONTENT" "$FILE" "$FILE_DIFF") + BATCH_LINES=$(( BATCH_LINES + FILE_LINES )) + fi + done + + # Flush final batch + if [ $BATCH_LINES -gt 0 ] && [ $BATCH_NUM -le $(( MAX_BATCHES + 1 )) ]; then + PART_NUM=$(( BATCH_NUM - 1 )) + { + cat /tmp/system-prompt.txt + printf '\nThis is diff batch %d. Analyse each file diff completely and list your findings.\n\n' "$PART_NUM" + printf '%s\n' "$BATCH_CONTENT" + } > "/tmp/prompts/prompt-${BATCH_NUM}.txt" + echo "Batch ${BATCH_NUM}: ${BATCH_LINES} lines -> /tmp/prompts/prompt-${BATCH_NUM}.txt" + fi + + # ── Ensure prompts 2-6 exist (empty marker if no content) ───────────── + for N in 2 3 4 5 6; do + if [ ! -f "/tmp/prompts/prompt-${N}.txt" ]; then + printf 'No diff content for this batch.\n' > "/tmp/prompts/prompt-${N}.txt" + fi + done + + echo "=== All prompt sizes ===" + for N in 1 2 3 4 5 6; do + printf 'prompt-%d.txt: %d bytes\n' "$N" "$(wc -c < /tmp/prompts/prompt-${N}.txt)" + done + + - name: Upload prompt artifacts + if: steps.tag_check.outputs.exists == 'false' + uses: actions/upload-artifact@v4 + with: + name: ai-prompts + path: /tmp/prompts/ + retention-days: 1 + + # ── AI passes — each gets complete per-file diffs, never a mid-file cut ─── + - name: AI pass 1 — commits + API surface + if: steps.tag_check.outputs.exists == 'false' + id: ai_pass1 + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompts/prompt-1.txt + + - name: Save pass 1 response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_pass1.outputs.response-file }}" /tmp/pass-1.txt + sleep 65 + + - name: AI pass 2 — diff batch 1 + if: steps.tag_check.outputs.exists == 'false' + id: ai_pass2 + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompts/prompt-2.txt + + - name: Save pass 2 response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_pass2.outputs.response-file }}" /tmp/pass-2.txt + sleep 65 + + - name: AI pass 3 — diff batch 2 + if: steps.tag_check.outputs.exists == 'false' + id: ai_pass3 + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompts/prompt-3.txt + + - name: Save pass 3 response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_pass3.outputs.response-file }}" /tmp/pass-3.txt + sleep 65 + + - name: AI pass 4 — diff batch 3 + if: steps.tag_check.outputs.exists == 'false' + id: ai_pass4 + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompts/prompt-4.txt + + - name: Save pass 4 response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_pass4.outputs.response-file }}" /tmp/pass-4.txt + sleep 65 + + - name: AI pass 5 — diff batch 4 + if: steps.tag_check.outputs.exists == 'false' + id: ai_pass5 + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompts/prompt-5.txt + + - name: Save pass 5 response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_pass5.outputs.response-file }}" /tmp/pass-5.txt + sleep 65 + + - name: AI pass 6 — diff batch 5 + if: steps.tag_check.outputs.exists == 'false' + id: ai_pass6 + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompts/prompt-6.txt + + - name: Save pass 6 response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_pass6.outputs.response-file }}" /tmp/pass-6.txt + sleep 65 + + - name: Build merge-A prompt (passes 1-3) + if: steps.tag_check.outputs.exists == 'false' + run: | + # all printf — no heredoc to avoid YAML indent issues + printf '%s\n' \ + 'You are a senior technical writer for PleasantUI — a cross-platform UI theme and control library for Avalonia (.NET).' \ + 'Below are raw per-batch findings from analysis passes 1-3 of this release.' \ + '' \ + 'Your task: produce a LOSSLESS intermediate summary.' \ + '- Keep EVERY distinct finding. Do not drop or merge away any item.' \ + '- Deduplicate only exact duplicates (same file, same change mentioned twice).' \ + '- Be concise per bullet (one sentence max) but preserve all facts: names, types, behaviours.' \ + '- Group under: **New Controls / Features** | **Improvements** | **Bug Fixes** | **Breaking Changes**' \ + '- Each bullet: `- [FileName] `' \ + '- This output feeds a final merge step — completeness is critical.' \ + '' \ + '---' \ + > /tmp/prompt-merge-a.txt + { + printf '## Pass 1 — Commits, changed files, public API\n' + cat /tmp/pass-1.txt 2>/dev/null || printf '(no output)\n' + printf '\n## Pass 2 — Diff batch 1\n' + cat /tmp/pass-2.txt 2>/dev/null || printf '(no output)\n' + printf '\n## Pass 3 — Diff batch 2\n' + cat /tmp/pass-3.txt 2>/dev/null || printf '(no output)\n' + } >> /tmp/prompt-merge-a.txt + printf 'merge-A prompt: %d bytes\n' "$(wc -c < /tmp/prompt-merge-a.txt)" + + - name: AI merge-A (passes 1-3) + if: steps.tag_check.outputs.exists == 'false' + id: ai_merge_a + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompt-merge-a.txt + + - name: Save merge-A response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_merge_a.outputs.response-file }}" /tmp/merge-a.txt + sleep 65 + + - name: Compress merge-A output + if: steps.tag_check.outputs.exists == 'false' + run: | + # all printf — no heredoc to avoid YAML indent issues + printf '%s\n' \ + 'You are compressing a change-log summary for PleasantUI.' \ + 'Rewrite the input as a TIGHT structured list. Rules:' \ + '- One bullet per distinct change. Max 12 words per bullet.' \ + '- Format: `- [Category] FileName: ` where Category is one of: New | Improved | Fixed | Breaking' \ + '- Preserve ALL items — do not drop any finding.' \ + '- No prose, no headers, no blank lines between bullets.' \ + '- Output ONLY the bullet list, nothing else.' \ + '---' \ + > /tmp/prompt-compress-a.txt + cat /tmp/merge-a.txt >> /tmp/prompt-compress-a.txt + printf 'compress-A prompt: %d bytes\n' "$(wc -c < /tmp/prompt-compress-a.txt)" + + - name: AI compress-A + if: steps.tag_check.outputs.exists == 'false' + id: ai_compress_a + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompt-compress-a.txt + + - name: Save compress-A response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_compress_a.outputs.response-file }}" /tmp/compress-a.txt + sleep 65 + + - name: Build merge-B prompt (passes 4-6) + if: steps.tag_check.outputs.exists == 'false' + run: | + # all printf — no heredoc to avoid YAML indent issues + printf '%s\n' \ + 'You are a senior technical writer for PleasantUI — a cross-platform UI theme and control library for Avalonia (.NET).' \ + 'Below are raw per-batch findings from analysis passes 4-6 of this release.' \ + '' \ + 'Your task: produce a LOSSLESS intermediate summary.' \ + '- Keep EVERY distinct finding. Do not drop or merge away any item.' \ + '- Deduplicate only exact duplicates (same file, same change mentioned twice).' \ + '- Be concise per bullet (one sentence max) but preserve all facts: names, types, behaviours.' \ + '- Group under: **New Controls / Features** | **Improvements** | **Bug Fixes** | **Breaking Changes**' \ + '- Each bullet: `- [FileName] `' \ + '- This output feeds a final merge step — completeness is critical.' \ + '' \ + '---' \ + > /tmp/prompt-merge-b.txt + { + printf '## Pass 4 — Diff batch 3\n' + cat /tmp/pass-4.txt 2>/dev/null || printf '(no output)\n' + printf '\n## Pass 5 — Diff batch 4\n' + cat /tmp/pass-5.txt 2>/dev/null || printf '(no output)\n' + printf '\n## Pass 6 — Diff batch 5\n' + cat /tmp/pass-6.txt 2>/dev/null || printf '(no output)\n' + } >> /tmp/prompt-merge-b.txt + printf 'merge-B prompt: %d bytes\n' "$(wc -c < /tmp/prompt-merge-b.txt)" + + - name: AI merge-B (passes 4-6) + if: steps.tag_check.outputs.exists == 'false' + id: ai_merge_b + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompt-merge-b.txt + + - name: Save merge-B response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_merge_b.outputs.response-file }}" /tmp/merge-b.txt + sleep 65 + + - name: Compress merge-B output + if: steps.tag_check.outputs.exists == 'false' + run: | + # all printf — no heredoc to avoid YAML indent issues + printf '%s\n' \ + 'You are compressing a change-log summary for PleasantUI.' \ + 'Rewrite the input as a TIGHT structured list. Rules:' \ + '- One bullet per distinct change. Max 12 words per bullet.' \ + '- Format: `- [Category] FileName: ` where Category is one of: New | Improved | Fixed | Breaking' \ + '- Preserve ALL items — do not drop any finding.' \ + '- No prose, no headers, no blank lines between bullets.' \ + '- Output ONLY the bullet list, nothing else.' \ + '---' \ + > /tmp/prompt-compress-b.txt + cat /tmp/merge-b.txt >> /tmp/prompt-compress-b.txt + printf 'compress-B prompt: %d bytes\n' "$(wc -c < /tmp/prompt-compress-b.txt)" + + - name: AI compress-B + if: steps.tag_check.outputs.exists == 'false' + id: ai_compress_b + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini + max-tokens: 8000 + prompt-file: /tmp/prompt-compress-b.txt + + - name: Save compress-B response + if: steps.tag_check.outputs.exists == 'false' + run: | + cp "${{ steps.ai_compress_b.outputs.response-file }}" /tmp/compress-b.txt + sleep 65 + + - name: Build final merge prompt (compress-A + compress-B) + if: steps.tag_check.outputs.exists == 'false' + run: | + # all printf — no heredoc to avoid YAML indent issues + IS_PRERELEASE="${{ steps.version.outputs.prerelease }}" + VERSION="v${{ steps.version.outputs.version }}" + + printf '%s\n' \ + 'You are a senior technical writer for PleasantUI — a cross-platform UI theme and control library for Avalonia (.NET).' \ + 'Below are two compressed change lists covering the COMPLETE diff of this release.' \ + 'Each line is one distinct change. Together they represent every change in this release.' \ + '' \ + '## Critical rules' \ + '- You MUST include every single bullet from BOTH lists in your output.' \ + '- Read ALL bullets before writing. Do not stop after the first section.' \ + '- Deduplicate only true duplicates (same file + same change in both lists).' \ + '- When the same item appears in both, use the more detailed description.' \ + '- Use markdown: ### section headers, bullet lists, inline code.' \ + '- Do NOT invent anything not present in the lists.' \ + '- After writing, mentally verify: does every input bullet appear somewhere in the output?' \ + > /tmp/prompt-final.txt + + if [ "$IS_PRERELEASE" = 'true' ]; then + printf '%s\n' \ + '' \ + '## Release type: PRE-RELEASE' \ + "This is a pre-release build ($VERSION). Adjust the tone accordingly:" \ + '- Open with a short pre-release notice: features may be incomplete, APIs may change.' \ + '- Mark experimental or in-progress items with *(experimental)* or *(preview)*.' \ + '- Use language like "introduces", "adds initial support for", "work in progress".' \ + >> /tmp/prompt-final.txt + else + printf '%s\n' \ + '' \ + '## Release type: STABLE RELEASE' \ + "This is a stable release ($VERSION). Adjust the tone accordingly:" \ + '- Write with confidence. Features are production-ready.' \ + '- Use language like "adds", "improves", "fixes", "introduces".' \ + '- No experimental caveats unless explicitly noted in the lists.' \ + >> /tmp/prompt-final.txt + fi + + printf '%s\n' \ + '' \ + '## Required output sections (include only those with content):' \ + '### What is New' \ + '### Improvements' \ + '### Bug Fixes' \ + '### Breaking Changes' \ + '### Packages' \ + '' \ + 'Always end with a Packages section listing:' \ + '- PleasantUI' \ + '- PleasantUI.DataGrid' \ + '- PleasantUI.MaterialIcons' \ + '- PleasantUI.ToolKit' \ + '' \ + '---' \ + >> /tmp/prompt-final.txt + + { + printf '## Compressed list A (passes 1-3: commits, API, diff batches 1-2)\n' + cat /tmp/compress-a.txt 2>/dev/null || printf '(no output)\n' + printf '\n## Compressed list B (passes 4-6: diff batches 3-5)\n' + cat /tmp/compress-b.txt 2>/dev/null || printf '(no output)\n' + } >> /tmp/prompt-final.txt + + printf 'Final merge prompt: %d bytes\n' "$(wc -c < /tmp/prompt-final.txt)" + + - name: AI final merge — write release notes + if: steps.tag_check.outputs.exists == 'false' + id: ai_merge + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4.1 + max-tokens: 8000 + prompt-file: /tmp/prompt-final.txt + + - name: Save final merge response + if: steps.tag_check.outputs.exists == 'false' + run: cp "${{ steps.ai_merge.outputs.response-file }}" /tmp/final-merge.txt + - name: Write release notes to file + if: steps.tag_check.outputs.exists == 'false' + id: notes_file + run: | + if [ -f /tmp/final-merge.txt ] && [ -s /tmp/final-merge.txt ]; then + cp /tmp/final-merge.txt /tmp/release_notes.md + echo "Using final merged AI-generated release notes." + else + # Fallback: use whichever intermediate merges succeeded + { + printf '### Changes in v%s\n\n' "${{ steps.version.outputs.version }}" + cat /tmp/compress-a.txt 2>/dev/null || cat /tmp/merge-a.txt 2>/dev/null || true + printf '\n' + cat /tmp/compress-b.txt 2>/dev/null || cat /tmp/merge-b.txt 2>/dev/null || true + } > /tmp/release_notes.md + echo "Used fallback: compressed merge outputs." + fi + + echo "path=/tmp/release_notes.md" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + if: steps.tag_check.outputs.exists == 'false' + id: create_release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + body_path: ${{ steps.notes_file.outputs.path }} + fail_on_unmatched_files: true + files: | + src/PleasantUI/bin/Release/*.nupkg + src/PleasantUI.DataGrid/bin/Release/*.nupkg + src/PleasantUI.MaterialIcons/bin/Release/*.nupkg + src/PleasantUI.ToolKit/bin/Release/*.nupkg + + - name: Confirm release created + if: steps.tag_check.outputs.exists == 'false' + run: | + echo "Release created: ${{ steps.create_release.outputs.url }}" + + - name: Publish to GitHub Packages + if: steps.tag_check.outputs.exists == 'false' + run: | + dotnet nuget add source \ + --username ghudulf \ + --password ${{ secrets.GITHUB_TOKEN }} \ + --store-password-in-clear-text \ + --name github \ + "https://nuget.pkg.github.com/ghudulf/index.json" || true + + for pkg in \ + src/PleasantUI/bin/Release/*.nupkg \ + src/PleasantUI.DataGrid/bin/Release/*.nupkg \ + src/PleasantUI.MaterialIcons/bin/Release/*.nupkg \ + src/PleasantUI.ToolKit/bin/Release/*.nupkg; do + echo "Pushing $pkg" + dotnet nuget push "$pkg" \ + --api-key ${{ secrets.GITHUB_TOKEN }} \ + --source "github" \ + --skip-duplicate + done diff --git a/.gitignore b/.gitignore index abf76745..9ed3e209 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,8 @@ obj-Skia/ coc-settings.json .ccls-cache .ccls + +################# +## Kiro +################# +.kiro/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..bb282a11 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + pieckenst.Avalonia12. + + diff --git a/PleasantUI.sln b/PleasantUI.sln index cf67466c..714bf104 100644 --- a/PleasantUI.sln +++ b/PleasantUI.sln @@ -29,8 +29,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{D6E493DE build\Package.props = build\Package.props EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Example.Browser", "samples\PleasantUI.Example.Browser\PleasantUI.Example.Browser.csproj", "{F745D592-9AD7-4E57-B12F-41B94BDF362A}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{4BCCA866-A31E-49B1-AB79-B9C94651D295}" EndProject Global @@ -75,10 +73,6 @@ Global {722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Debug|Any CPU.Build.0 = Debug|Any CPU {722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Release|Any CPU.ActiveCfg = Release|Any CPU {722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Release|Any CPU.Build.0 = Release|Any CPU - {F745D592-9AD7-4E57-B12F-41B94BDF362A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F745D592-9AD7-4E57-B12F-41B94BDF362A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F745D592-9AD7-4E57-B12F-41B94BDF362A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F745D592-9AD7-4E57-B12F-41B94BDF362A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {71BFCFA6-B53E-4F28-819F-51CB26516B5A} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40} @@ -86,7 +80,6 @@ Global {59DC0F4B-421E-407D-9435-F6ED14765634} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40} {88C264B9-51D4-41CF-AEC0-24F5075FA6B8} = {EBAC1577-CFD7-477C-848E-86BFFC345D90} {9FDFD065-7664-4E0C-B928-D7FC8B074EDD} = {941F7A66-EFCB-4141-97CA-A0B779ED1BB4} - {F745D592-9AD7-4E57-B12F-41B94BDF362A} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40} {2ABE0576-837B-4D1B-970D-00B52BF0E078} = {4BCCA866-A31E-49B1-AB79-B9C94651D295} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 521bd604..ffb4734c 100644 --- a/README.md +++ b/README.md @@ -7,54 +7,150 @@ # PleasantUI -Pleasant UI is a graphical user interface library for Avalonia with its own controls. Previously, it was only available as part of the Regul and Regul Save Cleaner projects. This project has existed since at least 2021. +> **Repositories:** [Original (Onebeld)](https://github.com/Onebeld/PleasantUI) · [Fork (ghudulf)](https://github.com/ghudulf/PleasantUI) -This library continues the OlibUI tradition of releasing only later versions, not the very first. +PleasantUI is a cross-platform UI theme and control library for [Avalonia](https://github.com/AvaloniaUI/Avalonia), inspired by Microsoft Fluent Design and the WinUI/UWP visual language. It completely re-styles every standard Avalonia control and adds a suite of custom controls, a multi-theme engine with custom theme support, a reactive localization system, and a custom window chrome — all AOT-compatible with no `rd.xml` required. -This library is mostly focused on performance, lightness, and beauty, compared to many others. +The project has been in active development since 2021, originally as part of the [Regul](https://github.com/Onebeld/Regul) and [Regul Save Cleaner](https://github.com/Onebeld/RegulSaveCleaner) projects. -This library is fully compatible with AOT compilation, and does not need to be added to rd.xml +--- + +## Features + +### Complete Fluent-style control theming + +Every standard Avalonia control gets a full Fluent Design makeover — rounded corners, layered fill colors, smooth pointer-over and pressed transitions, and accent color integration: + +| Control | Control | Control | +|---|---|---| +| Button (+ AppBar, Accent, Danger variants) | CheckBox | RadioButton | +| ToggleButton / ToggleSwitch | RepeatButton / ButtonSpinner | Slider | +| TextBox / AutoCompleteBox | NumericUpDown | ComboBox / DropDownButton | +| Calendar / CalendarDatePicker / TimePicker | DataGrid | ListBox / TreeView | +| Expander | TabControl / TabItem | ScrollBar / ScrollViewer | +| ProgressBar | Menu / ContextMenu | ToolTip | +| Carousel | Separator | NotificationCard | + +### Custom Pleasant controls + +Controls built from scratch that go beyond what Avalonia ships: + +| Control | Description | +|---|---| +| `PleasantWindow` | Custom window chrome with a Fluent title bar, subtitle, custom icon/title content, optional blur, content-extends-into-titlebar, and macOS caption override | +| `NavigationView` / `NavigationViewItem` | Collapsible side navigation panel, similar to WinUI NavigationView | +| `PleasantTabView` / `PleasantTabItem` | Chromium-style tab strip with add/close buttons and scrollable tab bar | +| `ContentDialog` | Modal overlay dialog with bottom button panel and smooth scroll content area | +| `PleasantSnackbar` | Temporary non-intrusive notification bar | +| `ProgressRing` | Circular progress indicator — both determinate and indeterminate with animated arc | +| `OptionsDisplayItem` | Settings-style row with header, description, icon, action button slot, navigation chevron, and expandable content | +| `InformationBlock` | Compact pill-shaped label combining an icon and a value | +| `MarkedTextBox` / `MarkedNumericUpDown` | Input controls with inline label/unit markers | +| `RippleEffect` | Material-style ripple click feedback | +| `SmoothScrollViewer` | ScrollViewer with inertia gesture support | +| `PleasantMiniWindow` | Lightweight floating window | + +### Theme engine + +- Built-in themes: **Light**, **Dark**, **Mint**, **Strawberry**, **Ice**, **Sunny**, **Spruce**, **Cherry**, **Cave**, **Lunar** +- **System** mode — follows the OS light/dark preference automatically +- **Custom themes** — create, edit, export, import, and persist your own color palettes via the built-in `ThemeEditorWindow` +- Accent color follows the OS accent or can be overridden per-user; light/dark variants and gradient pairs are generated automatically +- Settings are persisted to disk automatically on desktop; mobile apps can save manually + +### Localization system + +- `Localizer` singleton backed by .NET `ResourceManager` — add any number of `.resx` resource files +- `{Localize Key}` AXAML markup extension binds reactively — switching language updates every bound string instantly without reloading views +- `Localizer.TrDefault(key, fallback)` for safe lookups that fall back to a raw string instead of an error message +- `LocalizationChanged` event for view models and code-behind to react to language switches + +--- + +## Packages + +| Package | Description | +|---|---| +| `PleasantUI` | Core theme, all control styles, Pleasant controls, theme engine, localization | +| `PleasantUI.ToolKit` | `MessageBox`, `ThemeEditorWindow`, color picker utilities | +| `PleasantUI.MaterialIcons` | Material Design icon geometry library for use with `PathIcon` | +| `PleasantUI.DataGrid` | Fluent-styled DataGrid extension | + +--- + +## Documentation + +Detailed reference docs for each control are in the [`docs/`](docs/) folder: + +| Doc | Controls | +|---|---| +| [PleasantWindow](docs/PleasantWindow.md) | `PleasantWindow`, `IPleasantSplashScreen` | +| [PleasantMiniWindow](docs/PleasantMiniWindow.md) | `PleasantMiniWindow` | +| [NavigationView](docs/NavigationView.md) | `NavigationView`, `NavigationViewItem` | +| [PleasantTabView](docs/PleasantTabView.md) | `PleasantTabView`, `PleasantTabItem` | +| [ContentDialog](docs/ContentDialog.md) | `ContentDialog` | +| [MessageBox](docs/MessageBox.md) | `MessageBox` (ToolKit) | +| [PleasantDialog](docs/PleasantDialog.md) | `PleasantDialog` (ToolKit) | +| [PleasantSnackbar](docs/PleasantSnackbar.md) | `PleasantSnackbar` | +| [ProgressRing](docs/ProgressRing.md) | `ProgressRing` | +| [OptionsDisplayItem](docs/OptionsDisplayItem.md) | `OptionsDisplayItem` | +| [InformationBlock](docs/InformationBlock.md) | `InformationBlock` | +| [DataGrid](docs/DataGrid.md) | `PleasantUI.DataGrid` package | +| [Localization](docs/Localization.md) | `Localizer`, `{Localize}` markup extension | +| [Theme Engine](docs/ThemeEngine.md) | `PleasantTheme`, custom themes, color tokens | + +--- ## Getting Started -Install this library using NuGet, or copy the code to paste into your project file: +### Install + +> **Note:** Until [Onebeld/PleasantUI#6](https://github.com/Onebeld/PleasantUI/pull/6) is merged upstream, this fork targets Avalonia 12 and is ahead of the upstream Avalonia 11 packages. Use the fork packages if you need Avalonia 12 support. + +**This fork (Avalonia 12, recommended)** + +Published under the `pieckenst.Avalonia12.*` prefix on NuGet: ```xml - + + + + ``` -### Setup +**Original upstream (Avalonia 11)** -For your application, add PleasantTheme to your styles: +Only `PleasantUI` and `PleasantUI.DataGrid` are published upstream — `MaterialIcons` and `ToolKit` are exclusive to this fork. + +```xml + + +``` + +### Add the theme + +In your `App.axaml`, add `PleasantTheme` to your styles: ```xml + x:Class="YourApp.App"> ``` -This library automatically loads settings and saves them when the program is finished _(note, for mobile projects you need to save settings manually)_ - -Make sure that in the application class file, the XAML loader is in the overridden initialization method. Otherwise, you will get an error that the program is not initialized when you run the program. -```csharp -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; -using YourApplication.ViewModels; -using YourApplication.Views; +### Initialize correctly -namespace YourApplication; +Make sure `AvaloniaXamlLoader.Load(this)` is called in `Initialize()`: +```csharp public partial class App : Application { - // That's exactly what you need to do, as shown below public override void Initialize() { - AvaloniaXamlLoader.Load(this); + AvaloniaXamlLoader.Load(this); // required } public override void OnFrameworkInitializationCompleted() @@ -72,22 +168,18 @@ public partial class App : Application } ``` -Next, we need to modify the main window so that it inherits from PleasantWindow: +### Use PleasantWindow + +Replace `Window` with `PleasantWindow` to get the custom Fluent title bar: ```csharp using PleasantUI.Controls; -namespace YourApplication.Views; - public partial class MainWindow : PleasantWindow { - public MainWindow() - { - InitializeComponent(); - } + public MainWindow() => InitializeComponent(); } ``` -Make sure that the (A)XAML file of the main window has a PleasantWindow object instead of Window: ```xml ``` -Done! Now you can build your applications with this library. +Key `PleasantWindow` properties: + +| Property | Type | Description | +|---|---|---| +| `TitleBarType` | `Classic` / `ClassicExtended` | Title bar layout style | +| `ExtendsContentIntoTitleBar` | `bool` | Lets content render behind the title bar | +| `Subtitle` | `string` | Secondary text shown next to the title | +| `DisplayIcon` | `object` | Custom icon content in the title bar | +| `DisplayTitle` | `object` | Custom title content (e.g. a `PathIcon`) | +| `EnableBlur` | `bool` | Acrylic/blur window background | +| `CaptionButtons` | enum | Which caption buttons to show | +| `LeftTitleBarContent` | `object` | Content injected left of the title | + +--- + +## Localization + +Register your `.resx` resource managers in your `Application` constructor: + +```csharp +public App() +{ + Localizer.AddRes(new ResourceManager(typeof(Properties.Localizations.App))); + Localizer.ChangeLang("en"); +} +``` + +Use `{Localize Key}` in AXAML — updates live when the language changes: + +```xml + + diff --git a/samples/PleasantUI.Example/Factories/ControlPageCardsFactory.cs b/samples/PleasantUI.Example/Factories/ControlPageCardsFactory.cs index d6b3ef3b..e6fa73a6 100644 --- a/samples/PleasantUI.Example/Factories/ControlPageCardsFactory.cs +++ b/samples/PleasantUI.Example/Factories/ControlPageCardsFactory.cs @@ -20,27 +20,16 @@ public AvaloniaList CreateBasicControlPageCards() { return [ - new("Button", MaterialIcons.ButtonCursor, "A clickable element that triggers an action when pressed.", - new ButtonPage(), _eventAggregator), - new("Checkbox", MaterialIcons.CheckboxMarkedOutline, - "A small interactive box that allows users to select one or more options from a set.", - new CheckBoxPage(), _eventAggregator), - new("Progress", MaterialIcons.ProgressHelper, - "A circular animation that indicates an ongoing operation or loading process.", new ProgressPage(), - _eventAggregator), - new("Calendar", MaterialIcons.CalendarOutline, - "A control that displays dates in a structured format, allowing users to select a specific date or date range.", - new CalendarPage(), _eventAggregator), - new("Carousel", MaterialIcons.ViewCarouselOutline, - "A container that displays a set of items (images, text, etc.) one at a time, allowing users to navigate through them sequentially.", - new CarouselPage(), _eventAggregator), - new("ComboBox", MaterialIcons.ExpandAllOutline, - "A dropdown list that allows users to select a single option from a predefined list. It combines a text box with a dropdown menu.", - new ComboBoxPage(), _eventAggregator), - new("TextBox", MaterialIcons.FormTextbox, "A rectangular area where users can enter and edit text.", - new TextBoxPage(), _eventAggregator), - new("DataGrid", MaterialIcons.Grid, "A grid that displays data in a tabular format.", new DataGridPage(), - _eventAggregator) + new("CardTitle/Button", MaterialIcons.ButtonCursor, "Card/Button", new ButtonPage(), _eventAggregator), + new("CardTitle/Checkbox", MaterialIcons.CheckboxMarkedOutline,"Card/Checkbox", new CheckBoxPage(), _eventAggregator), + new("CardTitle/Progress", MaterialIcons.ProgressHelper, "Card/Progress", new ProgressPage(), _eventAggregator), + new("CardTitle/Calendar", MaterialIcons.CalendarOutline, "Card/Calendar", new CalendarPage(), _eventAggregator), + new("CardTitle/Carousel", MaterialIcons.ViewCarouselOutline, "Card/Carousel", new CarouselPage(), _eventAggregator), + new("CardTitle/ComboBox", MaterialIcons.ExpandAllOutline, "Card/ComboBox", new ComboBoxPage(), _eventAggregator), + new("CardTitle/TextBox", MaterialIcons.FormTextbox, "Card/TextBox", new TextBoxPage(), _eventAggregator), + new("CardTitle/DataGrid", MaterialIcons.Grid, "Card/DataGrid", new DataGridPage(), _eventAggregator), + new("CardTitle/PinCode", MaterialIcons.KeyboardOutline, "Card/PinCode", new PinCodePage(), _eventAggregator), + new("CardTitle/SelectionList", MaterialIcons.ViewListOutline, "Card/SelectionList", new SelectionListPage(), _eventAggregator), ]; } @@ -48,24 +37,25 @@ public AvaloniaList CreatePleasantControlPageCards() { return [ - new("PleasantSnackbar", MaterialIcons.InformationOutline, - "Custom control for displaying temporary, non-intrusive messages to the user.", - new PleasantSnackbarPage(), _eventAggregator), - new("InformationBlock", MaterialIcons.InformationBoxOutline, - "Custom control for displaying a structured block of information, potentially including a title, icon, and descriptive text.", - new InformationBlockPage(), _eventAggregator), - new("OptionsDisplayItem", MaterialIcons.ViewListOutline, - "Custom control representing a single item in a list of options.", new OptionsDisplayItemPage(), - _eventAggregator), - new("PleasantTabView", MaterialIcons.Tab, "Custom control implementing a tabbed interface.", - new PleasantTabViewPage(), _eventAggregator) + new("CardTitle/PleasantSnackbar", MaterialIcons.InformationOutline, "Card/PleasantSnackbar", new PleasantSnackbarPage(), _eventAggregator), + new("CardTitle/InformationBlock", MaterialIcons.InformationBoxOutline, "Card/InformationBlock", new InformationBlockPage(), _eventAggregator), + new("CardTitle/OptionsDisplayItem", MaterialIcons.ViewListOutline, "Card/OptionsDisplayItem", new OptionsDisplayItemPage(), _eventAggregator), + new("CardTitle/PleasantTabView", MaterialIcons.Tab, "Card/PleasantTabView", new PleasantTabViewPage(), _eventAggregator), + new("CardTitle/PleasantMenu", MaterialIcons.MenuOpen, "Card/PleasantMenu", new PleasantMenuPage(), _eventAggregator), + new("CardTitle/Timeline", MaterialIcons.TimelineOutline, "Card/Timeline", new TimelinePage(), _eventAggregator), + new("CardTitle/InstallWizard", MaterialIcons.WizardHat, "Card/InstallWizard", new InstallWizardPage(), _eventAggregator), + new("CardTitle/PleasantDrawer", MaterialIcons.DrawingBox, "Card/PleasantDrawer", new PleasantDrawerPage(), _eventAggregator), + new("CardTitle/PopConfirm", MaterialIcons.CheckboxMarkedCircle, "Card/PopConfirm", new PopConfirmPage(), _eventAggregator), + new("CardTitle/PathPicker", MaterialIcons.FolderOpenOutline, "Card/PathPicker", new PathPickerPage(), _eventAggregator), ]; } public AvaloniaList CreateToolkitControlPageCards() { - return [ - new("MessageBox", MaterialIcons.MessageOutline, "", new MessageBoxPage(), _eventAggregator) + return + [ + new("CardTitle/MessageBox", MaterialIcons.MessageOutline, "Card/MessageBox", new MessageBoxPage(), _eventAggregator), + new("CardTitle/Docking", MaterialIcons.ViewDashboardOutline, "Card/Docking", new DockingPage(), _eventAggregator), ]; } } \ No newline at end of file diff --git a/samples/PleasantUI.Example/Interfaces/IPage.cs b/samples/PleasantUI.Example/Interfaces/IPage.cs index 21a93b09..8d9c8fdd 100644 --- a/samples/PleasantUI.Example/Interfaces/IPage.cs +++ b/samples/PleasantUI.Example/Interfaces/IPage.cs @@ -1,12 +1,17 @@ -using Avalonia.Controls; +using System.ComponentModel; +using Avalonia.Controls; namespace PleasantUI.Example.Interfaces; -public interface IPage +public interface IPage : INotifyPropertyChanged { + /// The localization key used to resolve . + string TitleKey { get; } + + /// Resolved, localized title — updates when the language changes. string Title { get; } - + bool ShowTitle { get; } - + Control Content { get; } -} \ No newline at end of file +} diff --git a/samples/PleasantUI.Example/Logging/Logging/SerilogSink.cs b/samples/PleasantUI.Example/Logging/Logging/SerilogSink.cs index 8de5736e..3174a06e 100644 --- a/samples/PleasantUI.Example/Logging/Logging/SerilogSink.cs +++ b/samples/PleasantUI.Example/Logging/Logging/SerilogSink.cs @@ -1,7 +1,6 @@ using System.Text; using Avalonia; using Avalonia.Logging; -using Avalonia.Utilities; namespace PleasantUI.Example.Logging.Logging; @@ -113,36 +112,33 @@ private static string Format( object?[]? values) { StringBuilder result = new(); - CharacterReader r = new(template.AsSpan()); - int i = 0; - result.Append('['); result.Append(area); result.Append("] "); - while (!r.End) + int i = 0; + int pos = 0; + while (pos < template.Length) { - char c = r.Take(); - + char c = template[pos++]; if (c != '{') { result.Append(c); } + else if (pos < template.Length && template[pos] == '{') + { + result.Append('{'); + pos++; + } else { - if (r.Peek != '{') - { - result.Append('\''); - result.Append(values?[i++]); - result.Append('\''); - r.TakeUntil('}'); - r.Take(); - } - else - { - result.Append('{'); - r.Take(); - } + result.Append('\''); + result.Append(values?[i++]); + result.Append('\''); + while (pos < template.Length && template[pos] != '}') + pos++; + if (pos < template.Length) + pos++; // skip '}' } } diff --git a/samples/PleasantUI.Example/MainView.axaml b/samples/PleasantUI.Example/MainView.axaml index 2be7da32..82505466 100644 --- a/samples/PleasantUI.Example/MainView.axaml +++ b/samples/PleasantUI.Example/MainView.axaml @@ -6,36 +6,72 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="PleasantUI.Example.MainView"> - - - + + + + - - + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + - + diff --git a/samples/PleasantUI.Example/MainView.axaml.cs b/samples/PleasantUI.Example/MainView.axaml.cs index dc1f2e12..8e9e0ef1 100644 --- a/samples/PleasantUI.Example/MainView.axaml.cs +++ b/samples/PleasantUI.Example/MainView.axaml.cs @@ -1,8 +1,97 @@ using Avalonia.Controls; +using PleasantUI.Controls; +using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Pages.BasicControls; +using PleasantUI.Example.Pages.PleasantControls; +using PleasantUI.Example.Pages.Toolkit; +using PleasantUI.Example.ViewModels; namespace PleasantUI.Example; public partial class MainView : UserControl { - public MainView() => InitializeComponent(); -} \ No newline at end of file + // Tracks the last leaf NavigationViewItem that was selected so we can + // explicitly deselect it when the user switches to About/Settings. + private NavigationViewItem? _lastLeafItem; + + public MainView() + { + InitializeComponent(); + MainNavigationView.SelectionChanged += OnNavigationSelectionChanged; + } + + private void OnNavigationSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (DataContext is not AppViewModel vm) return; + if (e.AddedItems.Count == 0) return; + + var selected = e.AddedItems[0] as NavigationViewItem; + if (selected is null) return; + + // Switching to a top-level item (About, Settings, or Home directly) — + // clear any previously selected leaf so it doesn't stay highlighted. + if (selected.Tag is null) + { + ClearLastLeaf(); + + if (ReferenceEquals(selected, HomeNavItem)) + vm.BackToHomePage(); + + return; + } + + // Leaf item — navigate to the corresponding page + var page = selected.Tag as string switch + { + // Basic controls + "Button" => (IPage)new ButtonPage(), + "Checkbox" => new CheckBoxPage(), + "Progress" => new ProgressPage(), + "Calendar" => new CalendarPage(), + "Carousel" => new Pages.BasicControls.CarouselPage(), + "ComboBox" => new ComboBoxPage(), + "TextBox" => new TextBoxPage(), + "DataGrid" => new DataGridPage(), + "PinCode" => new PinCodePage(), + "SelectionList" => new SelectionListPage(), + // Pleasant controls + "PleasantSnackbar" => new PleasantSnackbarPage(), + "InformationBlock" => new InformationBlockPage(), + "OptionsDisplayItem" => new OptionsDisplayItemPage(), + "PleasantTabView" => new PleasantTabViewPage(), + "PleasantMenu" => new PleasantMenuPage(), + "Timeline" => new TimelinePage(), + "InstallWizard" => new InstallWizardPage(), + "PleasantDrawer" => new PleasantDrawerPage(), + "PopConfirm" => new PopConfirmPage(), + "PathPicker" => new PathPickerPage(), + // ToolKit + "MessageBox" => new MessageBoxPage(), + "Docking" => new DockingPage(), + _ => null + }; + + if (page is null) return; + + // Deselect the previous leaf before tracking the new one + ClearLastLeaf(); + _lastLeafItem = selected; + + vm.ChangePage(page); + + // Redirect SelectedItem back to HomeNavItem so its Content (HomeView) + // stays visible, without going through SelectionChanged again. + MainNavigationView.SelectionChanged -= OnNavigationSelectionChanged; + MainNavigationView.SelectedItem = HomeNavItem; + MainNavigationView.SelectionChanged += OnNavigationSelectionChanged; + } + + private void ClearLastLeaf() + { + if (_lastLeafItem is not null) + { + _lastLeafItem.IsSelected = false; + _lastLeafItem = null; + } + } +} diff --git a/samples/PleasantUI.Example/Models/ControlPageCard.cs b/samples/PleasantUI.Example/Models/ControlPageCard.cs index a8ae5106..11f01e3f 100644 --- a/samples/PleasantUI.Example/Models/ControlPageCard.cs +++ b/samples/PleasantUI.Example/Models/ControlPageCard.cs @@ -1,34 +1,83 @@ -using Avalonia.Media; +using System.ComponentModel; +using System.Diagnostics; +using Avalonia.Media; +using PleasantUI.Core.Localization; using PleasantUI.Example.Interfaces; using PleasantUI.Example.Messages; using PleasantUI.ToolKit.Services.Interfaces; namespace PleasantUI.Example.Models; -public class ControlPageCard +public class ControlPageCard : INotifyPropertyChanged { - private readonly IEventAggregator _eventAggregator; - - public string Title { get; set; } - - public Geometry Icon { get; set; } - - public string Description { get; set; } - - public IPage Page { get; set; } - - public ControlPageCard(string title, Geometry icon, string description, IPage page, IEventAggregator eventAggregator) - { - _eventAggregator = eventAggregator; - - Title = title; - Icon = icon; - Description = description; - Page = page; - } - - public void OpenPage() - { - _eventAggregator.PublishAsync(new ChangePageMessage(Page)); - } -} \ No newline at end of file + private readonly IEventAggregator _eventAggregator; + private string _title; + private string _description; + + public event PropertyChangedEventHandler? PropertyChanged; + + public string TitleKey { get; } + public string DescriptionKey { get; } + public Geometry Icon { get; set; } + public IPage Page { get; set; } + + public string Title + { + get => _title; + private set + { + if (_title == value) return; + _title = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Title))); + } + } + + public string Description + { + get => _description; + private set + { + if (_description == value) return; + _description = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Description))); + } + } + + public ControlPageCard(string titleKey, Geometry icon, string descriptionKey, IPage page, IEventAggregator eventAggregator) + { + _eventAggregator = eventAggregator; + TitleKey = titleKey; + DescriptionKey = descriptionKey; + Icon = icon; + Page = page; + + // Read translated values immediately — satellite DLL must already be loaded by now + _title = Resolve(titleKey); + _description = Resolve(descriptionKey); + + Debug.WriteLine($"[ControlPageCard] Created key={titleKey} title=\"{_title}\""); + + Localizer.Instance.LocalizationChanged += OnLanguageChanged; + } + + private void OnLanguageChanged(string lang) + { + void Update() + { + Title = Resolve(TitleKey); + Description = Resolve(DescriptionKey); + Debug.WriteLine($"[ControlPageCard] Updated key={TitleKey} title=\"{_title}\" lang={lang}"); + } + + if (Avalonia.Threading.Dispatcher.UIThread.CheckAccess()) + Update(); + else + Avalonia.Threading.Dispatcher.UIThread.Post(Update); + } + + private static string Resolve(string key) => + Localizer.Instance.TryGetString(key, out string value) ? value : key; + + public void OpenPage() => + _eventAggregator.PublishAsync(new ChangePageMessage(Page)); +} diff --git a/samples/PleasantUI.Example/Models/DataModel.cs b/samples/PleasantUI.Example/Models/DataModel.cs index 8734ac76..01a39166 100644 --- a/samples/PleasantUI.Example/Models/DataModel.cs +++ b/samples/PleasantUI.Example/Models/DataModel.cs @@ -3,15 +3,19 @@ public class DataModel { public string Name { get; set; } - + public string Department { get; set; } public int Age { get; set; } - + public double Salary { get; set; } public bool IsNew { get; set; } + public string Status { get; set; } - public DataModel(string name, int age, bool isNew) + public DataModel(string name, string department, int age, double salary, bool isNew, string status) { Name = name; + Department = department; Age = age; + Salary = salary; IsNew = isNew; + Status = status; } -} \ No newline at end of file +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/ButtonPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/ButtonPage.cs index 72a6a888..6b9f99c2 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/ButtonPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/ButtonPage.cs @@ -1,17 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class ButtonPage : IPage +public class ButtonPage : LocalizedPage { - public string Title { get; } = "Button"; - - public bool ShowTitle { get; } = true; - - public Control Content - { - get { return new ButtonPageView(); } - } -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/Button"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new ButtonPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/CalendarPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/CalendarPage.cs index a565c45a..1682756f 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/CalendarPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/CalendarPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class CalendarPage : IPage +public class CalendarPage : LocalizedPage { - public string Title { get; } = "Calendar"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/Calendar"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new CalendarPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/CarouselPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/CarouselPage.cs index 35ae7c92..faeefba0 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/CarouselPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/CarouselPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class CarouselPage : IPage +public class CarouselPage : LocalizedPage { - public string Title { get; } = "Carousel"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/Carousel"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new CarouselPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/CheckBoxPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/CheckBoxPage.cs index 27cd7faf..f3d16aaf 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/CheckBoxPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/CheckBoxPage.cs @@ -1,17 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class CheckBoxPage : IPage +public class CheckBoxPage : LocalizedPage { - public string Title { get; } = "CheckBox"; - - public bool ShowTitle { get; } = true; - - public Control Content - { - get { return new CheckBoxPageView(); } - } -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/Checkbox"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new CheckBoxPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/ComboBoxPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/ComboBoxPage.cs index 49db42e0..80979aa6 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/ComboBoxPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/ComboBoxPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class ComboBoxPage : IPage +public class ComboBoxPage : LocalizedPage { - public string Title { get; } = "ComboBox"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/ComboBox"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new ComboBoxPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/DataGridPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/DataGridPage.cs index 95e6736b..be0cdbed 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/DataGridPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/DataGridPage.cs @@ -1,13 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class DataGridPage : IPage +public class DataGridPage : LocalizedPage { - public string Title { get; } = "DataGrid"; - public bool ShowTitle { get; } = true; - - public Control Content => new DataGridPageView(); -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/DataGrid"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new DataGridPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/HomePage.cs b/samples/PleasantUI.Example/Pages/BasicControls/HomePage.cs index f0fa4db4..c4140517 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/HomePage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/HomePage.cs @@ -1,17 +1,15 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; using PleasantUI.Example.Views.Pages; namespace PleasantUI.Example.Pages.BasicControls; -public class HomePage : IPage +public class HomePage : LocalizedPage { - public string Title { get; } = "Home"; - - public bool ShowTitle { get; } = false; - - public Control Content - { - get { return new HomePageView(); } - } -} \ No newline at end of file + public override string TitleKey { get; } = "Home"; + public override bool ShowTitle { get; } = false; + + // Lazily created once per HomePage instance — a new HomePage means a new HomePageView + // with fresh LocalizeKeyObservable bindings, which is exactly what we want on language change. + private HomePageView? _view; + protected override Control CreateContent() => _view ??= new HomePageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/PinCodePage.cs b/samples/PleasantUI.Example/Pages/BasicControls/PinCodePage.cs new file mode 100644 index 00000000..5cf5ca70 --- /dev/null +++ b/samples/PleasantUI.Example/Pages/BasicControls/PinCodePage.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.ControlPages; + +namespace PleasantUI.Example.Pages.BasicControls; + +public class PinCodePage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/PinCode"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new PinCodePageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/ProgressBarPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/ProgressBarPage.cs index 1f068436..6adabe6a 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/ProgressBarPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/ProgressBarPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class ProgressBarPage : IPage +public class ProgressBarPage : LocalizedPage { - public string Title { get; } = "ProgressBar"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/Progress"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new ProgressBarPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/SelectionListPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/SelectionListPage.cs new file mode 100644 index 00000000..961ee60e --- /dev/null +++ b/samples/PleasantUI.Example/Pages/BasicControls/SelectionListPage.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.ControlPages; + +namespace PleasantUI.Example.Pages.BasicControls; + +public class SelectionListPage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/SelectionList"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new SelectionListPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/SliderPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/SliderPage.cs index 9bc6f1bd..921363f0 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/SliderPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/SliderPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class SliderPage : IPage +public class SliderPage : LocalizedPage { - public string Title { get; } = "Slider"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/Slider"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new SliderPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/BasicControls/TextBoxPage.cs b/samples/PleasantUI.Example/Pages/BasicControls/TextBoxPage.cs index 6a4e8980..524bd8c0 100644 --- a/samples/PleasantUI.Example/Pages/BasicControls/TextBoxPage.cs +++ b/samples/PleasantUI.Example/Pages/BasicControls/TextBoxPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.BasicControls; -public class TextBoxPage : IPage +public class TextBoxPage : LocalizedPage { - public string Title { get; } = "TextBox"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/TextBox"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new TextBoxPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/LocalizedPage.cs b/samples/PleasantUI.Example/Pages/LocalizedPage.cs new file mode 100644 index 00000000..d5f00bb7 --- /dev/null +++ b/samples/PleasantUI.Example/Pages/LocalizedPage.cs @@ -0,0 +1,66 @@ +using System.ComponentModel; +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Threading; +using PleasantUI.Core.Localization; +using PleasantUI.Example.Interfaces; + +namespace PleasantUI.Example.Pages; + +/// +/// Base class for all pages. Manages a cached view that is +/// invalidated on every language change so the next navigation always gets a fresh +/// view with correct localized strings. +/// +public abstract class LocalizedPage : IPage +{ + private Control? _cachedContent; + + public event PropertyChangedEventHandler? PropertyChanged; + + public abstract string TitleKey { get; } + + public string Title => + Localizer.Instance.TryGetString(TitleKey, out string value) ? value : TitleKey; + + public abstract bool ShowTitle { get; } + + /// + /// Returns the cached view, creating it fresh if the cache is empty. + /// The cache is cleared on every language change so the next access + /// creates a new view that reads the current language from scratch. + /// + public Control Content => _cachedContent ??= CreateContent(); + + /// + /// Override to create the view for this page. + /// + protected abstract Control CreateContent(); + + protected LocalizedPage() + { + Localizer.Instance.LocalizationChanged += OnLocalizationChanged; + } + + private void OnLocalizationChanged(string lang) + { + void Notify() + { + Debug.WriteLine($"[LocalizedPage] Invalidating content cache for key={TitleKey} lang={lang}"); + + // Drop the cached view — next time Content is accessed (on navigation) + // a fresh view is created that reads the new language immediately. + _cachedContent = null; + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Title))); + // Also notify Content so if the page is currently visible the DataTemplate + // re-reads Content and gets the fresh view. + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Content))); + } + + if (Dispatcher.UIThread.CheckAccess()) + Notify(); + else + Dispatcher.UIThread.Post(Notify); + } +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/InformationBlockPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/InformationBlockPage.cs index 5d6334ef..bdc6a7b9 100644 --- a/samples/PleasantUI.Example/Pages/PleasantControls/InformationBlockPage.cs +++ b/samples/PleasantUI.Example/Pages/PleasantControls/InformationBlockPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.PleasantControlPages; namespace PleasantUI.Example.Pages.PleasantControls; -public class InformationBlockPage : IPage +public class InformationBlockPage : LocalizedPage { - public string Title { get; } = "InformationBlock"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/InformationBlock"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new InformationBlockPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/InstallWizardPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/InstallWizardPage.cs new file mode 100644 index 00000000..f915566f --- /dev/null +++ b/samples/PleasantUI.Example/Pages/PleasantControls/InstallWizardPage.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using PleasantUI.Example.Pages; +using PleasantUI.Example.Views.Pages.PleasantControlPages; + +namespace PleasantUI.Example.Pages.PleasantControls; + +public class InstallWizardPage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/InstallWizard"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new InstallWizardPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/OptionsDisplayItemPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/OptionsDisplayItemPage.cs index 83f294e5..929794f6 100644 --- a/samples/PleasantUI.Example/Pages/PleasantControls/OptionsDisplayItemPage.cs +++ b/samples/PleasantUI.Example/Pages/PleasantControls/OptionsDisplayItemPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.PleasantControlPages; namespace PleasantUI.Example.Pages.PleasantControls; -public class OptionsDisplayItemPage : IPage +public class OptionsDisplayItemPage : LocalizedPage { - public string Title { get; } = "OptionsDisplayItem"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/OptionsDisplayItem"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new OptionsDisplayItemPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/PathPickerPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/PathPickerPage.cs new file mode 100644 index 00000000..46c8229e --- /dev/null +++ b/samples/PleasantUI.Example/Pages/PleasantControls/PathPickerPage.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.PleasantControlPages; + +namespace PleasantUI.Example.Pages.PleasantControls; + +public class PathPickerPage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/PathPicker"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new PathPickerPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/PleasantDrawerPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantDrawerPage.cs new file mode 100644 index 00000000..ef9ad259 --- /dev/null +++ b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantDrawerPage.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.PleasantControlPages; + +namespace PleasantUI.Example.Pages.PleasantControls; + +public class PleasantDrawerPage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/PleasantDrawer"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new PleasantDrawerPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/PleasantMenuPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantMenuPage.cs new file mode 100644 index 00000000..5e14f829 --- /dev/null +++ b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantMenuPage.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.PleasantControlPages; + +namespace PleasantUI.Example.Pages.PleasantControls; + +public class PleasantMenuPage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/PleasantMenu"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new PleasantMenuPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/PleasantSnackbarPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantSnackbarPage.cs index ec993c11..438f3793 100644 --- a/samples/PleasantUI.Example/Pages/PleasantControls/PleasantSnackbarPage.cs +++ b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantSnackbarPage.cs @@ -1,19 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; using PleasantUI.Example.Views.Pages.PleasantControlPages; namespace PleasantUI.Example.Pages.PleasantControls; -public class PleasantSnackbarPage : IPage +public class PleasantSnackbarPage : LocalizedPage { - public string Title { get; } = "PleasantSnackbar"; - public bool ShowTitle { get; } = true; - - public Control Content - { - get - { - return new PleasantSnackbarPageView(); - } - } -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/PleasantSnackbar"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new PleasantSnackbarPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/PleasantTabViewPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantTabViewPage.cs index ae0b183c..a090eaea 100644 --- a/samples/PleasantUI.Example/Pages/PleasantControls/PleasantTabViewPage.cs +++ b/samples/PleasantUI.Example/Pages/PleasantControls/PleasantTabViewPage.cs @@ -1,11 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; +using PleasantUI.Example.Views.Pages.PleasantControlPages; namespace PleasantUI.Example.Pages.PleasantControls; -public class PleasantTabViewPage : IPage +public class PleasantTabViewPage : LocalizedPage { - public string Title { get; } = "PleasantTabView"; - public bool ShowTitle { get; } = true; - public Control Content { get; } = null!; -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/PleasantTabView"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new PleasantTabViewPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/PopConfirmPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/PopConfirmPage.cs new file mode 100644 index 00000000..cd578b10 --- /dev/null +++ b/samples/PleasantUI.Example/Pages/PleasantControls/PopConfirmPage.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.PleasantControlPages; + +namespace PleasantUI.Example.Pages.PleasantControls; + +public class PopConfirmPage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/PopConfirm"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new PopConfirmPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/ProgressPage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/ProgressPage.cs index a3c4c677..8095d823 100644 --- a/samples/PleasantUI.Example/Pages/PleasantControls/ProgressPage.cs +++ b/samples/PleasantUI.Example/Pages/PleasantControls/ProgressPage.cs @@ -1,15 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; -using PleasantUI.Example.Views.Pages.PleasantControlPages; +using PleasantUI.Example.Views.Pages.ControlPages; namespace PleasantUI.Example.Pages.PleasantControls; -public class ProgressPage : IPage +public class ProgressPage : LocalizedPage { - public string Title { get; } = "Progress"; - public bool ShowTitle { get; } = true; - public Control Content - { - get => new ProgressRingPageView(); - } -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/Progress"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new ProgressBarPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/PleasantControls/TimelinePage.cs b/samples/PleasantUI.Example/Pages/PleasantControls/TimelinePage.cs new file mode 100644 index 00000000..01c6f6c1 --- /dev/null +++ b/samples/PleasantUI.Example/Pages/PleasantControls/TimelinePage.cs @@ -0,0 +1,10 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.PleasantControlPages; + +namespace PleasantUI.Example.Pages.PleasantControls; +public class TimelinePage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/Timeline"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new TimelinePageView(); +} diff --git a/samples/PleasantUI.Example/Pages/ToolKit/DockingPage.cs b/samples/PleasantUI.Example/Pages/ToolKit/DockingPage.cs new file mode 100644 index 00000000..57d1b5f8 --- /dev/null +++ b/samples/PleasantUI.Example/Pages/ToolKit/DockingPage.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using PleasantUI.Example.Views.Pages.ToolkitPages; + +namespace PleasantUI.Example.Pages.Toolkit; + +public class DockingPage : LocalizedPage +{ + public override string TitleKey { get; } = "CardTitle/Docking"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new DockingPageView(); +} diff --git a/samples/PleasantUI.Example/Pages/ToolKit/MessageBoxPage.cs b/samples/PleasantUI.Example/Pages/ToolKit/MessageBoxPage.cs index aabd44b3..66b034e3 100644 --- a/samples/PleasantUI.Example/Pages/ToolKit/MessageBoxPage.cs +++ b/samples/PleasantUI.Example/Pages/ToolKit/MessageBoxPage.cs @@ -1,13 +1,11 @@ using Avalonia.Controls; -using PleasantUI.Example.Interfaces; using PleasantUI.Example.Views.Pages.ToolkitPages; namespace PleasantUI.Example.Pages.Toolkit; -public class MessageBoxPage : IPage +public class MessageBoxPage : LocalizedPage { - public string Title { get; } = "MessageBox"; - public bool ShowTitle { get; } = true; - - public Control Content => new MessageBoxPageView(); -} \ No newline at end of file + public override string TitleKey { get; } = "CardTitle/MessageBox"; + public override bool ShowTitle { get; } = true; + protected override Control CreateContent() => new MessageBoxPageView(); +} diff --git a/samples/PleasantUI.Example/PleasantUI.Example.csproj b/samples/PleasantUI.Example/PleasantUI.Example.csproj index 9ae3249a..e86aa45d 100644 --- a/samples/PleasantUI.Example/PleasantUI.Example.csproj +++ b/samples/PleasantUI.Example/PleasantUI.Example.csproj @@ -44,6 +44,10 @@ ProgressRingPageView.axaml Code + + ProgressBarPageView.axaml + Code + True True diff --git a/samples/PleasantUI.Example/PleasantUIExampleApp.cs b/samples/PleasantUI.Example/PleasantUIExampleApp.cs index 8cf8d96d..127d1338 100644 --- a/samples/PleasantUI.Example/PleasantUIExampleApp.cs +++ b/samples/PleasantUI.Example/PleasantUIExampleApp.cs @@ -1,6 +1,7 @@ -using System.Resources; +using System.Resources; using Avalonia; using Avalonia.Controls; +using PleasantUI.Core; using PleasantUI.Core.Interfaces; using PleasantUI.Core.Localization; using PleasantUI.Example.Structures; @@ -15,25 +16,48 @@ public class PleasantUiExampleApp : Application public static IPleasantWindow Main { get; protected set; } = null!; - public static AppViewModel ViewModel { get; } = null!; + public static AppViewModel ViewModel { get; private set; } = null!; public static TopLevel? TopLevel { get; protected set; } - static PleasantUiExampleApp() - { - if (!Design.IsDesignMode) - ViewModel = new AppViewModel(new EventAggregator()); - } - public PleasantUiExampleApp() { - if (!Design.IsDesignMode) - DataContext = ViewModel; + // Soft restarts can keep static singletons alive. Start from a clean localization state + // so we can't end up with mixed old/new subscribers and stale resources. + Localizer.Reset(); Localizer.AddRes(new ResourceManager(typeof(Properties.Localizations.App))); Localizer.AddRes(new ResourceManager(typeof(Properties.Localizations.Library))); - Localizer.ChangeLang("en"); + // NOTE: PleasantSettings.Current is NOT yet set here — PleasantTheme (which loads + // settings from disk) is initialized later during Initialize() → AvaloniaXamlLoader.Load(). + // Language loading from persisted settings is deferred to InitializeFromSettings(), + // which is called from OnFrameworkInitializationCompleted() after PleasantTheme is ready. + Localizer.ChangeLang(LanguageKey); + } + + /// + /// Called from OnFrameworkInitializationCompleted() after PleasantTheme has been + /// initialized and PleasantSettings.Current has been loaded from disk. + /// Applies the persisted language and constructs the AppViewModel. + /// + protected void InitializeFromSettings() + { + if (Design.IsDesignMode) return; + + // Now PleasantSettings.Current is available — apply persisted language. + if (PleasantSettings.Current is not null && !string.IsNullOrEmpty(PleasantSettings.Current.Language)) + { + LanguageKey = PleasantSettings.Current.Language; + } + + // Re-apply language now that we know the persisted value. + // This fires LocalizationChanged so all already-subscribed bindings update. + Localizer.ChangeLang(LanguageKey); + + // Construct VM after localization is fully initialized with the correct language. + ViewModel = new AppViewModel(new EventAggregator()); + DataContext = ViewModel; } public static string LanguageKey { get; set; } = "en"; diff --git a/samples/PleasantUI.Example/Properties/Localizations/App.resx b/samples/PleasantUI.Example/Properties/Localizations/App.resx index 8c0dd78d..85ef8eea 100644 --- a/samples/PleasantUI.Example/Properties/Localizations/App.resx +++ b/samples/PleasantUI.Example/Properties/Localizations/App.resx @@ -1,4 +1,4 @@ - + @@ -66,4 +66,467 @@ Language + + Home + About + + Basic controls + Pleasant controls + ToolKit + + About + Links + Description + Graphical user interface library for Avalonia with its own controls + + Custom themes + + Inline Calendar + Date Picker + Pick a date... + + Carousel + ← Previous + Next → + + ComboBox + Select an option... + Disabled ComboBox + AutoCompleteBox + Start typing... + Option 1 + Option 2 + Option 3 + Option 4 + + Horizontal Slider + With Tick Marks + Disabled + Vertical Slider + + TextBox + Enter text... + Password + Enter password... + Multiline + Multiline (flat corners) + Enter multiple lines... + Read-only + This is read-only text + Disabled input + NumericUpDown + + Live demo + Start + Reset + Running… + Done + Indeterminate + Used when duration is unknown — both bar and ring variants. + Determinate states + ProgressRing sizes + Value + Is indeterminate + + Basic + Simple item + No icon, no action + With icon + Shows an icon on the left + Navigates + Tap to navigate somewhere + With action controls + Toggle setting + Enable or disable a feature + Choose theme + Expandable + Advanced options + Click to expand + Hidden content revealed on expand. + Enable feature A + Enable feature B + + Overview + Overview Tab + PleasantTabView supports adding and closing tabs, and scrolling the tab strip when there are many tabs. + Details + Details Tab + Some detailed content lives here. + Do something + Settings Tab + Enable notifications + Dark mode + Disabled + + Show Snackbar + Severity variants + Information + Neutral informational message. + Success + Confirms a completed operation. + Warning + Alerts the user to a potential issue. + Error + Notifies the user of a failure. + Content variants + Long message (multiline) + Message wraps across multiple lines when it exceeds the max pill width. + Your export has finished. The file has been saved to your Downloads folder and is ready to open. You can also share it directly from there. + With title + Bold title above the message — pill expands vertically. + With action button + Text button that triggers an action and closes the snackbar. + With custom action control + Any Avalonia control placed in the action slot. + Closable + Shows a × button so the user can dismiss manually. Duration is 10 s. + Events + Closing / Closed events + Fires Closing (cancellable) and Closed with the close reason. + Last event: + This is an informational message. + Operation completed successfully. + Disk space is running low. + Failed to save the file. + Saved + Your changes have been saved to the cloud. + Item moved to trash. + Undo + Action undone. + New update available. + View + Opening details… + Dismissable + This snackbar has a close button. + Tap or wait to dismiss — events are tracked. + + Show MessageBox + MessageBox + This is a sample message box dialog. + Show + Last result: + Default (OK) + Single OK button, the most common variant. + OK / Cancel + Asks the user to confirm or cancel an action. + Yes / No + Asks a yes-or-no question. + Yes / No / Cancel + Three-way choice — save, discard, or cancel. + With additional text + Shows an error with a detailed stack trace in the secondary text area. + Destructive action + Delete confirmation with a danger-styled button and Cancel as the safe default. + Information + This is a default message box with a single OK button. + Confirm + Do you want to proceed with this action? + Question + Would you like to save your changes before closing? + Save changes to the document before closing? + Error + An unexpected error occurred while processing your request. + System.InvalidOperationException: Object reference not set to an instance of an object.\n at SomeMethod() in File.cs:line 42 + Delete + This action is irreversible. All selected items will be permanently deleted. + OK + Cancel + Yes + No + Delete + Custom content + Embeds a radio button group inside the dialog to capture user choice alongside the button result. + Data conflict + A file with this name already exists. How would you like to proceed? + Choose how to handle the conflict: + Keep existing data + Replace with new data + Merge both + + PleasantDialog + Rich dialog + Icon, subheader, radio buttons, checkbox, and an expandable footer. + With progress bar + Animated progress bar updated from code while the dialog is open. + Danger with command links + Red header with large command-link buttons, each with a description. + + Save + Cancel + More details + Remember my choice + Sync settings + This affects all connected accounts. + Choose how your settings should be synchronized across devices. + Sync automatically + Ask before syncing + Never sync + Changes take effect after restarting the application. + Processing + 0% + Please wait while the operation completes… + Permanently delete account + This will remove all your data, settings, and history. This cannot be undone. + Delete everything + Removes all files, preferences, and account data permanently. + Export data first + Download a copy of your data before deletion. + + A clickable element that triggers an action when pressed. + A small interactive box that allows users to select one or more options from a set. + A circular animation that indicates an ongoing operation or loading process. + A control that displays dates in a structured format, allowing users to select a specific date or date range. + A container that displays a set of items one at a time, allowing users to navigate through them sequentially. + A dropdown list that allows users to select a single option from a predefined list. + A rectangular area where users can enter and edit text. + A grid that displays data in a tabular format. + A PIN / OTP entry control with individual character cells. + + Last completed value: + Default (letters + digits) + Accepts any letter or digit. Fill all 4 cells to fire the Complete event. + Digit only + Only 0–9 are accepted. Letters are silently ignored. + Letter only + Only A–Z / a–z are accepted. + Password mask + Each filled cell shows ● instead of the actual digit. + 6-cell OTP + Typical one-time password length. Paste support — try Ctrl+V with a 6-digit string. + Clear all + + Grid lines + Reorder columns + Resize columns + Sort columns + Row details + Name + Department + Age + Salary + New + Status + Department + Salary + Status + Custom control for displaying temporary, non-intrusive messages to the user. + Custom control for displaying a structured block of information, including a title, icon, and descriptive text. + Custom control representing a single item in a list of options. + Custom control implementing a tabbed interface. + A flyout application menu with a title, icon grid, badges, and footer utility buttons. + + A customizable flyout menu with a title, optional info badges, a grid of large icon buttons, and a footer bar with small utility buttons. + Full menu (3 columns, badges, footer) + Without footer + 2-column layout + Open menu + Menu + Open + Open a file + Save + Save current file + New + Create new file + Tools + Open tools + Home + Go to home screen + Delete + Delete selected + Settings + About + Exit + Open files + Loaded modules + A dialog that displays a message and waits for user confirmation. + + Account + + Button + AppBarButton + AccentButton + DangerButton + Disabled button + ButtonSpinner + ToggleButton + RepeatButton + RadioButton 1 + RadioButton 2 + Content 1 + + Button + Checkbox + Progress + Calendar + Carousel + ComboBox + TextBox + DataGrid + PinCode + SelectionList + Vertical list with images + Multi-select list with image thumbnail, title, subtitle and timestamp per item. + Horizontal chip list + Horizontal orientation for tag or category selection. + Empty state + No items found + PleasantSnackbar + InformationBlock + OptionsDisplayItem + PleasantTabView + PleasantMenu + Slider + MessageBox + + + Timeline + A vertical sequence of events displayed along a connecting axis line, with support for timestamps, icons, and multiple layout modes. + Displays a list of events in chronological order along a vertical axis. Supports four layout modes, custom icons, and five severity types. + Display mode + Left + Right + Center + Alternate + Order tracking + Order placed + Your order has been received and confirmed. + Payment verified + Payment was processed successfully. + Preparing shipment + Your items are being packed and prepared. + Out for delivery + Estimated delivery date. + Delivered + Package delivered to your address. + Item types + Each item can carry a Type that controls the colour of the default dot when no custom icon is provided. + Default + Neutral grey dot. + Ongoing + Blue dot — operation in progress. + Success + Green dot — completed successfully. + Warning + Yellow dot — needs attention. + Error + Red dot — something went wrong. + Custom icons + Set the Icon property to any control — PathIcon, Image, or anything else. + Approved + Request was approved by the reviewer. + In review + Awaiting review from the team. + Rejected + Request was rejected. Please revise and resubmit. + + + InstallWizard + A multi-step installation wizard with a sidebar step list, progress bar, and Back / Next / Cancel navigation. + A templated control that guides users through a multi-step setup process. Combines a sidebar step tracker, a content area with smooth transitions, and a navigation footer. + Interactive demo + Use the Back and Next buttons to navigate between steps. The sidebar tracks progress and marks completed steps with a checkmark. + PleasantUI Setup + © 2025 PleasantUI. All rights reserved. + Welcome + Welcome to the PleasantUI Setup Wizard. + This wizard will guide you through the installation of PleasantUI. Click Next to continue. + I want to create a desktop shortcut + License Agreement + Please read the license agreement before continuing. + MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + I accept the terms of the license agreement + Installation Type + Choose how you want to install PleasantUI. + Standard + Installs the most common components. Recommended for most users. + Custom + Choose which components to install. For advanced users. + Installing + Please wait while files are being copied. + Copying files... + Installing PleasantUI.dll + Summary + Installation complete. + Installation was successful. + PleasantUI has been installed successfully. Click Finish to close the wizard. + Key properties + Application name shown in the sidebar header. + Icon content displayed above the app name in the sidebar. + Copyright or info text shown at the bottom of the sidebar. + Ordered list of WizardStep items. Each step has Header, Description, and arbitrary Content. + Controls visibility of the Cancel button. Default is true. + Routed events raised when the wizard finishes (last step Next) or is cancelled. + Display modes + The wizard can be embedded in a page, shown as a modal ContentDialog overlay, or opened in a standalone PleasantWindow. + Open as Modal + Open as Window + Back + Next + Finish + Cancel + + + Confirm + Cancel + + + Drawer + A panel that slides in from any edge of the screen, overlaying the main content with optional title, footer, and light-dismiss support. + A slide-in overlay panel that can appear from any edge. Supports light-dismiss, optional title, footer content, and all four positions. + Interactive demo + Click a button to open the drawer from the corresponding edge. + Open Right + Open Left + Open Top + Open Bottom + Drawer Panel + This is the drawer content area. It supports any Avalonia control and scrolls automatically when content overflows. + Close + + + PopConfirm + A lightweight confirmation popup that wraps any control and asks for confirmation before executing a command. + Wraps any trigger control and shows a small popup with a header, body, and Confirm / Cancel buttons before executing a command. + Interactive demo + Click the buttons below to see PopConfirm in different configurations. + Delete item? + This action cannot be undone. + Delete + Submit form? + Are you sure you want to submit this form? + Submit + Sign out? + You will be signed out of your account. + Sign out + + + PathPicker + A control combining a text box and a browse button to let users pick files or folders via the platform storage picker. + Combines a read-only TextBox with a browse button. Supports OpenFile, SaveFile, and OpenFolder modes, optional multi-select, file-type filters, and two-way binding on the selected path text. + Interactive demo + Open file + Open folder + Save file + Open multiple files + + + Docking + A full docking system with resizable sidebars, split views, and drag-and-drop tool panels. + Drag the sidebar buttons between the left and right bars. Resize panels by dragging the dividers. Right-click a button to move it via the context menu. + EXPLORER + PROPERTIES + Editor area + Drag sidebar buttons to rearrange panels + Name + Width + Height + Left — Upper + Left — Lower + Right — Upper + Right — Lower + \ No newline at end of file diff --git a/samples/PleasantUI.Example/Properties/Localizations/App.ru.resx b/samples/PleasantUI.Example/Properties/Localizations/App.ru.resx index 2d4c73f1..41e70391 100644 --- a/samples/PleasantUI.Example/Properties/Localizations/App.ru.resx +++ b/samples/PleasantUI.Example/Properties/Localizations/App.ru.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -15,7 +15,7 @@ Настройки - Добро пожаловать + Добро пожаловать в Тема @@ -59,4 +59,465 @@ Язык + + Главная + О программе + + Базовые элементы + Pleasant элементы + Инструменты + + О программе + Ссылки + Описание + Библиотека графического интерфейса для Avalonia с собственными элементами управления + + Пользовательские темы + + Встроенный календарь + Выбор даты + Выберите дату... + + Карусель + ← Назад + Вперёд → + + Выпадающий список + Выберите вариант... + Отключённый список + Автодополнение + Начните вводить... + Вариант 1 + Вариант 2 + Вариант 3 + Вариант 4 + + Горизонтальный слайдер + С делениями + Отключён + Вертикальный слайдер + + Текстовое поле + Введите текст... + Пароль + Введите пароль... + Многострочное + Многострочное (плоские углы) + Введите несколько строк... + Только чтение + Это текст только для чтения + Отключённый ввод + Числовое поле + + Живая демонстрация + Старт + Сброс + Выполняется… + Готово + Неопределённый + Используется, когда продолжительность неизвестна — варианты с полосой и кольцом. + Определённые состояния + Размеры ProgressRing + Значение + Неопределённый + + Базовые + Простой элемент + Без иконки и действия + С иконкой + Показывает иконку слева + Навигация + Нажмите для перехода + С элементами действий + Переключатель + Включить или отключить функцию + Выбор темы + Раскрываемый + Расширенные настройки + Нажмите для раскрытия + Скрытое содержимое отображается при раскрытии. + Включить функцию A + Включить функцию B + + Обзор + Вкладка обзора + PleasantTabView поддерживает добавление и закрытие вкладок, а также прокрутку панели вкладок. + Детали + Вкладка деталей + Здесь находится подробное содержимое. + Выполнить + Вкладка настроек + Включить уведомления + Тёмный режим + Отключена + + Показать Snackbar + Варианты серьёзности + Информация + Нейтральное информационное сообщение. + Успех + Подтверждает завершённую операцию. + Предупреждение + Предупреждает пользователя о возможной проблеме. + Ошибка + Уведомляет пользователя об ошибке. + Варианты содержимого + Длинное сообщение (многострочное) + Сообщение переносится на несколько строк при превышении максимальной ширины таблетки. + Экспорт завершён. Файл сохранён в папку Загрузки и готов к открытию. Вы также можете поделиться им прямо оттуда. + С заголовком + Жирный заголовок над сообщением — таблетка расширяется вертикально. + С кнопкой действия + Текстовая кнопка, выполняющая действие и закрывающая уведомление. + С произвольным элементом действия + Любой элемент Avalonia в слоте действия. + Закрываемый + Показывает кнопку × для ручного закрытия. Длительность — 10 с. + События + События Closing / Closed + Вызывает Closing (с возможностью отмены) и Closed с причиной закрытия. + Последнее событие: + Это информационное сообщение. + Операция выполнена успешно. + Заканчивается место на диске. + Не удалось сохранить файл. + Сохранено + Ваши изменения сохранены в облаке. + Элемент перемещён в корзину. + Отменить + Действие отменено. + Доступно новое обновление. + Открыть + Открываем подробности… + Закрываемый + У этого уведомления есть кнопка закрытия. + Нажмите или подождите — события отслеживаются. + + Показать MessageBox + Сообщение + Это пример диалогового окна с сообщением. + Показать + Последний результат: + По умолчанию (OK) + Одна кнопка OK — самый распространённый вариант. + OK / Отмена + Запрашивает подтверждение или отмену действия. + Да / Нет + Задаёт вопрос с ответом да или нет. + Да / Нет / Отмена + Тройной выбор — сохранить, не сохранять или отменить. + С дополнительным текстом + Показывает ошибку с подробным стек-трейсом в дополнительной области. + Деструктивное действие + Подтверждение удаления с кнопкой в стиле опасности и Отменой как безопасным вариантом. + Информация + Это диалог по умолчанию с одной кнопкой OK. + Подтверждение + Вы хотите продолжить это действие? + Вопрос + Сохранить изменения перед закрытием? + Сохранить изменения в документе перед закрытием? + Ошибка + При обработке запроса произошла непредвиденная ошибка. + System.InvalidOperationException: Ссылка на объект не указывает на экземпляр объекта.\n at SomeMethod() in File.cs:line 42 + Удаление + Это действие необратимо. Все выбранные элементы будут удалены навсегда. + OK + Отмена + Да + Нет + Удалить + Пользовательский контент + Встраивает группу переключателей в диалог для захвата выбора пользователя вместе с результатом кнопки. + Конфликт данных + Файл с таким именем уже существует. Как вы хотите продолжить? + Выберите способ разрешения конфликта: + Сохранить существующие данные + Заменить новыми данными + Объединить оба + + PleasantDialog + Расширенный диалог + Иконка, подзаголовок, переключатели, флажок и раскрываемый нижний блок. + С прогресс-баром + Анимированный прогресс-бар, обновляемый из кода пока диалог открыт. + Опасный с командными кнопками + Красный заголовок с крупными командными кнопками, каждая с описанием. + + Сохранить + Отмена + Подробнее + Запомнить выбор + Синхронизация настроек + Это затронет все подключённые аккаунты. + Выберите способ синхронизации настроек между устройствами. + Синхронизировать автоматически + Спрашивать перед синхронизацией + Никогда не синхронизировать + Изменения вступят в силу после перезапуска приложения. + Обработка + 0% + Пожалуйста, подождите завершения операции… + Удалить аккаунт навсегда + Это удалит все ваши данные, настройки и историю. Действие необратимо. + Удалить всё + Удаляет все файлы, настройки и данные аккаунта навсегда. + Сначала экспортировать данные + Скачать копию данных перед удалением. + + Кликабельный элемент, который выполняет действие при нажатии. + Небольшой интерактивный флажок для выбора одного или нескольких вариантов. + Круговая анимация, указывающая на выполнение операции или загрузку. + Элемент для отображения дат и выбора конкретной даты или диапазона дат. + Контейнер, отображающий элементы по одному с возможностью навигации. + Выпадающий список для выбора одного варианта из предопределённого набора. + Прямоугольная область для ввода и редактирования текста. + Сетка для отображения данных в табличном формате. + Элемент ввода PIN / OTP с отдельными ячейками для каждого символа. + + Последнее значение: + По умолчанию (буквы + цифры) + Принимает любую букву или цифру. Заполните все 4 ячейки для срабатывания события Complete. + Только цифры + Принимаются только 0–9. Буквы игнорируются. + Только буквы + Принимаются только A–Z / a–z. + Маска пароля + Каждая заполненная ячейка показывает ● вместо реального символа. + 6 ячеек (OTP) + Стандартная длина одноразового пароля. Поддержка вставки — попробуйте Ctrl+V с 6-значной строкой. + Очистить всё + + Линии сетки + Перестановка столбцов + Изменение ширины + Сортировка + Детали строки + Имя + Отдел + Возраст + Зарплата + Новый + Статус + Отдел + Зарплата + Статус + Пользовательский элемент для отображения временных ненавязчивых сообщений. + Пользовательский элемент для отображения структурированного блока информации с заголовком, иконкой и текстом. + Пользовательский элемент, представляющий один пункт в списке параметров. + Пользовательский элемент управления с вкладками. + Всплывающее меню приложения с заголовком, сеткой иконок, значками и нижней панелью. + + Настраиваемое всплывающее меню с заголовком, опциональными значками, сеткой крупных кнопок с иконками и нижней панелью утилит. + Полное меню (3 колонки, значки, нижняя панель) + Без нижней панели + Макет в 2 колонки + Открыть меню + Меню + Открыть + Открыть файл + Сохранить + Сохранить текущий файл + Создать + Создать новый файл + Инструменты + Открыть инструменты + Главная + Перейти на главный экран + Удалить + Удалить выбранное + Настройки + О программе + Выход + Открытые файлы + Загруженные модули + Диалог, отображающий сообщение и ожидающий подтверждения пользователя. + + Аккаунт + + Кнопка + Кнопка AppBar + Акцентная + Опасная + Отключённая кнопка + Кнопка-спиннер + Переключатель + Повтор + Радио 1 + Радио 2 + Содержимое 1 + + Кнопка + Флажок + Прогресс + Календарь + Карусель + Выпадающий список + Текстовое поле + Таблица + Пин-код + Список выбора + Вертикальный список с изображениями + Список с множественным выбором: миниатюра, заголовок, подзаголовок и метка времени. + Горизонтальный список тегов + Горизонтальная ориентация для выбора тегов или категорий. + Пустое состояние + Элементы не найдены + Уведомление + Информационный блок + Элемент настроек + Вкладки + Меню + Слайдер + Диалог + + + Временная шкала + Вертикальная последовательность событий вдоль оси с поддержкой временных меток, иконок и нескольких режимов отображения. + Отображает список событий в хронологическом порядке вдоль вертикальной оси. Поддерживает четыре режима расположения, пользовательские иконки и пять типов серьёзности. + Режим отображения + Слева + Справа + По центру + Чередование + Отслеживание заказа + Заказ оформлен + Ваш заказ получен и подтверждён. + Оплата подтверждена + Платёж успешно обработан. + Подготовка к отправке + Ваши товары упаковываются и готовятся к отправке. + В пути + Ожидаемая дата доставки. + Доставлено + Посылка доставлена по вашему адресу. + Типы элементов + Каждый элемент может иметь тип, определяющий цвет точки по умолчанию при отсутствии пользовательской иконки. + По умолчанию + Нейтральная серая точка. + В процессе + Синяя точка — операция выполняется. + Успех + Зелёная точка — выполнено успешно. + Предупреждение + Жёлтая точка — требует внимания. + Ошибка + Красная точка — что-то пошло не так. + Пользовательские иконки + Установите свойство Icon в любой элемент — PathIcon, Image или что-либо другое. + Одобрено + Запрос одобрен рецензентом. + На проверке + Ожидает проверки командой. + Отклонено + Запрос отклонён. Исправьте и отправьте повторно. + + + Мастер установки + Многошаговый мастер установки с боковой панелью шагов, прогресс-баром и навигацией Назад / Далее / Отмена. + Шаблонный элемент управления, который проводит пользователя через многоэтапный процесс настройки. Включает боковую панель отслеживания шагов, область содержимого с плавными переходами и нижнюю панель навигации. + Интерактивная демонстрация + Используйте кнопки Назад и Далее для перемещения между шагами. Боковая панель отслеживает прогресс и отмечает завершённые шаги галочкой. + Установка PleasantUI + © 2025 PleasantUI. Все права защищены. + Добро пожаловать + Добро пожаловать в мастер установки PleasantUI. + Этот мастер проведёт вас через установку PleasantUI. Нажмите Далее для продолжения. + Создать ярлык на рабочем столе + Лицензионное соглашение + Пожалуйста, прочитайте лицензионное соглашение перед продолжением. + Лицензия MIT + +Настоящим предоставляется бесплатное разрешение любому лицу, получившему копию данного программного обеспечения и сопутствующей документации, использовать программное обеспечение без ограничений, включая права на использование, копирование, изменение, слияние, публикацию, распространение, сублицензирование и продажу копий программного обеспечения. + Я принимаю условия лицензионного соглашения + Тип установки + Выберите способ установки PleasantUI. + Стандартная + Устанавливает наиболее распространённые компоненты. Рекомендуется для большинства пользователей. + Выборочная + Выберите компоненты для установки. Для опытных пользователей. + Установка + Пожалуйста, подождите, пока копируются файлы. + Копирование файлов... + Установка PleasantUI.dll + Итог + Установка завершена. + Установка выполнена успешно. + PleasantUI успешно установлен. Нажмите Готово, чтобы закрыть мастер. + Основные свойства + Название приложения в заголовке боковой панели. + Иконка, отображаемая над названием приложения в боковой панели. + Текст авторских прав или информации в нижней части боковой панели. + Упорядоченный список элементов WizardStep. Каждый шаг имеет Header, Description и произвольное Content. + Управляет видимостью кнопки Отмена. По умолчанию true. + Маршрутизируемые события, вызываемые при завершении мастера (кнопка Далее на последнем шаге) или отмене. + Режимы отображения + Мастер можно встроить в страницу, показать как модальный ContentDialog или открыть в отдельном окне PleasantWindow. + Открыть как модальный + Открыть в окне + Назад + Далее + Готово + Отмена + + + Подтвердить + Отмена + + + Выдвижная панель + Панель, выезжающая с любого края экрана поверх основного содержимого с поддержкой заголовка, подвала и закрытия по клику. + Выдвижная панель-оверлей, появляющаяся с любого края. Поддерживает закрытие по клику вне панели, заголовок, подвал и все четыре позиции. + Интерактивная демонстрация + Нажмите кнопку, чтобы открыть панель с соответствующей стороны. + Справа + Слева + Сверху + Снизу + Выдвижная панель + Это область содержимого панели. Поддерживает любые элементы управления Avalonia и автоматически прокручивается при переполнении. + Закрыть + + + Подтверждение + Лёгкий всплывающий запрос подтверждения, оборачивающий любой элемент управления. + Оборачивает любой триггерный элемент и показывает небольшой попап с заголовком, текстом и кнопками Подтвердить / Отмена перед выполнением команды. + Интерактивная демонстрация + Нажмите кнопки ниже, чтобы увидеть PopConfirm в разных конфигурациях. + Удалить элемент? + Это действие нельзя отменить. + Удалить + Отправить форму? + Вы уверены, что хотите отправить эту форму? + Отправить + Выйти из аккаунта? + Вы будете выведены из своего аккаунта. + Выйти + + + Выбор пути + Элемент управления, сочетающий текстовое поле и кнопку обзора для выбора файлов или папок через системный диалог. + Сочетает поле только для чтения с кнопкой обзора. Поддерживает режимы OpenFile, SaveFile и OpenFolder, множественный выбор, фильтры типов файлов и двустороннее связывание текста выбранного пути. + Интерактивная демонстрация + Открыть файл + Открыть папку + Сохранить файл + Открыть несколько файлов + + + Докинг + Полная система докинга с изменяемыми боковыми панелями, разделёнными видами и перетаскиваемыми инструментами. + Перетаскивайте кнопки боковой панели между левой и правой панелями. Изменяйте размер панелей, перетаскивая разделители. Щёлкните правой кнопкой мыши по кнопке, чтобы переместить её через контекстное меню. + ПРОВОДНИК + СВОЙСТВА + Область редактора + Перетащите кнопки боковой панели для перестановки панелей + Имя + Ширина + Высота + Лево — Верх + Лево — Низ + Право — Верх + Право — Низ + \ No newline at end of file diff --git a/samples/PleasantUI.Example/Properties/Localizations/Library.resx b/samples/PleasantUI.Example/Properties/Localizations/Library.resx index 7212cb87..836170df 100644 --- a/samples/PleasantUI.Example/Properties/Localizations/Library.resx +++ b/samples/PleasantUI.Example/Properties/Localizations/Library.resx @@ -141,4 +141,22 @@ Undo + + On + + + Off + + + Full Screen + + + Exit Full Screen + + + Full Screen Button + + + Show a full screen toggle button in the title bar + \ No newline at end of file diff --git a/samples/PleasantUI.Example/Properties/Localizations/Library.ru.resx b/samples/PleasantUI.Example/Properties/Localizations/Library.ru.resx index 2d834c35..36bc68b7 100644 --- a/samples/PleasantUI.Example/Properties/Localizations/Library.ru.resx +++ b/samples/PleasantUI.Example/Properties/Localizations/Library.ru.resx @@ -134,4 +134,22 @@ Отмена + + Вкл + + + Выкл + + + Полный экран + + + Выйти из полного экрана + + + Кнопка полного экрана + + + Показать кнопку переключения полного экрана в заголовке окна + \ No newline at end of file diff --git a/samples/PleasantUI.Example/ViewModels/AppViewModel.cs b/samples/PleasantUI.Example/ViewModels/AppViewModel.cs index 6304b89e..07ed41e1 100644 --- a/samples/PleasantUI.Example/ViewModels/AppViewModel.cs +++ b/samples/PleasantUI.Example/ViewModels/AppViewModel.cs @@ -1,5 +1,7 @@ using Avalonia.Collections; +using Avalonia.Threading; using PleasantUI.Core; +using PleasantUI.Core.Localization; using PleasantUI.Example.Factories; using PleasantUI.Example.Interfaces; using PleasantUI.Example.Messages; @@ -12,22 +14,21 @@ namespace PleasantUI.Example.ViewModels; public class AppViewModel : ViewModelBase { private readonly IEventAggregator _eventAggregator; - - /// - /// The current page - /// + private readonly ControlPageCardsFactory _factory; + private IPage _page = null!; - - /// - /// Indicates whether the animation should be forward or backward - /// private bool _isForwardAnimation = true; - public AvaloniaList BasicControlPageCards { get; } - - public AvaloniaList PleasantControlPageCards { get; } - - public AvaloniaList ToolKitPageCards { get; } + // Localized header strings — updated directly on language change so + // {CompiledBinding} in HomePageView always gets the correct value. + private string _welcomeText = string.Empty; + private string _basicControlsText = string.Empty; + private string _pleasantControlsText = string.Empty; + private string _toolKitText = string.Empty; + + public AvaloniaList BasicControlPageCards { get; } = []; + public AvaloniaList PleasantControlPageCards { get; } = []; + public AvaloniaList ToolKitPageCards { get; } = []; public IPage Page { @@ -41,16 +42,39 @@ public bool IsForwardAnimation set => SetProperty(ref _isForwardAnimation, value); } + public string WelcomeText + { + get => _welcomeText; + private set => SetProperty(ref _welcomeText, value); + } + + public string BasicControlsText + { + get => _basicControlsText; + private set => SetProperty(ref _basicControlsText, value); + } + + public string PleasantControlsText + { + get => _pleasantControlsText; + private set => SetProperty(ref _pleasantControlsText, value); + } + + public string ToolKitText + { + get => _toolKitText; + private set => SetProperty(ref _toolKitText, value); + } + public AppViewModel(IEventAggregator eventAggregator) { _eventAggregator = eventAggregator; - - ControlPageCardsFactory factory = new(eventAggregator); - - BasicControlPageCards = factory.CreateBasicControlPageCards(); - PleasantControlPageCards = factory.CreatePleasantControlPageCards(); - ToolKitPageCards = factory.CreateToolkitControlPageCards(); - + _factory = new ControlPageCardsFactory(eventAggregator); + + BasicControlPageCards.AddRange(_factory.CreateBasicControlPageCards()); + PleasantControlPageCards.AddRange(_factory.CreatePleasantControlPageCards()); + ToolKitPageCards.AddRange(_factory.CreateToolkitControlPageCards()); + Page = new HomePage(); _eventAggregator.Subscribe(async message => @@ -58,25 +82,65 @@ public AppViewModel(IEventAggregator eventAggregator) ChangePage(message.Page); await Task.CompletedTask; }); + + Localizer.Instance.LocalizationChanged += OnLanguageChanged; + + // Initialize text properties with current language + RefreshLocalizedTexts(); + } + + private void OnLanguageChanged(string _) + { + Dispatcher.UIThread.Post(Rebuild, DispatcherPriority.Background); + } + + private void Rebuild() + { + System.Diagnostics.Debug.WriteLine($"[AppViewModel] Rebuild lang={Localizer.Instance.CurrentLanguage}"); + + // Refresh header texts first — these are bound via CompiledBinding so they + // update instantly without any view recreation needed. + RefreshLocalizedTexts(); + + // Rebuild card collections with fresh instances that read the new language + var newBasic = _factory.CreateBasicControlPageCards().ToList(); + var newPleasant = _factory.CreatePleasantControlPageCards().ToList(); + var newToolkit = _factory.CreateToolkitControlPageCards().ToList(); + + BasicControlPageCards.Clear(); + PleasantControlPageCards.Clear(); + ToolKitPageCards.Clear(); + + BasicControlPageCards.AddRange(newBasic); + PleasantControlPageCards.AddRange(newPleasant); + ToolKitPageCards.AddRange(newToolkit); + + System.Diagnostics.Debug.WriteLine($"[AppViewModel] Rebuild done, welcome=\"{_welcomeText}\" first card=\"{newBasic.FirstOrDefault()?.Title}\""); + } + + private void RefreshLocalizedTexts() + { + WelcomeText = Localizer.Tr("WelcomeToPleasantUI"); + BasicControlsText = Localizer.Tr("BasicControls"); + PleasantControlsText = Localizer.Tr("PleasantControls"); + ToolKitText = Localizer.Tr("ToolKit"); } /// - /// Changes the current page + /// Public entry point so views can force a re-push of all localized text properties + /// as a failsafe when their own LocalizationChanged subscription fires. /// - /// The new page - /// The direction of the animation + public void ForceRefreshLocalizedTexts() => RefreshLocalizedTexts(); + public void ChangePage(IPage page) { IsForwardAnimation = true; Page = page; } - /// - /// Goes back to the home page - /// public void BackToHomePage() { IsForwardAnimation = false; Page = new HomePage(); } -} \ No newline at end of file +} diff --git a/samples/PleasantUI.Example/ViewModels/Pages/ControlPages/DataGridViewModel.cs b/samples/PleasantUI.Example/ViewModels/Pages/ControlPages/DataGridViewModel.cs index 7763a478..4e4dc6ca 100644 --- a/samples/PleasantUI.Example/ViewModels/Pages/ControlPages/DataGridViewModel.cs +++ b/samples/PleasantUI.Example/ViewModels/Pages/ControlPages/DataGridViewModel.cs @@ -6,17 +6,60 @@ namespace PleasantUI.Example.ViewModels.Pages.ControlPages; public class DataGridViewModel : ViewModelBase { + private bool _showGridLines; + private bool _canUserReorderColumns = true; + private bool _canUserResizeColumns = true; + private bool _canUserSortColumns = true; + private bool _showRowDetails; + public AvaloniaList DataModels { get; } + public bool ShowGridLines + { + get => _showGridLines; + set => SetProperty(ref _showGridLines, value); + } + + public bool CanUserReorderColumns + { + get => _canUserReorderColumns; + set => SetProperty(ref _canUserReorderColumns, value); + } + + public bool CanUserResizeColumns + { + get => _canUserResizeColumns; + set => SetProperty(ref _canUserResizeColumns, value); + } + + public bool CanUserSortColumns + { + get => _canUserSortColumns; + set => SetProperty(ref _canUserSortColumns, value); + } + + public bool ShowRowDetails + { + get => _showRowDetails; + set => SetProperty(ref _showRowDetails, value); + } + public DataGridViewModel() { DataModels = new AvaloniaList { - new("John", 23, true), - new("Jane", 24, false), - new("Jack", 25, true), - new("Jill", 26, false), - new("Joe", 27, true), + new("Alice Johnson", "Engineering", 31, 95000, true, "Active"), + new("Bob Smith", "Marketing", 28, 72000, false, "Active"), + new("Carol White", "Engineering", 35, 110000, false, "On Leave"), + new("David Brown", "HR", 42, 68000, false, "Active"), + new("Eva Martinez", "Engineering", 27, 88000, true, "Active"), + new("Frank Lee", "Marketing", 33, 75000, false, "Inactive"), + new("Grace Kim", "HR", 29, 65000, true, "Active"), + new("Henry Wilson", "Engineering", 38, 105000, false, "Active"), + new("Iris Chen", "Marketing", 26, 70000, true, "Active"), + new("James Taylor", "HR", 45, 72000, false, "On Leave"), + new("Karen Davis", "Engineering", 32, 98000, false, "Active"), + new("Leo Garcia", "Marketing", 30, 78000, true, "Active"), }; } -} \ No newline at end of file +} diff --git a/samples/PleasantUI.Example/ViewModels/Pages/SettingsViewModel.cs b/samples/PleasantUI.Example/ViewModels/Pages/SettingsViewModel.cs index 58eba98d..70c1effc 100644 --- a/samples/PleasantUI.Example/ViewModels/Pages/SettingsViewModel.cs +++ b/samples/PleasantUI.Example/ViewModels/Pages/SettingsViewModel.cs @@ -1,4 +1,6 @@ -using PleasantUI.Core; +using Avalonia.Controls; +using PleasantUI.Controls; +using PleasantUI.Core; using PleasantUI.Core.Localization; using PleasantUI.Core.Models; using PleasantUI.Example.Structures; @@ -6,15 +8,39 @@ namespace PleasantUI.Example.ViewModels.Pages; -public partial class SettingsViewModel +public partial class SettingsViewModel : ViewModelBase { + public SettingsViewModel() + { + // Re-raise SelectedLanguage when language changes so the ComboBox stays in sync + Localizer.Instance.LocalizationChanged += _ => RaisePropertyChanged(nameof(SelectedLanguage)); + } + + public bool IsFullScreenButtonVisible + { + get => PleasantUiExampleApp.Main is PleasantWindow w && w.IsFullScreenButtonVisible; + set + { + if (PleasantUiExampleApp.Main is PleasantWindow w) + w.IsFullScreenButtonVisible = value; + RaisePropertyChanged(); + } + } + public Language SelectedLanguage { - get => PleasantUiExampleApp.Languages.First(language => language.Key == PleasantUiExampleApp.LanguageKey); + get => PleasantUiExampleApp.Languages.FirstOrDefault(l => l.Key == PleasantUiExampleApp.LanguageKey); set { + if (value.Key == PleasantUiExampleApp.LanguageKey) return; PleasantUiExampleApp.LanguageKey = value.Key; + + // Persist language to settings + if (PleasantSettings.Current is not null) + PleasantSettings.Current.Language = value.Key; + Localizer.ChangeLang(value.Key); + RaisePropertyChanged(); } } @@ -27,7 +53,10 @@ public Theme? SelectedTheme set { if (PleasantSettings.Current is not null) + { PleasantSettings.Current.Theme = value?.Name ?? "System"; + System.Diagnostics.Debug.WriteLine($"[SettingsViewModel] Theme changed to {PleasantSettings.Current.Theme}"); + } } } diff --git a/samples/PleasantUI.Example/ViewModels/Pages/ToolKitPages/MessageBoxViewModel.cs b/samples/PleasantUI.Example/ViewModels/Pages/ToolKitPages/MessageBoxViewModel.cs new file mode 100644 index 00000000..93b42fc9 --- /dev/null +++ b/samples/PleasantUI.Example/ViewModels/Pages/ToolKitPages/MessageBoxViewModel.cs @@ -0,0 +1,313 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using PleasantUI.Core; +using PleasantUI.Core.Enums; +using PleasantUI.Core.Localization; +using PleasantUI.Core.Structures; +using PleasantUI.ToolKit; + +namespace PleasantUI.Example.ViewModels.Pages.ToolKitPages; + +public class MessageBoxViewModel : ViewModelBase +{ + private string _lastResult = "—"; + + public string LastResult + { + get => _lastResult; + set => SetProperty(ref _lastResult, value); + } + + // Resolves a key under the MessageBox/ context with a hardcoded fallback + private static string T(string key, string fallback) => + Localizer.TrDefault(key, fallback, "MessageBox"); + + // Maps an internal result token back to a localized display string + private static string LocalizeResult(string result) => result switch + { + "OK" => T("Ok", "OK"), + "Cancel" => T("Cancel", "Cancel"), + "Yes" => T("Yes", "Yes"), + "No" => T("No", "No"), + "Delete" => T("Delete", "Delete"), + _ => result + }; + + public async Task ShowDefault() + { + string result = await MessageBox.Show( + PleasantUiExampleApp.Main, + T("Title", "Information"), + T("DefaultText", "This is a default message box with a single OK button.")); + + LastResult = LocalizeResult(result); + } + + public async Task ShowOkCancel() + { + string result = await MessageBox.Show( + PleasantUiExampleApp.Main, + T("ConfirmTitle", "Confirm"), + T("OkCancelText", "Do you want to proceed with this action?"), + new[] + { + new MessageBoxButton { Text = T("Ok", "OK"), Result = "OK", Default = true, IsKeyDown = true }, + new MessageBoxButton { Text = T("Cancel", "Cancel"), Result = "Cancel" } + }); + + LastResult = LocalizeResult(result); + } + + public async Task ShowYesNo() + { + string result = await MessageBox.Show( + PleasantUiExampleApp.Main, + T("QuestionTitle", "Question"), + T("YesNoText", "Would you like to save your changes before closing?"), + new[] + { + new MessageBoxButton { Text = T("Yes", "Yes"), Result = "Yes", Default = true, IsKeyDown = true }, + new MessageBoxButton { Text = T("No", "No"), Result = "No" } + }); + + LastResult = LocalizeResult(result); + } + + public async Task ShowYesNoCancel() + { + string result = await MessageBox.Show( + PleasantUiExampleApp.Main, + T("QuestionTitle", "Question"), + T("YesNoCancelText", "Save changes to the document before closing?"), + new[] + { + new MessageBoxButton { Text = T("Yes", "Yes"), Result = "Yes", Default = true, IsKeyDown = true }, + new MessageBoxButton { Text = T("No", "No"), Result = "No" }, + new MessageBoxButton { Text = T("Cancel", "Cancel"), Result = "Cancel" } + }); + + LastResult = LocalizeResult(result); + } + + public async Task ShowWithAdditionalText() + { + string result = await MessageBox.Show( + PleasantUiExampleApp.Main, + T("ErrorTitle", "Error"), + T("ErrorText", "An unexpected error occurred while processing your request."), + new[] + { + new MessageBoxButton { Text = T("Ok", "OK"), Result = "OK", Default = true, IsKeyDown = true } + }, + T("ErrorDetail", "System.InvalidOperationException: Object reference not set to an instance of an object.\n at SomeMethod() in File.cs:line 42")); + + LastResult = LocalizeResult(result); + } + + public async Task ShowDanger() + { + string result = await MessageBox.Show( + PleasantUiExampleApp.Main, + T("DangerTitle", "Delete"), + T("DangerText", "This action is irreversible. All selected items will be permanently deleted."), + new[] + { + new MessageBoxButton { Text = T("Delete", "Delete"), Result = "Delete", Default = true }, + new MessageBoxButton { Text = T("Cancel", "Cancel"), Result = "Cancel", IsKeyDown = true } + }, + style: MessageBoxStyle.Danger); + + LastResult = LocalizeResult(result); + } + + public async Task ShowCustomContent() + { + // Build extra content: warning icon + description + radio buttons + var option1 = new RadioButton { Content = T("CustomOption1", "Keep existing data"), GroupName = "MBOptions", IsChecked = true }; + var option2 = new RadioButton { Content = T("CustomOption2", "Replace with new data"), GroupName = "MBOptions" }; + var option3 = new RadioButton { Content = T("CustomOption3", "Merge both"), GroupName = "MBOptions" }; + + var panel = new StackPanel + { + Spacing = 8, + Children = + { + new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = + { + new PathIcon + { + Data = MaterialIcons.InformationOutline, + Width = 16, + Height = 16, + }, + new TextBlock + { + Text = T("CustomHint", "Choose how to handle the conflict:"), + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + VerticalAlignment = VerticalAlignment.Center + } + } + }, + option1, + option2, + option3 + } + }; + + var result = await MessageBox.Show( + PleasantUiExampleApp.Main, + T("CustomTitle", "Data conflict"), + T("CustomText", "A file with this name already exists. How would you like to proceed?"), + extraContent: panel, + valueSelector: _ => option1.IsChecked == true ? "keep" + : option2.IsChecked == true ? "replace" + : "merge", + buttons: new[] + { + new MessageBoxButton { Text = T("Ok", "OK"), Result = "OK", Default = true, IsKeyDown = true }, + new MessageBoxButton { Text = T("Cancel", "Cancel"), Result = "Cancel" } + }); + + LastResult = result.Button == "Cancel" + ? LocalizeResult("Cancel") + : $"{LocalizeResult(result.Button)} → {result.Value}"; + } + + // ── PleasantDialog samples ──────────────────────────────────────────────── + + private static string TD(string key, string fallback) => + Localizer.TrDefault(key, fallback, "PleasantDialog"); + + public async Task ShowPleasantDialogRich() + { + var remember = new PleasantDialogCheckBox { Text = TD("RememberChoice", "Remember my choice") }; + + var result = await PleasantDialog.Show( + PleasantUiExampleApp.Main, + header: TD("RichTitle", "Sync settings"), + body: TD("RichBody", "Choose how your settings should be synchronized across devices."), + iconGeometryKey: "TuneRegular", + subHeader: TD("RichSubHeader", "This affects all connected accounts."), + commands: new PleasantDialogCommand[] + { + new PleasantDialogRadioButton { Text = TD("RichOpt1", "Sync automatically"), IsChecked = true }, + new PleasantDialogRadioButton { Text = TD("RichOpt2", "Ask before syncing") }, + new PleasantDialogRadioButton { Text = TD("RichOpt3", "Never sync") }, + remember + }, + buttons: new[] + { + new PleasantDialogButton { Text = TD("Save", "Save"), DialogResult = PleasantDialogResult.OK, IsDefault = true }, + new PleasantDialogButton { Text = TD("Cancel", "Cancel"), DialogResult = PleasantDialogResult.Cancel } + }, + footer: new TextBlock + { + Text = TD("RichFooter", "Changes take effect after restarting the application."), + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + Foreground = null // inherits theme color + }, + footerExpandable: true, + footerToggleText: TD("MoreDetails", "More details")); + + LastResult = result is PleasantDialogResult r + ? r.ToString() + : result?.ToString() ?? "—"; + } + + public async Task ShowPleasantDialogProgress() + { + PleasantDialog? dialogRef = null; + var cts = new CancellationTokenSource(); + + var result = await PleasantDialog.Show( + PleasantUiExampleApp.Main, + header: TD("ProgressTitle", "Processing"), + body: TD("ProgressBody", "Please wait while the operation completes…"), + iconGeometryKey: "ProgressHelper", + subHeader: TD("ProgressSubHeader", "0%"), + buttons: new[] + { + new PleasantDialogButton + { + Text = TD("Cancel", "Cancel"), + DialogResult = PleasantDialogResult.Cancel, + IsDefault = true + } + }, + onDialogReady: d => + { + dialogRef = d; + d.SetProgressBarState(0); + + // Run simulated work on a background thread + _ = Task.Run(async () => + { + for (int i = 0; i <= 100; i += 5) + { + if (cts.Token.IsCancellationRequested) break; + await Task.Delay(120, cts.Token).ContinueWith(_ => { }); + + int captured = i; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + d.SetProgressBarState(captured); + // Update subheader text to show percentage + var sub = d.FindControl("SubHeaderText"); + if (sub is not null) + { + sub.Text = $"{captured}%"; + sub.IsVisible = true; + } + }); + + if (captured >= 100) + { + await Task.Delay(300); + Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = d.CloseAsync()); + break; + } + } + }, cts.Token); + }); + + cts.Cancel(); + LastResult = result?.ToString() ?? "—"; + } + + public async Task ShowPleasantDialogDanger() + { + var result = await PleasantDialog.Show( + PleasantUiExampleApp.Main, + header: TD("DangerTitle", "Permanently delete account"), + body: TD("DangerBody", "This will remove all your data, settings, and history. This cannot be undone."), + iconGeometryKey: "ErrorCircleRegular", + style: MessageBoxStyle.Danger, + commands: new PleasantDialogCommand[] + { + new PleasantDialogCommandLink + { + Text = TD("DangerCmd1", "Delete everything"), + Description = TD("DangerCmd1Desc", "Removes all files, preferences, and account data permanently."), + DialogResult = PleasantDialogResult.OK, + ClosesOnInvoked = true + }, + new PleasantDialogCommandLink + { + Text = TD("DangerCmd2", "Export data first"), + Description = TD("DangerCmd2Desc", "Download a copy of your data before deletion."), + DialogResult = "export", + ClosesOnInvoked = true + } + }, + buttons: new[] + { + new PleasantDialogButton { Text = TD("Cancel", "Cancel"), DialogResult = PleasantDialogResult.Cancel, IsDefault = true } + }); + + LastResult = result?.ToString() ?? "—"; + } +} diff --git a/samples/PleasantUI.Example/Views/AboutView.axaml b/samples/PleasantUI.Example/Views/AboutView.axaml index 77ad3aaa..c15f3e4a 100644 --- a/samples/PleasantUI.Example/Views/AboutView.axaml +++ b/samples/PleasantUI.Example/Views/AboutView.axaml @@ -6,7 +6,7 @@ x:Class="PleasantUI.Example.Views.AboutView"> - @@ -43,7 +43,7 @@ - @@ -75,9 +75,8 @@ Padding="12" CornerRadius="{StaticResource ControlCornerRadius}"> - - - + + diff --git a/samples/PleasantUI.Example/Views/HomeView.axaml b/samples/PleasantUI.Example/Views/HomeView.axaml index 7b1385f1..4c5b367a 100644 --- a/samples/PleasantUI.Example/Views/HomeView.axaml +++ b/samples/PleasantUI.Example/Views/HomeView.axaml @@ -9,7 +9,8 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="PleasantUI.Example.Views.HomeView" x:DataType="viewModels:AppViewModel"> - + @@ -17,7 +18,7 @@ - + -