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

47
Cargo.lock generated
View file

@ -1076,6 +1076,8 @@ dependencies = [
"i18n-embed",
"i18n-embed-fl",
"iced_video_player",
"icu_collator",
"icu_provider",
"image",
"lazy_static",
"libcosmic",
@ -2484,9 +2486,9 @@ dependencies = [
[[package]]
name = "i18n-embed"
version = "0.13.9"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92a86226a7a16632de6723449ee5fe70bac5af718bc642ee9ca2f0f6e14fa1fa"
checksum = "94205d95764f5bb9db9ea98fa77f89653365ca748e27161f5bbea2ffd50e459c"
dependencies = [
"arc-swap",
"fluent",
@ -2506,9 +2508,9 @@ dependencies = [
[[package]]
name = "i18n-embed-fl"
version = "0.6.7"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26a3d3569737dfaac7fc1c4078e6af07471c3060b8e570bcd83cdd5f4685395"
checksum = "9fc1f8715195dffc4caddcf1cf3128da15fe5d8a137606ea8856c9300047d5a2"
dependencies = [
"dashmap",
"find-crate",
@ -2797,6 +2799,31 @@ dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "icu_collator"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d370371887d31d56f361c3eaa15743e54f13bc677059c9191c77e099ed6966b2"
dependencies = [
"displaydoc",
"icu_collator_data",
"icu_collections",
"icu_locid_transform",
"icu_normalizer",
"icu_properties",
"icu_provider",
"smallvec",
"utf16_iter",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_collator_data"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee3f88741364b7d6269cce6827a3e6a8a2cf408a78f766c9224ab479d5e4ae5"
[[package]]
name = "icu_collections"
version = "1.5.0"
@ -4442,9 +4469,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rust-embed"
version = "6.8.1"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661"
checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
@ -4453,9 +4480,9 @@ dependencies = [
[[package]]
name = "rust-embed-impl"
version = "6.8.1"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac"
checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478"
dependencies = [
"proc-macro2",
"quote",
@ -4466,9 +4493,9 @@ dependencies = [
[[package]]
name = "rust-embed-utils"
version = "7.8.1"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74"
checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
dependencies = [
"sha2",
"walkdir",

View file

@ -13,9 +13,11 @@ tempfile = "3"
tokio = "1"
url = "2"
# Internationalization
i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] }
i18n-embed-fl = "0.6"
rust-embed = "6"
icu_collator = "1.5"
icu_provider = { version = "1.5", features = ["sync"] }
i18n-embed = { version = "0.14", features = ["fluent-system", "desktop-requester"] }
i18n-embed-fl = "0.7"
rust-embed = "8"
# Logging
env_logger = "0.10"
log = "0.4"

View file

@ -1,6 +1,7 @@
audio = Audio
no-video-or-audio-file-open = No video or audio file open
open-file = Open file
open-folder = Open folder
subtitles = Subtitles
# Context Pages
@ -24,4 +25,5 @@ open-recent-media = Open recent media
close-file = Close file
open-media-folder = Open media folder...
open-recent-media-folder = Open recent media folder
close-media-folder = Close media folder
quit = Quit

View file

@ -5,7 +5,7 @@ use cosmic::{
theme,
};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::{collections::VecDeque, path::PathBuf};
pub const CONFIG_VERSION: u64 = 1;
@ -43,14 +43,14 @@ impl Default for Config {
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ConfigState {
pub recent_files: VecDeque<url::Url>,
pub recent_folders: VecDeque<url::Url>,
pub recent_projects: VecDeque<PathBuf>,
}
impl Default for ConfigState {
fn default() -> Self {
Self {
recent_files: VecDeque::new(),
recent_folders: VecDeque::new(),
recent_projects: VecDeque::new(),
}
}
}

View file

@ -1,17 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-only
use std::str::FromStr;
use std::sync::OnceLock;
use i18n_embed::{
fluent::{fluent_language_loader, FluentLanguageLoader},
DefaultLocalizer, LanguageLoader, Localizer,
};
use icu_collator::{Collator, CollatorOptions, Numeric};
use icu_provider::DataLocale;
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "i18n/"]
struct Localizations;
lazy_static::lazy_static! {
pub static ref LANGUAGE_LOADER: FluentLanguageLoader = {
pub static LANGUAGE_LOADER: OnceLock<FluentLanguageLoader> = OnceLock::new();
pub static LANGUAGE_SORTER: OnceLock<Collator> = OnceLock::new();
#[macro_export]
macro_rules! fl {
($message_id:literal) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER.get().unwrap(), $message_id)
}};
($message_id:literal, $($args:expr),*) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER.get().unwrap(), $message_id, $($args), *)
}};
}
// Get the `Localizer` to be used for localizing this library.
pub fn localizer() -> Box<dyn Localizer> {
LANGUAGE_LOADER.get_or_init(|| {
let loader: FluentLanguageLoader = fluent_language_loader!();
loader
@ -19,23 +39,12 @@ lazy_static::lazy_static! {
.expect("Error while loading fallback language");
loader
};
}
});
#[macro_export]
macro_rules! fl {
($message_id:literal) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id)
}};
($message_id:literal, $($args:expr),*) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *)
}};
}
// Get the `Localizer` to be used for localizing this library.
pub fn localizer() -> Box<dyn Localizer> {
Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations))
Box::from(DefaultLocalizer::new(
LANGUAGE_LOADER.get().unwrap(),
&Localizations,
))
}
pub fn localize() {
@ -46,3 +55,22 @@ pub fn localize() {
eprintln!("Error while loading language for App List {}", error);
}
}
pub fn sorter() -> &'static Collator {
LANGUAGE_SORTER.get_or_init(|| {
let mut options = CollatorOptions::new();
options.numeric = Some(Numeric::On);
let localizer = localizer();
let language_loader = localizer.language_loader();
DataLocale::from_str(&language_loader.current_language().to_string())
.or_else(|_| DataLocale::from_str(&language_loader.fallback_language().to_string()))
.ok()
.and_then(|locale| Collator::try_new(&locale, options).ok())
.or_else(|| {
let locale = DataLocale::from_str("en-US").expect("en-US is a valid BCP-47 tag");
Collator::try_new(&locale, options).ok()
})
.expect("Creating a collator from the system's current language, the fallback language, or American English should succeed")
})
}

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,
)]
}

