diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml
new file mode 100644
index 00000000..872db291
--- /dev/null
+++ b/.github/workflows/ui-review-screenshots.yml
@@ -0,0 +1,550 @@
+name: UI review screenshots
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ["fix/desktop-ui-control-spacing"]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ui-review-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: "1"
+ RUST_TOOLCHAIN: "1.97.1"
+ CARGO_TARGET_DIR: target/ui-review
+ UI_REVIEW_PR: "285"
+ UI_REVIEW_SCENARIO: "settings"
+ UI_REVIEW_TARGET: "Settings controls, segmented controls, title-bar buttons, spacing and hit targets"
+
+jobs:
+ native-ui:
+ name: ${{ matrix.name }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: Windows UI review
+ runner: windows-2022
+ platform: windows
+ - name: macOS UI review
+ runner: macos-26
+ platform: macos
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 75
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: ${{ env.RUST_TOOLCHAIN }}
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ cache-on-failure: true
+ workspaces: ". -> target/ui-review"
+
+ - name: Prepare Windows console runtime
+ if: matrix.platform == 'windows'
+ shell: pwsh
+ run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost
+
+ - name: Build complete Pebrel application
+ run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell
+
+ - name: Capture Windows application and changed UI
+ if: matrix.platform == 'windows'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $qa = Join-Path $env:RUNNER_TEMP 'ui-review'
+ $config = Join-Path $qa 'config'
+ New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null
+ @(
+ 'language=en-US'
+ 'theme=Nord'
+ 'opacity=1'
+ 'blur=off'
+ 'restore_session=false'
+ 'resume_ai=false'
+ 'auto_check_updates=off'
+ ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii
+
+ $env:PEBREL_CONFIG_DIR = $config
+ $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe'
+ $stdout = Join-Path $qa 'pebrel.stdout.log'
+ $stderr = Join-Path $qa 'pebrel.stderr.log'
+ $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+ Start-Sleep -Seconds 7
+
+ function Capture([string]$name) {
+ $shot = Join-Path $qa $name
+ for ($i = 0; $i -lt 8; $i++) {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot
+ if (Test-Path $shot) { return }
+ Start-Sleep -Seconds 1
+ }
+ throw "Could not capture Pebrel window: $name"
+ }
+
+ Capture '00-full-app.png'
+
+ switch ($env:UI_REVIEW_SCENARIO) {
+ 'settings' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Start-Sleep -Seconds 1
+ Capture '01-appearance-control-sizing.png'
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,246'
+ Capture '02-terminal-control-sizing.png'
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,76'
+ Capture '03-workspace-toolbar-sizing.png'
+ }
+ 'shortcuts' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Start-Sleep -Seconds 1
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,360'
+ Capture '01-key-bindings-platform-labels.png'
+ }
+ 'dialog' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Start-Sleep -Seconds 1
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '120,705'
+ Capture '01-confirm-dialog-shortcuts.png'
+ }
+ 'sidebar' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Start-Sleep -Seconds 1
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Click '90,323'
+ Capture '01-interaction-panel-resize.png'
+ }
+ 'ssh-copy' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Capture '01-ssh-settings-entry.png'
+ }
+ 'ssh-ports' {
+ Capture '01-ssh-ports-regression.png'
+ }
+ default {
+ Capture '01-platform-regression.png'
+ }
+ }
+
+ @(
+ "scenario=$env:UI_REVIEW_SCENARIO"
+ "target=$env:UI_REVIEW_TARGET"
+ "platform=windows"
+ "pid=$($p.Id)"
+ ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8
+
+ if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill
+ }
+
+ - name: Capture SSH copy menu on Windows
+ if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $qa = Join-Path $env:RUNNER_TEMP 'ui-review'
+ $probe = Join-Path $qa 'ssh-copy-probe'
+ $config = Join-Path $probe 'config'
+ New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null
+ @(
+ 'language=en-US'
+ 'theme=Nord'
+ 'opacity=1'
+ 'blur=off'
+ 'restore_session=false'
+ 'resume_ai=false'
+ 'auto_check_updates=off'
+ ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii
+
+ $env:PEBREL_CONFIG_DIR = $config
+ $env:PEBREL_SSH_COPY_QA_DIR = $probe
+ $stdout = Join-Path $probe 'probe.stdout.log'
+ $stderr = Join-Path $probe 'probe.stderr.log'
+ $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture')
+ $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+
+ $ready = Join-Path $probe 'menu-ready.json'
+ $deadline = (Get-Date).AddMinutes(20)
+ while (-not (Test-Path $ready)) {
+ if ($cargo.HasExited) {
+ Get-Content $stdout -ErrorAction SilentlyContinue
+ Get-Content $stderr -ErrorAction SilentlyContinue
+ throw 'SSH screenshot probe exited before opening its native window.'
+ }
+ if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' }
+ Start-Sleep -Milliseconds 500
+ }
+
+ $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid
+ ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png')
+ ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294'
+ Start-Sleep -Milliseconds 700
+ ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png')
+ New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null
+ $cargo.WaitForExit()
+ if ($cargo.ExitCode -ne 0) {
+ Get-Content $stdout -ErrorAction SilentlyContinue
+ Get-Content $stderr -ErrorAction SilentlyContinue
+ throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)."
+ }
+
+ - name: Capture macOS application and changed UI
+ if: matrix.platform == 'macos'
+ shell: bash
+ run: |
+ set -euo pipefail
+ qa="$RUNNER_TEMP/ui-review"
+ config="$qa/config"
+ mkdir -p "$qa" "$config"
+ cat >"$config/pebrel_settings.txt" <<'EOF'
+ language=en-US
+ theme=Nord
+ opacity=1
+ blur=off
+ restore_session=false
+ resume_ai=false
+ auto_check_updates=off
+ EOF
+
+ export PEBREL_CONFIG_DIR="$config"
+ app="$PWD/target/ui-review/release/pebrel"
+ "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" &
+ pid=$!
+
+ cleanup() {
+ kill "$pid" >/dev/null 2>&1 || true
+ wait "$pid" >/dev/null 2>&1 || true
+ }
+ trap cleanup EXIT
+
+ for _ in $(seq 1 20); do
+ if ! kill -0 "$pid" >/dev/null 2>&1; then
+ cat "$qa/pebrel.stderr.log" >&2 || true
+ exit 1
+ fi
+ sleep 0.5
+ done
+
+ screencapture -x "$qa/00-full-app.png"
+
+ focus_and_keys() {
+ local keys="$1"
+ PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true
+ set targetPid to (system attribute "PID") as integer
+ set requestedKeys to system attribute "KEYS"
+ tell application "System Events"
+ set appProc to first application process whose unix id is targetPid
+ set frontmost of appProc to true
+ delay 0.4
+ if requestedKeys is "settings" then
+ keystroke "," using command down
+ else if requestedKeys is "menu" then
+ click menu bar item 2 of menu bar 1 of appProc
+ else if requestedKeys is "quit" then
+ keystroke "q" using command down
+ end if
+ end tell
+ APPLESCRIPT
+ }
+
+ click_offset() {
+ local x="$1"
+ local y="$2"
+ PID="$pid" X="$x" Y="$y" osascript <<'APPLESCRIPT' || true
+ set targetPid to (system attribute "PID") as integer
+ set clickX to (system attribute "X") as integer
+ set clickY to (system attribute "Y") as integer
+ tell application "System Events"
+ set appProc to first application process whose unix id is targetPid
+ set frontmost of appProc to true
+ tell window 1 of appProc
+ set {wx, wy} to position
+ end tell
+ click at {wx + clickX, wy + clickY}
+ end tell
+ APPLESCRIPT
+ sleep 1
+ }
+
+ click_bottom() {
+ local x="$1"
+ local offset="$2"
+ PID="$pid" X="$x" OFFSET="$offset" osascript <<'APPLESCRIPT' || true
+ set targetPid to (system attribute "PID") as integer
+ set clickX to (system attribute "X") as integer
+ set bottomOffset to (system attribute "OFFSET") as integer
+ tell application "System Events"
+ set appProc to first application process whose unix id is targetPid
+ set frontmost of appProc to true
+ tell window 1 of appProc
+ set {wx, wy} to position
+ set {ww, wh} to size
+ end tell
+ click at {wx + clickX, wy + wh - bottomOffset}
+ end tell
+ APPLESCRIPT
+ sleep 1
+ }
+
+ case "$UI_REVIEW_SCENARIO" in
+ settings)
+ focus_and_keys settings
+ sleep 2
+ screencapture -x "$qa/01-appearance-control-sizing.png"
+ click_offset 90 249
+ screencapture -x "$qa/02-terminal-control-sizing.png"
+ click_offset 90 81
+ screencapture -x "$qa/03-workspace-toolbar-sizing.png"
+ ;;
+ shortcuts)
+ focus_and_keys settings
+ sleep 2
+ click_offset 90 363
+ screencapture -x "$qa/01-key-bindings-platform-labels.png"
+ ;;
+ dialog)
+ focus_and_keys settings
+ sleep 2
+ click_bottom 120 24
+ screencapture -x "$qa/01-confirm-dialog-shortcuts.png"
+ ;;
+ sidebar)
+ focus_and_keys settings
+ sleep 2
+ click_offset 90 326
+ screencapture -x "$qa/01-interaction-panel-resize.png"
+ ;;
+ menu)
+ focus_and_keys menu
+ sleep 1
+ screencapture -x "$qa/01-native-menu.png"
+ ;;
+ portable)
+ cleanup
+ trap - EXIT
+ bundle="$RUNNER_TEMP/Pebrel UI Review.app"
+ mkdir -p "$bundle/Contents/MacOS"
+ cp "$app" "$bundle/Contents/MacOS/pebrel"
+ chmod +x "$bundle/Contents/MacOS/pebrel"
+ cat >"$bundle/Contents/Info.plist" <<'PLIST'
+
+
+
+ CFBundleExecutablepebrel
+ CFBundleIdentifierio.github.kuddev.pebrel.ui-review
+ CFBundleNamePebrel UI Review
+ CFBundlePackageTypeAPPL
+
+ PLIST
+ unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG
+ export HOME="$qa/home"
+ mkdir -p "$HOME"
+ open -n "$bundle"
+ sleep 4
+ screencapture -x "$qa/01-portable-startup-dialog.png"
+ pkill -f "$bundle/Contents/MacOS/pebrel" || true
+ ;;
+ ssh-copy)
+ focus_and_keys settings
+ sleep 2
+ screencapture -x "$qa/01-ssh-settings-entry.png"
+ ;;
+ ssh-ports)
+ screencapture -x "$qa/01-ssh-ports-regression.png"
+ ;;
+ notification)
+ screencapture -x "$qa/01-foreground-notification-regression.png"
+ ;;
+ *)
+ screencapture -x "$qa/01-platform-regression.png"
+ ;;
+ esac
+
+ {
+ echo "scenario=$UI_REVIEW_SCENARIO"
+ echo "target=$UI_REVIEW_TARGET"
+ echo "platform=macos"
+ sw_vers
+ } >"$qa/review-target.txt"
+
+ - name: Upload reviewer screenshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ui-review-${{ matrix.platform }}
+ path: |
+ ${{ runner.temp }}/ui-review/*.png
+ ${{ runner.temp }}/ui-review/*.txt
+ ${{ runner.temp }}/ui-review/*.log
+ ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json
+ ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log
+ if-no-files-found: error
+ retention-days: 14
+ compression-level: 0
+
+ - name: Add screenshot pointers to job summary
+ if: always()
+ shell: bash
+ run: |
+ {
+ echo "## Native UI review"
+ echo
+ echo "- Platform: ${{ matrix.platform }}"
+ echo "- Scenario: $UI_REVIEW_SCENARIO"
+ echo "- Changed UI target: $UI_REVIEW_TARGET"
+ echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence."
+ } >>"$GITHUB_STEP_SUMMARY"
+
+
+ publish-evidence:
+ name: Publish UI evidence
+ if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push'
+ needs: native-ui
+ runs-on: ubuntu-22.04
+ concurrency:
+ group: ui-review-evidence-publish
+ cancel-in-progress: false
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: true
+
+ - uses: actions/download-artifact@v4
+ continue-on-error: true
+ with:
+ name: ui-review-windows
+ path: ${{ runner.temp }}/ui-review-evidence/windows
+
+ - uses: actions/download-artifact@v4
+ continue-on-error: true
+ with:
+ name: ui-review-macos
+ path: ${{ runner.temp }}/ui-review-evidence/macos
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Polish screenshots for PR presentation
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0"
+ python3 - <<'PY'
+ import os
+ from pathlib import Path
+ from PIL import Image, ImageDraw, ImageFilter, ImageStat
+
+ source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence"
+ output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished"
+ scale = 2
+ radius = 24 * scale
+ padding = 36 * scale
+ shadow_pad = 18 * scale
+
+ for platform in ("windows", "macos"):
+ src_dir = source / platform
+ dst_dir = output / platform
+ dst_dir.mkdir(parents=True, exist_ok=True)
+ if not src_dir.exists():
+ continue
+
+ for path in sorted(src_dir.glob("*.png")):
+ image = Image.open(path).convert("RGB")
+ image = image.resize(
+ (image.width * scale, image.height * scale),
+ Image.Resampling.LANCZOS,
+ )
+ image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2))
+
+ stat = ImageStat.Stat(image.resize((1, 1)))
+ luminance = sum(stat.mean) / 3
+ background = (243, 245, 248) if luminance > 120 else (18, 20, 24)
+ border = (205, 209, 216) if luminance > 120 else (65, 69, 78)
+
+ frame_w = image.width + padding * 2
+ frame_h = image.height + padding * 2
+ canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,))
+
+ shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
+ shadow_draw = ImageDraw.Draw(shadow)
+ x0 = shadow_pad + padding
+ y0 = shadow_pad + padding
+ x1 = x0 + image.width
+ y1 = y0 + image.height
+ shadow_draw.rounded_rectangle(
+ (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale),
+ radius=radius,
+ fill=(0, 0, 0, 92),
+ )
+ shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale))
+ canvas.alpha_composite(shadow)
+
+ mask = Image.new("L", image.size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle(
+ (0, 0, image.width - 1, image.height - 1),
+ radius=radius,
+ fill=255,
+ )
+ framed = Image.new("RGBA", image.size, (0, 0, 0, 0))
+ framed.paste(image, (0, 0), mask)
+ canvas.alpha_composite(framed, (x0, y0))
+
+ draw = ImageDraw.Draw(canvas)
+ draw.rounded_rectangle(
+ (x0, y0, x1 - 1, y1 - 1),
+ radius=radius,
+ outline=border + (255,),
+ width=2 * scale,
+ )
+
+ out = dst_dir / f"{path.stem}-review.png"
+ canvas.convert("RGB").save(out, "PNG", optimize=True)
+ PY
+
+ - name: Publish screenshots outside the PR diff
+ shell: bash
+ run: |
+ set -euo pipefail
+ source_dir="$RUNNER_TEMP/ui-review-evidence"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git fetch origin ui-review-evidence || true
+ if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then
+ git switch -C ui-review-evidence origin/ui-review-evidence
+ else
+ git switch --orphan ui-review-evidence
+ git rm -rf . >/dev/null 2>&1 || true
+ fi
+
+ polished_dir="$RUNNER_TEMP/ui-review-polished"
+ root="ui-review/pr-$UI_REVIEW_PR"
+ rm -rf "$root"
+ mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos"
+ find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true
+ find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true
+ find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true
+ find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true
+ printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt"
+ printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt"
+
+ git add "$root"
+ if git diff --cached --quiet; then
+ exit 0
+ fi
+ git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots"
+ git push origin HEAD:ui-review-evidence
diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs
index 6a7aa805..85b6faca 100644
--- a/nebula_app/src/gpui_shell/settings_pane.rs
+++ b/nebula_app/src/gpui_shell/settings_pane.rs
@@ -33,7 +33,7 @@ use std::time::Duration;
use crate::gpui_shell::config::{DEFAULT_CURSOR_BLINK, effective_cursor_blink};
use crate::gpui_shell::prelude::*;
-use crate::gpui_shell::widgets::NebulaButton;
+use crate::gpui_shell::widgets::{NebulaButton, settings_control_height};
mod about;
mod agents;
@@ -683,7 +683,7 @@ impl SettingsPane {
.debug_selector(move || format!("settings-select-{key}"))
.w(px(SETTINGS_SELECT_WIDTH))
.text_color(cx.theme().link)
- .children(select.map(|state| Select::new(&state)))
+ .children(select.map(|state| Select::new(&state).h(settings_control_height(cx))))
.into_any_element()
});
self.maybe_marked(key, label, desc, control, cx)
@@ -698,7 +698,7 @@ impl SettingsPane {
.w(px(SETTINGS_SELECT_WIDTH))
.font_family(cx.theme().mono_font_family.clone())
.text_color(cx.theme().link)
- .child(Select::new(&self.shell_select)),
+ .child(Select::new(&self.shell_select).h(settings_control_height(cx))),
cx,
)
}
diff --git a/nebula_app/src/gpui_shell/settings_pane/design.rs b/nebula_app/src/gpui_shell/settings_pane/design.rs
index 8bf2adf2..e08117a7 100644
--- a/nebula_app/src/gpui_shell/settings_pane/design.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/design.rs
@@ -127,7 +127,7 @@ impl SettingsPane {
let reset = dirty.then(|| {
div()
.id(SharedString::from(format!("setting-reset-{label}")))
- .size(px(20.0))
+ .size(px(32.0))
.rounded_md()
.flex()
.items_center()
@@ -144,7 +144,7 @@ impl SettingsPane {
.build(window, cx)
})
.on_click(cx.listener(move |this, _, window, cx| on_reset(this, window, cx)))
- .child(Icon::new(IconName::Undo2).xsmall())
+ .child(Icon::new(IconName::Undo2).size(px(16.0)))
.into_any_element()
});
self.row_shell(label, desc.into(), reset, dirty, RowLayout::Standard, control, cx)
@@ -239,7 +239,7 @@ impl SettingsPane {
Button::new(SharedString::from(format!("settings-help-{label}")))
.icon(IconName::Info)
.ghost()
- .size(px(22.0))
+ .size(px(32.0))
.text_color(theme.muted_foreground)
.accessibility_id(SharedString::from(format!(
"settings-help-{label}"
diff --git a/nebula_app/src/gpui_shell/settings_pane/segmented.rs b/nebula_app/src/gpui_shell/settings_pane/segmented.rs
index f3836942..fbac02d9 100644
--- a/nebula_app/src/gpui_shell/settings_pane/segmented.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/segmented.rs
@@ -31,7 +31,6 @@ impl SettingsPane {
)))
.w(px(SETTINGS_SELECT_WIDTH))
.max_w_full()
- .small()
.outline()
.children(values.iter().copied().zip(labels).enumerate().map(
|(index, (value, label))| {
@@ -39,9 +38,8 @@ impl SettingsPane {
.debug_selector(move || format!("settings-choice-{key}-{value}"))
.flex_1()
.min_w_0()
- .small()
- .h(px(28.0))
- .rounded(px(14.0))
+ .h(settings_control_height(cx))
+ .rounded(px(6.0))
.selected(index == selected)
.label(label)
.on_click(cx.listener(move |this, _, window, cx| {
diff --git a/nebula_app/src/gpui_shell/widgets.rs b/nebula_app/src/gpui_shell/widgets.rs
index 44eb976e..7d705d2d 100644
--- a/nebula_app/src/gpui_shell/widgets.rs
+++ b/nebula_app/src/gpui_shell/widgets.rs
@@ -16,8 +16,8 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
- App, ClickEvent, ElementId, IntoElement, ParentElement as _, RenderImage, RenderOnce,
- SharedString, Styled as _, Window, div, px,
+ App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
+ RenderImage, RenderOnce, SharedString, Styled as _, Window, div, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::switch::Switch;
@@ -51,6 +51,20 @@ pub fn shell_brand_image(
Some(Arc::new(RenderImage::new([Frame::new(rgba)])))
}
+/// Desktop form controls keep their padding even with a small UI font.
+pub(crate) fn settings_control_height(cx: &App) -> gpui::Pixels {
+ px(f32::from(cx.theme().font_size).max(16.0) * 2.0)
+}
+
+/// Toolbar glyphs and their hover/hit surfaces have independent logical sizes.
+pub(crate) fn toolbar_button(id: impl Into, icon: impl Into) -> Button {
+ Button::new(id).icon(Icon::new(icon).size(px(18.0))).ghost().size(px(32.0))
+}
+
+#[cfg(all(test, feature = "gpui-test-support"))]
+#[path = "widgets_tests.rs"]
+mod tests;
+
/// 设置行开关。组件库 `Switch` 的转发壳——`on_click` 与它同签名
/// (`Fn(&bool, &mut Window, &mut App)`,参数是**点击后**的目标值)。
#[derive(IntoElement)]
@@ -187,8 +201,12 @@ impl NebulaButton {
}
impl RenderOnce for NebulaButton {
- fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let key = self.key.clone();
let button = Button::new(ElementId::Name(format!("nebula-btn-{}", self.key).into()))
+ .debug_selector(move || format!("nebula-btn-{key}"))
+ .h(settings_control_height(cx))
+ .px(px(12.0))
.label(self.label)
.disabled(self.disabled);
// Default 走 outline:设置行里的动作按钮需要一条边把自己从行底分出来,
diff --git a/nebula_app/src/gpui_shell/widgets_tests.rs b/nebula_app/src/gpui_shell/widgets_tests.rs
new file mode 100644
index 00000000..4680326c
--- /dev/null
+++ b/nebula_app/src/gpui_shell/widgets_tests.rs
@@ -0,0 +1,66 @@
+use std::{cell::Cell, rc::Rc};
+
+use gpui::{AppContext as _, Context, Modifiers, Render, TestAppContext, point};
+use gpui_component::{IconName, Root, Theme, h_flex};
+
+use super::*;
+
+struct ControlProbe(Rc| >);
+
+impl Render for ControlProbe {
+ fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement {
+ let action = self.0.clone();
+ let tool = self.0.clone();
+ let disabled = self.0.clone();
+ h_flex()
+ .gap(px(8.0))
+ .child(
+ NebulaButton::new("comfort-action")
+ .label("查看详情 / Details")
+ .on_click(move |_, _, _| action.set(action.get() + 1)),
+ )
+ .child(
+ toolbar_button("comfort-tool", IconName::Settings)
+ .debug_selector(|| "comfort-tool".to_owned())
+ .on_click(move |_, _, _| tool.set(tool.get() + 1)),
+ )
+ .child(
+ NebulaButton::new("comfort-disabled")
+ .label("Disabled")
+ .disabled(true)
+ .on_click(move |_, _, _| disabled.set(disabled.get() + 1)),
+ )
+ }
+}
+
+#[gpui::test]
+fn desktop_controls_keep_padding_clickable_with_small_ui_fonts(cx: &mut TestAppContext) {
+ cx.update(gpui_component::init);
+ let clicks = Rc::new(Cell::new(0));
+ let (_, cx) = cx.add_window_view(|window, cx| {
+ let view = cx.new(|_| ControlProbe(clicks.clone()));
+ Root::new(view, window, cx)
+ });
+ cx.simulate_resize(gpui::size(px(1000.0), px(200.0)));
+ for (font, height) in [(10.0, 32.0), (14.0, 32.0), (24.0, 48.0)] {
+ cx.update(|window, cx| {
+ Theme::global_mut(cx).font_size = px(font);
+ window.refresh();
+ let _ = window.draw(cx);
+ });
+ let action = cx.debug_bounds("nebula-btn-comfort-action").expect("text button");
+ let tool = cx.debug_bounds("comfort-tool").expect("toolbar button");
+ let disabled = cx.debug_bounds("nebula-btn-comfort-disabled").expect("disabled button");
+ assert!(action.size.height >= px(height));
+ assert_eq!(tool.size, gpui::size(px(32.0), px(32.0)));
+ let before = clicks.get();
+ // These corners are padding, not glyphs: the whole surface must activate.
+ for bounds in [action, tool, disabled] {
+ cx.simulate_click(
+ point(bounds.origin.x + px(2.0), bounds.bottom() - px(2.0)),
+ Modifiers::default(),
+ );
+ }
+ assert_eq!(clicks.get(), before + 2, "disabled padding must not activate");
+ }
+}
diff --git a/nebula_app/src/gpui_shell/workspace/details_panel.rs b/nebula_app/src/gpui_shell/workspace/details_panel.rs
index 588468c8..59693781 100644
--- a/nebula_app/src/gpui_shell/workspace/details_panel.rs
+++ b/nebula_app/src/gpui_shell/workspace/details_panel.rs
@@ -4,6 +4,7 @@
use super::*;
use crate::display::side_panel::PanelView;
use crate::gpui_shell::file_editor::{DocumentDetails, DocumentSection, TextFileView};
+use crate::gpui_shell::widgets::toolbar_button;
use crate::i18n::Message;
#[cfg(all(test, feature = "gpui-test-support"))]
@@ -103,9 +104,7 @@ impl NebulaWorkspace {
cx: &mut Context,
) -> Button {
let visible = self.side_panel.open && !self.reader_focus_active(cx);
- Button::new("toggle-right-sidebar")
- .icon(IconName::PanelRight)
- .ghost()
+ toolbar_button("toggle-right-sidebar", IconName::PanelRight)
.disabled(disabled)
.selected(visible)
.when(visible, |button| button.bg(cx.theme().secondary))
diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs
index 6ee9b7ae..44491752 100644
--- a/nebula_app/src/gpui_shell/workspace/sidebar.rs
+++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs
@@ -1,4 +1,5 @@
use super::*;
+use crate::gpui_shell::widgets::toolbar_button;
/// 折叠箭头的固定布局槽。图标是 SVG,不应借任一字体的 advance 决定留白。
const TABS_DISCLOSURE_SLOT_W: f32 = 24.0;
@@ -871,15 +872,12 @@ impl NebulaWorkspace {
.justify_between()
.child(
h_flex()
- // 旧壳两枚 32px 命中块之间固定留 8px;默认 Button 正好是
- // 32px,`.small()` 会把热区缩成 24px。
- .gap_2()
+ // Keep toolbar gaps independent of the UI font/rem size.
+ .gap(px(8.0))
.items_center()
.occlude()
.child(
- Button::new("toggle-sidebar")
- .icon(IconName::PanelLeft)
- .ghost()
+ toolbar_button("toggle-sidebar", IconName::PanelLeft)
.disabled(settings_active)
// 侧栏是开关而非一次性动作:展开期间必须持续显示
// 选中底,和旧壳 `left_sidebar_visible()` 同义。
@@ -899,9 +897,7 @@ impl NebulaWorkspace {
})),
)
.child(
- Button::new("open-settings")
- .icon(IconName::Settings)
- .ghost()
+ toolbar_button("open-settings", IconName::Settings)
.selected(settings_active)
.when(settings_active, |button| {
button.bg(settings_active_bg).text_color(settings_active_fg)
@@ -915,19 +911,18 @@ impl NebulaWorkspace {
.child(self.render_collapsed_tab_title(cx))
.child(
title_bar_panel_controls()
- .gap_2()
+ .gap(px(8.0))
.child(
- Button::new("toggle-command-manager")
- .icon(
- Icon::new(Icon::empty())
- .path(crate::gpui_shell::assets::nav::COMMAND_MANAGER),
- )
- .ghost()
- .selected(self.command_manager_open)
- .tooltip("命令列表")
- .on_click(cx.listener(|this, _, window, cx| {
- this.toggle_command_manager(window, cx);
- })),
+ toolbar_button(
+ "toggle-command-manager",
+ Icon::new(Icon::empty())
+ .path(crate::gpui_shell::assets::nav::COMMAND_MANAGER),
+ )
+ .selected(self.command_manager_open)
+ .tooltip("命令列表")
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.toggle_command_manager(window, cx);
+ })),
)
.child(self.render_right_sidebar_button(settings_active, cx)),
)
diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs
index 45bdac49..51fbdd35 100644
--- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs
+++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs
@@ -16,6 +16,7 @@ use gpui_component::menu::PopupMenuItem;
use crate::gpui_shell::prelude::*;
use crate::gpui_shell::terminal::view::SidebarActivity;
+use crate::gpui_shell::widgets::toolbar_button;
use super::{
NebulaWorkspace, NewWindow, OpenSettings, TAB_LABEL_ICON_SIZE, TAB_LABEL_ICON_W, TabDrag,
@@ -683,13 +684,11 @@ impl NebulaWorkspace {
.child(
title_bar_panel_controls()
.child(
- Button::new("top-toggle-command-manager")
- .icon(
- Icon::new(Icon::empty()).path(
- crate::gpui_shell::assets::nav::COMMAND_MANAGER,
- ),
- )
- .ghost()
+ toolbar_button(
+ "top-toggle-command-manager",
+ Icon::new(Icon::empty())
+ .path(crate::gpui_shell::assets::nav::COMMAND_MANAGER),
+ )
.selected(self.command_manager_open)
.tooltip("命令列表")
.on_click(cx.listener(|this, _, window, cx| {
|