1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//! Interaction half of ChoicePrompt (backlog 0515, wave-5 cycle 2):
//! the gate's signal bundle, the root key handler (movement, shortcut
//! letters, digits, Space, Enter, non-dismissable Esc refusal), the
//! focus-retreat machinery, and the hint row. `#[path]` sibling of
//! choice_prompt.rs — nothing here is public API.
//!
//! OWNER: CHOICE (0515).
use std::cell::Cell;
use std::rc::Rc;
use crate::base::{Point, Rgba};
use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::{Memo, Signal};
use crate::render::Style;
use crate::ui::{dyn_view, Element, EventCtx, Key, Mods, MouseKind, UiEvent, View, ViewId};
use super::parts::{clamp_row, hint_segments};
/// The gate's reactive state (all `Signal`s are Copy ids — the bundle
/// rides into closures by value). Lives in the MODAL scope.
#[derive(Copy, Clone)]
pub(crate) struct GateState {
/// Highlight row (options then Other).
pub highlight: Signal<usize>,
/// Multiple mode: per-option checked marks.
pub checks: Signal<Vec<bool>>,
/// Multiple mode: the Other row's checked mark.
pub other_on: Signal<bool>,
/// The Other draft (persists across reveals — charter O3).
pub other_text: Signal<String>,
/// Hollow-Other commit refusal note (charter O4).
pub needs_other: Signal<bool>,
/// Non-dismissable Esc refusal note (charter G3); cleared by the
/// next movement/toggle.
pub esc_note: Signal<bool>,
/// The Other editor holds focus (drives the layered-Esc hint truth
/// — wave-5 F4).
pub editing: Signal<bool>,
/// The options region holds focus (selection pair vs accent — A5).
pub region_focused: Signal<bool>,
}
/// The gate's static shape (Copy).
#[derive(Copy, Clone)]
pub(crate) struct GateDims {
pub n: usize,
pub rows_total: usize,
pub other_row: usize,
pub multiple: bool,
pub has_other: bool,
pub dismissable: bool,
pub windowed: bool,
}
/// FOCUS ANCHORS: unmounting a focused node drops the tree's focus to
/// None (mount.rs) and keys then target the PANEL root — the content
/// root's handlers would fall off the routing path (the 0230
/// dead-keys class). Every path that can unmount the focused Other
/// editor re-anchors focus FIRST: preferably on the options REGION
/// (its focus affordance is visible — A5), else on the content root
/// (programmatic focus needs no focusability, focus_init clause 3).
/// Ids are recorded by Capture-phase handlers (capture runs before
/// target/bubble, so an anchor is always fresh by the time a retreat
/// needs it).
#[derive(Clone, Default)]
pub(crate) struct Anchors {
pub region: Rc<Cell<Option<ViewId>>>,
pub root: Rc<Cell<Option<ViewId>>>,
}
impl Anchors {
/// Move focus back to the list (or the content root when the
/// region never routed an event — the mouse-only corner).
pub(crate) fn retreat(&self, ctx: &mut EventCtx) {
if let Some(a) = self.region.get().or_else(|| self.root.get()) {
ctx.request_focus(a);
}
}
}
/// Highlight movement. Clears the Esc-refusal note: the user acted.
pub(crate) fn move_row(s: GateState, d: GateDims, row: usize) {
s.esc_note.set(false);
s.highlight.set(clamp_row(row, d.rows_total));
}
/// Toggle a row's mark (multiple mode). Clears the Esc-refusal note.
pub(crate) fn toggle_row(s: GateState, d: GateDims, row: usize) {
s.esc_note.set(false);
if row < d.n {
s.checks.update(|c| {
if let Some(m) = c.get_mut(row) {
*m = !*m;
}
});
} else {
s.other_on.update(|b| *b = !*b);
}
}
/// The ONE root key handler (Bubble on the content root): movement,
/// declared shortcut letters, digit jumps, Space toggles, Enter
/// commits, wheel movement, and — non-dismissable gates only — the
/// visible Esc refusal (dismissable gates leave Esc to the advertised
/// `shortcut_labeled` cancel; a focused Other editor's Esc was already
/// consumed deeper by the retreat handler).
#[allow(clippy::too_many_arguments)]
pub(crate) fn root_key_handler(
s: GateState,
d: GateDims,
letters: Rc<Vec<(char, usize)>>,
try_commit: Rc<dyn Fn()>,
anchors: Anchors,
) -> impl FnMut(&mut EventCtx, &UiEvent) {
// Single-mode movement OFF the Other row unmounts the focused
// editor: re-anchor focus before the flush (see [`Anchors`]).
let retreat_guard = {
let anchors = anchors.clone();
move |ctx: &mut EventCtx, from: usize, to: usize| {
if !d.multiple && d.has_other && from == d.other_row && to != d.other_row {
anchors.retreat(ctx);
}
}
};
move |ctx: &mut EventCtx, ev: &UiEvent| match ev {
UiEvent::Key(k) => {
// The two wire spellings of a shifted letter fold to ONE
// canonical key before the gate (first-app 0288): kitty's
// Char('a')+SHIFT and legacy's Char('A') both mean the
// uppercase letter — matching runs on the normalized
// spelling, plain keys only. Shift on anything else
// (movement, Space, digits, symbols) still bounces here.
let k = k.normalized();
if k.mods != Mods::NONE {
return;
}
let h = s.highlight.get_untracked();
match k.key {
Key::Down => {
retreat_guard(ctx, h, clamp_row(h + 1, d.rows_total));
move_row(s, d, h + 1);
}
Key::Up => {
retreat_guard(ctx, h, h.saturating_sub(1));
move_row(s, d, h.saturating_sub(1));
}
Key::Home => {
retreat_guard(ctx, h, 0);
move_row(s, d, 0);
}
Key::End => move_row(s, d, d.rows_total.saturating_sub(1)),
Key::Enter => {
// Bookkeeping-first (0297): stop propagation BEFORE
// a commit that may dispose this tree.
ctx.stop_propagation();
try_commit();
return;
}
Key::Escape if !d.dismissable => {
// Must-choose mode: refuse VISIBLY (charter G3) —
// the hint row explains; nothing resolves.
s.esc_note.set(true);
}
Key::Char(c) if !c.is_control() => {
// Declared option letters first (case-sensitive —
// 'a' and 'A' are distinct keys; the approval
// consumer's vocabulary — the normalization above
// folds SPELLINGS, never case: Shift+A reads as
// 'A' on both wires and can never fire a declared
// 'a'). A letter is an EXPLICIT activation:
// select+commit in single mode, jump-toggle in
// multiple. A focused Other editor consumed
// printables before this handler (letters type,
// never activate).
if let Some(&(_, row)) = letters.iter().find(|(key, _)| *key == c) {
if d.multiple {
move_row(s, d, row);
toggle_row(s, d, row);
} else {
move_row(s, d, row);
ctx.stop_propagation();
try_commit();
return;
}
} else if c == ' ' && d.multiple {
toggle_row(s, d, h);
} else if let Some(digit) = c.to_digit(10).filter(|d| (1..=9).contains(d)) {
// Digit jump: single mode MOVES only (0250 —
// digits are movement; declared letters are
// the activation vocabulary); multiple mode
// jump-toggles (the mark IS the selection act).
let row = digit as usize - 1;
if row >= d.n {
return;
}
move_row(s, d, row);
if d.multiple {
toggle_row(s, d, row);
}
} else {
return;
}
}
_ => return,
}
ctx.stop_propagation();
}
UiEvent::Mouse(m) => match m.kind {
MouseKind::ScrollUp => {
let h = s.highlight.get_untracked();
retreat_guard(ctx, h, h.saturating_sub(1));
move_row(s, d, h.saturating_sub(1));
ctx.stop_propagation();
}
MouseKind::ScrollDown => {
let h = s.highlight.get_untracked();
retreat_guard(ctx, h, clamp_row(h + 1, d.rows_total));
move_row(s, d, h + 1);
ctx.stop_propagation();
}
_ => {}
},
_ => {}
}
}
/// Hint-row paint (Copy).
#[derive(Copy, Clone)]
pub(crate) struct HintPaint {
pub muted: Rgba,
pub accent: Rgba,
pub ground: Rgba,
}
/// The hint row: refusal notes first (hollow Other, then the
/// non-dismissable Esc refusal), the layered-Esc truth while the Other
/// editor is focused, else the verb segments (+ the windowed `i/N`
/// position, right-aligned so it survives narrow panels). `esc` = the
/// ready Esc segment ("Esc cancels" / "Esc <dismiss label>" — 0271),
/// `None` on must-choose gates.
#[allow(clippy::too_many_arguments)]
pub(crate) fn hint_row(
s: GateState,
d: GateDims,
paint: HintPaint,
engaged: Memo<bool>,
other_label: String,
keys: String,
esc: Option<String>,
) -> View {
dyn_view(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(1)),
move || {
let warn =
s.needs_other.get() && engaged.get() && s.other_text.with(|t| t.trim().is_empty());
let refused = !warn && s.esc_note.get();
let editing = s.editing.get();
let note = if warn {
Some(format!("{other_label} needs text — type your answer"))
} else if refused {
Some(String::from("an answer is required"))
} else {
None
};
// Windowed lists show the highlight position RIGHT-ALIGNED
// (the select family's "n of N"): it must survive even when
// the verb hints degrade on a narrow panel.
let h = s.highlight.get();
let pos =
(d.windowed && note.is_none() && h < d.n).then(|| format!("{}/{}", h + 1, d.n));
let ink = if note.is_some() {
paint.accent
} else {
paint.muted
};
let segs: Vec<String> = if editing {
// Layered Esc (wave-5 F4): while the editor is focused
// the truth is retreat, not cancel.
vec![
String::from("Enter confirms"),
String::from("Esc back to the list"),
]
} else {
hint_segments(d.multiple, esc.as_deref(), &keys)
};
let bg = paint.ground;
Element::new()
.style(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(1)),
)
.draw(move |canvas, rect| {
if rect.is_empty() {
return;
}
let style = Style::new().fg(ink).bg(bg);
let mut room = rect.w;
if let Some(pos) = &pos {
let pw = crate::text::width(pos);
if pw <= rect.w {
canvas.print_styled(Point::new(rect.right() - pw, rect.y), pos, &style);
room = rect.w - pw - 1;
}
}
let text = match ¬e {
Some(w) => crate::text::truncate_ellipsis(w, room.max(0)),
None => {
// Degrade by whole segments from the front;
// the last segment alone still ellipsizes
// on truly tiny panels.
let mut segs = segs.clone();
loop {
let joined = segs.join(" · ");
if crate::text::width(&joined) <= room || segs.len() == 1 {
break crate::text::truncate_ellipsis(&joined, room.max(0));
}
segs.remove(0);
}
}
};
canvas.print_styled(rect.origin(), &text, &style);
})
.build()
},
)
}