Skip to main content

dot/tui/
widgets.rs

1#[derive(Clone)]
2pub struct ModelEntry {
3    pub provider: String,
4    pub model: String,
5}
6
7pub struct ModelSelector {
8    pub visible: bool,
9    pub entries: Vec<ModelEntry>,
10    pub filtered: Vec<usize>,
11    pub selected: usize,
12    pub query: String,
13    pub current_provider: String,
14    pub current_model: String,
15    pub favorites: Vec<String>,
16}
17
18impl Default for ModelSelector {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl ModelSelector {
25    pub fn new() -> Self {
26        Self {
27            visible: false,
28            entries: Vec::new(),
29            filtered: Vec::new(),
30            selected: 0,
31            query: String::new(),
32            current_provider: String::new(),
33            current_model: String::new(),
34            favorites: Vec::new(),
35        }
36    }
37
38    pub fn open(
39        &mut self,
40        grouped: Vec<(String, Vec<String>)>,
41        current_provider: &str,
42        current_model: &str,
43    ) {
44        self.entries.clear();
45        for (provider, models) in grouped {
46            for model in models {
47                self.entries.push(ModelEntry {
48                    provider: provider.clone(),
49                    model,
50                });
51            }
52        }
53        self.current_provider = current_provider.to_string();
54        self.current_model = current_model.to_string();
55        self.query.clear();
56        self.visible = true;
57        self.apply_filter();
58        if let Some(pos) = self.filtered.iter().position(|&i| {
59            self.entries[i].provider == current_provider && self.entries[i].model == current_model
60        }) {
61            self.selected = pos;
62        }
63    }
64
65    pub fn toggle_favorite(&mut self) -> Option<String> {
66        let idx = *self.filtered.get(self.selected)?;
67        let model = self.entries[idx].model.clone();
68        if let Some(pos) = self.favorites.iter().position(|f| f == &model) {
69            self.favorites.remove(pos);
70        } else {
71            self.favorites.push(model.clone());
72        }
73        Some(model)
74    }
75
76    pub fn apply_filter(&mut self) {
77        let q = self.query.to_lowercase();
78        self.filtered = self
79            .entries
80            .iter()
81            .enumerate()
82            .filter(|(_, e)| {
83                if q.is_empty() {
84                    return true;
85                }
86                e.model.to_lowercase().contains(&q) || e.provider.to_lowercase().contains(&q)
87            })
88            .map(|(i, _)| i)
89            .collect();
90        self.filtered.sort_by(|&a, &b| {
91            let a_fav = self.favorites.contains(&self.entries[a].model);
92            let b_fav = self.favorites.contains(&self.entries[b].model);
93            b_fav.cmp(&a_fav)
94        });
95        if self.selected >= self.filtered.len() {
96            self.selected = self.filtered.len().saturating_sub(1);
97        }
98    }
99
100    pub fn close(&mut self) {
101        self.visible = false;
102        self.query.clear();
103    }
104
105    pub fn up(&mut self) {
106        if self.selected > 0 {
107            self.selected -= 1;
108        }
109    }
110
111    pub fn down(&mut self) {
112        if self.selected + 1 < self.filtered.len() {
113            self.selected += 1;
114        }
115    }
116
117    pub fn confirm(&mut self) -> Option<ModelEntry> {
118        if self.visible && !self.filtered.is_empty() {
119            self.visible = false;
120            let entry = self.entries[self.filtered[self.selected]].clone();
121            self.query.clear();
122            Some(entry)
123        } else {
124            None
125        }
126    }
127}
128
129#[derive(Clone)]
130pub struct AgentEntry {
131    pub name: String,
132    pub description: String,
133}
134
135pub struct AgentSelector {
136    pub visible: bool,
137    pub entries: Vec<AgentEntry>,
138    pub selected: usize,
139    pub current: String,
140}
141
142impl Default for AgentSelector {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl AgentSelector {
149    pub fn new() -> Self {
150        Self {
151            visible: false,
152            entries: Vec::new(),
153            selected: 0,
154            current: String::new(),
155        }
156    }
157
158    pub fn open(&mut self, agents: Vec<AgentEntry>, current: &str) {
159        self.entries = agents;
160        self.current = current.to_string();
161        self.visible = true;
162        self.selected = self
163            .entries
164            .iter()
165            .position(|e| e.name == current)
166            .unwrap_or(0);
167    }
168
169    pub fn close(&mut self) {
170        self.visible = false;
171    }
172
173    pub fn up(&mut self) {
174        if self.selected > 0 {
175            self.selected -= 1;
176        }
177    }
178
179    pub fn down(&mut self) {
180        if self.selected + 1 < self.entries.len() {
181            self.selected += 1;
182        }
183    }
184
185    pub fn confirm(&mut self) -> Option<AgentEntry> {
186        if self.visible && !self.entries.is_empty() {
187            self.visible = false;
188            Some(self.entries[self.selected].clone())
189        } else {
190            None
191        }
192    }
193}
194
195use chrono::{DateTime, Utc};
196
197pub struct SlashCommand {
198    pub name: &'static str,
199    pub aliases: &'static [&'static str],
200    pub description: &'static str,
201    pub shortcut: &'static str,
202}
203
204pub const COMMANDS: &[SlashCommand] = &[
205    SlashCommand {
206        name: "model",
207        aliases: &["m"],
208        description: "switch model",
209        shortcut: "",
210    },
211    SlashCommand {
212        name: "agent",
213        aliases: &["a"],
214        description: "switch agent profile",
215        shortcut: "Tab",
216    },
217    SlashCommand {
218        name: "clear",
219        aliases: &["cl"],
220        description: "clear conversation",
221        shortcut: "",
222    },
223    SlashCommand {
224        name: "help",
225        aliases: &["h"],
226        description: "show commands",
227        shortcut: "",
228    },
229    SlashCommand {
230        name: "thinking",
231        aliases: &["t", "think"],
232        description: "set thinking level",
233        shortcut: "^T",
234    },
235    SlashCommand {
236        name: "sessions",
237        aliases: &["s", "sess"],
238        description: "resume a previous session",
239        shortcut: "",
240    },
241    SlashCommand {
242        name: "new",
243        aliases: &["n"],
244        description: "start new conversation",
245        shortcut: "",
246    },
247    SlashCommand {
248        name: "rename",
249        aliases: &["r"],
250        description: "rename this session",
251        shortcut: "^R",
252    },
253    SlashCommand {
254        name: "export",
255        aliases: &["e"],
256        description: "export session to markdown",
257        shortcut: "",
258    },
259    SlashCommand {
260        name: "login",
261        aliases: &["l"],
262        description: "manage provider credentials",
263        shortcut: "",
264    },
265    SlashCommand {
266        name: "aside",
267        aliases: &["btw"],
268        description: "ask a quick side question",
269        shortcut: "",
270    },
271];
272#[derive(Debug, Clone, PartialEq)]
273pub enum PaletteEntryKind {
274    Command,
275    Skill,
276}
277
278#[derive(Debug, Clone)]
279pub struct PaletteEntry {
280    pub name: String,
281    pub description: String,
282    pub shortcut: String,
283    pub kind: PaletteEntryKind,
284}
285
286pub struct CommandPalette {
287    pub visible: bool,
288    pub selected: usize,
289    pub filtered: Vec<usize>,
290    pub entries: Vec<PaletteEntry>,
291}
292
293impl Default for CommandPalette {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299impl CommandPalette {
300    pub fn new() -> Self {
301        Self {
302            visible: false,
303            selected: 0,
304            filtered: Vec::new(),
305            entries: Vec::new(),
306        }
307    }
308
309    pub fn set_skills(&mut self, skills: &[(String, String)]) {
310        self.entries.clear();
311        for cmd in COMMANDS {
312            self.entries.push(PaletteEntry {
313                name: cmd.name.to_string(),
314                description: cmd.description.to_string(),
315                shortcut: cmd.shortcut.to_string(),
316                kind: PaletteEntryKind::Command,
317            });
318        }
319        for (name, desc) in skills {
320            self.entries.push(PaletteEntry {
321                name: name.clone(),
322                description: desc.clone(),
323                shortcut: String::new(),
324                kind: PaletteEntryKind::Skill,
325            });
326        }
327    }
328
329    pub fn add_custom_commands(&mut self, commands: &[(&str, &str)]) {
330        for (name, desc) in commands {
331            self.entries.push(PaletteEntry {
332                name: name.to_string(),
333                description: desc.to_string(),
334                shortcut: String::new(),
335                kind: PaletteEntryKind::Command,
336            });
337        }
338    }
339
340    pub fn update_filter(&mut self, input: &str) {
341        if self.entries.is_empty() {
342            for cmd in COMMANDS {
343                self.entries.push(PaletteEntry {
344                    name: cmd.name.to_string(),
345                    description: cmd.description.to_string(),
346                    shortcut: cmd.shortcut.to_string(),
347                    kind: PaletteEntryKind::Command,
348                });
349            }
350        }
351        let query = input.strip_prefix('/').unwrap_or(input).to_lowercase();
352        self.filtered = self
353            .entries
354            .iter()
355            .enumerate()
356            .filter(|(_, e)| {
357                if query.is_empty() {
358                    return true;
359                }
360                e.name.to_lowercase().starts_with(&query)
361                    || e.description.to_lowercase().contains(&query)
362            })
363            .map(|(i, _)| i)
364            .collect();
365        if self.selected >= self.filtered.len() {
366            self.selected = self.filtered.len().saturating_sub(1);
367        }
368    }
369
370    pub fn open(&mut self, input: &str) {
371        self.visible = true;
372        self.selected = 0;
373        self.update_filter(input);
374    }
375
376    pub fn close(&mut self) {
377        self.visible = false;
378    }
379
380    pub fn up(&mut self) {
381        if self.selected > 0 {
382            self.selected -= 1;
383        }
384    }
385
386    pub fn down(&mut self) {
387        if self.selected + 1 < self.filtered.len() {
388            self.selected += 1;
389        }
390    }
391
392    pub fn confirm(&mut self) -> Option<PaletteEntry> {
393        if self.visible && !self.filtered.is_empty() {
394            self.visible = false;
395            Some(self.entries[self.filtered[self.selected]].clone())
396        } else {
397            None
398        }
399    }
400}
401
402#[derive(Debug, Clone, Copy, PartialEq)]
403pub enum ThinkingLevel {
404    Off,
405    Low,
406    Medium,
407    High,
408}
409
410impl ThinkingLevel {
411    pub fn budget_tokens(self) -> u32 {
412        match self {
413            ThinkingLevel::Off => 0,
414            ThinkingLevel::Low => 1024,
415            ThinkingLevel::Medium => 8192,
416            ThinkingLevel::High => 32768,
417        }
418    }
419
420    pub fn label(self) -> &'static str {
421        match self {
422            ThinkingLevel::Off => "off",
423            ThinkingLevel::Low => "low",
424            ThinkingLevel::Medium => "medium",
425            ThinkingLevel::High => "high",
426        }
427    }
428
429    pub fn description(self) -> &'static str {
430        match self {
431            ThinkingLevel::Off => "no extended thinking",
432            ThinkingLevel::Low => "1k token budget",
433            ThinkingLevel::Medium => "8k token budget",
434            ThinkingLevel::High => "32k token budget",
435        }
436    }
437
438    pub fn all() -> &'static [ThinkingLevel] {
439        &[
440            ThinkingLevel::Off,
441            ThinkingLevel::Low,
442            ThinkingLevel::Medium,
443            ThinkingLevel::High,
444        ]
445    }
446
447    pub fn from_budget(budget: u32) -> Self {
448        match budget {
449            0 => ThinkingLevel::Off,
450            1..=4095 => ThinkingLevel::Low,
451            4096..=16383 => ThinkingLevel::Medium,
452            _ => ThinkingLevel::High,
453        }
454    }
455
456    pub fn next(self) -> Self {
457        let all = Self::all();
458        let idx = all.iter().position(|l| *l == self).unwrap_or(0);
459        all[(idx + 1) % all.len()]
460    }
461}
462
463pub struct ThinkingSelector {
464    pub visible: bool,
465    pub selected: usize,
466    pub current: ThinkingLevel,
467}
468
469impl Default for ThinkingSelector {
470    fn default() -> Self {
471        Self::new()
472    }
473}
474
475impl ThinkingSelector {
476    pub fn new() -> Self {
477        Self {
478            visible: false,
479            selected: 0,
480            current: ThinkingLevel::Off,
481        }
482    }
483
484    pub fn open(&mut self, current: ThinkingLevel) {
485        self.current = current;
486        self.selected = ThinkingLevel::all()
487            .iter()
488            .position(|l| *l == current)
489            .unwrap_or(0);
490        self.visible = true;
491    }
492
493    pub fn close(&mut self) {
494        self.visible = false;
495    }
496
497    pub fn up(&mut self) {
498        if self.selected > 0 {
499            self.selected -= 1;
500        }
501    }
502
503    pub fn down(&mut self) {
504        if self.selected + 1 < ThinkingLevel::all().len() {
505            self.selected += 1;
506        }
507    }
508
509    pub fn confirm(&mut self) -> Option<ThinkingLevel> {
510        if self.visible {
511            self.visible = false;
512            Some(ThinkingLevel::all()[self.selected])
513        } else {
514            None
515        }
516    }
517}
518
519#[derive(Clone)]
520pub struct SessionEntry {
521    pub id: String,
522    pub title: String,
523    pub subtitle: String,
524}
525
526pub struct SessionSelector {
527    pub visible: bool,
528    pub entries: Vec<SessionEntry>,
529    pub filtered: Vec<usize>,
530    pub selected: usize,
531    pub query: String,
532}
533
534impl Default for SessionSelector {
535    fn default() -> Self {
536        Self::new()
537    }
538}
539
540impl SessionSelector {
541    pub fn new() -> Self {
542        Self {
543            visible: false,
544            entries: Vec::new(),
545            filtered: Vec::new(),
546            selected: 0,
547            query: String::new(),
548        }
549    }
550
551    pub fn open(&mut self, entries: Vec<SessionEntry>) {
552        self.entries = entries;
553        self.query.clear();
554        self.visible = true;
555        self.selected = 0;
556        self.apply_filter();
557    }
558
559    pub fn apply_filter(&mut self) {
560        let q = self.query.to_lowercase();
561        self.filtered = self
562            .entries
563            .iter()
564            .enumerate()
565            .filter(|(_, e)| {
566                if q.is_empty() {
567                    return true;
568                }
569                e.title.to_lowercase().contains(&q) || e.subtitle.to_lowercase().contains(&q)
570            })
571            .map(|(i, _)| i)
572            .collect();
573        if self.selected >= self.filtered.len() {
574            self.selected = self.filtered.len().saturating_sub(1);
575        }
576    }
577
578    pub fn close(&mut self) {
579        self.visible = false;
580        self.query.clear();
581    }
582
583    pub fn up(&mut self) {
584        if self.selected > 0 {
585            self.selected -= 1;
586        }
587    }
588
589    pub fn down(&mut self) {
590        if self.selected + 1 < self.filtered.len() {
591            self.selected += 1;
592        }
593    }
594
595    pub fn confirm(&mut self) -> Option<String> {
596        if self.visible && !self.filtered.is_empty() {
597            self.visible = false;
598            let id = self.entries[self.filtered[self.selected]].id.clone();
599            self.query.clear();
600            Some(id)
601        } else {
602            None
603        }
604    }
605}
606
607pub struct HelpPopup {
608    pub visible: bool,
609}
610
611impl Default for HelpPopup {
612    fn default() -> Self {
613        Self::new()
614    }
615}
616
617impl HelpPopup {
618    pub fn new() -> Self {
619        Self { visible: false }
620    }
621
622    pub fn open(&mut self) {
623        self.visible = true;
624    }
625
626    pub fn close(&mut self) {
627        self.visible = false;
628    }
629}
630
631pub fn time_ago(iso: &str) -> String {
632    if let Ok(dt) = iso.parse::<DateTime<Utc>>() {
633        let secs = Utc::now().signed_duration_since(dt).num_seconds();
634        if secs < 60 {
635            return "just now".to_string();
636        }
637        if secs < 3600 {
638            return format!("{}m ago", secs / 60);
639        }
640        if secs < 86400 {
641            return format!("{}h ago", secs / 3600);
642        }
643        if secs < 604800 {
644            return format!("{}d ago", secs / 86400);
645        }
646        return format!("{}w ago", secs / 604800);
647    }
648    iso.to_string()
649}
650
651#[derive(Debug, Clone, Copy, PartialEq)]
652pub enum LoginStep {
653    SelectProvider,
654    SelectMethod,
655    EnterApiKey,
656    OAuthWaiting,
657    OAuthExchanging,
658}
659
660pub struct LoginPopup {
661    pub visible: bool,
662    pub step: LoginStep,
663    pub selected: usize,
664    pub provider: Option<String>,
665    pub key_input: String,
666    pub status: Option<String>,
667    pub oauth_url: Option<String>,
668    pub oauth_verifier: Option<String>,
669    pub oauth_create_key: bool,
670    pub code_input: String,
671    pub from_welcome: bool,
672}
673
674impl Default for LoginPopup {
675    fn default() -> Self {
676        Self::new()
677    }
678}
679
680impl LoginPopup {
681    pub fn new() -> Self {
682        Self {
683            visible: false,
684            step: LoginStep::SelectProvider,
685            selected: 0,
686            provider: None,
687            key_input: String::new(),
688            status: None,
689            oauth_url: None,
690            oauth_verifier: None,
691            oauth_create_key: false,
692            code_input: String::new(),
693            from_welcome: false,
694        }
695    }
696
697    pub fn open(&mut self) {
698        self.visible = true;
699        self.step = LoginStep::SelectProvider;
700        self.selected = 0;
701        self.provider = None;
702        self.key_input.clear();
703        self.code_input.clear();
704        self.status = None;
705        self.oauth_url = None;
706        self.oauth_verifier = None;
707        self.oauth_create_key = false;
708        self.from_welcome = false;
709    }
710
711    pub fn close(&mut self) {
712        self.visible = false;
713        self.key_input.clear();
714        self.code_input.clear();
715        self.status = None;
716        self.oauth_url = None;
717        self.oauth_verifier = None;
718    }
719
720    pub fn providers() -> &'static [&'static str] {
721        &["Anthropic", "OpenAI", "GitHub Copilot"]
722    }
723
724    pub fn anthropic_methods() -> &'static [&'static str] {
725        &[
726            "Claude Pro/Max (OAuth)",
727            "Create API Key (OAuth)",
728            "Enter API Key",
729        ]
730    }
731
732    pub fn up(&mut self) {
733        if self.selected > 0 {
734            self.selected -= 1;
735        }
736    }
737
738    pub fn down(&mut self) {
739        let max = match self.step {
740            LoginStep::SelectProvider => Self::providers().len(),
741            LoginStep::SelectMethod => Self::anthropic_methods().len(),
742            LoginStep::EnterApiKey | LoginStep::OAuthWaiting | LoginStep::OAuthExchanging => 0,
743        };
744        if max > 0 && self.selected + 1 < max {
745            self.selected += 1;
746        }
747    }
748}
749
750#[derive(Debug, Clone, Copy, PartialEq)]
751pub enum WelcomeChoice {
752    Login,
753    UseEnvKeys,
754    SetEnvVars,
755}
756
757pub struct WelcomeScreen {
758    pub visible: bool,
759    pub selected: usize,
760}
761
762impl Default for WelcomeScreen {
763    fn default() -> Self {
764        Self::new()
765    }
766}
767
768impl WelcomeScreen {
769    pub fn new() -> Self {
770        Self {
771            visible: false,
772            selected: 0,
773        }
774    }
775
776    pub fn open(&mut self) {
777        self.visible = true;
778        self.selected = 0;
779    }
780
781    pub fn close(&mut self) {
782        self.visible = false;
783    }
784
785    pub fn choices() -> &'static [(&'static str, &'static str)] {
786        &[
787            ("Login", "OAuth or API key"),
788            ("Use env keys", "ANTHROPIC_API_KEY / OPENAI_API_KEY"),
789            ("Set env variables", "configure keys in your shell"),
790        ]
791    }
792
793    pub fn up(&mut self) {
794        if self.selected > 0 {
795            self.selected -= 1;
796        }
797    }
798
799    pub fn down(&mut self) {
800        if self.selected + 1 < Self::choices().len() {
801            self.selected += 1;
802        }
803    }
804
805    pub fn confirm(&mut self) -> Option<WelcomeChoice> {
806        if !self.visible {
807            return None;
808        }
809        self.visible = false;
810        match self.selected {
811            0 => Some(WelcomeChoice::Login),
812            1 => Some(WelcomeChoice::UseEnvKeys),
813            2 => Some(WelcomeChoice::SetEnvVars),
814            _ => None,
815        }
816    }
817}
818
819pub struct MessageContextMenu {
820    pub visible: bool,
821    pub message_index: usize,
822    pub selected: usize,
823    pub screen_x: u16,
824    pub screen_y: u16,
825}
826
827impl Default for MessageContextMenu {
828    fn default() -> Self {
829        Self::new()
830    }
831}
832
833impl MessageContextMenu {
834    pub fn new() -> Self {
835        Self {
836            visible: false,
837            message_index: 0,
838            selected: 0,
839            screen_x: 0,
840            screen_y: 0,
841        }
842    }
843
844    pub fn open(&mut self, message_index: usize, x: u16, y: u16) {
845        self.visible = true;
846        self.message_index = message_index;
847        self.selected = 0;
848        self.screen_x = x;
849        self.screen_y = y;
850    }
851
852    pub fn close(&mut self) {
853        self.visible = false;
854    }
855
856    pub fn up(&mut self) {
857        if self.selected > 0 {
858            self.selected -= 1;
859        }
860    }
861
862    pub fn down(&mut self) {
863        if self.selected < Self::labels().len() - 1 {
864            self.selected += 1;
865        }
866    }
867
868    pub fn confirm(&mut self) -> Option<(usize, usize)> {
869        if self.visible {
870            self.visible = false;
871            Some((self.selected, self.message_index))
872        } else {
873            None
874        }
875    }
876
877    pub fn labels() -> &'static [&'static str] {
878        &["revert to message", "fork from here", "copy"]
879    }
880}
881
882#[derive(Clone)]
883pub struct FilePickerEntry {
884    pub name: String,
885    pub path: String,
886    pub is_dir: bool,
887}
888
889pub struct FilePicker {
890    pub visible: bool,
891    pub entries: Vec<FilePickerEntry>,
892    pub filtered: Vec<usize>,
893    pub selected: usize,
894    pub query: String,
895    pub at_pos: usize,
896    base_dir: String,
897}
898
899impl Default for FilePicker {
900    fn default() -> Self {
901        Self::new()
902    }
903}
904
905impl FilePicker {
906    pub fn new() -> Self {
907        Self {
908            visible: false,
909            entries: Vec::new(),
910            filtered: Vec::new(),
911            selected: 0,
912            query: String::new(),
913            at_pos: 0,
914            base_dir: String::new(),
915        }
916    }
917
918    pub fn open(&mut self, at_pos: usize) {
919        self.visible = true;
920        self.at_pos = at_pos;
921        self.query.clear();
922        self.selected = 0;
923        self.base_dir.clear();
924        self.populate();
925    }
926
927    pub fn close(&mut self) {
928        self.visible = false;
929        self.query.clear();
930        self.entries.clear();
931        self.filtered.clear();
932    }
933
934    pub fn populate(&mut self) {
935        let (dir, _) = self.dir_and_filter();
936        self.base_dir = dir.clone();
937        self.entries.clear();
938
939        let read_path = if dir.is_empty() {
940            ".".to_string()
941        } else {
942            dir.clone()
943        };
944        let Ok(rd) = std::fs::read_dir(&read_path) else {
945            return;
946        };
947
948        let mut dirs = Vec::new();
949        let mut files = Vec::new();
950
951        for entry in rd.flatten() {
952            let name = entry.file_name().to_string_lossy().to_string();
953            if name.starts_with('.') {
954                continue;
955            }
956            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
957            let rel = if dir.is_empty() {
958                name.clone()
959            } else {
960                format!("{}{}", dir, name)
961            };
962            let e = FilePickerEntry {
963                name,
964                path: rel,
965                is_dir,
966            };
967            if is_dir {
968                dirs.push(e);
969            } else {
970                files.push(e);
971            }
972        }
973
974        dirs.sort_by(|a, b| a.name.cmp(&b.name));
975        files.sort_by(|a, b| a.name.cmp(&b.name));
976        self.entries.extend(dirs);
977        self.entries.extend(files);
978        self.apply_filter();
979    }
980
981    fn dir_and_filter(&self) -> (String, String) {
982        if let Some(pos) = self.query.rfind('/') {
983            (
984                self.query[..=pos].to_string(),
985                self.query[pos + 1..].to_string(),
986            )
987        } else {
988            (String::new(), self.query.clone())
989        }
990    }
991
992    pub fn apply_filter(&mut self) {
993        let (_, filter) = self.dir_and_filter();
994        let q = filter.to_lowercase();
995        self.filtered = self
996            .entries
997            .iter()
998            .enumerate()
999            .filter(|(_, e)| {
1000                if q.is_empty() {
1001                    return true;
1002                }
1003                e.name.to_lowercase().starts_with(&q) || e.name.to_lowercase().contains(&q)
1004            })
1005            .map(|(i, _)| i)
1006            .collect();
1007        if self.selected >= self.filtered.len() {
1008            self.selected = self.filtered.len().saturating_sub(1);
1009        }
1010    }
1011
1012    pub fn update_query(&mut self, query: &str) {
1013        let (old_dir, _) = self.dir_and_filter();
1014        self.query = query.to_string();
1015        let (new_dir, _) = self.dir_and_filter();
1016        if new_dir != old_dir {
1017            self.populate();
1018        } else {
1019            self.apply_filter();
1020        }
1021    }
1022
1023    pub fn up(&mut self) {
1024        if self.selected > 0 {
1025            self.selected -= 1;
1026        }
1027    }
1028
1029    pub fn down(&mut self) {
1030        if self.selected + 1 < self.filtered.len() {
1031            self.selected += 1;
1032        }
1033    }
1034
1035    pub fn confirm(&mut self) -> Option<FilePickerEntry> {
1036        if self.visible && !self.filtered.is_empty() {
1037            self.visible = false;
1038            let entry = self.entries[self.filtered[self.selected]].clone();
1039            self.query.clear();
1040            Some(entry)
1041        } else {
1042            None
1043        }
1044    }
1045}
1046
1047pub struct AsidePopup {
1048    pub visible: bool,
1049    pub question: String,
1050    pub response: String,
1051    pub done: bool,
1052    pub scroll_offset: u16,
1053}
1054
1055impl Default for AsidePopup {
1056    fn default() -> Self {
1057        Self::new()
1058    }
1059}
1060
1061impl AsidePopup {
1062    pub fn new() -> Self {
1063        Self {
1064            visible: false,
1065            question: String::new(),
1066            response: String::new(),
1067            done: false,
1068            scroll_offset: 0,
1069        }
1070    }
1071
1072    pub fn open(&mut self, question: String) {
1073        self.visible = true;
1074        self.question = question;
1075        self.response.clear();
1076        self.done = false;
1077        self.scroll_offset = 0;
1078    }
1079
1080    pub fn close(&mut self) {
1081        self.visible = false;
1082        self.question.clear();
1083        self.response.clear();
1084        self.done = false;
1085        self.scroll_offset = 0;
1086    }
1087
1088    pub fn scroll_up(&mut self) {
1089        self.scroll_offset = self.scroll_offset.saturating_sub(1);
1090    }
1091
1092    pub fn scroll_down(&mut self) {
1093        self.scroll_offset = self.scroll_offset.saturating_add(1);
1094    }
1095}