//! Shared widget / floating-panel runtime methods on `Editor`.
//!
//! This module holds the editor-side widget runtime that backs both the
//! plugin widget API and the built-in UI. It is intentionally NOT gated
//! behind the `plugins` feature: these methods are invoked from non-plugin
//! input/mouse/lifecycle code and must compile in plugin-less builds.
//!
//! The plugin-only command dispatch (`handle_plugin_command` and the
//! per-command handlers reachable only from it) lives in the
//! `plugins`-gated `plugin_dispatch` / `plugin_commands` modules.

use crate::model::event::{BufferId, LeafId, SplitId};

use super::Editor;

/// Render a floating panel's spec, choosing the marker-gutter
/// renderer when the panel opted into the `▸ ` focus marker (the
/// Orchestrator New Session form) and the plain renderer otherwise.
/// Centralised so the mount / update / rerender paths can't drift on
/// which renderer a given panel uses. Lives here (not in the
/// `plugins`-gated `plugin_dispatch`) so the non-plugin rerender path
/// can call it in plugin-less builds.
///
/// `hover_key` is the widget the pointer is over (`""` for none). It's
/// host state that changes with every mouse move, so it is applied
/// around the render rather than carried in the spec; only bare icon
/// buttons read it.
impl Editor {
    /// Render a buffer-mounted panel spec with the live theme + grammars
    /// threaded in (so `markdown: true` Text widgets render through the
    /// shared markdown engine). The read guard on the theme lives only
    /// for the render call.
    pub(super) fn render_panel_spec(
        &self,
        spec: &fresh_core::api::WidgetSpec,
        prev: &std::collections::HashMap<String, crate::widgets::WidgetInstanceState>,
        prev_focus_key: &str,
        panel_width: u32,
    ) -> crate::widgets::RenderOutput {
        let theme_guard = self.theme.read().unwrap();
        crate::widgets::render_spec_with_options(
            spec,
            prev,
            panel_width,
            crate::widgets::RenderOptions {
                prev_focus_key,
                auto_focus_first: true,
                markdown: Some(crate::widgets::MarkdownCtx {
                    theme: &theme_guard,
                    grammars: Some(self.grammar_registry.as_ref()),
                }),
                ..Default::default()
            },
        )
    }
}

pub(super) fn render_floating_spec(
    focus_marker: bool,
    spec: &fresh_core::api::WidgetSpec,
    prev: &std::collections::HashMap<String, crate::widgets::WidgetInstanceState>,
    prev_focus_key: &str,
    panel_width: u32,
    hover_key: &str,
    markdown: Option<crate::widgets::MarkdownCtx<'_>>,
) -> crate::widgets::RenderOutput {
    crate::widgets::render_spec_with_options(
        spec,
        prev,
        panel_width,
        crate::widgets::RenderOptions {
            prev_focus_key,
            hover_key,
            marker_gutter: focus_marker,
            auto_focus_first: true,
            markdown,
        },
    )
}

/// Walk a `Tree`'s flat `nodes` and return the absolute indices of
/// nodes that are currently visible — i.e. every ancestor is in
/// `expanded`. Mirrors the renderer's filter so dispatcher and
/// renderer agree on what's selectable.
/// First `Tree` or `List` widget key in `spec`, scanning in
/// declaration order. Used by mouse-wheel routing to pick which
/// widget inside a panel absorbs the scroll.
fn find_scrollable_widget_key(spec: &fresh_core::api::WidgetSpec) -> Option<String> {
    use fresh_core::api::WidgetSpec;
    match spec {
        WidgetSpec::Tree { key: Some(k), .. } | WidgetSpec::List { key: Some(k), .. }
            if !k.is_empty() =>
        {
            return Some(k.clone());
        }
        // A markdown document view scrolls like a list; plain editable
        // textareas stay excluded (they scroll with their caret and
        // emit no region for the wheel to move).
        WidgetSpec::Text {
            key: Some(k),
            rows,
            markdown: true,
            ..
        } if !k.is_empty() && *rows > 1 => {
            return Some(k.clone());
        }
        _ => {}
    }
    spec.children().find_map(find_scrollable_widget_key)
}

/// Re-hydrate the plugin runtime's flattened key *name* (e.g. `"Home"`)
/// back into a `KeyEvent`, so widget text editing can go through the shared
/// [`apply_text_key`](crate::primitives::text_key::apply_text_key) table
/// rather than its own dispatch. Only the bare named keys the widget key
/// router forwards to text fields are recognized; `"Enter"` is handled by
/// the caller, and modifier chords aren't currently surfaced to widget
/// text fields.
fn widget_key_name_to_event(name: &str) -> Option<crossterm::event::KeyEvent> {
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    // Peel `C-` / `S-` / `A-` prefixes (in any order) so shift-selection
    // and word-motion chords reach the shared text-key table — a
    // markdown document view needs `S-Down` to extend the selection.
    let mut modifiers = KeyModifiers::NONE;
    let mut rest = name;
    loop {
        if let Some(r) = rest.strip_prefix("C-") {
            modifiers |= KeyModifiers::CONTROL;
            rest = r;
        } else if let Some(r) = rest.strip_prefix("S-") {
            modifiers |= KeyModifiers::SHIFT;
            rest = r;
        } else if let Some(r) = rest.strip_prefix("A-") {
            modifiers |= KeyModifiers::ALT;
            rest = r;
        } else {
            break;
        }
    }
    let code = match rest {
        "Backspace" => KeyCode::Backspace,
        "Delete" => KeyCode::Delete,
        "Left" => KeyCode::Left,
        "Right" => KeyCode::Right,
        "Up" => KeyCode::Up,
        "Down" => KeyCode::Down,
        "Home" => KeyCode::Home,
        "End" => KeyCode::End,
        _ => return None,
    };
    Some(KeyEvent::new(code, modifiers))
}

/// Whether routing `event` through [`apply_text_key`] would mutate the
/// surface. Everything else in the table is caret motion / selection.
fn text_key_mutates(event: &crossterm::event::KeyEvent) -> bool {
    use crossterm::event::KeyCode;
    matches!(
        event.code,
        KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete
    )
}

/// A `DualList` interaction, resolved from a keystroke or click by
/// [`Editor::handle_widget_dual`].
pub(super) enum DualOp {
    /// Make the Included column active (`true`) or Available (`false`).
    SwitchColumn(bool),
    /// Move the active column's cursor by `delta`.
    CursorMove(i32),
    /// Move the focused item across columns (add if Available is
    /// active, remove if Included is active).
    MoveAcross,
    /// Reorder the focused Included item by `delta` (no-op unless the
    /// Included column is active).
    Reorder(i32),
}

/// Step a column cursor by `delta`, clamped to `[0, len)`. Empty
/// column stays at 0.
fn step_cursor(cursor: usize, delta: i32, len: usize) -> usize {
    if len == 0 {
        return 0;
    }
    let raw = cursor as i32 + delta;
    raw.clamp(0, len as i32 - 1) as usize
}

fn collect_visible_tree_indices(
    nodes: &[fresh_core::api::TreeNode],
    item_keys: &[String],
    expanded: &std::collections::HashSet<String>,
) -> Vec<usize> {
    let mut ancestor_open: Vec<bool> = Vec::new();
    let mut visible: Vec<usize> = Vec::with_capacity(nodes.len());
    for (i, node) in nodes.iter().enumerate() {
        let depth = node.depth as usize;
        ancestor_open.truncate(depth);
        if ancestor_open.iter().all(|open| *open) {
            visible.push(i);
        }
        let key = item_keys.get(i).cloned().unwrap_or_default();
        let is_open = if node.has_children {
            !key.is_empty() && expanded.contains(&key)
        } else {
            true
        };
        ancestor_open.push(is_open);
    }
    visible
}

/// Translate the plugin-facing animation description to the internal
/// `AnimationKind` the runner consumes.
pub(super) fn translate_plugin_animation_kind(
    kind: fresh_core::api::PluginAnimationKind,
) -> crate::view::animation::AnimationKind {
    use crate::view::animation::{AnimationKind, Edge};
    use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
    use std::time::Duration;
    match kind {
        PluginAnimationKind::SlideIn {
            from,
            duration_ms,
            delay_ms,
        } => AnimationKind::SlideIn {
            from: match from {
                PluginAnimationEdge::Top => Edge::Top,
                PluginAnimationEdge::Bottom => Edge::Bottom,
                PluginAnimationEdge::Left => Edge::Left,
                PluginAnimationEdge::Right => Edge::Right,
            },
            duration: Duration::from_millis(duration_ms as u64),
            delay: Duration::from_millis(delay_ms as u64),
        },
    }
}

impl Editor {
    /// Set the host-owned selected index for a `List` or `Tree`
    /// instance in `panel`, dispatching on the *existing* instance
    /// variant so a Tree keeps its scroll + expanded-keys set (and a
    /// List keeps its item height / user-scroll flag). Shared by the
    /// mouse-click select path (here) and the `SetSelectedIndex`
    /// mutation (in the plugin dispatcher) so both move Tree
    /// selections, not just List ones. Lives here rather than in the
    /// plugin dispatcher because the widget runtime — and the click
    /// path that calls this — compiles even without the `plugins`
    /// feature. Does not re-render; callers decide when to repaint.
    pub(super) fn set_widget_selected_index_state(
        panel: &mut crate::widgets::WidgetPanelState,
        widget_key: &str,
        index: i32,
    ) {
        use crate::widgets::WidgetInstanceState;
        let new_state = match panel.instance_states.get(widget_key) {
            Some(WidgetInstanceState::Tree {
                scroll_offset,
                selected_index,
                expanded_keys,
                user_scrolled,
            }) => WidgetInstanceState::Tree {
                scroll_offset: *scroll_offset,
                expanded_keys: expanded_keys.clone(),
                // Re-pinning the *same* index (which the orchestrator
                // dock's `refreshOpenDialog` does on every probe-poll
                // repaint) must preserve a user scroll — otherwise the
                // refresh would snap the view back to the selection a
                // beat after a mouse scroll. Only an actual selection
                // change re-arms scroll-follows-selection. Mirrors the
                // List branch below.
                user_scrolled: *user_scrolled && index == *selected_index,
                selected_index: index,
            },
            other => {
                let (prev_scroll, prev_index, prev_item_height, prev_user_scrolled) = match other {
                    Some(WidgetInstanceState::List {
                        scroll_offset,
                        selected_index,
                        item_height,
                        user_scrolled,
                    }) => (
                        *scroll_offset,
                        *selected_index,
                        *item_height,
                        *user_scrolled,
                    ),
                    _ => (0, -1, 1, false),
                };
                // Re-pinning the *same* index (which `refreshOpenDialog`
                // does on every repaint) must preserve a user scroll —
                // otherwise a probe-poll refresh would snap the view back
                // to the selection a beat after a mouse scroll. Only an
                // actual selection change re-arms scroll-follows-selection.
                WidgetInstanceState::List {
                    scroll_offset: prev_scroll,
                    selected_index: index,
                    item_height: prev_item_height,
                    user_scrolled: prev_user_scrolled && index == prev_index,
                }
            }
        };
        panel
            .instance_states
            .insert(widget_key.to_string(), new_state);
    }

    /// Process a resolved widget hit (from a TUI cell click or a native-frontend
    /// click): move focus to the clicked widget, apply host-owned state changes
    /// (tree expand / list selection) and fire the plugin's `widget_event`. This
    /// is the single dispatch path shared by the buffer-cell click handler and
    /// the web `/widget` route, so a click delivers identical behaviour in both.
    pub(crate) fn deliver_widget_hit(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        hit: &crate::widgets::HitArea,
        clicked_byte: Option<usize>,
    ) {
        // Click-to-focus: if the clicked widget has a stable, tabbable key, move
        // focus there before firing the event so the next render reflects it.
        // A List row's `widget_key` is the row's *item* key; the focusable key
        // is the owning List's spec key, carried in `payload.list_key` — so a
        // row click focuses the list, and arrows right after it keep moving
        // the list's selection.
        let focus_key = if hit.widget_kind == "list" && hit.event_type == "select" {
            hit.payload
                .get("list_key")
                .and_then(|v| v.as_str())
                .map(str::to_string)
                .unwrap_or_else(|| hit.widget_key.clone())
        } else {
            hit.widget_key.clone()
        };
        if !focus_key.is_empty() {
            let is_tabbable = self
                .widget_registry
                .get(panel_key)
                .map(|p| p.tabbable.iter().any(|k| k == &focus_key))
                .unwrap_or(false);
            if is_tabbable {
                self.set_panel_focus_and_notify(panel_key, focus_key);
            }
            self.rerender_widget_panel(panel_key);
        }
        // Click-to-position-cursor for buffer-mounted text fields: move
        // the caret to the clicked column when the hit resolver knew it
        // (#2573). Mirrors the floating-panel path in `mouse_input`.
        if hit.widget_kind == "text" && hit.event_type == "focus" {
            if let Some(line) = hit.payload.get("mdLine").and_then(|v| v.as_u64()) {
                // Markdown document row: place the caret at the clicked
                // byte within the rendered line, and arm drag-to-select
                // anchored there.
                if let Some(byte) = clicked_byte {
                    self.position_markdown_text_cursor_from_click(
                        panel_key,
                        &hit.widget_key,
                        line as usize,
                        byte.saturating_sub(hit.byte_start),
                    );
                }
            } else if let Some(byte) = clicked_byte {
                self.reposition_widget_text_cursor_from_click(
                    panel_key,
                    &hit.widget_key,
                    byte,
                    hit.byte_start,
                    &hit.payload,
                );
            }
        }
        // Tree disclosure click: the host owns expansion state, so toggle it
        // (the toggle handler fires its own `expand` event with the post-toggle
        // state). Tree row-body (`select`) and other kinds fall through.
        let mut handled_specially = false;
        if hit.widget_kind == "tree" && hit.event_type == "expand" {
            if let Some(item_key) = hit.payload.get("key").and_then(|v| v.as_str()) {
                self.handle_widget_tree_expand_toggle(panel_key, &hit.widget_key, item_key);
                handled_specially = true;
            }
        }
        // Number value-cell click: focus already moved above; the value
        // is changed by typing (Left/Right on the focused widget also
        // adjusts by `step`), so the click itself is just a focus move.
        if hit.widget_kind == "number" && hit.event_type == "number_value" {
            handled_specially = true;
        }
        // Dropdown button click toggles the inline option list open;
        // clicking an option row selects it (fires `change`) and closes
        // the list. The host owns both the open flag and the index.
        if hit.widget_kind == "dropdown" && hit.event_type == "dropdown_toggle" {
            let now_open = !self.focused_dropdown_open(panel_key);
            self.set_dropdown_open(panel_key, &hit.widget_key, now_open);
            handled_specially = true;
        }
        if hit.widget_kind == "dropdown" && hit.event_type == "dropdown_select" {
            if let Some(idx) = hit.payload.get("index").and_then(|v| v.as_i64()) {
                self.handle_widget_dropdown_set(panel_key, &hit.widget_key, idx as i32);
            }
            self.set_dropdown_open(panel_key, &hit.widget_key, false);
            handled_specially = true;
        }
        // DualList cell click: focus the clicked column and move its
        // cursor to the clicked row. The host owns the cursor + active
        // column, so apply it directly (no plugin round-trip).
        if hit.widget_kind == "dual_list" && hit.event_type == "dual_focus" {
            let to_included =
                hit.payload.get("column").and_then(|v| v.as_str()) == Some("included");
            let index = hit
                .payload
                .get("index")
                .and_then(|v| v.as_i64())
                .unwrap_or(0) as usize;
            self.handle_widget_dual_click(panel_key, &hit.widget_key, to_included, index);
            handled_specially = true;
        }
        // List row click: the host owns the List's selected index; a click only
        // yields a `select` hit, so sync the selection (and repaint) then fall
        // through to fire `select` with the List's *spec* key (per-item key stays
        // in payload) — identical to keyboard nav.
        let mut event_widget_key = hit.widget_key.clone();
        if hit.widget_kind == "list" && hit.event_type == "select" {
            if let Some(list_key) = hit.payload.get("list_key").and_then(|v| v.as_str()) {
                event_widget_key = list_key.to_string();
                if let Some(idx) = hit.payload.get("index").and_then(|v| v.as_i64()) {
                    self.set_widget_list_selected_index(panel_key, list_key, idx as i32);
                }
            }
        }
        // Tree row-body click: the host owns the Tree's selected index too,
        // and a click only yields a `select` hit — so sync the selection
        // (and repaint) before firing `select`, mirroring the List path.
        // Without this a click leaves the highlight where it was. The
        // per-row key/index travel in the payload; the tree's spec key is
        // already `hit.widget_key`.
        if hit.widget_kind == "tree" && hit.event_type == "select" {
            if let Some(idx) = hit.payload.get("index").and_then(|v| v.as_i64()) {
                if let Some(panel) = self.widget_registry.get_mut(panel_key) {
                    Self::set_widget_selected_index_state(panel, &hit.widget_key, idx as i32);
                }
                self.rerender_widget_panel(panel_key);
            }
        }
        if !handled_specially {
            // Tag the event as mouse-originated so a plugin can tell a
            // click apart from a keyboard move that emits the same
            // event/payload (arrows fire `select` without this marker).
            // Matches the floating-panel click path; e.g. Search &
            // Replace opens a result on click but not on arrow-move.
            let mut payload = hit.payload.clone();
            if let Some(obj) = payload.as_object_mut() {
                obj.insert("via".to_string(), serde_json::json!("click"));
            }
            self.fire_widget_event(
                panel_key,
                event_widget_key,
                hit.event_type.to_string(),
                payload,
            );
        }
    }

