From 0aaaeb3b06b417ee3aab7e56b68dd6f5d232f2dc Mon Sep 17 00:00:00 2001 From: WilliamWang1721 <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:06:33 +0000 Subject: [PATCH 1/7] fix(ui): improve desktop control sizing and spacing --- nebula_app/src/gpui_shell/settings_pane.rs | 6 +- .../src/gpui_shell/settings_pane/design.rs | 6 +- .../src/gpui_shell/settings_pane/segmented.rs | 6 +- nebula_app/src/gpui_shell/widgets.rs | 24 ++++++- nebula_app/src/gpui_shell/widgets_tests.rs | 66 +++++++++++++++++++ .../src/gpui_shell/workspace/details_panel.rs | 5 +- .../src/gpui_shell/workspace/sidebar.rs | 37 +++++------ .../src/gpui_shell/workspace/top_tabs.rs | 9 +-- 8 files changed, 116 insertions(+), 43 deletions(-) create mode 100644 nebula_app/src/gpui_shell/widgets_tests.rs 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..a61a5893 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -3,6 +3,7 @@ //! 这里只负责同一组 workspace tab 的第二种呈现;激活、关闭、重命名、排序 //! 与 dock 都调用 `NebulaWorkspace` 既有动作,不维护平行状态。 +use crate::gpui_shell::widgets::toolbar_button; use std::time::Duration; use gpui::prelude::FluentBuilder as _; @@ -683,13 +684,9 @@ impl NebulaWorkspace { .child( title_bar_panel_controls() .child( - Button::new("top-toggle-command-manager") - .icon( - Icon::new(Icon::empty()).path( + toolbar_button("top-toggle-command-manager", 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| { From fd388a4730510b5ddbbc0b9964254880b2b46c98 Mon Sep 17 00:00:00 2001 From: WilliamWang1721 <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:13:33 +0000 Subject: [PATCH 2/7] style(ui): keep toolbar composition readable --- nebula_app/src/gpui_shell/workspace/top_tabs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index a61a5893..51fbdd35 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -3,7 +3,6 @@ //! 这里只负责同一组 workspace tab 的第二种呈现;激活、关闭、重命名、排序 //! 与 dock 都调用 `NebulaWorkspace` 既有动作,不维护平行状态。 -use crate::gpui_shell::widgets::toolbar_button; use std::time::Duration; use gpui::prelude::FluentBuilder as _; @@ -17,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, @@ -684,9 +684,11 @@ impl NebulaWorkspace { .child( title_bar_panel_controls() .child( - toolbar_button("top-toggle-command-manager", Icon::new(Icon::empty()).path( - crate::gpui_shell::assets::nav::COMMAND_MANAGER, - ),) + 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| { From cc670e7d7d4e970dfa07f85188a2bc259f1e9737 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:04:24 +0800 Subject: [PATCH 3/7] ci(ui): capture Windows and macOS review screenshots for PR #285 --- .github/workflows/ui-review-screenshots.yml | 351 ++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 .github/workflows/ui-review-screenshots.yml diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml new file mode 100644 index 00000000..672b7627 --- /dev/null +++ b/.github/workflows/ui-review-screenshots.yml @@ -0,0 +1,351 @@ +name: UI review screenshots + +on: + workflow_dispatch: + 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_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 ($LASTEXITCODE -eq 0 -and (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 '^,' + Capture '01-settings-controls.png' + } + 'shortcuts' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-shortcut-labels.png' + } + 'dialog' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-settings-shortcuts.png' + } + 'sidebar' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-sidebar-settings.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 1 of menu bar 1 of appProc + else if requestedKeys is "quit" then + keystroke "q" using command down + end if + end tell + APPLESCRIPT + } + + case "$UI_REVIEW_SCENARIO" in + settings) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-controls.png" + ;; + shortcuts) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-shortcut-labels.png" + ;; + dialog) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-shortcuts.png" + focus_and_keys quit + sleep 1 + screencapture -x "$qa/02-dialog-state.png" || true + ;; + sidebar) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-sidebar-settings.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" From d704c5fe00091fc6ca37c2b8f1a839dfaf870487 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:40:59 +0800 Subject: [PATCH 4/7] ci(ui): fix native screenshot capture checks --- .github/workflows/ui-review-screenshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 672b7627..b2a09794 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -87,7 +87,7 @@ jobs: $shot = Join-Path $qa $name for ($i = 0; $i -lt 8; $i++) { & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot - if ($LASTEXITCODE -eq 0 -and (Test-Path $shot)) { return } + if (Test-Path $shot) { return } Start-Sleep -Seconds 1 } throw "Could not capture Pebrel window: $name" @@ -237,7 +237,7 @@ jobs: if requestedKeys is "settings" then keystroke "," using command down else if requestedKeys is "menu" then - click menu bar item 1 of menu bar 1 of appProc + click menu bar item 2 of menu bar 1 of appProc else if requestedKeys is "quit" then keystroke "q" using command down end if From 652b296ba3909284ef5982a5ce8bf68e6fee382b Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:42:45 +0800 Subject: [PATCH 5/7] ci(ui): publish reviewer screenshots outside PR diff --- .github/workflows/ui-review-screenshots.yml | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index b2a09794..8f697266 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -2,6 +2,8 @@ name: UI review screenshots on: workflow_dispatch: + push: + branches: ["fix/desktop-ui-control-spacing"] pull_request: types: [opened, synchronize, reopened] @@ -17,6 +19,7 @@ env: 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" @@ -349,3 +352,59 @@ jobs: 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: 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 + with: + name: ui-review-windows + path: ${{ runner.temp }}/ui-review-evidence/windows + + - uses: actions/download-artifact@v4 + with: + name: ui-review-macos + path: ${{ runner.temp }}/ui-review-evidence/macos + + - 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 + + root="ui-review/pr-$UI_REVIEW_PR" + rm -rf "$root" + mkdir -p "$root/windows" "$root/macos" + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + 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 From ddcccc82431709a32671fabc223bb2aa4e6e6775 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:06:19 +0800 Subject: [PATCH 6/7] ci(ui): polish screenshots before PR presentation --- .github/workflows/ui-review-screenshots.yml | 94 ++++++++++++++++++++- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 8f697266..3b6b40cc 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -356,7 +356,7 @@ jobs: publish-evidence: name: Publish UI evidence - if: github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' needs: native-ui runs-on: ubuntu-22.04 concurrency: @@ -370,15 +370,98 @@ jobs: 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: | @@ -394,11 +477,14 @@ jobs: 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" - find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; - find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + 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" From 96885704097ff5b10224e791413ef5c7f36632e9 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:23:32 +0800 Subject: [PATCH 7/7] ci(ui): capture the actual changed settings UI --- .github/workflows/ui-review-screenshots.yml | 76 ++++++++++++++++++--- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 3b6b40cc..872db291 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -101,19 +101,30 @@ jobs: switch ($env:UI_REVIEW_SCENARIO) { 'settings' { & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-settings-controls.png' + 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 '^,' - Capture '01-shortcut-labels.png' + 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 '^,' - Capture '01-settings-shortcuts.png' + 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 '^,' - Capture '01-sidebar-settings.png' + 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 '^,' @@ -248,29 +259,72 @@ jobs: 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-settings-controls.png" + 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 - screencapture -x "$qa/01-shortcut-labels.png" + click_offset 90 363 + screencapture -x "$qa/01-key-bindings-platform-labels.png" ;; dialog) focus_and_keys settings sleep 2 - screencapture -x "$qa/01-settings-shortcuts.png" - focus_and_keys quit - sleep 1 - screencapture -x "$qa/02-dialog-state.png" || true + click_bottom 120 24 + screencapture -x "$qa/01-confirm-dialog-shortcuts.png" ;; sidebar) focus_and_keys settings sleep 2 - screencapture -x "$qa/01-sidebar-settings.png" + click_offset 90 326 + screencapture -x "$qa/01-interaction-panel-resize.png" ;; menu) focus_and_keys menu