fresh-editor 0.4.0

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
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
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
//! Panel registry — maps plugin-allocated `panel_id` to mounted spec
//! and hit-area data for click routing.
//!
//! The registry is the source of truth for "which panels exist, what
//! spec are they currently rendering, and which buffer rows belong
//! to which widget." It does *not* own the virtual buffer the
//! rendered output goes into — the plugin still owns the virtual
//! buffer and passes its `BufferId` at mount time.

use crate::primitives::text_edit::TextEdit;
use fresh_core::api::WidgetSpec;
use fresh_core::BufferId;
use std::collections::{HashMap, HashSet};

/// Plugin-allocated panel identifier. Unique within a plugin; the
/// editor does not interpret the value.
pub type PanelId = u64;

/// One clickable rectangle within a rendered widget panel.
///
/// The renderer produces one `HitArea` per interactive widget node
/// (`Toggle`, `Button` in v1). Layout containers (`Row`, `Col`,
/// `Spacer`, `HintBar`, `Raw`) emit no hit areas of their own; their
/// children's hit areas bubble up with row/byte offsets adjusted to
/// reflect the final on-screen position.
///
/// Hit-test is `(buffer_row, buffer_col_byte) ∈ rectangle`; the byte
/// range is in UTF-8 bytes within the row's text, matching the
/// coordinate space `mouse_click` already delivers
/// (`HookArgs::MouseClick::buffer_col`).
#[derive(Debug, Clone)]
pub struct HitArea {
    /// Stable widget key from the spec, or empty when the spec did
    /// not assign one.
    pub widget_key: String,
    /// Widget kind discriminator: `"toggle"` or `"button"`.
    pub widget_kind: &'static str,
    /// 0-indexed row within the rendered virtual buffer.
    pub buffer_row: u32,
    /// First UTF-8 byte (inclusive) within the row's text.
    pub byte_start: usize,
    /// Last UTF-8 byte (exclusive) within the row's text.
    pub byte_end: usize,
    /// Event payload to deliver with the `widget_event` hook.
    /// For `"toggle"`: `{ "checked": <new value> }`. For
    /// `"button"`: `{}`.
    pub payload: serde_json::Value,
    /// Event type to deliver with the `widget_event` hook
    /// (`"toggle"` or `"activate"`).
    pub event_type: &'static str,
}

