Add tree view, fixes #52, fixes #53

This commit is contained in:
Jeremy Soller 2025-01-24 14:05:50 -07:00
parent 0169cccfa2
commit 841816e8d1
No known key found for this signature in database
GPG key ID: D02FD439211AF56F
8 changed files with 557 additions and 59 deletions

View file

@ -12,8 +12,8 @@ use cosmic::{
subscription::Subscription,
window, Alignment, Background, Border, Color, ContentFit, Length, Limits,
},
theme,
widget::{self, menu::action::MenuAction, Slider},
iced_style, theme,
widget::{self, menu::action::MenuAction, nav_bar, segmented_button, Slider},
Application, ApplicationExt, Element,
};
use iced_video_player::{
@ -24,7 +24,9 @@ use std::{
any::TypeId,
collections::HashMap,
ffi::{CStr, CString},
fs, process, thread,
fs,
path::{Path, PathBuf},
process, thread,
time::{Duration, Instant},
};
use tokio::sync::mpsc;
@ -32,6 +34,7 @@ use tokio::sync::mpsc;
use crate::{
config::{Config, ConfigState, CONFIG_VERSION},
key_bind::{key_binds, KeyBind},
project::ProjectNode,
};
mod config;
@ -40,6 +43,7 @@ mod localize;
mod menu;
#[cfg(feature = "mpris-server")]
mod mpris;
mod project;
static CONTROLS_TIMEOUT: Duration = Duration::new(2, 0);
@ -143,6 +147,7 @@ pub enum Action {
FileClose,
FileOpen,
FileOpenRecent(usize),
FolderClose(usize),
FolderOpen,
FolderOpenRecent(usize),
Fullscreen,
@ -160,6 +165,7 @@ impl MenuAction for Action {
Self::FileClose => Message::FileClose,
Self::FileOpen => Message::FileOpen,
Self::FileOpenRecent(index) => Message::FileOpenRecent(*index),
Self::FolderClose(index) => Message::FolderClose(*index),
Self::FolderOpen => Message::FolderOpen,
Self::FolderOpenRecent(index) => Message::FolderOpenRecent(*index),
Self::Fullscreen => Message::Fullscreen,
@ -224,6 +230,8 @@ pub enum Message {
FileLoad(url::Url),
FileOpen,
FileOpenRecent(usize),
FolderClose(usize),
FolderLoad(PathBuf),
FolderOpen,
FolderOpenRecent(usize),
Fullscreen,
@ -259,6 +267,8 @@ pub struct App {
fullscreen: bool,
key_binds: HashMap<KeyBind, Action>,
mpris_opt: Option<(MprisMeta, MprisState, mpsc::UnboundedSender<MprisEvent>)>,
nav_model: segmented_button::SingleSelectModel,
projects: Vec<(String, PathBuf)>,
video_opt: Option<Video>,
position: f64,
duration: f64,
@ -293,6 +303,7 @@ impl App {
self.text_codes.clear();
self.current_text = -1;
self.update_mpris_meta();
self.update_nav_bar_active();
was_open
}
@ -424,6 +435,128 @@ impl App {
self.update_title()
}
fn open_folder<P: AsRef<Path>>(&mut self, path: P, mut position: u16, indent: u16) {
let read_dir = match fs::read_dir(&path) {
Ok(ok) => ok,
Err(err) => {
log::error!("failed to read directory {:?}: {}", path.as_ref(), err);
return;
}
};
let mut nodes = Vec::new();
for entry_res in read_dir {
let entry = match entry_res {
Ok(ok) => ok,
Err(err) => {
log::error!(
"failed to read entry in directory {:?}: {}",
path.as_ref(),
err
);
continue;
}
};
let entry_path = entry.path();
let node = match ProjectNode::new(&entry_path) {
Ok(ok) => ok,
Err(err) => {
log::error!(
"failed to open directory {:?} entry {:?}: {}",
path.as_ref(),
entry_path,
err
);
continue;
}
};
nodes.push(node);
}
nodes.sort();
for node in nodes {
let mut entity = self
.nav_model
.insert()
.position(position)
.indent(indent)
.text(node.name().to_string());
if let Some(icon) = node.icon(16) {
entity = entity.icon(icon);
}
entity.data(node);
position += 1;
}
}
pub fn open_project<P: AsRef<Path>>(&mut self, path: P) {
let path = path.as_ref();
let node = match ProjectNode::new(path) {
Ok(mut node) => {
match &mut node {
ProjectNode::Folder {
name,
path,
open,
root,
} => {
*open = true;
*root = true;
for (_project_name, project_path) in self.projects.iter() {
if project_path == path {
// Project already open
return;
}
}
// Save the absolute path
self.projects.push((name.to_string(), path.to_path_buf()));
// Add to recent projects, ensuring only one entry
self.flags
.config_state
.recent_projects
.retain(|x| x != path);
self.flags
.config_state
.recent_projects
.push_front(path.to_path_buf());
self.flags.config_state.recent_projects.truncate(10);
self.save_config_state();
// Open nav bar
self.core.nav_bar_set_toggled(true);
}
_ => {
log::error!("failed to open project {:?}: not a directory", path);
return;
}
}
node
}
Err(err) => {
log::error!("failed to open project {:?}: {}", path, err);
return;
}
};
let mut entity = self.nav_model.insert().text(node.name().to_string());
if let Some(icon) = node.icon(16) {
entity = entity.icon(icon);
}
entity = entity.data(node);
let id = entity.id();
let position = self.nav_model.position(id).unwrap_or(0);
self.open_folder(path, position + 1, 1);
}
fn save_config_state(&mut self) {
if let Some(ref config_state_handler) = self.flags.config_state_handler {
if let Err(err) = self.flags.config_state.write_entry(config_state_handler) {
@ -561,6 +694,52 @@ impl App {
}
}
fn update_nav_bar_active(&mut self) {
let tab_path_opt = match &self.flags.url_opt {
Some(url) => url.to_file_path().ok(),
None => None,
};
// Locate tree node to activate
let mut active_id = segmented_button::Entity::default();
if let Some(tab_path) = tab_path_opt {
// Automatically expand tree to find and select active file
loop {
let mut expand_opt = None;
for id in self.nav_model.iter() {
if let Some(node) = self.nav_model.data(id) {
match node {
ProjectNode::Folder { path, open, .. } => {
if tab_path.starts_with(path) && !*open {
expand_opt = Some(id);
break;
}
}
ProjectNode::File { path, .. } => {
if path == &tab_path {
active_id = id;
break;
}
}
}
}
}
match expand_opt {
Some(id) => {
//TODO: can this be optimized?
// Task not used becuase opening a folder just returns Task::none
let _ = self.on_nav_select(id);
}
None => {
break;
}
}
}
}
self.nav_model.activate(active_id);
}
fn update_title(&mut self) -> Command<Message> {
//TODO: filename?
let title = "COSMIC Media Player";
@ -604,6 +783,8 @@ impl Application for App {
fullscreen: false,
key_binds: key_binds(),
mpris_opt: None,
nav_model: nav_bar::Model::builder().build(),
projects: Vec::new(),
video_opt: None,
position: 0.0,
duration: 0.0,
@ -615,10 +796,25 @@ impl Application for App {
current_text: -1,
};
// Do not show nav bar by default. Will be opened by open_project if needed
app.core.nav_bar_set_toggled(false);
//TODO: handle command line arguments that are folders?
// Add button to open a project
//TODO: remove and show this based on open projects?
app.nav_model
.insert()
.icon(widget::icon::from_name("folder-open-symbolic").size(16))
.text(fl!("open-folder"));
let command = app.load();
(app, command)
}
fn nav_model(&self) -> Option<&nav_bar::Model> {
Some(&self.nav_model)
}
fn on_escape(&mut self) -> Command<Self::Message> {
if self.fullscreen {
return self.update(Message::Fullscreen);
@ -627,6 +823,78 @@ impl Application for App {
}
}
fn on_nav_select(&mut self, id: nav_bar::Id) -> Command<Message> {
// Toggle open state and get clone of node data
let node_opt = match self.nav_model.data_mut::<ProjectNode>(id) {
Some(node) => {
if let ProjectNode::Folder { open, .. } = node {
*open = !*open;
}
Some(node.clone())
}
None => None,
};
match node_opt {
Some(node) => {
// Update icon
if let Some(icon) = node.icon(16) {
self.nav_model.icon_set(id, icon);
} else {
self.nav_model.icon_remove(id);
}
match node {
ProjectNode::Folder { path, open, .. } => {
let position = self.nav_model.position(id).unwrap_or(0);
let indent = self.nav_model.indent(id).unwrap_or(0);
if open {
// Open folder
self.open_folder(path, position + 1, indent + 1);
} else {
// Close folder
while let Some(child_id) = self.nav_model.entity_at(position + 1) {
if self.nav_model.indent(child_id).unwrap_or(0) > indent {
self.nav_model.remove(child_id);
} else {
break;
}
}
}
// Prevent nav bar from closing when selecting a
// folder in condensed mode.
self.core_mut().nav_bar_set_toggled(true);
Command::none()
}
ProjectNode::File { path, .. } => match url::Url::from_file_path(&path) {
Ok(url) => self.update(Message::FileLoad(url)),
Err(()) => {
log::warn!("failed to convert {:?} to url", path);
Command::none()
}
},
}
}
None => {
// Open folder
self.update(Message::FolderOpen)
}
}
}
fn style(&self) -> Option<theme::Application> {
// This ensures we have a solid background color even when using no content container
Some(theme::Application::Custom(Box::new(|theme| {
iced_style::application::Appearance {
background_color: theme.cosmic().bg_color().into(),
icon_color: theme.cosmic().on_bg_color().into(),
text_color: theme.cosmic().on_bg_color().into(),
}
})))
}
/// Handle application events here.
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
match message {
@ -678,12 +946,75 @@ impl Application for App {
}
Message::FileOpenRecent(index) => {
if let Some(url) = self.flags.config_state.recent_files.get(index) {
self.flags.url_opt = Some(url.clone());
return self.load();
return self.update(Message::FileLoad(url.clone()));
}
}
Message::FolderOpen | Message::FolderOpenRecent(..) => {
log::error!("TODO: {:?}", message);
Message::FolderClose(project_i) => {
if project_i < self.projects.len() {
let (_project_name, project_path) = self.projects.remove(project_i);
let mut position = 0;
let mut closing = false;
while let Some(id) = self.nav_model.entity_at(position) {
match self.nav_model.data::<ProjectNode>(id) {
Some(node) => {
if let ProjectNode::Folder { path, root, .. } = node {
if path == &project_path {
// Found the project root node, closing
closing = true;
} else if *root && closing {
// Found another project root node after closing, breaking
break;
}
}
}
None => {
if closing {
break;
}
}
}
if closing {
self.nav_model.remove(id);
} else {
position += 1;
}
}
}
}
Message::FolderLoad(path) => {
self.open_project(path);
}
Message::FolderOpen => {
//TODO: embed cosmic-files dialog (after libcosmic rebase works)
#[cfg(feature = "xdg-portal")]
return Command::perform(
async move {
let dialog = cosmic::dialog::file_chooser::open::Dialog::new()
.title(fl!("open-media-folder"));
match dialog.open_folder().await {
Ok(response) => {
let url = response.url();
match url.to_file_path() {
Ok(path) => message::app(Message::FolderLoad(path)),
Err(()) => {
log::warn!("unsupported folder URL {:?}", url);
message::none()
}
}
}
Err(err) => {
log::warn!("failed to open folder: {}", err);
message::none()
}
}
},
|x| x,
);
}
Message::FolderOpenRecent(index) => {
if let Some(path) = self.flags.config_state.recent_projects.get(index) {
return self.update(Message::FolderLoad(path.clone()));
}
}
Message::Fullscreen => {
//TODO: cleanest way to close dropdowns
@ -882,6 +1213,7 @@ impl Application for App {
&self.flags.config,
&self.flags.config_state,
&self.key_binds,
&self.projects,
)]
}