View file

@ -5,7 +5,7 @@ use cosmic::{
widget::menu::{self, key_bind::KeyBind, ItemHeight, ItemWidth, MenuBar},
Element,
};
use std::collections::HashMap;
use std::{collections::HashMap, path::PathBuf};
use crate::{fl, Action, Config, ConfigState, Message};
@ -13,18 +13,20 @@ pub fn menu_bar<'a>(
config: &Config,
config_state: &ConfigState,
key_binds: &HashMap<KeyBind, Action>,
projects: &Vec<(String, PathBuf)>,
) -> Element<'a, Message> {
let home_dir_opt = dirs::home_dir();
let format_path = |url: &url::Url| -> String {
match url.to_file_path() {
Ok(path) => {
if let Some(home_dir) = &home_dir_opt {
if let Ok(part) = path.strip_prefix(home_dir) {
return format!("~/{}", part.display());
}
}
path.display().to_string()
let format_path = |path: &PathBuf| -> String {
if let Some(home_dir) = &home_dir_opt {
if let Ok(part) = path.strip_prefix(home_dir) {
return format!("~/{}", part.display());
}
}
path.display().to_string()
};
let format_url = |url: &url::Url| -> String {
match url.to_file_path() {
Ok(path) => format_path(&path),
Err(()) => url.to_string(),
}
};
@ -32,19 +34,27 @@ pub fn menu_bar<'a>(
let mut recent_files = Vec::with_capacity(config_state.recent_files.len());
for (i, path) in config_state.recent_files.iter().enumerate() {
recent_files.push(menu::Item::Button(
format_path(path),
format_url(path),
Action::FileOpenRecent(i),
));
}
let mut recent_folders = Vec::with_capacity(config_state.recent_folders.len());
for (i, path) in config_state.recent_folders.iter().enumerate() {
recent_folders.push(menu::Item::Button(
let mut recent_projects = Vec::with_capacity(config_state.recent_projects.len());
for (i, path) in config_state.recent_projects.iter().enumerate() {
recent_projects.push(menu::Item::Button(
format_path(path),
Action::FolderOpenRecent(i),
));
}
let mut close_projects = Vec::with_capacity(projects.len());
for (folder_i, (name, _path)) in projects.iter().enumerate() {
close_projects.push(menu::Item::Button(
name.clone(),
Action::FolderClose(folder_i),
));
}
MenuBar::new(vec![menu::Tree::with_children(
menu::root(fl!("file")),
menu::items(
@ -54,12 +64,10 @@ pub fn menu_bar<'a>(
menu::Item::Folder(fl!("open-recent-media"), recent_files),
menu::Item::Button(fl!("close-file"), Action::FileClose),
menu::Item::Divider,
/*TODO: folders
menu::Item::Button(fl!("open-media-folder"), Action::FolderOpen),
menu::Item::Folder(fl!("open-recent-media-folder"), recent_folders),
menu::Item::Folder(fl!("close-media-folder"), close_folders),
menu::Item::Folder(fl!("open-recent-media-folder"), recent_projects),
menu::Item::Folder(fl!("close-media-folder"), close_projects),
menu::Item::Divider,
*/
menu::Item::Button(fl!("quit"), Action::WindowClose),
],
),

99
src/project.rs Normal file
View file

@ -0,0 +1,99 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::widget;
use std::{
cmp::Ordering,
fs, io,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProjectNode {
Folder {
name: String,
path: PathBuf,
open: bool,
root: bool,
},
File {
name: String,
path: PathBuf,
},
}
impl ProjectNode {
pub fn new<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let path = fs::canonicalize(path)?;
let name = path
.file_name()
.ok_or(io::Error::new(
io::ErrorKind::Other,
format!("path {:?} has no file name", path),
))?
.to_str()
.ok_or(io::Error::new(
io::ErrorKind::Other,
format!("path {:?} is not valid UTF-8", path),
))?
.to_string();
Ok(if path.is_dir() {
Self::Folder {
path,
name,
open: false,
root: false,
}
} else {
Self::File { path, name }
})
}
pub fn icon(&self, size: u16) -> Option<widget::icon::Icon> {
match self {
//TODO: different icon for project root?
Self::Folder { open, .. } => Some(if *open {
widget::icon::from_name("go-down-symbolic")
.size(size)
.into()
} else {
widget::icon::from_name("go-next-symbolic")
.size(size)
.into()
}),
Self::File { .. } => None,
}
}
pub fn name(&self) -> &str {
match self {
Self::Folder { name, .. } => name,
Self::File { name, .. } => name,
}
}
}
impl Ord for ProjectNode {
fn cmp(&self, other: &Self) -> Ordering {
match self {
// Folders are always before files
Self::Folder { .. } => {
if let Self::File { .. } = other {
return Ordering::Less;
}
}
// Files are always after folders
Self::File { .. } => {
if let Self::Folder { .. } = other {
return Ordering::Greater;
}
}
}
crate::localize::sorter().compare(self.name(), other.name())
}
}
impl PartialOrd for ProjectNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}