/// Widget instance state retained across spec updates, keyed by
/// the widget's stable `key`. This is the "Spec/instance separation"
/// described in §6 of the design doc — a plugin can rebuild its
/// `WidgetSpec` from scratch on every model change without losing
/// scroll offset, cursor position, expanded keys, or focus, because
/// stateful widgets look up their instance state by `key`.
#[derive(Debug, Clone, Default)]
pub enum WidgetInstanceState {
    /// Empty/placeholder — never persisted, used as a default.
    #[default]
    None,
    /// `List` instance state: host-owned scroll offset *and*
    /// selected index. `selected_index` becomes authoritative
    /// after first render — same correctness reasoning as
    /// `TextInput`'s host-owned value (host can mutate it via
    /// `WidgetCommand::SelectMove` without racing the plugin's
    /// spec round-trip).
    List {
        scroll_offset: u32,
        selected_index: i32,
        /// Rows each item occupies in the last render: 1 for a classic
        /// one-row-per-item list, or the uniform card height for an
        /// `item_specs` (card) list. The renderer writes it; mouse
        /// handlers read it to convert the row-denominated `visible_rows`
        /// into a per-item scroll window (so wheel/scrollbar bounds are
        /// right for card lists, and an un-scrollable list still lets the
        /// wheel bubble to an enclosing scrollable pane).
        item_height: u32,
        /// True once the user has scrolled the list by mouse (wheel or
        /// scrollbar) without moving the selection. While set, the
        /// renderer respects `scroll_offset` as-is instead of snapping
        /// it back to keep `selected_index` in view — so a mouse scroll
        /// can push the selected card off-screen. Cleared whenever the
        /// selection itself moves (keyboard nav, click, or a plugin
        /// `SetSelectedIndex`), which re-arms scroll-follows-selection.
        user_scrolled: bool,
    },
    /// `Text` instance state: host-owned `TextEdit` (value + cursor
    /// row/col + selection anchor + multiline flag), plus a viewport
    /// scroll offset that's only meaningful for multi-line
    /// (`rows > 1`) variants — the row index of the first visible
    /// line. Single-line text widgets always render from value
    /// byte 0 and rely on render-time head-truncate scrolling, so
    /// they leave `scroll` at `0`.
    ///
    /// Becomes authoritative once the widget mounts; the spec's
    /// `value` / `cursor_byte` are *initial-only* (used at first
    /// render and ignored thereafter). This guarantees correctness
    /// under concurrent keystrokes — the plugin's spec round-trip
    /// can't race against multiple in-flight `WidgetCommand`
    /// mutations because the host doesn't read from the spec for
    /// value at all once instance state exists.
    ///
    /// Switching from a naive `(String, u32)` to `TextEdit` is what
    /// gives the widget framework selection support, word
    /// navigation, and clipboard ops "for free" — every keybinding
    /// the legacy Settings UI accepted via `TextEdit` now applies
    /// to widget-backed text inputs too.
    Text {
        editor: TextEdit,
        scroll: u32,
        /// Completion popup candidates the plugin most recently
        /// pushed via `WidgetMutation::SetCompletions`. Empty =
        /// popup closed. The list is stored host-side rather
        /// than read from each `WidgetSpec` so the host can
        /// keep painting the popup across renders that don't
        /// re-push it, and so `Up`/`Down` selection survives a
        /// spec refresh.
        completions: Vec<fresh_core::api::CompletionItem>,
        /// Host-managed selection cursor into `completions`.
        /// Reset to 0 every time `SetCompletions` runs with a
        /// non-empty list; clamped on every render in case the
        /// list shrank.
        completion_selected_index: usize,
        /// Index of the first candidate row currently painted.
        /// Up/Down adjusts this implicitly (the renderer auto-
        /// scrolls to keep selection in view); the mouse wheel
        /// scrolls it directly without moving the selection.
        completion_scroll_offset: u32,
    },
    /// `Tree` instance state: host-owned scroll offset, selected
    /// index, and the set of expanded item keys. All three become
    /// authoritative after first render — the spec's
    /// `selected_index` / `expanded_keys` are seed values only.
    /// `expanded_keys` is a `HashSet` because expansion is
    /// set-membership semantically (a key is either expanded or
    /// not); ordering doesn't matter and we hit-test on contains.
    Tree {
        scroll_offset: u32,
        selected_index: i32,
        expanded_keys: HashSet<String>,
    },
}

/// Per-panel state retained between renders. The reconciler will use
/// the previous spec to compute the minimum mutation when a future
/// `UpdateWidgetPanel` arrives.
#[derive(Debug, Clone)]
pub struct WidgetPanelState {
    /// The virtual buffer this panel renders into.
    pub buffer_id: BufferId,
    /// The currently-mounted spec.
    pub spec: WidgetSpec,
    /// Click rectangles for the rendered output, in declaration
    /// order. Hit-test scans linearly — the small N (one per
    /// interactive widget per panel) doesn't justify a spatial
    /// index.
    pub hits: Vec<HitArea>,
    /// Widget instance state by widget `key`. Survives re-renders —
    /// see `WidgetInstanceState` for what's stored.
    pub instance_states: HashMap<String, WidgetInstanceState>,
    /// Currently-focused widget key within this panel. Empty when
    /// the panel has no focusable widgets, or before the first
    /// render. Maintained by the renderer (clamps to a valid
    /// tabbable key on every render) and by `widget_focus_advance`
    /// (cycles through tabbables on Tab / Shift+Tab).
    pub focus_key: String,
    /// Tabbable widget keys collected from the most recent render,
    /// in declaration order. The Tab-cycle command finds the
    /// current `focus_key`'s position in this list and advances by
    /// the requested delta (with wraparound).
    pub tabbable: Vec<String>,
}

/// Global registry of mounted widget panels.
#[derive(Debug, Default)]
pub struct WidgetRegistry {
    panels: HashMap<PanelId, WidgetPanelState>,
}

