Migrates the config model from the phase-2 bag-of-bools (ToolbarItems)
to an ordered Vec<ToolbarAction> so the user can pick BOTH the set of
buttons AND their order in the toolbar.
Config (config.rs):
- new ToolbarAction enum with 11 variants (LocationUp, Reload,
NewFolder, NewFile, Rename, Delete, Cut, Copy, Paste,
ToggleShowHidden, OpenTerminal) + to_u8/from_u8 for DnD payload
- Config.toolbar: Vec<ToolbarAction>, default = default_toolbar()
(NewFolder, Rename, Delete, Cut, Copy, Paste — same 6 as phase 2)
Rendering (view()):
- iterate self.config.toolbar in order and emit a tooltip'd icon button
per entry via the new toolbar_action_ui(action) helper shared with
the Settings page. Paste stays disabled when clipboard empty.
- No hardcoded groups or auto-dividers anymore — order is 100% user.
Settings page (toolbar_settings_section):
- two stacked lists:
* 'Toolbar': currently-enabled actions in their Vec order. Each row
is wrapped in dnd_source (drags a ToolbarActionPayload carrying
the enum discriminant) + dnd_destination (accepts drops from other
rows, fires Message::ToolbarReorder { src, target } to move src
before target in the Vec). A list-drag-handle icon + a minus button
(ToolbarRemove) per row.
* 'Available': actions not yet enabled, each with a plus button
(ToolbarAdd) that pushes to the end of the Vec.
- 'Reset to defaults' button at the bottom (ToolbarReset).
DnD infra (app.rs top):
- TOOLBAR_MIME constant: 'application/x-cosmic-files-toolbar-action'
- ToolbarActionPayload(u8) with AsMimeTypes + AllowedMimeTypes +
TryFrom<(Vec<u8>, String)> impls — single-byte wire format matching
the enum discriminant.
Messages:
- ToolbarAdd(ToolbarAction) — append to toolbar vec if absent
- ToolbarRemove(ToolbarAction)
- ToolbarReorder { src, target } — remove src, reinsert before target
- ToolbarReset — restore default_toolbar()
i18n (en + fr):
- new keys: toolbar-available, toolbar-empty-hint, toolbar-reset
Migration: existing installs with a phase-2 ToolbarItems struct in
their config will error at load time (different shape); cosmic_config
falls back to Self::default() which gives the phase-2 minimal-6 set —
a safe reset rather than a broken partial read.
480 lines
13 KiB
Rust
480 lines
13 KiB
Rust
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
use std::{any::TypeId, num::NonZeroU16, path::PathBuf};
|
|
|
|
use cosmic::{
|
|
Application,
|
|
cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry},
|
|
iced::Subscription,
|
|
theme,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
FxOrderMap,
|
|
app::App,
|
|
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,
|
|
},
|
|
}
|
|
|
|
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()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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)>,
|
|
}
|
|
|
|
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),
|
|
)
|
|
})),
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|