Skip to content

feat(client): press pulse for the tab bar, window buttons, settings rows and context menu (UI/UX v3 P3b3) - #83

Merged
mizu-jun merged 11 commits into
masterfrom
p3b3-press-pulse
Aug 29, 2026
Merged

feat(client): press pulse for the tab bar, window buttons, settings rows and context menu (UI/UX v3 P3b3)#83
mizu-jun merged 11 commits into
masterfrom
p3b3-press-pulse

Conversation

@mizu-jun

Copy link
Copy Markdown
Owner

UI/UX v3 P3b3 closes the other half of the parent design's "widget hover/press": the moment the user commits to a control. P3b1 gave overlay surfaces an entrance and an exit (#79); P3b2 gave the pointer's hover a cross-fade (#80, #82); this gives the press its feedback, across the same four pointer models.

Design: docs/superpowers/specs/2026-08-29-p3b3-press-pulse-design.md
Plan: docs/superpowers/plans/2026-08-29-p3b3-press-pulse.md

Why a pulse and not a pressed state

Three of the four models commit their action on mouse-down, not on mouse-up:

Model Commit point Surface after the click
Settings widget row on_mouse_left_pressed stays
Tab on_mouse_left_pressed stays
Window button on_mouse_left_pressed Minimize/Close: gone. Maximize: stays
Context-menu item on_mouse_left_released dismissed on release

A Fluent-style held "pressed" state therefore has no window to live in on three of them — by the frame the button comes up, the tab has switched, the row has toggled, or the window is gone. Moving those commits to mouse-up would change click semantics across the chrome and is out of scope.

So press is a one-shot pulse: full weight at the press instant, zero 100 ms later (duration::FASTER, the Fluent token documented as "Button press feedback"), independent of when the button comes up. PressPulse<Id> holds one Timed, where HoverTransition needs two — that type's second timer exists so a hand-off decays the outgoing item from the weight it actually held, and a press has no hand-off.

With animations disabled, scaled_duration_ms returns 0 and the weight is 0 from the first read, so reduced motion (P3c) means "no press feedback" rather than a stuck pressed appearance.

Composition: why max, and why the alpha boost

let w = hover_weight.max(press_weight);   // press forces the fill on
// ... each site builds its fill from `w`, unchanged ...
let fill = press_fill(fill, press_weight); // dims it and strengthens its alpha

Only the tab composes its hover as an opaque lerp_rgba. The other three draw the hover as an additive layer gated on w > 0.0 — they emit no vertices at all at weight zero, so dimming a layer that is not being drawn is invisible. Press has to raise the weight before it dims it.

And the dim alone is not enough: the normal click lands on a control that is already fully hovered, where max changes nothing. A brightness multiplier scales HSV v, so on a near-black scheme the step has no absolute room. Measured: at the design's provisional PRESS_ALPHA_BOOST = 1.7, the pulse was imperceptible on eight of the nine builtin schemes. Shipped value is 2.3, pinned by a test over all nine.

Test coverage

  • animations/press.rs — 5 tests: decay timing, the single-slot replacement, the deliberate non-idempotence (a double-click must pulse twice, unlike retarget), and the animations.enabled = false gate.
  • color_util.rs — identity at weight 0, darken + strengthen at weight 1, alpha clamp, and two nine-scheme gates: press is perceptible on every builtin scheme, and it does not cost legibility (after >= 4.5 || after >= before * 0.90).
  • widgets/draw/mod.rs — a pressed row paints even with no hover (this is what the max buys), and a pressed row's fill differs from a merely hovered one.
  • state/mod.rs — one test per model that a decaying pulse keeps the frame loop awake.

1031 tests pass; cargo clippy -- -D warnings and cargo fmt --check are clean.

The defect this phase kept producing

Redefining a site's weight variable as hover.max(press) silently changes every later use of that variable in the same function. Twice, that later use was a foreground colour — the window-button glyph and the context-menu label — so press moved text colour for 100 ms, which this project forbids. Neither line was edited; their meaning changed underneath them. Both are fixed: the glyph now goes through window_button_glyph_color, whose signature has no press parameter at all, and the menu label reads a separate hover_w. A whole-branch review confirmed there is no third instance.

Non-goals, stated so they are not read as omissions

  • No per-control elevation. The plan's "pressed sinks to elevation 1" has nothing to hook: add_px_soft_shadow_sdf draws shadows for whole surfaces, and there is no per-control elevation in this renderer.
  • No geometric scale-down, and no foreground colour change (see above).
  • No press on the Windows snap-layout pathsnap_layout.rs swallows WM_NCLBUTTONDOWN and completes on WM_NCLBUTTONUP, so the press never reaches client state. Unlike hover, press has one writer there, not two.

Known limitations

  • A context menu overlapping the tab bar pulses both the menu item and the tab underneath, because the press falls through to the tab branch. That fall-through is pre-existing — the click already switches tabs today — and this PR deliberately does not change control flow to fix it.
  • Minimize and Close never show their pulse (the window is gone first). Wired anyway; excluding two of three would leave an untestable exception in the press chain.
  • Solarized moves 3.52 → 3.34 in text contrast under a full pulse. That scheme is already a contrast defect at rest — known since P2b, tracked for P5 — so the pulse deepens a problem P5 owns rather than creating one.
  • Nothing here is verified on a GPU. The nine-scheme tests measure colours, not appearance. This lands on the existing on-device verification backlog in docs/plans/ui-ux-modernization-v3.md.

Test plan

  • cargo test -p nexterm-client-gpu — 1031 passed
  • cargo clippy -- -D warnings
  • cargo fmt --check
  • On-device: click each of a tab, a settings row, a context-menu item and the Maximize button; confirm the pulse is visible and that no text colour moves with it
  • On-device: with animations.enabled = false, confirm no press feedback appears at all

押下即発火という既存のクリック確定タイミングを前提に、保持型の
pressed 状態ではなく一発再生の press pulse を設計した。共有型
PressPulse<Id>、hover の後段での明度合成、4 モデルへの配線点、
コンテキストメニューの新規 press 分岐とその順序上の罠を記録する。
6 タスク(共有型 → 合成ヘルパ → 4 モデルの配線)に分割した。
spec には計画中に実コードを読んで判明した 2 点を反映済み:
描画サイトのうち 3 つは加算レイヤで base fill を持たないこと、
コントラストは既存欠陥を踏むため相対比較で固定すること。
押下即発火という既存のクリック確定タイミングに合わせ、保持型ではなく
一発減衰の press 表現を共有型として追加する。HoverTransition と違い
二段タイマーは不要で、押下ごとに作り直す(非冪等)。
押下時の見た目を1箇所に集約する。明度を下げるだけでは暗いスキームで
知覚できないため、加算レイヤのalphaも同時に強める。9スキーム全部で
hoverとpressが区別できることをテストで固定する。
実装中の計測で 1.7 では 9 スキーム中 8 つで press が知覚できないことが
判明したため 2.3 へ引き上げた。コントラストのゲートも、暗いスキームでは
hover レイヤが行を明るくするという実測に合わせて絶対値 4.5:1 との
二者択一に改めた。
タブのクリックは押下で確定するため、押下時点で pulse を発火し、
描画側は hover と press の大きい方で fill を作ってから press_fill に通す。
Minimize と Close は押下直後にウィンドウが消えるため実際には見えないが、
3 つとも配線して press 連鎖に例外を作らない。
fill は hover と press の大きい方で良いが、グリフ色は hover 単独のまま
にする。仕様上 press は前景色を動かしてはならないため、判定式を
window_button_glyph_color に切り出して press を引数から外し、
hover=0 で unhovered 色に一致することをテストで固定した。
hit → WidgetId の対応を settings_panel_hit.rs へ切り出し、hover と press
の両方が同じ対応表を使うようにした。focus 済みの行を押した場合も
press_fill を通すため、押下フィードバックが消えない。
メニューだけは release で確定するため、押下から離すまで pulse が見える。
既存の分岐順は変えず、press ハンドラの先頭で hovered 済みの index に
対して発火するだけの純粋な追加にとどめた。
P3b3レビューの唯一のInstant指摘に対応。tab/window-button/context-menuの
press pulseには専用の回帰テストがあったが、settings-row側のクローズは
間接的にしか検証されておらず、将来のリファクタで無音に壊れうる状態だった。
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

@mizu-jun
mizu-jun merged commit ecd8b10 into master Aug 29, 2026
12 checks passed
@mizu-jun
mizu-jun deleted the p3b3-press-pulse branch August 29, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant