Skip to main content

fresh/view/
scene.rs

1//! Shared semantic UI projections — the single source of truth for *what* the
2//! chrome is, computed once in the core and consumed by every frontend.
3//!
4//! The guiding principle (see docs/internal/UNIFIED_SCENE_DESIGN.md): the TUI and
5//! the web/GUI must not re-implement the same logic. Everything semantic — which
6//! menus exist, which items are enabled/checked, their accelerators, which menu
7//! is open — is derived here, once. A frontend then only does the *rendering*
8//! (this model → cells for the TUI; this model → HTML for the web) and the input
9//! bridge (crossterm vs. DOM → the shared `handle_key`/`handle_mouse`).
10//!
11//! These projections derive `serde::Serialize` so the web bridge can ship them
12//! as-is; the field names match the JSON the browser frontend already consumes.
13
14use crate::app::Editor;
15use fresh_core::LeafId;
16use ratatui::layout::Rect;
17use serde::Serialize;
18use std::collections::HashMap;
19
20/// A cell rectangle, serialized as `{x, y, w, h}` (matching the bridge's
21/// historical `rect_json`).
22#[derive(Debug, Clone, Copy, Serialize)]
23pub struct RectView {
24    pub x: u16,
25    pub y: u16,
26    pub w: u16,
27    pub h: u16,
28}
29
30impl From<Rect> for RectView {
31    fn from(r: Rect) -> Self {
32        RectView {
33            x: r.x,
34            y: r.y,
35            w: r.width,
36            h: r.height,
37        }
38    }
39}
40
41/// One item in a menu, projected semantically (no cells). `kind` tags the
42/// variant so the frontend can render actions, separators, submenus and labels
43/// differently.
44#[derive(Debug, Clone, Serialize)]
45#[serde(tag = "kind", rename_all = "lowercase")]
46pub enum MenuItemView {
47    Action {
48        label: String,
49        action: String,
50        #[serde(skip_serializing_if = "HashMap::is_empty")]
51        args: HashMap<String, serde_json::Value>,
52        accel: Option<String>,
53        enabled: bool,
54        checked: Option<bool>,
55    },
56    Sep,
57    Submenu {
58        label: String,
59        items: Vec<MenuItemView>,
60    },
61    Label {
62        label: String,
63    },
64}
65
66/// A top-level menu: its label, its menu-bar cell position (when laid out), and
67/// its item tree.
68#[derive(Debug, Clone, Serialize)]
69pub struct MenuEntry {
70    pub label: String,
71    /// Whether this menu's `when` condition is satisfied. Derived once here via
72    /// the shared `is_menu_visible` (the same the TUI uses), so the frontend
73    /// doesn't re-decide visibility on its own.
74    pub visible: bool,
75    pub x: Option<u16>,
76    pub w: Option<u16>,
77    pub items: Vec<MenuItemView>,
78}
79
80/// The currently open dropdown's cell geometry (from the pipeline's MenuLayout),
81/// so a frontend can position native rows at the exact cells the editor
82/// hit-tests against.
83#[derive(Debug, Clone, Serialize)]
84pub struct DropdownView {
85    pub rect: Option<RectView>,
86    pub items: Vec<ItemArea>,
87    pub submenus: Vec<SubmenuArea>,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct ItemArea {
92    pub index: usize,
93    pub rect: RectView,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct SubmenuArea {
98    pub depth: usize,
99    pub index: usize,
100    pub rect: RectView,
101}
102
103/// The full semantic menu model: the menu tree plus which menu/item is open and
104/// highlighted. The editor is the single source of truth for open/highlight;
105/// frontends render this and forward interactions back through `handle_mouse`.
106#[derive(Debug, Clone, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub struct MenuView {
109    pub menus: Vec<MenuEntry>,
110    pub menu_open: Option<usize>,
111    pub menu_highlight: Option<usize>,
112    pub submenu_path: Vec<usize>,
113    pub dropdown: Option<DropdownView>,
114}
115
116fn item_view(editor: &Editor, item: &fresh_core::menu::MenuItem) -> MenuItemView {
117    use fresh_core::menu::MenuItem::*;
118    match item {
119        Separator { .. } => MenuItemView::Sep,
120        Action {
121            label,
122            action,
123            args,
124            when: _,
125            checkbox,
126        } => MenuItemView::Action {
127            label: label.clone(),
128            action: action.clone(),
129            args: args.clone(),
130            accel: editor.accelerator_for(action),
131            // Same enabled/checked logic the TUI MenuRenderer uses — one source.
132            enabled: crate::view::ui::menu::is_menu_item_enabled(
133                item,
134                &editor.menu_state().context,
135            ),
136            checked: checkbox.as_ref().map(|_| {
137                crate::view::ui::menu::is_checkbox_checked(checkbox, &editor.menu_state().context)
138            }),
139        },
140        Submenu { label, items } => MenuItemView::Submenu {
141            label: label.clone(),
142            items: items.iter().map(|i| item_view(editor, i)).collect(),
143        },
144        DynamicSubmenu { label, .. } => MenuItemView::Submenu {
145            label: label.clone(),
146            items: Vec::new(),
147        },
148        Label { info } => MenuItemView::Label {
149            label: info.clone(),
150        },
151    }
152}
153
154fn union_rect(rects: &[Rect]) -> Option<Rect> {
155    let mut acc: Option<Rect> = None;
156    for r in rects {
157        acc = Some(match acc {
158            None => *r,
159            Some(a) => {
160                let x0 = a.x.min(r.x);
161                let y0 = a.y.min(r.y);
162                let x1 = (a.x + a.width).max(r.x + r.width);
163                let y1 = (a.y + a.height).max(r.y + r.height);
164                Rect::new(x0, y0, x1 - x0, y1 - y0)
165            }
166        });
167    }
168    acc
169}
170
171impl Editor {
172    /// Build the semantic menu model. This is the *single* place the menu's
173    /// structure, enabled/checked state and accelerators are derived; the TUI
174    /// renderer and the web bridge both consume this rather than recomputing it.
175    ///
176    /// Geometry (`x`/`w`, dropdown rects) comes from the pipeline's `MenuLayout`,
177    /// which is populated during render — so this reflects the most recent frame.
178    pub fn menu_view(&self) -> MenuView {
179        let chrome = self.active_chrome();
180        let menu_areas: HashMap<usize, Rect> = chrome
181            .menu_layout
182            .as_ref()
183            .map(|m| m.menu_areas.iter().cloned().collect())
184            .unwrap_or_default();
185
186        // Same expanded menu list the TUI renderer uses (config + plugin menus),
187        // so the two frontends never diverge on which menus/items exist.
188        let menus: Vec<MenuEntry> = self
189            .all_menus_expanded()
190            .iter()
191            .enumerate()
192            .map(|(i, m)| MenuEntry {
193                label: m.label.clone(),
194                visible: crate::view::ui::menu::is_menu_visible(m, &self.menu_state().context),
195                x: menu_areas.get(&i).map(|r| r.x),
196                w: menu_areas.get(&i).map(|r| r.width),
197                items: m.items.iter().map(|it| item_view(self, it)).collect(),
198            })
199            .collect();
200
201        let dropdown = chrome.menu_layout.as_ref().and_then(|ml| {
202            if ml.item_areas.is_empty() {
203                return None;
204            }
205            let rects: Vec<Rect> = ml.item_areas.iter().map(|(_, r)| *r).collect();
206            Some(DropdownView {
207                rect: union_rect(&rects).map(RectView::from),
208                items: ml
209                    .item_areas
210                    .iter()
211                    .map(|(index, r)| ItemArea {
212                        index: *index,
213                        rect: RectView::from(*r),
214                    })
215                    .collect(),
216                submenus: ml
217                    .submenu_areas
218                    .iter()
219                    .map(|(depth, index, r)| SubmenuArea {
220                        depth: *depth,
221                        index: *index,
222                        rect: RectView::from(*r),
223                    })
224                    .collect(),
225            })
226        });
227
228        let ms = self.menu_state();
229        MenuView {
230            menus,
231            menu_open: ms.active_menu,
232            menu_highlight: ms.highlighted_item,
233            submenu_path: ms.submenu_path.clone(),
234            dropdown,
235        }
236    }
237}
238
239// ─────────────────────────── tabs ───────────────────────────
240
241/// One tab in a pane's tab bar (semantic; geometry from the pipeline's
242/// TabLayout for click/close hit-testing).
243#[derive(Debug, Clone, Serialize)]
244#[serde(rename_all = "camelCase")]
245pub struct TabView {
246    pub buffer_id: Option<usize>,
247    pub label: String,
248    pub active: bool,
249    pub modified: bool,
250    pub rect: RectView,
251    pub close_rect: RectView,
252}
253
254/// A pane's tab bar: the bar rect (when laid out) and its tabs.
255#[derive(Debug, Clone, Default, Serialize)]
256pub struct TabBarView {
257    pub bar: Option<RectView>,
258    pub tabs: Vec<TabView>,
259}
260
261// ─────────────────────────── status bar ───────────────────────────
262
263#[derive(Debug, Clone, Serialize)]
264pub struct StatusSegment {
265    pub name: &'static str,
266    pub key: Option<String>,
267    pub text: String,
268    pub x: u16,
269    pub w: u16,
270    pub side: &'static str,
271}
272
273#[derive(Debug, Clone, Serialize)]
274pub struct StatusView {
275    pub rect: RectView,
276    pub segments: Vec<StatusSegment>,
277}
278
279// ─────────────────────────── command palette / picker ───────────────────────────
280
281#[derive(Debug, Clone, Serialize)]
282pub struct SuggestionView {
283    pub text: String,
284    pub description: Option<String>,
285    pub keybinding: Option<String>,
286    pub disabled: bool,
287}
288
289#[derive(Debug, Clone, Serialize)]
290#[serde(rename_all = "camelCase")]
291pub struct PaletteView {
292    pub query: String,
293    pub message: String,
294    pub prompt_type: &'static str,
295    pub overlay: bool,
296    pub title: String,
297    pub status: String,
298    pub selected: Option<usize>,
299    pub scroll_start: usize,
300    pub visible_count: usize,
301    pub total: usize,
302    pub outer_rect: Option<RectView>,
303    pub list_rect: Option<RectView>,
304    /// Content rect of the live-grep / quick-open preview pane (the buffer
305    /// interior, inside the left border). The preview is real rendered cells,
306    /// so the bridge slices them from this rect and the frontend draws them
307    /// like a pane interior. `None` when no preview is showing.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub preview_rect: Option<RectView>,
310    pub suggestions: Vec<SuggestionView>,
311    /// Optional plugin-built toolbar for the overlay header (real `WidgetSpec`
312    /// widgets — e.g. live-grep's scope toggles). Rendered natively; toggle/
313    /// button clicks route back through `toggle_overlay_toolbar_widget`.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub toolbar: Option<fresh_core::api::WidgetSpec>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub toolbar_focus: Option<String>,
318}
319
320/// Stable tag for a prompt type so the frontend can label the palette/picker.
321fn prompt_type_tag(t: &crate::view::prompt::PromptType) -> &'static str {
322    use crate::view::prompt::PromptType::*;
323    match t {
324        QuickOpen => "quickopen",
325        LiveGrep => "livegrep",
326        Search | ReplaceSearch | QueryReplaceSearch => "search",
327        OpenFile | OpenFileWithEncoding { .. } => "openfile",
328        SaveFileAs => "saveas",
329        GotoLine | GotoByteOffset => "goto",
330        _ => "input",
331    }
332}
333
334impl Editor {
335    /// Semantic tab bar for a pane (leaf). Single derivation of tab labels /
336    /// active / modified shared by the TUI tab renderer and the web bridge.
337    pub fn tab_bar_view(&self, leaf: LeafId) -> TabBarView {
338        let active = self.active_buffer();
339        let layout = self.active_layout();
340        match layout.tab_layouts.get(&leaf) {
341            None => TabBarView::default(),
342            Some(tl) => TabBarView {
343                bar: Some(RectView::from(tl.bar_area)),
344                tabs: tl
345                    .tabs
346                    .iter()
347                    .map(|tab| {
348                        let bid = tab.target.as_buffer();
349                        TabView {
350                            buffer_id: bid.map(|b| b.0),
351                            label: bid
352                                .and_then(|b| self.buffer_display_name(b))
353                                .unwrap_or_else(|| "untitled".into()),
354                            active: bid == Some(active),
355                            modified: bid.map(|b| self.buffer_is_modified(b)).unwrap_or(false),
356                            rect: RectView::from(tab.tab_area),
357                            close_rect: RectView::from(tab.close_area),
358                        }
359                    })
360                    .collect(),
361            },
362        }
363    }
364
365    /// Semantic status bar: the whole bar tiled into labeled indicator segments
366    /// plus the untracked text runs between them (file name / Ln,Col). The
367    /// segment *text* is lifted from the rendered `buf` for now. Single
368    /// derivation shared by both frontends.
369    pub fn status_view(&self) -> Option<StatusView> {
370        let chrome = self.active_chrome();
371        let (sy, sx, sw) = chrome.status_bar_area?;
372
373        // Read the status bar's semantic model captured by the renderer — no
374        // cell scraping. Each rendered element (indicators + text) is a segment,
375        // and `side` is the renderer's actual left/right tiling (carried on the
376        // segment), not a midpoint guess from `x`.
377        let segments: Vec<StatusSegment> = chrome
378            .status_bar_segments
379            .iter()
380            .filter(|s| !s.text.trim().is_empty())
381            .map(|s| StatusSegment {
382                name: s.name,
383                key: s.key.clone(),
384                text: s.text.trim().to_string(),
385                x: s.x,
386                w: s.w,
387                side: s.side,
388            })
389            .collect();
390
391        Some(StatusView {
392            rect: RectView {
393                x: sx,
394                y: sy,
395                w: sw,
396                h: 1,
397            },
398            segments,
399        })
400    }
401
402    /// Semantic command palette / picker, derived from the active prompt and the
403    /// pipeline's suggestion-popup geometry. `None` unless a picker list (or a
404    /// floating overlay) is showing. Single derivation shared by both frontends.
405    pub fn palette_view(&self) -> Option<PaletteView> {
406        let chrome = self.active_chrome();
407        let sugg_outer = chrome.suggestions_outer_area;
408        let sugg_area = chrome.suggestions_area;
409        let prompt_results = chrome.prompt_results_area;
410        let p = self.active_window().prompt.as_ref()?;
411        if p.suggestions.is_empty() && !p.overlay {
412            return None;
413        }
414        let (scroll_start, visible, total) = sugg_area.map(|(_, s, v, t)| (s, v, t)).unwrap_or((
415            p.scroll_offset,
416            p.suggestions.len(),
417            p.suggestions.len(),
418        ));
419        Some(PaletteView {
420            query: p.input.clone(),
421            message: p.message.clone(),
422            prompt_type: prompt_type_tag(&p.prompt_type),
423            overlay: p.overlay,
424            title: p.title.iter().map(|t| t.text.as_str()).collect(),
425            status: p.status.clone(),
426            selected: p.selected_suggestion,
427            scroll_start,
428            visible_count: visible,
429            total,
430            outer_rect: sugg_outer.map(RectView::from),
431            list_rect: sugg_area
432                .map(|(r, _, _, _)| r)
433                .or(prompt_results)
434                .map(RectView::from),
435            // Inner content of the preview pane: the stored area minus its
436            // single left border column (matches `Block::borders(LEFT)` in
437            // render_overlay_prompt). Only meaningful for overlay prompts.
438            preview_rect: chrome.prompt_preview_area.and_then(|r| {
439                (r.width > 1 && r.height > 0).then(|| {
440                    RectView::from(Rect::new(
441                        r.x.saturating_add(1),
442                        r.y,
443                        r.width.saturating_sub(1),
444                        r.height,
445                    ))
446                })
447            }),
448            suggestions: p
449                .suggestions
450                .iter()
451                .map(|s| SuggestionView {
452                    text: s.text.clone(),
453                    description: s.description.clone(),
454                    keybinding: s.keybinding.clone(),
455                    disabled: s.disabled,
456                })
457                .collect(),
458            toolbar: p.toolbar_widget.clone(),
459            toolbar_focus: p.toolbar_focus.clone(),
460        })
461    }
462}
463
464// ─────────────────────────── popups (completion / hover / action / list / text) ───────────────────────────
465
466#[derive(Debug, Clone, Serialize)]
467pub struct PopupItemView {
468    pub text: String,
469    pub detail: Option<String>,
470    pub icon: Option<String>,
471    pub disabled: bool,
472}
473
474#[derive(Debug, Clone, Serialize)]
475#[serde(tag = "type", rename_all = "lowercase")]
476pub enum PopupContentView {
477    List {
478        items: Vec<PopupItemView>,
479        selected: usize,
480    },
481    Lines {
482        lines: Vec<String>,
483    },
484}
485
486/// A floating popup (completion menu, hover doc, action chooser, …) projected
487/// semantically. Geometry (`rect`/`content_rect`) is the pipeline's popup layout
488/// so the frontend can position the native box and forward clicks/scroll back
489/// through `handle_mouse` — the existing popup hit-tester resolves them.
490#[derive(Debug, Clone, Serialize)]
491#[serde(rename_all = "camelCase")]
492pub struct ScenePopup {
493    pub kind: &'static str,
494    pub title: Option<String>,
495    pub description: Option<String>,
496    pub rect: RectView,
497    pub content_rect: RectView,
498    pub scroll_offset: usize,
499    pub content: PopupContentView,
500}
501
502fn project_popup(
503    p: &crate::view::popup::Popup,
504    outer: Rect,
505    inner: Rect,
506    scroll: usize,
507) -> ScenePopup {
508    use crate::view::popup::{PopupContent, PopupKind};
509    let kind = match p.kind {
510        PopupKind::Completion => "completion",
511        PopupKind::Hover => "hover",
512        PopupKind::Action => "action",
513        PopupKind::List => "list",
514        PopupKind::Text => "text",
515    };
516    let content = match &p.content {
517        PopupContent::List { items, selected } => PopupContentView::List {
518            items: items
519                .iter()
520                .map(|i| PopupItemView {
521                    text: i.text.clone(),
522                    detail: i.detail.clone(),
523                    icon: i.icon.clone(),
524                    disabled: i.disabled,
525                })
526                .collect(),
527            selected: *selected,
528        },
529        PopupContent::Text(lines) | PopupContent::Custom(lines) => PopupContentView::Lines {
530            lines: lines.clone(),
531        },
532        PopupContent::Markdown(styled) => PopupContentView::Lines {
533            lines: styled
534                .iter()
535                .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect::<String>())
536                .collect(),
537        },
538    };
539    ScenePopup {
540        kind,
541        title: p.title.clone(),
542        description: p.description.clone(),
543        rect: RectView::from(outer),
544        content_rect: RectView::from(inner),
545        scroll_offset: scroll,
546        content,
547    }
548}
549
550impl Editor {
551    /// All visible popups across the per-buffer and global stacks, projected
552    /// semantically. Single derivation shared by the web frontend (native HTML)
553    /// and available to the TUI compositor; geometry comes from the pipeline's
554    /// popup-area caches so clicks/scroll route through the existing hit-tester.
555    pub fn popups_view(&self) -> Vec<ScenePopup> {
556        let chrome = self.active_chrome();
557        let mut out = Vec::new();
558        let locals = self.active_state().popups.all();
559        for (idx, outer, inner, scroll, _n, _sb, _t) in &chrome.popup_areas {
560            if let Some(p) = locals.get(*idx) {
561                out.push(project_popup(p, *outer, *inner, *scroll));
562            }
563        }
564        let globals = self.global_popups.all();
565        for (idx, outer, inner, scroll, _n) in &chrome.global_popup_areas {
566            if let Some(p) = globals.get(*idx) {
567                out.push(project_popup(p, *outer, *inner, *scroll));
568            }
569        }
570        out
571    }
572}
573
574// ─────────────────────────── file explorer (sidebar tree) ───────────────────────────
575
576#[derive(Debug, Clone, Serialize)]
577#[serde(rename_all = "camelCase")]
578pub struct FileRow {
579    pub name: String,
580    pub depth: usize,
581    pub is_dir: bool,
582    pub expanded: bool,
583}
584
585#[derive(Debug, Clone, Serialize)]
586#[serde(rename_all = "camelCase")]
587pub struct FileExplorerView {
588    pub rect: RectView,
589    pub title: String,
590    pub scroll_offset: usize,
591    pub viewport_height: usize,
592    pub selected: Option<usize>,
593    pub rows: Vec<FileRow>,
594}
595
596impl Editor {
597    /// Semantic file-explorer sidebar: the flattened visible tree rows (the same
598    /// `get_display_nodes()` the TUI renderer uses) plus selection/scroll and the
599    /// sidebar rect. Rendered natively by the web frontend; row clicks route back
600    /// through `handle_mouse` at the sidebar's content cells, which the existing
601    /// file-explorer hit-test resolves to the same display index.
602    pub fn file_explorer_view(&self) -> Option<FileExplorerView> {
603        let rect = self.active_layout().file_explorer_area?;
604        let view = self.file_explorer()?;
605        let tree = view.tree();
606        let rows = view
607            .get_display_nodes()
608            .into_iter()
609            .filter_map(|(id, indent)| {
610                tree.get_node(id).map(|n| FileRow {
611                    name: n.entry.name.clone(),
612                    depth: indent,
613                    is_dir: n.is_dir(),
614                    expanded: n.is_expanded(),
615                })
616            })
617            .collect();
618        let title = tree
619            .get_node(tree.root_id())
620            .map(|n| n.entry.name.clone())
621            .unwrap_or_default();
622        Some(FileExplorerView {
623            rect: RectView::from(rect),
624            title,
625            scroll_offset: view.get_scroll_offset(),
626            viewport_height: view.viewport_height,
627            selected: view.get_selected_index(),
628            rows,
629        })
630    }
631}
632
633// ─────────────────────────── workspace-trust dialog ───────────────────────────
634
635#[derive(Debug, Clone, Serialize)]
636pub struct TrustOptionView {
637    pub label: String,
638    pub description: String,
639    pub selected: bool,
640    pub data: &'static str,
641    pub rect: RectView,
642}
643
644#[derive(Debug, Clone, Serialize)]
645#[serde(rename_all = "camelCase")]
646pub struct TrustDialogView {
647    pub dialog: RectView,
648    pub title: String,
649    pub path: String,
650    pub triggers: String,
651    pub cancellable: bool,
652    pub options: Vec<TrustOptionView>,
653    pub ok: RectView,
654    pub ok_label: String,
655    pub quit: RectView,
656    pub quit_label: String,
657}
658
659impl Editor {
660    /// Semantic workspace-trust dialog (the blocking "trust this folder?" modal).
661    /// `None` unless it's showing. Geometry comes from the pipeline's
662    /// `TrustDialogLayout`; clicks on the options / OK / Quit route back through
663    /// `handle_mouse` at those rects (the existing `handle_workspace_trust_mouse`).
664    pub fn trust_dialog_view(&self) -> Option<TrustDialogView> {
665        let layout = self.active_chrome().workspace_trust_dialog.clone()?;
666        let selected = self.current_workspace_trust_selection();
667        let data = ["trusted", "restricted", "blocked"];
668        let options = crate::view::workspace_trust_dialog::options()
669            .into_iter()
670            .enumerate()
671            .map(|(i, o)| TrustOptionView {
672                label: o.label,
673                description: o.description,
674                selected: i == selected,
675                data: data.get(i).copied().unwrap_or("restricted"),
676                rect: RectView::from(layout.radios[i]),
677            })
678            .collect();
679        let quit_label = if self.workspace_trust_cancellable() {
680            rust_i18n::t!("trust.dialog.btn_cancel").into_owned()
681        } else {
682            rust_i18n::t!("trust.dialog.btn_quit").into_owned()
683        };
684        Some(TrustDialogView {
685            dialog: RectView::from(layout.dialog),
686            title: rust_i18n::t!("trust.dialog.security_warning").into_owned(),
687            path: self.working_dir().display().to_string(),
688            triggers: self.workspace_trust_markers().join(", "),
689            cancellable: self.workspace_trust_cancellable(),
690            options,
691            ok: RectView::from(layout.ok),
692            ok_label: rust_i18n::t!("trust.dialog.btn_ok").into_owned(),
693            quit: RectView::from(layout.quit),
694            quit_label,
695        })
696    }
697}
698
699// ─────────────────────────── plugin widget surfaces (floating / dock) ───────────────────────────
700
701#[derive(Debug, Clone, Serialize)]
702#[serde(rename_all = "camelCase")]
703pub struct WidgetHitView {
704    /// Index into this surface's `hits` — sent back on click so the editor runs
705    /// the exact same hit it would for a TUI cell click.
706    pub index: usize,
707    pub widget_key: String,
708    pub widget_kind: String,
709    pub event_type: String,
710    pub payload: serde_json::Value,
711}
712
713/// Host-owned instance state a frontend needs to render a widget correctly
714/// (List/Tree selection + scroll). Keyed by widget `key`.
715#[derive(Debug, Clone, Serialize)]
716#[serde(rename_all = "camelCase")]
717pub struct WidgetInstanceView {
718    pub selected_index: Option<i32>,
719    pub scroll_offset: Option<u32>,
720    #[serde(skip_serializing_if = "Vec::is_empty")]
721    pub expanded_keys: Vec<String>,
722}
723
724#[derive(Debug, Clone, Serialize)]
725#[serde(rename_all = "camelCase")]
726pub struct WidgetSurfaceView {
727    /// "dock" (left dock) or "floatingModal" (centered).
728    pub kind: &'static str,
729    pub plugin: String,
730    pub panel_id: u64,
731    pub rect: RectView,
732    pub focus_key: String,
733    /// The raw, already-serializable `WidgetSpec` tree — rendered natively.
734    pub spec: fresh_core::api::WidgetSpec,
735    pub instances: HashMap<String, WidgetInstanceView>,
736    pub hits: Vec<WidgetHitView>,
737}
738
739impl Editor {
740    /// Semantic model for plugin-mounted floating / dock widget panels (e.g. the
741    /// orchestrator session dock). Each surface ships its `WidgetSpec` tree +
742    /// instance state + on-screen rect + hit list; the frontend renders the spec
743    /// natively and forwards a clicked hit's index back through `/widget`, which
744    /// runs the same `deliver_widget_hit` path as a TUI cell click. `None`
745    /// surfaces (unmounted panels) are simply omitted.
746    pub fn widgets_view(&self) -> Vec<WidgetSurfaceView> {
747        let mut out = Vec::new();
748        for (kind, slot) in [
749            ("dock", self.dock.as_ref()),
750            ("floatingModal", self.floating_widget_panel.as_ref()),
751        ] {
752            let Some(fwp) = slot else { continue };
753            let Some(rect) = fwp.last_inner_rect else {
754                continue;
755            };
756            let Some(panel) = self.widget_registry.get(&fwp.panel_key) else {
757                continue;
758            };
759            let mut instances = HashMap::new();
760            for (key, st) in &panel.instance_states {
761                use crate::widgets::WidgetInstanceState as W;
762                let view = match st {
763                    W::List {
764                        scroll_offset,
765                        selected_index,
766                        ..
767                    } => WidgetInstanceView {
768                        selected_index: Some(*selected_index),
769                        scroll_offset: Some(*scroll_offset),
770                        expanded_keys: Vec::new(),
771                    },
772                    W::Tree {
773                        scroll_offset,
774                        selected_index,
775                        expanded_keys,
776                    } => WidgetInstanceView {
777                        selected_index: Some(*selected_index),
778                        scroll_offset: Some(*scroll_offset),
779                        expanded_keys: expanded_keys.iter().cloned().collect(),
780                    },
781                    _ => continue,
782                };
783                instances.insert(key.clone(), view);
784            }
785            let hits = panel
786                .hits
787                .iter()
788                .enumerate()
789                .map(|(index, h)| WidgetHitView {
790                    index,
791                    widget_key: h.widget_key.clone(),
792                    widget_kind: h.widget_kind.to_string(),
793                    event_type: h.event_type.to_string(),
794                    payload: h.payload.clone(),
795                })
796                .collect();
797            out.push(WidgetSurfaceView {
798                kind,
799                plugin: fwp.panel_key.plugin.clone(),
800                panel_id: fwp.panel_key.id,
801                rect: RectView::from(rect),
802                focus_key: panel.focus_key.clone(),
803                spec: panel.spec.clone(),
804                instances,
805                hits,
806            });
807        }
808        out
809    }
810}
811
812// ─────────────────────────── context menus (right-click / new-tab) ───────────────────────────
813
814#[derive(Debug, Clone, Serialize)]
815#[serde(rename_all = "camelCase")]
816pub struct ContextMenuView {
817    /// "tab" | "newTab" | "fileExplorer" — for styling / debugging.
818    pub kind: &'static str,
819    pub x: u16,
820    pub y: u16,
821    pub highlighted: usize,
822    pub items: Vec<String>,
823}
824
825impl Editor {
826    /// The active right-click / new-tab context menu (only one shows at a time),
827    /// projected for native rendering. Items render at `y + 1 + i` (the bordered
828    /// box); a click forwarded to `handle_mouse` at `(x + 1, y + 1 + i)` resolves
829    /// to item `i` via the existing hover/hit-test (`item_idx = row - y - 1`).
830    pub fn context_menu_view(&self) -> Option<ContextMenuView> {
831        let w = self.active_window();
832        let chrome = self.active_chrome();
833        if let Some(m) = &w.file_explorer_context_menu {
834            let (x, y) = m.clamped_position(chrome.last_frame_width, chrome.last_frame_height);
835            return Some(ContextMenuView {
836                kind: "fileExplorer",
837                x,
838                y,
839                highlighted: m.highlighted,
840                items: m.items().iter().map(|i| i.label()).collect(),
841            });
842        }
843        if let Some(m) = &w.new_tab_menu {
844            return Some(ContextMenuView {
845                kind: "newTab",
846                x: m.position.0,
847                y: m.position.1,
848                highlighted: m.highlighted,
849                items: crate::app::types::NewTabMenuItem::all()
850                    .iter()
851                    .map(|i| i.label())
852                    .collect(),
853            });
854        }
855        if let Some(m) = &w.tab_context_menu {
856            return Some(ContextMenuView {
857                kind: "tab",
858                x: m.position.0,
859                y: m.position.1,
860                highlighted: m.highlighted,
861                items: crate::app::types::TabContextMenuItem::all()
862                    .iter()
863                    .map(|i| i.label())
864                    .collect(),
865            });
866        }
867        None
868    }
869}
870
871// ─────────────────────────── auxiliary modals (keybindings / event-debug / theme-info) ───────────────────────────
872
873#[derive(Debug, Clone, Serialize)]
874#[serde(rename_all = "camelCase")]
875pub struct AuxLine {
876    pub text: String,
877    pub selected: bool,
878}
879
880/// A small/secondary modal projected as a titled list of text lines. Covers the
881/// keybinding editor (binding list), the event-debug log, and the theme-info
882/// popup — read-mostly surfaces whose interaction (nav / Esc / rebind) already
883/// flows through `handle_key`. `rect` anchors the theme popup; `None` ⇒ the
884/// frontend centers it.
885#[derive(Debug, Clone, Serialize)]
886#[serde(rename_all = "camelCase")]
887pub struct AuxModalView {
888    pub kind: &'static str,
889    pub title: String,
890    pub rect: Option<RectView>,
891    pub lines: Vec<AuxLine>,
892    pub footer: Option<String>,
893}
894
895impl Editor {
896    /// The active auxiliary modal (keybinding editor / event-debug / theme-info),
897    /// projected as a titled line list for native rendering. Only one shows at a
898    /// time. Cells for these are suppressed on the web; keyboard drives them.
899    pub fn aux_modals_view(&self) -> Option<AuxModalView> {
900        // NOTE: the keybinding editor is intentionally NOT projected here — it's a
901        // full interactive modal (search, context/source filters, an add/edit
902        // sub-dialog, help overlay), Settings-grade rather than a line list. It
903        // renders as cells (functional) until it gets a proper native projection,
904        // grouped with the Settings UI.
905        let w = self.active_window();
906        // Event-debug log.
907        if let Some(ed) = &w.event_debug {
908            let mut lines: Vec<AuxLine> = ed
909                .history
910                .iter()
911                .map(|r| AuxLine {
912                    text: r.description.clone(),
913                    selected: false,
914                })
915                .collect();
916            if lines.is_empty() {
917                lines.push(AuxLine {
918                    text: rust_i18n::t!("event_debug.no_events").into_owned(),
919                    selected: false,
920                });
921            }
922            return Some(AuxModalView {
923                kind: "eventDebug",
924                title: rust_i18n::t!("event_debug.title").into_owned(),
925                rect: None,
926                lines,
927                footer: Some(rust_i18n::t!("event_debug.help_text").into_owned()),
928            });
929        }
930        // Theme-info popup (anchored at its click position).
931        if let Some(ti) = &w.theme_info_popup {
932            fn color_str(c: ratatui::style::Color) -> String {
933                match c {
934                    ratatui::style::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
935                    other => format!("{other:?}"),
936                }
937            }
938            let info = &ti.info;
939            let mut lines = vec![AuxLine {
940                text: format!("Region: {}", info.region),
941                selected: false,
942            }];
943            if let Some(k) = &info.fg_key {
944                let c = info
945                    .fg_color
946                    .map(|c| format!("  {}", color_str(c)))
947                    .unwrap_or_default();
948                lines.push(AuxLine {
949                    text: format!("Foreground: {k}{c}"),
950                    selected: false,
951                });
952            }
953            if let Some(k) = &info.bg_key {
954                let c = info
955                    .bg_color
956                    .map(|c| format!("  {}", color_str(c)))
957                    .unwrap_or_default();
958                lines.push(AuxLine {
959                    text: format!("Background: {k}{c}"),
960                    selected: false,
961                });
962            }
963            if let Some(cat) = &info.syntax_category {
964                lines.push(AuxLine {
965                    text: format!("Category: {cat}"),
966                    selected: false,
967                });
968            }
969            return Some(AuxModalView {
970                kind: "themeInfo",
971                title: "Theme".to_string(),
972                rect: Some(RectView {
973                    x: ti.position.0,
974                    y: ti.position.1,
975                    w: 0,
976                    h: 0,
977                }),
978                lines,
979                footer: None,
980            });
981        }
982        None
983    }
984}
985
986// ─────────────────────────── keybinding editor (full native modal) ───────────────────────────
987
988#[derive(Debug, Clone, Serialize)]
989#[serde(rename_all = "camelCase")]
990pub struct KbSearchView {
991    pub active: bool,
992    pub focused: bool,
993    pub mode: &'static str, // "text" | "recordKey"
994    pub query: String,
995    pub key_display: String,
996}
997
998#[derive(Debug, Clone, Serialize)]
999#[serde(tag = "type", rename_all = "camelCase")]
1000pub enum KbRow {
1001    Section {
1002        name: String,
1003        collapsed: bool,
1004        count: usize,
1005        selected: bool,
1006    },
1007    Binding {
1008        key: String,
1009        action: String,
1010        description: String,
1011        context: String,
1012        source: &'static str, // "keymap" | "custom" | "plugin" | ""
1013        selected: bool,
1014    },
1015}
1016
1017#[derive(Debug, Clone, Serialize)]
1018#[serde(rename_all = "camelCase")]
1019pub struct KbEditDialog {
1020    pub title: String,
1021    pub focus_area: usize, // 0=key 1=action 2=context 3=buttons
1022    pub key_display: String,
1023    pub key_capturing: bool,
1024    pub action_text: String,
1025    pub action_error: Option<String>,
1026    pub autocomplete: Vec<String>,
1027    pub autocomplete_selected: Option<usize>,
1028    pub context: String,
1029    pub context_options: Vec<String>,
1030    pub conflicts: Vec<String>,
1031    pub save_focused: bool,
1032    pub cancel_focused: bool,
1033}
1034
1035#[derive(Debug, Clone, Serialize)]
1036#[serde(rename_all = "camelCase")]
1037pub struct KbConfirm {
1038    pub buttons: Vec<String>,
1039    pub selected: usize,
1040}
1041
1042#[derive(Debug, Clone, Serialize)]
1043#[serde(rename_all = "camelCase")]
1044pub struct KeybindingEditorView {
1045    pub title: String,
1046    pub config_path: String,
1047    pub keymaps: Vec<String>,
1048    pub search: KbSearchView,
1049    pub context_filter: String,
1050    pub context_filtered: bool,
1051    pub source_filter: String,
1052    pub source_filtered: bool,
1053    pub count: String,
1054    pub has_changes: bool,
1055    pub rows: Vec<KbRow>,
1056    pub selected: usize,
1057    pub scroll_offset: u16,
1058    pub viewport: u16,
1059    pub showing_help: bool,
1060    pub edit_dialog: Option<KbEditDialog>,
1061    pub confirm: Option<KbConfirm>,
1062}
1063
1064impl Editor {
1065    /// Full semantic model of the keybinding editor modal (header + search +
1066    /// filters, the binding/section table, the add/edit sub-dialog, the confirm
1067    /// dialog and the help flag). Rendered natively; all interaction already
1068    /// flows through `handle_key` (the editor is keyboard-driven).
1069    pub fn keybinding_editor_view(&self) -> Option<KeybindingEditorView> {
1070        use crate::app::keybinding_editor::{
1071            BindingSource, ContextFilter, DisplayRow, SearchMode, SourceFilter,
1072        };
1073        let kb = self.keybinding_editor.as_ref()?;
1074
1075        let rows = kb
1076            .display_rows
1077            .iter()
1078            .enumerate()
1079            .map(|(i, dr)| {
1080                let selected = i == kb.selected;
1081                match dr {
1082                    DisplayRow::SectionHeader {
1083                        plugin_name,
1084                        collapsed,
1085                        binding_count,
1086                    } => KbRow::Section {
1087                        name: plugin_name.clone().unwrap_or_else(|| "Builtin".to_string()),
1088                        collapsed: *collapsed,
1089                        count: *binding_count,
1090                        selected,
1091                    },
1092                    DisplayRow::Binding(bi) => {
1093                        let b = &kb.bindings[*bi];
1094                        KbRow::Binding {
1095                            key: b.key_display.clone(),
1096                            action: b.action.clone(),
1097                            description: b.action_display.clone(),
1098                            context: b.context.clone(),
1099                            source: match b.source {
1100                                BindingSource::Keymap => "keymap",
1101                                BindingSource::Custom => "custom",
1102                                BindingSource::Plugin => "plugin",
1103                                BindingSource::Unbound => "",
1104                            },
1105                            selected,
1106                        }
1107                    }
1108                }
1109            })
1110            .collect();
1111
1112        let (context_filter, context_filtered) = match &kb.context_filter {
1113            ContextFilter::All => ("All".to_string(), false),
1114            ContextFilter::Specific(s) => (s.clone(), true),
1115        };
1116        let (source_filter, source_filtered) = match kb.source_filter {
1117            SourceFilter::All => ("All", false),
1118            SourceFilter::KeymapOnly => ("Keymap", true),
1119            SourceFilter::CustomOnly => ("Custom", true),
1120            SourceFilter::PluginOnly => ("Plugin", true),
1121        };
1122
1123        let edit_dialog = kb.edit_dialog.as_ref().map(|d| KbEditDialog {
1124            title: if d.editing_index.is_some() {
1125                "Edit Binding".to_string()
1126            } else {
1127                "Add Binding".to_string()
1128            },
1129            focus_area: d.focus_area,
1130            key_display: d.key_display.clone(),
1131            key_capturing: d.capturing_special,
1132            action_text: d.action_text.clone(),
1133            action_error: d.action_error.clone(),
1134            autocomplete: if d.autocomplete_visible {
1135                d.autocomplete_suggestions.clone()
1136            } else {
1137                Vec::new()
1138            },
1139            autocomplete_selected: d.autocomplete_selected,
1140            context: d.context.clone(),
1141            context_options: d.context_options.clone(),
1142            conflicts: d.conflicts.clone(),
1143            save_focused: d.focus_area == 3 && d.selected_button == 0,
1144            cancel_focused: d.focus_area == 3 && d.selected_button == 1,
1145        });
1146
1147        let confirm = kb.showing_confirm_dialog.then(|| KbConfirm {
1148            buttons: vec!["Save".into(), "Discard".into(), "Cancel".into()],
1149            selected: kb.confirm_selection,
1150        });
1151
1152        Some(KeybindingEditorView {
1153            title: format!("Keybindings — {}", kb.active_keymap),
1154            config_path: kb.config_file_path.clone(),
1155            keymaps: kb.keymap_names.clone(),
1156            search: KbSearchView {
1157                active: kb.search_active,
1158                focused: kb.search_focused,
1159                mode: match kb.search_mode {
1160                    SearchMode::Text => "text",
1161                    SearchMode::RecordKey => "recordKey",
1162                },
1163                query: kb.search_query.clone(),
1164                key_display: kb.search_key_display.clone(),
1165            },
1166            context_filter,
1167            context_filtered,
1168            source_filter: source_filter.to_string(),
1169            source_filtered,
1170            count: format!("{} / {}", kb.filtered_indices.len(), kb.bindings.len()),
1171            has_changes: kb.has_changes,
1172            rows,
1173            selected: kb.selected,
1174            scroll_offset: kb.scroll.offset,
1175            viewport: kb.scroll.viewport,
1176            showing_help: kb.showing_help,
1177            edit_dialog,
1178            confirm,
1179        })
1180    }
1181}
1182
1183// ─────────────────────────── settings UI (full native modal) ───────────────────────────
1184
1185#[derive(Debug, Clone, Serialize)]
1186#[serde(tag = "kind", rename_all = "camelCase")]
1187pub enum SettingControlView {
1188    Toggle {
1189        checked: bool,
1190    },
1191    Number {
1192        value: i64,
1193        min: Option<i64>,
1194        max: Option<i64>,
1195    },
1196    Dropdown {
1197        selected: usize,
1198        options: Vec<String>,
1199        open: bool,
1200    },
1201    Text {
1202        value: String,
1203        editing: bool,
1204        placeholder: String,
1205    },
1206    TextList {
1207        items: Vec<String>,
1208        focused: Option<usize>,
1209    },
1210    // Variant-level camelCase: the enum's `rename_all` renames variants but
1211    // not struct-variant *fields*, so the multi-word fields below need this to
1212    // match the camelCase JSON contract the frontend consumes.
1213    #[serde(rename_all = "camelCase")]
1214    DualList {
1215        included: Vec<String>,
1216        available: Vec<String>,
1217        /// Cursor row in each column and which column is active, so the web
1218        /// can mirror the TUI's selection highlight. Row indices line up with
1219        /// `included` / `available` (same order the dispatch hits use).
1220        included_cursor: usize,
1221        available_cursor: usize,
1222        active_column: &'static str, // "included" | "available"
1223    },
1224    Map {
1225        entries: Vec<MapEntryView>,
1226    },
1227    ObjectArray {
1228        entries: Vec<String>,
1229    },
1230    Json {
1231        value: String,
1232    },
1233    Complex {
1234        type_name: String,
1235    },
1236}
1237
1238#[derive(Debug, Clone, Serialize)]
1239#[serde(rename_all = "camelCase")]
1240pub struct MapEntryView {
1241    pub key: String,
1242    pub display: String,
1243}
1244
1245#[derive(Debug, Clone, Serialize)]
1246#[serde(rename_all = "camelCase")]
1247pub struct SettingItemView {
1248    pub index: usize,
1249    pub path: String,
1250    pub name: String,
1251    pub description: Option<String>,
1252    pub section: Option<String>,
1253    pub section_start: bool,
1254    pub modified: bool,
1255    pub read_only: bool,
1256    pub nullable: bool,
1257    pub is_null: bool,
1258    pub selected: bool,
1259    pub control: SettingControlView,
1260}
1261
1262#[derive(Debug, Clone, Serialize)]
1263#[serde(rename_all = "camelCase")]
1264pub struct SettingsCategoryView {
1265    pub index: usize,
1266    pub name: String,
1267    pub selected: bool,
1268    pub expandable: bool,
1269    pub expanded: bool,
1270    pub sections: Vec<String>,
1271}
1272
1273#[derive(Debug, Clone, Serialize)]
1274#[serde(rename_all = "camelCase")]
1275pub struct SettingsSearchResultView {
1276    pub name: String,
1277    pub category: String,
1278}
1279
1280#[derive(Debug, Clone, Serialize)]
1281#[serde(rename_all = "camelCase")]
1282pub struct EntryDialogView {
1283    pub title: String,
1284    pub is_new: bool,
1285    pub items: Vec<SettingItemView>,
1286    pub selected_item: usize,
1287    pub focus_on_buttons: bool,
1288    pub focused_button: usize,
1289    pub no_delete: bool,
1290}
1291
1292#[derive(Debug, Clone, Serialize)]
1293#[serde(rename_all = "camelCase")]
1294pub struct SettingsView {
1295    pub title: String,
1296    pub focus: &'static str, // "categories" | "settings" | "footer"
1297    pub target_layer: String,
1298    pub categories: Vec<SettingsCategoryView>,
1299    pub items: Vec<SettingItemView>,
1300    pub footer_buttons: Vec<String>,
1301    pub footer_selected: usize,
1302    pub search_active: bool,
1303    pub search_query: String,
1304    pub search_results: Vec<SettingsSearchResultView>,
1305    pub search_selected: usize,
1306    pub entry_dialog: Option<EntryDialogView>,
1307    pub showing_help: bool,
1308    pub showing_confirm: bool,
1309    pub showing_reset: bool,
1310}
1311
1312fn setting_control_view(c: &crate::view::settings::items::SettingControl) -> SettingControlView {
1313    use crate::view::settings::items::SettingControl as C;
1314    match c {
1315        C::Toggle(s) => SettingControlView::Toggle { checked: s.checked },
1316        C::Number(s) => SettingControlView::Number {
1317            value: s.value,
1318            min: s.min,
1319            max: s.max,
1320        },
1321        C::Dropdown(s) => SettingControlView::Dropdown {
1322            selected: s.selected,
1323            options: s.options.clone(),
1324            open: s.open,
1325        },
1326        C::Text(s) => SettingControlView::Text {
1327            value: s.value.clone(),
1328            editing: s.editing,
1329            placeholder: s.placeholder.clone(),
1330        },
1331        C::TextList(s) => SettingControlView::TextList {
1332            items: s.items.clone(),
1333            focused: s.focused_item,
1334        },
1335        C::DualList(s) => SettingControlView::DualList {
1336            // Use the control's own item enumerations so the row indices the
1337            // web sends back (ControlDualListIncluded/Available(idx,row)) match
1338            // exactly what `add_selected`/`remove_selected` index into.
1339            included: s
1340                .included_items()
1341                .iter()
1342                .map(|(_, n)| n.to_string())
1343                .collect(),
1344            available: s.available_items().iter().map(|(_, n)| n.clone()).collect(),
1345            included_cursor: s.included_cursor,
1346            available_cursor: s.available_cursor,
1347            active_column: match s.active_column {
1348                crate::view::controls::DualListColumn::Included => "included",
1349                crate::view::controls::DualListColumn::Available => "available",
1350            },
1351        },
1352        C::Map(s) => SettingControlView::Map {
1353            entries: s
1354                .entries
1355                .iter()
1356                .map(|(k, v)| MapEntryView {
1357                    key: k.clone(),
1358                    display: v
1359                        .as_str()
1360                        .map(|x| x.to_string())
1361                        .unwrap_or_else(|| v.to_string()),
1362                })
1363                .collect(),
1364        },
1365        C::ObjectArray(s) => SettingControlView::ObjectArray {
1366            entries: s
1367                .bindings
1368                .iter()
1369                .map(|v| {
1370                    s.display_field
1371                        .as_ref()
1372                        .and_then(|f| v.pointer(f))
1373                        .and_then(|x| x.as_str())
1374                        .map(|x| x.to_string())
1375                        .unwrap_or_else(|| v.to_string())
1376                })
1377                .collect(),
1378        },
1379        C::Json(s) => SettingControlView::Json { value: s.value() },
1380        C::Complex { type_name } => SettingControlView::Complex {
1381            type_name: type_name.clone(),
1382        },
1383    }
1384}
1385
1386fn setting_item_view(
1387    item: &crate::view::settings::items::SettingItem,
1388    i: usize,
1389    selected: bool,
1390) -> SettingItemView {
1391    SettingItemView {
1392        index: i,
1393        path: item.path.clone(),
1394        name: item.name.clone(),
1395        description: item.description.clone(),
1396        section: item.section.clone(),
1397        section_start: item.is_section_start,
1398        modified: item.modified,
1399        read_only: item.read_only,
1400        nullable: item.nullable,
1401        is_null: item.is_null,
1402        selected,
1403        control: setting_control_view(&item.control),
1404    }
1405}
1406
1407impl Editor {
1408    /// Full semantic model of the Settings modal: the category tree, the item
1409    /// list for the selected category (every control kind), search, the footer,
1410    /// and the add/edit entry sub-dialog (Map/ObjectArray). Keyboard-driven via
1411    /// `handle_key`; rendered natively. `None` unless settings is showing.
1412    pub fn settings_view(&self) -> Option<SettingsView> {
1413        use crate::view::settings::state::FocusPanel;
1414        let st = self.settings_state.as_ref()?;
1415        if !st.visible {
1416            return None;
1417        }
1418
1419        let categories = st
1420            .pages
1421            .iter()
1422            .enumerate()
1423            .map(|(i, p)| SettingsCategoryView {
1424                index: i,
1425                name: p.name.clone(),
1426                selected: i == st.selected_category,
1427                expandable: !p.subpages.is_empty() || p.sections.len() > 1,
1428                expanded: st.expanded_categories.contains(&i),
1429                sections: p.sections.iter().map(|s| s.name.clone()).collect(),
1430            })
1431            .collect();
1432
1433        let items = st
1434            .pages
1435            .get(st.selected_category)
1436            .map(|p| {
1437                p.items
1438                    .iter()
1439                    .enumerate()
1440                    .map(|(i, it)| setting_item_view(it, i, i == st.selected_item))
1441                    .collect()
1442            })
1443            .unwrap_or_default();
1444
1445        let entry_dialog = st.entry_dialog_stack.last().map(|d| EntryDialogView {
1446            title: d.title.clone(),
1447            is_new: d.is_new,
1448            items: d
1449                .items
1450                .iter()
1451                .enumerate()
1452                .map(|(i, it)| setting_item_view(it, i, i == d.selected_item))
1453                .collect(),
1454            selected_item: d.selected_item,
1455            focus_on_buttons: d.focus_on_buttons,
1456            focused_button: d.focused_button,
1457            no_delete: d.no_delete,
1458        });
1459
1460        Some(SettingsView {
1461            title: "Settings".to_string(),
1462            focus: match st.focus.current() {
1463                Some(FocusPanel::Settings) => "settings",
1464                Some(FocusPanel::Footer) => "footer",
1465                _ => "categories",
1466            },
1467            target_layer: format!("{:?}", st.target_layer),
1468            categories,
1469            items,
1470            footer_buttons: vec![
1471                format!("{:?}", st.target_layer),
1472                "Reset".into(),
1473                "Save".into(),
1474                "Cancel".into(),
1475            ],
1476            footer_selected: st.footer_button_index,
1477            search_active: st.search_active,
1478            search_query: st.search_query.clone(),
1479            search_results: st
1480                .search_results
1481                .iter()
1482                .map(|r| SettingsSearchResultView {
1483                    name: r.item.name.clone(),
1484                    category: r.breadcrumb.clone(),
1485                })
1486                .collect(),
1487            search_selected: st.selected_search_result,
1488            entry_dialog,
1489            showing_help: st.showing_help,
1490            showing_confirm: st.showing_confirm_dialog,
1491            showing_reset: st.showing_reset_dialog,
1492        })
1493    }
1494}