1use crate::app::Editor;
15use fresh_core::LeafId;
16use ratatui::layout::Rect;
17use serde::Serialize;
18use std::collections::HashMap;
19
20#[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#[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#[derive(Debug, Clone, Serialize)]
69pub struct MenuEntry {
70 pub label: String,
71 pub visible: bool,
75 pub x: Option<u16>,
76 pub w: Option<u16>,
77 pub items: Vec<MenuItemView>,
78}
79
80#[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#[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 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 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 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#[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#[derive(Debug, Clone, Default, Serialize)]
256pub struct TabBarView {
257 pub bar: Option<RectView>,
258 pub tabs: Vec<TabView>,
259}
260
261#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
309 pub preview_rect: Option<RectView>,
310 pub suggestions: Vec<SuggestionView>,
311 #[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
320fn 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 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 pub fn status_view(&self) -> Option<StatusView> {
370 let chrome = self.active_chrome();
371 let (sy, sx, sw) = chrome.status_bar.area?;
372
373 let segments: Vec<StatusSegment> = chrome
378 .status_bar
379 .segments
380 .iter()
381 .filter(|s| !s.text.trim().is_empty())
382 .map(|s| StatusSegment {
383 name: s.name,
384 key: s.key.clone(),
385 text: s.text.trim().to_string(),
386 x: s.x,
387 w: s.w,
388 side: s.side,
389 })
390 .collect();
391
392 Some(StatusView {
393 rect: RectView {
394 x: sx,
395 y: sy,
396 w: sw,
397 h: 1,
398 },
399 segments,
400 })
401 }
402
403 pub fn palette_view(&self) -> Option<PaletteView> {
407 let chrome = self.active_chrome();
408 let sugg_outer = chrome.suggestions_outer_area;
409 let sugg_area = chrome.suggestions_area;
410 let prompt_results = chrome.prompt_results_area;
411 let p = self.active_window().prompt.as_ref()?;
412 if p.suggestions.is_empty() && !p.overlay {
413 return None;
414 }
415 let (scroll_start, visible, total) = sugg_area.map(|(_, s, v, t)| (s, v, t)).unwrap_or((
416 p.scroll_offset,
417 p.suggestions.len(),
418 p.suggestions.len(),
419 ));
420 Some(PaletteView {
421 query: p.input.clone(),
422 message: p.message.clone(),
423 prompt_type: prompt_type_tag(&p.prompt_type),
424 overlay: p.overlay,
425 title: p.title.iter().map(|t| t.text.as_str()).collect(),
426 status: p.status.clone(),
427 selected: p.selected_suggestion,
428 scroll_start,
429 visible_count: visible,
430 total,
431 outer_rect: sugg_outer.map(RectView::from),
432 list_rect: sugg_area
433 .map(|(r, _, _, _)| r)
434 .or(prompt_results)
435 .map(RectView::from),
436 preview_rect: chrome.prompt_preview_area.and_then(|r| {
440 (r.width > 1 && r.height > 0).then(|| {
441 RectView::from(Rect::new(
442 r.x.saturating_add(1),
443 r.y,
444 r.width.saturating_sub(1),
445 r.height,
446 ))
447 })
448 }),
449 suggestions: p
450 .suggestions
451 .iter()
452 .map(|s| SuggestionView {
453 text: s.text.clone(),
454 description: s.description.clone(),
455 keybinding: s.keybinding.clone(),
456 disabled: s.disabled,
457 })
458 .collect(),
459 toolbar: p.toolbar_widget.clone(),
460 toolbar_focus: p.toolbar_focus.clone(),
461 })
462 }
463}
464
465#[derive(Debug, Clone, Serialize)]
468pub struct PopupItemView {
469 pub text: String,
470 pub detail: Option<String>,
471 pub icon: Option<String>,
472 pub disabled: bool,
473}
474
475#[derive(Debug, Clone, Serialize)]
476#[serde(tag = "type", rename_all = "lowercase")]
477pub enum PopupContentView {
478 List {
479 items: Vec<PopupItemView>,
480 selected: usize,
481 },
482 Lines {
483 lines: Vec<String>,
484 },
485}
486
487#[derive(Debug, Clone, Serialize)]
492#[serde(rename_all = "camelCase")]
493pub struct ScenePopup {
494 pub kind: &'static str,
495 pub title: Option<String>,
496 pub description: Option<String>,
497 pub rect: RectView,
498 pub content_rect: RectView,
499 pub scroll_offset: usize,
500 pub content: PopupContentView,
501}
502
503fn project_popup(
504 p: &crate::view::popup::Popup,
505 outer: Rect,
506 inner: Rect,
507 scroll: usize,
508) -> ScenePopup {
509 use crate::view::popup::{PopupContent, PopupKind};
510 let kind = match p.kind {
511 PopupKind::Completion => "completion",
512 PopupKind::Hover => "hover",
513 PopupKind::Action => "action",
514 PopupKind::List => "list",
515 PopupKind::Text => "text",
516 };
517 let content = match &p.content {
518 PopupContent::List { items, selected } => PopupContentView::List {
519 items: items
520 .iter()
521 .map(|i| PopupItemView {
522 text: i.text.clone(),
523 detail: i.detail.clone(),
524 icon: i.icon.clone(),
525 disabled: i.disabled,
526 })
527 .collect(),
528 selected: *selected,
529 },
530 PopupContent::Text(lines) | PopupContent::Custom(lines) => PopupContentView::Lines {
531 lines: lines.clone(),
532 },
533 PopupContent::Markdown(styled) => PopupContentView::Lines {
534 lines: styled
535 .iter()
536 .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect::<String>())
537 .collect(),
538 },
539 };
540 ScenePopup {
541 kind,
542 title: p.title.clone(),
543 description: p.description.clone(),
544 rect: RectView::from(outer),
545 content_rect: RectView::from(inner),
546 scroll_offset: scroll,
547 content,
548 }
549}
550
551impl Editor {
552 pub fn popups_view(&self) -> Vec<ScenePopup> {
557 let chrome = self.active_chrome();
558 let mut out = Vec::new();
559 let locals = self.active_state().popups.all();
560 for (idx, outer, inner, scroll, _n, _sb, _t) in &chrome.popup_areas {
561 if let Some(p) = locals.get(*idx) {
562 out.push(project_popup(p, *outer, *inner, *scroll));
563 }
564 }
565 let globals = self.global_popups.all();
566 for (idx, outer, inner, scroll, _n) in &chrome.global_popup_areas {
567 if let Some(p) = globals.get(*idx) {
568 out.push(project_popup(p, *outer, *inner, *scroll));
569 }
570 }
571 out
572 }
573}
574
575#[derive(Debug, Clone, Serialize)]
578#[serde(rename_all = "camelCase")]
579pub struct FileRow {
580 pub name: String,
581 pub depth: usize,
582 pub is_dir: bool,
583 pub expanded: bool,
584}
585
586#[derive(Debug, Clone, Serialize)]
587#[serde(rename_all = "camelCase")]
588pub struct FileExplorerView {
589 pub rect: RectView,
590 pub title: String,
591 pub scroll_offset: usize,
592 pub viewport_height: usize,
593 pub selected: Option<usize>,
594 pub rows: Vec<FileRow>,
595}
596
597impl Editor {
598 pub fn file_explorer_view(&self) -> Option<FileExplorerView> {
604 let rect = self.active_layout().file_explorer_area?;
605 let view = self.file_explorer()?;
606 let tree = view.tree();
607 let rows = view
608 .get_display_nodes()
609 .into_iter()
610 .filter_map(|(id, indent)| {
611 tree.get_node(id).map(|n| FileRow {
612 name: n.entry.name.clone(),
613 depth: indent,
614 is_dir: n.is_dir(),
615 expanded: n.is_expanded(),
616 })
617 })
618 .collect();
619 let title = tree
620 .get_node(tree.root_id())
621 .map(|n| n.entry.name.clone())
622 .unwrap_or_default();
623 Some(FileExplorerView {
624 rect: RectView::from(rect),
625 title,
626 scroll_offset: view.get_scroll_offset(),
627 viewport_height: view.viewport_height,
628 selected: view.get_selected_index(),
629 rows,
630 })
631 }
632}
633
634#[derive(Debug, Clone, Serialize)]
637pub struct TrustOptionView {
638 pub label: String,
639 pub description: String,
640 pub selected: bool,
641 pub data: &'static str,
642 pub rect: RectView,
643}
644
645#[derive(Debug, Clone, Serialize)]
646#[serde(rename_all = "camelCase")]
647pub struct TrustDialogView {
648 pub dialog: RectView,
649 pub title: String,
650 pub path: String,
651 pub triggers: String,
652 pub cancellable: bool,
653 pub options: Vec<TrustOptionView>,
654 pub ok: RectView,
655 pub ok_label: String,
656 pub quit: RectView,
657 pub quit_label: String,
658}
659
660impl Editor {
661 pub fn trust_dialog_view(&self) -> Option<TrustDialogView> {
666 let layout = self.active_chrome().workspace_trust_dialog.clone()?;
667 let selected = self.current_workspace_trust_selection();
668 let data = ["trusted", "restricted", "blocked"];
669 let options = crate::view::workspace_trust_dialog::options()
670 .into_iter()
671 .enumerate()
672 .map(|(i, o)| TrustOptionView {
673 label: o.label,
674 description: o.description,
675 selected: i == selected,
676 data: data.get(i).copied().unwrap_or("restricted"),
677 rect: RectView::from(layout.radios[i]),
678 })
679 .collect();
680 let quit_label = if self.workspace_trust_cancellable() {
681 rust_i18n::t!("trust.dialog.btn_cancel").into_owned()
682 } else {
683 rust_i18n::t!("trust.dialog.btn_quit").into_owned()
684 };
685 Some(TrustDialogView {
686 dialog: RectView::from(layout.dialog),
687 title: rust_i18n::t!("trust.dialog.security_warning").into_owned(),
688 path: self.working_dir().display().to_string(),
689 triggers: self.workspace_trust_markers().join(", "),
690 cancellable: self.workspace_trust_cancellable(),
691 options,
692 ok: RectView::from(layout.ok),
693 ok_label: rust_i18n::t!("trust.dialog.btn_ok").into_owned(),
694 quit: RectView::from(layout.quit),
695 quit_label,
696 })
697 }
698}
699
700#[derive(Debug, Clone, Serialize)]
703#[serde(rename_all = "camelCase")]
704pub struct WidgetHitView {
705 pub index: usize,
708 pub widget_key: String,
709 pub widget_kind: String,
710 pub event_type: String,
711 pub payload: serde_json::Value,
712}
713
714#[derive(Debug, Clone, Serialize)]
717#[serde(rename_all = "camelCase")]
718pub struct WidgetInstanceView {
719 pub selected_index: Option<i32>,
720 pub scroll_offset: Option<u32>,
721 #[serde(skip_serializing_if = "Vec::is_empty")]
722 pub expanded_keys: Vec<String>,
723}
724
725#[derive(Debug, Clone, Serialize)]
726#[serde(rename_all = "camelCase")]
727pub struct WidgetSurfaceView {
728 pub kind: &'static str,
730 pub plugin: String,
731 pub panel_id: u64,
732 pub rect: RectView,
733 pub focus_key: String,
734 pub spec: fresh_core::api::WidgetSpec,
736 pub instances: HashMap<String, WidgetInstanceView>,
737 pub hits: Vec<WidgetHitView>,
738}
739
740impl Editor {
741 pub fn widgets_view(&self) -> Vec<WidgetSurfaceView> {
748 let mut out = Vec::new();
749 for (kind, slot) in [
750 ("dock", self.dock.as_ref()),
751 ("floatingModal", self.floating_widget_panel.as_ref()),
752 ] {
753 let Some(fwp) = slot else { continue };
754 let Some(rect) = fwp.last_inner_rect else {
755 continue;
756 };
757 let Some(panel) = self.widget_registry.get(&fwp.panel_key) else {
758 continue;
759 };
760 let mut instances = HashMap::new();
761 for (key, st) in &panel.instance_states {
762 use crate::widgets::WidgetInstanceState as W;
763 let view = match st {
764 W::List {
765 scroll_offset,
766 selected_index,
767 ..
768 } => WidgetInstanceView {
769 selected_index: Some(*selected_index),
770 scroll_offset: Some(*scroll_offset),
771 expanded_keys: Vec::new(),
772 },
773 W::Tree {
774 scroll_offset,
775 selected_index,
776 expanded_keys,
777 } => WidgetInstanceView {
778 selected_index: Some(*selected_index),
779 scroll_offset: Some(*scroll_offset),
780 expanded_keys: expanded_keys.iter().cloned().collect(),
781 },
782 _ => continue,
783 };
784 instances.insert(key.clone(), view);
785 }
786 let hits = panel
787 .hits
788 .iter()
789 .enumerate()
790 .map(|(index, h)| WidgetHitView {
791 index,
792 widget_key: h.widget_key.clone(),
793 widget_kind: h.widget_kind.to_string(),
794 event_type: h.event_type.to_string(),
795 payload: h.payload.clone(),
796 })
797 .collect();
798 out.push(WidgetSurfaceView {
799 kind,
800 plugin: fwp.panel_key.plugin.clone(),
801 panel_id: fwp.panel_key.id,
802 rect: RectView::from(rect),
803 focus_key: panel.focus_key.clone(),
804 spec: panel.spec.clone(),
805 instances,
806 hits,
807 });
808 }
809 out
810 }
811}
812
813#[derive(Debug, Clone, Serialize)]
816#[serde(rename_all = "camelCase")]
817pub struct ContextMenuView {
818 pub kind: &'static str,
820 pub x: u16,
821 pub y: u16,
822 pub highlighted: usize,
823 pub items: Vec<String>,
824}
825
826impl Editor {
827 pub fn context_menu_view(&self) -> Option<ContextMenuView> {
832 let w = self.active_window();
833 let chrome = self.active_chrome();
834 if let Some(m) = &w.file_explorer_context_menu {
835 let (x, y) = m.clamped_position(chrome.last_frame.width, chrome.last_frame.height);
836 return Some(ContextMenuView {
837 kind: "fileExplorer",
838 x,
839 y,
840 highlighted: m.highlighted,
841 items: m.items().iter().map(|i| i.label()).collect(),
842 });
843 }
844 if let Some(m) = &w.new_tab_menu {
845 return Some(ContextMenuView {
846 kind: "newTab",
847 x: m.position.0,
848 y: m.position.1,
849 highlighted: m.highlighted,
850 items: crate::app::types::NewTabMenuItem::all()
851 .iter()
852 .map(|i| i.label())
853 .collect(),
854 });
855 }
856 if let Some(m) = &w.tab_context_menu {
857 return Some(ContextMenuView {
858 kind: "tab",
859 x: m.position.0,
860 y: m.position.1,
861 highlighted: m.highlighted,
862 items: crate::app::types::TabContextMenuItem::all()
863 .iter()
864 .map(|i| i.label())
865 .collect(),
866 });
867 }
868 None
869 }
870}
871
872#[derive(Debug, Clone, Serialize)]
875#[serde(rename_all = "camelCase")]
876pub struct AuxLine {
877 pub text: String,
878 pub selected: bool,
879}
880
881#[derive(Debug, Clone, Serialize)]
887#[serde(rename_all = "camelCase")]
888pub struct AuxModalView {
889 pub kind: &'static str,
890 pub title: String,
891 pub rect: Option<RectView>,
892 pub lines: Vec<AuxLine>,
893 pub footer: Option<String>,
894}
895
896impl Editor {
897 pub fn aux_modals_view(&self) -> Option<AuxModalView> {
901 let w = self.active_window();
907 if let Some(ed) = &w.event_debug {
909 let mut lines: Vec<AuxLine> = ed
910 .history
911 .iter()
912 .map(|r| AuxLine {
913 text: r.description.clone(),
914 selected: false,
915 })
916 .collect();
917 if lines.is_empty() {
918 lines.push(AuxLine {
919 text: rust_i18n::t!("event_debug.no_events").into_owned(),
920 selected: false,
921 });
922 }
923 return Some(AuxModalView {
924 kind: "eventDebug",
925 title: rust_i18n::t!("event_debug.title").into_owned(),
926 rect: None,
927 lines,
928 footer: Some(rust_i18n::t!("event_debug.help_text").into_owned()),
929 });
930 }
931 if let Some(ti) = &w.theme_info_popup {
933 fn color_str(c: ratatui::style::Color) -> String {
934 match c {
935 ratatui::style::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
936 other => format!("{other:?}"),
937 }
938 }
939 let info = &ti.info;
940 let mut lines = vec![AuxLine {
941 text: format!("Region: {}", info.region),
942 selected: false,
943 }];
944 if let Some(k) = &info.fg_key {
945 let c = info
946 .fg_color
947 .map(|c| format!(" {}", color_str(c)))
948 .unwrap_or_default();
949 lines.push(AuxLine {
950 text: format!("Foreground: {k}{c}"),
951 selected: false,
952 });
953 }
954 if let Some(k) = &info.bg_key {
955 let c = info
956 .bg_color
957 .map(|c| format!(" {}", color_str(c)))
958 .unwrap_or_default();
959 lines.push(AuxLine {
960 text: format!("Background: {k}{c}"),
961 selected: false,
962 });
963 }
964 if let Some(cat) = &info.syntax_category {
965 lines.push(AuxLine {
966 text: format!("Category: {cat}"),
967 selected: false,
968 });
969 }
970 return Some(AuxModalView {
971 kind: "themeInfo",
972 title: "Theme".to_string(),
973 rect: Some(RectView {
974 x: ti.position.0,
975 y: ti.position.1,
976 w: 0,
977 h: 0,
978 }),
979 lines,
980 footer: None,
981 });
982 }
983 None
984 }
985}
986
987#[derive(Debug, Clone, Serialize)]
990#[serde(rename_all = "camelCase")]
991pub struct KbSearchView {
992 pub active: bool,
993 pub focused: bool,
994 pub mode: &'static str, pub query: String,
996 pub key_display: String,
997}
998
999#[derive(Debug, Clone, Serialize)]
1000#[serde(tag = "type", rename_all = "camelCase")]
1001pub enum KbRow {
1002 Section {
1003 name: String,
1004 collapsed: bool,
1005 count: usize,
1006 selected: bool,
1007 },
1008 Binding {
1009 key: String,
1010 action: String,
1011 description: String,
1012 context: String,
1013 source: &'static str, selected: bool,
1015 },
1016}
1017
1018#[derive(Debug, Clone, Serialize)]
1019#[serde(rename_all = "camelCase")]
1020pub struct KbEditDialog {
1021 pub title: String,
1022 pub focus_area: usize, pub key_display: String,
1024 pub key_capturing: bool,
1025 pub action_text: String,
1026 pub action_error: Option<String>,
1027 pub autocomplete: Vec<String>,
1028 pub autocomplete_selected: Option<usize>,
1029 pub context: String,
1030 pub context_options: Vec<String>,
1031 pub conflicts: Vec<String>,
1032 pub save_focused: bool,
1033 pub cancel_focused: bool,
1034}
1035
1036#[derive(Debug, Clone, Serialize)]
1037#[serde(rename_all = "camelCase")]
1038pub struct KbConfirm {
1039 pub buttons: Vec<String>,
1040 pub selected: usize,
1041}
1042
1043#[derive(Debug, Clone, Serialize)]
1044#[serde(rename_all = "camelCase")]
1045pub struct KeybindingEditorView {
1046 pub title: String,
1047 pub config_path: String,
1048 pub keymaps: Vec<String>,
1049 pub search: KbSearchView,
1050 pub context_filter: String,
1051 pub context_filtered: bool,
1052 pub source_filter: String,
1053 pub source_filtered: bool,
1054 pub count: String,
1055 pub has_changes: bool,
1056 pub rows: Vec<KbRow>,
1057 pub selected: usize,
1058 pub scroll_offset: u16,
1059 pub viewport: u16,
1060 pub showing_help: bool,
1061 pub edit_dialog: Option<KbEditDialog>,
1062 pub confirm: Option<KbConfirm>,
1063}
1064
1065impl Editor {
1066 pub fn keybinding_editor_view(&self) -> Option<KeybindingEditorView> {
1071 use crate::app::keybinding_editor::{
1072 BindingSource, ContextFilter, DisplayRow, SearchMode, SourceFilter,
1073 };
1074 let kb = self.keybinding_editor.as_ref()?;
1075
1076 let rows = kb
1077 .display_rows
1078 .iter()
1079 .enumerate()
1080 .map(|(i, dr)| {
1081 let selected = i == kb.selected;
1082 match dr {
1083 DisplayRow::SectionHeader {
1084 plugin_name,
1085 collapsed,
1086 binding_count,
1087 } => KbRow::Section {
1088 name: plugin_name.clone().unwrap_or_else(|| "Builtin".to_string()),
1089 collapsed: *collapsed,
1090 count: *binding_count,
1091 selected,
1092 },
1093 DisplayRow::Binding(bi) => {
1094 let b = &kb.bindings[*bi];
1095 KbRow::Binding {
1096 key: b.key_display.clone(),
1097 action: b.action.clone(),
1098 description: b.action_display.clone(),
1099 context: b.context.clone(),
1100 source: match b.source {
1101 BindingSource::Keymap => "keymap",
1102 BindingSource::Custom => "custom",
1103 BindingSource::Plugin => "plugin",
1104 BindingSource::Unbound => "",
1105 },
1106 selected,
1107 }
1108 }
1109 }
1110 })
1111 .collect();
1112
1113 let (context_filter, context_filtered) = match &kb.context_filter {
1114 ContextFilter::All => ("All".to_string(), false),
1115 ContextFilter::Specific(s) => (s.clone(), true),
1116 };
1117 let (source_filter, source_filtered) = match kb.source_filter {
1118 SourceFilter::All => ("All", false),
1119 SourceFilter::KeymapOnly => ("Keymap", true),
1120 SourceFilter::CustomOnly => ("Custom", true),
1121 SourceFilter::PluginOnly => ("Plugin", true),
1122 };
1123
1124 let edit_dialog = kb.edit_dialog.as_ref().map(|d| KbEditDialog {
1125 title: if d.editing_index.is_some() {
1126 "Edit Binding".to_string()
1127 } else {
1128 "Add Binding".to_string()
1129 },
1130 focus_area: d.focus_area,
1131 key_display: d.key_display.clone(),
1132 key_capturing: d.capturing_special,
1133 action_text: d.action_text.clone(),
1134 action_error: d.action_error.clone(),
1135 autocomplete: if d.autocomplete_visible {
1136 d.autocomplete_suggestions.clone()
1137 } else {
1138 Vec::new()
1139 },
1140 autocomplete_selected: d.autocomplete_selected,
1141 context: d.context.clone(),
1142 context_options: d.context_options.clone(),
1143 conflicts: d.conflicts.clone(),
1144 save_focused: d.focus_area == 3 && d.selected_button == 0,
1145 cancel_focused: d.focus_area == 3 && d.selected_button == 1,
1146 });
1147
1148 let confirm = kb.showing_confirm_dialog.then(|| KbConfirm {
1149 buttons: vec!["Save".into(), "Discard".into(), "Cancel".into()],
1150 selected: kb.confirm_selection,
1151 });
1152
1153 Some(KeybindingEditorView {
1154 title: format!("Keybindings — {}", kb.active_keymap),
1155 config_path: kb.config_file_path.clone(),
1156 keymaps: kb.keymap_names.clone(),
1157 search: KbSearchView {
1158 active: kb.search_active,
1159 focused: kb.search_focused,
1160 mode: match kb.search_mode {
1161 SearchMode::Text => "text",
1162 SearchMode::RecordKey => "recordKey",
1163 },
1164 query: kb.search_query.clone(),
1165 key_display: kb.search_key_display.clone(),
1166 },
1167 context_filter,
1168 context_filtered,
1169 source_filter: source_filter.to_string(),
1170 source_filtered,
1171 count: format!("{} / {}", kb.filtered_indices.len(), kb.bindings.len()),
1172 has_changes: kb.has_changes,
1173 rows,
1174 selected: kb.selected,
1175 scroll_offset: kb.scroll.offset,
1176 viewport: kb.scroll.viewport,
1177 showing_help: kb.showing_help,
1178 edit_dialog,
1179 confirm,
1180 })
1181 }
1182}
1183
1184#[derive(Debug, Clone, Serialize)]
1187#[serde(tag = "kind", rename_all = "camelCase")]
1188pub enum SettingControlView {
1189 Toggle {
1190 checked: bool,
1191 },
1192 Number {
1193 value: i64,
1194 min: Option<i64>,
1195 max: Option<i64>,
1196 },
1197 Dropdown {
1198 selected: usize,
1199 options: Vec<String>,
1200 open: bool,
1201 },
1202 Text {
1203 value: String,
1204 editing: bool,
1205 placeholder: String,
1206 },
1207 TextList {
1208 items: Vec<String>,
1209 focused: Option<usize>,
1210 },
1211 #[serde(rename_all = "camelCase")]
1215 DualList {
1216 included: Vec<String>,
1217 available: Vec<String>,
1218 included_cursor: usize,
1222 available_cursor: usize,
1223 active_column: &'static str, },
1225 Map {
1226 entries: Vec<MapEntryView>,
1227 },
1228 ObjectArray {
1229 entries: Vec<String>,
1230 },
1231 Json {
1232 value: String,
1233 },
1234 Complex {
1235 type_name: String,
1236 },
1237}
1238
1239#[derive(Debug, Clone, Serialize)]
1240#[serde(rename_all = "camelCase")]
1241pub struct MapEntryView {
1242 pub key: String,
1243 pub display: String,
1244}
1245
1246#[derive(Debug, Clone, Serialize)]
1247#[serde(rename_all = "camelCase")]
1248pub struct SettingItemView {
1249 pub index: usize,
1250 pub path: String,
1251 pub name: String,
1252 pub description: Option<String>,
1253 pub section: Option<String>,
1254 pub section_start: bool,
1255 pub modified: bool,
1256 pub read_only: bool,
1257 pub nullable: bool,
1258 pub is_null: bool,
1259 pub selected: bool,
1260 pub control: SettingControlView,
1261}
1262
1263#[derive(Debug, Clone, Serialize)]
1264#[serde(rename_all = "camelCase")]
1265pub struct SettingsCategoryView {
1266 pub index: usize,
1267 pub name: String,
1268 pub selected: bool,
1269 pub expandable: bool,
1270 pub expanded: bool,
1271 pub sections: Vec<String>,
1272}
1273
1274#[derive(Debug, Clone, Serialize)]
1275#[serde(rename_all = "camelCase")]
1276pub struct SettingsSearchResultView {
1277 pub name: String,
1278 pub category: String,
1279}
1280
1281#[derive(Debug, Clone, Serialize)]
1282#[serde(rename_all = "camelCase")]
1283pub struct EntryDialogView {
1284 pub title: String,
1285 pub is_new: bool,
1286 pub items: Vec<SettingItemView>,
1287 pub selected_item: usize,
1288 pub focus_on_buttons: bool,
1289 pub focused_button: usize,
1290 pub no_delete: bool,
1291}
1292
1293#[derive(Debug, Clone, Serialize)]
1294#[serde(rename_all = "camelCase")]
1295pub struct SettingsView {
1296 pub title: String,
1297 pub focus: &'static str, pub target_layer: String,
1299 pub categories: Vec<SettingsCategoryView>,
1300 pub items: Vec<SettingItemView>,
1301 pub footer_buttons: Vec<String>,
1302 pub footer_selected: usize,
1303 pub search_active: bool,
1304 pub search_query: String,
1305 pub search_results: Vec<SettingsSearchResultView>,
1306 pub search_selected: usize,
1307 pub entry_dialog: Option<EntryDialogView>,
1308 pub showing_help: bool,
1309 pub showing_confirm: bool,
1310 pub showing_reset: bool,
1311}
1312
1313fn setting_control_view(c: &crate::view::settings::items::SettingControl) -> SettingControlView {
1314 use crate::view::settings::items::SettingControl as C;
1315 match c {
1316 C::Toggle(s) => SettingControlView::Toggle { checked: s.checked },
1317 C::Number(s) => SettingControlView::Number {
1318 value: s.value,
1319 min: s.min,
1320 max: s.max,
1321 },
1322 C::Dropdown(s) => SettingControlView::Dropdown {
1323 selected: s.selected,
1324 options: s.options.clone(),
1325 open: s.open,
1326 },
1327 C::Text(s) => SettingControlView::Text {
1328 value: s.value.clone(),
1329 editing: s.editing,
1330 placeholder: s.placeholder.clone(),
1331 },
1332 C::TextList(s) => SettingControlView::TextList {
1333 items: s.items.clone(),
1334 focused: s.focused_item,
1335 },
1336 C::DualList(s) => SettingControlView::DualList {
1337 included: s
1341 .included_items()
1342 .iter()
1343 .map(|(_, n)| n.to_string())
1344 .collect(),
1345 available: s.available_items().iter().map(|(_, n)| n.clone()).collect(),
1346 included_cursor: s.included_cursor,
1347 available_cursor: s.available_cursor,
1348 active_column: match s.active_column {
1349 crate::view::controls::DualListColumn::Included => "included",
1350 crate::view::controls::DualListColumn::Available => "available",
1351 },
1352 },
1353 C::Map(s) => SettingControlView::Map {
1354 entries: s
1355 .entries
1356 .iter()
1357 .map(|(k, v)| MapEntryView {
1358 key: k.clone(),
1359 display: v
1360 .as_str()
1361 .map(|x| x.to_string())
1362 .unwrap_or_else(|| v.to_string()),
1363 })
1364 .collect(),
1365 },
1366 C::ObjectArray(s) => SettingControlView::ObjectArray {
1367 entries: s
1368 .bindings
1369 .iter()
1370 .map(|v| {
1371 s.display_field
1372 .as_ref()
1373 .and_then(|f| v.pointer(f))
1374 .and_then(|x| x.as_str())
1375 .map(|x| x.to_string())
1376 .unwrap_or_else(|| v.to_string())
1377 })
1378 .collect(),
1379 },
1380 C::Json(s) => SettingControlView::Json { value: s.value() },
1381 C::Complex { type_name } => SettingControlView::Complex {
1382 type_name: type_name.clone(),
1383 },
1384 }
1385}
1386
1387fn setting_item_view(
1388 item: &crate::view::settings::items::SettingItem,
1389 i: usize,
1390 selected: bool,
1391) -> SettingItemView {
1392 SettingItemView {
1393 index: i,
1394 path: item.path.clone(),
1395 name: item.name.clone(),
1396 description: item.description.clone(),
1397 section: item.section.clone(),
1398 section_start: item.is_section_start,
1399 modified: item.modified,
1400 read_only: item.read_only,
1401 nullable: item.nullable,
1402 is_null: item.is_null,
1403 selected,
1404 control: setting_control_view(&item.control),
1405 }
1406}
1407
1408impl Editor {
1409 pub fn settings_view(&self) -> Option<SettingsView> {
1414 use crate::view::settings::state::FocusPanel;
1415 let st = self.settings_state.as_ref()?;
1416 if !st.visible {
1417 return None;
1418 }
1419
1420 let categories = st
1421 .pages
1422 .iter()
1423 .enumerate()
1424 .map(|(i, p)| SettingsCategoryView {
1425 index: i,
1426 name: p.name.clone(),
1427 selected: i == st.selected_category,
1428 expandable: !p.subpages.is_empty() || p.sections.len() > 1,
1429 expanded: st.expanded_categories.contains(&i),
1430 sections: p.sections.iter().map(|s| s.name.clone()).collect(),
1431 })
1432 .collect();
1433
1434 let items = st
1435 .pages
1436 .get(st.selected_category)
1437 .map(|p| {
1438 p.items
1439 .iter()
1440 .enumerate()
1441 .map(|(i, it)| setting_item_view(it, i, i == st.selected_item))
1442 .collect()
1443 })
1444 .unwrap_or_default();
1445
1446 let entry_dialog = st.entry_dialog_stack.last().map(|d| EntryDialogView {
1447 title: d.title.clone(),
1448 is_new: d.is_new,
1449 items: d
1450 .items
1451 .iter()
1452 .enumerate()
1453 .map(|(i, it)| setting_item_view(it, i, i == d.selected_item))
1454 .collect(),
1455 selected_item: d.selected_item,
1456 focus_on_buttons: d.focus_on_buttons,
1457 focused_button: d.focused_button,
1458 no_delete: d.no_delete,
1459 });
1460
1461 Some(SettingsView {
1462 title: "Settings".to_string(),
1463 focus: match st.focus.current() {
1464 Some(FocusPanel::Settings) => "settings",
1465 Some(FocusPanel::Footer) => "footer",
1466 _ => "categories",
1467 },
1468 target_layer: format!("{:?}", st.target_layer),
1469 categories,
1470 items,
1471 footer_buttons: vec![
1472 format!("{:?}", st.target_layer),
1473 "Reset".into(),
1474 "Save".into(),
1475 "Cancel".into(),
1476 ],
1477 footer_selected: st.footer_button_index,
1478 search_active: st.search_active,
1479 search_query: st.search_query.clone(),
1480 search_results: st
1481 .search_results
1482 .iter()
1483 .map(|r| SettingsSearchResultView {
1484 name: r.item.name.clone(),
1485 category: r.breadcrumb.clone(),
1486 })
1487 .collect(),
1488 search_selected: st.selected_search_result,
1489 entry_dialog,
1490 showing_help: st.showing_help,
1491 showing_confirm: st.showing_confirm_dialog,
1492 showing_reset: st.showing_reset_dialog,
1493 })
1494 }
1495}