Add basic command palette

This commit is contained in:
2026-03-09 20:37:20 +01:00
parent c63babb599
commit f95eab02de
5 changed files with 192 additions and 36 deletions

View File

@@ -1,6 +1,5 @@
use std::{ use std::{
fs, fs,
io::Read,
path::PathBuf, path::PathBuf,
sync::{Arc, mpsc}, sync::{Arc, mpsc},
thread::JoinHandle, thread::JoinHandle,
@@ -8,11 +7,12 @@ use std::{
}; };
use crate::{ use crate::{
command_palette::{self, CommandPalette},
file_editor::{FileEditor, SaveStatus}, file_editor::{FileEditor, SaveStatus},
folder::Folder, folder::Folder,
preferences::Preferences, preferences::Preferences,
text_styles::{H1, H1_MONO, H2, H2_MONO, H3, H3_MONO, H4, H4_MONO, H5, H5_MONO, H6, H6_MONO}, text_styles::{H1, H1_MONO, H2, H2_MONO, H3, H3_MONO, H4, H4_MONO, H5, H5_MONO, H6, H6_MONO},
util::{GuiSender, file_mtime, log_error}, util::{GuiSender, log_error},
}; };
use egui::{ use egui::{
Align, Button, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Image, Key, Align, Button, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Image, Key,
@@ -40,6 +40,9 @@ pub struct App {
open_tab_index: Option<usize>, open_tab_index: Option<usize>,
next_tab_id: TabId, next_tab_id: TabId,
#[serde(skip)]
command_palette: Option<CommandPalette>,
} }
pub struct Jobs { pub struct Jobs {
@@ -129,6 +132,7 @@ impl Default for App {
next_tab_id: 2, next_tab_id: 2,
show_folders: false, show_folders: false,
folders: vec![], folders: vec![],
command_palette: None,
} }
} }
} }
@@ -292,6 +296,10 @@ impl eframe::App for App {
if input.consume_key(Modifiers::CTRL, Key::S) { if input.consume_key(Modifiers::CTRL, Key::S) {
self.save_active_tab(ctx); self.save_active_tab(ctx);
} }
if input.consume_key(Modifiers::CTRL, Key::K) {
self.command_palette = Some(Default::default());
}
}); });
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
@@ -310,19 +318,10 @@ impl eframe::App for App {
self.jobs.start(ui.ctx(), move || { self.jobs.start(ui.ctx(), move || {
let file_path = rfd::FileDialog::new().pick_file()?; let file_path = rfd::FileDialog::new().pick_file()?;
let mut file = fs::File::open(&file_path) log_error(eyre!("Failed to open file {file_path:?}"), || {
.inspect_err(|e| log::error!("Failed to open {file_path:?}: {e}")) FileEditor::open_file(file_path)
.ok()?; })
.map(Action::OpenFile)
let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?;
let mut text = String::new();
file.read_to_string(&mut text)
.inspect_err(|e| log::error!("Failed to read {file_path:?}: {e}"))
.ok()?;
let editor = FileEditor::from_file(file_path, &text, mtime);
Some(Action::OpenFile(editor))
}); });
} }
@@ -445,23 +444,10 @@ impl eframe::App for App {
if let Some(file_path) = response.open_file { if let Some(file_path) = response.open_file {
let file_path = file_path.to_owned(); let file_path = file_path.to_owned();
self.jobs.start(ui.ctx(), move || { self.jobs.start(ui.ctx(), move || {
let mut file = fs::File::open(&file_path) log_error(eyre!("Failed to open file {file_path:?}"), || {
.inspect_err(|e| { FileEditor::open_file(file_path)
log::error!("Failed to open {file_path:?}: {e}") })
}) .map(Action::OpenFile)
.ok()?;
let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?;
let mut text = String::new();
file.read_to_string(&mut text)
.inspect_err(|e| {
log::error!("Failed to read {file_path:?}: {e}")
})
.ok()?;
let editor = FileEditor::from_file(file_path, &text, mtime);
Some(Action::OpenFile(editor))
}); });
} }
@@ -489,6 +475,16 @@ impl eframe::App for App {
egui::warn_if_debug_build(ui); egui::warn_if_debug_build(ui);
}); });
}); });
if let Some(command_palette) = &mut self.command_palette {
match command_palette.show(ctx, &mut self.jobs, &mut self.folders) {
command_palette::Response::None => {}
command_palette::Response::Close => {
self.command_palette = None;
ctx.request_repaint();
}
};
}
} }
} }

146
src/command_palette.rs Normal file
View File

