Skip to main content

fresh/app/
widget_runtime.rs

1//! Shared widget / floating-panel runtime methods on `Editor`.
2//!
3//! This module holds the editor-side widget runtime that backs both the
4//! plugin widget API and the built-in UI. It is intentionally NOT gated
5//! behind the `plugins` feature: these methods are invoked from non-plugin
6//! input/mouse/lifecycle code and must compile in plugin-less builds.
7//!
8//! The plugin-only command dispatch (`handle_plugin_command` and the
9//! per-command handlers reachable only from it) lives in the
10//! `plugins`-gated `plugin_dispatch` / `plugin_commands` modules.
11
12use crate::model::event::{BufferId, LeafId, SplitId};
13
14use super::Editor;
15
16/// Render a floating panel's spec, choosing the marker-gutter
17/// renderer when the panel opted into the `▸ ` focus marker (the
18/// Orchestrator New Session form) and the plain renderer otherwise.
19/// Centralised so the mount / update / rerender paths can't drift on
20/// which renderer a given panel uses. Lives here (not in the
21/// `plugins`-gated `plugin_dispatch`) so the non-plugin rerender path
22/// can call it in plugin-less builds.
23pub(super) fn render_floating_spec(
24    focus_marker: bool,
25    spec: &fresh_core::api::WidgetSpec,
26    prev: &std::collections::HashMap<String, crate::widgets::WidgetInstanceState>,
27    prev_focus_key: &str,
28    panel_width: u32,
29) -> crate::widgets::RenderOutput {
30    if focus_marker {
31        crate::widgets::render_spec_with_marker(spec, prev, prev_focus_key, panel_width)
32    } else {
33        crate::widgets::render_spec(spec, prev, prev_focus_key, panel_width)
34    }
35}
36
37/// Walk a `Tree`'s flat `nodes` and return the absolute indices of
38/// nodes that are currently visible — i.e. every ancestor is in
39/// `expanded`. Mirrors the renderer's filter so dispatcher and
40/// renderer agree on what's selectable.
41/// First `Tree` or `List` widget key in `spec`, scanning in
42/// declaration order. Used by mouse-wheel routing to pick which
43/// widget inside a panel absorbs the scroll.
44fn find_scrollable_widget_key(spec: &fresh_core::api::WidgetSpec) -> Option<String> {
45    use fresh_core::api::WidgetSpec;
46    match spec {
47        WidgetSpec::Tree { key: Some(k), .. } | WidgetSpec::List { key: Some(k), .. }
48            if !k.is_empty() =>
49        {
50            return Some(k.clone());
51        }
52        _ => {}
53    }
54    spec.children().find_map(find_scrollable_widget_key)
55}
56
57fn collect_visible_tree_indices(
58    nodes: &[fresh_core::api::TreeNode],
59    item_keys: &[String],
60    expanded: &std::collections::HashSet<String>,
61) -> Vec<usize> {
62    let mut ancestor_open: Vec<bool> = Vec::new();
63    let mut visible: Vec<usize> = Vec::with_capacity(nodes.len());
64    for (i, node) in nodes.iter().enumerate() {
65        let depth = node.depth as usize;
66        ancestor_open.truncate(depth);
67        if ancestor_open.iter().all(|open| *open) {
68            visible.push(i);
69        }
70        let key = item_keys.get(i).cloned().unwrap_or_default();
71        let is_open = if node.has_children {
72            !key.is_empty() && expanded.contains(&key)
73        } else {
74            true
75        };
76        ancestor_open.push(is_open);
77    }
78    visible
79}
80
81/// Translate the plugin-facing animation description to the internal
82/// `AnimationKind` the runner consumes.
83pub(super) fn translate_plugin_animation_kind(
84    kind: fresh_core::api::PluginAnimationKind,
85) -> crate::view::animation::AnimationKind {
86    use crate::view::animation::{AnimationKind, Edge};
87    use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
88    use std::time::Duration;
89    match kind {
90        PluginAnimationKind::SlideIn {
91            from,
92            duration_ms,
93            delay_ms,
94        } => AnimationKind::SlideIn {
95            from: match from {
96                PluginAnimationEdge::Top => Edge::Top,
97                PluginAnimationEdge::Bottom => Edge::Bottom,
98                PluginAnimationEdge::Left => Edge::Left,
99                PluginAnimationEdge::Right => Edge::Right,
100            },
101            duration: Duration::from_millis(duration_ms as u64),
102            delay: Duration::from_millis(delay_ms as u64),
103        },
104    }
105}
106
107impl Editor {
108    /// Process a resolved widget hit (from a TUI cell click or a native-frontend
109    /// click): move focus to the clicked widget, apply host-owned state changes
110    /// (tree expand / list selection) and fire the plugin's `widget_event`. This
111    /// is the single dispatch path shared by the buffer-cell click handler and
112    /// the web `/widget` route, so a click delivers identical behaviour in both.
113    pub(crate) fn deliver_widget_hit(
114        &mut self,
115        panel_key: &crate::widgets::PanelKey,
116        hit: &crate::widgets::HitArea,
117    ) {
118        // Click-to-focus: if the clicked widget has a stable, tabbable key, move
119        // focus there before firing the event so the next render reflects it.
120        if !hit.widget_key.is_empty() {
121            let is_tabbable = self
122                .widget_registry
123                .get(panel_key)
124                .map(|p| p.tabbable.iter().any(|k| k == &hit.widget_key))
125                .unwrap_or(false);
126            if is_tabbable {
127                self.set_panel_focus_and_notify(panel_key, hit.widget_key.clone());
128            }
129            self.rerender_widget_panel(panel_key);
130        }
131        // Tree disclosure click: the host owns expansion state, so toggle it
132        // (the toggle handler fires its own `expand` event with the post-toggle
133        // state). Tree row-body (`select`) and other kinds fall through.
134        let mut handled_specially = false;
135        if hit.widget_kind == "tree" && hit.event_type == "expand" {
136            if let Some(item_key) = hit.payload.get("key").and_then(|v| v.as_str()) {
137                self.handle_widget_tree_expand_toggle(panel_key, &hit.widget_key, item_key);
138                handled_specially = true;
139            }
140        }
141        // List row click: the host owns the List's selected index; a click only
142        // yields a `select` hit, so sync the selection (and repaint) then fall
143        // through to fire `select` with the List's *spec* key (per-item key stays
144        // in payload) — identical to keyboard nav.
145        let mut event_widget_key = hit.widget_key.clone();
146        if hit.widget_kind == "list" && hit.event_type == "select" {
147            if let Some(list_key) = hit.payload.get("list_key").and_then(|v| v.as_str()) {
148                event_widget_key = list_key.to_string();
149                if let Some(idx) = hit.payload.get("index").and_then(|v| v.as_i64()) {
150                    self.set_widget_list_selected_index(panel_key, list_key, idx as i32);
151                }
152            }
153        }
154        if !handled_specially {
155            self.fire_widget_event(
156                panel_key,
157                event_widget_key,
158                hit.event_type.to_string(),
159                hit.payload.clone(),
160            );
161        }
162    }
163
164    /// Native-frontend entry point: deliver the hit at `hit_index` in panel
165    /// `(plugin, panel_id)`'s recorded hit list — the same hits `widgets_view`
166    /// shipped to the frontend. Runs the shared `deliver_widget_hit` path.
167    pub fn deliver_widget_hit_by_index(&mut self, plugin: &str, panel_id: u64, hit_index: usize) {
168        let panel_key = crate::widgets::PanelKey::new(plugin, panel_id);
169        let hit = self
170            .widget_registry
171            .get(&panel_key)
172            .and_then(|p| p.hits.get(hit_index).cloned());
173        if let Some(hit) = hit {
174            self.deliver_widget_hit(&panel_key, &hit);
175        }
176    }
177
178    /// Deliver a `widget_event` hook to the plugin owning `panel_key` —
179    /// and to that plugin only. Panel ids are plugin-local, so the event
180    /// carries the bare id; no other plugin ever sees it.
181    pub(crate) fn fire_widget_event(
182        &self,
183        panel_key: &crate::widgets::PanelKey,
184        widget_key: String,
185        event_type: String,
186        payload: serde_json::Value,
187    ) {
188        let pm = self.plugin_manager.read().unwrap();
189        if !pm.has_hook_handlers("widget_event") {
190            return;
191        }
192        pm.run_hook_for_plugin(
193            &panel_key.plugin,
194            "widget_event",
195            fresh_core::hooks::HookArgs::WidgetEvent {
196                panel_id: panel_key.id,
197                widget_key,
198                event_type,
199                payload,
200            },
201        );
202    }
203
204    /// Apply a `RenderOutput`'s focus-cursor position to the panel
205    /// buffer + every split rendering it. When a `TextInput` is
206    /// focused, the dispatcher flips `show_cursors=true` and moves
207    /// the primary cursor to the right byte. When no TextInput is
208    /// focused, the cursor is hidden (`show_cursors=false`) — the
209    /// focused widget's own bg overlay shows where focus is.
210    ///
211    /// Must be called *after* `set_virtual_buffer_content` so the
212    /// buffer's text matches the row/byte coordinates the renderer
213    /// produced.
214    pub(super) fn apply_widget_focus_cursor(
215        &mut self,
216        buffer_id: BufferId,
217        entries: &[fresh_core::text_property::TextPropertyEntry],
218        focus_cursor: Option<crate::widgets::FocusCursor>,
219    ) {
220        // If the plugin has taken explicit control of this buffer's cursor
221        // (via `setBufferShowCursors`), the widget runtime must not touch
222        // its visibility or position — the plugin owns it. This lets a
223        // widget-panel pane be cursor-driven (e.g. git log's commit list)
224        // without each repaint clearing the cursor.
225        let locked = self
226            .windows
227            .get(&self.active_window)
228            .and_then(|w| w.buffers.get(&buffer_id))
229            .map(|s| s.cursor_visibility_locked)
230            .unwrap_or(false);
231        if locked {
232            return;
233        }
234
235        let absolute_byte = focus_cursor.map(|fc| {
236            let row = fc.buffer_row as usize;
237            let prefix: usize = entries.iter().take(row).map(|e| e.text.len()).sum();
238            prefix + fc.byte_in_row as usize
239        });
240
241        if let Some(state) = self
242            .windows
243            .get_mut(&self.active_window)
244            .map(|w| &mut w.buffers)
245            .expect("active window present")
246            .get_mut(&buffer_id)
247        {
248            state.show_cursors = absolute_byte.is_some();
249        }
250
251        if let Some(byte) = absolute_byte {
252            for vs in self
253                .windows
254                .get_mut(&self.active_window)
255                .and_then(|w| w.split_view_states_mut())
256                .expect("active window must have a populated split layout")
257                .values_mut()
258            {
259                if vs.buffer_state(buffer_id).is_some() {
260                    let cursor = vs.cursors.primary_mut();
261                    cursor.position = byte;
262                }
263            }
264        }
265    }
266
267    /// Best-effort width for a buffer's containing split. Returns
268    /// the most recent `SplitViewState::viewport.width` for any
269    /// split rendering this buffer; falls back to terminal width
270    /// when the buffer hasn't been rendered yet (e.g. mid-mount).
271    /// Subtracts 2 columns to account for gutter/scrollbar/border
272    /// padding the renderer adds — leaving the right edge clear
273    /// instead of pushing content into the chrome. This is what
274    /// flex `Spacer`s inside `Row` use to size their fill.
275    pub(super) fn widget_panel_width(&self, buffer_id: BufferId) -> u32 {
276        let raw = self
277            .windows
278            .get(&self.active_window)
279            .and_then(|w| w.buffers.splits())
280            .map(|(_, vs)| vs)
281            .expect("active window must have a populated split layout")
282            .values()
283            .find(|vs| vs.buffer_state(buffer_id).is_some() && vs.viewport.width > 0)
284            .map(|vs| vs.viewport.width as u32)
285            .unwrap_or_else(|| self.terminal_width.max(1) as u32);
286        // Reserve 2 cols for gutter/scrollbar/border. Saturate to
287        // avoid 0 width on tiny panels.
288        raw.saturating_sub(2).max(10)
289    }
290
291    /// Re-render an existing widget panel after an in-host state
292    /// change (focus advance, scroll move, etc.) without the plugin
293    /// re-emitting the spec. Reads the panel's current spec from
294    /// the registry, runs `render_spec` against the (possibly
295    /// updated) prev state / focus key, writes the result back.
296    pub(super) fn rerender_widget_panel(&mut self, panel_key: &crate::widgets::PanelKey) {
297        // The spec already lives in the registry — mutations (e.g.
298        // `append_tree_nodes_in_spec`) edit it in place. Borrow it for
299        // render, then write back only the side-effects (hits, instance
300        // states, focus key, tabbable). The previous shape cloned the
301        // whole spec out, rendered, then moved it back — for a Tree
302        // with 5 000 nodes that's a multi-MB deep clone per IPC, which
303        // dominates the host's per-mutation cost during a streaming
304        // search.
305        let (buffer_id, _is_floating, panel_width, out_pieces) = {
306            let (buffer_id, spec) = match self.widget_registry.buffer_and_spec_ref(panel_key) {
307                Some(s) => s,
308                None => return,
309            };
310            let prev = self
311                .widget_registry
312                .instance_states(panel_key)
313                .cloned()
314                .unwrap_or_default();
315            let prev_focus = self
316                .widget_registry
317                .focus_key(panel_key)
318                .map(|s| s.to_string())
319                .unwrap_or_default();
320            let panel_slot = Self::slot_for_panel_buffer(buffer_id);
321            let is_floating = panel_slot.is_some();
322            let panel_width = if let Some(slot) = panel_slot {
323                self.floating_panel_inner_width(slot)
324            } else {
325                self.widget_panel_width(buffer_id)
326            };
327            // Floating panels that opted into the focus-marker gutter
328            // (the Orchestrator New Session form) must re-render
329            // through the same marker renderer on every host-driven
330            // refresh — otherwise a Tab / focus advance would repaint
331            // the panel without the gutter and the layout would jump.
332            let focus_marker = panel_slot
333                .and_then(|slot| self.panel(slot))
334                .map(|f| f.focus_marker)
335                .unwrap_or(false);
336            let out = render_floating_spec(focus_marker, spec, &prev, &prev_focus, panel_width);
337            (buffer_id, is_floating, panel_width, out)
338        };
339        let _ = panel_width;
340        let panel_slot = Self::slot_for_panel_buffer(buffer_id);
341        let focus_cursor = out_pieces.focus_cursor;
342        let entries = out_pieces.entries;
343        let embeds = out_pieces.embeds;
344        let overlays = out_pieces.overlays;
345        let scroll_regions = out_pieces.scroll_regions;
346        if self
347            .widget_registry
348            .update_side_effects(
349                panel_key,
350                out_pieces.hits,
351                out_pieces.instance_states,
352                out_pieces.focus_key,
353                out_pieces.tabbable,
354            )
355            .is_err()
356        {
357            tracing::warn!("rerender_widget_panel({}) lost panel mid-call", panel_key);
358            return;
359        }
360        if let Some(slot) = panel_slot {
361            if let Some(fwp) = self.panel_mut(slot) {
362                if &fwp.panel_key == panel_key {
363                    fwp.entries = entries;
364                    fwp.focus_cursor = focus_cursor;
365                    fwp.embeds = embeds;
366                    fwp.overlays = overlays;
367                    fwp.scroll_regions = scroll_regions;
368                }
369            }
370            return;
371        }
372        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
373            tracing::error!("rerender_widget_panel({}) failed: {}", panel_key, e);
374        }
375        self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
376    }
377
378    pub(super) fn handle_widget_command(
379        &mut self,
380        panel_key: &crate::widgets::PanelKey,
381        action: fresh_core::api::WidgetAction,
382    ) {
383        use fresh_core::api::WidgetAction;
384        match action {
385            WidgetAction::FocusAdvance { delta } => {
386                self.handle_widget_focus_advance(panel_key, delta);
387            }
388            WidgetAction::Activate => {
389                self.handle_widget_activate(panel_key);
390            }
391            WidgetAction::SelectMove { delta } => {
392                self.handle_widget_select_move(panel_key, delta);
393            }
394            WidgetAction::TextInputKey { key } => {
395                self.handle_widget_text_key(panel_key, &key);
396            }
397            WidgetAction::TextInputChar { text } => {
398                self.handle_widget_text_char(panel_key, &text);
399            }
400            WidgetAction::Key { key } => {
401                self.handle_widget_key(panel_key, &key);
402            }
403        }
404    }
405
406    fn handle_widget_key(&mut self, panel_key: &crate::widgets::PanelKey, key: &str) {
407        // Smart key dispatch — route to the right specialized
408        // handler based on focused widget kind. See WidgetAction::Key
409        // doc for the dispatch table.
410        let panel = match self.widget_registry.get(panel_key) {
411            Some(p) => p,
412            None => return,
413        };
414        let focus_key = panel.focus_key.clone();
415        // Completion-popup short-circuit: when the focused Text
416        // widget has an open completion popup, intercept Tab /
417        // Up / Down / Enter / Esc so they drive the popup instead
418        // of falling through to the widget's default key
419        // behaviour. Tab fires `completion_accept`, Enter/Esc
420        // dismiss, Up/Down move the host-managed selection. Any
421        // other key (printable, Backspace, etc.) still goes to
422        // the text editor, which lets the user keep typing to
423        // refine the candidate list.
424        let completions_open = matches!(key, "Tab" | "Up" | "Down" | "Enter" | "Escape")
425            && self.focused_text_completions_open(panel_key);
426        if completions_open {
427            match key {
428                "Up" => {
429                    self.move_focused_text_completion_index(panel_key, -1);
430                    // Selection moved host-side; force a repaint
431                    // so the highlight + scroll-into-view shift
432                    // is visible without waiting for the next
433                    // unrelated mutation.
434                    self.rerender_widget_panel(panel_key);
435                    return;
436                }
437                "Down" => {
438                    self.move_focused_text_completion_index(panel_key, 1);
439                    self.rerender_widget_panel(panel_key);
440                    return;
441                }
442                "Escape" => {
443                    // First Esc only closes the popup — the form stays
444                    // open. (A second Esc, with no popup, cancels.)
445                    self.dismiss_focused_text_completions(panel_key);
446                    self.rerender_widget_panel(panel_key);
447                    return;
448                }
449                "Enter" => {
450                    if self.focused_text_completion_navigated(panel_key) {
451                        // The user stepped into the dropdown and Enter
452                        // accepts the highlighted candidate.
453                        self.fire_completion_accept(panel_key);
454                        return;
455                    }
456                    // Not navigated: the dropdown must not swallow the
457                    // submit. Close it, then fall through so Enter acts
458                    // on the form (advance / submit) below.
459                    self.dismiss_focused_text_completions(panel_key);
460                }
461                "Tab" => {
462                    if self.focused_text_completion_navigated(panel_key) {
463                        // The user stepped into the dropdown (↑/↓/wheel)
464                        // so a row is highlighted — Tab applies it and
465                        // closes the popup, just like Enter. Focus stays
466                        // on the field so the accepted value is visible
467                        // and editable (a second Tab then advances).
468                        self.fire_completion_accept(panel_key);
469                        return;
470                    }
471                    // Nothing highlighted (a freshly surfaced popup): Tab
472                    // commits the typed text and moves focus. Close the
473                    // popup, then fall through to the focus-advance
474                    // dispatch below.
475                    self.dismiss_focused_text_completions(panel_key);
476                }
477                _ => {}
478            }
479        }
480        // Re-fetch the focused widget for the main dispatch: the
481        // completion block above may have run `&mut self` (dismissing a
482        // popup), so we can't hold a borrow from before it. The spec is
483        // unchanged by a dismiss, so this resolves to the same widget.
484        let panel = match self.widget_registry.get(panel_key) {
485            Some(p) => p,
486            None => return,
487        };
488        let widget = if focus_key.is_empty() {
489            None
490        } else {
491            crate::widgets::find_widget_by_key(&panel.spec, &focus_key)
492        };
493        match key {
494            "Tab" => self.handle_widget_focus_advance(panel_key, 1),
495            "Shift+Tab" => self.handle_widget_focus_advance(panel_key, -1),
496            "Up" | "Down" => {
497                let delta = if key == "Up" { -1 } else { 1 };
498                match widget {
499                    Some(fresh_core::api::WidgetSpec::List { .. }) => {
500                        self.handle_widget_select_move(panel_key, delta);
501                    }
502                    Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
503                        self.handle_widget_tree_select_move(panel_key, delta);
504                    }
505                    Some(fresh_core::api::WidgetSpec::Text { rows, .. }) if *rows > 1 => {
506                        // Multi-line Text: line nav. Single-line
507                        // is filtered out — TextEdit::move_up /
508                        // move_down would no-op on the single
509                        // line, but skipping the dispatch keeps
510                        // the change-event quiet.
511                        self.handle_widget_text_key(panel_key, key);
512                    }
513                    _ => {
514                        // Picker-style nav: when the focused widget
515                        // doesn't have a meaningful Up/Down (single-
516                        // line Text, Button, Toggle, or no focus),
517                        // route the arrow to the first scrollable
518                        // widget in the panel. Lets a filter input
519                        // stay focused for typing while arrows
520                        // navigate the adjacent list.
521                        let scrollable = self
522                            .widget_registry
523                            .get(panel_key)
524                            .and_then(|p| find_scrollable_widget_key(&p.spec));
525                        if let Some(target_key) = scrollable {
526                            let target_kind = self.widget_registry.get(panel_key).and_then(|p| {
527                                crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
528                            });
529                            match target_kind {
530                                Some(fresh_core::api::WidgetSpec::List { .. }) => {
531                                    self.handle_widget_select_move_for_key(
532                                        panel_key,
533                                        &target_key,
534                                        delta,
535                                    );
536                                }
537                                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
538                                    self.handle_widget_tree_select_move_for_key(
539                                        panel_key,
540                                        &target_key,
541                                        delta,
542                                    );
543                                }
544                                _ => {}
545                            }
546                        }
547                    }
548                }
549            }
550            "PageUp" | "PageDown" => {
551                // Page step = visible_rows - 1 (one row of overlap so
552                // the user keeps a visual anchor across pages). Ignored
553                // for non-scrollable widgets.
554                let page = match widget {
555                    Some(fresh_core::api::WidgetSpec::List { visible_rows, .. })
556                    | Some(fresh_core::api::WidgetSpec::Tree { visible_rows, .. }) => {
557                        visible_rows.saturating_sub(1).max(1) as i32
558                    }
559                    _ => 0,
560                };
561                if page == 0 {
562                    return;
563                }
564                let delta = if key == "PageUp" { -page } else { page };
565                match widget {
566                    Some(fresh_core::api::WidgetSpec::List { .. }) => {
567                        self.handle_widget_select_move(panel_key, delta);
568                    }
569                    Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
570                        self.handle_widget_tree_select_move(panel_key, delta);
571                    }
572                    _ => {}
573                }
574            }
575            "Left" | "Right" => match widget {
576                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
577                    self.handle_widget_text_key(panel_key, key);
578                }
579                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
580                    self.handle_widget_tree_lateral(panel_key, key == "Right");
581                }
582                _ => {}
583            },
584            "Backspace" | "Delete" | "Home" | "End" => match widget {
585                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
586                    self.handle_widget_text_key(panel_key, key);
587                }
588                _ => {}
589            },
590            "Enter" => match widget {
591                Some(fresh_core::api::WidgetSpec::Button { .. })
592                | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
593                    self.handle_widget_activate(panel_key);
594                }
595                Some(fresh_core::api::WidgetSpec::List { .. }) => {
596                    self.fire_list_activate(panel_key, &focus_key);
597                }
598                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
599                    self.fire_tree_activate(panel_key, &focus_key);
600                }
601                Some(fresh_core::api::WidgetSpec::Text { rows, .. }) => {
602                    if *rows > 1 {
603                        // Multi-line: Enter inserts a newline at the
604                        // cursor. Plugins that want Enter to submit
605                        // can intercept it in their mode binding
606                        // before dispatching through the smart-key
607                        // router.
608                        self.handle_widget_text_key(panel_key, "Enter");
609                    } else if let Some(target_key) = self
610                        .widget_registry
611                        .get(panel_key)
612                        .and_then(|p| find_scrollable_widget_key(&p.spec))
613                    {
614                        // Picker-style activate: a single-line filter
615                        // input paired with a List/Tree fires that
616                        // scrollable's activate event on Enter, so the
617                        // user can type-then-Enter without tabbing
618                        // focus to the list.
619                        let kind = self.widget_registry.get(panel_key).and_then(|p| {
620                            crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
621                        });
622                        match kind {
623                            Some(fresh_core::api::WidgetSpec::List { .. }) => {
624                                self.fire_list_activate(panel_key, &target_key);
625                            }
626                            Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
627                                self.fire_tree_activate(panel_key, &target_key);
628                            }
629                            _ => {}
630                        }
631                    } else {
632                        // Form-like UX: Enter commits the field and
633                        // moves to the next tabbable widget.
634                        self.handle_widget_focus_advance(panel_key, 1);
635                    }
636                }
637                _ => {}
638            },
639            "Space" => match widget {
640                Some(fresh_core::api::WidgetSpec::Button { .. })
641                | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
642                    self.handle_widget_activate(panel_key);
643                }
644                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
645                    self.handle_widget_text_char(panel_key, " ");
646                }
647                Some(fresh_core::api::WidgetSpec::List { .. }) => {
648                    self.fire_list_activate(panel_key, &focus_key);
649                }
650                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
651                    // On a checkable Tree, Space is the conventional
652                    // checkbox key — fire `toggle` for the focused row
653                    // (matching what a click on its `[v]`/`[ ]` glyph
654                    // would do). Falls back to `activate` for trees
655                    // that aren't checkable, or rows that don't have
656                    // a checkbox glyph (`checked: None`).
657                    if !self.fire_tree_toggle_if_checkable(panel_key, &focus_key) {
658                        self.fire_tree_activate(panel_key, &focus_key);
659                    }
660                }
661                _ => {}
662            },
663            _ => {} // unrecognised key — quietly ignore
664        }
665    }
666
667    fn handle_widget_focus_advance(&mut self, panel_key: &crate::widgets::PanelKey, delta: i32) {
668        let panel = match self.widget_registry.get(panel_key) {
669            Some(p) => p,
670            None => return,
671        };
672        if panel.tabbable.is_empty() {
673            return;
674        }
675        let cur_idx = panel
676            .tabbable
677            .iter()
678            .position(|k| k == &panel.focus_key)
679            .unwrap_or(0) as i32;
680        let n = panel.tabbable.len() as i32;
681        let new_idx = ((cur_idx + delta) % n + n) % n;
682        let new_key = panel.tabbable[new_idx as usize].clone();
683        self.set_panel_focus_and_notify(panel_key, new_key);
684        self.rerender_widget_panel(panel_key);
685    }
686
687    /// Update the panel's focused widget AND fire a
688    /// `widget_event { event_type: "focus" }` so plugins can
689    /// react. Used by every host-driven focus move — key-driven
690    /// Tab / Shift-Tab / Enter focus-advance, click-driven
691    /// focus moves, etc. — so plugins never have to predict the
692    /// host's focus rules to keep a local mirror in sync.
693    ///
694    /// No-op when the key isn't actually changing (avoids
695    /// spurious events on every render that touches focus).
696    pub(crate) fn set_panel_focus_and_notify(
697        &mut self,
698        panel_key: &crate::widgets::PanelKey,
699        new_key: String,
700    ) {
701        let old_key = self
702            .widget_registry
703            .focus_key(panel_key)
704            .map(|s| s.to_string())
705            .unwrap_or_default();
706        if old_key == new_key {
707            tracing::debug!(
708                target: "fresh::dock",
709                panel = %panel_key,
710                key = %new_key,
711                "set_panel_focus_and_notify: no-op (old == new)"
712            );
713            return;
714        }
715        tracing::debug!(
716            target: "fresh::dock",
717            panel = %panel_key,
718            old = %old_key,
719            new = %new_key,
720            "set_panel_focus_and_notify: firing `focus` widget_event"
721        );
722        self.widget_registry
723            .set_focus_key(panel_key, new_key.clone());
724        self.fire_widget_event(
725            panel_key,
726            new_key,
727            "focus".to_string(),
728            serde_json::json!({ "previous": old_key }),
729        );
730    }
731
732    fn handle_widget_activate(&mut self, panel_key: &crate::widgets::PanelKey) {
733        // Fire `widget_event` based on the focused widget's kind.
734        // Button → "activate"; Toggle → "toggle" (with the
735        // computed-new payload); other kinds: no-op.
736        let panel = match self.widget_registry.get(panel_key) {
737            Some(p) => p,
738            None => return,
739        };
740        let focus_key = panel.focus_key.clone();
741        if focus_key.is_empty() {
742            return;
743        }
744        let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
745        let (event_type, payload) = match widget {
746            // Disabled buttons don't fire activate. The renderer
747            // already excludes them from the tab cycle and skips
748            // their hit area, so the only way `focus_key` could
749            // still point at a disabled button is a stale focus
750            // from before the disable transition — drop the event
751            // in that race.
752            Some(fresh_core::api::WidgetSpec::Button { disabled: true, .. }) => return,
753            Some(fresh_core::api::WidgetSpec::Button { .. }) => ("activate", serde_json::json!({})),
754            Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
755                ("toggle", serde_json::json!({ "checked": !checked }))
756            }
757            _ => return,
758        };
759        self.fire_widget_event(panel_key, focus_key, event_type.to_string(), payload);
760    }
761
762    /// Fire a `widget_event { event_type: "activate", payload: {
763    /// index, key } }` for the focused List, using its instance-state
764    /// selection (or spec selection on first render). The plugin's
765    /// activate handler does the actual user-visible thing — open
766    /// the matched file, expand/collapse a tree node, etc.
767    /// True when the focused widget on `panel_key` is a Text input
768    /// whose host-managed completion popup is currently open
769    /// (instance state has at least one candidate). Lets the
770    /// smart-key dispatcher route Tab/Enter/Up/Down/Esc to the
771    /// popup-specific paths before falling through to the
772    /// widget's default key behaviour.
773    fn focused_text_completions_open(&self, panel_key: &crate::widgets::PanelKey) -> bool {
774        let panel = match self.widget_registry.get(panel_key) {
775            Some(p) => p,
776            None => return false,
777        };
778        if panel.focus_key.is_empty() {
779            return false;
780        }
781        matches!(
782            panel.instance_states.get(&panel.focus_key),
783            Some(crate::widgets::WidgetInstanceState::Text { completions, .. })
784                if !completions.is_empty()
785        )
786    }
787
788    /// Has the user explicitly stepped into the focused Text widget's
789    /// open completion popup (via ↑/↓ / wheel)? Drives the Tab/Enter
790    /// dispatch: only a *navigated* popup accepts on Enter — a freshly
791    /// surfaced one lets Enter act on the form instead.
792    fn focused_text_completion_navigated(&self, panel_key: &crate::widgets::PanelKey) -> bool {
793        let panel = match self.widget_registry.get(panel_key) {
794            Some(p) => p,
795            None => return false,
796        };
797        if panel.focus_key.is_empty() {
798            return false;
799        }
800        matches!(
801            panel.instance_states.get(&panel.focus_key),
802            Some(crate::widgets::WidgetInstanceState::Text {
803                completions,
804                completion_navigated,
805                ..
806            }) if !completions.is_empty() && *completion_navigated
807        )
808    }
809
810    /// Move the selected-index cursor of the focused Text widget's
811    /// completion popup by `delta` (Up = -1, Down = +1). Clamps
812    /// at the ends rather than wrapping — Down past the last
813    /// candidate stays on the last candidate, Up past the first
814    /// stays on the first. Wraparound on a popup-style picker
815    /// reads as "I scrolled past the bottom and now I'm at the
816    /// top" which is jarring when the user is actively comparing
817    /// items they expect to be in monotonic positions. No-op
818    /// when the focused widget isn't a Text-with-open-
819    /// completions.
820    fn move_focused_text_completion_index(
821        &mut self,
822        panel_key: &crate::widgets::PanelKey,
823        delta: i32,
824    ) {
825        // First read the spec's visible-rows cap so we can pull
826        // scroll back into view if the new selection lands above
827        // the current scroll offset. (The renderer only does
828        // forward-pull — it would otherwise fight the mouse-
829        // wheel handler which deliberately diverges scroll from
830        // selection.)
831        let panel = match self.widget_registry.get(panel_key) {
832            Some(p) => p,
833            None => return,
834        };
835        let focus_key = panel.focus_key.clone();
836        if focus_key.is_empty() {
837            return;
838        }
839        let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
840            Some(fresh_core::api::WidgetSpec::Text {
841                completions_visible_rows,
842                ..
843            }) => *completions_visible_rows,
844            _ => 0,
845        };
846        let visible = if spec_visible_rows == 0 {
847            5u32
848        } else {
849            spec_visible_rows
850        };
851        let panel = match self.widget_registry.get_mut(panel_key) {
852            Some(p) => p,
853            None => return,
854        };
855        if let Some(crate::widgets::WidgetInstanceState::Text {
856            completions,
857            completion_selected_index,
858            completion_scroll_offset,
859            completion_navigated,
860            ..
861        }) = panel.instance_states.get_mut(&focus_key)
862        {
863            if completions.is_empty() {
864                return;
865            }
866            // The first ↑/↓ *enters* the dropdown: flip `navigated`
867            // and select the current (top) row without moving, so the
868            // user lands on a sensible candidate instead of skipping
869            // the first one. Subsequent presses move the selection.
870            if !*completion_navigated {
871                *completion_navigated = true;
872                return;
873            }
874            let max = (completions.len() - 1) as i32;
875            let cur = *completion_selected_index as i32;
876            let next = (cur + delta).clamp(0, max);
877            *completion_selected_index = next as usize;
878            // Keyboard-driven selection move: if the new
879            // selection sits above the current scroll window,
880            // pull the scroll back so the selection stays
881            // visible. Forward-pull is handled by the renderer.
882            let next_u = next as u32;
883            if next_u < *completion_scroll_offset {
884                *completion_scroll_offset = next_u;
885            } else if next_u >= *completion_scroll_offset + visible {
886                *completion_scroll_offset = next_u + 1 - visible;
887            }
888        }
889    }
890
891    /// Clear the focused Text widget's completion popup (close it)
892    /// and fire a `completion_dismiss` event so the plugin can
893    /// sync its own state (e.g. invalidate any in-flight fetch
894    /// token, so a late-arriving result doesn't re-open the
895    /// popup the user just closed). Used by Enter and Escape on
896    /// a Text-with-open-completions.
897    fn dismiss_focused_text_completions(&mut self, panel_key: &crate::widgets::PanelKey) {
898        let focus_key = {
899            let panel = match self.widget_registry.get_mut(panel_key) {
900                Some(p) => p,
901                None => return,
902            };
903            let focus_key = panel.focus_key.clone();
904            if focus_key.is_empty() {
905                return;
906            }
907            if let Some(crate::widgets::WidgetInstanceState::Text {
908                completions,
909                completion_selected_index,
910                ..
911            }) = panel.instance_states.get_mut(&focus_key)
912            {
913                if completions.is_empty() {
914                    return;
915                }
916                completions.clear();
917                *completion_selected_index = 0;
918            } else {
919                return;
920            }
921            focus_key
922        };
923        self.fire_widget_event(
924            panel_key,
925            focus_key,
926            "completion_dismiss".into(),
927            serde_json::json!({}),
928        );
929    }
930
931    /// Fire `completion_accept` on the focused Text widget's
932    /// currently-selected candidate. Used by Tab on a Text-with-
933    /// open-completions — the plugin's handler is expected to
934    /// apply the accepted value to the field (typically via
935    /// `WidgetMutation::SetValue`). The host does NOT close the
936    /// popup automatically: directory-descent style flows (the
937    /// orchestrator's Project Path acceptance of `/foo/` re-
938    /// fetches children for the new path) want the popup to
939    /// stay alive so the user can keep Tab-ing. Plugins that
940    /// want a one-shot accept close the popup themselves with
941    /// `setCompletions(key, [])`.
942    fn fire_completion_accept(&mut self, panel_key: &crate::widgets::PanelKey) {
943        let (focus_key, value) = {
944            let panel = match self.widget_registry.get(panel_key) {
945                Some(p) => p,
946                None => return,
947            };
948            let focus_key = panel.focus_key.clone();
949            if focus_key.is_empty() {
950                return;
951            }
952            match panel.instance_states.get(&focus_key) {
953                Some(crate::widgets::WidgetInstanceState::Text {
954                    completions,
955                    completion_selected_index,
956                    ..
957                }) if !completions.is_empty() => {
958                    let idx = (*completion_selected_index).min(completions.len() - 1);
959                    (focus_key, completions[idx].value.clone())
960                }
961                _ => return,
962            }
963        };
964        self.fire_widget_event(
965            panel_key,
966            focus_key,
967            "completion_accept".into(),
968            serde_json::json!({ "value": value }),
969        );
970    }
971
972    fn fire_list_activate(&mut self, panel_key: &crate::widgets::PanelKey, focus_key: &str) {
973        let panel = match self.widget_registry.get(panel_key) {
974            Some(p) => p,
975            None => return,
976        };
977        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
978        let (spec_sel, item_keys) = match widget {
979            Some(fresh_core::api::WidgetSpec::List {
980                selected_index,
981                item_keys,
982                ..
983            }) => (*selected_index, item_keys.clone()),
984            _ => return,
985        };
986        let sel = match panel.instance_states.get(focus_key) {
987            Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
988                *selected_index
989            }
990            _ => spec_sel,
991        };
992        if sel < 0 {
993            return;
994        }
995        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
996        self.fire_widget_event(
997            panel_key,
998            focus_key.to_string(),
999            "activate".into(),
1000            serde_json::json!({ "index": sel, "key": item_key, }),
1001        );
1002    }
1003
1004    fn handle_widget_select_move(&mut self, panel_key: &crate::widgets::PanelKey, delta: i32) {
1005        let focus_key = match self.widget_registry.get(panel_key) {
1006            Some(p) => p.focus_key.clone(),
1007            None => return,
1008        };
1009        if focus_key.is_empty() {
1010            return;
1011        }
1012        self.handle_widget_select_move_for_key(panel_key, &focus_key, delta);
1013    }
1014
1015    /// Set a `List` widget's selected index to an absolute item index,
1016    /// preserving its scroll offset, and repaint. Used by the click
1017    /// path: a row click only produces a `select` hit and — unlike
1018    /// keyboard nav via [`handle_widget_select_move_for_key`] — does
1019    /// not move the host-owned selection. Without this the highlight
1020    /// would not follow a click and a subsequent Up/Down would resume
1021    /// from the stale index.
1022    pub(super) fn set_widget_list_selected_index(
1023        &mut self,
1024        panel_key: &crate::widgets::PanelKey,
1025        widget_key: &str,
1026        index: i32,
1027    ) {
1028        if let Some(panel) = self.widget_registry.get_mut(panel_key) {
1029            let (prev_scroll, prev_item_height) = match panel.instance_states.get(widget_key) {
1030                Some(crate::widgets::WidgetInstanceState::List {
1031                    scroll_offset,
1032                    item_height,
1033                    ..
1034                }) => (*scroll_offset, *item_height),
1035                _ => (0, 1),
1036            };
1037            panel.instance_states.insert(
1038                widget_key.to_string(),
1039                crate::widgets::WidgetInstanceState::List {
1040                    scroll_offset: prev_scroll,
1041                    selected_index: index,
1042                    item_height: prev_item_height,
1043                    // A deliberate selection re-arms scroll-follows-selection.
1044                    user_scrolled: false,
1045                },
1046            );
1047        }
1048        self.rerender_widget_panel(panel_key);
1049    }
1050
1051    /// Same as [`handle_widget_select_move`] but targets an explicit
1052    /// `List` widget key instead of the panel's focused widget. Used
1053    /// by the picker-style smart-key dispatch — `Up`/`Down` on a
1054    /// focused filter input route to the first scrollable widget in
1055    /// the panel without changing focus.
1056    fn handle_widget_select_move_for_key(
1057        &mut self,
1058        panel_key: &crate::widgets::PanelKey,
1059        widget_key: &str,
1060        delta: i32,
1061    ) {
1062        let panel = match self.widget_registry.get(panel_key) {
1063            Some(p) => p,
1064            None => return,
1065        };
1066        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
1067        let (spec_sel, total, item_keys) = match widget {
1068            Some(fresh_core::api::WidgetSpec::List {
1069                selected_index,
1070                items,
1071                item_specs,
1072                item_keys,
1073                ..
1074            }) => {
1075                // Item count is in *items* (cards override the plain
1076                // `items` rows; see `WidgetSpec::List::item_specs`).
1077                let total = if item_specs.is_empty() {
1078                    items.len()
1079                } else {
1080                    item_specs.len()
1081                };
1082                (*selected_index, total as i32, item_keys.clone())
1083            }
1084            _ => return,
1085        };
1086        if total == 0 {
1087            return;
1088        }
1089        let cur_sel = match panel.instance_states.get(widget_key) {
1090            Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
1091                *selected_index
1092            }
1093            _ => spec_sel,
1094        };
1095        let raw = if cur_sel < 0 { 0 } else { cur_sel + delta };
1096        let new_sel = raw.clamp(0, total - 1);
1097        let new_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
1098        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
1099            let (cur_scroll, cur_item_height) = match panel_mut.instance_states.get(widget_key) {
1100                Some(crate::widgets::WidgetInstanceState::List {
1101                    scroll_offset,
1102                    item_height,
1103                    ..
1104                }) => (*scroll_offset, *item_height),
1105                _ => (0, 1),
1106            };
1107            panel_mut.instance_states.insert(
1108                widget_key.to_string(),
1109                crate::widgets::WidgetInstanceState::List {
1110                    scroll_offset: cur_scroll,
1111                    selected_index: new_sel,
1112                    item_height: cur_item_height,
1113                    // Keyboard nav re-arms scroll-follows-selection so the
1114                    // renderer brings the new selection back into view.
1115                    user_scrolled: false,
1116                },
1117            );
1118        }
1119        self.rerender_widget_panel(panel_key);
1120        // A clamped move at the list's top/bottom edge leaves the
1121        // selection where it was. Still re-render above (re-arming
1122        // `user_scrolled = false` snaps a scrolled-away view back to the
1123        // selection), but don't fire a `select` event for a no-op move:
1124        // holding ↑/↓ against the boundary would otherwise spam the
1125        // plugin with same-index selections — each one re-runs the
1126        // plugin's preview / live-switch work (in the Orchestrator dock
1127        // it schedules a redundant `scheduleDockSwitch`). Mirrors the
1128        // Tree handler's "No change → bail (don't fire spurious select)".
1129        if new_sel != cur_sel {
1130            self.fire_widget_event(
1131                panel_key,
1132                widget_key.to_string(),
1133                "select".into(),
1134                serde_json::json!({ "index": new_sel, "key": new_key }),
1135            );
1136        }
1137    }
1138
1139    /// Move the focused Tree's selection up/down, skipping
1140    /// descendants of collapsed nodes. Selection is the *absolute*
1141    /// `nodes` index; we walk the visible-flat order to find the
1142    /// neighbour. Mirrors the List handler shape but tree-aware.
1143    fn handle_widget_tree_select_move(&mut self, panel_key: &crate::widgets::PanelKey, delta: i32) {
1144        let focus_key = match self.widget_registry.get(panel_key) {
1145            Some(p) => p.focus_key.clone(),
1146            None => return,
1147        };
1148        if focus_key.is_empty() {
1149            return;
1150        }
1151        self.handle_widget_tree_select_move_for_key(panel_key, &focus_key, delta);
1152    }
1153
1154    /// Tree counterpart of [`handle_widget_select_move_for_key`].
1155    fn handle_widget_tree_select_move_for_key(
1156        &mut self,
1157        panel_key: &crate::widgets::PanelKey,
1158        widget_key: &str,
1159        delta: i32,
1160    ) {
1161        let panel = match self.widget_registry.get(panel_key) {
1162            Some(p) => p,
1163            None => return,
1164        };
1165        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
1166        let (spec_sel, nodes, item_keys) = match widget {
1167            Some(fresh_core::api::WidgetSpec::Tree {
1168                selected_index,
1169                nodes,
1170                item_keys,
1171                ..
1172            }) => (*selected_index, nodes.clone(), item_keys.clone()),
1173            _ => return,
1174        };
1175        if nodes.is_empty() {
1176            return;
1177        }
1178        let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
1179            Some(crate::widgets::WidgetInstanceState::Tree {
1180                selected_index,
1181                scroll_offset,
1182                expanded_keys,
1183            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
1184            _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
1185        };
1186        let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
1187        if visible_indices.is_empty() {
1188            return;
1189        }
1190        let cur_pos = if cur_sel < 0 {
1191            if delta > 0 {
1192                -1
1193            } else {
1194                visible_indices.len() as i32
1195            }
1196        } else {
1197            visible_indices
1198                .iter()
1199                .position(|&v| v as i32 == cur_sel)
1200                .map(|p| p as i32)
1201                .unwrap_or(-1)
1202        };
1203        let new_pos = (cur_pos + delta).clamp(0, (visible_indices.len() as i32) - 1);
1204        let new_abs = visible_indices[new_pos as usize];
1205        let new_key = item_keys.get(new_abs).cloned().unwrap_or_default();
1206        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
1207            panel_mut.instance_states.insert(
1208                widget_key.to_string(),
1209                crate::widgets::WidgetInstanceState::Tree {
1210                    scroll_offset: cur_scroll,
1211                    selected_index: new_abs as i32,
1212                    expanded_keys: expanded,
1213                },
1214            );
1215        }
1216        self.rerender_widget_panel(panel_key);
1217        self.fire_widget_event(
1218            panel_key,
1219            widget_key.to_string(),
1220            "select".into(),
1221            serde_json::json!({ "index": new_abs as i64, "key": new_key }),
1222        );
1223    }
1224
1225    /// Mouse-wheel scroll over a widget panel buffer. Finds the
1226    /// first `Tree`/`List` in any panel rendering into `buffer_id`
1227    /// and shifts its viewport by `delta` rows. Drags the selection
1228    /// to stay inside the new visible window so the renderer's
1229    /// auto-scroll doesn't snap the offset back. No focus change,
1230    /// no `widget_event` fires — wheel is viewport navigation, not
1231    /// selection.
1232    ///
1233    /// Returns `true` if any panel consumed the scroll.
1234    pub(super) fn handle_widget_panel_wheel(
1235        &mut self,
1236        buffer_id: crate::model::event::BufferId,
1237        delta: i32,
1238    ) -> bool {
1239        let panels = self.widget_registry.panels_for_buffer(buffer_id);
1240        let mut consumed = false;
1241        for panel_key in panels {
1242            // First chance: a focused Text widget with an open
1243            // completion popup absorbs the wheel — scrolling the
1244            // candidate list when the popup is what the user is
1245            // pointing at takes priority over scrolling a
1246            // sibling List/Tree elsewhere on the panel.
1247            if self.focused_text_completions_open(&panel_key) {
1248                self.scroll_focused_text_completions(&panel_key, delta);
1249                // The renderer reads `completion_scroll_offset`
1250                // out of the Text widget's instance state on
1251                // each paint, so flushing a rerender here is
1252                // what actually puts the new scroll on screen
1253                // — without this, the cached overlay rows on
1254                // the floating panel stay pinned to the old
1255                // offset until the user's next keystroke
1256                // happens to re-render for some other reason.
1257                self.rerender_widget_panel(&panel_key);
1258                consumed = true;
1259                continue;
1260            }
1261            let spec = match self.widget_registry.get(&panel_key) {
1262                Some(p) => p.spec.clone(),
1263                None => continue,
1264            };
1265            let Some(widget_key) = find_scrollable_widget_key(&spec) else {
1266                continue;
1267            };
1268            let widget = crate::widgets::find_widget_by_key(&spec, &widget_key);
1269            match widget {
1270                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
1271                    // Only claim the wheel if the widget actually scrolled.
1272                    // A List/Tree that declares `visible_rows >= total`
1273                    // (e.g. Git Log, which renders every row and relies on
1274                    // its scrollable region's buffer scroll instead) has
1275                    // nothing to scroll here; swallowing the event would
1276                    // leave the wheel dead. Falling through lets the
1277                    // underlying buffer scroll handle it.
1278                    consumed |= self.handle_widget_tree_wheel(&panel_key, &widget_key, delta);
1279                }
1280                Some(fresh_core::api::WidgetSpec::List { .. }) => {
1281                    consumed |= self.handle_widget_list_wheel(&panel_key, &widget_key, delta);
1282                }
1283                _ => {}
1284            }
1285        }
1286        consumed
1287    }
1288
1289    /// Shift the focused Text widget's completion popup scroll
1290    /// offset by `delta` rows. The renderer reads the visible-
1291    /// rows cap from the Text spec; we approximate it here as
1292    /// "5 if zero / unset" to mirror the renderer's default —
1293    /// the cap matters for clamping the max scroll so the
1294    /// thumb doesn't drift past the end.
1295    fn scroll_focused_text_completions(
1296        &mut self,
1297        panel_key: &crate::widgets::PanelKey,
1298        delta: i32,
1299    ) {
1300        let panel = match self.widget_registry.get(panel_key) {
1301            Some(p) => p,
1302            None => return,
1303        };
1304        let focus_key = panel.focus_key.clone();
1305        if focus_key.is_empty() {
1306            return;
1307        }
1308        let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
1309            Some(fresh_core::api::WidgetSpec::Text {
1310                completions_visible_rows,
1311                ..
1312            }) => *completions_visible_rows,
1313            _ => 0,
1314        };
1315        let visible = if spec_visible_rows == 0 {
1316            5u32
1317        } else {
1318            spec_visible_rows
1319        };
1320        let panel = match self.widget_registry.get_mut(panel_key) {
1321            Some(p) => p,
1322            None => return,
1323        };
1324        if let Some(crate::widgets::WidgetInstanceState::Text {
1325            completions,
1326            completion_scroll_offset,
1327            completion_navigated,
1328            ..
1329        }) = panel.instance_states.get_mut(&focus_key)
1330        {
1331            if completions.is_empty() {
1332                return;
1333            }
1334            // Scrolling the popup with the wheel counts as stepping
1335            // into it — Enter should then accept the highlighted row.
1336            *completion_navigated = true;
1337            let total = completions.len() as u32;
1338            let max_scroll = total.saturating_sub(visible.min(total));
1339            let next = (*completion_scroll_offset as i32 + delta).clamp(0, max_scroll as i32);
1340            *completion_scroll_offset = next as u32;
1341        }
1342    }
1343
1344    /// Shift a Tree's `scroll_offset` by `delta` rows. If the
1345    /// selection would fall outside the new viewport, drag it to
1346    /// the edge so the renderer's keep-selection-visible logic
1347    /// doesn't snap the offset back.
1348    fn handle_widget_tree_wheel(
1349        &mut self,
1350        panel_key: &crate::widgets::PanelKey,
1351        widget_key: &str,
1352        delta: i32,
1353    ) -> bool {
1354        let panel = match self.widget_registry.get(panel_key) {
1355            Some(p) => p,
1356            None => return false,
1357        };
1358        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
1359        let (visible_rows, nodes, item_keys) = match widget {
1360            Some(fresh_core::api::WidgetSpec::Tree {
1361                visible_rows,
1362                nodes,
1363                item_keys,
1364                ..
1365            }) => (*visible_rows, nodes.clone(), item_keys.clone()),
1366            _ => return false,
1367        };
1368        if nodes.is_empty() {
1369            return false;
1370        }
1371        let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
1372            Some(crate::widgets::WidgetInstanceState::Tree {
1373                selected_index,
1374                scroll_offset,
1375                expanded_keys,
1376            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
1377            _ => (-1, 0, std::collections::HashSet::<String>::new()),
1378        };
1379        let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
1380        if visible_indices.is_empty() {
1381            return false;
1382        }
1383        let visible = visible_rows.max(1);
1384        let total_visible = visible_indices.len() as u32;
1385        let max_scroll = total_visible.saturating_sub(visible);
1386        let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
1387        if new_scroll == cur_scroll {
1388            return false;
1389        }
1390        // Drag selection to stay inside the new viewport.
1391        let cur_pos: Option<u32> = if cur_sel >= 0 {
1392            visible_indices
1393                .iter()
1394                .position(|&v| v as i32 == cur_sel)
1395                .map(|p| p as u32)
1396        } else {
1397            None
1398        };
1399        let new_sel_abs = match cur_pos {
1400            Some(pos) if pos < new_scroll => visible_indices[new_scroll as usize] as i32,
1401            Some(pos) if pos >= new_scroll + visible => {
1402                visible_indices[(new_scroll + visible - 1) as usize] as i32
1403            }
1404            _ => cur_sel,
1405        };
1406        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
1407            panel_mut.instance_states.insert(
1408                widget_key.to_string(),
1409                crate::widgets::WidgetInstanceState::Tree {
1410                    scroll_offset: new_scroll,
1411                    selected_index: new_sel_abs,
1412                    expanded_keys: expanded,
1413                },
1414            );
1415        }
1416        self.rerender_widget_panel(panel_key);
1417        true
1418    }
1419
1420    /// List counterpart of `handle_widget_tree_wheel`. Returns true if the
1421    /// list's scroll offset actually changed (the wheel was consumed).
1422    fn handle_widget_list_wheel(
1423        &mut self,
1424        panel_key: &crate::widgets::PanelKey,
1425        widget_key: &str,
1426        delta: i32,
1427    ) -> bool {
1428        let panel = match self.widget_registry.get(panel_key) {
1429            Some(p) => p,
1430            None => return false,
1431        };
1432        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
1433        let (visible_rows, total) = match widget {
1434            Some(fresh_core::api::WidgetSpec::List {
1435                visible_rows,
1436                items,
1437                item_specs,
1438                ..
1439            }) => {
1440                let total = if item_specs.is_empty() {
1441                    items.len()
1442                } else {
1443                    item_specs.len()
1444                };
1445                (*visible_rows, total as u32)
1446            }
1447            _ => return false,
1448        };
1449        if total == 0 {
1450            return false;
1451        }
1452        let (cur_sel, cur_scroll, item_height) = match panel.instance_states.get(widget_key) {
1453            Some(crate::widgets::WidgetInstanceState::List {
1454                selected_index,
1455                scroll_offset,
1456                item_height,
1457                ..
1458            }) => (*selected_index, *scroll_offset, (*item_height).max(1)),
1459            _ => (-1, 0, 1),
1460        };
1461        // Convert the row-denominated viewport into a per-item window so
1462        // the bound is right for card lists (item_height > 1), and so a
1463        // list that already shows everything (max_scroll == 0, e.g. the
1464        // Git Log which sets visible_rows == commit count and scrolls via
1465        // its enclosing pane) reports "can't scroll" and lets the wheel
1466        // bubble to that pane rather than swallowing it.
1467        let visible_items = (visible_rows.max(1) / item_height).max(1);
1468        let max_scroll = total.saturating_sub(visible_items);
1469        let new_scroll = (cur_scroll as i64 + delta as i64).clamp(0, max_scroll as i64) as u32;
1470        if new_scroll == cur_scroll {
1471            return false;
1472        }
1473        // Wheel scrolls the *view* only — the selection stays put (and
1474        // may leave the visible window); `user_scrolled` tells the
1475        // renderer not to snap the offset back to it.
1476        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
1477            panel_mut.instance_states.insert(
1478                widget_key.to_string(),
1479                crate::widgets::WidgetInstanceState::List {
1480                    scroll_offset: new_scroll,
1481                    selected_index: cur_sel,
1482                    item_height,
1483                    user_scrolled: true,
1484                },
1485            );
1486        }
1487        self.rerender_widget_panel(panel_key);
1488        true
1489    }
1490
1491    /// Right/Left arrow on a focused Tree.
1492    ///
1493    /// * Right: if the selected node has children and is collapsed,
1494    ///   expand it. Else no-op.
1495    /// * Left: if the selected node has children and is expanded,
1496    ///   collapse it. Else move selection up to the parent.
1497    ///
1498    /// Both update host instance state, re-render, and (when a
1499    /// change happened) fire `widget_event { event_type: "expand" }`.
1500    fn handle_widget_tree_lateral(&mut self, panel_key: &crate::widgets::PanelKey, is_right: bool) {
1501        let panel = match self.widget_registry.get(panel_key) {
1502            Some(p) => p,
1503            None => return,
1504        };
1505        let focus_key = panel.focus_key.clone();
1506        if focus_key.is_empty() {
1507            return;
1508        }
1509        let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
1510        let (spec_sel, nodes, item_keys) = match widget {
1511            Some(fresh_core::api::WidgetSpec::Tree {
1512                selected_index,
1513                nodes,
1514                item_keys,
1515                ..
1516            }) => (*selected_index, nodes.clone(), item_keys.clone()),
1517            _ => return,
1518        };
1519        if nodes.is_empty() {
1520            return;
1521        }
1522        let (cur_sel, cur_scroll, mut expanded) = match panel.instance_states.get(&focus_key) {
1523            Some(crate::widgets::WidgetInstanceState::Tree {
1524                selected_index,
1525                scroll_offset,
1526                expanded_keys,
1527            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
1528            _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
1529        };
1530        if cur_sel < 0 {
1531            return;
1532        }
1533        let sel_idx = cur_sel as usize;
1534        let node = match nodes.get(sel_idx) {
1535            Some(n) => n,
1536            None => return,
1537        };
1538        let key = item_keys.get(sel_idx).cloned().unwrap_or_default();
1539        let was_expanded = !key.is_empty() && expanded.contains(&key);
1540
1541        let mut new_sel = cur_sel;
1542        let mut expansion_changed: Option<bool> = None; // Some(new_state)
1543        if is_right {
1544            if node.has_children && !was_expanded && !key.is_empty() {
1545                expanded.insert(key.clone());
1546                expansion_changed = Some(true);
1547            }
1548        } else if node.has_children && was_expanded && !key.is_empty() {
1549            expanded.remove(&key);
1550            expansion_changed = Some(false);
1551        } else if let Some(parent_idx) = crate::widgets::tree_parent_index(&nodes, sel_idx) {
1552            new_sel = parent_idx as i32;
1553        }
1554        // No change → bail (don't fire spurious select/expand).
1555        if expansion_changed.is_none() && new_sel == cur_sel {
1556            return;
1557        }
1558        let final_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
1559        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
1560            panel_mut.instance_states.insert(
1561                focus_key.clone(),
1562                crate::widgets::WidgetInstanceState::Tree {
1563                    scroll_offset: cur_scroll,
1564                    selected_index: new_sel,
1565                    expanded_keys: expanded,
1566                },
1567            );
1568        }
1569        self.rerender_widget_panel(panel_key);
1570        if let Some(now_expanded) = expansion_changed {
1571            self.fire_widget_event(
1572                panel_key,
1573                focus_key.clone(),
1574                "expand".into(),
1575                serde_json::json!({
1576                    "index": cur_sel as i64,
1577                    "key": key,
1578                    "expanded": now_expanded,
1579                }),
1580            );
1581        } else if new_sel != cur_sel {
1582            self.fire_widget_event(
1583                panel_key,
1584                focus_key,
1585                "select".into(),
1586                serde_json::json!({
1587                    "index": new_sel as i64,
1588                    "key": final_key,
1589                }),
1590            );
1591        }
1592    }
1593
1594    /// Toggle a Tree node's expansion state, re-render, and fire
1595    /// `widget_event { event_type: "expand" }`. Used by the click
1596    /// handler when the user clicks the disclosure column.
1597    pub(crate) fn handle_widget_tree_expand_toggle(
1598        &mut self,
1599        panel_key: &crate::widgets::PanelKey,
1600        widget_key: &str,
1601        item_key: &str,
1602    ) {
1603        if widget_key.is_empty() || item_key.is_empty() {
1604            return;
1605        }
1606        let now_expanded = {
1607            let panel = match self.widget_registry.get_mut(panel_key) {
1608                Some(p) => p,
1609                None => return,
1610            };
1611            let (cur_scroll, cur_sel, mut expanded) = match panel.instance_states.get(widget_key) {
1612                Some(crate::widgets::WidgetInstanceState::Tree {
1613                    scroll_offset,
1614                    selected_index,
1615                    expanded_keys,
1616                }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
1617                _ => (0u32, -1i32, std::collections::HashSet::<String>::new()),
1618            };
1619            let next = if expanded.contains(item_key) {
1620                expanded.remove(item_key);
1621                false
1622            } else {
1623                expanded.insert(item_key.to_string());
1624                true
1625            };
1626            panel.instance_states.insert(
1627                widget_key.to_string(),
1628                crate::widgets::WidgetInstanceState::Tree {
1629                    scroll_offset: cur_scroll,
1630                    selected_index: cur_sel,
1631                    expanded_keys: expanded,
1632                },
1633            );
1634            next
1635        };
1636        self.rerender_widget_panel(panel_key);
1637        self.fire_widget_event(
1638            panel_key,
1639            widget_key.to_string(),
1640            "expand".into(),
1641            serde_json::json!({ "key": item_key, "expanded": now_expanded, }),
1642        );
1643    }
1644
1645    /// Fire `widget_event { event_type: "activate" }` for the focused
1646    /// Tree's currently-selected node. Mirrors `fire_list_activate`
1647    /// — the plugin's handler decides what "activate" means
1648    /// (open the file, run an action, etc.).
1649    /// If the focused Tree row is checkable (parent tree has
1650    /// `checkable: true` *and* the row's `checked` is `Some(_)`),
1651    /// fire `widget_event { event_type: "toggle" }` with the
1652    /// inverted value and return `true`. Otherwise return `false`
1653    /// so the caller falls back to `activate`.
1654    ///
1655    /// Mirrors what a click on the row's `[v]`/`[ ]` glyph would
1656    /// do — Space is the conventional checkbox key, so on a
1657    /// checkable tree Space toggles instead of activating.
1658    fn fire_tree_toggle_if_checkable(
1659        &mut self,
1660        panel_key: &crate::widgets::PanelKey,
1661        focus_key: &str,
1662    ) -> bool {
1663        let panel = match self.widget_registry.get(panel_key) {
1664            Some(p) => p,
1665            None => return false,
1666        };
1667        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
1668        let (spec_sel, nodes, item_keys, checkable) = match widget {
1669            Some(fresh_core::api::WidgetSpec::Tree {
1670                selected_index,
1671                nodes,
1672                item_keys,
1673                checkable,
1674                ..
1675            }) => (*selected_index, nodes, item_keys.clone(), *checkable),
1676            _ => return false,
1677        };
1678        if !checkable {
1679            return false;
1680        }
1681        let sel = match panel.instance_states.get(focus_key) {
1682            Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
1683                *selected_index
1684            }
1685            _ => spec_sel,
1686        };
1687        if sel < 0 {
1688            return false;
1689        }
1690        let cur_checked = match nodes.get(sel as usize).and_then(|n| n.checked) {
1691            Some(b) => b,
1692            None => return false, // No checkbox glyph on this row — let activate fire.
1693        };
1694        let new_checked = !cur_checked;
1695        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
1696        self.fire_widget_event(
1697            panel_key,
1698            focus_key.to_string(),
1699            "toggle".into(),
1700            serde_json::json!({ "index": sel, "key": item_key, "checked": new_checked, }),
1701        );
1702        true
1703    }
1704
1705    fn fire_tree_activate(&mut self, panel_key: &crate::widgets::PanelKey, focus_key: &str) {
1706        let panel = match self.widget_registry.get(panel_key) {
1707            Some(p) => p,
1708            None => return,
1709        };
1710        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
1711        let (spec_sel, item_keys) = match widget {
1712            Some(fresh_core::api::WidgetSpec::Tree {
1713                selected_index,
1714                item_keys,
1715                ..
1716            }) => (*selected_index, item_keys.clone()),
1717            _ => return,
1718        };
1719        let sel = match panel.instance_states.get(focus_key) {
1720            Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
1721                *selected_index
1722            }
1723            _ => spec_sel,
1724        };
1725        if sel < 0 {
1726            return;
1727        }
1728        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
1729        self.fire_widget_event(
1730            panel_key,
1731            focus_key.to_string(),
1732            "activate".into(),
1733            serde_json::json!({ "index": sel, "key": item_key, }),
1734        );
1735    }
1736
1737    /// Walk every panel rendering into `buffer_id` and return the
1738    /// first one whose currently-focused widget is a `Text`.
1739    /// Returns `None` when no such panel exists (e.g. when the
1740    /// buffer is a regular text buffer, or the panel has focus on
1741    /// a `Button` / `List` / etc.).
1742    ///
1743    /// This is the universal hook the clipboard ops use to route
1744    /// Paste / Copy / Cut / Select-All to a focused widget text
1745    /// field instead of the underlying buffer. Same idea as the
1746    /// existing Prompt and FileExplorer branches in the clipboard
1747    /// path, generalised: any plugin-mounted Text widget that has
1748    /// focus wins over the underlying buffer.
1749    pub(super) fn focused_text_widget_panel_for_buffer(
1750        &self,
1751        buffer_id: crate::model::event::BufferId,
1752    ) -> Option<crate::widgets::PanelKey> {
1753        for panel_key in self.widget_registry.panels_for_buffer(buffer_id) {
1754            if self.panel_focused_widget_is_text(&panel_key) {
1755                return Some(panel_key);
1756            }
1757        }
1758        None
1759    }
1760
1761    /// True when `panel_key`'s currently-focused widget is a `Text`
1762    /// field (so it can accept clipboard insertion). `false` when the
1763    /// panel is gone, has no focus, or focus rests on a non-text
1764    /// widget (`Button` / `List` / `Toggle` / …). This is the shared
1765    /// predicate behind both the buffer-mounted paste routing
1766    /// (`focused_text_widget_panel_for_buffer`) and the floating-panel
1767    /// bracketed-paste routing (`paste_bracketed_into_focused_panel`).
1768    pub(super) fn panel_focused_widget_is_text(
1769        &self,
1770        panel_key: &crate::widgets::PanelKey,
1771    ) -> bool {
1772        let Some(panel) = self.widget_registry.get(panel_key) else {
1773            return false;
1774        };
1775        if panel.focus_key.is_empty() {
1776            return false;
1777        }
1778        matches!(
1779            crate::widgets::find_widget_by_key(&panel.spec, &panel.focus_key),
1780            Some(fresh_core::api::WidgetSpec::Text { .. })
1781        )
1782    }
1783
1784    /// Read the currently-selected text from the focused `Text`
1785    /// widget on the given panel, or `None` when nothing is
1786    /// selected (no anchor, or anchor == cursor). Used by the
1787    /// host-side Copy / Cut routing path.
1788    pub(super) fn focused_widget_selected_text(
1789        &self,
1790        panel_key: &crate::widgets::PanelKey,
1791    ) -> Option<String> {
1792        let panel = self.widget_registry.get(panel_key)?;
1793        if panel.focus_key.is_empty() {
1794            return None;
1795        }
1796        match panel.instance_states.get(&panel.focus_key) {
1797            Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
1798                editor.selected_text()
1799            }
1800            _ => None,
1801        }
1802    }
1803
1804    /// Select-all in the focused widget Text. Returns true when
1805    /// applied (focus was a Text widget). The op fires a `change`
1806    /// event only if the selection range actually changed; an
1807    /// already-fully-selected widget is a no-op.
1808    pub(super) fn handle_widget_select_all(
1809        &mut self,
1810        panel_key: &crate::widgets::PanelKey,
1811    ) -> bool {
1812        // SelectAll moves the cursor to end-of-value and sets anchor
1813        // at start — `with_focused_text_editor` will skip re-render
1814        // when nothing changed, which is fine.
1815        self.with_focused_text_editor(panel_key, |editor| editor.select_all())
1816    }
1817
1818    /// Copy the focused widget Text's current selection to the
1819    /// internal clipboard. Returns true when copy ran (even when
1820    /// the selection was empty — the action is consumed either way
1821    /// so it doesn't fall through to the buffer's copy path).
1822    pub(super) fn handle_widget_copy(&mut self, panel_key: &crate::widgets::PanelKey) -> bool {
1823        if self.widget_registry.get(panel_key).is_none() {
1824            return false;
1825        }
1826        if let Some(text) = self.focused_widget_selected_text(panel_key) {
1827            self.clipboard.copy(text);
1828        }
1829        true
1830    }
1831
1832    /// Cut the focused widget Text's current selection — copy then
1833    /// delete. With no selection, this is a no-op consume.
1834    pub(super) fn handle_widget_cut(&mut self, panel_key: &crate::widgets::PanelKey) -> bool {
1835        if self.widget_registry.get(panel_key).is_none() {
1836            return false;
1837        }
1838        if let Some(text) = self.focused_widget_selected_text(panel_key) {
1839            self.clipboard.copy(text);
1840            self.with_focused_text_editor(panel_key, |editor| {
1841                editor.delete_selection();
1842            });
1843        }
1844        true
1845    }
1846
1847    /// Insert `text` at the focused widget Text's cursor (replacing
1848    /// any active selection). Used by the host-side Paste routing
1849    /// path; `text` is already line-ending-normalised by the
1850    /// caller (CRLF / CR → LF). `TextEdit::insert_str` strips
1851    /// embedded newlines when the editor is single-line.
1852    pub(super) fn handle_widget_insert_str(
1853        &mut self,
1854        panel_key: &crate::widgets::PanelKey,
1855        text: &str,
1856    ) -> bool {
1857        if self.widget_registry.get(panel_key).is_none() {
1858            return false;
1859        }
1860        let owned = text.to_string();
1861        self.with_focused_text_editor(panel_key, move |editor| {
1862            editor.insert_str(&owned);
1863        });
1864        true
1865    }
1866
1867    /// Ensure `panel.instance_states[focus_key]` is a seeded
1868    /// `Text { editor, .. }` for the focused widget. If instance
1869    /// state already has the entry, no-op. If not, seeds from the
1870    /// spec's `value` / `cursor_byte` / `rows`. Returns true on
1871    /// success (focus is a Text widget that's now in instance state),
1872    /// false otherwise.
1873    fn ensure_focused_text_seeded(
1874        &mut self,
1875        panel_key: &crate::widgets::PanelKey,
1876        focus_key: &str,
1877    ) -> bool {
1878        let panel = match self.widget_registry.get_mut(panel_key) {
1879            Some(p) => p,
1880            None => return false,
1881        };
1882        if matches!(
1883            panel.instance_states.get(focus_key),
1884            Some(crate::widgets::WidgetInstanceState::Text { .. })
1885        ) {
1886            return true;
1887        }
1888        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
1889        let (value, cursor_byte, multiline) = match widget {
1890            Some(fresh_core::api::WidgetSpec::Text {
1891                value,
1892                cursor_byte,
1893                rows,
1894                ..
1895            }) => (value.clone(), *cursor_byte, *rows > 1),
1896            _ => return false,
1897        };
1898        let mut editor = if multiline {
1899            crate::primitives::text_edit::TextEdit::with_text(&value)
1900        } else {
1901            crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
1902        };
1903        let seed = if cursor_byte < 0 {
1904            value.len()
1905        } else {
1906            (cursor_byte as usize).min(value.len())
1907        };
1908        editor.set_cursor_from_flat(seed);
1909        panel.instance_states.insert(
1910            focus_key.to_string(),
1911            crate::widgets::WidgetInstanceState::Text {
1912                editor,
1913                scroll: 0,
1914                completions: Vec::new(),
1915                completion_selected_index: 0,
1916                completion_scroll_offset: 0,
1917                completion_navigated: false,
1918            },
1919        );
1920        true
1921    }
1922
1923    /// Apply a mutating operation to the focused `Text` widget's
1924    /// `TextEdit`. Handles seeding the editor from the spec on first
1925    /// touch, no-op detection (skips rerender + change event), and
1926    /// firing the `widget_event` "change" hook with the post-state.
1927    ///
1928    /// Returns true when the op ran *and* produced a visible change.
1929    pub(super) fn with_focused_text_editor<F>(
1930        &mut self,
1931        panel_key: &crate::widgets::PanelKey,
1932        op: F,
1933    ) -> bool
1934    where
1935        F: FnOnce(&mut crate::primitives::text_edit::TextEdit),
1936    {
1937        let focus_key = match self.widget_registry.get(panel_key) {
1938            Some(p) if !p.focus_key.is_empty() => p.focus_key.clone(),
1939            _ => return false,
1940        };
1941        if !self.ensure_focused_text_seeded(panel_key, &focus_key) {
1942            return false;
1943        }
1944        let (before_value, before_cursor) = {
1945            let panel = self.widget_registry.get(panel_key).unwrap();
1946            match panel.instance_states.get(&focus_key) {
1947                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
1948                    (editor.value(), editor.flat_cursor_byte())
1949                }
1950                _ => return false,
1951            }
1952        };
1953        {
1954            let panel = self.widget_registry.get_mut(panel_key).unwrap();
1955            match panel.instance_states.get_mut(&focus_key) {
1956                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => op(editor),
1957                _ => return false,
1958            }
1959        }
1960        let (after_value, after_cursor) = {
1961            let panel = self.widget_registry.get(panel_key).unwrap();
1962            match panel.instance_states.get(&focus_key) {
1963                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
1964                    (editor.value(), editor.flat_cursor_byte())
1965                }
1966                _ => return false,
1967            }
1968        };
1969        if after_value == before_value && after_cursor == before_cursor {
1970            return false;
1971        }
1972        self.rerender_widget_panel(panel_key);
1973        self.fire_widget_event(
1974            panel_key,
1975            focus_key.clone(),
1976            "change".into(),
1977            serde_json::json!({ "value": after_value, "cursorByte": after_cursor as i64, }),
1978        );
1979        true
1980    }
1981
1982    /// Apply a non-printable editing key to the focused text widget
1983    /// by dispatching to the corresponding `TextEdit` method. The
1984    /// single/multi-line discriminator is carried by `TextEdit`'s
1985    /// `multiline` field, so the same set of methods serves both
1986    /// kinds — single-line just no-ops on Up/Down/Enter.
1987    fn handle_widget_text_key(&mut self, panel_key: &crate::widgets::PanelKey, key: &str) {
1988        self.with_focused_text_editor(panel_key, |editor| match key {
1989            "Backspace" => editor.backspace(),
1990            "Delete" => editor.delete(),
1991            "Left" => editor.move_left(),
1992            "Right" => editor.move_right(),
1993            "Up" => editor.move_up(),
1994            "Down" => editor.move_down(),
1995            "Home" => editor.move_home(),
1996            "End" => editor.move_end(),
1997            "Enter" => editor.insert_char('\n'),
1998            _ => { /* unknown key — no-op */ }
1999        });
2000    }
2001
2002    /// Insert printable / IME-committed text at the focused text
2003    /// widget's cursor. Same path for single-line and multi-line —
2004    /// `TextEdit::insert_str` strips `\n` automatically when the
2005    /// editor was constructed single-line. `text` may be a single
2006    /// codepoint, a grapheme cluster, or a multi-codepoint IME
2007    /// commit; `insert_str` handles each identically.
2008    fn handle_widget_text_char(&mut self, panel_key: &crate::widgets::PanelKey, text: &str) {
2009        if text.is_empty() {
2010            return;
2011        }
2012        let text = text.to_string();
2013        self.with_focused_text_editor(panel_key, move |editor| {
2014            editor.insert_str(&text);
2015        });
2016    }
2017
2018    /// Inner-rect column budget for a floating panel render — the
2019    /// terminal width × `width_pct`, minus 2 cols for the frame
2020    /// border. Mirrors the `widget_panel_width` reservation; never
2021    /// goes below 10 cols so flex spacers don't collapse to zero on
2022    /// narrow terminals.
2023    pub(super) fn floating_panel_inner_width(&self, slot: super::PanelSlot) -> u32 {
2024        // A left-dock panel wraps its content to the dock's fixed
2025        // column width rather than a percentage of the terminal.
2026        if let Some(super::PanelPlacement::LeftDock { width_cols }) =
2027            self.panel(slot).map(|f| f.placement)
2028        {
2029            return (width_cols as u32).saturating_sub(2).max(10);
2030        }
2031        let term_w = self.terminal_width.max(1) as u32;
2032        let pct = self
2033            .panel(slot)
2034            .map(|f| f.width_pct.clamp(1, 100) as u32)
2035            .unwrap_or(80);
2036        let w = (term_w * pct) / 100;
2037        w.saturating_sub(2).max(10)
2038    }
2039
2040    /// Restore keyboard focus to a (docked) floating panel that was
2041    /// previously blurred — typically a mouse click landing back inside
2042    /// the dock's column after the user dived into the editor. Sets
2043    /// the panel's `focused` flag and fires a `focus` widget_event so
2044    /// the owning plugin can update any mirror of the focused state
2045    /// (the orchestrator's `dockBlurred`, for instance). Symmetric
2046    /// with [`Editor::blur_floating_panel`], which has always fired
2047    /// `blur` on the inverse transition.
2048    ///
2049    /// Unlike [`Editor::set_panel_focus_and_notify`] this fires the
2050    /// `focus` event even when the *inner* focus_key hasn't changed —
2051    /// the dive only flipped overall focus, not the active widget, so
2052    /// the inner key is identical on re-focus and the "key-changed"
2053    /// short-circuit would silently drop the event. That short-circuit
2054    /// was the original bug: the host updated `dock.focused` but the
2055    /// plugin's mirror stayed stale, and the dock's debounced
2056    /// dock-switch then aborted at its `dockBlurred` guard.
2057    pub(super) fn refocus_floating_panel(&mut self, slot: super::PanelSlot) {
2058        let Some(panel_key) = self.panel(slot).map(|f| f.panel_key.clone()) else {
2059            return;
2060        };
2061        if let Some(f) = self.panel_mut(slot) {
2062            f.focused = true;
2063        }
2064        let widget_key = self
2065            .widget_registry
2066            .get(&panel_key)
2067            .map(|p| p.focus_key.clone())
2068            .unwrap_or_default();
2069        tracing::debug!(
2070            target: "fresh::dock",
2071            panel = %panel_key,
2072            ?slot,
2073            widget_key = %widget_key,
2074            "refocus_floating_panel: firing unconditional `focus` widget_event"
2075        );
2076        self.fire_widget_event(
2077            &panel_key,
2078            widget_key,
2079            "focus".to_string(),
2080            serde_json::json!({ "previous": "(re-focus)" }),
2081        );
2082    }
2083
2084    /// Return keyboard focus to the editor while leaving a (docked)
2085    /// floating panel visible. Clears the panel's `focused` flag and
2086    /// fires a `blur` widget_event so the owning plugin can react
2087    /// (e.g. drop its editor mode). No-op when no panel is mounted.
2088    /// Shared by the Esc handler, the editor-click handler, and the
2089    /// `FloatingPanelControl{op:"blur"}` command.
2090    pub(super) fn blur_floating_panel(&mut self, slot: super::PanelSlot) {
2091        let Some(panel_key) = self.panel(slot).map(|f| f.panel_key.clone()) else {
2092            return;
2093        };
2094        if let Some(f) = self.panel_mut(slot) {
2095            f.focused = false;
2096        }
2097        tracing::debug!(
2098            target: "fresh::dock",
2099            panel = %panel_key,
2100            ?slot,
2101            "blur_floating_panel: firing `blur` widget_event"
2102        );
2103        let widget_key = self
2104            .widget_registry
2105            .get(&panel_key)
2106            .map(|p| p.focus_key.clone())
2107            .unwrap_or_default();
2108        self.fire_widget_event(
2109            &panel_key,
2110            widget_key,
2111            "blur".to_string(),
2112            serde_json::json!({}),
2113        );
2114    }
2115
2116    /// Handle CloseSplit command
2117    pub(super) fn handle_close_split(&mut self, split_id: SplitId) {
2118        // Plugin sends arbitrary SplitId — convert to LeafId at the boundary
2119        let leaf_id = LeafId(split_id);
2120        match self
2121            .windows
2122            .get_mut(&self.active_window)
2123            .and_then(|w| w.split_manager_mut())
2124            .expect("active window must have a populated split layout")
2125            .close_split(leaf_id)
2126        {
2127            Ok(()) => {
2128                // Clean up the view state for the closed split
2129                self.windows
2130                    .get_mut(&self.active_window)
2131                    .and_then(|w| w.split_view_states_mut())
2132                    .expect("active window must have a populated split layout")
2133                    .remove(&leaf_id);
2134                tracing::info!("Closed split {:?}", split_id);
2135            }
2136            Err(e) => {
2137                tracing::warn!("Failed to close split {:?}: {}", split_id, e);
2138            }
2139        }
2140    }
2141
2142    /// Handle RefreshLines command
2143    pub(super) fn handle_refresh_lines(&mut self, buffer_id: BufferId) {
2144        // Clear seen_byte_ranges for this buffer so all visible lines will be re-processed
2145        // on the next render. This is useful when a plugin is enabled and needs to
2146        // process lines that were already marked as seen.
2147        self.active_window_mut().seen_byte_ranges.remove(&buffer_id);
2148        // Request a render so the lines_changed hook fires
2149        #[cfg(feature = "plugins")]
2150        {
2151            self.plugin_render_requested = true;
2152        }
2153    }
2154
2155    /// Flush pending grammars: spawn a background rebuild if any ReloadGrammars
2156    /// commands were received during this command batch.
2157    ///
2158    /// Called after processing all plugin commands in a batch, so that multiple
2159    /// RegisterGrammar+ReloadGrammars pairs result in only one rebuild.
2160    /// The rebuild happens on a background thread; when complete, a
2161    /// `GrammarRegistryBuilt` message swaps in the new registry.
2162    ///
2163    /// On the first call, this triggers the deferred full grammar build
2164    /// (user grammars + language packs + any plugin grammars accumulated so far).
2165    pub(super) fn flush_pending_grammars(&mut self) {
2166        // On the first call, start the deferred full grammar build.
2167        // This includes any plugin grammars that were registered during init,
2168        // so we get everything in a single builder.build() pass.
2169        if self.needs_full_grammar_build {
2170            self.needs_full_grammar_build = false;
2171            self.grammar_reload_pending = false;
2172
2173            // Drain all pending grammars to include in the initial build
2174            let additional: Vec<_> = self
2175                .pending_grammars
2176                .drain(..)
2177                .map(|g| crate::primitives::grammar::GrammarSpec {
2178                    language: g.language.clone(),
2179                    path: std::path::PathBuf::from(g.grammar_path),
2180                    extensions: g.extensions.clone(),
2181                })
2182                .collect();
2183
2184            // Update config.languages with the extensions so detect_language() works
2185            for crate::primitives::grammar::GrammarSpec {
2186                language,
2187                extensions,
2188                ..
2189            } in &additional
2190            {
2191                let lang_config = self
2192                    .config_mut()
2193                    .languages
2194                    .entry(language.clone())
2195                    .or_default();
2196                for ext in extensions {
2197                    if !lang_config.extensions.contains(ext) {
2198                        lang_config.extensions.push(ext.clone());
2199                    }
2200                }
2201            }
2202
2203            let callback_ids: Vec<_> = self.pending_grammar_callbacks.drain(..).collect();
2204            self.start_background_grammar_build(additional, callback_ids);
2205            return;
2206        }
2207
2208        if !self.grammar_reload_pending {
2209            return;
2210        }
2211        self.grammar_reload_pending = false;
2212
2213        // If a background build is already in progress, it will call
2214        // flush_pending_grammars() again when it completes — so just
2215        // re-arm the flag and return.
2216        if self.grammar_build_in_progress {
2217            self.grammar_reload_pending = true;
2218            tracing::debug!("Grammar build in progress, deferring flush");
2219            return;
2220        }
2221
2222        use std::path::PathBuf;
2223
2224        if self.pending_grammars.is_empty() {
2225            tracing::debug!("Grammar reload requested but no pending grammars");
2226            return;
2227        }
2228
2229        // Deduplicate: skip grammars whose extensions are all already mapped
2230        // in the current registry (meaning the grammar was already loaded by
2231        // for_editor or a previous build).
2232        let pending_before = self.pending_grammars.len();
2233        self.pending_grammars.retain(|g| {
2234            // Check if ALL extensions for this grammar are already mapped
2235            let all_mapped = !g.extensions.is_empty()
2236                && g.extensions
2237                    .iter()
2238                    .all(|ext| self.grammar_registry.find_by_extension(ext).is_some());
2239            if all_mapped {
2240                tracing::debug!(
2241                    "Skipping already-loaded grammar '{}' (extensions {:?} already mapped)",
2242                    g.language,
2243                    g.extensions
2244                );
2245                false
2246            } else {
2247                true
2248            }
2249        });
2250        if pending_before != self.pending_grammars.len() {
2251            tracing::info!(
2252                "Deduplicated pending grammars: {} -> {}",
2253                pending_before,
2254                self.pending_grammars.len()
2255            );
2256        }
2257
2258        if self.pending_grammars.is_empty() {
2259            tracing::info!(
2260                "All pending grammars already loaded, resolving callbacks without rebuild"
2261            );
2262            // Resolve callbacks immediately — no rebuild needed
2263            #[cfg(feature = "plugins")]
2264            for cb_id in self.pending_grammar_callbacks.drain(..) {
2265                self.plugin_manager
2266                    .read()
2267                    .unwrap()
2268                    .resolve_callback(cb_id, "null".to_string());
2269            }
2270            #[cfg(not(feature = "plugins"))]
2271            self.pending_grammar_callbacks.clear();
2272            return;
2273        }
2274
2275        tracing::info!(
2276            "Flushing {} pending grammars via background rebuild",
2277            self.pending_grammars.len()
2278        );
2279
2280        // Collect pending grammars
2281        let additional: Vec<crate::primitives::grammar::GrammarSpec> = self
2282            .pending_grammars
2283            .drain(..)
2284            .map(|g| crate::primitives::grammar::GrammarSpec {
2285                language: g.language.clone(),
2286                path: PathBuf::from(g.grammar_path),
2287                extensions: g.extensions.clone(),
2288            })
2289            .collect();
2290
2291        // Update config.languages with the extensions so detect_language() works
2292        for crate::primitives::grammar::GrammarSpec {
2293            language,
2294            extensions,
2295            ..
2296        } in &additional
2297        {
2298            let lang_config = self
2299                .config_mut()
2300                .languages
2301                .entry(language.clone())
2302                .or_default();
2303            for ext in extensions {
2304                if !lang_config.extensions.contains(ext) {
2305                    lang_config.extensions.push(ext.clone());
2306                }
2307            }
2308        }
2309
2310        // Collect pending callback IDs to resolve when build completes
2311        let callback_ids: Vec<_> = self.pending_grammar_callbacks.drain(..).collect();
2312
2313        // Spawn background rebuild
2314        let base_registry = std::sync::Arc::clone(&self.grammar_registry);
2315        if let Some(bridge) = &self.async_bridge {
2316            let sender = bridge.sender();
2317            self.grammar_build_in_progress = true;
2318            std::thread::Builder::new()
2319                .name("grammar-rebuild".to_string())
2320                .spawn(move || {
2321                    use crate::primitives::grammar::GrammarRegistry;
2322                    match GrammarRegistry::with_additional_grammars(&base_registry, &additional) {
2323                        Some(new_registry) => {
2324                            // Ok to ignore: receiver may be gone if app is shutting down.
2325                            drop(sender.send(
2326                                crate::services::async_bridge::AsyncMessage::GrammarRegistryBuilt {
2327                                    registry: std::sync::Arc::new(new_registry),
2328                                    callback_ids,
2329                                },
2330                            ));
2331                        }
2332                        None => {
2333                            tracing::error!("Failed to rebuild grammar registry in background");
2334                            // Still send the message so callbacks get resolved (even on failure)
2335                            drop(sender.send(
2336                                crate::services::async_bridge::AsyncMessage::GrammarRegistryBuilt {
2337                                    registry: base_registry,
2338                                    callback_ids,
2339                                },
2340                            ));
2341                        }
2342                    }
2343                })
2344                .ok();
2345        }
2346    }
2347
2348    // ==================== Project Grep ====================
2349
2350    /// Retry deferred virtual-buffer animations now that split_areas has
2351    /// been recomputed. Called from render() after layout but before
2352    /// animations.apply_all so the first frame of the effect lands in
2353    /// the same render pass.
2354    pub(crate) fn drain_pending_vb_animations(&mut self) {
2355        if self.pending_vb_animations.is_empty() {
2356            return;
2357        }
2358        let pending = std::mem::take(&mut self.pending_vb_animations);
2359        for (id, buffer_id, kind) in pending {
2360            match self.virtual_buffer_screen_rect(buffer_id) {
2361                Some(area) => {
2362                    let animation_kind = translate_plugin_animation_kind(kind);
2363                    self.active_window_mut().animations.start_with_id(
2364                        crate::view::animation::AnimationId::from_raw(id),
2365                        area,
2366                        animation_kind,
2367                    );
2368                }
2369                None => {
2370                    // Still not visible; keep pending for next frame.
2371                    self.pending_vb_animations.push((id, buffer_id, kind));
2372                }
2373            }
2374        }
2375    }
2376
2377    /// Look up the on-screen Rect currently occupied by `buffer_id`, if any.
2378    /// Reads from the cached split layout captured in the last render pass.
2379    pub(crate) fn virtual_buffer_screen_rect(
2380        &self,
2381        buffer_id: BufferId,
2382    ) -> Option<ratatui::layout::Rect> {
2383        self.active_layout()
2384            .split_areas
2385            .iter()
2386            .find(|(_, bid, _, _, _, _)| *bid == buffer_id)
2387            .map(|(_, _, content_rect, _, _, _)| *content_rect)
2388    }
2389}