From c6a2fbd4f47bce4042458fe04c9b4e5feb991ef8 Mon Sep 17 00:00:00 2001 From: Carl Geib Date: Sat, 30 Dec 2023 10:59:47 +1300 Subject: [PATCH] Initial Global Hot Key Support There is a bit of restructuring here, as well as basic support for global hot keys. I have only tested it on Windows 11. It should in theory work on most "modern" versions of Windows, and Linux with X11, as well as possibly MacOS. I don't have a Mac to test, and I haven't tested it on X11 yet. Wayland support is waiting on this bug to be resolved before it will work: https://github.com/tauri-apps/global-hotkey/issues/28 --- Cargo.lock | 39 +++++ Cargo.toml | 1 + src/main.rs | 287 +++++++++++++++++++++++------------- src/settings.rs | 351 ++++++++++++++++++++++----------------------- ui/appwindow.slint | 2 + 5 files changed, 403 insertions(+), 277 deletions(-) mode change 100644 => 100755 src/settings.rs diff --git a/Cargo.lock b/Cargo.lock index bc99c3f..8fe956a 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,6 +509,9 @@ name = "bitflags" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +dependencies = [ + "serde", +] [[package]] name = "block" @@ -937,6 +940,16 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7059fff8937831a9ae6f0fe4d658ffabf58f2ca96aa9dec1c889f936f705f216" +[[package]] +name = "crossbeam-channel" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.3" @@ -1639,6 +1652,20 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "global-hotkey" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d505f007b733fc3b3261ca0710e7e689411422ace5f5b2a63a14d8596159db23" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "once_cell", + "thiserror", + "windows-sys 0.52.0", + "x11-dl", +] + [[package]] name = "glow" version = "0.13.0" @@ -2220,6 +2247,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.4.1", + "serde", + "unicode-segmentation", +] + [[package]] name = "khronos_api" version = "3.1.0" @@ -3976,6 +4014,7 @@ dependencies = [ "anyhow", "directories", "etcetera", + "global-hotkey", "hex_color", "i-slint-backend-winit", "open", diff --git a/Cargo.toml b/Cargo.toml index 516f3af..2050fe0 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ walkdir = "2.4" etcetera = "0.8.0" directories = "5.0.1" single-instance = "0.3.3" +global-hotkey = "0.4.1" [target.'cfg(windows)'.dependencies] diff --git a/src/main.rs b/src/main.rs index c45997d..7ceca2f 100755 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use settings::{GlobalShortcuts, JsonSettings}; use single_instance::SingleInstance; -use slint::{Color, ModelRc, Timer, TimerMode, VecModel}; +use slint::{Color, JoinHandle, ModelRc, Timer, TimerMode, VecModel}; use std::{ fs::File, io::{BufReader, Read}, @@ -23,6 +23,12 @@ use std::{ use tray_item::{IconSource, TrayItem}; use walkdir::WalkDir; +use global_hotkey::GlobalHotKeyEvent; +use global_hotkey::{ + hotkey::{Code, HotKey, Modifiers}, + GlobalHotKeyManager, +}; + mod settings; slint::include_modules!(); @@ -157,31 +163,8 @@ fn color_to_hex_string(color: slint::Color) -> String { ) } -fn update_prg_svg(bg_clr: slint::Color, fg_clr: slint::Color, rem_per: f32) -> slint::Image { - // stroke-dasharray="100, 100" - slint::Image::load_from_svg_data( - PROG_BYTES - .replace( - "stroke:#9ca5b5", - &format!("stroke:{}", color_to_hex_string(bg_clr)), - ) - //for now I'll just set this to the focus round, but it actually depends on what timer is active - .replace( - "stroke:#ff4e4d", - &format!("stroke:{}", color_to_hex_string(fg_clr)), - ) - .replace( - "stroke-dasharray=\"100, 100\"", - &format!("stroke-dasharray=\"{}, 100\"", rem_per), - ) - .as_bytes(), - ) - .unwrap() -} - impl Main { - fn load_settings(&self) { - let settings = settings::load_settings(); + fn set_settings(&self, settings: &JsonSettings) { self.global::() .set_always_on_top(settings.always_on_top); self.global::() @@ -197,7 +180,8 @@ impl Main { .set_min_to_tray_on_close(settings.min_to_tray_on_close); self.global::() .set_notifications(settings.notifications); - self.global::().set_theme(settings.theme.into()); + self.global::() + .set_theme((&settings.theme).into()); self.global::() .set_tick_sounds(settings.tick_sounds); self.global::() @@ -243,13 +227,70 @@ impl Main { } } +struct Tomotroid { + pub window: Main, + settings: JsonSettings, + reset: HotKey, + skip: HotKey, + toggle: HotKey, + ghk_manager: GlobalHotKeyManager, +} + +impl Tomotroid { + fn new() -> Self { + let settings = settings::load_settings(); + + let ghk_manager = GlobalHotKeyManager::new().unwrap(); + let toggle = HotKey::new(Some(Modifiers::CONTROL), Code::F1); + let reset = HotKey::new(Some(Modifiers::CONTROL), Code::F2); + let skip = HotKey::new(Some(Modifiers::CONTROL), Code::F3); + + ghk_manager.register(toggle).unwrap(); + ghk_manager.register(reset).unwrap(); + ghk_manager.register(skip).unwrap(); + + let window = Main::new().unwrap(); + window.set_settings(&settings); + + Self { + window, + settings, + reset, + skip, + toggle, + ghk_manager, + } + } + + fn update_prg_svg(bg_clr: slint::Color, fg_clr: slint::Color, rem_per: f32) -> slint::Image { + slint::Image::load_from_svg_data( + PROG_BYTES + .replace( + "stroke:#9ca5b5", + &format!("stroke:{}", color_to_hex_string(bg_clr)), + ) + .replace( + "stroke:#ff4e4d", + &format!("stroke:{}", color_to_hex_string(fg_clr)), + ) + .replace( + "stroke-dasharray=\"100, 100\"", + &format!("stroke-dasharray=\"{}, 100\"", rem_per), + ) + .as_bytes(), + ) + .unwrap() + } +} + fn main() -> Result<()> { let instance = SingleInstance::new("org.vadoola.tomotroid").unwrap(); - assert!(instance.is_single()); if !instance.is_single() { - return Err(anyhow::anyhow!("Only one instance of Tomotroid is allowed to run")); + return Err(anyhow::anyhow!( + "Only one instance of Tomotroid is allowed to run" + )); } - + //TODO: I'm not seeing an obvious way to mimic the Pomotroid behavoir //where it just minimizes or restores by clicking the tray icon //because I don't see any way to capture when the tray icon is clicked @@ -286,7 +327,7 @@ fn main() -> Result<()> { }) .unwrap(); - let quit_tx = tray_tx; //.clone(); + let quit_tx = tray_tx; tray.add_menu_item("Quit", move || { quit_tx.send(TrayMsg::Quit).unwrap(); }) @@ -295,15 +336,16 @@ fn main() -> Result<()> { slint::platform::set_platform(Box::new(i_slint_backend_winit::Backend::new().unwrap())) .unwrap(); - let main = Main::new()?; + let tomotroid = Tomotroid::new(); - main.load_settings(); - let set_bool_handle = main.as_weak(); + let set_bool_handle = tomotroid.window.as_weak(); //if this is being called when the value changes....why is it passing me the old value? //I guess this is being called instead of the Touch Area's callback? So the value isn't updating //until I do it here? But how will that work with the sliders? I can't just invert the value //like I can with the bools. - main.global::() + tomotroid + .window + .global::() .on_bool_changed(move |set_type, val| { let set_handle = set_bool_handle.upgrade().unwrap(); match set_type { @@ -349,8 +391,42 @@ fn main() -> Result<()> { set_handle.save_settings(); }); - let set_int_handle = main.as_weak(); - main.global::() + let ghk_handle = tomotroid.window.as_weak(); + let ghk_receiver = GlobalHotKeyEvent::receiver(); + let _thread = std::thread::spawn(move || loop { + let ghk_handle2 = ghk_handle.clone(); + slint::invoke_from_event_loop(move || { + if let Ok(event) = ghk_receiver.try_recv() { + if event.state() == global_hotkey::HotKeyState::Released { + let ghk_handle2 = ghk_handle2.upgrade().unwrap(); + match event.id() { + tg_id if tg_id == tomotroid.toggle.id() => { + let action = if ghk_handle2.get_running() { + TimerAction::Stop + } else { + TimerAction::Start + }; + ghk_handle2.invoke_action_timer(action); + } + rst_id if rst_id == tomotroid.reset.id() => { + ghk_handle2.invoke_action_timer(TimerAction::Reset) + } + skp_id if skp_id == tomotroid.skip.id() => { + ghk_handle2.invoke_action_timer(TimerAction::Skip) + } + _ => {} + } + } + } + }) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + }); + + let set_int_handle = tomotroid.window.as_weak(); + tomotroid + .window + .global::() .on_int_changed(move |set_type, val| { let set_handle = set_int_handle.upgrade().unwrap(); match set_type { @@ -378,8 +454,8 @@ fn main() -> Result<()> { // settings not currently being saved // * Global Shortcuts (which can't even be set currently) - let close_handle = main.as_weak(); - main.on_close_window(move || { + let close_handle = tomotroid.window.as_weak(); + tomotroid.window.on_close_window(move || { let close_handle = close_handle.upgrade().unwrap(); close_handle.save_settings(); @@ -390,16 +466,16 @@ fn main() -> Result<()> { //i_slint_backend_winit::WinitWindowAccessor::with_winit_window(min_handle.window(), |win| win.set_visible(false)); }); - let min_handle = main.as_weak(); - main.on_minimize_window(move || { + let min_handle = tomotroid.window.as_weak(); + tomotroid.window.on_minimize_window(move || { let min_handle = min_handle.upgrade().unwrap(); i_slint_backend_winit::WinitWindowAccessor::with_winit_window(min_handle.window(), |win| { win.set_minimized(true); }); }); - let move_handle = main.as_weak(); - main.on_move_window(move || { + let move_handle = tomotroid.window.as_weak(); + tomotroid.window.on_move_window(move || { let move_handle = move_handle.upgrade().unwrap(); i_slint_backend_winit::WinitWindowAccessor::with_winit_window( move_handle.window(), @@ -407,7 +483,7 @@ fn main() -> Result<()> { ); }); - let tray_handle = main.as_weak(); + let tray_handle = tomotroid.window.as_weak(); let _tray_rec_thread = std::thread::spawn(move || loop { match tray_rx.recv() { Ok(TrayMsg::MinRes) => { @@ -435,12 +511,14 @@ fn main() -> Result<()> { } }); - main.global::().on_hl_clicked(|url| { + tomotroid.window.global::().on_hl_clicked(|url| { open::that(url.as_str()).unwrap(); }); - let thm_handle = main.as_weak(); - main.global::() + let thm_handle = tomotroid.window.as_weak(); + tomotroid + .window + .global::() .on_theme_changed(move |idx, theme| { let thm_handle = thm_handle.upgrade().unwrap(); thm_handle.global::().set_theme(theme.name); @@ -480,7 +558,7 @@ fn main() -> Result<()> { let rem_per = thm_handle.get_remaining_time() as f32 / thm_handle.get_current_timer() as f32 * 100.0; - thm_handle.set_circ_progress(update_prg_svg( + thm_handle.set_circ_progress(Tomotroid::update_prg_svg( theme.background_lightest.color(), theme.focus_round.color(), rem_per, @@ -517,63 +595,67 @@ fn main() -> Result<()> { thm_handle.global::().set_accent(theme.accent); }); - let load_theme_handle = main.as_weak(); - main.global::().on_load_themes(move || { - let load_theme_handle = load_theme_handle.unwrap(); - //let mut theme_dir = std::env::current_dir().unwrap(); - let mut theme_dir = std::path::PathBuf::from(settings::get_dir().unwrap()); - theme_dir.push("themes"); - let themes: Vec = { - //I'm thinking I need to move this into the settings modules maybe? - let mut themes: Vec = WalkDir::new(theme_dir) - .into_iter() - .filter(|e| { - return e.as_ref().map_or(false, |f| { - f.file_name() - .to_str() - .map(|s| s.to_lowercase().ends_with(".json")) - .unwrap_or(false) - }); - }) - .filter_map(|e| { - e.map(|e| { - let reader = BufReader::new(File::open(e.path()).unwrap()); - let theme = std::io::read_to_string(reader).unwrap(); - let theme = serde_json::from_str::(&theme) - .unwrap() - .into(); - theme + let load_theme_handle = tomotroid.window.as_weak(); + tomotroid + .window + .global::() + .on_load_themes(move || { + let load_theme_handle = load_theme_handle.unwrap(); + let mut theme_dir = std::path::PathBuf::from(settings::get_dir().unwrap()); + theme_dir.push("themes"); + let themes: Vec = { + //I'm thinking I need to move this into the settings modules maybe? + let mut themes: Vec = WalkDir::new(theme_dir) + .into_iter() + .filter(|e| { + return e.as_ref().map_or(false, |f| { + f.file_name() + .to_str() + .map(|s| s.to_lowercase().ends_with(".json")) + .unwrap_or(false) + }); }) - .ok() - }) - .collect(); - if themes.is_empty() { - themes.push((*settings::default_theme()).clone().into()) - } - themes.sort_by(|a, b| a.name.partial_cmp(&b.name).unwrap()); - themes - }; - - let thm_name = load_theme_handle.global::().get_theme(); - let (idx, cur_theme) = themes - .iter() - .enumerate() - .find(|(_, thm)| thm.name == thm_name) - .unwrap(); + .filter_map(|e| { + e.map(|e| { + let reader = BufReader::new(File::open(e.path()).unwrap()); + let theme = std::io::read_to_string(reader).unwrap(); + serde_json::from_str::(&theme) + .unwrap() + .into() + }) + .ok() + }) + .collect(); + if themes.is_empty() { + themes.push((*settings::default_theme()).clone().into()) + } + themes.sort_by(|a, b| a.name.partial_cmp(&b.name).unwrap()); + themes + }; + + let thm_name = load_theme_handle.global::().get_theme(); + let (idx, cur_theme) = themes + .iter() + .enumerate() + .find(|(_, thm)| thm.name == thm_name) + .unwrap(); - load_theme_handle - .global::() - .invoke_theme_changed(idx as i32, cur_theme.clone()); - let model: Rc> = Rc::new(VecModel::from(themes)); + load_theme_handle + .global::() + .invoke_theme_changed(idx as i32, cur_theme.clone()); + let model: Rc> = Rc::new(VecModel::from(themes)); - ModelRc::from(model.clone()) - }); + ModelRc::from(model.clone()) + }); - main.global::().invoke_load_themes(); + tomotroid + .window + .global::() + .invoke_load_themes(); let timer = Timer::default(); - let timer_handle = main.as_weak(); - main.on_action_timer(move |action| { + let timer_handle = tomotroid.window.as_weak(); + tomotroid.window.on_action_timer(move |action| { let tmrstrt_handle = timer_handle.clone(); let timer_handle = timer_handle.upgrade().unwrap(); match action { @@ -601,7 +683,7 @@ fn main() -> Result<()> { } }; - tmrstrt_handle.set_circ_progress(update_prg_svg( + tmrstrt_handle.set_circ_progress(Tomotroid::update_prg_svg( tmrstrt_handle .global::() .get_background_lightest() @@ -631,7 +713,7 @@ fn main() -> Result<()> { } }; - timer_handle.set_circ_progress(update_prg_svg( + timer_handle.set_circ_progress(Tomotroid::update_prg_svg( timer_handle .global::() .get_background_lightest() @@ -641,9 +723,12 @@ fn main() -> Result<()> { )); //need to be updating the running status from Rust not slint } + TimerAction::Skip => { + println!("Skip pressed"); + } } }); - main.run()?; + tomotroid.window.run()?; Ok(()) } diff --git a/src/settings.rs b/src/settings.rs old mode 100644 new mode 100755 index f44db46..7878e70 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,176 +1,175 @@ -use directories::ProjectDirs; -use serde::{Deserialize, Serialize}; -use std::cell::OnceCell; -use std::fs::{File, OpenOptions}; -use std::io::{BufReader, BufWriter, Write}; -use std::path::Path; -use std::sync::{Arc, OnceLock}; - -use crate::JsonThemeTemp; - -slint::include_modules!(); - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct JsonSettings { - pub always_on_top: bool, - pub auto_start_break_timer: bool, - pub auto_start_work_timer: bool, - pub break_always_on_top: bool, - pub global_shortcuts: GlobalShortcuts, - pub min_to_tray: bool, - pub min_to_tray_on_close: bool, - pub notifications: bool, - pub theme: String, - pub tick_sounds: bool, - pub tick_sounds_during_break: bool, - pub time_long_break: i32, - pub time_short_break: i32, - pub time_work: i32, - pub volume: i32, - pub work_rounds: i32, -} - -//Need to look into if the serialization of the Slint structs in better in the newer release -//haven't tested in a bit, and I might not need to do this back and forth marshalling to use -//serde on this and the Theme struct anymore.... -/*impl From for Settings { - fn from(other: JsonSettings) -> Self { - Settings { - always_on_top: other.always_on_top, - auto_start_break_timer: other.auto_start_break_timer, - auto_start_work_timer: other.auto_start_work_timer, - break_always_on_top: other.break_always_on_top, - //global_shortcuts: other.global_shortcuts, - min_to_tray: other.min_to_tray, - min_to_tray_on_close: other.min_to_tray_on_close, - notifications: other.notifications, - //theme: other.theme, - tick_sounds: other.tick_sounds, - tick_sounds_during_break: other.tick_sounds_during_break, - time_long_break: other.time_long_break.into(), - time_short_break: other.time_short_break.into(), - time_work: other.time_work.into(), - volume: other.volume.into(), - work_rounds: other.work_rounds.into(), - } - } -}*/ - -//I'm thinking later, I probably need to store this in some sort of specific struct? -//Maybe 2 key type enums or something? -//A Modifier key, and a main key?...not really sure -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GlobalShortcuts { - #[serde(rename = "call-timer-reset")] - pub call_timer_reset: String, - #[serde(rename = "call-timer-skip")] - pub call_timer_skip: String, - #[serde(rename = "call-timer-toggle")] - pub call_timer_toggle: String, -} - -static CFG_DIR: OnceLock> = OnceLock::new(); -static DEF_THEME: OnceLock = OnceLock::new(); - -//This really probably shouldn't be public. But for now as a quick way to get the theme loading -//working from the correct directory I'm making it public. I need to move the theme loading -//from the main.rs file into the settings module, then I can make this private again -//fn get_dir() -> Option<&'static Path> { -pub fn get_dir() -> Option<&'static Path> { - if let Some(dirs) = CFG_DIR.get_or_init(|| ProjectDirs::from("org", "Vadoola", "Tomotroid")) { - return Some(dirs.config_dir()); - } else { - None - } -} - -pub fn default_theme() -> &'static JsonThemeTemp { - DEF_THEME.get_or_init(|| { - let def_theme = r##"{ - "name": "Rangitoto", - "colors": { - "--color-long-round": "#af486d", - "--color-short-round": "#719002", - "--color-focus-round": "#3c73b8", - "--color-background": "#1a191e", - "--color-background-light": "#343132", - "--color-background-lightest": "#837c7e", - "--color-foreground": "#dfdfd7", - "--color-foreground-darker": "#bec0c0", - "--color-foreground-darkest": "#adadae", - "--color-accent": "#cd7a0c" - } - }"##; - serde_json::from_str::(&def_theme).unwrap() - }) -} - -//so I'm thinking this module has a load settings and save settings -//it handles getting the proper directory etc. and just reads in a file returning a PathBuf -//need to probably include_bytes or include_str a default settings and default theme. -//so that if no settings and/or no theme files are found it has a fallback -//would it make any sense to use something like Figment(https://crates.io/crates/figment) instead of -//just looking at the raw Json? Could it provide any benefit or flexibility? -//pub fn load_settings() -> Settings { -pub fn load_settings() -> JsonSettings { - //if reading the files fails use default settings - //need to start adding some logging probably - //actually probably need to restructure this a bit - //if the cfg dir doesn't exist, need to load defaults, - //then if reading the file from the dir doesn't exist - //need to load defualts - if let Some(cfg_dir) = get_dir() { - let file = cfg_dir.join("preferences.json"); - if let Ok(set_file) = File::open(file) { - let reader = BufReader::new(set_file); - serde_json::from_reader(reader).unwrap() - } else { - default_settings() - } - } else { - default_settings() - } -} - -//fn default_settings() -> Settings { -fn default_settings() -> JsonSettings { - let def_set = include_bytes!("../assets/default-preferences.json"); - serde_json::from_reader(&def_set[..]).unwrap() -} - -//Use https://docs.rs/serde_json/latest/serde_json/fn.to_writer_pretty.html -//for writing out the json when I save the settings - -//what's the best way to call this...calling save settings every time a setting change -//would be the safest from the perspective of ensuring the settings are updated -//but that could be a lot of saving the file over and over. -//for example they change a timer slider form say 5m to 10m -//will the slint slider trigger the callback once, saying it was changed to 10, -//or will it trigger on every value update (ie, 6, 7, 8, 9, 10) triggering 5 updates -//to save settings? Since the settings file is pretty small/simple, I'm not sure it's worth -//trying to update just the value that's changed, probably just easier to rewrite the whole -//file every time. I could just save the settings on program exit...but if the program does crash -//the settings woin't get saved. Is there some good middle ground? Every X minutes check if there -//is a mismatch and save the settings? But then I have the overhead of some sort of timer to -//check every so often....Actually could I save the settings only when on the main screen somehow? -//So if the volume is changed, it saves right away (may not be super effecient, if it triggers for -//update of the slider and they change the volume a large amount), but if it changes something on the -//slidover screen, ie, timer, theme, etc it only saves when the slideover goes away? The logic might be -//a bit trickier, but might be a good middle ground of ensuring the settings get saved without quite -//writing out the file quite as much. -pub fn save_settings(settings: JsonSettings) { - if let Some(cfg_dir) = get_dir() { - let file = cfg_dir.join("preferences.json"); - let set_file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(file) - .unwrap(); - let writer = BufWriter::new(set_file); - - serde_json::to_writer_pretty(writer, &settings).unwrap(); - } -} +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::cell::OnceCell; +use std::fs::{File, OpenOptions}; +use std::io::{BufReader, BufWriter, Write}; +use std::path::Path; +use std::sync::{Arc, OnceLock}; + +use crate::JsonThemeTemp; + +slint::include_modules!(); + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JsonSettings { + pub always_on_top: bool, + pub auto_start_break_timer: bool, + pub auto_start_work_timer: bool, + pub break_always_on_top: bool, + pub global_shortcuts: GlobalShortcuts, + pub min_to_tray: bool, + pub min_to_tray_on_close: bool, + pub notifications: bool, + pub theme: String, + pub tick_sounds: bool, + pub tick_sounds_during_break: bool, + pub time_long_break: i32, + pub time_short_break: i32, + pub time_work: i32, + pub volume: i32, + pub work_rounds: i32, +} + +//Need to look into if the serialization of the Slint structs in better in the newer release +//haven't tested in a bit, and I might not need to do this back and forth marshalling to use +//serde on this and the Theme struct anymore.... +/*impl From for Settings { + fn from(other: JsonSettings) -> Self { + Settings { + always_on_top: other.always_on_top, + auto_start_break_timer: other.auto_start_break_timer, + auto_start_work_timer: other.auto_start_work_timer, + break_always_on_top: other.break_always_on_top, + //global_shortcuts: other.global_shortcuts, + min_to_tray: other.min_to_tray, + min_to_tray_on_close: other.min_to_tray_on_close, + notifications: other.notifications, + //theme: other.theme, + tick_sounds: other.tick_sounds, + tick_sounds_during_break: other.tick_sounds_during_break, + time_long_break: other.time_long_break.into(), + time_short_break: other.time_short_break.into(), + time_work: other.time_work.into(), + volume: other.volume.into(), + work_rounds: other.work_rounds.into(), + } + } +}*/ + +//I'm thinking later, I probably need to store this in some sort of specific struct? +//Maybe 2 key type enums or something? +//A Modifier key, and a main key?...not really sure +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlobalShortcuts { + #[serde(rename = "call-timer-reset")] + pub call_timer_reset: String, + #[serde(rename = "call-timer-skip")] + pub call_timer_skip: String, + #[serde(rename = "call-timer-toggle")] + pub call_timer_toggle: String, +} + +static CFG_DIR: OnceLock> = OnceLock::new(); +static DEF_THEME: OnceLock = OnceLock::new(); + +//This really probably shouldn't be public. But for now as a quick way to get the theme loading +//working from the correct directory I'm making it public. I need to move the theme loading +//from the main.rs file into the settings module, then I can make this private again +pub fn get_dir() -> Option<&'static Path> { + if let Some(dirs) = CFG_DIR.get_or_init(|| ProjectDirs::from("org", "Vadoola", "Tomotroid")) { + return Some(dirs.config_dir()); + } else { + None + } +} + +pub fn default_theme() -> &'static JsonThemeTemp { + DEF_THEME.get_or_init(|| { + let def_theme = r##"{ + "name": "Rangitoto", + "colors": { + "--color-long-round": "#af486d", + "--color-short-round": "#719002", + "--color-focus-round": "#3c73b8", + "--color-background": "#1a191e", + "--color-background-light": "#343132", + "--color-background-lightest": "#837c7e", + "--color-foreground": "#dfdfd7", + "--color-foreground-darker": "#bec0c0", + "--color-foreground-darkest": "#adadae", + "--color-accent": "#cd7a0c" + } + }"##; + serde_json::from_str::(def_theme).unwrap() + }) +} + +//so I'm thinking this module has a load settings and save settings +//it handles getting the proper directory etc. and just reads in a file returning a PathBuf +//need to probably include_bytes or include_str a default settings and default theme. +//so that if no settings and/or no theme files are found it has a fallback +//would it make any sense to use something like Figment(https://crates.io/crates/figment) instead of +//just looking at the raw Json? Could it provide any benefit or flexibility? +//pub fn load_settings() -> Settings { +pub fn load_settings() -> JsonSettings { + //if reading the files fails use default settings + //need to start adding some logging probably + //actually probably need to restructure this a bit + //if the cfg dir doesn't exist, need to load defaults, + //then if reading the file from the dir doesn't exist + //need to load defualts + if let Some(cfg_dir) = get_dir() { + let file = cfg_dir.join("preferences.json"); + if let Ok(set_file) = File::open(file) { + let reader = BufReader::new(set_file); + serde_json::from_reader(reader).unwrap() + } else { + default_settings() + } + } else { + default_settings() + } +} + +//fn default_settings() -> Settings { +fn default_settings() -> JsonSettings { + let def_set = include_bytes!("../assets/default-preferences.json"); + serde_json::from_reader(&def_set[..]).unwrap() +} + +//Use https://docs.rs/serde_json/latest/serde_json/fn.to_writer_pretty.html +//for writing out the json when I save the settings + +//what's the best way to call this...calling save settings every time a setting change +//would be the safest from the perspective of ensuring the settings are updated +//but that could be a lot of saving the file over and over. +//for example they change a timer slider form say 5m to 10m +//will the slint slider trigger the callback once, saying it was changed to 10, +//or will it trigger on every value update (ie, 6, 7, 8, 9, 10) triggering 5 updates +//to save settings? Since the settings file is pretty small/simple, I'm not sure it's worth +//trying to update just the value that's changed, probably just easier to rewrite the whole +//file every time. I could just save the settings on program exit...but if the program does crash +//the settings woin't get saved. Is there some good middle ground? Every X minutes check if there +//is a mismatch and save the settings? But then I have the overhead of some sort of timer to +//check every so often....Actually could I save the settings only when on the main screen somehow? +//So if the volume is changed, it saves right away (may not be super effecient, if it triggers for +//update of the slider and they change the volume a large amount), but if it changes something on the +//slidover screen, ie, timer, theme, etc it only saves when the slideover goes away? The logic might be +//a bit trickier, but might be a good middle ground of ensuring the settings get saved without quite +//writing out the file quite as much. +pub fn save_settings(settings: JsonSettings) { + if let Some(cfg_dir) = get_dir() { + let file = cfg_dir.join("preferences.json"); + let set_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(file) + .unwrap(); + let writer = BufWriter::new(set_file); + + serde_json::to_writer_pretty(writer, &settings).unwrap(); + } +} diff --git a/ui/appwindow.slint b/ui/appwindow.slint index 5d12026..2e2ab28 100755 --- a/ui/appwindow.slint +++ b/ui/appwindow.slint @@ -19,6 +19,7 @@ export enum TimerAction { start, stop, reset, + skip, } export component Main inherits BorderlessWindow { @@ -316,6 +317,7 @@ export component Main inherits BorderlessWindow { Rectangle { y: parent.height/2 - self.height/2; SkipBtn-ta := TouchArea { + clicked => { action-timer(TimerAction.skip) } } Image { source: @image-url("../assets/icons/skip.svg");