Skip to content

Commit 78c94e3

Browse files
committed
impl SSD popup
1 parent 7e0fa20 commit 78c94e3

27 files changed

Lines changed: 4080 additions & 107 deletions

File tree

‎docs/docs/configuration/components.md‎

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ These are the building blocks you assemble inside
99
decorations. They are imported from `shoji_wm`:
1010

1111
```tsx
12-
import {Box, Label, Button, AppIcon, Image, ShaderEffect, WindowBorder} from 'shoji_wm';
12+
import {Box, Label, Button, AppIcon, Image, Popup, ShaderEffect, WindowBorder} from 'shoji_wm';
1313
```
1414

1515
`<ManagedWindow/>` and `<ClientWindow/>` are documented on the
@@ -163,6 +163,160 @@ the border and provides interactive resize hit areas.
163163
</WindowBorder>
164164
```
165165

166+
## `<Popup/>`
167+
168+
Content shown next to its parent but drawn **outside the window**: a tooltip
169+
for a title bar button, a menu with buttons. It is written inside the
170+
component it belongs to (its parent is its *anchor*), so it shares that
171+
component's signals. The model follows the browser's
172+
[popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API).
173+
174+
### A tooltip
175+
176+
```tsx
177+
import {Box, Button, Label, Popup, windowAction} from 'shoji_wm';
178+
179+
<Button onClick={windowAction('maximize')} style={{width: 16, height: 16}}>
180+
<Popup trigger="hover" openDelay={500} placement="bottom" offset={6}>
181+
<Box style={{background: '#1e1e2ef0', borderRadius: 6, paddingX: 10, paddingY: 4}}>
182+
<Label text="Maximize" style={{fontSize: 12, color: '#f5f7fa'}} />
183+
</Box>
184+
</Popup>
185+
</Button>
186+
```
187+
188+
`trigger="hover"` opens it once the pointer has rested `openDelay` ms on the
189+
button and closes it `closeDelay` ms after the pointer left.
190+
191+
### A menu with buttons
192+
193+
```tsx
194+
<Button onClick={toggleMaximize}>
195+
<Popup trigger="hover" mode="auto" openDelay={500} closeDelay={200}>
196+
<Box direction="row" style={{gap: 6, padding: 6}}>
197+
<Button onClick={() => snap(window, 'left')}>…</Button>
198+
<Button onClick={() => snap(window, 'right')}>…</Button>
199+
</Box>
200+
</Popup>
201+
</Button>
202+
```
203+
204+
With `mode="auto"` the popup takes pointer input, so its buttons work. The
205+
pointer can move from the anchor into the popup: inside the popup the anchor
206+
still counts as hovered, and `closeDelay` covers the gap between them. A press
207+
outside, Escape, or opening another `"auto"` popup closes it.
208+
209+
### A menu that opens on click, with your own state
210+
211+
```tsx
212+
const [open, setOpen] = useState(false);
213+
214+
<Button onClick={() => setOpen(!open())}>
215+
<AppIcon icon={window.icon} />
216+
<Popup mode="auto" open={open} onOpenChange={(next, reason) => setOpen(next)}>
217+
<Button onClick={() => { window.close(); setOpen(false); }}>…</Button>
218+
</Popup>
219+
</Button>
220+
```
221+
222+
The same with less code is `<Popup trigger="click" mode="auto">`.
223+
224+
### Props
225+
226+
| Prop | Type | Meaning |
227+
| --- | --- | --- |
228+
| `open` | `boolean` (or signal) | Whether it is shown (default `true`); not used with `trigger` |
229+
| `trigger` | `"hover" \| "click"` | Let the popup open and close itself (see below) |
230+
| `openDelay` | `number` | `trigger="hover"`: ms on the anchor before it opens (default `500`) |
231+
| `closeDelay` | `number` | `trigger="hover"`: ms after the pointer left before it closes (default `200`) |
232+
| `mode` | `"hint" \| "auto" \| "manual"` | How it takes part in input (default `"hint"`, see below) |
233+
| `onOpenChange` | `(open, reason) => void` | `"auto"`: the compositor asks it to close |
234+
| `closeOnEscape` | `boolean` | `"auto"`: Escape closes it (default `true`) |
235+
| `closeOnOutsidePress` | `boolean` | `"auto"`: a press outside it and its anchor closes it (default `true`) |
236+
| `onInterestChange` | `(interested) => void` | The pointer entered / left the anchor or, unless `"hint"`, the popup |
237+
| `onAnchorPress` | `() => void` | The anchor was pressed |
238+
| `placement` | `"top" \| "bottom" \| "left" \| "right"` | The side of the anchor it goes to (default `"bottom"`) |
239+
| `align` | `"start" \| "center" \| "end"` | Alignment along that side (default `"center"`) |
240+
| `offset` | `number` | Distance from the anchor in logical pixels (default `0`) |
241+
| `collision` | `"flip" \| "none"` | `"flip"` (default) moves it to the opposite side when that has more room on the output and slides it along the side to stay on the output |
242+
| `layer` | `"top" \| "window"` | `"top"` (default): above every window. `"window"`: right above its own window, so windows stacked higher cover it |
243+
| `style` | `SSDStyle` | Styling of the popup box itself |
244+
245+
### Modes
246+
247+
| `mode` | Pointer input | Closed by the compositor |
248+
| --- | --- | --- |
249+
| `"hint"` (default) | Falls through to whatever is below | Never |
250+
| `"auto"` | Taken, also above other windows' clients | On a press outside, Escape, its window hidden, another `"auto"` popup opening |
251+
| `"manual"` | Taken | Never |
252+
253+
The compositor never changes `open` itself: it calls `onOpenChange(false,
254+
reason)` and your config decides. An `"auto"` popup without `onOpenChange` or
255+
`trigger` gets no close requests (and leaves Escape to the window). `reason` is one of:
256+
257+
| `reason` | When |
258+
| --- | --- |
259+
| `"outside-press"` | A press outside the popup (and the popups nested in it) and outside its anchor |
260+
| `"escape"` | Escape, for the most recently opened `"auto"` popup. While one is open, Escape does not reach the focused window; set `closeOnEscape={false}` to keep Escape for the window |
261+
| `"anchor-gone"` | Its window was hidden (minimized, moved to another workspace) |
262+
| `"other-popup"` | Another `"auto"` popup opened that is not nested in this one |
263+
264+
### Triggers
265+
266+
`trigger` keeps the open state for you, like `interestfor` / `popovertarget`
267+
in browsers:
268+
269+
- `"hover"`: opens after the pointer rested `openDelay` ms on the anchor and
270+
closes `closeDelay` ms after it left both the anchor and (unless `"hint"`) the
271+
popup. Coming back within `closeDelay` keeps it open.
272+
- `"click"`: each press of the anchor toggles it.
273+
274+
A `trigger` popup also closes on `onOpenChange` requests; your own
275+
`onOpenChange` is still called. Set `trigger` once and do not switch it on and
276+
off.
277+
278+
### Hover inside a popup
279+
280+
Outside popups, hover goes to the innermost node with `onHoverChange` only.
281+
Inside an `"auto"` / `"manual"` popup it covers the whole ancestor chain, like
282+
the DOM's `:hover`: the buttons in the popup, the popup, its anchor and the
283+
anchor's ancestors all count as hovered. So `open={hover}` driven by the
284+
anchor's `onHoverChange` keeps a menu open while the pointer is on it.
285+
286+
### How a popup differs from an ordinary child
287+
288+
- **Position**: it is sized by its content (its children are laid out as a
289+
column) and takes no space in the parent's layout.
290+
- **Not clipped**: the rounded corners of the window and `overflow: 'hidden'`
291+
ancestors do not cut it.
292+
- **Stacking**: drawn in its own pass, above every window (`layer="top"`) and
293+
below layer-shell bars and overlays. A popup nested in a popup is drawn with
294+
its outer popup.
295+
- **Painting**: it is an ordinary decoration subtree: `style`, `boxShadow`,
296+
[paint shaders](./paint.md), labels, images and signal-driven `opacity` all
297+
work. A `<ShaderEffect/>` inside a popup draws its children but not its
298+
effect.
299+
- **Window animations**: while its window is drawn with an animated transform
300+
(open / close, minimize, scaling), the popup is neither drawn nor hit.
301+
Dragging a window moves its popups with it.
302+
- `open` only toggles visibility: a closed popup keeps its layout, so opening it
303+
is cheap.
304+
305+
### Rust
306+
307+
```rust
308+
Button::new().child(
309+
Popup::new()
310+
.mode(PopupMode::Auto)
311+
.trigger(PopupTrigger::hover(500.0, 200.0))
312+
.on_open_change(|open, reason| eprintln!("open: {open} ({reason:?})"))
313+
.child(Label::new("Maximize")),
314+
)
315+
```
316+
317+
`Popup::new().open(signal)`, `.close_on_escape(false)`,
318+
`.on_interest_change(...)` and `.on_anchor_press(...)` match the props above.
319+
166320
---
167321

168322
## Style reference

‎docs/docs/configuration/default-config.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ they keep working even if your config is broken.
9191
Each window gets a title bar with the app icon, the window title, and
9292
minimize / maximize / close buttons. The border is **gold when focused**
9393
(`#d7ba7d`) and gray otherwise (`#4f5666`), with rounded corners.
94+
Resting the pointer on the minimize or maximize button for half a second shows a
95+
tooltip under it ([`<Popup/>`](./components.md#popup)).
9496

9597
- **Terminals** (kitty, ghostty) get a translucent *liquid-glass* blurred
9698
background instead of a solid title bar.

‎docs/i18n/ja/docusaurus-plugin-content-docs/current/configuration/components.md‎

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ sidebar_position: 8
88
ウィンドウ装飾を描くための部品です。`shoji_wm` から import します。
99

1010
```tsx
11-
import {Box, Label, Button, AppIcon, Image, ShaderEffect, WindowBorder} from 'shoji_wm';
11+
import {Box, Label, Button, AppIcon, Image, Popup, ShaderEffect, WindowBorder} from 'shoji_wm';
1212
```
1313

1414
`<ManagedWindow/>` と `<ClientWindow/>` は
@@ -162,6 +162,154 @@ const [hover, setHover] = useState(false);
162162
</WindowBorder>
163163
```
164164

165+
## `<Popup/>`
166+
167+
親の隣に表示し、**ウィンドウの外**に描くコンテンツです。タイトルバーのボタンの
168+
ツールチップや、ボタン付きのメニューに使います。属するコンポーネントの中に書き
169+
(親が *基準(アンカー)* になります)、そのコンポーネントのシグナルをそのまま
170+
使えます。仕組みはブラウザの
171+
[Popover API](https://developer.mozilla.org/ja/docs/Web/API/Popover_API) に倣っています。
172+
173+
### ツールチップ
174+
175+
```tsx
176+
import {Box, Button, Label, Popup, windowAction} from 'shoji_wm';
177+
178+
<Button onClick={windowAction('maximize')} style={{width: 16, height: 16}}>
179+
<Popup trigger="hover" openDelay={500} placement="bottom" offset={6}>
180+
<Box style={{background: '#1e1e2ef0', borderRadius: 6, paddingX: 10, paddingY: 4}}>
181+
<Label text="最大化" style={{fontSize: 12, color: '#f5f7fa'}} />
182+
</Box>
183+
</Popup>
184+
</Button>
185+
```
186+
187+
`trigger="hover"` は、ボタンにポインターを `openDelay` ms 置くと開き、離れて
188+
`closeDelay` ms 経つと閉じます。
189+
190+
### ボタン付きのメニュー
191+
192+
```tsx
193+
<Button onClick={toggleMaximize}>
194+
<Popup trigger="hover" mode="auto" openDelay={500} closeDelay={200}>
195+
<Box direction="row" style={{gap: 6, padding: 6}}>
196+
<Button onClick={() => snap(window, 'left')}>…</Button>
197+
<Button onClick={() => snap(window, 'right')}>…</Button>
198+
</Box>
199+
</Popup>
200+
</Button>
201+
```
202+
203+
`mode="auto"` ではポップアップが入力を受けるので、中のボタンが押せます。基準から
204+
ポップアップへポインターを移しても、ポップアップ内では基準もホバー中とみなされ、
205+
間の隙間は `closeDelay` が吸収します。外側を押す・Esc・別の `"auto"` ポップアップが
206+
開く、のいずれかで閉じます。
207+
208+
### 自分で状態を持つ、クリックで開くメニュー
209+
210+
```tsx
211+
const [open, setOpen] = useState(false);
212+
213+
<Button onClick={() => setOpen(!open())}>
214+
<AppIcon icon={window.icon} />
215+
<Popup mode="auto" open={open} onOpenChange={(next, reason) => setOpen(next)}>
216+
<Button onClick={() => { window.close(); setOpen(false); }}>…</Button>
217+
</Popup>
218+
</Button>
219+
```
220+
221+
短く書くなら `<Popup trigger="click" mode="auto">` です。
222+
223+
### Props
224+
225+
| Prop | 型 | 意味 |
226+
| --- | --- | --- |
227+
| `open` | `boolean`(またはシグナル) | 表示するか(既定 `true`)。`trigger` 使用時は使いません |
228+
| `trigger` | `"hover" \| "click"` | ポップアップが自分で開閉する(後述) |
229+
| `openDelay` | `number` | `trigger="hover"`: 基準に置いてから開くまでの ms(既定 `500`) |
230+
| `closeDelay` | `number` | `trigger="hover"`: 離れてから閉じるまでの ms(既定 `200`) |
231+
| `mode` | `"hint" \| "auto" \| "manual"` | 入力への関わり方(既定 `"hint"`、後述) |
232+
| `onOpenChange` | `(open, reason) => void` | `"auto"`: コンポジターから閉じる要求が来た |
233+
| `closeOnEscape` | `boolean` | `"auto"`: Esc で閉じる(既定 `true`) |
234+
| `closeOnOutsidePress` | `boolean` | `"auto"`: ポップアップと基準の外を押すと閉じる(既定 `true`) |
235+
| `onInterestChange` | `(interested) => void` | ポインターが基準(`"hint"` 以外はポップアップも)に出入りした |
236+
| `onAnchorPress` | `() => void` | 基準が押された |
237+
| `placement` | `"top" \| "bottom" \| "left" \| "right"` | 基準のどちら側に出すか(既定 `"bottom"`) |
238+
| `align` | `"start" \| "center" \| "end"` | その辺での揃え位置(既定 `"center"`) |
239+
| `offset` | `number` | 基準からの距離(論理ピクセル、既定 `0`) |
240+
| `collision` | `"flip" \| "none"` | `"flip"`(既定): 出力内の余白が反対側の方が大きければそちらへ移り、辺に沿って出力内に収まるようずらす |
241+
| `layer` | `"top" \| "window"` | `"top"`(既定): 全ウィンドウの上。`"window"`: 自分のウィンドウのすぐ上(上にあるウィンドウには隠れる) |
242+
| `style` | `SSDStyle` | ポップアップ自体のスタイル |
243+
244+
### モード
245+
246+
| `mode` | ポインター入力 | コンポジターが閉じる要求を出すとき |
247+
| --- | --- | --- |
248+
| `"hint"`(既定) | 下へ素通り | なし |
249+
| `"auto"` | 受ける(他のウィンドウのクライアントの上でも) | 外側を押した・Esc・ウィンドウが隠れた・別の `"auto"` が開いた |
250+
| `"manual"` | 受ける | なし |
251+
252+
コンポジターが `open` を直接変えることはありません。`onOpenChange(false, reason)` を
253+
呼び、どうするかは設定側が決めます。`onOpenChange` も `trigger` もない `"auto"` には
254+
閉じる要求は来ません(Esc もウィンドウに届きます)。`reason` は次のいずれかです。
255+
256+
| `reason` | いつ |
257+
| --- | --- |
258+
| `"outside-press"` | ポップアップ(とその中の入れ子のポップアップ)と基準の外が押された |
259+
| `"escape"` | Esc。最後に開いた `"auto"` に届きます。開いている間 Esc はフォーカス中のウィンドウに届きません。ウィンドウに渡したいときは `closeOnEscape={false}` |
260+
| `"anchor-gone"` | ウィンドウが隠れた(最小化・別ワークスペースへ移動) |
261+
| `"other-popup"` | このポップアップの中に入れ子になっていない、別の `"auto"` が開いた |
262+
263+
### トリガー
264+
265+
`trigger` を付けると開閉の状態を SDK が持ちます。ブラウザの `interestfor` /
266+
`popovertarget` に相当します。
267+
268+
- `"hover"`: 基準に `openDelay` ms 置くと開き、基準と(`"hint"` 以外は)ポップアップの
269+
両方から離れて `closeDelay` ms で閉じます。その間に戻れば開いたままです。
270+
- `"click"`: 基準を押すたびに開閉します。
271+
272+
`trigger` 付きのポップアップは `onOpenChange` の要求でも閉じます(自分の
273+
`onOpenChange` も呼ばれます)。`trigger` は最初に決めたら切り替えないでください。
274+
275+
### ポップアップ内のホバー
276+
277+
ポップアップの外では、ホバーは `onHoverChange` を持つ一番内側のノードだけに届きます。
278+
`"auto"` / `"manual"` のポップアップの中では、DOM の `:hover` と同じく祖先の連なり
279+
全体に届きます。中のボタン・ポップアップ・基準・基準の祖先がすべてホバー中になるので、
280+
基準の `onHoverChange` で動かす `open={hover}` は、ポインターがメニュー上にある間
281+
開いたままになります。
282+
283+
### 普通の子要素との違い
284+
285+
- **位置**: 大きさは中身で決まり(子要素は縦に並びます)、親のレイアウトで場所を取りません。
286+
- **切り抜かれない**: ウィンドウの角丸や `overflow: 'hidden'` の祖先に切られません。
287+
- **重なり順**: 専用のパスで描かれ、全ウィンドウの上(`layer="top"`)、レイヤーシェルの
288+
バーやオーバーレイの下に来ます。入れ子のポップアップは外側のポップアップと一緒に描かれます。
289+
- **描画**: 普通の装飾ツリーなので、`style`・`boxShadow`・
290+
[ペイントシェーダー](./paint.md)・ラベル・画像・シグナルで動かす `opacity` が
291+
そのまま使えます。ポップアップ内の `<ShaderEffect/>` は子要素だけ描き、
292+
エフェクトは描きません。
293+
- **ウィンドウのアニメーション**: ウィンドウが変形を伴うアニメーション(開閉・最小化・
294+
拡縮)で描かれている間は、描かれず入力も受けません。ドラッグで動かすと一緒に動きます。
295+
- `open` は表示の切り替えだけです。閉じている間もレイアウトは保たれるので、
296+
開くのは軽い処理です。
297+
298+
### Rust
299+
300+
```rust
301+
Button::new().child(
302+
Popup::new()
303+
.mode(PopupMode::Auto)
304+
.trigger(PopupTrigger::hover(500.0, 200.0))
305+
.on_open_change(|open, reason| eprintln!("open: {open} ({reason:?})"))
306+
.child(Label::new("最大化")),
307+
)
308+
```
309+
310+
`Popup::new().open(signal)`・`.close_on_escape(false)`・`.on_interest_change(...)`・
311+
`.on_anchor_press(...)` は上の props に対応します。
312+
165313
---
166314

167315
## スタイルリファレンス

‎docs/i18n/ja/docusaurus-plugin-content-docs/current/configuration/default-config.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ Wine はウィンドウマネージャー発の状態変更を受け入れてウ
9292
各ウィンドウには、アプリアイコン・ウィンドウタイトル・最小化/最大化/閉じるボタンを
9393
備えたタイトルバーが付きます。ボーダーは**フォーカス時は金色**(`#d7ba7d`)、それ以外は
9494
グレー(`#4f5666`)で、角は丸められています。
95+
最小化・最大化ボタンにポインターを 0.5 秒置いたままにすると、その下にツールチップが
96+
出ます([`<Popup/>`](./components.md#popup))。
9597

9698
- **ターミナル**(kitty・ghostty)は、ソリッドなタイトルバーの代わりに半透明の
9799
*リキッドグラス*なブラー背景になります。

‎knowledges/ssd-paint.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,15 @@ Rust SDK: `view.rs` `paint_shaders()`.
6262
shadow, custom shader sees layout pixels at 1.25x).
6363
- `shoji_wm` evaluator test `embedded_runtime_sends_paint_shaders_and_patches_their_uniforms`.
6464
- `shojiwm_rs/tests/reactive_runtime.rs` (overlay uniform patch, box_shadow).
65+
- Checked by hand: a `boxShadow` reaching outside the window root survives
66+
the snapshot-based close / minimize animations.
6567

6668
## Not done yet
6769

6870
- Per-component post effects on the final image (render a subtree
6971
offscreen, then run an effect pipeline over it).
70-
- Paint for layer / popup surfaces (fancy popups).
71-
- Unverified: whether a `boxShadow` reaching outside the window root
72-
survives the snapshot-based close / minimize animations.
72+
- SSD layers not tied to a window (OSDs). SSD popups (tooltips that escape
73+
the window) are done: see `ssd-popup.md`.
7374

7475
## EffectContext frame shape
7576

0 commit comments

Comments
 (0)