impl WidgetRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    /// Mount or replace a panel. Returns the previous state if the
    /// panel was already mounted (the dispatcher may use this to
    /// detect re-mounts on the same id).
    ///
    /// The wide parameter list is the price of `WidgetPanelState`
    /// being public — every field is plainly named at the call
    /// site rather than buried inside an opaque builder. The
    /// dispatcher always populates them all from one `RenderOutput`,
    /// so the apparent verbosity stays at the boundary.
    #[allow(clippy::too_many_arguments)]
    pub fn mount(
        &mut self,
        panel_id: PanelId,
        buffer_id: BufferId,
        spec: WidgetSpec,
        hits: Vec<HitArea>,
        instance_states: HashMap<String, WidgetInstanceState>,
        focus_key: String,
        tabbable: Vec<String>,
    ) -> Option<WidgetPanelState> {
        self.panels.insert(
            panel_id,
            WidgetPanelState {
                buffer_id,
                spec,
                hits,
                instance_states,
                focus_key,
                tabbable,
            },
        )
    }

    /// Replace the spec and rendered metadata on an already-mounted
    /// panel. Returns `Ok(buffer_id)` to render into, or `Err(())`
    /// if no panel exists for that id (caller should drop the
    /// update — the plugin re-emitted after unmount). The unit
    /// error is sufficient: there's exactly one failure mode and
    /// no payload to attach.
    #[allow(clippy::result_unit_err)]
    #[allow(clippy::too_many_arguments)]
    pub fn update(
        &mut self,
        panel_id: PanelId,
        spec: WidgetSpec,
        hits: Vec<HitArea>,
        instance_states: HashMap<String, WidgetInstanceState>,
        focus_key: String,
        tabbable: Vec<String>,
    ) -> Result<BufferId, ()> {
        match self.panels.get_mut(&panel_id) {
            Some(state) => {
                state.spec = spec;
                state.hits = hits;
                state.instance_states = instance_states;
                state.focus_key = focus_key;
                state.tabbable = tabbable;
                Ok(state.buffer_id)
            }
            None => Err(()),
        }
    }

    /// Read-only access to the instance state for a panel — used by
    /// the dispatcher to thread previous scroll offsets / cursor
    /// positions into the next render so they persist.
    pub fn instance_states(
        &self,
        panel_id: PanelId,
    ) -> Option<&HashMap<String, WidgetInstanceState>> {
        self.panels.get(&panel_id).map(|s| &s.instance_states)
    }

    /// Read-only access to the previous render's focus key.
    pub fn focus_key(&self, panel_id: PanelId) -> Option<&str> {
        self.panels.get(&panel_id).map(|s| s.focus_key.as_str())
    }

    /// Set the focus key directly (used by `widget_focus_advance`
    /// and click-driven focus moves). Updates the in-place state;
    /// the next render reads it via `focus_key()`.
    pub fn set_focus_key(&mut self, panel_id: PanelId, key: String) {
        if let Some(state) = self.panels.get_mut(&panel_id) {
            state.focus_key = key;
        }
    }

    /// Host-driven scroll of a `List` widget (e.g. a scrollbar drag).
    /// Sets the list's `scroll_offset` and, when the list has a live
    /// selection, clamps `selected_index` into the new visible window
    /// `[scroll, scroll + visible)` so the next render's
    /// ensure-selected-visible doesn't snap the thumb back.
    ///
    /// Returns the post-clamp `selected_index` when the list has a
    /// selection that moved (so the caller can notify the plugin to
    /// keep its own selection mirror + preview in sync), else `None`.
    pub fn set_list_scroll(
        &mut self,
        panel_id: PanelId,
        list_key: &str,
        scroll_offset: u32,
        visible: u32,
    ) -> Option<i32> {
        let _ = visible;
        let state = self.panels.get_mut(&panel_id)?;
        let WidgetInstanceState::List {
            scroll_offset: so,
            user_scrolled,
            ..
        } = state.instance_states.get_mut(list_key)?
        else {
            return None;
        };
        // Mouse scroll moves the *view* only — the selection stays put
        // (and may scroll out of view). `user_scrolled` tells the
        // renderer not to snap the offset back to the selection. Never
        // returns a moved selection, so no `select`/live-switch fires.
        *so = scroll_offset;
        *user_scrolled = true;
        None
    }

    /// Update side-effects (hits, instance_states, focus_key, tabbable)
    /// without taking ownership of the spec. Used by `rerender_widget_panel`
    /// after an in-place spec mutation: the spec in the registry is already
    /// current (mutation helpers like `append_tree_nodes_in_spec` mutate it
    /// in place), so cloning it back through `update()` just to write the
    /// same value would waste a 5 000-node deep clone for every IPC.
    pub fn update_side_effects(
        &mut self,
        panel_id: PanelId,
        hits: Vec<HitArea>,
        instance_states: HashMap<String, WidgetInstanceState>,
        focus_key: String,
        tabbable: Vec<String>,
    ) -> Result<BufferId, ()> {
        match self.panels.get_mut(&panel_id) {
            Some(state) => {
                state.hits = hits;
                state.instance_states = instance_states;
                state.focus_key = focus_key;
                state.tabbable = tabbable;
                Ok(state.buffer_id)
            }
            None => Err(()),
        }
    }

    /// Borrow the current spec + return the buffer id. Companion to
    /// `update_side_effects` — render with the borrow and then write
    /// back only the side-effects, avoiding the deep clone of the spec
    /// that `buffer_and_spec()` does.
    pub fn buffer_and_spec_ref(&self, panel_id: PanelId) -> Option<(BufferId, &WidgetSpec)> {
        self.panels.get(&panel_id).map(|s| (s.buffer_id, &s.spec))
    }

    /// Find the buffer and current spec for a panel — used by the
    /// dispatcher to re-render after a focus advance / activate
    /// command without the plugin needing to send an UpdateWidgetPanel.
    pub fn buffer_and_spec(&self, panel_id: PanelId) -> Option<(BufferId, WidgetSpec)> {
        self.panels
            .get(&panel_id)
            .map(|s| (s.buffer_id, s.spec.clone()))
    }

    /// Tear down a panel. Returns the buffer_id the panel was
    /// rendering into, so the caller can clear the buffer if it
    /// owns it.
    pub fn unmount(&mut self, panel_id: PanelId) -> Option<BufferId> {
        self.panels.remove(&panel_id).map(|s| s.buffer_id)
    }

    /// Read-only access to a panel's current state.
    pub fn get(&self, panel_id: PanelId) -> Option<&WidgetPanelState> {
        self.panels.get(&panel_id)
    }

    /// Mutable access — used by `WidgetCommand` handlers that
    /// update widget instance state (e.g. TextInput value/cursor)
    /// directly without round-tripping through the plugin.
    pub fn get_mut(&mut self, panel_id: PanelId) -> Option<&mut WidgetPanelState> {
        self.panels.get_mut(&panel_id)
    }

    /// All currently-mounted panel ids — useful for theme-change
    /// re-render passes (every panel re-renders against the new
    /// theme without plugin involvement).
    pub fn panel_ids(&self) -> Vec<PanelId> {
        self.panels.keys().copied().collect()
    }

    /// Panels rendering into `buffer_id`. Used by mouse-wheel
    /// routing to find which widget panel sits under the pointer.
    pub fn panels_for_buffer(&self, buffer_id: BufferId) -> Vec<PanelId> {
        self.panels
            .iter()
            .filter(|(_, s)| s.buffer_id == buffer_id)
            .map(|(pid, _)| *pid)
            .collect()
    }

    /// Hit-test the given buffer-local position against every
    /// currently-mounted panel rendering into `buffer_id`. Returns
    /// the matching panel id and a clone of the hit area on a hit,
    /// `None` otherwise.
    ///
    /// Linear scan: panel count is typically 1 per buffer; per-panel
    /// hit count is small (one per interactive widget). A spatial
    /// index would be over-engineering at this scale.
    pub fn hit_test(
        &self,
        buffer_id: BufferId,
        row: u32,
        col_byte: u32,
    ) -> Option<(PanelId, HitArea)> {
        for (pid, state) in &self.panels {
            if state.buffer_id != buffer_id {
                continue;
            }
            for hit in &state.hits {
                if hit.buffer_row == row
                    && (col_byte as usize) >= hit.byte_start
                    && (col_byte as usize) < hit.byte_end
                {
                    return Some((*pid, hit.clone()));
                }
            }
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn empty_spec() -> WidgetSpec {
        WidgetSpec::Col {
            children: vec![],
            key: None,
        }
    }

    fn make_hit(row: u32, byte_start: usize, byte_end: usize, key: &str) -> HitArea {
        HitArea {
            widget_key: key.into(),
            widget_kind: "button",
            buffer_row: row,
            byte_start,
            byte_end,
            payload: json!({}),
            event_type: "activate",
        }
    }

    #[test]
    fn hit_test_finds_widget_inside_range() {
        let mut reg = WidgetRegistry::new();
        reg.mount(
            42,
            BufferId(7),
            empty_spec(),
            vec![make_hit(0, 0, 5, "a"), make_hit(0, 7, 12, "b")],
            HashMap::new(),
            String::new(),
            Vec::new(),
        );
        let hit = reg.hit_test(BufferId(7), 0, 8).expect("inside b");
        assert_eq!(hit.0, 42);
        assert_eq!(hit.1.widget_key, "b");
    }

    #[test]
    fn hit_test_returns_none_when_outside_range() {
        let mut reg = WidgetRegistry::new();
        reg.mount(
            1,
            BufferId(0),
            empty_spec(),
            vec![make_hit(0, 0, 5, "a")],
            HashMap::new(),
            String::new(),
            Vec::new(),
        );
        assert!(
            reg.hit_test(BufferId(0), 0, 5).is_none(),
            "byte_end is exclusive"
        );
        assert!(reg.hit_test(BufferId(0), 0, 100).is_none());
        assert!(reg.hit_test(BufferId(0), 1, 0).is_none(), "wrong row");
        assert!(reg.hit_test(BufferId(99), 0, 0).is_none(), "wrong buffer");
    }

    fn mount_with_list(reg: &mut WidgetRegistry, scroll: u32, sel: i32) {
        let mut states = HashMap::new();
        states.insert(
            "lst".to_string(),
            WidgetInstanceState::List {
                scroll_offset: scroll,
                selected_index: sel,
                item_height: 1,
                user_scrolled: false,
            },
        );
        reg.mount(
            7,
            BufferId(0),
            empty_spec(),
            Vec::new(),
            states,
            String::new(),
            Vec::new(),
        );
    }

    fn list_state(reg: &WidgetRegistry) -> (u32, i32) {
        match reg.instance_states(7).unwrap().get("lst").unwrap() {
            WidgetInstanceState::List {
                scroll_offset,
                selected_index,
                ..
            } => (*scroll_offset, *selected_index),
            _ => panic!("not a list"),
        }
    }

    #[test]
    fn set_list_scroll_moves_view_only_not_selection() {
        // Mouse scroll moves the *view* and never the selection — even
        // when the selection (row 2) ends up above the dragged-to window
        // [10, 18). No move is reported, so no `select`/live-switch
        // fires; the selection is allowed to leave the visible range.
        let mut reg = WidgetRegistry::new();
        mount_with_list(&mut reg, 0, 2);
        let moved = reg.set_list_scroll(7, "lst", 10, 8);
        assert_eq!(moved, None);
        assert_eq!(list_state(&reg), (10, 2));
    }

    #[test]
    fn set_list_scroll_leaves_in_view_selection_untouched() {
        // Selection already inside the new window — offset updates,
        // selection stays, and no move is reported.
        let mut reg = WidgetRegistry::new();
        mount_with_list(&mut reg, 0, 12);
        let moved = reg.set_list_scroll(7, "lst", 10, 8); // window [10,18)
        assert_eq!(moved, None);
        assert_eq!(list_state(&reg), (10, 12));
    }

    #[test]
    fn set_list_scroll_ignores_selectionless_list() {
        // A display-only list (selected_index < 0) just scrolls; no
        // selection clamp, no reported move.
        let mut reg = WidgetRegistry::new();
        mount_with_list(&mut reg, 0, -1);
        let moved = reg.set_list_scroll(7, "lst", 5, 8);
        assert_eq!(moved, None);
        assert_eq!(list_state(&reg), (5, -1));
    }

    #[test]
    fn unmount_clears_hits() {
        let mut reg = WidgetRegistry::new();
        reg.mount(
            5,
            BufferId(2),
            empty_spec(),
            vec![make_hit(0, 0, 3, "x")],
            HashMap::new(),
            String::new(),
            Vec::new(),
        );
        assert!(reg.hit_test(BufferId(2), 0, 1).is_some());
        reg.unmount(5);
        assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
    }

    #[test]
    fn update_replaces_hits() {
        let mut reg = WidgetRegistry::new();
        reg.mount(
            5,
            BufferId(2),
            empty_spec(),
            vec![make_hit(0, 0, 3, "old")],
            HashMap::new(),
            String::new(),
            Vec::new(),
        );
        reg.update(
            5,
            empty_spec(),
            vec![make_hit(1, 4, 9, "new")],
            HashMap::new(),
            String::new(),
            Vec::new(),
        )
        .expect("mounted");
        // Old hit gone; new hit visible.
        assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
        let hit = reg.hit_test(BufferId(2), 1, 5).unwrap();
        assert_eq!(hit.1.widget_key, "new");
    }
}