Path completion, fixes #86

This commit is contained in:
Jeremy Soller 2025-02-07 09:11:20 -07:00
parent 9ca5bfaf19
commit 6612c5203e
No known key found for this signature in database
GPG key ID: 670FDFB5428E05CA

View file

@ -46,6 +46,7 @@ use std::{
cell::Cell, cell::Cell,
cmp::Ordering, cmp::Ordering,
collections::HashMap, collections::HashMap,
error::Error,
fmt::{self, Display}, fmt::{self, Display},
fs::{self, File, Metadata}, fs::{self, File, Metadata},
io::{BufRead, BufReader}, io::{BufRead, BufReader},
@ -234,6 +235,41 @@ pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Han
.handle() .handle()
} }
fn tab_complete(path: &Path) -> Result<Vec<(String, PathBuf)>, Box<dyn Error>> {
let parent = if path.exists() {
path
} else {
path.parent()
.ok_or_else(|| format!("path has no parent {:?}", path))?
};
let child_os = path.strip_prefix(&parent).unwrap_or_else(|_| Path::new(""));
let child = child_os
.to_str()
.ok_or_else(|| format!("invalid UTF-8 {:?}", child_os))?;
let pattern = format!("^{}", regex::escape(&child));
let regex = regex::RegexBuilder::new(&pattern)
.case_insensitive(true)
.build()?;
let mut completions = Vec::new();
for entry_res in fs::read_dir(&parent)? {
let entry = entry_res?;
let file_name_os = entry.file_name();
let Some(file_name) = file_name_os.to_str() else {
continue;
};
if regex.is_match(&file_name) {
completions.push((file_name.to_string(), entry.path()));
}
}
completions.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.0, &b.0));
println!("{:?}", completions);
Ok(completions)
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn trash_entries() -> usize { pub fn trash_entries() -> usize {
0 0
@ -905,6 +941,56 @@ pub fn scan_desktop(
items items
} }
#[derive(Clone, Debug)]
pub struct EditLocation {
pub location: Location,
pub completions: Option<Vec<(String, PathBuf)>>,
pub selected: Option<usize>,
}
impl EditLocation {
pub fn resolve(&self) -> Option<Location> {
let Some(selected) = self.selected else {
return Some(self.location.clone());
};
let completions = self.completions.as_ref()?;
let completion = completions.get(selected)?;
Some(self.location.with_path(completion.1.clone()))
}
pub fn select(&mut self, forwards: bool) {
if let Some(completions) = &self.completions {
if completions.is_empty() {
self.selected = None;
} else {
let mut selected = if forwards {
self.selected.and_then(|x| x.checked_add(1)).unwrap_or(0)
} else {
self.selected
.and_then(|x| x.checked_sub(1))
.unwrap_or(completions.len() - 1)
};
if selected >= completions.len() {
selected = 0;
}
self.selected = Some(selected);
}
} else {
self.selected = None;
}
}
}
impl From<Location> for EditLocation {
fn from(location: Location) -> Self {
Self {
location,
completions: None,
selected: None,
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Location { pub enum Location {
Desktop(PathBuf, String, DesktopConfig), Desktop(PathBuf, String, DesktopConfig),
@ -1032,8 +1118,10 @@ pub enum Message {
LocationContextMenuIndex(Option<usize>), LocationContextMenuIndex(Option<usize>),
LocationMenuAction(LocationMenuAction), LocationMenuAction(LocationMenuAction),
Drag(Option<Rectangle>), Drag(Option<Rectangle>),
EditLocation(Option<Location>), EditLocation(Option<EditLocation>),
EditLocationComplete(usize),
EditLocationEnable, EditLocationEnable,
EditLocationSubmit,
OpenInNewTab(PathBuf), OpenInNewTab(PathBuf),
EmptyTrash, EmptyTrash,
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
@ -1062,6 +1150,7 @@ pub enum Message {
SelectLast, SelectLast,
SetOpenWith(Mime, String), SetOpenWith(Mime, String),
SetSort(HeadingOptions, bool), SetSort(HeadingOptions, bool),
TabComplete(PathBuf, Vec<(String, PathBuf)>),
Thumbnail(PathBuf, ItemThumbnail), Thumbnail(PathBuf, ItemThumbnail),
ToggleShowHidden, ToggleShowHidden,
View(View), View(View),
@ -1654,7 +1743,7 @@ pub struct Tab {
pub scroll_opt: Option<AbsoluteOffset>, pub scroll_opt: Option<AbsoluteOffset>,
pub size_opt: Cell<Option<Size>>, pub size_opt: Cell<Option<Size>>,
pub item_view_size_opt: Cell<Option<Size>>, pub item_view_size_opt: Cell<Option<Size>>,
pub edit_location: Option<Location>, pub edit_location: Option<EditLocation>,
pub edit_location_id: widget::Id, pub edit_location_id: widget::Id,
pub history_i: usize, pub history_i: usize,
pub history: Vec<Location>, pub history: Vec<Location>,
@ -2346,18 +2435,29 @@ impl Tab {
} }
} }
Message::EditLocation(edit_location) => { Message::EditLocation(edit_location) => {
if self.edit_location.is_none() && edit_location.is_some() { self.edit_location = edit_location;
if self.edit_location.is_some() {
commands.push(Command::Iced( commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(), widget::text_input::focus(self.edit_location_id.clone()).into(),
)); ));
} }
self.edit_location = edit_location; }
Message::EditLocationComplete(selected) => {
if let Some(mut edit_location) = self.edit_location.take() {
edit_location.selected = Some(selected);
cd = edit_location.resolve();
}
} }
Message::EditLocationEnable => { Message::EditLocationEnable => {
commands.push(Command::Iced( commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(), widget::text_input::focus(self.edit_location_id.clone()).into(),
)); ));
self.edit_location = Some(self.location.clone()); self.edit_location = Some(self.location.clone().into());
}
Message::EditLocationSubmit => {
if let Some(edit_location) = self.edit_location.take() {
cd = edit_location.resolve();
}
} }
Message::OpenInNewTab(path) => { Message::OpenInNewTab(path) => {
commands.push(Command::OpenInNewTab(path)); commands.push(Command::OpenInNewTab(path));
@ -2450,7 +2550,9 @@ impl Tab {
} }
} }
Message::ItemDown => { Message::ItemDown => {
if self.gallery { if let Some(edit_location) = &mut self.edit_location {
edit_location.select(true);
} else if self.gallery {
for command in self.update(Message::GalleryNext, modifiers) { for command in self.update(Message::GalleryNext, modifiers) {
commands.push(command); commands.push(command);
} }
@ -2574,7 +2676,9 @@ impl Tab {
} }
} }
Message::ItemUp => { Message::ItemUp => {
if self.gallery { if let Some(edit_location) = &mut self.edit_location {
edit_location.select(false);
} else if self.gallery {
for command in self.update(Message::GalleryPrevious, modifiers) { for command in self.update(Message::GalleryPrevious, modifiers) {
commands.push(command); commands.push(command);
} }
@ -2829,6 +2933,16 @@ impl Tab {
self.sort_direction = dir; self.sort_direction = dir;
} }
} }
Message::TabComplete(path, completions) => {
if let Some(edit_location) = &mut self.edit_location {
if edit_location.location.path_opt() == Some(&path) {
edit_location.completions = Some(completions);
commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(),
));
}
}
}
Message::Thumbnail(path, thumbnail) => { Message::Thumbnail(path, thumbnail) => {
if let Some(ref mut items) = self.items_opt { if let Some(ref mut items) = self.items_opt {
let location = Location::Path(path); let location = Location::Path(path);
@ -2980,7 +3094,7 @@ impl Tab {
} }
// Change directory if requested // Change directory if requested
if let Some(location) = cd { if let Some(mut location) = cd {
if matches!(self.mode, Mode::Desktop) { if matches!(self.mode, Mode::Desktop) {
match location { match location {
Location::Path(path) => { Location::Path(path) => {
@ -2991,19 +3105,37 @@ impl Tab {
} }
_ => {} _ => {}
} }
} else if location != self.location { } else {
// Select parent if location is not directory
let mut selected_paths = None;
if let Some(path) = location.path_opt() {
if !path.is_dir() {
if let Some(parent) = path.parent() {
selected_paths = Some(vec![path.to_path_buf()]);
location = location.with_path(parent.to_path_buf());
}
}
}
if location != self.location {
if location.path_opt().map_or(true, |path| path.is_dir()) { if location.path_opt().map_or(true, |path| path.is_dir()) {
let prev_path = self if selected_paths.is_none() {
selected_paths = self
.location .location
.path_opt() .path_opt()
.map(|path| vec![path.to_path_buf()]); .map(|path| vec![path.to_path_buf()]);
}
self.change_location(&location, history_i_opt); self.change_location(&location, history_i_opt);
commands.push(Command::ChangeLocation(self.title(), location, prev_path)); commands.push(Command::ChangeLocation(
self.title(),
location,
selected_paths,
));
} else { } else {
log::warn!("tried to cd to {:?} which is not a directory", location); log::warn!("tried to cd to {:?} which is not a directory", location);
} }
} }
} }
}
commands commands
} }
@ -3421,9 +3553,10 @@ impl Tab {
let heading_rule = widget::container(horizontal_rule(1)) let heading_rule = widget::container(horizontal_rule(1))
.padding([0, theme::active().cosmic().corner_radii.radius_xs[0] as u16]); .padding([0, theme::active().cosmic().corner_radii.radius_xs[0] as u16]);
if let Some(location) = &self.edit_location { if let Some(edit_location) = &self.edit_location {
if let Some(location) = edit_location.resolve() {
//TODO: allow editing other locations //TODO: allow editing other locations
if let Some(path) = location.path_opt() { if let Some(path) = location.path_opt().map(|x| x.to_path_buf()) {
row = row.push( row = row.push(
widget::button::custom( widget::button::custom(
widget::icon::from_name("window-close-symbolic").size(16), widget::icon::from_name("window-close-symbolic").size(16),
@ -3432,15 +3565,41 @@ impl Tab {
.padding(space_xxs) .padding(space_xxs)
.class(theme::Button::Icon), .class(theme::Button::Icon),
); );
row = row.push( let location = location.clone();
widget::text_input("", path.to_string_lossy()) let text_input = widget::text_input("", path.to_string_lossy().to_string())
.id(self.edit_location_id.clone()) .id(self.edit_location_id.clone())
.on_input(|input| { .on_input(move |input| {
Message::EditLocation(Some(location.with_path(PathBuf::from(input)))) Message::EditLocation(Some(
location.with_path(PathBuf::from(input)).into(),
))
}) })
.on_submit(Message::Location(location.clone())) .on_submit(Message::EditLocationSubmit)
.line_height(1.0), .line_height(1.0);
let mut popover =
widget::popover(text_input).position(widget::popover::Position::Bottom);
if let Some(completions) = &edit_location.completions {
if !completions.is_empty() {
let mut column =
widget::column::with_capacity(completions.len()).padding(space_xxs);
for (i, (name, _path)) in completions.iter().enumerate() {
let selected = edit_location.selected == Some(i);
column = column.push(
widget::button::custom(widget::text::body(name))
.class(button_style(selected, false, false, false, false))
.on_press(Message::EditLocationComplete(i))
.padding(space_xxs)
.width(Length::Fill),
); );
}
popover = popover.popup(
widget::layer_container(column)
.layer(cosmic_theme::Layer::Background)
//TODO: This is a hack to get the popover to be the right width
.max_width(size.width - 124.0),
);
}
}
row = row.push(popover);
let mut column = widget::column::with_capacity(4).padding([0, space_s]); let mut column = widget::column::with_capacity(4).padding([0, space_s]);
column = column.push(row); column = column.push(row);
column = column.push(accent_rule); column = column.push(accent_rule);
@ -3450,13 +3609,14 @@ impl Tab {
} }
return column.into(); return column.into();
} }
}
} else if let Some(path) = self.location.path_opt() { } else if let Some(path) = self.location.path_opt() {
row = row.push( row = row.push(
crate::mouse_area::MouseArea::new( crate::mouse_area::MouseArea::new(
widget::button::custom(widget::icon::from_name("edit-symbolic").size(16)) widget::button::custom(widget::icon::from_name("edit-symbolic").size(16))
.padding(space_xxs) .padding(space_xxs)
.class(theme::Button::Icon) .class(theme::Button::Icon)
.on_press(Message::EditLocation(Some(self.location.clone()))), .on_press(Message::EditLocation(Some(self.location.clone().into()))),
) )
.on_middle_press(move |_| Message::OpenInNewTab(path.clone())), .on_middle_press(move |_| Message::OpenInNewTab(path.clone())),
); );
@ -4432,14 +4592,11 @@ impl Tab {
} }
pub fn subscription(&self, preview: bool) -> Subscription<Message> { pub fn subscription(&self, preview: bool) -> Subscription<Message> {
let Some(items) = &self.items_opt else {
return Subscription::none();
};
//TODO: how many thumbnail loads should be in flight at once? //TODO: how many thumbnail loads should be in flight at once?
let jobs = 8; let jobs = 8;
let mut subscriptions = Vec::with_capacity(jobs + 1); let mut subscriptions = Vec::with_capacity(jobs + 3);
if let Some(items) = &self.items_opt {
//TODO: move to function //TODO: move to function
let visible_rect = { let visible_rect = {
let point = match self.scroll_opt { let point = match self.scroll_opt {
@ -4581,6 +4738,7 @@ impl Tab {
} }
} }
} }
}
// Load search items incrementally // Load search items incrementally
if let Location::Search(path, term, show_hidden, start) = &self.location { if let Location::Search(path, term, show_hidden, start) = &self.location {
@ -4673,6 +4831,46 @@ impl Tab {
)); ));
} }
if let Some(path) = self
.edit_location
.as_ref()
.and_then(|x| x.location.path_opt())
.map(|x| x.to_path_buf())
{
subscriptions.push(Subscription::run_with_id(
("tab_complete", path.to_string_lossy().to_string()),
stream::channel(1, |mut output| async move {
let message = {
let path = path.clone();
tokio::task::spawn_blocking(move || {
let start = Instant::now();
match tab_complete(&path) {
Ok(completions) => {
log::info!("tab completed {:?} in {:?}", path, start.elapsed());
Message::TabComplete(path.clone(), completions)
}
Err(err) => {
log::warn!("failed to tab complete {:?}: {}", path, err);
Message::TabComplete(path.clone(), Vec::new())
}
}
})
.await
.unwrap()
};
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!("failed to send tab completion for {:?}: {}", path, err);
}
}
std::future::pending().await
}),
));
}
Subscription::batch(subscriptions) Subscription::batch(subscriptions)
} }
} }