    /// Native-frontend entry point: deliver the hit at `hit_index` in panel
    /// `(plugin, panel_id)`'s recorded hit list — the same hits `widgets_view`
    /// shipped to the frontend. Runs the shared `deliver_widget_hit` path.
    pub fn deliver_widget_hit_by_index(&mut self, plugin: &str, panel_id: u64, hit_index: usize) {
        let panel_key = crate::widgets::PanelKey::new(plugin, panel_id);
        let hit = self
            .widget_registry
            .get(&panel_key)
            .and_then(|p| p.hits.get(hit_index).cloned());
        if let Some(hit) = hit {
            // Native frontends deliver by index, without a per-cell click
            // column, so there's no click-to-position payload to honour here.
            self.deliver_widget_hit(&panel_key, &hit, None);
        }
    }

    /// Native-frontend entry point, robust against hit-list drift: resolve
    /// the clicked hit by IDENTITY — `widget_key` + `event_type`, preferring
    /// exact `payload` equality — instead of by raw index. A raw index goes
    /// stale the moment the plugin re-renders between the pushed frame and
    /// the click; identity survives reordering by construction. `hit_index`
    /// (the index the frontend rendered from) stays as the last-resort
    /// tiebreaker for hits that carry no key.
    ///
    /// The recorded hit list is additionally a projection of the TUI's
    /// *visible* rows — `collect_list` windows its hits to the cell
    /// viewport's `[scroll, scroll+visible)` — while a native frontend
    /// renders the whole list in its own scroll container. A click on a row
    /// outside that window therefore matches NO recorded hit; for
    /// `list`/`select` clicks the hit is then synthesized from the panel's
    /// own spec (`synthesize_list_hit` rebuilds exactly the `HitArea` the
    /// renderer would have emitted for that row — the item key comes from
    /// the spec, never from frontend-supplied strings alone).
    pub fn deliver_widget_hit_semantic(
        &mut self,
        plugin: &str,
        panel_id: u64,
        widget_key: &str,
        event_type: &str,
        payload: &serde_json::Value,
        hit_index: Option<usize>,
    ) {
        let panel_key = crate::widgets::PanelKey::new(plugin, panel_id);
        let hit = {
            let Some(panel) = self.widget_registry.get(&panel_key) else {
                return;
            };
            let identity = |strict: bool| {
                panel
                    .hits
                    .iter()
                    .find(|h| {
                        h.event_type == event_type
                            && h.widget_key == widget_key
                            && (!strict || h.payload == *payload)
                            // Never loose-match across rows: tree hits all
                            // share the tree's spec key, so when both payloads
                            // carry an `index` they must agree — otherwise a
                            // click on an off-window row would deliver some
                            // other row's recorded hit.
                            && (strict
                                || match (h.payload.get("index"), payload.get("index")) {
                                    (Some(a), Some(b)) => a == b,
                                    _ => true,
                                })
                    })
                    .cloned()
            };
            // Exact match first; then key+event only (payload drift, e.g. a
            // toggle whose `checked` flipped — the CURRENT hit is the right
            // one to deliver, exactly as a TUI click would use it). The
            // loose tier requires a non-empty key so keyless hits can't
            // cross-match. Then the raw-index tiebreaker, then the
            // off-window list synthesis.
            identity(true)
                .or_else(|| {
                    if widget_key.is_empty() {
                        None
                    } else {
                        identity(false)
                    }
                })
                .or_else(|| hit_index.and_then(|i| panel.hits.get(i).cloned()))
                .or_else(|| Self::synthesize_list_hit(panel, event_type, payload))
                .or_else(|| Self::synthesize_tree_hit(panel, widget_key, event_type, payload))
                .or_else(|| Self::synthesize_control_hit(panel, widget_key, event_type, payload))
        };
        if let Some(hit) = hit {
            self.deliver_widget_hit(&panel_key, &hit, None);
        }
    }

    /// Copy the right-click screen cell (`col`/`row`) from a frontend
    /// `context` payload into the synthesized one, clamped to `u16` like a
    /// real terminal cell. The plugin uses it to anchor its popup; absent
    /// or malformed coordinates are simply omitted (the plugin falls back
    /// to a default anchor).
    fn copy_context_anchor_cell(from: &serde_json::Value, into: &mut serde_json::Value) {
        if let Some(obj) = into.as_object_mut() {
            for cell in ["col", "row"] {
                if let Some(v) = from.get(cell).and_then(|v| v.as_u64()) {
                    obj.insert(cell.to_string(), serde_json::json!(v.min(u16::MAX as u64)));
                }
            }
        }
    }

    /// Rebuild the `HitArea` that `collect_list` would have emitted for a
    /// list row that is outside the TUI's scroll window (so no hit was
    /// recorded), from the panel's own spec: the payload's `list_key` must
    /// name a `List` in the spec and `index` must be in bounds; the item key
    /// is read from the spec's `item_keys`. Returns `None` for anything
    /// that isn't a valid in-bounds list `select` or right-click `context`
    /// (which is never recorded as a hit — the TUI synthesizes it from a
    /// right-click as well).
    fn synthesize_list_hit(
        panel: &crate::widgets::WidgetPanelState,
        event_type: &str,
        payload: &serde_json::Value,
    ) -> Option<crate::widgets::HitArea> {
        if event_type != "select" && event_type != "context" {
            return None;
        }
        let list_key = payload.get("list_key")?.as_str()?;
        let index = payload.get("index")?.as_i64()?;
        let spec = crate::widgets::find_widget_by_key(&panel.spec, list_key)?;
        let fresh_core::api::WidgetSpec::List {
            items,
            item_specs,
            item_keys,
            ..
        } = spec
        else {
            return None;
        };
        let total = if item_specs.is_empty() {
            items.len()
        } else {
            item_specs.len()
        };
        if index < 0 || index as usize >= total {
            return None;
        }
        let item_key = item_keys.get(index as usize).cloned().unwrap_or_default();
        let mut row_payload = serde_json::json!({
            "index": index,
            "key": item_key,
            "list_key": list_key,
        });
        let event_type = if event_type == "context" {
            Self::copy_context_anchor_cell(payload, &mut row_payload);
            "context"
        } else {
            "select"
        };
        Some(crate::widgets::HitArea {
            overlay: false,
            widget_key: item_key.clone(),
            widget_kind: "list",
            buffer_row: 0,
            byte_start: 0,
            byte_end: 0,
            payload: row_payload,
            event_type,
        })
    }

    /// Rebuild the `HitArea` `render_widget_tree` would have emitted for a
    /// tree row outside the TUI's scroll window (so no hit was recorded),
    /// from the panel's own spec: `widget_key` must name a `Tree`, the
    /// payload's `index` must be in bounds, and the row's item key comes
    /// from the spec's `item_keys`. Covers the row-body `select`, the
    /// disclosure `expand`, the checkbox `toggle`, and the right-click
    /// `context` events (the natively-scrolled web frontend can click any
    /// row, not just the TUI's visible window; no `context` hit is ever
    /// recorded — the TUI synthesizes those from a right-click too).
    fn synthesize_tree_hit(
        panel: &crate::widgets::WidgetPanelState,
        widget_key: &str,
        event_type: &str,
        payload: &serde_json::Value,
    ) -> Option<crate::widgets::HitArea> {
        if !matches!(event_type, "select" | "expand" | "toggle" | "context")
            || widget_key.is_empty()
        {
            return None;
        }
        let index = payload.get("index")?.as_i64()?;
        let spec = crate::widgets::find_widget_by_key(&panel.spec, widget_key)?;
        let fresh_core::api::WidgetSpec::Tree {
            nodes, item_keys, ..
        } = spec
        else {
            return None;
        };
        if index < 0 || index as usize >= nodes.len() {
            return None;
        }
        let item_key = item_keys.get(index as usize).cloned().unwrap_or_default();
        let (event_type, payload) = match event_type {
            "expand" => (
                "expand",
                serde_json::json!({
                    "index": index,
                    "key": item_key,
                    "expanded": payload.get("expanded").and_then(|v| v.as_bool()).unwrap_or(true),
                }),
            ),
            // Right-click: same shape the TUI's context path fires —
            // the row identity plus the click cell the plugin anchors
            // its popup at (see `handle_floating_widget_context_click`).
            "context" => {
                let mut p = serde_json::json!({ "index": index, "key": item_key });
                Self::copy_context_anchor_cell(payload, &mut p);
                ("context", p)
            }
            // Checkbox click: same shape the renderer's checkbox hit
            // carries — `checked` is the NEW value, derived from the
            // spec (the plugin's pushed truth), never from the frontend.
            // Only nodes that actually bear a checkbox (checked is
            // Some) get one, mirroring the renderer.
            "toggle" => {
                let current = nodes.get(index as usize).and_then(|n| n.checked)?;
                (
                    "toggle",
                    serde_json::json!({
                        "index": index,
                        "key": item_key,
                        "checked": !current,
                    }),
                )
            }
            _ => (
                "select",
                serde_json::json!({ "index": index, "key": item_key }),
            ),
        };
        Some(crate::widgets::HitArea {
            overlay: false,
            widget_key: widget_key.to_string(),
            widget_kind: "tree",
            buffer_row: 0,
            byte_start: 0,
            byte_end: 0,
            payload,
            event_type,
        })
    }

    /// Rebuild the `HitArea` the renderer would have emitted for a keyed
    /// control widget that recorded no hit — because the TUI clipped it (a
    /// native frontend grows a floating panel to fit its content) or
    /// because the frontend renders states the TUI's hit window didn't
    /// (e.g. a dropdown's option rows). State comes from the panel's own
    /// spec: a disabled Button synthesizes nothing (the renderer records no
    /// hit for it either), a Toggle's `checked` and a Dropdown's option
    /// bounds are read from the spec, never trusted from the frontend.
    fn synthesize_control_hit(
        panel: &crate::widgets::WidgetPanelState,
        widget_key: &str,
        event_type: &str,
        payload: &serde_json::Value,
    ) -> Option<crate::widgets::HitArea> {
        if widget_key.is_empty() {
            return None;
        }
        let spec = crate::widgets::find_widget_by_key(&panel.spec, widget_key)?;
        use fresh_core::api::WidgetSpec as W;
        let (widget_kind, event_type, payload): (_, &'static str, _) = match (spec, event_type) {
            (W::Button { disabled, .. }, "activate") if !disabled => {
                ("button", "activate", serde_json::json!({}))
            }
            (W::Toggle { checked, .. }, "toggle") => (
                "toggle",
                "toggle",
                serde_json::json!({ "checked": !checked }),
            ),
            (W::Text { .. }, "focus") => ("text", "focus", serde_json::json!({})),
            (W::Number { .. }, "number_value") => ("number", "number_value", serde_json::json!({})),
            (W::Dropdown { .. }, "dropdown_toggle") => {
                ("dropdown", "dropdown_toggle", serde_json::json!({}))
            }
            (W::Dropdown { options, .. }, "dropdown_select") => {
                let index = payload.get("index")?.as_i64()?;
                if index < 0 || index as usize >= options.len() {
                    return None;
                }
                (
                    "dropdown",
                    "dropdown_select",
                    serde_json::json!({ "index": index }),
                )
            }
            (W::DualList { options, .. }, "dual_focus") => {
                let column = payload.get("column")?.as_str()?;
                if column != "available" && column != "included" {
                    return None;
                }
                let index = payload.get("index")?.as_i64()?;
                // Loose bound: either column's row count can never exceed
                // the full option universe; the click handler clamps to the
                // live column length itself.
                if index < 0 || index as usize >= options.len().max(1) {
                    return None;
                }
                (
                    "dual_list",
                    "dual_focus",
                    serde_json::json!({ "column": column, "index": index }),
                )
            }
            _ => return None,
        };
        Some(crate::widgets::HitArea {
            overlay: false,
            widget_key: widget_key.to_string(),
            widget_kind,
            buffer_row: 0,
            byte_start: 0,
            byte_end: 0,
            payload,
            event_type,
        })
    }

    /// Native-frontend entry point: place a text widget's caret at a flat
    /// byte offset into its value. A browser input positions its caret
    /// natively on click (it owns the font metrics), then reports the
    /// position here so the host `TextEdit` — the single source of truth —
    /// follows. `set_cursor_from_flat` clamps, snaps to a grapheme
    /// boundary, and clears any selection (matching a plain GUI click).
    pub fn set_widget_text_cursor(
        &mut self,
        plugin: &str,
        panel_id: u64,
        widget_key: &str,
        byte: usize,
    ) {
        let panel_key = crate::widgets::PanelKey::new(plugin, panel_id);
        let Some(panel) = self.widget_registry.get_mut(&panel_key) else {
            return;
        };
        let Some(crate::widgets::WidgetInstanceState::Text { editor: te, .. }) =
            panel.instance_states.get_mut(widget_key)
        else {
            return;
        };
        te.set_cursor_from_flat(byte);
        self.rerender_widget_panel(&panel_key);
    }

    /// Deliver a `widget_event` hook to the plugin owning `panel_key` —
    /// and to that plugin only. Panel ids are plugin-local, so the event
    /// carries the bare id; no other plugin ever sees it.
    pub(crate) fn fire_widget_event(
        &self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: String,
        event_type: String,
        payload: serde_json::Value,
    ) {
        let pm = self.plugin_manager.read().unwrap();
        if !pm.has_hook_handlers("widget_event") {
            return;
        }
        pm.run_hook_for_plugin(
            &panel_key.plugin,
            "widget_event",
            fresh_core::hooks::HookArgs::WidgetEvent {
                panel_id: panel_key.id,
                widget_key,
                event_type,
                payload,
            },
        );
    }

    /// Apply a `RenderOutput`'s focus-cursor position to the panel
    /// buffer + every split rendering it. When a `TextInput` is
    /// focused, the dispatcher flips `show_cursors=true` and moves
    /// the primary cursor to the right byte. When no TextInput is
    /// focused, the cursor is hidden (`show_cursors=false`) — the
    /// focused widget's own bg overlay shows where focus is.
    ///
    /// Must be called *after* `set_virtual_buffer_content` so the
    /// buffer's text matches the row/byte coordinates the renderer
    /// produced.
    pub(super) fn apply_widget_focus_cursor(
        &mut self,
        buffer_id: BufferId,
        entries: &[fresh_core::text_property::TextPropertyEntry],
        focus_cursor: Option<crate::widgets::FocusCursor>,
    ) {
        // If the plugin has taken explicit control of this buffer's cursor
        // (via `setBufferShowCursors`), the widget runtime must not touch
        // its visibility or position — the plugin owns it. This lets a
        // widget-panel pane be cursor-driven (e.g. git log's commit list)
        // without each repaint clearing the cursor.
        let locked = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.get(&buffer_id))
            .map(|s| s.cursor_visibility_locked)
            .unwrap_or(false);
        if locked {
            return;
        }

        let absolute_byte = focus_cursor.map(|fc| {
            let row = fc.buffer_row as usize;
            let prefix: usize = entries.iter().take(row).map(|e| e.text.len()).sum();
            prefix + fc.byte_in_row as usize
        });

        if let Some(state) = self
            .windows
            .get_mut(&self.active_window)
            .map(|w| &mut w.buffers)
            .expect("active window present")
            .get_mut(&buffer_id)
        {
            state.show_cursors = absolute_byte.is_some();
        }