@@ -0,0 +1,146 @@
use std::{
cmp::min,
path::{Path, PathBuf},
};
use egui::{Key, Modifiers, RichText, Ui, Window};
use eyre::eyre;
use crate::{
app::{Action, Jobs},
file_editor::FileEditor,
folder::Folder,
util::log_error,
};
pub struct CommandPalette {
first_frame: bool,
input: String,
results: Vec<OpenFileCommand>,
list_i: usize,
}
impl Default for CommandPalette {
fn default() -> Self {
Self {
first_frame: true,
input: Default::default(),
results: Default::default(),
list_i: 0,
}
}
}
struct OpenFileCommand {
pretty: String,
path: PathBuf,
}
pub enum Response {
None,
Close,
}
impl CommandPalette {
pub fn show(
&mut self,
ctx: &egui::Context,
jobs: &mut Jobs,
folders: &mut [Folder],
) -> Response {
Window::new("Command Palette")
.collapsible(false)
.show(ctx, |ui| {
let text_edit = ui.text_edit_singleline(&mut self.input);
let mut response = Response::None;
if self.first_frame {
self.first_frame = false;
text_edit.request_focus();
}
if text_edit.has_focus() || text_edit.lost_focus() {
ui.input_mut(|input| {
if input.consume_key(Modifiers::NONE, Key::ArrowUp) {
self.list_i = self.list_i.saturating_sub(1);
};
if input.consume_key(Modifiers::NONE, Key::ArrowDown) {
self.list_i =
min(self.list_i + 1, self.results.len().saturating_sub(1));
};
if input.consume_key(Modifiers::NONE, Key::Enter)
&& let Some(result) = self.results.get(self.list_i)
{
let path = result.path.clone();
log::info!("open {}", result.pretty);
jobs.start(ui.ctx(), move || {
log_error(eyre!("Failed to open file {path:?}"), move || {
FileEditor::open_file(path)
})
.map(Action::OpenFile)
});
};
});
}
if !text_edit.has_focus() {
log::info!("sad face");
response = Response::Close;
}
if text_edit.changed() {
self.results.clear();
if !self.input.is_empty() {
self.input = self.input.to_lowercase();
search_files(ui, &self.input, "".as_ref(), folders, &mut self.results);
}
self.list_i = min(self.list_i, self.results.len().saturating_sub(1));
}
for (i, result) in self.results.iter().enumerate() {
let highlight = i == self.list_i;
ui.horizontal(|ui| {
let mut label = RichText::new("> open");
if highlight {
label = label.strong().underline();
}
ui.label(label);
ui.monospace(&result.pretty);
});
}
response
})
.expect("Window is always open")
.inner
.expect("Windows is never collapsed")
}
}
/// Search the tree of [Folder]s for files matching `query`.
fn search_files(
ui: &mut Ui,
query: &str,
path: &Path,
folders: &mut [Folder],
out: &mut Vec<OpenFileCommand>,
) {
for folder in folders {
let Some(folder) = folder.load(ui) else {
continue;
};
let path = path.join(&folder.name);
for file in &folder.child_files {
if file.name.to_lowercase().contains(query) {
let pretty = path.join(&file.name).to_string_lossy().to_string();
let path = file.path.clone();
out.push(OpenFileCommand { pretty, path });
}
}
search_files(ui, query, &path, &mut folder.child_folders, out);
}
}

View File

@@ -2,7 +2,7 @@ use std::{
cmp::Ordering, cmp::Ordering,
fmt::{self, Display}, fmt::{self, Display},
fs::{self, File}, fs::{self, File},
io::Write, io::{Read as _, Write},
ops::{Div as _, Sub as _}, ops::{Div as _, Sub as _},
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
@@ -13,7 +13,7 @@ use chrono::{DateTime, Local};
use egui::{ use egui::{
Align, Button, Context, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, vec2, Align, Button, Context, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, vec2,
}; };
use eyre::eyre; use eyre::{Context as _, eyre};
use notify::{EventKind, Watcher}; use notify::{EventKind, Watcher};
use crate::{ use crate::{
@@ -100,7 +100,7 @@ impl FileEditor {
} }
} }
pub fn from_file(file_path: PathBuf, contents: &str, mtime: DateTime<Local>) -> Self { pub fn from_path(file_path: PathBuf, contents: &str, mtime: DateTime<Local>) -> Self {
let file_title = file_path let file_title = file_path
.file_name() .file_name()
.map(|name| name.to_string_lossy().to_string()) .map(|name| name.to_string_lossy().to_string())
@@ -115,6 +115,19 @@ impl FileEditor {
} }
} }
pub fn open_file(file_path: PathBuf) -> eyre::Result<Self> {
let mut file =
fs::File::open(&file_path).wrap_err_with(|| eyre!("Failed to open {file_path:?}"))?;
let mtime = file_mtime(&file)?;
let mut text = String::new();
file.read_to_string(&mut text)
.wrap_err_with(|| eyre!("Failed to read {file_path:?}"))?;
Ok(FileEditor::from_path(file_path, &text, mtime))
}
pub fn title(&self) -> &str { pub fn title(&self) -> &str {
&self.title &self.title
} }

View File

@@ -158,7 +158,7 @@ impl LoadedFolder {
} }
impl Folder { impl Folder {
fn load(&mut self, ui: &mut Ui) -> Option<&mut LoadedFolder> { pub fn load(&mut self, ui: &mut Ui) -> Option<&mut LoadedFolder> {
if let Folder::NotLoaded { name, path } = self { if let Folder::NotLoaded { name, path } = self {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();

View File

@@ -1,4 +1,5 @@
pub mod app; pub mod app;
pub mod command_palette;
pub mod constants; pub mod constants;
pub mod custom_code_block; pub mod custom_code_block;
pub mod file_editor; pub mod file_editor;