1use omp_core::{Str, fmts};
12use omp_tui::{
13 Color, Dim, Key, Layer, Mouse, OverlayAnchor, OverlayOptions, Prop, Size, Ui, UiContext,
14 UiEvent, dom,
15};
16
17use crate::demo::demo_commands;
18
19const CYAN: Color = Color::Rgb(62, 190, 203);
20const TEXT: Color = Color::Rgb(194, 198, 204);
21const DIM: Color = Color::Rgb(110, 116, 124);
22
23const HINT: &str = "↑/↓ commands · Enter run · type to search · Esc close";
24
25const FRAME_ROWS: u16 = 4;
28
29#[derive(Clone, PartialEq, Eq, Debug)]
31pub enum PaletteEvent {
32 Consumed,
34 Close,
36 Run(PaletteAction),
38}
39
40#[derive(Clone, PartialEq, Eq, Debug)]
42pub enum PaletteAction {
43 SwitchModel,
45 ToggleSidebar,
47 Quit,
49 Insert(Str),
51}
52
53const SWITCH_MODEL: &str = "switch-model";
56const TOGGLE_SIDEBAR: &str = "toggle-sidebar";
57const QUIT: &str = "quit";
58
59pub struct CommandPalette {
62 ui: Ui,
63 ctx: UiContext,
64 options: OverlayOptions,
65 query: Str,
67 rows: u16,
69}
70
71impl CommandPalette {
72 pub fn open(ctx: &UiContext) -> Self {
74 let options = OverlayOptions::default()
75 .anchor(OverlayAnchor::Top)
76 .offset_y(1)
77 .z(10);
78 Self { ui: build("", 8, 100, ctx), ctx: ctx.clone(), options, query: Str::default(), rows: 8 }
79 }
80
81 pub fn handle_key(&mut self, key: Key) -> PaletteEvent {
83 let event = self.ui.handle_key(key);
84 self.route(event)
85 }
86
87 pub fn handle_paste(&mut self, text: &str) -> PaletteEvent {
89 let event = self.ui.handle_paste(text);
90 self.route(event)
91 }
92
93 pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> PaletteEvent {
96 match self
97 .ui
98 .handle_mouse_as_layer(&self.options, viewport, col, row, kind)
99 {
100 Some(event) => self.route(event),
101 None if kind == Mouse::Click => PaletteEvent::Close,
102 None => PaletteEvent::Consumed,
103 }
104 }
105
106 pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
109 let width = (viewport.width * 3 / 5).max(48).min(viewport.width);
110 let rows = (viewport.height / 2).saturating_sub(FRAME_ROWS).max(5);
111 if rows != self.rows {
112 self.rows = rows;
113 self
115 .ui
116 .set_prop("commands", Prop::H, rows.saturating_add(1));
117 }
118 if self.ui.frame().size().width != width {
119 self.ui = build(&self.query, self.rows, width, &self.ctx);
120 }
121 self.options = self.options.width(Dim::Cells(width));
122 Layer { frame: self.ui.frame(), options: &self.options, active: true }
123 }
124
125 fn route(&mut self, event: UiEvent) -> PaletteEvent {
127 match event {
128 UiEvent::Cancel => PaletteEvent::Close,
129 UiEvent::Changed { value, .. } => match value.as_str() {
130 SWITCH_MODEL => PaletteEvent::Run(PaletteAction::SwitchModel),
131 TOGGLE_SIDEBAR => PaletteEvent::Run(PaletteAction::ToggleSidebar),
132 QUIT => PaletteEvent::Run(PaletteAction::Quit),
133 slash => PaletteEvent::Run(PaletteAction::Insert(fmts!("{slash} "))),
134 },
135 UiEvent::Filtered { query, .. } => {
136 self.query = query;
137 PaletteEvent::Consumed
138 },
139 UiEvent::None | UiEvent::Submit | UiEvent::Highlighted { .. } | UiEvent::Pressed(_) => {
140 PaletteEvent::Consumed
141 },
142 }
143 }
144}
145
146struct EntrySpec {
148 value: Str,
149 label: Str,
150 name: Str,
151 name_fg: Color,
152 detail: Str,
153 key: Str,
155}
156
157impl EntrySpec {
158 const fn action(
159 value: &'static str,
160 name: &'static str,
161 detail: &'static str,
162 key: &'static str,
163 ) -> Self {
164 Self {
165 value: Str::new_static(value),
166 label: Str::new_static(name),
167 name: Str::new_static(name),
168 name_fg: TEXT,
169 detail: Str::new_static(detail),
170 key: Str::new_static(key),
171 }
172 }
173}
174
175fn entries() -> Vec<EntrySpec> {
178 let commands = demo_commands();
179 let mut list = Vec::with_capacity(commands.len() + 3);
180 list.push(EntrySpec::action(
181 SWITCH_MODEL,
182 "Switch Model",
183 "Pick the model for this session",
184 "ctrl+p",
185 ));
186 list.push(EntrySpec::action(
187 TOGGLE_SIDEBAR,
188 "Toggle Sidebar",
189 "Show or hide the session rail",
190 "ctrl+b",
191 ));
192 list.push(EntrySpec::action(QUIT, "Quit", "Exit the demo", "ctrl+c"));
193 list.extend(commands.iter().map(|command| {
194 let name = fmts!("/{}", command.name());
195 EntrySpec {
196 value: name.clone(),
197 label: name.clone(),
198 name,
199 name_fg: CYAN,
200 detail: Str::from(command.description()),
201 key: Str::default(),
202 }
203 }));
204 list
205}
206
207fn build(query: &str, rows: u16, width: u16, ctx: &UiContext) -> Ui {
209 let list = entries();
210 let seed = Str::from(query);
211 let height = rows.saturating_add(1);
212 Ui::from_root(
213 dom! {
214 <box border=round title="Commands" pad-x=1>
215 <col>
216 <select id="commands" filter={seed} h={height}>
217 for entry in list {
218 <option value={entry.value} label={entry.label}>
219 <td><pre fg={entry.name_fg}>{entry.name}</pre></td>
220 <td truncate grow><pre fg={DIM}>{entry.detail}</pre></td>
221 if !entry.key.is_empty() {
222 <td align=end><pre fg={DIM}>{entry.key}</pre></td>
223 }
224 </option>
225 }
226 </select>
227 <text dim truncate>{HINT}</text>
228 </col>
229 </box>
230 },
231 width,
232 ctx.clone(),
233 )
234}