        if let Some(byte) = absolute_byte {
            for vs in self
                .windows
                .get_mut(&self.active_window)
                .and_then(|w| w.split_view_states_mut())
                .expect("active window must have a populated split layout")
                .values_mut()
            {
                if vs.buffer_state(buffer_id).is_some() {
                    let cursor = vs.cursors.primary_mut();
                    cursor.position = byte;
                }
            }
        }
    }

    /// Best-effort width for a buffer's containing split. Returns
    /// the most recent `SplitViewState::viewport.width` for any
    /// split rendering this buffer; falls back to terminal width
    /// when the buffer hasn't been rendered yet (e.g. mid-mount).
    /// Subtracts 2 columns to account for gutter/scrollbar/border
    /// padding the renderer adds — leaving the right edge clear
    /// instead of pushing content into the chrome. This is what
    /// flex `Spacer`s inside `Row` use to size their fill.
    pub(super) fn widget_panel_width(&self, buffer_id: BufferId) -> u32 {
        let raw = self
            .windows
            .get(&self.active_window)
            .and_then(|w| w.buffers.splits())
            .map(|(_, vs)| vs)
            .expect("active window must have a populated split layout")
            .values()
            .find(|vs| vs.buffer_state(buffer_id).is_some() && vs.viewport.width > 0)
            .map(|vs| vs.viewport.width as u32)
            .unwrap_or_else(|| self.terminal_width.max(1) as u32);
        // Reserve 2 cols for gutter/scrollbar/border. Saturate to
        // avoid 0 width on tiny panels.
        raw.saturating_sub(2).max(10)
    }

    /// Re-render an existing widget panel after an in-host state
    /// change (focus advance, scroll move, etc.) without the plugin
    /// re-emitting the spec. Reads the panel's current spec from
    /// the registry, runs `render_spec` against the (possibly
    /// updated) prev state / focus key, writes the result back.
    pub(super) fn rerender_widget_panel(&mut self, panel_key: &crate::widgets::PanelKey) {
        // The spec already lives in the registry — mutations (e.g.
        // `append_tree_nodes_in_spec`) edit it in place. Borrow it for
        // render, then write back only the side-effects (hits, instance
        // states, focus key, tabbable). The previous shape cloned the
        // whole spec out, rendered, then moved it back — for a Tree
        // with 5 000 nodes that's a multi-MB deep clone per IPC, which
        // dominates the host's per-mutation cost during a streaming
        // search.
        let (buffer_id, _is_floating, panel_width, out_pieces) = {
            let (buffer_id, spec) = match self.widget_registry.buffer_and_spec_ref(panel_key) {
                Some(s) => s,
                None => return,
            };
            let prev = self
                .widget_registry
                .instance_states(panel_key)
                .cloned()
                .unwrap_or_default();
            let prev_focus = self
                .widget_registry
                .focus_key(panel_key)
                .map(|s| s.to_string())
                .unwrap_or_default();
            let panel_slot = Self::slot_for_panel_buffer(buffer_id);
            let is_floating = panel_slot.is_some();
            let panel_width = if let Some(slot) = panel_slot {
                self.floating_panel_inner_width(slot)
            } else {
                self.widget_panel_width(buffer_id)
            };
            // Floating panels that opted into the focus-marker gutter
            // (the Orchestrator New Session form) must re-render
            // through the same marker renderer on every host-driven
            // refresh — otherwise a Tab / focus advance would repaint
            // the panel without the gutter and the layout would jump.
            let focus_marker = panel_slot
                .and_then(|slot| self.panel(slot))
                .map(|f| f.focus_marker)
                .unwrap_or(false);
            // This is also the path a hover change re-renders through, so
            // the panel's tracked hover key has to reach the renderer here
            // — otherwise entering a `×` would repaint it unhighlighted.
            let hover_key = panel_slot
                .and_then(|slot| self.panel(slot))
                .map(|f| f.hovered_widget_key.clone())
                .unwrap_or_default();
            let theme_guard = self.theme.read().unwrap();
            let out = render_floating_spec(
                focus_marker,
                spec,
                &prev,
                &prev_focus,
                panel_width,
                &hover_key,
                Some(crate::widgets::MarkdownCtx {
                    theme: &theme_guard,
                    grammars: Some(self.grammar_registry.as_ref()),
                }),
            );
            (buffer_id, is_floating, panel_width, out)
        };
        let _ = panel_width;
        let panel_slot = Self::slot_for_panel_buffer(buffer_id);
        let focus_cursor = out_pieces.focus_cursor;
        let entries = out_pieces.entries;
        let embeds = out_pieces.embeds;
        let overlays = out_pieces.overlays;
        let scroll_regions = out_pieces.scroll_regions;
        let dropdown_popup = out_pieces.dropdown_popup;
        if self
            .widget_registry
            .update_side_effects(
                panel_key,
                out_pieces.hits,
                out_pieces.instance_states,
                out_pieces.focus_key,
                out_pieces.tabbable,
                scroll_regions.clone(),
            )
            .is_none()
        {
            tracing::warn!("rerender_widget_panel({}) lost panel mid-call", panel_key);
            return;
        }
        if let Some(slot) = panel_slot {
            if let Some(fwp) = self.panel_mut(slot) {
                if &fwp.panel_key == panel_key {
                    fwp.entries = entries;
                    fwp.focus_cursor = focus_cursor;
                    fwp.embeds = embeds;
                    fwp.overlays = overlays;
                    fwp.scroll_regions = scroll_regions;
                    fwp.dropdown_popup = dropdown_popup;
                }
            }
            return;
        }
        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries.clone()) {
            tracing::error!("rerender_widget_panel({}) failed: {}", panel_key, e);
        }
        self.apply_widget_focus_cursor(buffer_id, &entries, focus_cursor);
    }

    pub(super) fn handle_widget_command(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        action: fresh_core::api::WidgetAction,
    ) {
        use fresh_core::api::WidgetAction;
        match action {
            WidgetAction::FocusAdvance { delta } => {
                self.handle_widget_focus_advance(panel_key, delta);
            }
            WidgetAction::Activate => {
                self.handle_widget_activate(panel_key);
            }
            WidgetAction::SelectMove { delta } => {
                self.handle_widget_select_move(panel_key, delta);
            }
            WidgetAction::TextInputKey { key } => {
                self.handle_widget_text_key(panel_key, &key);
            }
            WidgetAction::TextInputChar { text } => {
                self.handle_widget_text_char(panel_key, &text);
            }
            WidgetAction::Key { key } => {
                self.handle_widget_key(panel_key, &key);
            }
        }
    }

    fn handle_widget_key(&mut self, panel_key: &crate::widgets::PanelKey, key: &str) {
        // Smart key dispatch — route to the right specialized
        // handler based on focused widget kind. See WidgetAction::Key
        // doc for the dispatch table.
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let focus_key = panel.focus_key.clone();
        // Completion-popup short-circuit: when the focused Text
        // widget has an open completion popup, intercept Tab /
        // Up / Down / Enter / Esc so they drive the popup instead
        // of falling through to the widget's default key
        // behaviour. Tab fires `completion_accept`, Enter/Esc
        // dismiss, Up/Down move the host-managed selection. Any
        // other key (printable, Backspace, etc.) still goes to
        // the text editor, which lets the user keep typing to
        // refine the candidate list.
        let completions_open = matches!(key, "Tab" | "Up" | "Down" | "Enter" | "Escape")
            && self.focused_text_completions_open(panel_key);
        if completions_open {
            match key {
                "Up" => {
                    self.move_focused_text_completion_index(panel_key, -1);
                    // Selection moved host-side; force a repaint
                    // so the highlight + scroll-into-view shift
                    // is visible without waiting for the next
                    // unrelated mutation.
                    self.rerender_widget_panel(panel_key);
                    return;
                }
                "Down" => {
                    self.move_focused_text_completion_index(panel_key, 1);
                    self.rerender_widget_panel(panel_key);
                    return;
                }
                "Escape" => {
                    // First Esc only closes the popup — the form stays
                    // open. (A second Esc, with no popup, cancels.)
                    self.dismiss_focused_text_completions(panel_key);
                    self.rerender_widget_panel(panel_key);
                    return;
                }
                "Enter" => {
                    if self.focused_text_completion_navigated(panel_key) {
                        // The user stepped into the dropdown and Enter
                        // accepts the highlighted candidate.
                        self.fire_completion_accept(panel_key);
                        return;
                    }
                    // Not navigated: the dropdown must not swallow the
                    // submit. Close it, then fall through so Enter acts
                    // on the form (advance / submit) below.
                    self.dismiss_focused_text_completions(panel_key);
                }
                "Tab" => {
                    if self.focused_text_completion_navigated(panel_key) {
                        // The user stepped into the dropdown (↑/↓/wheel)
                        // so a row is highlighted — Tab applies it and
                        // closes the popup, just like Enter. Focus stays
                        // on the field so the accepted value is visible
                        // and editable (a second Tab then advances).
                        self.fire_completion_accept(panel_key);
                        return;
                    }
                    // Nothing highlighted (a freshly surfaced popup): Tab
                    // commits the typed text and moves focus. Close the
                    // popup, then fall through to the focus-advance
                    // dispatch below.
                    self.dismiss_focused_text_completions(panel_key);
                }
                _ => {}
            }
        }
        // Dropdown-popup short-circuit: while a focused Dropdown's
        // option popup is open, Up/Down move the (live) selection,
        // Enter/Space commit-and-close, Esc closes. Mirrors the
        // completions short-circuit above.
        let dropdown_open = matches!(key, "Up" | "Down" | "Enter" | "Space" | "Escape")
            && self.focused_dropdown_open(panel_key);
        if dropdown_open {
            match key {
                "Up" => {
                    self.handle_widget_dropdown_cycle(panel_key, &focus_key, -1);
                    return;
                }
                "Down" => {
                    self.handle_widget_dropdown_cycle(panel_key, &focus_key, 1);
                    return;
                }
                "Enter" | "Space" | "Escape" => {
                    // The selection is already live (Up/Down fired
                    // `change`); closing just dismisses the list.
                    self.set_dropdown_open(panel_key, &focus_key, false);
                    return;
                }
                _ => {}
            }
        }
        // Re-fetch the focused widget for the main dispatch: the
        // completion block above may have run `&mut self` (dismissing a
        // popup), so we can't hold a borrow from before it. The spec is
        // unchanged by a dismiss, so this resolves to the same widget.
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let widget = if focus_key.is_empty() {
            None
        } else {
            crate::widgets::find_widget_by_key(&panel.spec, &focus_key)
        };
        match key {
            "Tab" => self.handle_widget_focus_advance(panel_key, 1),
            "Shift+Tab" => self.handle_widget_focus_advance(panel_key, -1),
            "Up" | "Down" => {
                let delta = if key == "Up" { -1 } else { 1 };
                match widget {
                    Some(fresh_core::api::WidgetSpec::List { .. }) => {
                        self.handle_widget_select_move(panel_key, delta);
                    }
                    Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                        self.handle_widget_tree_select_move(panel_key, delta);
                    }
                    Some(fresh_core::api::WidgetSpec::Text { rows, .. }) if *rows > 1 => {
                        // Multi-line Text: line nav. Single-line
                        // is filtered out — TextEdit::move_up /
                        // move_down would no-op on the single
                        // line, but skipping the dispatch keeps
                        // the change-event quiet.
                        self.handle_widget_text_key(panel_key, key);
                    }
                    Some(fresh_core::api::WidgetSpec::Number { .. }) => {
                        // Up increments, Down decrements (the reverse
                        // of the list's Up = select-previous).
                        let step_delta = if key == "Up" { 1 } else { -1 };
                        self.handle_widget_number_adjust(panel_key, &focus_key, step_delta);
                    }
                    Some(fresh_core::api::WidgetSpec::Dropdown { .. }) => {
                        // Up = previous option, Down = next option
                        // (wrapping), matching the ◂/▸ glyphs.
                        let cycle = if key == "Up" { -1 } else { 1 };
                        self.handle_widget_dropdown_cycle(panel_key, &focus_key, cycle);
                    }
                    Some(fresh_core::api::WidgetSpec::DualList { .. }) => {
                        let d = if key == "Up" { -1 } else { 1 };
                        self.handle_widget_dual(panel_key, &focus_key, DualOp::CursorMove(d));
                    }
                    _ => {
                        // Picker-style nav: when the focused widget
                        // doesn't have a meaningful Up/Down (single-
                        // line Text, Button, Toggle, or no focus),
                        // route the arrow to the first scrollable
                        // widget in the panel.
                        let on_button = matches!(
                            widget,
                            Some(fresh_core::api::WidgetSpec::Button { .. })
                                | Some(fresh_core::api::WidgetSpec::Toggle { .. })
                        );
                        let scrollable = self
                            .widget_registry
                            .get(panel_key)
                            .and_then(|p| find_scrollable_widget_key(&p.spec));
                        if scrollable.is_none() && on_button {
                            // Button-only popups (the dock's right-click
                            // context menu, confirm panes): arrows walk
                            // the controls like Tab / Shift+Tab, matching
                            // every other menu in the dock. Previously
                            // ↑/↓ were silently dropped here.
                            self.handle_widget_focus_advance(panel_key, delta);
                        }
                        if let Some(target_key) = scrollable {
                            let target_kind = self.widget_registry.get(panel_key).and_then(|p| {
                                crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
                            });
                            match target_kind {
                                Some(fresh_core::api::WidgetSpec::List { .. }) => {
                                    // A List peek keeps the filter input
                                    // focused for typing while the arrow moves
                                    // the list selection.
                                    self.handle_widget_select_move_for_key(
                                        panel_key,
                                        &target_key,
                                        delta,
                                    );
                                }
                                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                                    // A Tree is a real (tabbable) focus target.
                                    // Peek-forwarding here would move the tree's
                                    // selection while the previously focused
                                    // button/field keeps its focus ring — two
                                    // focused elements at once, and Enter would
                                    // still act on the button, not the
                                    // highlighted row. Move focus *into* the
                                    // tree so it becomes the single focused
                                    // element; set_panel_focus_and_notify seeds
                                    // its selection to the first visible row.
                                    self.set_panel_focus_and_notify(panel_key, target_key.clone());
                                    self.rerender_widget_panel(panel_key);
                                }
                                _ => {}
                            }
                        }
                    }
                }
            }
            "PageUp" | "PageDown" => {
                // DualList repurposes PageUp/PageDown to reorder the
                // focused Included item up/down.
                if let Some(fresh_core::api::WidgetSpec::DualList { .. }) = widget {
                    let d = if key == "PageUp" { -1 } else { 1 };
                    self.handle_widget_dual(panel_key, &focus_key, DualOp::Reorder(d));
                    return;
                }
                // Page step = visible_rows - 1 (one row of overlap so
                // the user keeps a visual anchor across pages). Ignored
                // for non-scrollable widgets. A Tree paces in *nodes*, so
                // a multi-row card tree divides the row budget by its
                // fixed item height first.
                let page = match widget {
                    Some(fresh_core::api::WidgetSpec::List { visible_rows, .. }) => {
                        visible_rows.saturating_sub(1).max(1) as i32
                    }
                    Some(fresh_core::api::WidgetSpec::Text { rows, .. }) if *rows > 1 => {
                        rows.saturating_sub(1).max(1) as i32
                    }
                    Some(fresh_core::api::WidgetSpec::Tree {
                        visible_rows,
                        item_height,
                        card_borders,
                        ..
                    }) => {
                        // Bordered cards take two extra rows each; folder
                        // headers take one. Dividing by the card height is
                        // a conservative page estimate (never overshoots).
                        let per_node = if *card_borders && *item_height > 1 {
                            *item_height + 2
                        } else {
                            (*item_height).max(1)
                        };
                        let nodes = visible_rows / per_node;
                        nodes.saturating_sub(1).max(1) as i32
                    }
                    _ => 0,
                };
                if page == 0 {
                    return;
                }
                let delta = if key == "PageUp" { -page } else { page };
                match widget {
                    Some(fresh_core::api::WidgetSpec::List { .. }) => {
                        self.handle_widget_select_move(panel_key, delta);
                    }
                    Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                        self.handle_widget_tree_select_move(panel_key, delta);
                    }
                    Some(fresh_core::api::WidgetSpec::Text { .. }) => {
                        // Multi-line text: page the caret (the viewport
                        // follows it), one row of overlap like the lists.
                        let down = delta > 0;
                        let rearmed = self.clear_focused_text_user_scrolled(panel_key);
                        let changed = self.with_focused_text_editor(panel_key, |editor| {
                            for _ in 0..delta.unsigned_abs() {
                                if down {
                                    editor.move_down();
                                } else {
                                    editor.move_up();
                                }
                            }
                        });
                        if rearmed && !changed {
                            self.rerender_widget_panel(panel_key);
                        }
                    }
                    _ => {}
                }
            }
            "Left" | "Right" => match widget {
                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
                    self.handle_widget_text_key(panel_key, key);
                }
                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                    self.handle_widget_tree_lateral(panel_key, key == "Right");
                }
                Some(fresh_core::api::WidgetSpec::Number { .. }) => {
                    let step_delta = if key == "Right" { 1 } else { -1 };
                    self.handle_widget_number_adjust(panel_key, &focus_key, step_delta);
                }
                Some(fresh_core::api::WidgetSpec::Dropdown { .. }) => {
                    let cycle = if key == "Right" { 1 } else { -1 };
                    self.handle_widget_dropdown_cycle(panel_key, &focus_key, cycle);
                }
                Some(fresh_core::api::WidgetSpec::DualList { .. }) => {
                    // Left focuses the Available column, Right the Included.
                    self.handle_widget_dual(
                        panel_key,
                        &focus_key,
                        DualOp::SwitchColumn(key == "Right"),
                    );
                }
                _ => {}
            },
            "Backspace" | "Delete" | "Home" | "End" => {
                if let Some(fresh_core::api::WidgetSpec::Text { .. }) = widget {
                    self.handle_widget_text_key(panel_key, key);
                }
            }
            // Selection / word-motion chords for text widgets. Routed by
            // name so a plugin panel mode can bind e.g. `S-Down` and
            // dispatch it straight through (the markdown document view's
            // keyboard selection rides this).
            "S-Up" | "S-Down" | "S-Left" | "S-Right" | "S-Home" | "S-End" | "C-Left"
            | "C-Right" | "C-S-Left" | "C-S-Right" => {
                if let Some(fresh_core::api::WidgetSpec::Text { .. }) = widget {
                    self.handle_widget_text_key(panel_key, key);
                }
            }
            // Clipboard chords on a focused text widget. These also
            // arrive via the editor-level Copy/Cut/Select-All routing
            // when the panel buffer holds focus; the explicit names let
            // custom panel modes (the tour) dispatch them directly.
            "C-c" => {
                if matches!(widget, Some(fresh_core::api::WidgetSpec::Text { .. })) {
                    self.handle_widget_copy(panel_key);
                }
            }
            "C-x" => {
                if matches!(widget, Some(fresh_core::api::WidgetSpec::Text { .. })) {
                    self.handle_widget_cut(panel_key);
                }
            }
            "C-a" => {
                if matches!(widget, Some(fresh_core::api::WidgetSpec::Text { .. })) {
                    self.handle_widget_select_all(panel_key);
                }
            }
            "Enter" => match widget {
                Some(fresh_core::api::WidgetSpec::Button { .. })
                | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
                    self.handle_widget_activate(panel_key);
                }
                Some(fresh_core::api::WidgetSpec::DualList { .. }) => {
                    // Form-like: Enter commits the column edits and
                    // moves to the next widget.
                    self.handle_widget_focus_advance(panel_key, 1);
                }
                Some(fresh_core::api::WidgetSpec::Dropdown { .. }) => {
                    // Closed dropdown (open case handled above): Enter
                    // opens the option popup.
                    self.set_dropdown_open(panel_key, &focus_key, true);
                }
                Some(fresh_core::api::WidgetSpec::List { .. }) => {
                    self.fire_list_activate(panel_key, &focus_key);
                }
                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                    self.fire_tree_activate(panel_key, &focus_key);
                }
                Some(fresh_core::api::WidgetSpec::Text { rows, .. }) => {
                    if *rows > 1 {
                        // Multi-line: Enter inserts a newline at the
                        // cursor. Plugins that want Enter to submit
                        // can intercept it in their mode binding
                        // before dispatching through the smart-key
                        // router.
                        self.handle_widget_text_key(panel_key, "Enter");
                    } else if let Some(target_key) = self
                        .widget_registry
                        .get(panel_key)
                        .and_then(|p| find_scrollable_widget_key(&p.spec))
                    {
                        // Picker-style activate: a single-line filter
                        // input paired with a List/Tree fires that
                        // scrollable's activate event on Enter, so the
                        // user can type-then-Enter without tabbing
                        // focus to the list.
                        let kind = self.widget_registry.get(panel_key).and_then(|p| {
                            crate::widgets::find_widget_by_key(&p.spec, &target_key).cloned()
                        });
                        match kind {
                            Some(fresh_core::api::WidgetSpec::List { .. }) => {
                                self.fire_list_activate(panel_key, &target_key);
                            }
                            Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                                self.fire_tree_activate(panel_key, &target_key);
                            }
                            _ => {}
                        }
                    } else {
                        // Form-like UX: Enter commits the field and
                        // moves to the next tabbable widget.
                        self.handle_widget_focus_advance(panel_key, 1);
                    }
                }
                _ => {}
            },
            "Space" => match widget {
                Some(fresh_core::api::WidgetSpec::Button { .. })
                | Some(fresh_core::api::WidgetSpec::Toggle { .. }) => {
                    self.handle_widget_activate(panel_key);
                }
                Some(fresh_core::api::WidgetSpec::Dropdown { .. }) => {
                    // Closed dropdown (the open case is handled by the
                    // short-circuit above): Space opens the option popup.
                    self.set_dropdown_open(panel_key, &focus_key, true);
                }
                Some(fresh_core::api::WidgetSpec::DualList { .. }) => {
                    // Space moves the focused item across columns.
                    self.handle_widget_dual(panel_key, &focus_key, DualOp::MoveAcross);
                }
                Some(fresh_core::api::WidgetSpec::Text { .. }) => {
                    self.handle_widget_text_char(panel_key, " ");
                }
                Some(fresh_core::api::WidgetSpec::List { .. }) => {
                    self.fire_list_activate(panel_key, &focus_key);
                }
                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                    // On a checkable Tree, Space is the conventional
                    // checkbox key — fire `toggle` for the focused row
                    // (matching what a click on its `[v]`/`[ ]` glyph
                    // would do). Falls back to `activate` for trees
                    // that aren't checkable, or rows that don't have
                    // a checkbox glyph (`checked: None`).
                    let toggled = self.fire_tree_toggle_if_checkable(panel_key, &focus_key);
                    if !toggled {
                        self.fire_tree_activate(panel_key, &focus_key);
                    }
                }
                _ => {}
            },
            _ => {} // unrecognised key — quietly ignore
        }
    }

    fn handle_widget_focus_advance(&mut self, panel_key: &crate::widgets::PanelKey, delta: i32) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        if panel.tabbable.is_empty() {
            return;
        }
        let cur_idx = panel
            .tabbable
            .iter()
            .position(|k| k == &panel.focus_key)
            .unwrap_or(0) as i32;
        let n = panel.tabbable.len() as i32;
        let new_idx = ((cur_idx + delta) % n + n) % n;
        let new_key = panel.tabbable[new_idx as usize].clone();
        self.set_panel_focus_and_notify(panel_key, new_key);
        self.rerender_widget_panel(panel_key);
    }

    /// Update the panel's focused widget AND fire a
    /// `widget_event { event_type: "focus" }` so plugins can
    /// react. Used by every host-driven focus move — key-driven
    /// Tab / Shift-Tab / Enter focus-advance, click-driven
    /// focus moves, etc. — so plugins never have to predict the
    /// host's focus rules to keep a local mirror in sync.
    ///
    /// No-op when the key isn't actually changing (avoids
    /// spurious events on every render that touches focus).
    pub(crate) fn set_panel_focus_and_notify(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        new_key: String,
    ) {
        let old_key = self
            .widget_registry
            .focus_key(panel_key)
            .map(|s| s.to_string())
            .unwrap_or_default();
        if old_key == new_key {
            tracing::debug!(
                target: "fresh::dock",
                panel = %panel_key,
                key = %new_key,
                "set_panel_focus_and_notify: no-op (old == new)"
            );
            return;
        }
        tracing::debug!(
            target: "fresh::dock",
            panel = %panel_key,
            old = %old_key,
            new = %new_key,
            "set_panel_focus_and_notify: firing `focus` widget_event"
        );
        self.widget_registry
            .set_focus_key(panel_key, new_key.clone());
        // Keep exactly one focused element when focus crosses a Tree
        // boundary: clear a blurred tree's selection and seed a newly
        // focused tree's, so the tree's selected-row highlight never
        // lingers next to another widget's focus ring (and Tab focus is
        // never invisible).
        self.sync_tree_focus_selection(panel_key, &old_key, &new_key);
        self.fire_widget_event(
            panel_key,
            new_key,
            "focus".to_string(),
            serde_json::json!({ "previous": old_key }),
        );
    }

    /// Keep the single-focus invariant consistent when focus moves
    /// between a `Tree` widget and other controls in the same panel.
    ///
    /// A Tree renders a highlight on its selected row independent of the
    /// panel focus — that is deliberate, so editor-driven match
    /// navigation (`search_replace_next_match`) can highlight a row while
    /// the panel is unfocused. The cost is that focus moving *within* the
    /// panel could leave a toolbar button's focus ring next to a
    /// highlighted tree row (two focused elements), or Tab onto the tree
    /// with no visible selection (invisible focus). To keep exactly one
    /// focused element:
    ///   * clear the previously focused Tree's selection on blur, and
    ///   * seed the newly focused Tree's selection to its first visible
    ///     row when it has none.
    fn sync_tree_focus_selection(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        old_key: &str,
        new_key: &str,
    ) {
        if old_key != new_key && !old_key.is_empty() && self.widget_key_is_tree(panel_key, old_key)
        {
            if let Some(panel) = self.widget_registry.get_mut(panel_key) {
                Self::set_widget_selected_index_state(panel, old_key, -1);
            }
        }
        if !new_key.is_empty() && self.widget_key_is_tree(panel_key, new_key) {
            let cur_sel = match self
                .widget_registry
                .get(panel_key)
                .and_then(|p| p.instance_states.get(new_key))
            {
                Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
                    *selected_index
                }
                _ => -1,
            };
            if cur_sel < 0 {
                if let Some(first) = self.first_visible_tree_index(panel_key, new_key) {
                    if let Some(panel) = self.widget_registry.get_mut(panel_key) {
                        Self::set_widget_selected_index_state(panel, new_key, first);
                    }
                }
            }
        }
    }

    /// True when `key` names a `Tree` widget in `panel_key`'s spec.
    fn widget_key_is_tree(&self, panel_key: &crate::widgets::PanelKey, key: &str) -> bool {
        self.widget_registry
            .get(panel_key)
            .and_then(|p| crate::widgets::find_widget_by_key(&p.spec, key))
            .map(|w| matches!(w, fresh_core::api::WidgetSpec::Tree { .. }))
            .unwrap_or(false)
    }

    /// First visible (un-collapsed) absolute node index of the Tree at
    /// `tree_key`, honoring the host's instance-state expansion set
    /// (falling back to the spec's initial `expanded_keys`). `None` when
    /// the widget isn't a Tree or has no visible rows.
    fn first_visible_tree_index(
        &self,
        panel_key: &crate::widgets::PanelKey,
        tree_key: &str,
    ) -> Option<i32> {
        let panel = self.widget_registry.get(panel_key)?;
        let (nodes, item_keys, spec_expanded) =
            match crate::widgets::find_widget_by_key(&panel.spec, tree_key)? {
                fresh_core::api::WidgetSpec::Tree {
                    nodes,
                    item_keys,
                    expanded_keys,
                    ..
                } => (nodes, item_keys, expanded_keys),
                _ => return None,
            };
        let expanded = match panel.instance_states.get(tree_key) {
            Some(crate::widgets::WidgetInstanceState::Tree { expanded_keys, .. }) => {
                expanded_keys.clone()
            }
            _ => spec_expanded.iter().cloned().collect(),
        };
        let visible = collect_visible_tree_indices(nodes, item_keys, &expanded);
        visible.first().map(|&i| i as i32)
    }

    fn handle_widget_activate(&mut self, panel_key: &crate::widgets::PanelKey) {
        // Fire `widget_event` based on the focused widget's kind.
        // Button → "activate"; Toggle → "toggle" (with the
        // computed-new payload); other kinds: no-op.
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let focus_key = panel.focus_key.clone();
        if focus_key.is_empty() {
            return;
        }
        let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
        let (event_type, payload) = match widget {
            // Disabled buttons don't fire activate. The renderer
            // already excludes them from the tab cycle and skips
            // their hit area, so the only way `focus_key` could
            // still point at a disabled button is a stale focus
            // from before the disable transition — drop the event
            // in that race.
            Some(fresh_core::api::WidgetSpec::Button { disabled: true, .. }) => return,
            Some(fresh_core::api::WidgetSpec::Button { .. }) => ("activate", serde_json::json!({})),
            Some(fresh_core::api::WidgetSpec::Toggle { checked, .. }) => {
                ("toggle", serde_json::json!({ "checked": !checked }))
            }
            _ => return,
        };
        self.fire_widget_event(panel_key, focus_key, event_type.to_string(), payload);
    }

    /// Fire a `widget_event { event_type: "activate", payload: {
    /// index, key } }` for the focused List, using its instance-state
    /// selection (or spec selection on first render). The plugin's
    /// activate handler does the actual user-visible thing — open
    /// the matched file, expand/collapse a tree node, etc.
    /// True when the focused widget on `panel_key` is a Text input
    /// whose host-managed completion popup is currently open
    /// (instance state has at least one candidate). Lets the
    /// smart-key dispatcher route Tab/Enter/Up/Down/Esc to the
    /// popup-specific paths before falling through to the
    /// widget's default key behaviour.
    fn focused_text_completions_open(&self, panel_key: &crate::widgets::PanelKey) -> bool {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return false,
        };
        if panel.focus_key.is_empty() {
            return false;
        }
        matches!(
            panel.instance_states.get(&panel.focus_key),
            Some(crate::widgets::WidgetInstanceState::Text { completions, .. })
                if !completions.is_empty()
        )
    }

    /// Has the user explicitly stepped into the focused Text widget's
    /// open completion popup (via ↑/↓ / wheel)? Drives the Tab/Enter
    /// dispatch: only a *navigated* popup accepts on Enter — a freshly
    /// surfaced one lets Enter act on the form instead.
    fn focused_text_completion_navigated(&self, panel_key: &crate::widgets::PanelKey) -> bool {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return false,
        };
        if panel.focus_key.is_empty() {
            return false;
        }
        matches!(
            panel.instance_states.get(&panel.focus_key),
            Some(crate::widgets::WidgetInstanceState::Text {
                completions,
                completion_navigated,
                ..
            }) if !completions.is_empty() && *completion_navigated
        )
    }

    /// Move the selected-index cursor of the focused Text widget's
    /// completion popup by `delta` (Up = -1, Down = +1). Clamps
    /// at the ends rather than wrapping — Down past the last
    /// candidate stays on the last candidate, Up past the first
    /// stays on the first. Wraparound on a popup-style picker
    /// reads as "I scrolled past the bottom and now I'm at the
    /// top" which is jarring when the user is actively comparing
    /// items they expect to be in monotonic positions. No-op
    /// when the focused widget isn't a Text-with-open-
    /// completions.
    fn move_focused_text_completion_index(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        delta: i32,
    ) {
        // First read the spec's visible-rows cap so we can pull
        // scroll back into view if the new selection lands above
        // the current scroll offset. (The renderer only does
        // forward-pull — it would otherwise fight the mouse-
        // wheel handler which deliberately diverges scroll from
        // selection.)
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let focus_key = panel.focus_key.clone();
        if focus_key.is_empty() {
            return;
        }
        let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
            Some(fresh_core::api::WidgetSpec::Text {
                completions_visible_rows,
                ..
            }) => *completions_visible_rows,
            _ => 0,
        };
        let visible = if spec_visible_rows == 0 {
            5u32
        } else {
            spec_visible_rows
        };
        let panel = match self.widget_registry.get_mut(panel_key) {
            Some(p) => p,
            None => return,
        };
        if let Some(crate::widgets::WidgetInstanceState::Text {
            completions,
            completion_selected_index,
            completion_scroll_offset,
            completion_navigated,
            ..
        }) = panel.instance_states.get_mut(&focus_key)
        {
            if completions.is_empty() {
                return;
            }
            // The first ↑/↓ *enters* the dropdown: flip `navigated`
            // and select the current (top) row without moving, so the
            // user lands on a sensible candidate instead of skipping
            // the first one. Subsequent presses move the selection.
            if !*completion_navigated {
                *completion_navigated = true;
                return;
            }
            let max = (completions.len() - 1) as i32;
            let cur = *completion_selected_index as i32;
            let next = (cur + delta).clamp(0, max);
            *completion_selected_index = next as usize;
            // Keyboard-driven selection move: if the new
            // selection sits above the current scroll window,
            // pull the scroll back so the selection stays
            // visible. Forward-pull is handled by the renderer.
            let next_u = next as u32;
            if next_u < *completion_scroll_offset {
                *completion_scroll_offset = next_u;
            } else if next_u >= *completion_scroll_offset + visible {
                *completion_scroll_offset = next_u + 1 - visible;
            }
        }
    }

    /// Clear the focused Text widget's completion popup (close it)
    /// and fire a `completion_dismiss` event so the plugin can
    /// sync its own state (e.g. invalidate any in-flight fetch
    /// token, so a late-arriving result doesn't re-open the
    /// popup the user just closed). Used by Enter and Escape on
    /// a Text-with-open-completions.
    fn dismiss_focused_text_completions(&mut self, panel_key: &crate::widgets::PanelKey) {
        let focus_key = {
            let panel = match self.widget_registry.get_mut(panel_key) {
                Some(p) => p,
                None => return,
            };
            let focus_key = panel.focus_key.clone();
            if focus_key.is_empty() {
                return;
            }
            if let Some(crate::widgets::WidgetInstanceState::Text {
                completions,
                completion_selected_index,
                ..
            }) = panel.instance_states.get_mut(&focus_key)
            {
                if completions.is_empty() {
                    return;
                }
                completions.clear();
                *completion_selected_index = 0;
            } else {
                return;
            }
            focus_key
        };
        self.fire_widget_event(
            panel_key,
            focus_key,
            "completion_dismiss".into(),
            serde_json::json!({}),
        );
    }

    /// Fire `completion_accept` on the focused Text widget's
    /// currently-selected candidate. Used by Tab on a Text-with-
    /// open-completions — the plugin's handler is expected to
    /// apply the accepted value to the field (typically via
    /// `WidgetMutation::SetValue`). The host does NOT close the
    /// popup automatically: directory-descent style flows (the
    /// orchestrator's Project Path acceptance of `/foo/` re-
    /// fetches children for the new path) want the popup to
    /// stay alive so the user can keep Tab-ing. Plugins that
    /// want a one-shot accept close the popup themselves with
    /// `setCompletions(key, [])`.
    fn fire_completion_accept(&mut self, panel_key: &crate::widgets::PanelKey) {
        let (focus_key, value) = {
            let panel = match self.widget_registry.get(panel_key) {
                Some(p) => p,
                None => return,
            };
            let focus_key = panel.focus_key.clone();
            if focus_key.is_empty() {
                return;
            }
            match panel.instance_states.get(&focus_key) {
                Some(crate::widgets::WidgetInstanceState::Text {
                    completions,
                    completion_selected_index,
                    ..
                }) if !completions.is_empty() => {
                    let idx = (*completion_selected_index).min(completions.len() - 1);
                    (focus_key, completions[idx].value.clone())
                }
                _ => return,
            }
        };
        self.fire_widget_event(
            panel_key,
            focus_key,
            "completion_accept".into(),
            serde_json::json!({ "value": value }),
        );
    }

    fn fire_list_activate(&mut self, panel_key: &crate::widgets::PanelKey, focus_key: &str) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
        let (spec_sel, item_keys) = match widget {
            Some(fresh_core::api::WidgetSpec::List {
                selected_index,
                item_keys,
                ..
            }) => (*selected_index, item_keys.clone()),
            _ => return,
        };
        let sel = match panel.instance_states.get(focus_key) {
            Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
                *selected_index
            }
            _ => spec_sel,
        };
        if sel < 0 {
            return;
        }
        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
        self.fire_widget_event(
            panel_key,
            focus_key.to_string(),
            "activate".into(),
            serde_json::json!({ "index": sel, "key": item_key, }),
        );
    }

    fn handle_widget_select_move(&mut self, panel_key: &crate::widgets::PanelKey, delta: i32) {
        let focus_key = match self.widget_registry.get(panel_key) {
            Some(p) => p.focus_key.clone(),
            None => return,
        };
        if focus_key.is_empty() {
            return;
        }
        self.handle_widget_select_move_for_key(panel_key, &focus_key, delta);
    }

    /// Step a `Number` widget's host-owned value by `steps * step`,
    /// clamp to `[min, max]`, repaint, and fire `change` when the
    /// value actually moved. `steps` is the number of `step` units
    /// (positive = increment); a click on `◂`/`▸` or an arrow key
    /// passes `±1`.
    pub(super) fn handle_widget_number_adjust(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        steps: i32,
    ) {
        if widget_key.is_empty() {
            return;
        }
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let (spec_value, min, max, step) =
            match crate::widgets::find_widget_by_key(&panel.spec, widget_key) {
                Some(fresh_core::api::WidgetSpec::Number {
                    value,
                    min,
                    max,
                    step,
                    ..
                }) => (*value, *min, *max, *step),
                _ => return,
            };
        let cur = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::Number { value }) => *value,
            _ => spec_value,
        };
        let raw = cur + (steps as f64) * step;
        let clamped = crate::widgets::clamp_number(raw, min, max);
        let changed = clamped != cur;
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::Number { value: clamped },
            );
        }
        self.rerender_widget_panel(panel_key);
        if changed {
            self.fire_widget_event(
                panel_key,
                widget_key.to_string(),
                "change".into(),
                serde_json::json!({ "value": clamped }),
            );
        }
    }

    /// Cycle a `Dropdown` widget's selected option by `delta`
    /// (wrapping), repaint, and fire `change` with the new
    /// `{ index, value }`. A click on `◂`/`▸` or an arrow key passes
    /// `±1`.
    pub(super) fn handle_widget_dropdown_cycle(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        delta: i32,
    ) {
        if widget_key.is_empty() {
            return;
        }
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let (options, spec_sel) = match crate::widgets::find_widget_by_key(&panel.spec, widget_key)
        {
            Some(fresh_core::api::WidgetSpec::Dropdown {
                options,
                selected_index,
                ..
            }) => (options.clone(), *selected_index),
            _ => return,
        };
        if options.is_empty() {
            return;
        }
        let (cur, open) = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::Dropdown {
                selected_index,
                open,
            }) => (*selected_index, *open),
            _ => (spec_sel, false),
        };
        let cur = cur.clamp(0, options.len() as i32 - 1);
        let new_sel = crate::widgets::wrap_index(cur, delta, options.len());
        let changed = new_sel != cur;
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::Dropdown {
                    selected_index: new_sel,
                    // Preserve the popup's open state across a cycle so
                    // Up/Down inside the open list keeps it open.
                    open,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        if changed {
            let value = options.get(new_sel as usize).cloned().unwrap_or_default();
            self.fire_widget_event(
                panel_key,
                widget_key.to_string(),
                "change".into(),
                serde_json::json!({ "index": new_sel, "value": value }),
            );
        }
    }

    /// Set a `Dropdown`'s selected index to an absolute value (a click
    /// on an option row of the open list). Clamps into the option set,
    /// fires `change` when the selection actually moved.
    pub(super) fn handle_widget_dropdown_set(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        index: i32,
    ) {
        if widget_key.is_empty() {
            return;
        }
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let (options, spec_sel) = match crate::widgets::find_widget_by_key(&panel.spec, widget_key)
        {
            Some(fresh_core::api::WidgetSpec::Dropdown {
                options,
                selected_index,
                ..
            }) => (options.clone(), *selected_index),
            _ => return,
        };
        if options.is_empty() {
            return;
        }
        let (cur, open) = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::Dropdown {
                selected_index,
                open,
            }) => (*selected_index, *open),
            _ => (spec_sel, false),
        };
        let new_sel = index.clamp(0, options.len() as i32 - 1);
        let changed = new_sel != cur.clamp(0, options.len() as i32 - 1);
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::Dropdown {
                    selected_index: new_sel,
                    open,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        if changed {
            let value = options.get(new_sel as usize).cloned().unwrap_or_default();
            self.fire_widget_event(
                panel_key,
                widget_key.to_string(),
                "change".into(),
                serde_json::json!({ "index": new_sel, "value": value }),
            );
        }
    }

    /// True when the panel's focused widget is a `Dropdown` whose
    /// option popup is currently open. Mirrors
    /// `focused_text_completions_open` — the smart-key dispatcher uses
    /// it to route Up/Down/Enter/Esc to the popup.
    pub(super) fn focused_dropdown_open(&self, panel_key: &crate::widgets::PanelKey) -> bool {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return false,
        };
        if panel.focus_key.is_empty() {
            return false;
        }
        matches!(
            panel.instance_states.get(&panel.focus_key),
            Some(crate::widgets::WidgetInstanceState::Dropdown { open: true, .. })
        )
    }

    /// Open or close a `Dropdown`'s option popup, preserving the
    /// selected index. Repaints; no `change` event (opening/closing is
    /// not a value edit — the selection change fires its own `change`).
    pub(super) fn set_dropdown_open(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        open: bool,
    ) {
        if widget_key.is_empty() {
            return;
        }
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let spec_sel = match crate::widgets::find_widget_by_key(&panel.spec, widget_key) {
            Some(fresh_core::api::WidgetSpec::Dropdown { selected_index, .. }) => *selected_index,
            _ => return,
        };
        let (cur, prev_open) = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::Dropdown {
                selected_index,
                open,
            }) => (*selected_index, *open),
            _ => (spec_sel, false),
        };
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::Dropdown {
                    selected_index: cur,
                    open,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        // Notify the plugin when the option pop-over actually opens or
        // closes (any path: keyboard, a `[value ▼]` trigger click, or an
        // option pick that closes it). The plugin mirrors this so its own
        // key handlers can tell "dropdown open" apart from "dropdown
        // closed" — e.g. Escape closes the open list but cancels the
        // dialog when it's already closed. Not a value edit, so it's a
        // distinct `dropdown_open` event, never `change`.
        if open != prev_open {
            self.fire_widget_event(
                panel_key,
                widget_key.to_string(),
                "dropdown_open".into(),
                serde_json::json!({ "open": open }),
            );
        }
    }

    /// Apply a `DualList` interaction, update the host-owned instance
    /// state, repaint, and fire `change` with the new `included` order
    /// when it actually changed.
    pub(super) fn handle_widget_dual(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        op: DualOp,
    ) {
        if widget_key.is_empty() {
            return;
        }
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let (options, excluded, spec_included) =
            match crate::widgets::find_widget_by_key(&panel.spec, widget_key) {
                Some(fresh_core::api::WidgetSpec::DualList {
                    options,
                    excluded,
                    included,
                    ..
                }) => (options.clone(), excluded.clone(), included.clone()),
                _ => return,
            };
        // Load current instance state (or seed from spec).
        let (mut included, mut active_included, mut avail_cur, mut incl_cur) =
            match panel.instance_states.get(widget_key) {
                Some(crate::widgets::WidgetInstanceState::DualList {
                    included,
                    active_included,
                    available_cursor,
                    included_cursor,
                }) => (
                    included.clone(),
                    *active_included,
                    *available_cursor as usize,
                    *included_cursor as usize,
                ),
                _ => (spec_included, false, 0usize, 0usize),
            };
        included = crate::widgets::dual_sanitize_included(&options, &included);
        let mut available = crate::widgets::dual_available_values(&options, &included, &excluded);
        let clamp = |c: usize, len: usize| if len == 0 { 0 } else { c.min(len - 1) };
        avail_cur = clamp(avail_cur, available.len());
        incl_cur = clamp(incl_cur, included.len());

        let before = included.clone();
        match op {
            DualOp::SwitchColumn(to_included) => active_included = to_included,
            DualOp::CursorMove(delta) => {
                if active_included {
                    incl_cur = step_cursor(incl_cur, delta, included.len());
                } else {
                    avail_cur = step_cursor(avail_cur, delta, available.len());
                }
            }
            DualOp::MoveAcross => {
                if active_included {
                    // Remove the focused included item back to Available.
                    if incl_cur < included.len() {
                        included.remove(incl_cur);
                        incl_cur = clamp(incl_cur, included.len());
                    }
                } else {
                    // Add the focused available item to the Included list.
                    if avail_cur < available.len() {
                        included.push(available[avail_cur].clone());
                        available =
                            crate::widgets::dual_available_values(&options, &included, &excluded);
                        avail_cur = clamp(avail_cur, available.len());
                    }
                }
            }
            DualOp::Reorder(delta) => {
                // Only meaningful in the Included column.
                if active_included && !included.is_empty() {
                    let target = incl_cur as i32 + delta;
                    if target >= 0 && (target as usize) < included.len() {
                        included.swap(incl_cur, target as usize);
                        incl_cur = target as usize;
                    }
                }
            }
        }
        let changed = included != before;
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::DualList {
                    included: included.clone(),
                    active_included,
                    available_cursor: avail_cur as u32,
                    included_cursor: incl_cur as u32,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        if changed {
            self.fire_widget_event(
                panel_key,
                widget_key.to_string(),
                "change".into(),
                serde_json::json!({ "included": included }),
            );
        }
    }

    /// Click on a `DualList` cell: make the clicked column active and
    /// move its cursor to the clicked row. Cursor/active state only —
    /// no `change` (the included set is unchanged).
    fn handle_widget_dual_click(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        to_included: bool,
        index: usize,
    ) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let (mut included, _active, mut avail_cur, mut incl_cur) =
            match panel.instance_states.get(widget_key) {
                Some(crate::widgets::WidgetInstanceState::DualList {
                    included,
                    active_included,
                    available_cursor,
                    included_cursor,
                }) => (
                    included.clone(),
                    *active_included,
                    *available_cursor as usize,
                    *included_cursor as usize,
                ),
                _ => (Vec::new(), false, 0, 0),
            };
        // Re-derive so cursor clamping matches what's on screen.
        let (options, excluded) = match crate::widgets::find_widget_by_key(&panel.spec, widget_key)
        {
            Some(fresh_core::api::WidgetSpec::DualList {
                options, excluded, ..
            }) => (options.clone(), excluded.clone()),
            _ => return,
        };
        included = crate::widgets::dual_sanitize_included(&options, &included);
        let available = crate::widgets::dual_available_values(&options, &included, &excluded);
        if to_included {
            if index < included.len() {
                incl_cur = index;
            }
        } else if index < available.len() {
            avail_cur = index;
        }
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::DualList {
                    included,
                    active_included: to_included,
                    available_cursor: avail_cur as u32,
                    included_cursor: incl_cur as u32,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
    }

    /// Set a `List` widget's selected index to an absolute item index,
    /// preserving its scroll offset, and repaint. Used by the click
    /// path: a row click only produces a `select` hit and — unlike
    /// keyboard nav via [`handle_widget_select_move_for_key`] — does
    /// not move the host-owned selection. Without this the highlight
    /// would not follow a click and a subsequent Up/Down would resume
    /// from the stale index.
    pub(super) fn set_widget_list_selected_index(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        index: i32,
    ) {
        if let Some(panel) = self.widget_registry.get_mut(panel_key) {
            let (prev_scroll, prev_item_height) = match panel.instance_states.get(widget_key) {
                Some(crate::widgets::WidgetInstanceState::List {
                    scroll_offset,
                    item_height,
                    ..
                }) => (*scroll_offset, *item_height),
                _ => (0, 1),
            };
            panel.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::List {
                    scroll_offset: prev_scroll,
                    selected_index: index,
                    item_height: prev_item_height,
                    // A deliberate selection re-arms scroll-follows-selection.
                    user_scrolled: false,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
    }

    /// Same as [`handle_widget_select_move`] but targets an explicit
    /// `List` widget key instead of the panel's focused widget. Used
    /// by the picker-style smart-key dispatch — `Up`/`Down` on a
    /// focused filter input route to the first scrollable widget in
    /// the panel without changing focus.
    fn handle_widget_select_move_for_key(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        delta: i32,
    ) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
        let (spec_sel, total, item_keys) = match widget {
            Some(fresh_core::api::WidgetSpec::List {
                selected_index,
                items,
                item_specs,
                item_keys,
                ..
            }) => {
                // Item count is in *items* (cards override the plain
                // `items` rows; see `WidgetSpec::List::item_specs`).
                let total = if item_specs.is_empty() {
                    items.len()
                } else {
                    item_specs.len()
                };
                (*selected_index, total as i32, item_keys.clone())
            }
            _ => return,
        };
        if total == 0 {
            return;
        }
        let cur_sel = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::List { selected_index, .. }) => {
                *selected_index
            }
            _ => spec_sel,
        };
        let raw = if cur_sel < 0 { 0 } else { cur_sel + delta };
        let new_sel = raw.clamp(0, total - 1);
        let new_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            let (cur_scroll, cur_item_height) = match panel_mut.instance_states.get(widget_key) {
                Some(crate::widgets::WidgetInstanceState::List {
                    scroll_offset,
                    item_height,
                    ..
                }) => (*scroll_offset, *item_height),
                _ => (0, 1),
            };
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::List {
                    scroll_offset: cur_scroll,
                    selected_index: new_sel,
                    item_height: cur_item_height,
                    // Keyboard nav re-arms scroll-follows-selection so the
                    // renderer brings the new selection back into view.
                    user_scrolled: false,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        // A clamped move at the list's top/bottom edge leaves the
        // selection where it was. Still re-render above (re-arming
        // `user_scrolled = false` snaps a scrolled-away view back to the
        // selection), but don't fire a `select` event for a no-op move:
        // holding ↑/↓ against the boundary would otherwise spam the
        // plugin with same-index selections — each one re-runs the
        // plugin's preview / live-switch work (in the Orchestrator dock
        // it schedules a redundant `scheduleDockSwitch`). Mirrors the
        // Tree handler's "No change → bail (don't fire spurious select)".
        if new_sel != cur_sel {
            self.fire_widget_event(
                panel_key,
                widget_key.to_string(),
                "select".into(),
                serde_json::json!({ "index": new_sel, "key": new_key }),
            );
        }
    }

    /// Move the focused Tree's selection up/down, skipping
    /// descendants of collapsed nodes. Selection is the *absolute*
    /// `nodes` index; we walk the visible-flat order to find the
    /// neighbour. Mirrors the List handler shape but tree-aware.
    fn handle_widget_tree_select_move(&mut self, panel_key: &crate::widgets::PanelKey, delta: i32) {
        let focus_key = match self.widget_registry.get(panel_key) {
            Some(p) => p.focus_key.clone(),
            None => return,
        };
        if focus_key.is_empty() {
            return;
        }
        self.handle_widget_tree_select_move_for_key(panel_key, &focus_key, delta);
    }

    /// Tree counterpart of [`handle_widget_select_move_for_key`].
    fn handle_widget_tree_select_move_for_key(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        delta: i32,
    ) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
        let (spec_sel, nodes, item_keys) = match widget {
            Some(fresh_core::api::WidgetSpec::Tree {
                selected_index,
                nodes,
                item_keys,
                ..
            }) => (*selected_index, nodes.clone(), item_keys.clone()),
            _ => return,
        };
        if nodes.is_empty() {
            return;
        }
        let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::Tree {
                selected_index,
                scroll_offset,
                expanded_keys,
                ..
            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
            _ => (spec_sel, 0u32, std::collections::HashSet::<String>::new()),
        };
        let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
        if visible_indices.is_empty() {
            return;
        }
        let cur_pos = if cur_sel < 0 {
            if delta > 0 {
                -1
            } else {
                visible_indices.len() as i32
            }
        } else {
            visible_indices
                .iter()
                .position(|&v| v as i32 == cur_sel)
                .map(|p| p as i32)
                .unwrap_or(-1)
        };
        let new_pos = (cur_pos + delta).clamp(0, (visible_indices.len() as i32) - 1);
        let new_abs = visible_indices[new_pos as usize];
        let new_key = item_keys.get(new_abs).cloned().unwrap_or_default();
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::Tree {
                    scroll_offset: cur_scroll,
                    selected_index: new_abs as i32,
                    expanded_keys: expanded,
                    // Keyboard nav is a deliberate selection move —
                    // re-arm scroll-follows-selection.
                    user_scrolled: false,
                },
            );
        }
        // Keyboard nav in the dock: flash its overlay scrollbar so the
        // user sees where the selection sits in the overflowing list even
        // though the pointer (whose hover normally reveals the bar) never
        // moved. The renderer shows the bar while this deadline is in the
        // future; `check_dock_scrollbar_flash_expiry` (editor tick) clears
        // it and repaints once it passes.
        self.flash_dock_scrollbar(panel_key);
        self.rerender_widget_panel(panel_key);
        self.fire_widget_event(
            panel_key,
            widget_key.to_string(),
            "select".into(),
            serde_json::json!({ "index": new_abs as i64, "key": new_key }),
        );
    }

    /// Arm the dock's keyboard scrollbar flash: if `panel_key` is the
    /// mounted dock panel, record a visibility deadline
    /// [`super::DOCK_SCROLLBAR_FLASH`] from now on the editor's
    /// `time_source` (so tests can drive expiry on the logical clock).
    /// No-op for the centered modal / anchored popups — their scrollbars
    /// are always visible.
    fn flash_dock_scrollbar(&mut self, panel_key: &crate::widgets::PanelKey) {
        let until = self.time_source().now() + super::DOCK_SCROLLBAR_FLASH;
        if let Some(dock) = self.dock.as_mut() {
            if &dock.panel_key == panel_key {
                dock.scrollbar_flash_until = Some(until);
            }
        }
    }

    /// Editor-tick check: clear an expired dock scrollbar flash and
    /// request a repaint so the bar disappears without waiting for the
    /// next input event. Returns `true` when a redraw is needed (the
    /// deadline just passed). While the flash is still live, no work is
    /// done here — the renderer keeps showing the bar and the main loop's
    /// idle poll (≤50ms) guarantees a tick lands shortly after expiry.
    pub(crate) fn check_dock_scrollbar_flash_expiry(&mut self) -> bool {
        let now = self.time_source().now();
        if let Some(dock) = self.dock.as_mut() {
            if dock.scrollbar_flash_until.is_some_and(|t| now >= t) {
                dock.scrollbar_flash_until = None;
                return true;
            }
        }
        false
    }

    /// Mouse-wheel scroll over a widget panel buffer. With `pos` —
    /// the pointer's panel-relative (row, display column) — the wheel
    /// scrolls the `List`/`Tree` whose rendered region contains the
    /// pointer, so two side-by-side lists (the code tour's Steps rail
    /// and prose column) each answer to the wheel hovering over them.
    /// Without a position, or when the pointer sits on panel chrome
    /// outside every list, it falls back to the first `Tree`/`List`
    /// in the spec (the pre-position behaviour). Sets the widget's
    /// `user_scrolled` flag so the renderer's auto-scroll doesn't
    /// snap the offset back to the selection. No focus change,
    /// no `widget_event` fires — wheel is viewport navigation, not
    /// selection.
    ///
    /// Returns `true` if any panel consumed the scroll.
    pub(super) fn handle_widget_panel_wheel_at(
        &mut self,
        buffer_id: crate::model::event::BufferId,
        pos: Option<(u32, u32)>,
        delta: i32,
    ) -> bool {
        let panels = self.widget_registry.panels_for_buffer(buffer_id);
        let mut consumed = false;
        for panel_key in panels {
            // First chance: a focused Text widget with an open
            // completion popup absorbs the wheel — scrolling the
            // candidate list when the popup is what the user is
            // pointing at takes priority over scrolling a
            // sibling List/Tree elsewhere on the panel.
            if self.focused_text_completions_open(&panel_key) {
                self.scroll_focused_text_completions(&panel_key, delta);
                // The renderer reads `completion_scroll_offset`
                // out of the Text widget's instance state on
                // each paint, so flushing a rerender here is
                // what actually puts the new scroll on screen
                // — without this, the cached overlay rows on
                // the floating panel stay pinned to the old
                // offset until the user's next keystroke
                // happens to re-render for some other reason.
                self.rerender_widget_panel(&panel_key);
                consumed = true;
                continue;
            }
            // Position-aware routing: the rendered region under the
            // pointer names the widget to scroll. Regions are emitted
            // for every keyed List/Tree (overflowing or not), so a
            // wheel over a list that happens to fit is *not* rerouted
            // to a scrollable sibling elsewhere on the panel.
            let (spec, hovered_key) = match self.widget_registry.get(&panel_key) {
                Some(p) => {
                    let hovered = pos.and_then(|(row, col)| {
                        p.scroll_regions
                            .iter()
                            .find(|r| {
                                row >= r.buffer_row
                                    && row < r.buffer_row.saturating_add(r.height_rows)
                                    && col >= r.col_in_row
                                    && col < r.col_in_row.saturating_add(r.width_cols)
                            })
                            .map(|r| r.list_key.clone())
                    });
                    (p.spec.clone(), hovered)
                }
                None => continue,
            };
            let Some(widget_key) = hovered_key.or_else(|| find_scrollable_widget_key(&spec)) else {
                continue;
            };
            let widget = crate::widgets::find_widget_by_key(&spec, &widget_key);
            match widget {
                Some(fresh_core::api::WidgetSpec::Tree { .. }) => {
                    // Only claim the wheel if the widget actually scrolled.
                    // A List/Tree that declares `visible_rows >= total`
                    // (e.g. Git Log, which renders every row and relies on
                    // its scrollable region's buffer scroll instead) has
                    // nothing to scroll here; swallowing the event would
                    // leave the wheel dead. Falling through lets the
                    // underlying buffer scroll handle it.
                    consumed |= self.handle_widget_tree_wheel(&panel_key, &widget_key, delta);
                }
                Some(fresh_core::api::WidgetSpec::List { .. }) => {
                    consumed |= self.handle_widget_list_wheel(&panel_key, &widget_key, delta);
                }
                Some(fresh_core::api::WidgetSpec::Text { rows, .. }) if *rows > 1 => {
                    consumed |= self.handle_widget_text_wheel(&panel_key, &widget_key, delta);
                }
                _ => {}
            }
        }
        consumed
    }

    /// Wheel over a multi-line (markdown) Text document: shift its
    /// viewport by `delta` rows, clamped to the rendered line count
    /// recorded in the widget's scroll region. Sets `user_scrolled` so
    /// the next render doesn't snap back to the caret. Returns `true`
    /// when the viewport actually moved.
    fn handle_widget_text_wheel(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        delta: i32,
    ) -> bool {
        let Some(panel) = self.widget_registry.get(panel_key) else {
            return false;
        };
        let Some((total, visible)) = panel
            .scroll_regions
            .iter()
            .find(|r| r.list_key == widget_key)
            .map(|r| (r.total, r.visible))
        else {
            return false;
        };
        let max_scroll = total.saturating_sub(visible) as i64;
        if max_scroll == 0 {
            return false;
        }
        let Some(panel) = self.widget_registry.get_mut(panel_key) else {
            return false;
        };
        match panel.instance_states.get_mut(widget_key) {
            Some(crate::widgets::WidgetInstanceState::Text {
                scroll,
                user_scrolled,
                ..
            }) => {
                let new = (*scroll as i64 + delta as i64).clamp(0, max_scroll) as u32;
                if new == *scroll {
                    return false;
                }
                *scroll = new;
                *user_scrolled = true;
            }
            _ => return false,
        }
        self.rerender_widget_panel(panel_key);
        true
    }

    /// Shift the focused Text widget's completion popup scroll
    /// offset by `delta` rows. The renderer reads the visible-
    /// rows cap from the Text spec; we approximate it here as
    /// "5 if zero / unset" to mirror the renderer's default —
    /// the cap matters for clamping the max scroll so the
    /// thumb doesn't drift past the end.
    fn scroll_focused_text_completions(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        delta: i32,
    ) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let focus_key = panel.focus_key.clone();
        if focus_key.is_empty() {
            return;
        }
        let spec_visible_rows = match crate::widgets::find_widget_by_key(&panel.spec, &focus_key) {
            Some(fresh_core::api::WidgetSpec::Text {
                completions_visible_rows,
                ..
            }) => *completions_visible_rows,
            _ => 0,
        };
        let visible = if spec_visible_rows == 0 {
            5u32
        } else {
            spec_visible_rows
        };
        let panel = match self.widget_registry.get_mut(panel_key) {
            Some(p) => p,
            None => return,
        };
        if let Some(crate::widgets::WidgetInstanceState::Text {
            completions,
            completion_scroll_offset,
            completion_navigated,
            ..
        }) = panel.instance_states.get_mut(&focus_key)
        {
            if completions.is_empty() {
                return;
            }
            // Scrolling the popup with the wheel counts as stepping
            // into it — Enter should then accept the highlighted row.
            *completion_navigated = true;
            let total = completions.len() as u32;
            let max_scroll = total.saturating_sub(visible.min(total));
            let next = (*completion_scroll_offset as i32 + delta).clamp(0, max_scroll as i32);
            *completion_scroll_offset = next as u32;
        }
    }

    /// Shift a Tree's `scroll_offset` by `delta` rows. If the
    /// selection would fall outside the new viewport, drag it to
    /// the edge so the renderer's keep-selection-visible logic
    /// doesn't snap the offset back.
    fn handle_widget_tree_wheel(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        delta: i32,
    ) -> bool {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return false,
        };
        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
        let (visible_rows, item_height, card_borders, checkable, nodes, item_keys) = match widget {
            Some(fresh_core::api::WidgetSpec::Tree {
                visible_rows,
                item_height,
                card_borders,
                checkable,
                nodes,
                item_keys,
                ..
            }) => (
                *visible_rows,
                (*item_height).max(1),
                *card_borders,
                *checkable,
                nodes.clone(),
                item_keys.clone(),
            ),
            _ => return false,
        };
        if nodes.is_empty() {
            return false;
        }
        let (cur_sel, cur_scroll, expanded) = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::Tree {
                selected_index,
                scroll_offset,
                expanded_keys,
                ..
            }) => (*selected_index, *scroll_offset, expanded_keys.clone()),
            _ => (-1, 0, std::collections::HashSet::<String>::new()),
        };
        let visible_indices = collect_visible_tree_indices(&nodes, &item_keys, &expanded);
        if visible_indices.is_empty() {
            return false;
        }
        // Scroll offset and clamp are in *row* units (line-level
        // scrolling — a bordered card can be partially clipped at the
        // viewport edges). Compute per-node heights and the clamp with
        // the renderer's own helpers so the wheel can't disagree with
        // what will actually be painted: fixed `item_height` bands
        // normally, variable rows with `card_borders` (bordered cards
        // are `item_height + 2` rows, folder headers a single row).
        // Mirror the renderer's normalization: bordered-card layout only
        // engages for multi-row items.
        let card_borders = card_borders && item_height > 1;
        let heights: Vec<u32> = visible_indices
            .iter()
            .map(|&abs| {
                crate::widgets::render::tree_node_rows(
                    &nodes[abs],
                    checkable,
                    item_height,
                    card_borders,
                )
            })
            .collect();
        let max_scroll = crate::widgets::render::tree_max_scroll(&heights, visible_rows);
        let new_scroll = (cur_scroll as i32 + delta).clamp(0, max_scroll as i32) as u32;
        if new_scroll == cur_scroll {
            return false;
        }
        // Mouse scroll moves the *view* only — the selection stays put
        // (and may scroll out of view). `user_scrolled` tells the
        // renderer not to snap the offset back to the selection, and —
        // unlike the old drag-selection-to-the-edge workaround — it
        // survives a plugin `SetSelectedIndex` that re-pins the same
        // selection (the orchestrator dock re-pins on every probe-poll
        // refresh, which used to yank the scrolled view back to the top).
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::Tree {
                    scroll_offset: new_scroll,
                    selected_index: cur_sel,
                    expanded_keys: expanded,
                    user_scrolled: true,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        true
    }

    /// List counterpart of `handle_widget_tree_wheel`. Returns true if the
    /// list's scroll offset actually changed (the wheel was consumed).
    fn handle_widget_list_wheel(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        delta: i32,
    ) -> bool {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return false,
        };
        let widget = crate::widgets::find_widget_by_key(&panel.spec, widget_key);
        let (visible_rows, total) = match widget {
            Some(fresh_core::api::WidgetSpec::List {
                visible_rows,
                items,
                item_specs,
                ..
            }) => {
                let total = if item_specs.is_empty() {
                    items.len()
                } else {
                    item_specs.len()
                };
                (*visible_rows, total as u32)
            }
            _ => return false,
        };
        if total == 0 {
            return false;
        }
        let (cur_sel, cur_scroll, item_height) = match panel.instance_states.get(widget_key) {
            Some(crate::widgets::WidgetInstanceState::List {
                selected_index,
                scroll_offset,
                item_height,
                ..
            }) => (*selected_index, *scroll_offset, (*item_height).max(1)),
            _ => (-1, 0, 1),
        };
        // Convert the row-denominated viewport into a per-item window so
        // the bound is right for card lists (item_height > 1), and so a
        // list that already shows everything (max_scroll == 0, e.g. the
        // Git Log which sets visible_rows == commit count and scrolls via
        // its enclosing pane) reports "can't scroll" and lets the wheel
        // bubble to that pane rather than swallowing it.
        let visible_items = (visible_rows.max(1) / item_height).max(1);
        let max_scroll = total.saturating_sub(visible_items);
        let new_scroll = (cur_scroll as i64 + delta as i64).clamp(0, max_scroll as i64) as u32;
        if new_scroll == cur_scroll {
            return false;
        }
        // Wheel scrolls the *view* only — the selection stays put (and
        // may leave the visible window); `user_scrolled` tells the
        // renderer not to snap the offset back to it.
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::List {
                    scroll_offset: new_scroll,
                    selected_index: cur_sel,
                    item_height,
                    user_scrolled: true,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        true
    }

    /// Right/Left arrow on a focused Tree.
    ///
    /// * Right: if the selected node has children and is collapsed,
    ///   expand it. Else no-op.
    /// * Left: if the selected node has children and is expanded,
    ///   collapse it. Else move selection up to the parent.
    ///
    /// Both update host instance state, re-render, and (when a
    /// change happened) fire `widget_event { event_type: "expand" }`.
    fn handle_widget_tree_lateral(&mut self, panel_key: &crate::widgets::PanelKey, is_right: bool) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let focus_key = panel.focus_key.clone();
        if focus_key.is_empty() {
            return;
        }
        let widget = crate::widgets::find_widget_by_key(&panel.spec, &focus_key);
        let (spec_sel, nodes, item_keys) = match widget {
            Some(fresh_core::api::WidgetSpec::Tree {
                selected_index,
                nodes,
                item_keys,
                ..
            }) => (*selected_index, nodes.clone(), item_keys.clone()),
            _ => return,
        };
        if nodes.is_empty() {
            return;
        }
        let (cur_sel, cur_scroll, mut expanded, cur_user_scrolled) =
            match panel.instance_states.get(&focus_key) {
                Some(crate::widgets::WidgetInstanceState::Tree {
                    selected_index,
                    scroll_offset,
                    expanded_keys,
                    user_scrolled,
                }) => (
                    *selected_index,
                    *scroll_offset,
                    expanded_keys.clone(),
                    *user_scrolled,
                ),
                _ => (
                    spec_sel,
                    0u32,
                    std::collections::HashSet::<String>::new(),
                    false,
                ),
            };
        if cur_sel < 0 {
            return;
        }
        let sel_idx = cur_sel as usize;
        let node = match nodes.get(sel_idx) {
            Some(n) => n,
            None => return,
        };
        let key = item_keys.get(sel_idx).cloned().unwrap_or_default();
        let was_expanded = !key.is_empty() && expanded.contains(&key);

        let mut new_sel = cur_sel;
        let mut expansion_changed: Option<bool> = None; // Some(new_state)
        if is_right {
            if node.has_children && !was_expanded && !key.is_empty() {
                expanded.insert(key.clone());
                expansion_changed = Some(true);
            }
        } else if node.has_children && was_expanded && !key.is_empty() {
            expanded.remove(&key);
            expansion_changed = Some(false);
        } else if let Some(parent_idx) = crate::widgets::tree_parent_index(&nodes, sel_idx) {
            new_sel = parent_idx as i32;
        }
        // No change → bail (don't fire spurious select/expand).
        if expansion_changed.is_none() && new_sel == cur_sel {
            return;
        }
        let final_key = item_keys.get(new_sel as usize).cloned().unwrap_or_default();
        if let Some(panel_mut) = self.widget_registry.get_mut(panel_key) {
            panel_mut.instance_states.insert(
                focus_key.clone(),
                crate::widgets::WidgetInstanceState::Tree {
                    scroll_offset: cur_scroll,
                    selected_index: new_sel,
                    expanded_keys: expanded,
                    // Jumping to the parent is a deliberate selection
                    // move (re-arm follow); a pure expansion flip keeps
                    // the user's scroll intact.
                    user_scrolled: cur_user_scrolled && new_sel == cur_sel,
                },
            );
        }
        self.rerender_widget_panel(panel_key);
        if let Some(now_expanded) = expansion_changed {
            self.fire_widget_event(
                panel_key,
                focus_key.clone(),
                "expand".into(),
                serde_json::json!({
                    "index": cur_sel as i64,
                    "key": key,
                    "expanded": now_expanded,
                }),
            );
        } else if new_sel != cur_sel {
            self.fire_widget_event(
                panel_key,
                focus_key,
                "select".into(),
                serde_json::json!({
                    "index": new_sel as i64,
                    "key": final_key,
                }),
            );
        }
    }

    /// Toggle a Tree node's expansion state, re-render, and fire
    /// `widget_event { event_type: "expand" }`. Used by the click
    /// handler when the user clicks the disclosure column.
    pub(crate) fn handle_widget_tree_expand_toggle(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        item_key: &str,
    ) {
        if widget_key.is_empty() || item_key.is_empty() {
            return;
        }
        let now_expanded = {
            let panel = match self.widget_registry.get_mut(panel_key) {
                Some(p) => p,
                None => return,
            };
            let (cur_scroll, cur_sel, mut expanded, cur_user_scrolled) =
                match panel.instance_states.get(widget_key) {
                    Some(crate::widgets::WidgetInstanceState::Tree {
                        scroll_offset,
                        selected_index,
                        expanded_keys,
                        user_scrolled,
                    }) => (
                        *scroll_offset,
                        *selected_index,
                        expanded_keys.clone(),
                        *user_scrolled,
                    ),
                    _ => (
                        0u32,
                        -1i32,
                        std::collections::HashSet::<String>::new(),
                        false,
                    ),
                };
            let next = if expanded.contains(item_key) {
                expanded.remove(item_key);
                false
            } else {
                expanded.insert(item_key.to_string());
                true
            };
            panel.instance_states.insert(
                widget_key.to_string(),
                crate::widgets::WidgetInstanceState::Tree {
                    scroll_offset: cur_scroll,
                    selected_index: cur_sel,
                    expanded_keys: expanded,
                    // A disclosure click doesn't move the selection —
                    // keep the user's scroll suppression as-is.
                    user_scrolled: cur_user_scrolled,
                },
            );
            next
        };
        self.rerender_widget_panel(panel_key);
        self.fire_widget_event(
            panel_key,
            widget_key.to_string(),
            "expand".into(),
            serde_json::json!({ "key": item_key, "expanded": now_expanded, }),
        );
    }

    /// Fire `widget_event { event_type: "activate" }` for the focused
    /// Tree's currently-selected node. Mirrors `fire_list_activate`
    /// — the plugin's handler decides what "activate" means
    /// (open the file, run an action, etc.).
    /// If the focused Tree row is checkable (parent tree has
    /// `checkable: true` *and* the row's `checked` is `Some(_)`),
    /// fire `widget_event { event_type: "toggle" }` with the
    /// inverted value and return `true`. Otherwise return `false`
    /// so the caller falls back to `activate`.
    ///
    /// Mirrors what a click on the row's `[v]`/`[ ]` glyph would
    /// do — Space is the conventional checkbox key, so on a
    /// checkable tree Space toggles instead of activating.
    fn fire_tree_toggle_if_checkable(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        focus_key: &str,
    ) -> bool {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return false,
        };
        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
        let (spec_sel, nodes, item_keys, checkable) = match widget {
            Some(fresh_core::api::WidgetSpec::Tree {
                selected_index,
                nodes,
                item_keys,
                checkable,
                ..
            }) => (*selected_index, nodes, item_keys.clone(), *checkable),
            _ => return false,
        };
        if !checkable {
            return false;
        }
        let sel = match panel.instance_states.get(focus_key) {
            Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
                *selected_index
            }
            _ => spec_sel,
        };
        if sel < 0 {
            return false;
        }
        let cur_checked = match nodes.get(sel as usize).and_then(|n| n.checked) {
            Some(b) => b,
            None => return false, // No checkbox glyph on this row — let activate fire.
        };
        let new_checked = !cur_checked;
        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
        self.fire_widget_event(
            panel_key,
            focus_key.to_string(),
            "toggle".into(),
            serde_json::json!({ "index": sel, "key": item_key, "checked": new_checked, }),
        );
        true
    }

    fn fire_tree_activate(&mut self, panel_key: &crate::widgets::PanelKey, focus_key: &str) {
        let panel = match self.widget_registry.get(panel_key) {
            Some(p) => p,
            None => return,
        };
        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
        let (spec_sel, item_keys) = match widget {
            Some(fresh_core::api::WidgetSpec::Tree {
                selected_index,
                item_keys,
                ..
            }) => (*selected_index, item_keys.clone()),
            _ => return,
        };
        let sel = match panel.instance_states.get(focus_key) {
            Some(crate::widgets::WidgetInstanceState::Tree { selected_index, .. }) => {
                *selected_index
            }
            _ => spec_sel,
        };
        if sel < 0 {
            return;
        }
        let item_key = item_keys.get(sel as usize).cloned().unwrap_or_default();
        self.fire_widget_event(
            panel_key,
            focus_key.to_string(),
            "activate".into(),
            serde_json::json!({ "index": sel, "key": item_key, }),
        );
    }

    /// Walk every panel rendering into `buffer_id` and return the
    /// first one whose currently-focused widget is a `Text`.
    /// Returns `None` when no such panel exists (e.g. when the
    /// buffer is a regular text buffer, or the panel has focus on
    /// a `Button` / `List` / etc.).
    ///
    /// This is the universal hook the clipboard ops use to route
    /// Paste / Copy / Cut / Select-All to a focused widget text
    /// field instead of the underlying buffer. Same idea as the
    /// existing Prompt and FileExplorer branches in the clipboard
    /// path, generalised: any plugin-mounted Text widget that has
    /// focus wins over the underlying buffer.
    pub(super) fn focused_text_widget_panel_for_buffer(
        &self,
        buffer_id: crate::model::event::BufferId,
    ) -> Option<crate::widgets::PanelKey> {
        self.widget_registry
            .panels_for_buffer(buffer_id)
            .into_iter()
            .find(|panel_key| self.panel_focused_widget_is_text(panel_key))
    }

    /// True when `panel_key`'s currently-focused widget is a `Text`
    /// field (so it can accept clipboard insertion). `false` when the
    /// panel is gone, has no focus, or focus rests on a non-text
    /// widget (`Button` / `List` / `Toggle` / …). This is the shared
    /// predicate behind both the buffer-mounted paste routing
    /// (`focused_text_widget_panel_for_buffer`) and the floating-panel
    /// bracketed-paste routing (`paste_bracketed_into_focused_panel`).
    pub(super) fn panel_focused_widget_is_text(
        &self,
        panel_key: &crate::widgets::PanelKey,
    ) -> bool {
        let Some(panel) = self.widget_registry.get(panel_key) else {
            return false;
        };
        if panel.focus_key.is_empty() {
            return false;
        }
        matches!(
            crate::widgets::find_widget_by_key(&panel.spec, &panel.focus_key),
            Some(fresh_core::api::WidgetSpec::Text { .. })
        )
    }

    /// Read the currently-selected text from the focused `Text`
    /// widget on the given panel, or `None` when nothing is
    /// selected (no anchor, or anchor == cursor). Used by the
    /// host-side Copy / Cut routing path.
    pub(super) fn focused_widget_selected_text(
        &self,
        panel_key: &crate::widgets::PanelKey,
    ) -> Option<String> {
        let panel = self.widget_registry.get(panel_key)?;
        if panel.focus_key.is_empty() {
            return None;
        }
        match panel.instance_states.get(&panel.focus_key) {
            Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
                editor.selected_text()
            }
            _ => None,
        }
    }

    /// Select-all in the focused widget Text. Returns true when
    /// applied (focus was a Text widget). The op fires a `change`
    /// event only if the selection range actually changed; an
    /// already-fully-selected widget is a no-op.
    pub(super) fn handle_widget_select_all(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
    ) -> bool {
        // SelectAll moves the cursor to end-of-value and sets anchor
        // at start — `with_focused_text_editor` will skip re-render
        // when nothing changed, which is fine.
        self.with_focused_text_editor(panel_key, |editor| editor.select_all())
    }

    /// Copy the focused widget Text's current selection to the
    /// internal clipboard. Returns true when copy ran (even when
    /// the selection was empty — the action is consumed either way
    /// so it doesn't fall through to the buffer's copy path).
    pub(super) fn handle_widget_copy(&mut self, panel_key: &crate::widgets::PanelKey) -> bool {
        if self.widget_registry.get(panel_key).is_none() {
            return false;
        }
        if let Some(text) = self.focused_widget_selected_text(panel_key) {
            self.clipboard.copy(text);
        }
        true
    }

    /// Cut the focused widget Text's current selection — copy then
    /// delete. With no selection, this is a no-op consume.
    pub(super) fn handle_widget_cut(&mut self, panel_key: &crate::widgets::PanelKey) -> bool {
        if self.widget_registry.get(panel_key).is_none() {
            return false;
        }
        if let Some(text) = self.focused_widget_selected_text(panel_key) {
            self.clipboard.copy(text);
            // On a read-only / markdown document, Cut degrades to Copy:
            // the selection reaches the clipboard, nothing is deleted.
            if !self.focused_text_mode(panel_key).1 {
                self.with_focused_text_editor(panel_key, |editor| {
                    editor.delete_selection();
                });
            }
        }
        true
    }

    /// Insert `text` at the focused widget Text's cursor (replacing
    /// any active selection). Used by the host-side Paste routing
    /// path; `text` is already line-ending-normalised by the
    /// caller (CRLF / CR → LF). `TextEdit::insert_str` strips
    /// embedded newlines when the editor is single-line.
    pub(super) fn handle_widget_insert_str(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        text: &str,
    ) -> bool {
        if self.widget_registry.get(panel_key).is_none() {
            return false;
        }
        // Read-only / markdown documents accept no insertion — but the
        // paste is still consumed (it must not leak into the buffer
        // behind the panel).
        if self.focused_text_mode(panel_key).1 {
            return true;
        }
        let owned = text.to_string();
        self.with_focused_text_editor(panel_key, move |editor| {
            editor.insert_str(&owned);
        });
        true
    }

    /// Ensure `panel.instance_states[focus_key]` is a seeded
    /// `Text { editor, .. }` for the focused widget. If instance
    /// state already has the entry, no-op. If not, seeds from the
    /// spec's `value` / `cursor_byte` / `rows`. Returns true on
    /// success (focus is a Text widget that's now in instance state),
    /// false otherwise.
    fn ensure_focused_text_seeded(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        focus_key: &str,
    ) -> bool {
        let panel = match self.widget_registry.get_mut(panel_key) {
            Some(p) => p,
            None => return false,
        };
        if matches!(
            panel.instance_states.get(focus_key),
            Some(crate::widgets::WidgetInstanceState::Text { .. })
        ) {
            return true;
        }
        let widget = crate::widgets::find_widget_by_key(&panel.spec, focus_key);
        let (value, cursor_byte, multiline) = match widget {
            Some(fresh_core::api::WidgetSpec::Text {
                value,
                cursor_byte,
                rows,
                ..
            }) => (value.clone(), *cursor_byte, *rows > 1),
            _ => return false,
        };
        let mut editor = if multiline {
            crate::primitives::text_edit::TextEdit::with_text(&value)
        } else {
            crate::primitives::text_edit::TextEdit::single_line_with_text(&value)
        };
        let seed = if cursor_byte < 0 {
            value.len()
        } else {
            (cursor_byte as usize).min(value.len())
        };
        editor.set_cursor_from_flat(seed);
        panel.instance_states.insert(
            focus_key.to_string(),
            crate::widgets::WidgetInstanceState::Text {
                editor,
                scroll: 0,
                completions: Vec::new(),
                completion_selected_index: 0,
                completion_scroll_offset: 0,
                completion_navigated: false,
                user_scrolled: false,
            },
        );
        true
    }

    /// Apply a mutating operation to the focused `Text` widget's
    /// `TextEdit`. Handles seeding the editor from the spec on first
    /// touch, no-op detection (skips rerender + change event), and
    /// firing the `widget_event` "change" hook with the post-state.
    ///
    /// Returns true when the op ran *and* produced a visible change.
    pub(super) fn with_focused_text_editor<F>(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        op: F,
    ) -> bool
    where
        F: FnOnce(&mut crate::primitives::text_edit::TextEdit),
    {
        let focus_key = match self.widget_registry.get(panel_key) {
            Some(p) if !p.focus_key.is_empty() => p.focus_key.clone(),
            _ => return false,
        };
        if !self.ensure_focused_text_seeded(panel_key, &focus_key) {
            return false;
        }
        let (before_value, before_cursor) = {
            let panel = self.widget_registry.get(panel_key).unwrap();
            match panel.instance_states.get(&focus_key) {
                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
                    (editor.value(), editor.flat_cursor_byte())
                }
                _ => return false,
            }
        };
        {
            let panel = self.widget_registry.get_mut(panel_key).unwrap();
            match panel.instance_states.get_mut(&focus_key) {
                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => op(editor),
                _ => return false,
            }
        }
        let (after_value, after_cursor) = {
            let panel = self.widget_registry.get(panel_key).unwrap();
            match panel.instance_states.get(&focus_key) {
                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => {
                    (editor.value(), editor.flat_cursor_byte())
                }
                _ => return false,
            }
        };
        if after_value == before_value && after_cursor == before_cursor {
            return false;
        }
        self.rerender_widget_panel(panel_key);
        self.fire_widget_event(
            panel_key,
            focus_key.clone(),
            "change".into(),
            serde_json::json!({ "value": after_value, "cursorByte": after_cursor as i64, }),
        );
        true
    }

    /// Reposition a just-focused Text widget's cursor to the byte under
    /// a mouse click (#2573). `entry_byte` is the click's byte offset
    /// within the rendered row (as resolved by `hit_test`); `payload` is
    /// the `focus` HitArea payload, which carries the value-layout
    /// breadcrumbs the renderer stamped on it (`valueInnerStart` and the
    /// truncation fields). Maps the row byte back to a value byte, moves
    /// the cursor, and fires `change` so a plugin mirroring the cursor
    /// position (e.g. Search & Replace) stays in sync.
    ///
    /// A no-op for hits without the layout payload (older render paths,
    /// non-text widgets) or when the clicked widget isn't the focused
    /// one — the caller is expected to focus it first.
    pub(super) fn reposition_widget_text_cursor_from_click(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        entry_byte: usize,
        hit_byte_start: usize,
        payload: &serde_json::Value,
    ) {
        // `valueInnerStart` is relative to the *field's own* rendered
        // text (gutter + label + `[`). Fields can be composed
        // horizontally into a shared row (Search + Replace live on one
        // line), so `hit_byte_start` — the field's offset within that
        // composed row — rebases both the click and the value origin
        // into the same coordinate space.
        let inner_start = match payload.get("valueInnerStart").and_then(|v| v.as_u64()) {
            Some(v) => v as usize,
            None => return,
        };
        let offset_in_field = entry_byte.saturating_sub(hit_byte_start);
        // The cursor op below targets the panel's *focused* widget; guard
        // that focus already landed on the clicked field so a stray call
        // can't move an unrelated field's cursor.
        let is_focused = self
            .widget_registry
            .get(panel_key)
            .map(|p| p.focus_key == widget_key)
            .unwrap_or(false);
        if !is_focused {
            return;
        }
        let value_len = payload
            .get("valueLen")
            .and_then(|v| v.as_u64())
            .unwrap_or(0) as usize;
        let dropped = payload
            .get("valueDropped")
            .and_then(|v| v.as_u64())
            .unwrap_or(0) as usize;
        let ellipsis = payload
            .get("ellipsisBytes")
            .and_then(|v| v.as_u64())
            .unwrap_or(0) as usize;

        // Translate the click's field byte → value byte (shared with the
        // Settings UI via `crate::widgets`). `offset_in_field` already
        // rebased the click by `hit_byte_start`, so pass `byte_start = 0`.
        let value_byte = crate::widgets::row_byte_to_value_byte(
            offset_in_field,
            0,
            inner_start,
            dropped,
            ellipsis,
            value_len,
        );

        self.with_focused_text_editor(panel_key, |editor| editor.set_cursor_from_flat(value_byte));
    }

    /// Flat byte offset of `(line, byte_in_line)` within `value`,
    /// clamping the line into range and the byte onto a char boundary
    /// of that line. Newlines count one byte each, matching
    /// [`TextEdit::flat_cursor_byte`](crate::primitives::text_edit::TextEdit).
    fn markdown_line_byte_to_flat(value: &str, line: usize, byte_in_line: usize) -> usize {
        let mut flat = 0usize;
        for (i, l) in value.split('\n').enumerate() {
            if i == line {
                let mut b = byte_in_line.min(l.len());
                while b > 0 && !l.is_char_boundary(b) {
                    b -= 1;
                }
                return flat + b;
            }
            flat += l.len() + 1;
        }
        value.len()
    }

    /// A press on a markdown document row: focus already moved (the
    /// caller's tabbable path), so place the caret at the clicked byte
    /// of rendered line `line`, re-arm keep-caret-visible, and arm
    /// drag-to-select anchored at the press.
    pub(super) fn position_markdown_text_cursor_from_click(
        &mut self,
        panel_key: &crate::widgets::PanelKey,
        widget_key: &str,
        line: usize,
        byte_in_line: usize,
    ) {
        let is_focused = self
            .widget_registry
            .get(panel_key)
            .map(|p| p.focus_key == widget_key)
            .unwrap_or(false);
        if !is_focused {
            return;
        }
        let Some(flat) = ({
            let panel = self.widget_registry.get(panel_key);
            panel.and_then(|p| match p.instance_states.get(widget_key) {
                Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) => Some(
                    Self::markdown_line_byte_to_flat(&editor.value(), line, byte_in_line),
                ),
                _ => None,
            })
        }) else {
            return;
        };
        self.clear_focused_text_user_scrolled(panel_key);
        let moved = self.with_focused_text_editor(panel_key, |editor| {
            editor.set_cursor_from_flat(flat);
        });
        // A click that lands on the caret's own cell still dismisses an
        // existing selection: `set_cursor_from_flat` cleared the anchor,
        // but `with_focused_text_editor` saw no cursor/value change, so
        // repaint explicitly.
        if !moved {
            self.rerender_widget_panel(panel_key);
        }
        self.widget_text_drag = Some(super::WidgetTextDrag {
            panel: panel_key.clone(),
            widget: widget_key.to_string(),
            anchor_flat: flat,
        });
    }

    /// Extend the drag selection of an armed widget-text drag to
    /// `(line, byte_in_line)`: caret moves there, anchor stays at the
    /// press position. Selection-only — no `change` event fires.
    pub(super) fn extend_widget_text_selection_to(
        &mut self,
        drag: &super::WidgetTextDrag,
        line: usize,
        byte_in_line: usize,
    ) {
        let Some(panel) = self.widget_registry.get_mut(&drag.panel) else {
            return;
        };
        let Some(crate::widgets::WidgetInstanceState::Text { editor, .. }) =
            panel.instance_states.get_mut(&drag.widget)
        else {
            return;
        };
        let value = editor.value();
        let head = Self::markdown_line_byte_to_flat(&value, line, byte_in_line);
        // Anchor (row, col) from its flat offset: park the cursor there
        // momentarily to reuse the flat→(row, col) clamping, then move
        // the cursor to the head and re-attach the anchor.
        editor.set_cursor_from_flat(drag.anchor_flat);
        let anchor_rc = (editor.cursor_row, editor.cursor_col);
        editor.set_cursor_from_flat(head);
        editor.selection_anchor = if head != drag.anchor_flat {
            Some(anchor_rc)
        } else {
            None
        };
        self.rerender_widget_panel(&drag.panel);
    }

    /// Apply a non-printable editing key to the focused text widget. See
    /// [`widget_key_name_to_event`] for the name → `KeyEvent` re-hydration.
    ///
    /// Every caret-motion / mutation key is routed through the shared
    /// [`apply_text_key`](crate::primitives::text_key::apply_text_key)
    /// table — the single source of truth the Settings input handler also
    /// uses, so the two surfaces can't drift. `Enter` = newline is the one
    /// widget-multiline affordance the shared table deliberately leaves as
    /// chrome (it means "commit" on other surfaces), so it's handled here.
    ///
    /// The single/multi-line discriminator is carried by `TextEdit`'s own
    /// `multiline` field, so passing `multiline: true` lets the router
    /// forward Up/Down while a single-line editor simply no-ops on them —
    /// matching the prior dispatch.
    fn handle_widget_text_key(&mut self, panel_key: &crate::widgets::PanelKey, key: &str) {
        let (is_markdown, is_read_only) = self.focused_text_mode(panel_key);
        if key == "Enter" {
            if is_markdown {
                // A markdown document has no newline to insert; Enter is
                // its activate gesture (the tour jumps to the step's code).
                let focus_key = self
                    .widget_registry
                    .get(panel_key)
                    .map(|p| p.focus_key.clone())
                    .unwrap_or_default();
                if !focus_key.is_empty() {
                    self.fire_widget_event(
                        panel_key,
                        focus_key,
                        "activate".into(),
                        serde_json::json!({}),
                    );
                }
                return;
            }
            if is_read_only {
                return;
            }
            self.with_focused_text_editor(panel_key, |editor| editor.insert_char('\n'));
            return;
        }
        let Some(event) = widget_key_name_to_event(key) else {
            return;
        };
        if is_read_only && text_key_mutates(&event) {
            return;
        }
        // A key-driven caret move re-arms follow-the-caret: even if the
        // caret was already at a boundary (the op below no-ops), the
        // viewport must snap back from a wheel-scrolled position.
        let rearmed = self.clear_focused_text_user_scrolled(panel_key);
        let changed = self.with_focused_text_editor(panel_key, |editor| {
            crate::primitives::text_key::apply_text_key(
                editor,
                &event,
                crate::primitives::text_key::TextKeyContext::multiline(true),
            );
        });
        if rearmed && !changed {
            self.rerender_widget_panel(panel_key);
        }
    }

    /// `(markdown, read_only)` for the panel's focused widget. A
    /// `markdown` multi-line Text is forcibly read-only; a plain Text
    /// honours its `read_only` flag; every other widget is `(false,
    /// false)`.
    fn focused_text_mode(&self, panel_key: &crate::widgets::PanelKey) -> (bool, bool) {
        let Some(panel) = self.widget_registry.get(panel_key) else {
            return (false, false);
        };
        if panel.focus_key.is_empty() {
            return (false, false);
        }
        match crate::widgets::find_widget_by_key(&panel.spec, &panel.focus_key) {
            Some(fresh_core::api::WidgetSpec::Text {
                markdown,
                read_only,
                rows,
                ..
            }) => {
                let md = *markdown && *rows > 1;
                (md, md || *read_only)
            }
            _ => (false, false),
        }
    }

    /// Clear the focused Text widget's `user_scrolled` flag (re-arming
    /// keep-caret-visible). Returns true when the flag was set.
    fn clear_focused_text_user_scrolled(&mut self, panel_key: &crate::widgets::PanelKey) -> bool {
        let Some(panel) = self.widget_registry.get_mut(panel_key) else {
            return false;
        };
        let focus_key = panel.focus_key.clone();
        if focus_key.is_empty() {
            return false;
        }
        match panel.instance_states.get_mut(&focus_key) {
            Some(crate::widgets::WidgetInstanceState::Text { user_scrolled, .. })
                if *user_scrolled =>
            {
                *user_scrolled = false;
                true
            }
            _ => false,
        }
    }

    /// Insert printable / IME-committed text at the focused text
    /// widget's cursor. Same path for single-line and multi-line —
    /// `TextEdit::insert_str` strips `\n` automatically when the
    /// editor was constructed single-line. `text` may be a single
    /// codepoint, a grapheme cluster, or a multi-codepoint IME
    /// commit; `insert_str` handles each identically.
    fn handle_widget_text_char(&mut self, panel_key: &crate::widgets::PanelKey, text: &str) {
        if text.is_empty() || self.focused_text_mode(panel_key).1 {
            return;
        }
        let text = text.to_string();
        self.with_focused_text_editor(panel_key, move |editor| {
            editor.insert_str(&text);
        });
    }

    /// Inner-rect column budget for a floating panel render — the
    /// terminal width × `width_pct`, minus 2 cols for the frame
    /// border. Mirrors the `widget_panel_width` reservation; never
    /// goes below 10 cols so flex spacers don't collapse to zero on
    /// narrow terminals.
    pub(super) fn floating_panel_inner_width(&self, slot: super::PanelSlot) -> u32 {
        // A left-dock panel wraps its content to the dock's fixed
        // column width rather than a percentage of the terminal.
        if let Some(super::PanelPlacement::LeftDock { width_cols }) =
            self.panel(slot).map(|f| f.placement)
        {
            return (width_cols as u32).saturating_sub(2).max(10);
        }
        let term_w = self.terminal_width.max(1) as u32;
        let pct = self
            .panel(slot)
            .map(|f| f.width_pct.clamp(1, 100) as u32)
            .unwrap_or(80);
        let w = (term_w * pct) / 100;
        w.saturating_sub(2).max(10)
    }

    /// Restore keyboard focus to a (docked) floating panel that was
    /// previously blurred — typically a mouse click landing back inside
    /// the dock's column after the user dived into the editor. Sets
    /// the panel's `focused` flag and fires a `focus` widget_event so
    /// the owning plugin can update any mirror of the focused state
    /// (the orchestrator's `dockBlurred`, for instance). Symmetric
    /// with [`Editor::blur_floating_panel`], which has always fired
    /// `blur` on the inverse transition.
    ///
    /// Unlike [`Editor::set_panel_focus_and_notify`] this fires the
    /// `focus` event even when the *inner* focus_key hasn't changed —
    /// the dive only flipped overall focus, not the active widget, so
    /// the inner key is identical on re-focus and the "key-changed"
    /// short-circuit would silently drop the event. That short-circuit
    /// was the original bug: the host updated `dock.focused` but the
    /// plugin's mirror stayed stale, and the dock's debounced
    /// dock-switch then aborted at its `dockBlurred` guard.
    pub(super) fn refocus_floating_panel(&mut self, slot: super::PanelSlot) {
        let Some(panel_key) = self.panel(slot).map(|f| f.panel_key.clone()) else {
            return;
        };
        if let Some(f) = self.panel_mut(slot) {
            f.focused = true;
        }
        let widget_key = self
            .widget_registry
            .get(&panel_key)
            .map(|p| p.focus_key.clone())
            .unwrap_or_default();
        tracing::debug!(
            target: "fresh::dock",
            panel = %panel_key,
            ?slot,
            widget_key = %widget_key,
            "refocus_floating_panel: firing unconditional `focus` widget_event"
        );
        self.fire_widget_event(
            &panel_key,
            widget_key,
            "focus".to_string(),
            serde_json::json!({ "previous": "(re-focus)" }),
        );
    }

    /// Return keyboard focus to the editor while leaving a (docked)
    /// floating panel visible. Clears the panel's `focused` flag and
    /// fires a `blur` widget_event so the owning plugin can react
    /// (e.g. drop its editor mode). No-op when no panel is mounted.
    /// Shared by the Esc handler, the editor-click handler, and the
    /// `FloatingPanelControl{op:"blur"}` command.
    pub(super) fn blur_floating_panel(&mut self, slot: super::PanelSlot) {
        let Some(panel_key) = self.panel(slot).map(|f| f.panel_key.clone()) else {
            return;
        };
        if let Some(f) = self.panel_mut(slot) {
            f.focused = false;
        }
        tracing::debug!(
            target: "fresh::dock",
            panel = %panel_key,
            ?slot,
            "blur_floating_panel: firing `blur` widget_event"
        );
        let widget_key = self
            .widget_registry
            .get(&panel_key)
            .map(|p| p.focus_key.clone())
            .unwrap_or_default();
        self.fire_widget_event(
            &panel_key,
            widget_key,
            "blur".to_string(),
            serde_json::json!({}),
        );
    }

    /// Handle CloseSplit command
    pub(super) fn handle_close_split(&mut self, split_id: SplitId) {
        // Plugin sends arbitrary SplitId — convert to LeafId at the boundary
        let leaf_id = LeafId(split_id);
        match self
            .windows
            .get_mut(&self.active_window)
            .and_then(|w| w.split_manager_mut())
            .expect("active window must have a populated split layout")
            .close_split(leaf_id)
        {
            Ok(()) => {
                // Clean up the view state for the closed split
                self.windows
                    .get_mut(&self.active_window)
                    .and_then(|w| w.split_view_states_mut())
                    .expect("active window must have a populated split layout")
                    .remove(&leaf_id);
                // Drop the closed split from every terminal's scrollback set.
                self.active_window_mut()
                    .forget_split_terminal_modes(leaf_id);
                // The surviving panes just grew into the closed split's
                // space — reflow through the layout funnel so their
                // terminals are resized, same as `close_active_split`.
                self.relayout();
                tracing::info!("Closed split {:?}", split_id);
            }
            Err(e) => {
                tracing::warn!("Failed to close split {:?}: {}", split_id, e);
            }
        }
    }

    /// Handle RefreshLines command
    pub(super) fn handle_refresh_lines(&mut self, buffer_id: BufferId) {
        // Clear seen_byte_ranges for this buffer so all visible lines will be re-processed
        // on the next render. This is useful when a plugin is enabled and needs to
        // process lines that were already marked as seen.
        self.active_window_mut().seen_byte_ranges.remove(&buffer_id);
        // Request a render so the lines_changed hook fires
        #[cfg(feature = "plugins")]
        {
            self.plugin_render_requested = true;
        }
    }

    /// Flush pending grammars: spawn a background rebuild if any ReloadGrammars
    /// commands were received during this command batch.
    ///
    /// Called after processing all plugin commands in a batch, so that multiple
    /// RegisterGrammar+ReloadGrammars pairs result in only one rebuild.
    /// The rebuild happens on a background thread; when complete, a
    /// `GrammarRegistryBuilt` message swaps in the new registry.
    ///
    /// On the first call, this triggers the deferred full grammar build
    /// (user grammars + language packs + any plugin grammars accumulated so far).
    pub(super) fn flush_pending_grammars(&mut self) {
        // On the first call, start the deferred full grammar build.
        // This includes any plugin grammars that were registered during init,
        // so we get everything in a single builder.build() pass.
        if self.needs_full_grammar_build {
            self.needs_full_grammar_build = false;
            self.grammar_reload_pending = false;

            // Drain all pending grammars to include in the initial build
            let additional: Vec<_> = self
                .pending_grammars
                .drain(..)
                .map(|g| crate::primitives::grammar::GrammarSpec {
                    language: g.language.clone(),
                    path: std::path::PathBuf::from(g.grammar_path),
                    extensions: g.extensions.clone(),
                })
                .collect();

            // Update config.languages with the extensions so detect_language() works
            for crate::primitives::grammar::GrammarSpec {
                language,
                extensions,
                ..
            } in &additional
            {
                let lang_config = self
                    .config_mut()
                    .languages
                    .entry(language.clone())
                    .or_default();
                for ext in extensions {
                    if !lang_config.extensions.contains(ext) {
                        lang_config.extensions.push(ext.clone());
                    }
                }
            }

            let callback_ids: Vec<_> = self.pending_grammar_callbacks.drain(..).collect();
            self.start_background_grammar_build(additional, callback_ids);
            return;
        }

        if !self.grammar_reload_pending {
            return;
        }
        self.grammar_reload_pending = false;

        // If a background build is already in progress, it will call
        // flush_pending_grammars() again when it completes — so just
        // re-arm the flag and return.
        if self.grammar_build_in_progress {
            self.grammar_reload_pending = true;
            tracing::debug!("Grammar build in progress, deferring flush");
            return;
        }

        use std::path::PathBuf;

        if self.pending_grammars.is_empty() {
            tracing::debug!("Grammar reload requested but no pending grammars");
            return;
        }

        // Deduplicate: skip grammars whose extensions are all already mapped
        // in the current registry (meaning the grammar was already loaded by
        // for_editor or a previous build).
        let pending_before = self.pending_grammars.len();
        self.pending_grammars.retain(|g| {
            // Check if ALL extensions for this grammar are already mapped
            let all_mapped = !g.extensions.is_empty()
                && g.extensions
                    .iter()
                    .all(|ext| self.grammar_registry.find_by_extension(ext).is_some());
            if all_mapped {
                tracing::debug!(
                    "Skipping already-loaded grammar '{}' (extensions {:?} already mapped)",
                    g.language,
                    g.extensions
                );
                false
            } else {
                true
            }
        });
        if pending_before != self.pending_grammars.len() {
            tracing::info!(
                "Deduplicated pending grammars: {} -> {}",
                pending_before,
                self.pending_grammars.len()
            );
        }

        if self.pending_grammars.is_empty() {
            tracing::info!(
                "All pending grammars already loaded, resolving callbacks without rebuild"
            );
            // Resolve callbacks immediately — no rebuild needed
            #[cfg(feature = "plugins")]
            for cb_id in self.pending_grammar_callbacks.drain(..) {
                self.plugin_manager
                    .read()
                    .unwrap()
                    .resolve_callback(cb_id, "null".to_string());
            }
            #[cfg(not(feature = "plugins"))]
            self.pending_grammar_callbacks.clear();
            return;
        }

        tracing::info!(
            "Flushing {} pending grammars via background rebuild",
            self.pending_grammars.len()
        );

        // Collect pending grammars
        let additional: Vec<crate::primitives::grammar::GrammarSpec> = self
            .pending_grammars
            .drain(..)
            .map(|g| crate::primitives::grammar::GrammarSpec {
                language: g.language.clone(),
                path: PathBuf::from(g.grammar_path),
                extensions: g.extensions.clone(),
            })
            .collect();

        // Update config.languages with the extensions so detect_language() works
        for crate::primitives::grammar::GrammarSpec {
            language,
            extensions,
            ..
        } in &additional
        {
            let lang_config = self
                .config_mut()
                .languages
                .entry(language.clone())
                .or_default();
            for ext in extensions {
                if !lang_config.extensions.contains(ext) {
                    lang_config.extensions.push(ext.clone());
                }
            }
        }

        // Collect pending callback IDs to resolve when build completes
        let callback_ids: Vec<_> = self.pending_grammar_callbacks.drain(..).collect();

        // Spawn background rebuild
        let base_registry = std::sync::Arc::clone(&self.grammar_registry);
        if let Some(bridge) = &self.async_bridge {
            let sender = bridge.sender();
            self.grammar_build_in_progress = true;
            std::thread::Builder::new()
                .name("grammar-rebuild".to_string())
                .spawn(move || {
                    use crate::primitives::grammar::GrammarRegistry;
                    match GrammarRegistry::with_additional_grammars(&base_registry, &additional) {
                        Some(new_registry) => {
                            // Ok to ignore: receiver may be gone if app is shutting down.
                            drop(sender.send(
                                crate::services::async_bridge::AsyncMessage::GrammarRegistryBuilt {
                                    registry: std::sync::Arc::new(new_registry),
                                    callback_ids,
                                },
                            ));
                        }
                        None => {
                            tracing::error!("Failed to rebuild grammar registry in background");
                            // Still send the message so callbacks get resolved (even on failure)
                            drop(sender.send(
                                crate::services::async_bridge::AsyncMessage::GrammarRegistryBuilt {
                                    registry: base_registry,
                                    callback_ids,
                                },
                            ));
                        }
                    }
                })
                .ok();
        }
    }

    // ==================== Project Grep ====================

    /// Retry deferred virtual-buffer animations now that split_areas has
    /// been recomputed. Called from render() after layout but before
    /// animations.apply_all so the first frame of the effect lands in
    /// the same render pass.
    pub(crate) fn drain_pending_vb_animations(&mut self) {
        if self.pending_vb_animations.is_empty() {
            return;
        }
        let pending = std::mem::take(&mut self.pending_vb_animations);
        for (id, buffer_id, kind) in pending {
            match self.virtual_buffer_screen_rect(buffer_id) {
                Some(area) => {
                    let animation_kind = translate_plugin_animation_kind(kind);
                    self.active_window_mut().animations.start_with_id(
                        crate::view::animation::AnimationId::from_raw(id),
                        area,
                        animation_kind,
                    );
                }
                None => {
                    // Still not visible; keep pending for next frame.
                    self.pending_vb_animations.push((id, buffer_id, kind));
                }
            }
        }
    }

    /// Look up the on-screen Rect currently occupied by `buffer_id`, if any.
    /// Reads from the cached split layout captured in the last render pass.
    pub(crate) fn virtual_buffer_screen_rect(
        &self,
        buffer_id: BufferId,
    ) -> Option<ratatui::layout::Rect> {
        self.active_layout()
            .split_areas
            .iter()
            .find(|(_, bid, _, _, _, _)| *bid == buffer_id)
            .map(|(_, _, content_rect, _, _, _)| *content_rect)
    }
}
