cosmic-files/src/config.rs
Lionel DARNIS 4b5fcfe5d7 merge: epoch-1.9.0 d'upstream
# Conflicts:
#	Cargo.lock
#	src/menu.rs
2026-09-24 20:51:53 +02:00

580 lines
17 KiB
Rust

// SPDX-License-Identifier: GPL-3.0-only
use std::any::TypeId;
use std::num::NonZeroU16;
use std::path::PathBuf;
use cosmic::cosmic_config::cosmic_config_derive::CosmicConfigEntry;
use cosmic::cosmic_config::{self, CosmicConfigEntry};
use cosmic::iced::Subscription;
use cosmic::{Application, theme};
use serde::{Deserialize, Serialize};
use crate::FxOrderMap;
use crate::app::App;
use crate::tab::{HeadingOptions, Location, View};
pub use crate::context_action::{ContextActionPreset, ContextActionSelection};
pub const CONFIG_VERSION: u64 = 1;
// Default icon sizes
pub const ICON_SIZE_LIST: u16 = 32;
pub const ICON_SIZE_LIST_CONDENSED: u16 = 48;
pub const ICON_SIZE_GRID: u16 = 64;
// TODO: 5 is an arbitrary number. Maybe there's a better icon size max
pub const ICON_SCALE_MAX: u16 = 5;
macro_rules! percent {
($perc:expr, $pixel:ident) => {
(($perc.get() as f32 * $pixel as f32) / 100.).clamp(1., ($pixel * ICON_SCALE_MAX) as _)
};
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum AppTheme {
Dark,
Light,
System,
}
impl AppTheme {
pub fn theme(&self) -> theme::Theme {
match self {
Self::Dark => {
let mut t = theme::system_dark();
t.theme_type.prefer_dark(Some(true));
t
}
Self::Light => {
let mut t = theme::system_light();
t.theme_type.prefer_dark(Some(false));
t
}
Self::System => theme::system_preference(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Favorite {
Home,
Documents,
Downloads,
Music,
Pictures,
Videos,
Path(PathBuf),
Network {
uri: String,
name: String,
path: PathBuf,
},
/// A path with a custom name chosen by the user
Named {
path: PathBuf,
name: String,
},
}
impl Favorite {
pub fn from_path(path: PathBuf) -> Self {
// Ensure that special folders are handled properly
[
Self::Home,
Self::Documents,
Self::Downloads,
Self::Music,
Self::Pictures,
Self::Videos,
]
.into_iter()
.find(|fav| fav.path_opt().as_ref() == Some(&path))
.unwrap_or(Self::Path(path))
}
pub fn path_opt(&self) -> Option<PathBuf> {
match self {
Self::Home => dirs::home_dir(),
Self::Documents => dirs::document_dir(),
Self::Downloads => dirs::download_dir(),
Self::Music => dirs::audio_dir(),
Self::Pictures => dirs::picture_dir(),
Self::Videos => dirs::video_dir(),
Self::Path(path) => Some(path.clone()),
Self::Network { path, .. } => Some(path.clone()),
Self::Named { path, .. } => Some(path.clone()),
}
}
/// Name shown in the sidebar, or `None` if the path has no usable file name
pub fn display_name(&self) -> Option<String> {
match self {
Self::Home => Some(crate::fl!("home")),
Self::Named { name, .. } | Self::Network { name, .. } => Some(name.clone()),
_ => self
.path_opt()?
.file_name()
.and_then(|x| x.to_str())
.map(ToString::to_string),
}
}
/// Return this favorite with a custom sidebar label chosen by the user
pub fn with_label(&self, name: &str) -> Self {
match self {
Self::Network { uri, path, .. } => Self::Network {
uri: uri.clone(),
name: name.to_string(),
path: path.clone(),
},
other => match other.path_opt() {
Some(path) => Self::Named {
path,
name: name.to_string(),
},
None => other.clone(),
},
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum TypeToSearch {
Recursive,
EnterPath,
SelectByPrefix,
}
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct State {
pub sort_names: FxOrderMap<String, (HeadingOptions, bool)>,
pub dialog_sort_name: (HeadingOptions, bool),
}
impl Default for State {
fn default() -> Self {
Self {
sort_names: FxOrderMap::from_iter(dirs::download_dir().into_iter().map(|dir| {
(
Location::Path(dir).normalize().to_string(),
(HeadingOptions::Modified, false),
)
})),
dialog_sort_name: (HeadingOptions::Modified, false),
}
}
}
impl State {
pub fn load() -> (Option<cosmic_config::Config>, Self) {
match cosmic_config::Config::new_state(App::APP_ID, CONFIG_VERSION) {
Ok(config_handler) => {
let config = match Self::get_entry(&config_handler) {
Ok(ok) => ok,
Err((errs, config)) => {
log::info!("errors loading config: {errs:?}");
config
}
};
(Some(config_handler), config)
}
Err(err) => {
log::error!("failed to create config handler: {err}");
(None, Self::default())
}
}
}
pub fn subscription() -> Subscription<cosmic_config::Update<Self>> {
struct ConfigSubscription;
cosmic_config::config_state_subscription(
TypeId::of::<ConfigSubscription>(),
App::APP_ID.into(),
CONFIG_VERSION,
)
}
}
#[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct Config {
pub app_theme: AppTheme,
pub dialog: DialogConfig,
pub desktop: DesktopConfig,
pub context_actions: Vec<ContextActionPreset>,
pub thumb_cfg: ThumbCfg,
pub favorites: Vec<Favorite>,
pub show_details: bool,
pub show_recents: bool,
pub tab: TabConfig,
/// Yoda phase 3: Dolphin-style quick actions toolbar. An ordered list
/// of enabled buttons — position in the vec drives the toolbar order.
/// Reorder in Settings via drag-drop; items not in the vec are
/// hidden. Default = the minimal-6 set from phase 1.
pub toolbar: Vec<ToolbarAction>,
pub type_to_search: TypeToSearch,
}
impl Config {
pub fn load() -> (Option<cosmic_config::Config>, Self) {
match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) {
Ok(config_handler) => {
let config = match Self::get_entry(&config_handler) {
Ok(ok) => ok,
Err((errs, config)) => {
log::info!("errors loading config: {errs:?}");
config
}
};
(Some(config_handler), config)
}
Err(err) => {
log::error!("failed to create config handler: {err}");
(None, Self::default())
}
}
}
pub fn subscription() -> Subscription<cosmic_config::Update<Self>> {
struct ConfigSubscription;
cosmic_config::config_subscription(
TypeId::of::<ConfigSubscription>(),
App::APP_ID.into(),
CONFIG_VERSION,
)
}
/// Construct tab config for dialog
pub const fn dialog_tab(&self) -> TabConfig {
TabConfig {
folders_first: self.dialog.folders_first,
icon_sizes: self.dialog.icon_sizes,
military_time: self.tab.military_time,
show_hidden: self.dialog.show_hidden,
single_click: false,
view: self.dialog.view,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
app_theme: AppTheme::System,
desktop: DesktopConfig::default(),
dialog: DialogConfig::default(),
context_actions: Vec::new(),
thumb_cfg: ThumbCfg::default(),
favorites: vec![
Favorite::Home,
Favorite::Documents,
Favorite::Downloads,
Favorite::Music,
Favorite::Pictures,
Favorite::Videos,
],
show_details: false,
show_recents: true,
tab: TabConfig::default(),
toolbar: default_toolbar(),
type_to_search: TypeToSearch::Recursive,
}
}
}
/// Yoda phase 3: ordered enum of quick-action toolbar buttons.
/// The Config stores `Vec<ToolbarAction>` so the user can pick BOTH
/// visibility (just include/exclude the variant) AND order (position in
/// the vec). Drag-drop reorder in the Settings page moves items around.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub enum ToolbarAction {
LocationUp,
Reload,
NewFolder,
NewFile,
Rename,
Delete,
Cut,
Copy,
Paste,
ToggleShowHidden,
OpenTerminal,
}
impl ToolbarAction {
/// Stable list of every supported action. Ordered roughly by logical
/// grouping (location → create/edit → clipboard → view/misc) so that
/// the default enabled set follows a sensible shape and the Settings
/// row for a not-yet-enabled action lands in a predictable spot.
pub const ALL: &'static [Self] = &[
Self::LocationUp,
Self::Reload,
Self::NewFolder,
Self::NewFile,
Self::Rename,
Self::Delete,
Self::Cut,
Self::Copy,
Self::Paste,
Self::ToggleShowHidden,
Self::OpenTerminal,
];
/// u8 discriminant used to carry the action over a DnD mime payload.
pub const fn to_u8(self) -> u8 {
match self {
Self::LocationUp => 0,
Self::Reload => 1,
Self::NewFolder => 2,
Self::NewFile => 3,
Self::Rename => 4,
Self::Delete => 5,
Self::Cut => 6,
Self::Copy => 7,
Self::Paste => 8,
Self::ToggleShowHidden => 9,
Self::OpenTerminal => 10,
}
}
pub const fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::LocationUp),
1 => Some(Self::Reload),
2 => Some(Self::NewFolder),
3 => Some(Self::NewFile),
4 => Some(Self::Rename),
5 => Some(Self::Delete),
6 => Some(Self::Cut),
7 => Some(Self::Copy),
8 => Some(Self::Paste),
9 => Some(Self::ToggleShowHidden),
10 => Some(Self::OpenTerminal),
_ => None,
}
}
}
/// Default set shown on a fresh install — same "minimal 6" as phase 1/2.
pub fn default_toolbar() -> Vec<ToolbarAction> {
vec![
ToolbarAction::NewFolder,
ToolbarAction::Rename,
ToolbarAction::Delete,
ToolbarAction::Cut,
ToolbarAction::Copy,
ToolbarAction::Paste,
]
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]
#[serde(default)]
pub struct DesktopConfig {
pub grid_spacing: NonZeroU16,
pub icon_size: NonZeroU16,
pub show_content: bool,
pub show_mounted_drives: bool,
pub show_trash: bool,
}
impl Default for DesktopConfig {
fn default() -> Self {
Self {
grid_spacing: 100.try_into().unwrap(),
icon_size: 100.try_into().unwrap(),
show_content: true,
show_mounted_drives: false,
show_trash: false,
}
}
}
impl DesktopConfig {
pub fn grid_spacing_for(&self, space: u16) -> u16 {
percent!(self.grid_spacing, space) as _
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]
#[serde(default)]
pub struct DialogConfig {
/// Show folders before files
pub folders_first: bool,
/// Icon zoom
pub icon_sizes: IconSizes,
/// Show details sidebar
pub show_details: bool,
/// Show hidden files and folders
pub show_hidden: bool,
/// Selected view, grid or list
pub view: View,
}
impl Default for DialogConfig {
fn default() -> Self {
Self {
folders_first: false,
icon_sizes: IconSizes::default(),
show_details: true,
show_hidden: false,
view: View::List,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]
#[serde(default)]
pub struct ThumbCfg {
pub jobs: NonZeroU16,
pub max_mem_mb: NonZeroU16,
pub max_size_mb: NonZeroU16,
}
impl Default for ThumbCfg {
fn default() -> Self {
Self {
jobs: 4.try_into().unwrap(),
max_mem_mb: 2000.try_into().unwrap(),
max_size_mb: 64.try_into().unwrap(),
}
}
}
/// Global and local [`crate::tab::Tab`] config.
///
/// [`TabConfig`] contains options that are passed to each instance of [`crate::tab::Tab`].
/// These options are set globally through the main config, but each tab may change options
/// locally. Local changes aren't saved to the main config.
#[derive(Clone, Copy, Debug, Eq, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]
#[serde(default)]
pub struct TabConfig {
/// Show folders before files
pub folders_first: bool,
/// Icon zoom
pub icon_sizes: IconSizes,
#[serde(skip)]
/// 24 hour clock; this is neither serialized nor deserialized because we use the user's global
/// preference rather than save it
pub military_time: bool,
/// Show hidden files and folders
pub show_hidden: bool,
/// Single click to open
pub single_click: bool,
/// Selected view, grid or list
pub view: View,
}
impl Default for TabConfig {
fn default() -> Self {
Self {
folders_first: true,
icon_sizes: IconSizes::default(),
military_time: false,
show_hidden: false,
single_click: false,
view: View::List,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]
#[serde(default)]
pub struct IconSizes {
pub list: NonZeroU16,
pub grid: NonZeroU16,
}
impl Default for IconSizes {
fn default() -> Self {
Self {
list: 100.try_into().unwrap(),
grid: 100.try_into().unwrap(),
}
}
}
impl IconSizes {
pub fn list(&self) -> u16 {
percent!(self.list, ICON_SIZE_LIST) as _
}
pub fn list_condensed(&self) -> u16 {
percent!(self.list, ICON_SIZE_LIST_CONDENSED) as _
}
pub fn grid(&self) -> u16 {
percent!(self.grid, ICON_SIZE_GRID) as _
}
}
pub const TIME_CONFIG_ID: &str = "com.system76.CosmicAppletTime";
#[derive(Debug, Default, Clone, CosmicConfigEntry, PartialEq, Eq)]
#[version = 1]
pub struct TimeConfig {
pub military_time: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn favorite_with_label_converts_path_to_named() {
let favorite = Favorite::Path(PathBuf::from("/some/dir"));
assert_eq!(
favorite.with_label("Custom"),
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_with_label_updates_network_in_place() {
let favorite = Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "example.com".to_string(),
path: PathBuf::from("/run/mount/example"),
};
assert_eq!(
favorite.with_label("Custom"),
Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "Custom".to_string(),
path: PathBuf::from("/run/mount/example"),
}
);
}
#[test]
fn favorite_with_label_converts_special_folder_to_named() {
let home = dirs::home_dir().unwrap();
assert_eq!(
Favorite::Home.with_label("Custom"),
Favorite::Named {
path: home,
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_display_name() {
assert_eq!(
Favorite::Path(PathBuf::from("/some/dir")).display_name(),
Some("dir".to_string())
);
assert_eq!(Favorite::Path(PathBuf::from("/")).display_name(), None);
assert_eq!(
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
.display_name(),
Some("Custom".to_string())
);
}
}