diff --git a/Cargo.lock b/Cargo.lock index ce425093..9e66f99e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,15 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "arc-swap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -1257,6 +1266,9 @@ name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +dependencies = [ + "serde", +] [[package]] name = "loom" @@ -1521,6 +1533,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pausable_clock" version = "1.0.2" @@ -2371,6 +2389,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" name = "tmaze" version = "1.17.1" dependencies = [ + "arc-swap", "better-panic", "boml", "chrono", @@ -2384,6 +2403,7 @@ dependencies = [ "json5", "log", "pad", + "paste", "pausable_clock", "rand 0.8.5", "rodio", diff --git a/cmaze/src/algorithms/types.rs b/cmaze/src/algorithms/types.rs index 6154f4cb..b6bd2f53 100644 --- a/cmaze/src/algorithms/types.rs +++ b/cmaze/src/algorithms/types.rs @@ -78,8 +78,6 @@ pub type Algorithm = (String, Params); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MazeSpec { - // /// Size of the maze. - // pub size: Dims3D, /// Specification of the maze. #[serde(default, flatten)] pub inner_spec: MazeSpecType, diff --git a/cmaze/src/array.rs b/cmaze/src/array.rs index ede105bd..893a6dbc 100644 --- a/cmaze/src/array.rs +++ b/cmaze/src/array.rs @@ -51,7 +51,8 @@ impl Array3D { } pub fn get_mut(&mut self, pos: impl Into) -> Option<&mut T> { - self.dim_to_idx(pos.into()).and_then(move |i| self.buf.get_mut(i)) + self.dim_to_idx(pos.into()) + .and_then(move |i| self.buf.get_mut(i)) } } diff --git a/cmaze/src/dims.rs b/cmaze/src/dims.rs index aa6f58fe..9952cc50 100644 --- a/cmaze/src/dims.rs +++ b/cmaze/src/dims.rs @@ -190,6 +190,18 @@ impl Mul for Dims3D { } } +impl Mul for Dims3D { + type Output = Dims3D; + + fn mul(self, other: f64) -> Dims3D { + Dims3D( + (self.0 as f64 * other).round() as i32, + (self.1 as f64 * other).round() as i32, + (self.2 as f64 * other).round() as i32, + ) + } +} + impl MulAssign for Dims3D { fn mul_assign(&mut self, other: i32) { self.0 *= other; diff --git a/tmaze/Cargo.toml b/tmaze/Cargo.toml index bbac27e9..83ffc9ba 100644 --- a/tmaze/Cargo.toml +++ b/tmaze/Cargo.toml @@ -26,12 +26,14 @@ derivative = "2.2.0" unicode-width = "0.1.14" thiserror = "2" chrono = { version = "0.4.38", features = ["serde"] } -log = "0.4" +log = { version = "0.4", features = ["serde"] } smallvec = { version = "1.13.2", features = ["const_generics"] } hashbrown = { version = "0.14", features = ["serde"] } toml = "0.8" json5 = "0.4.1" serde_json = "1.0.137" +paste = "1.0.15" +arc-swap = "1.8.0" # optional crates_io_api = { version = "0.11.0", optional = true, default-features = false, features = ["rustls"] } diff --git a/tmaze/src/app/app.rs b/tmaze/src/app/app.rs index ef964dca..6fb309c6 100644 --- a/tmaze/src/app/app.rs +++ b/tmaze/src/app/app.rs @@ -1,5 +1,6 @@ use std::{ - sync::Arc, + rc::Rc, + sync::{mpsc, Arc}, time::{Duration, Instant}, }; @@ -15,12 +16,14 @@ use cmaze::{ use crossterm::event::{read, KeyCode, KeyEvent, KeyEventKind}; use crate::{ + app::event::{EventReceiver, EventReceiverFn}, data::SaveData, - helpers::{constants::paths::settings_path, on_off}, + helpers::{constants::paths, on_off}, logging::{self, AppLogger, LoggerOptions, UiLogs}, renderer::{self, draw::Draw, CellContent, GMutView, Renderer}, settings::{ - theme::{Theme, ThemeResolver}, + model::Config, + theme::{SharedScheme, TerminalColorScheme, Theme, ThemeDefinition, ThemeResolver}, Settings, }, ui, @@ -44,19 +47,23 @@ pub struct App { renderer: Renderer, activities: Activities, data: AppData, + event_drain: mpsc::Receiver, } pub struct AppData { pub settings: Settings, pub save: SaveData, pub use_data: AppStateData, + pub appearance: Appearance, pub screen_size: Dims, - pub theme: Theme, - pub theme_resolver: ThemeResolver, pub logs: UiLogs, pub registries: Registries, jobs: Jobs, + pub event_sink: EventSink, + pub event_receivers: Vec, + app_start: Instant, + read_only: bool, #[cfg(feature = "sound")] pub sound_player: SoundPlayer, @@ -77,11 +84,13 @@ impl AppData { } } - let volume = if self.settings.get_enable_audio() && self.settings.get_enable_music() { - self.settings.get_audio_volume() * self.settings.get_music_volume() + let cfg = &self.settings.read().audio; + let volume = if cfg.enable_audio && cfg.enable_music { + cfg.audio_volume * cfg.music_volume } else { 0.0 - }; + } as f32; + self.sound_player.set_volume(volume); self.bgm_track = Some(track); @@ -92,6 +101,10 @@ impl AppData { pub fn queuer(&self) -> Qer { self.jobs.queuer() } + + pub fn is_ro(&self) -> bool { + self.read_only + } } pub struct Registries { @@ -126,25 +139,37 @@ impl App { /// - initializes the job queue, /// - initializes the registries, pub fn empty(read_only: bool) -> Self { - let settings = - Settings::load_json(settings_path(), read_only).expect("failed to load settings"); - - let renderer = Renderer::new( - &settings - .get_terminal_scheme() - .expect("unknown built-in terminal color scheme, use '--print-terminal-schemes' to see options"), - ) - .expect("failed to create renderer"); + if !read_only { + Self::prepare_dirs() + .expect("Failed to prepare application directories. Please check permissions."); + } + + let (event_sink, event_drain) = Self::init_event_sink(); + let mut event_receivers = vec![]; + + let (settings, settings_errors) = Settings::load(event_sink.clone()); + let config = settings.read(); + event_receivers.push(settings.register()); + + let renderer = Renderer::new(&Rc::new(config.general.terminal_scheme.clone())) + .expect("failed to create renderer"); let activities = Activities::empty(); let (logger, logs) = AppLogger::new_with_options( - settings.get_logging_level(), + config.general.logging_level, LoggerOptions::default() .read_only(read_only) - .file_level(settings.get_file_logging_level()), + .file_level(config.general.file_logging_level), ); logger.init(); + if let Some(errors) = settings_errors { + log::error!("Errors were encountered while loading the config."); + for err in errors { + log::error!(" - {}", err); + } + } + let save = SaveData::load().expect("failed to load save data"); let use_data = AppStateData::default(); let jobs = Jobs::new(); @@ -164,27 +189,32 @@ impl App { }; log::info!("Loading theme"); - let resolver = init_theme_resolver(); - let theme_def = settings.get_theme(); - let theme = resolver.resolve(&theme_def); #[cfg(feature = "sound")] let sound_player = SoundPlayer::new(settings.clone()); + event_receivers.push(sound_player.register()); + + let appereance = Appearance::new(&config); + + drop(config); Self { renderer, activities, + event_drain, data: AppData { app_start, settings, save, use_data, + appearance: appereance, screen_size: frame_size, jobs, - theme, - theme_resolver: resolver, + event_sink, + event_receivers, logs, registries, + read_only, #[cfg(feature = "sound")] sound_player, @@ -205,7 +235,8 @@ impl App { let mut events = vec![]; - let mut delay = Duration::from_millis(45); + // FIXME: better polling strategy, IO will need faster response times + let mut delay = Duration::from_millis(10); while let Ok(true) = crossterm::event::poll(delay) { let event = read().unwrap(); @@ -219,7 +250,7 @@ impl App { .. }) => self.switch_debug(), event @ crossterm::event::Event::Mouse(_) => { - if self.data.settings.get_enable_mouse() { + if self.data.settings.read().nagivation.enable_mouse { events.push(Event::Term(event)); } } @@ -230,11 +261,23 @@ impl App { delay = Duration::from_nanos(1) } - while let Some(change) = match self.activities.active_mut() { - Some(active) => { - log::trace!("Updating activity: '{}'", active.name()); - active + // Read events from the drain + while let Ok(event) = self.event_drain.try_recv() { + events.push(event); + } + + // Update handle the event receivers + // (hack): due to borrow issues + let mut receivers = std::mem::take(&mut self.data.event_receivers); + for receiver in receivers.iter_mut() { + for event in &events { + receiver(event, &mut self.data); } + } + self.data.event_receivers = receivers; + + while let Some(change) = match self.activities.active_mut() { + Some(active) => active, None => break 'mainloop events, } .update(std::mem::take(&mut events), &mut self.data) @@ -265,29 +308,28 @@ impl App { } } + let theme = &self.data.appearance.theme; self.renderer .frame() .mut_view() - .fill(CellContent::styled(' ', self.data.theme.get("background"))); + .fill(CellContent::styled(' ', theme.get("background"))); match self .activities .active_mut() .expect("No active active") .screen() - .draw(&mut self.renderer.frame().mut_view(), &self.data.theme) + .draw(&mut self.renderer.frame().mut_view(), &theme) { Ok(_) => {} Err(ui::ScreenError::SmallScreen) => { - draw_small_screen_info(&mut self.renderer.frame().mut_view(), &self.data.theme) + draw_small_screen_info(&mut self.renderer.frame().mut_view(), &theme) } } - self.data.logs.draw_on( - Dims(0, 0), - &mut self.renderer.frame().mut_view(), - &self.data.theme, - ); + self.data + .logs + .draw_on(Dims(0, 0), &mut self.renderer.frame().mut_view(), &theme); // TODO: let activities show debug info and about the app itself // then we can draw it here @@ -305,13 +347,25 @@ impl App { fn switch_debug(&mut self) { self.data.use_data.show_debug = !self.data.use_data.show_debug; - self.data.logs.switch_debug(&self.data.settings); + self.data.logs.switch_debug(&self.data.settings.read()); log::warn!( "Debug mode: {}", on_off(self.data.use_data.show_debug, false) ); } + fn prepare_dirs() -> std::io::Result<()> { + for dir in paths::all_dirs() { + std::fs::create_dir_all(&dir)?; + } + + Ok(()) + } + + pub fn init_event_sink() -> (EventSink, mpsc::Receiver) { + mpsc::channel() + } + pub fn activity_count(&self) -> usize { self.activities.len() } @@ -337,12 +391,63 @@ impl App { } } +pub type EventSink = mpsc::Sender; + #[derive(Default)] pub struct AppStateData { pub last_selected_preset: Option, pub show_debug: bool, } +pub struct Appearance { + theme: Theme, + scheme: SharedScheme, + resolver: ThemeResolver, +} + +impl Appearance { + pub fn new(config: &Config) -> Self { + let resolver = init_theme_resolver(); + + Self { + theme: Self::load_theme(config, &resolver), + scheme: Self::load_scheme(config), + resolver, + } + } + + pub fn theme(&self) -> &Theme { + &self.theme + } + + pub fn scheme(&self) -> &Rc { + &self.scheme + } + + pub fn resolver(&self) -> &ThemeResolver { + &self.resolver + } +} + +impl Appearance { + fn load_theme(config: &Config, resolver: &ThemeResolver) -> Theme { + match config.general.theme.as_str() { + "" => resolver.resolve(&ThemeDefinition::parse_default()), + name => match ThemeDefinition::load_by_name(name) { + Ok(def) => resolver.resolve(&def), + Err(err) => { + log::error!("Failed to load theme '{}': {}", name, err); + resolver.resolve(&ThemeDefinition::parse_default()) + } + }, + } + } + + fn load_scheme(config: &Config) -> SharedScheme { + Rc::new(config.general.terminal_scheme.clone()) + } +} + pub fn init_theme_resolver() -> ThemeResolver { let mut resolver = ThemeResolver::new(); diff --git a/tmaze/src/app/event.rs b/tmaze/src/app/event.rs index 86de5a70..c3807b77 100644 --- a/tmaze/src/app/event.rs +++ b/tmaze/src/app/event.rs @@ -1,8 +1,23 @@ use crossterm::event::Event as TermEvent; +use crate::app::app::AppData; + use super::activity::ActivityResult; pub enum Event { Term(TermEvent), ActiveAfterPop(Option), + SettingsChanged, +} + +pub type EventReceiverFn = Box; + +// FIXME: fix the API, this is really bad +// +// There are two main problems with this API: +// - This API leaks the implementation details of the event system. +// - This cannot be optimized in any way, it would be much better if we knew what events a receiver +// is interested in, so we could avoid calling it for irrelevant events. +pub trait EventReceiver { + fn register(self) -> EventReceiverFn; } diff --git a/tmaze/src/app/game.rs b/tmaze/src/app/game.rs index e65df97d..7b740a61 100644 --- a/tmaze/src/app/game.rs +++ b/tmaze/src/app/game.rs @@ -17,16 +17,18 @@ use crate::{ lerp, menu_actions, renderer::{draw::Align, CellContent, GBuffer, GMutView, Padding}, settings::{ - self, - style_browser::StyleBrowser, + model::{CameraMode, Config, MazePreset, Viewport}, theme::{SharedScheme, Theme, ThemeResolver}, - CameraMode, MazePreset, Settings, SettingsActivity, }, ui::{ self, helpers::format_duration, multisize_duration_format, split_menu_actions, - usecase::dpad::{DPad, DPadType}, + usecase::{ + dpad::{DPad, DPadType}, + settings::SettingsActivity, + style_browser::StyleBrowser, + }, Menu, MenuAction, MenuConfig, Popup, ProgressBar, Rect, RedirectMenu, Screen, ScreenError, }, }; @@ -80,7 +82,7 @@ pub struct MainMenu { impl MainMenu { pub fn new() -> Self { let options = menu_actions!( - "New Game" -> data => Self::start_new_game(&data.settings, &data.use_data), + "New Game" -> data => Self::start_new_game(&data.settings.read(), &data.use_data), "Settings" -> _ => Self::show_settings_screen(), "Controls" -> _ => Self::show_controls_popup(), "Info" -> _ => Self::show_info_menu(), @@ -99,7 +101,7 @@ impl MainMenu { fn show_settings_screen() -> Change { Change::push(Activity::new_base_boxed( "settings".to_string(), - settings::SettingsActivity::new(), + SettingsActivity::new(), )) } @@ -165,7 +167,7 @@ impl MainMenu { "Style options browser" -> data => Change::Push( Activity::new_base_boxed( "style options browser".to_string(), - StyleBrowser::new(data.theme_resolver.clone()) + StyleBrowser::new(data.appearance.resolver().clone()) ) ), "Back" -> _ => Change::pop_top(), @@ -182,11 +184,20 @@ impl MainMenu { ) } - fn start_new_game(settings: &Settings, use_data: &AppStateData) -> Change { - Change::push(Activity::new_base_boxed( - "maze size", - MazePresetMenu::new(settings, use_data), - )) + fn start_new_game(settings: &Config, use_data: &AppStateData) -> Change { + match MazePresetMenu::new(settings, use_data) { + Some(preset_menu) => Change::push(Activity::new_base_boxed("maze preset", preset_menu)), + None => { + // TODO: reference settings once ready + const MSG: &str = "No maze presets available, please add some in config"; + log::warn!("{}", MSG); + + Change::push(Activity::new_base_boxed( + "no presets", + Popup::new("No presets".to_string(), vec![MSG.to_string()]), + )) + } + } } #[cfg(feature = "sound")] @@ -225,11 +236,15 @@ pub struct MazePresetMenu { } impl MazePresetMenu { - pub fn new(settings: &Settings, app_state_data: &AppStateData) -> Self { + pub fn new(config: &Config, app_state_data: &AppStateData) -> Option { + if config.presets.is_empty() { + return None; + } + let mut menu_config = MenuConfig::new_from_strings( - "Maze size".to_string(), - settings - .get_presets() + "Maze preset".to_string(), + config + .presets .iter() .map(|maze| maze.title.clone()) .collect::>(), @@ -237,7 +252,7 @@ impl MazePresetMenu { let default = app_state_data .last_selected_preset - .or_else(|| settings.get_presets().iter().position(|maze| maze.default)); + .or_else(|| config.presets.iter().position(|maze| maze.default)); if let Some(i) = default { menu_config = menu_config.default(i); @@ -245,9 +260,9 @@ impl MazePresetMenu { let menu = Menu::new(menu_config); - let presets = settings.get_presets().to_vec(); + let presets = config.presets.to_vec(); - Self { menu, presets } + Some(Self { menu, presets }) } } @@ -510,17 +525,14 @@ pub struct GameActivity { impl GameActivity { pub fn new(game: GameData, app_data: &mut AppData) -> Self { - let settings = &app_data.settings; - - let camera_mode = settings.get_camera_mode(); - let maze_board = MazeBoard::new( - &game.game, - &app_data.theme, - settings - .get_terminal_scheme() - .expect("invalid built-in terminal color scheme"), - ); - let margins = settings.get_viewport_margin(); + let config = app_data.settings.read(); + let viewport_cfg = &config.viewport; + let appear = &app_data.appearance; + + let camera_mode = viewport_cfg.camera_mode; + let maze_board = MazeBoard::new(&game.game, appear.theme(), appear.scheme().clone()); + let margins = viewport_cfg.viewport_margin; + drop(config); #[cfg(feature = "sound")] app_data.play_bgm(MusicTrack::choose_for_maze(game.game.get_maze())); @@ -644,10 +656,12 @@ impl GameActivity { } fn update_viewport(&mut self, data: &AppData) { + let cfg = &data.settings.read().nagivation; + if self.is_dpad_enabled() { let (viewport_rect, dpad_rect) = DPad::split_screen(data); let mut dpad_rect = dpad_rect; - if data.settings.get_enable_margin_around_dpad() { + if cfg.enable_margin_around_dpad { dpad_rect = dpad_rect.margin(self.margins); } @@ -665,17 +679,16 @@ impl GameActivity { fn init_dpad(&mut self, data: &AppData) { let dpad_type = DPadType::from_maze(self.data.game.get_maze()); - let swap_up_down = data.settings.get_dpad_swap_up_down(); + let swap_up_down = data.settings.read().nagivation.dpad_swap_up_down; let touch_controls = DPad::new(None, swap_up_down, dpad_type); self.touch_controls = Some(Box::new(touch_controls)); } fn update_dpad(&mut self, data: &AppData) { - if (data.settings.get_enable_dpad() && data.settings.get_enable_mouse()) - != self.is_dpad_enabled() - { - if data.settings.get_enable_dpad() { + let config = &data.settings.read().nagivation; + if (config.enable_dpad && config.enable_mouse) != self.is_dpad_enabled() { + if config.enable_dpad { log::info!("Enabling dpad"); self.init_dpad(data); } else { @@ -687,8 +700,8 @@ impl GameActivity { if self.is_dpad_enabled() { let dpad = self.touch_controls.as_mut().unwrap(); - dpad.swap_up_down = data.settings.get_dpad_swap_up_down(); - dpad.disable_highlight(!data.settings.get_enable_dpad_highlight()); + dpad.swap_up_down = config.dpad_swap_up_down; + dpad.disable_highlight(!config.enable_dpad_highlight); } } @@ -708,6 +721,8 @@ impl ActivityHandler for GameActivity { _ => {} } + let config = data.settings.read(); + self.update_dpad(data); self.update_viewport(data); @@ -719,24 +734,22 @@ impl ActivityHandler for GameActivity { #[allow(clippy::single_match)] match event { Event::Term(event) => match event { - TermEvent::Key(key_event) => { - match self.data.handle_event(&data.settings, key_event) { - Err(false) => { - self.data.game.pause().unwrap(); - - return Some(Change::push(Activity::new_base_boxed( - "pause".to_string(), - PauseMenu::new(), - ))); - } - Err(true) => return Some(Change::pop_until("main menu")), - Ok(_) => {} + TermEvent::Key(key_event) => match self.data.handle_event(&config, key_event) { + Err(false) => { + self.data.game.pause().unwrap(); + + return Some(Change::push(Activity::new_base_boxed( + "pause".to_string(), + PauseMenu::new(), + ))); } - } + Err(true) => return Some(Change::pop_until("main menu")), + Ok(_) => {} + }, TermEvent::Mouse(event) => { if let Some(ref mut touch_controls) = self.touch_controls { if let Some(dir) = touch_controls.apply_mouse_event(event) { - self.data.apply_move(&data.settings, dir, false); + self.data.apply_move(&config, dir, false); } } } @@ -786,8 +799,15 @@ impl ActivityHandler for GameActivity { } } - self.sm_player_pos = lerp!((self.sm_player_pos) -> (maze2screen_3d(self.data.game.get_player_pos())) at data.settings.get_player_smoothing()); - self.sm_camera_pos = lerp!((self.sm_camera_pos) -> (self.data.camera_pos) at data.settings.get_camera_smoothing()); + let Viewport { + camera_smoothing, + player_smoothing, + .. + } = data.settings.read().viewport; + + self.sm_player_pos = lerp!((self.sm_player_pos) -> (maze2screen_3d(self.data.game.get_player_pos())) at player_smoothing); + self.sm_camera_pos = + lerp!((self.sm_camera_pos) -> (self.data.camera_pos) at camera_smoothing); self.show_debug = data.use_data.show_debug; diff --git a/tmaze/src/app/game_state.rs b/tmaze/src/app/game_state.rs index 3ebd2514..feb41533 100644 --- a/tmaze/src/app/game_state.rs +++ b/tmaze/src/app/game_state.rs @@ -9,7 +9,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::{ helpers::{is_release, maze2screen_3d}, - settings::{MazePreset, Settings}, + settings::model::{Config, MazePreset}, }; #[derive(PartialEq, Eq, Clone, Copy)] @@ -45,7 +45,7 @@ pub struct GameData { } impl GameData { - pub fn handle_event(&mut self, settings: &Settings, event: KeyEvent) -> Result<(), bool> { + pub fn handle_event(&mut self, config: &Config, event: KeyEvent) -> Result<(), bool> { let KeyEvent { code, modifiers, @@ -60,23 +60,23 @@ impl GameData { match code { KeyCode::Up | KeyCode::Char('w' | 'W') => { - self.apply_move(settings, CellWall::Top, is_fast); + self.apply_move(config, CellWall::Top, is_fast); } KeyCode::Down | KeyCode::Char('s' | 'S') => { - self.apply_move(settings, CellWall::Bottom, is_fast); + self.apply_move(config, CellWall::Bottom, is_fast); } KeyCode::Left | KeyCode::Char('a' | 'A') => { - self.apply_move(settings, CellWall::Left, is_fast); + self.apply_move(config, CellWall::Left, is_fast); } KeyCode::Right | KeyCode::Char('d' | 'D') => { - self.apply_move(settings, CellWall::Right, is_fast); + self.apply_move(config, CellWall::Right, is_fast); } KeyCode::Char('Q') => return Err(true), KeyCode::Char('f' | 'q' | 'l') => { - self.apply_move(settings, CellWall::Down, is_fast); + self.apply_move(config, CellWall::Down, is_fast); } KeyCode::Char('r' | 'e' | 'p') => { - self.apply_move(settings, CellWall::Up, is_fast); + self.apply_move(config, CellWall::Up, is_fast); } KeyCode::Char(' ') => { match self.view_mode { @@ -103,7 +103,7 @@ impl GameData { Ok(()) } - pub fn apply_move(&mut self, settings: &Settings, wall: CellWall, fast: bool) { + pub fn apply_move(&mut self, settings: &Config, wall: CellWall, fast: bool) { match self.view_mode { GameViewMode::Spectator => { let mut off = wall.reverse_wall().to_coord(); @@ -122,14 +122,14 @@ impl GameData { self.game .move_player( wall, - if settings.get_slow() { + if settings.viewport.slow { MoveMode::Slow } else if fast { MoveMode::Fast } else { MoveMode::Normal }, - !settings.get_disable_tower_auto_up(), + !settings.viewport.disable_tower_auto_up, ) .unwrap(); } diff --git a/tmaze/src/data/mod.rs b/tmaze/src/data/mod.rs index ab71cb3b..473e20fc 100644 --- a/tmaze/src/data/mod.rs +++ b/tmaze/src/data/mod.rs @@ -9,8 +9,8 @@ use std::{ }; use crate::{ - helpers::constants::paths::save_data_path, - settings::{Settings, UpdateCheckInterval}, + helpers::constants::paths::managed::save_data, + settings::model::{Config, UpdateCheckInterval}, }; pub mod model { @@ -62,12 +62,12 @@ pub enum SaveDataError { impl SaveData { pub fn load() -> Result { - match Self::load_from(&save_data_path()) { + match Self::load_from(&save_data()) { Ok(data) => Ok(data), Err(SaveDataError::Io(_)) => Ok(SaveData { last_update_check: None, best_results: HashMap::new(), - path: save_data_path(), + path: save_data(), }), Err(err) => Err(err), } @@ -77,7 +77,7 @@ impl SaveData { Self::load().unwrap_or_else(|_| Self { last_update_check: None, best_results: HashMap::new(), - path: save_data_path(), + path: save_data(), }) } @@ -105,10 +105,10 @@ impl SaveData { } impl SaveData { - pub fn is_update_checked(&self, settings: &Settings) -> bool { + pub fn is_update_checked(&self, settings: &Config) -> bool { use UpdateCheckInterval::*; - match settings.get_check_interval() { + match settings.updates.check_interval { Never => true, Daily => self.check_date(|d| d), Weekly => self.check_date(|d| d.iso_week()), diff --git a/tmaze/src/helpers/constants.rs b/tmaze/src/helpers/constants.rs index 8f7d8334..1a6a0f03 100644 --- a/tmaze/src/helpers/constants.rs +++ b/tmaze/src/helpers/constants.rs @@ -26,34 +26,51 @@ pub mod paths { use std::path::PathBuf; #[cfg(not(feature = "local_paths"))] - pub fn base_path() -> PathBuf { + pub fn base() -> PathBuf { use dirs::preference_dir; preference_dir().unwrap().join("tmaze") } #[cfg(feature = "local_paths")] - pub fn base_path() -> PathBuf { + pub fn base() -> PathBuf { PathBuf::from("./") } - pub fn theme_path() -> PathBuf { - base_path().join("themes/") + pub fn theme() -> PathBuf { + base().join("themes/") } - pub fn theme_file_path(theme: &str) -> PathBuf { - theme_path().join(theme) + pub fn theme_file(theme_name: &str) -> PathBuf { + theme().join(theme_name) } - pub fn settings_path() -> PathBuf { - base_path().join("settings.json5") + pub fn config() -> PathBuf { + base().join("settings.json5") } - pub fn save_data_path() -> PathBuf { - base_path().join("data.json") + pub fn all_dirs() -> impl Iterator { + vec![theme(), managed::path()].into_iter() } - pub fn log_file_path() -> PathBuf { - base_path().join("log.txt") + pub mod managed { + use super::base; + use std::path::PathBuf; + + pub fn path() -> PathBuf { + base().join(".managed/") + } + + pub fn ui_settings() -> PathBuf { + path().join("ui_settings.json") + } + + pub fn save_data() -> PathBuf { + path().join("data.json") + } + + pub fn log_file() -> PathBuf { + path().join("log.txt") + } } } diff --git a/tmaze/src/helpers/mod.rs b/tmaze/src/helpers/mod.rs index 604b3306..9bc65c72 100644 --- a/tmaze/src/helpers/mod.rs +++ b/tmaze/src/helpers/mod.rs @@ -210,3 +210,19 @@ macro_rules! make_even { } }; } + +pub trait TupleMap { + fn map_first T2>(self, f: F) -> (T2, U); + + fn map_second U2>(self, f: F) -> (T, U2); +} + +impl TupleMap for (T, U) { + fn map_first T2>(self, f: F) -> (T2, U) { + (f(self.0), self.1) + } + + fn map_second U2>(self, f: F) -> (T, U2) { + (self.0, f(self.1)) + } +} diff --git a/tmaze/src/logging.rs b/tmaze/src/logging.rs index 0fb06e21..04921ff7 100644 --- a/tmaze/src/logging.rs +++ b/tmaze/src/logging.rs @@ -15,8 +15,8 @@ use crate::{ helpers::constants::paths, renderer::{draw::Draw, GMutView}, settings::{ + model::Config, theme::{Color, NamedColor, Style, Theme}, - Settings, }, }; @@ -108,14 +108,16 @@ impl UiLogs { } } - pub fn switch_debug(&self, settings: &Settings) { + pub fn switch_debug(&self, settings: &Config) { let mut debug = self.debug.write().unwrap(); *debug = !*debug; + let config = &settings.general; + if *debug { - *self.min_level.write().unwrap() = settings.get_debug_logging_level(); + *self.min_level.write().unwrap() = config.debug_logging_level; } else { - *self.min_level.write().unwrap() = settings.get_logging_level(); + *self.min_level.write().unwrap() = config.logging_level; } } @@ -186,7 +188,7 @@ impl Default for LoggerOptions { Self { decay: DEFAULT_DECAY, max_visible: DEFAULT_MAX_VISIBLE, - path: Some(paths::log_file_path()), + path: Some(paths::managed::log_file()), file_level: log::Level::Debug, } } diff --git a/tmaze/src/main.rs b/tmaze/src/main.rs index f9d9d1df..cfa0d57d 100644 --- a/tmaze/src/main.rs +++ b/tmaze/src/main.rs @@ -1,7 +1,9 @@ +use std::io::Write; + use tmaze::{ app::{app::init_theme_resolver, game::MainMenu, Activity, App, GameError}, - helpers::constants::paths::{save_data_path, settings_path}, - settings::Settings, + helpers::constants::paths, + settings::{theme::TerminalColorScheme, Settings}, }; #[cfg(feature = "updates")] @@ -12,8 +14,9 @@ use clap::{Parser, ValueEnum}; #[derive(Parser, Debug)] #[clap(version, author, about, name = "tmaze")] struct Args { - #[clap(long, action, help = "Reset config to default and quit")] - reset_config: bool, + // FIXME: This should reset UI config, not the user config + // #[clap(long, action, help = "Reset config to default and quit")] + // reset_config: bool, #[clap(short, long, action, help = "Show config path and quit")] show_config_path: bool, #[clap(long, help = "Show config in debug format and quit")] @@ -57,46 +60,59 @@ enum StylesPrintMode { } fn main() -> Result<(), GameError> { - let _args = Args::parse(); - - if _args.reset_config { - Settings::reset_json_config(settings_path()); + let args = Args::parse(); + + // if _args.reset_config { + // Settings::reset_json_config(settings_path()); + // return Ok(()); + // } + + if args.show_config_path { + let settings_path = paths::config(); + std::io::stdout().write_all( + &settings_path + .as_os_str() + .to_os_string() + .into_encoded_bytes(), + )?; + std::io::stdout().write_all(b"\n")?; + std::io::stdout().flush()?; return Ok(()); } - if _args.show_config_path { - let settings_path = settings_path(); - if let Some(s) = settings_path.to_str() { - println!("{}", s); - } else { - println!("{:?}", settings_path); + if args.debug_config { + let (event_sink, _drain) = tmaze::app::app::App::init_event_sink(); + let (config, errors) = Settings::load(event_sink); + if let Some(errors) = errors { + eprintln!("Warning: Errors were encountered while loading the config."); + for error in errors { + eprintln!("- {}", error); + } } - return Ok(()); - } - if _args.debug_config { - println!("{:#?}", Settings::load_json(settings_path(), true)?.read()); + println!("{:#?}", *config.read()); + return Ok(()); } - if _args.delete_data { - let _ = std::fs::remove_file(save_data_path()); + if args.delete_data { + let _ = std::fs::remove_file(paths::managed::save_data()); return Ok(()); } - if let Some(mode) = _args.print_theme_options { - print_style_options(mode.unwrap_or_default(), _args.counted_styles); + if let Some(mode) = args.print_theme_options { + print_style_options(mode.unwrap_or_default(), args.counted_styles); return Ok(()); } - if _args.print_terminal_schemes { + if args.print_terminal_schemes { print_builtin_terminal_schemes(); return Ok(()); } better_panic::install(); - let mut app = App::empty(_args.read_only); + let mut app = App::empty(args.read_only); let menu = MainMenu::new(); app.activities_mut() .push(Activity::new_base_boxed("main menu", menu)); @@ -146,7 +162,7 @@ fn print_style_options(mode: StylesPrintMode, counted: bool) { fn print_builtin_terminal_schemes() { println!("Built-in terminal color schemes:"); - for name in tmaze::settings::theme::TerminalColorScheme::all_schemes() { + for name in TerminalColorScheme::all_schemes() { println!("- {}", name); } println!("Credit to https://github.com/alacritty/alacritty-theme"); diff --git a/tmaze/src/renderer/mod.rs b/tmaze/src/renderer/mod.rs index 3ed8bb10..3298f59e 100644 --- a/tmaze/src/renderer/mod.rs +++ b/tmaze/src/renderer/mod.rs @@ -299,7 +299,7 @@ impl Cell { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct GBuffer(Array3D, SharedScheme); impl GBuffer { @@ -369,6 +369,13 @@ impl GBuffer { } } +impl PartialEq for GBuffer { + fn eq(&self, other: &Self) -> bool { + // Note: we purposely ignore the scheme + self.0 == other.0 + } +} + #[derive(Clone, Copy, Debug)] pub struct GView<'a> { buf: &'a GBuffer, diff --git a/tmaze/src/settings/config_utils.rs b/tmaze/src/settings/config_utils.rs new file mode 100644 index 00000000..2e797c11 --- /dev/null +++ b/tmaze/src/settings/config_utils.rs @@ -0,0 +1,332 @@ +use std::fmt::Display; + +use hashbrown::HashMap; +use serde::{Deserialize, Serialize}; + +pub trait Mergeable { + fn merge(&mut self, other: &O); +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Segment { + Key(String), + Index(usize), +} + +impl Display for Segment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Segment::Key(key) => write!(f, "{}", key), + Segment::Index(index) => write!(f, "[{}]", index), + } + } +} + +pub type Path = Vec; + +#[derive(Clone, Debug)] +pub struct ConvertError { + // TODO: Add source file/line info + pub path: Path, + pub detail: String, +} + +impl std::fmt::Display for ConvertError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let path_str = self.path.iter().map(Segment::to_string).collect::>(); + let path_str = path_str.join("."); + write!(f, "'{}': {}", path_str, self.detail) + } +} + +pub struct ConvertContext { + pub path: Path, + pub errors: Vec, +} + +impl ConvertContext { + pub fn new() -> Self { + Self { + path: vec![], + errors: vec![], + } + } + + pub fn at(&mut self, segment: Segment, inside: impl FnOnce(&mut Self) -> T) -> T { + self.push(segment); + let t = inside(self); + self.pop(); + t + } + + pub fn push(&mut self, segment: Segment) { + self.path.push(segment); + } + + #[allow(dead_code)] + pub fn push_key(&mut self, key: &str) { + self.path.push(Segment::Key(key.to_string())); + } + + pub fn push_index(&mut self, index: usize) { + self.path.push(Segment::Index(index)); + } + + pub fn pop(&mut self) { + self.path.pop(); + } + + pub fn err(&mut self, detail: String) { + self.errors.push(ConvertError { + path: self.path.clone(), + detail, + }); + } + + pub fn errors(self) -> Vec { + self.errors + } +} + +pub trait LenientConvert: Sized { + fn convert(value: Value, context: &mut ConvertContext) -> Option; +} + +#[macro_export] +macro_rules! config { + (@step $name:ident + [$($fields:tt)*] + [$($rfields:tt)*] + [$($pfields:tt)*] + // this branch must be 1st so that #[nest] is parsed as special attribute + { #[nest] $(#[$attr:meta])* $field:ident : $type:ty, $($rest:tt)* } + ) => { + ::paste::paste! { + config!{ @step + $name + [ $($fields)* $field : [] = ::std::default::Default::default(), ] + [ $($rfields)* pub $field : $type, ] + [ $($pfields)* #[serde(skip_serializing_if = "Option::is_none")] $(#[$attr])* pub $field : Option<[]>, ] + { $($rest)* } + } + } + }; + + (@step $name:ident + [$($fields:tt)*] + [$($rfields:tt)*] + [$($pfields:tt)*] + { $(#[$attr:meta])* $field:ident : $type:ty, $($rest:tt)* } + ) => { + config!{ @step + $name + [ $($fields)* $field : $type = ::std::default::Default::default(), ] + [ $($rfields)* pub $field : $type, ] + [ $($pfields)* #[serde(skip_serializing_if = "Option::is_none")] $(#[$attr])* pub $field : Option<$type>, ] + { $($rest)* } + } + }; + + (@step $name:ident + [$($fields:tt)*] + [$($rfields:tt)*] + [$($pfields:tt)*] + { $(#[$attr:meta])* $field:ident : $type:ty = $def:expr, $($rest:tt)* } + ) => { + config!{ @step + $name + [ $($fields)* $field : $type = ($def), ] + [ $($rfields)* pub $field : $type, ] + [ $($pfields)* #[serde(skip_serializing_if = "Option::is_none")] $(#[$attr])* pub $field : Option<$type>, ] + { $($rest)* } + } + }; + + (@step $name:ident + [$($fields:ident : $type:ty = $def_vals:expr),* ,] + [$($rfields:tt)*] + [$($pfields:tt)*] + { } + ) => { + #[derive(Clone, Debug)] + pub struct $name { + $($rfields)* + } + + impl ::std::default::Default for $name { + fn default() -> Self { + Self { + $($fields : $def_vals,)* + } + } + } + + ::paste::paste! { + #[derive(Default, Clone, Serialize, Deserialize)] + pub struct [] { + $($pfields)* + } + + impl [] { + $( + #[allow(dead_code)] + pub fn $fields(&mut self) -> &mut $type { + self.$fields.get_or_insert($def_vals) + } + )* + } + + impl $crate::settings::config_utils::Mergeable<[]> for $name { + fn merge(&mut self, other: &[]) { + $( + if let Some(value) = &other.$fields { + self.$fields.merge(value); + } + )* + } + } + + impl $crate::settings::config_utils::LenientConvert for [] { + fn convert( + value: super::config_utils::Value, + context: &mut super::config_utils::ConvertContext, + ) -> Option { + let super::config_utils::Value::Object(mut map) = value else { + context.err("expected an object".to_string()); + return None; + }; + + Some(Self { + $( + $fields: { + if let Some(value) = map.remove(stringify!($fields)) { + context.at( + $crate::settings::config_utils::Segment::Key(stringify!($fields).to_string()), + |ctx| <$type as $crate::settings::config_utils::LenientConvert>::convert(value, ctx) + ) + } else { + None + } + }, + )* + }) + } + } + } + }; + + ($(pub struct $name:ident { $($body:tt)* })*) => { + $(config!{ @step $name [] [] [] { $($body)* } })* + }; +} + +#[macro_export] +macro_rules! impl_merge_prims { + ($($t:ty)*) => { + $(impl Mergeable<$t> for $t where $t: Clone { + fn merge(&mut self, other: &$t) { + *self = other.clone(); + } + })* + }; +} + +#[macro_export] +macro_rules! impl_lenient_prims { + ($($t:ty => $($variant:ident)+),* $(,)?) => { + $(impl $crate::settings::config_utils::LenientConvert for $t { + fn convert(value: super::config_utils::Value, context: &mut super::config_utils::ConvertContext) -> Option { + match value { + $(super::config_utils::Value::$variant(v) => Some(v as $t),)+ + _ => { + context.err(format!("expected one of: {}", stringify!($($variant),+))); + None + } + } + } + })* + }; +} + +#[macro_export] +macro_rules! impl_lenient_deserialize { + ($($t:ty)*) => { + $(impl $crate::settings::config_utils::LenientConvert for $t { + fn convert(value: super::config_utils::Value, context: &mut super::config_utils::ConvertContext) -> Option { + let json_value = ::serde_json::to_value(&value) + .expect("Failed to convert Value to JSON value"); // should not happen + match ::serde_json::from_value::<$t>(json_value) { + Ok(v) => Some(v), + Err(e) => { + context.err(format!("deserialization error: {}", e)); + None + } + } + } + })* + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +// Note: order of variants matters for correct deserialization +pub enum Value { + Object(HashMap), + List(Vec), + Int(i64), + Float(f64), + Bool(bool), + String(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_value_deserialize() { + let json_data = r#" + { + "name": "Example", + "enabled": true, + "threshold": 10.5, + "count": 42, + "items": [1, 2, 3], + "settings": { + "option1": "value1", + "option2": false + } + } + "#; + + let parsed: Value = serde_json::from_str(json_data).unwrap(); + + if let Value::Object(map) = parsed { + assert_eq!(map.get("name"), Some(&Value::String("Example".to_string()))); + assert_eq!(map.get("enabled"), Some(&Value::Bool(true))); + assert_eq!(map.get("threshold"), Some(&Value::Float(10.5))); + assert_eq!(map.get("count"), Some(&Value::Int(42))); + + if let Some(Value::List(items)) = map.get("items") { + assert_eq!(items.len(), 3); + assert_eq!(items[0], Value::Int(1)); + assert_eq!(items[1], Value::Int(2)); + assert_eq!(items[2], Value::Int(3)); + } else { + panic!("Expected 'items' to be a list"); + } + + if let Some(Value::Object(settings)) = map.get("settings") { + assert_eq!( + settings.get("option1"), + Some(&Value::String("value1".to_string())) + ); + assert_eq!(settings.get("option2"), Some(&Value::Bool(false))); + } else { + panic!("Expected 'settings' to be an object"); + } + } else { + panic!("Expected top-level value to be an object"); + } + } +} diff --git a/tmaze/src/settings/default_settings.json5 b/tmaze/src/settings/files/default_settings.json5 similarity index 100% rename from tmaze/src/settings/default_settings.json5 rename to tmaze/src/settings/files/default_settings.json5 diff --git a/tmaze/src/settings/default_settings.ron b/tmaze/src/settings/files/default_settings.ron similarity index 100% rename from tmaze/src/settings/default_settings.ron rename to tmaze/src/settings/files/default_settings.ron diff --git a/tmaze/src/settings/default_theme.json5 b/tmaze/src/settings/files/default_theme.json5 similarity index 100% rename from tmaze/src/settings/default_theme.json5 rename to tmaze/src/settings/files/default_theme.json5 diff --git a/tmaze/src/settings/mod.rs b/tmaze/src/settings/mod.rs index 492be4b7..c43d914a 100644 --- a/tmaze/src/settings/mod.rs +++ b/tmaze/src/settings/mod.rs @@ -1,626 +1,244 @@ -mod attribute; -pub mod style_browser; +pub mod attribute; +pub mod model; pub mod theme; -use cmaze::{ - algorithms::{MazeSpec, MazeSpecType}, - dims::{Dims, Offset}, -}; -use derivative::Derivative; -use serde::{Deserialize, Serialize}; -use std::{ - fs, io, - path::PathBuf, - sync::{Arc, RwLock}, -}; -use theme::ThemeDefinition; +// mod old_settings; -use crate::{ - app::{self, app::AppData, Activity, ActivityHandler, Change}, - helpers::constants::paths::settings_path, - menu_actions, - renderer::MouseGuard, - settings::theme::{SharedScheme, TerminalColorScheme}, - ui::{split_menu_actions, Menu, MenuAction, MenuConfig, MenuItem, OptionDef, Popup, Screen}, -}; +mod config_utils; -#[cfg(feature = "sound")] -use crate::sound::create_audio_settings; +use std::{fmt::Display, ops::Deref, panic::Location, path::Path, sync::Arc}; -const DEFAULT_SETTINGS_JSON: &str = include_str!("./default_settings.json5"); +use arc_swap::ArcSwap; +use hashbrown::HashMap; -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] -#[serde(tag = "mode")] -pub enum CameraMode { - #[default] - CloseFollow, - EdgeFollow { - x: Offset, - y: Offset, +use crate::{ + app::{ + app::{AppData, EventSink}, + event::EventReceiver, + Event, }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MazePreset { - pub title: String, - pub description: Option, - - #[serde(default)] - pub default: bool, - - // TODO: make `serde(flatten)` once switched to TOML/JSON - #[serde(flatten)] - pub maze_spec: MazeSpec, -} - -impl MazePreset { - pub fn short_desc(&self) -> Option { - let (size, cells): (_, usize) = match &self.maze_spec.inner_spec { - MazeSpecType::Regions { regions, .. } => ( - self.maze_spec.size()?, - regions.iter().map(|r| r.mask.enabled_count()).sum(), - ), - MazeSpecType::Simple { mask, .. } => ( - self.maze_spec.size()?, - mask.as_ref() - .map(|m| m.enabled_count()) - .unwrap_or(self.maze_spec.size()?.product() as usize), - ), - }; - - if size.2 == 1 { - Some(format!( - "{}: {}x{} ({} cells)", - self.title, size.0, size.1, cells - )) - } else { - Some(format!( - "{}: {}x{}x{} ({} cells)", - self.title, size.0, size.1, size.2, cells - )) - } - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] -pub enum UpdateCheckInterval { - Never, - #[default] - Daily, - Weekly, - Monthly, - Yearly, - Always, -} + helpers::{constants::paths, TupleMap}, + settings::config_utils::{ConvertContext, ConvertError, LenientConvert, Mergeable, Value}, +}; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum TerminalSchemeDef { - Named(String), - Custom(TerminalColorScheme), -} +use model::{Config, PartialConfig}; -#[derive(Debug, Derivative, Serialize, Deserialize)] -#[derivative(Default)] -#[serde(rename = "Settings")] -// FIXME: separate sections into their own struct -pub struct SettingsInner { - // general - #[serde(default)] - pub theme: Option, - #[serde(default)] - pub logging_level: Option, - #[serde(default)] - pub debug_logging_level: Option, - #[serde(default)] - pub file_logging_level: Option, - #[serde(default)] - pub terminal_scheme: Option, - - // viewport - #[serde(default)] - pub slow: Option, - #[serde(default)] - pub disable_tower_auto_up: Option, - #[serde(default)] - pub camera_mode: Option, - #[serde(default)] - pub camera_smoothing: Option, - #[serde[default]] - pub player_smoothing: Option, - #[serde(default)] - pub viewport_margin: Option<(i32, i32)>, - - // navigation - #[serde(default)] - pub enable_mouse: Option, - #[serde(default)] - pub enable_dpad: Option, - #[serde(default)] - pub landscape_dpad_on_left: Option, - #[serde(default)] - pub dpad_swap_up_down: Option, - #[serde(default)] - pub enable_margin_around_dpad: Option, - #[serde(default)] - pub enable_dpad_highlight: Option, - - // update check - #[serde(default)] - pub update_check_interval: Option, - #[serde(default)] - pub display_update_check_errors: Option, - - // audio - #[serde(default)] - pub enable_audio: Option, - #[serde(default)] - pub audio_volume: Option, - #[serde(default)] - pub enable_music: Option, - #[serde(default)] - pub music_volume: Option, - - // presets - #[serde(default)] - pub presets: Option>, - // TODO: it's not possible in RON to have a HashMap with flattened keys, - // so we will support it in different way formats - // once we support them - this would mean dropping RON support - // https://github.com/ron-rs/ron/issues/115 - // pub unknown_fields: HashMap, -} - -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct Settings { - shared: Arc>, - path: PathBuf, - read_only: bool, + inner: Arc, + event_sink: EventSink, } -impl Default for Settings { - fn default() -> Self { - let settings = SettingsInner::default(); - Self { - shared: Arc::new(RwLock::new(settings)), - path: settings_path(), - read_only: false, - } - } -} - -#[allow(dead_code)] impl Settings { - pub fn new() -> Self { - Self::default() + /// Loads settings from configuration files. + /// + /// Returns the loaded settings and a boolean indicating whether any warnings occurred during + /// loading. + /// + /// TODO: Report the actual errors/warnings to the user. + pub fn load(event_sink: EventSink) -> (Self, Option>) { + SettingsInner::load().map_first(|inner| Self { + inner: Arc::new(inner), + event_sink, + }) } - pub fn path(&self) -> PathBuf { - self.path.clone() + pub fn read(&self) -> impl Deref> + use<'_> { + self.inner.config.load() } - pub fn is_ro(&self) -> bool { - self.read_only - } + #[track_caller] + pub fn update_ui(&self, with: impl FnOnce(&mut PartialConfig)) { + log::trace!("Updating UI settings from {}", Location::caller()); + let mut new_ui = (**self.inner.ui_layer.load()).clone(); + with(&mut new_ui); + self.inner.ui_layer.store(Arc::new(new_ui)); - pub fn read(&self) -> std::sync::RwLockReadGuard<'_, SettingsInner> { - self.shared.read().unwrap() + self.inner.rebuild(); + self.notify(); } - pub fn write(&mut self) -> std::sync::RwLockWriteGuard<'_, SettingsInner> { - self.shared.write().unwrap() + fn write_ui(&self) { + log::trace!("Writing UI settings to file"); + std::fs::write( + paths::managed::ui_settings(), + serde_json::to_string_pretty(&**self.inner.ui_layer.load()) + .expect("UI settings should be serializable"), + ) + .expect("Failed to write UI settings to file"); } -} - -impl Settings { - pub fn get_theme(&self) -> ThemeDefinition { - let name = self.read().theme.clone(); - let maybe_theme = match &name { - Some(theme_name) => ThemeDefinition::load_by_name(theme_name), - None => ThemeDefinition::load_default(self.read_only), - }; - match maybe_theme { - Ok(theme) => theme, - Err(err) if name.is_some() => { - log::error!("Could not load the theme: {}", err); - ThemeDefinition::parse_default() - } - Err(err) if name.is_none() => { - log::error!("Could not load the default theme: {}", err); - ThemeDefinition::parse_default() - } - _ => unreachable!("`is_none` and `is_some` handle all cases"), - } - } - - pub fn get_logging_level(&self) -> log::Level { - self.read() - .logging_level - .clone() - .and_then(|level| level.parse().ok()) - .unwrap_or(log::Level::Info) - } - - pub fn get_debug_logging_level(&self) -> log::Level { - self.read() - .debug_logging_level - .clone() - .and_then(|level| level.parse().ok()) - .unwrap_or(log::Level::Info) - } - - pub fn get_file_logging_level(&self) -> log::Level { - self.read() - .file_logging_level - .clone() - .and_then(|level| level.parse().ok()) - .unwrap_or(log::Level::Info) + fn notify(&self) { + self.event_sink + .send(Event::SettingsChanged) + .expect("Event drain should be alive"); } +} - pub fn get_terminal_scheme(&self) -> Option { - match &self.read().terminal_scheme { - Some(TerminalSchemeDef::Named(name)) => { - Some(SharedScheme::new(TerminalColorScheme::named(name)?)) +impl EventReceiver for &Settings { + fn register(self) -> Box { + let settings = self.clone(); + Box::new(move |event, _| { + if let Event::SettingsChanged = event { + log::trace!("Writing UI settings to file from event"); + settings.write_ui(); } - Some(TerminalSchemeDef::Custom(scheme)) => Some(SharedScheme::new(scheme.clone())), - None => Some(SharedScheme::new(TerminalColorScheme::default())), - } - } - - pub fn get_slow(&self) -> bool { - self.read().slow.unwrap_or_default() - } - - pub fn set_slow(&mut self, value: bool) -> &mut Self { - self.write().slow = Some(value); - self - } - - pub fn get_disable_tower_auto_up(&self) -> bool { - self.read().disable_tower_auto_up.unwrap_or_default() - } - - pub fn set_disable_tower_auto_up(&mut self, value: bool) -> &mut Self { - self.write().disable_tower_auto_up = Some(value); - self - } - - pub fn get_camera_mode(&self) -> CameraMode { - self.read().camera_mode.unwrap_or_default() - } - - pub fn set_camera_mode(&mut self, value: CameraMode) -> &mut Self { - self.write().camera_mode = Some(value); - self - } - - pub fn get_camera_smoothing(&self) -> f32 { - self.read().camera_smoothing.unwrap_or(0.5).clamp(0.5, 1.0) - } - - pub fn set_camera_smoothing(&mut self, value: f32) -> &mut Self { - self.write().camera_smoothing = Some(value.clamp(0.5, 1.0)); - self - } - - pub fn get_player_smoothing(&self) -> f32 { - self.read().player_smoothing.unwrap_or(0.8).clamp(0.5, 1.0) - } - - pub fn set_player_smoothing(&mut self, value: f32) -> &mut Self { - self.write().player_smoothing = Some(value.clamp(0.5, 1.0)); - self - } - - pub fn get_viewport_margin(&self) -> Dims { - self.read() - .viewport_margin - .map(Dims::from) - .unwrap_or(Dims(4, 3)) - } - - pub fn set_viewport_margin(&mut self, value: Dims) -> &mut Self { - self.write().viewport_margin = Some(value.into()); - self - } - - pub fn get_enable_mouse(&self) -> bool { - self.read().enable_mouse.unwrap_or(true) - } - - pub fn set_enable_mouse(&mut self, value: bool) -> &mut Self { - self.write().enable_mouse = Some(value); - self - } - - pub fn get_enable_dpad(&self) -> bool { - self.read().enable_dpad.unwrap_or(false) - } - - pub fn set_enable_dpad(&mut self, value: bool) -> &mut Self { - self.write().enable_dpad = Some(value); - self - } - - pub fn get_landscape_dpad_on_left(&self) -> bool { - self.read().landscape_dpad_on_left.unwrap_or(false) - } - - pub fn set_landscape_dpad_on_left(&mut self, value: bool) -> &mut Self { - self.write().landscape_dpad_on_left = Some(value); - self - } - - pub fn get_dpad_swap_up_down(&self) -> bool { - self.read().dpad_swap_up_down.unwrap_or(false) - } - - pub fn set_dpad_swap_up_down(&mut self, value: bool) -> &mut Self { - self.write().dpad_swap_up_down = Some(value); - self - } - - pub fn get_enable_margin_around_dpad(&self) -> bool { - self.read().enable_margin_around_dpad.unwrap_or(false) - } - - pub fn set_enable_margin_around_dpad(&mut self, value: bool) -> &mut Self { - self.write().enable_margin_around_dpad = Some(value); - self - } - - pub fn get_enable_dpad_highlight(&self) -> bool { - self.read().enable_dpad_highlight.unwrap_or(true) - } - - pub fn set_enable_dpad_highlight(&mut self, value: bool) -> &mut Self { - self.write().enable_dpad_highlight = Some(value); - self - } - - pub fn set_check_interval(&mut self, value: UpdateCheckInterval) -> &mut Self { - self.write().update_check_interval = Some(value); - self - } - - pub fn get_check_interval(&self) -> UpdateCheckInterval { - self.read().update_check_interval.unwrap_or_default() - } - - pub fn get_display_update_check_errors(&self) -> bool { - self.read().display_update_check_errors.unwrap_or(true) - } - - pub fn set_display_update_check_errors(&mut self, value: bool) -> &mut Self { - self.write().display_update_check_errors = Some(value); - self - } - - pub fn get_enable_audio(&self) -> bool { - self.read().enable_audio.unwrap_or_default() - } - - pub fn set_enable_audio(&mut self, value: bool) -> &mut Self { - self.write().enable_audio = Some(value); - self - } - - pub fn get_audio_volume(&self) -> f32 { - self.read().audio_volume.unwrap_or_default().clamp(0., 1.) - } - - pub fn set_audio_volume(&mut self, value: f32) -> &mut Self { - self.write().audio_volume = Some(value.clamp(0., 1.)); - self + }) } +} - pub fn get_enable_music(&self) -> bool { - self.read().enable_music.unwrap_or_default() - } +struct SettingsInner { + config_layer: PartialConfig, + ui_layer: ArcSwap, + config: ArcSwap, +} - pub fn set_enable_music(&mut self, value: bool) -> &mut Self { - self.write().enable_music = Some(value); - self - } +impl SettingsInner { + fn load() -> (Self, Option>) { + let mut errors = vec![]; - pub fn get_music_volume(&self) -> f32 { - self.read().music_volume.unwrap_or_default().clamp(0., 1.) - } + let (config_layer, load_errors) = load_config_from_file(&paths::config()); + errors.extend(load_errors.iter().map(ConvertError::to_string)); - pub fn set_music_volume(&mut self, value: f32) -> &mut Self { - self.write().music_volume = Some(value.clamp(0., 1.)); - self - } + let ui_layer = load_ui_config_from_file(&paths::managed::ui_settings()); - pub fn set_presets(&mut self, value: Vec) -> &mut Self { - self.write().presets = Some(value); - self - } + let settings = Self { + config_layer, + ui_layer: ArcSwap::from_pointee(ui_layer), + config: ArcSwap::default(), + }; - pub fn get_presets(&self) -> Vec { - self.read().presets.clone().unwrap_or_default() - } -} + settings.rebuild(); -// JSON -impl Settings { - pub fn load_json(path: PathBuf, read_only: bool) -> io::Result { - let settings_string = fs::read_to_string(&path); - let settings: SettingsInner = if let Ok(settings_string) = settings_string { - json5::from_str(&settings_string) - .expect("Could not parse settings file: check the syntax") + let errors = if errors.is_empty() { + None } else { - if !read_only { - fs::create_dir_all(path.parent().unwrap())?; - fs::write(&path, DEFAULT_SETTINGS_JSON)?; - } - json5::from_str(DEFAULT_SETTINGS_JSON).unwrap() + Some(errors) }; - Ok(Self { - shared: Arc::new(RwLock::new(settings)), - path, - read_only, - }) + (settings, errors) } - pub fn reset_json(&mut self) { - *self.write() = json5::from_str(DEFAULT_SETTINGS_JSON).unwrap(); - - let path = settings_path(); - fs::write(&path, DEFAULT_SETTINGS_JSON).unwrap(); + fn rebuild(&self) { + let mut config = Config::default(); + config.merge(&self.config_layer); + config.merge(&self.ui_layer.load()); - self.path = path; - } - - pub fn reset_json_config(path: PathBuf) { - fs::write(path, DEFAULT_SETTINGS_JSON).unwrap(); + self.config.store(Arc::new(config)); } } -struct OtherSettingsPopup(Popup, MouseGuard); - -impl OtherSettingsPopup { - fn new(settings: &Settings) -> Self { - let popup = Popup::new( - "Other settings".to_string(), - vec![ - "Path to the current settings:".to_string(), - format!(" {}", settings.path().to_string_lossy().to_string()), - "".to_string(), - "Other settings are not implemented in UI yet.".to_string(), - "Please edit the settings file directly.".to_string(), - ], - ); - - Self(popup, MouseGuard::new().unwrap()) - } +#[derive(Debug, thiserror::Error)] +enum ConfigLoadError { + IoError(#[from] std::io::Error), + JsonError(#[from] json5::Error), + SettingsFormatError(String), } -impl ActivityHandler for OtherSettingsPopup { - fn update(&mut self, events: Vec, data: &mut AppData) -> Option { - self.0.update(events, data) - } - - fn screen(&mut self) -> &mut dyn Screen { - &mut self.0 +impl Display for ConfigLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigLoadError::IoError(e) => write!(f, "I/O error: {}", e), + ConfigLoadError::JsonError(e) => write!(f, "Parse error: {}", e), + ConfigLoadError::SettingsFormatError(e) => write!(f, "Settings format error: {}", e), + } } } -pub struct SettingsActivity { - actions: Vec>, - menu: Menu, -} - -impl SettingsActivity { - fn other_settings_popup(settings: &Settings) -> Activity { - Activity::new_base_boxed("settings".to_string(), OtherSettingsPopup::new(settings)) - } +fn load_ui_config_from_file(path: &Path) -> PartialConfig { + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() } -#[allow(clippy::new_without_default)] -impl SettingsActivity { - pub fn new() -> Self { - let options = menu_actions!( - "Audio" on "sound" -> data => Change::push(create_audio_settings(data)), - "Controls" -> data => Change::push(create_controls_settings(data)), - "Other settings" -> data => Change::push(SettingsActivity::other_settings_popup(&data.settings)), - "Back" -> _ => Change::pop_top(), - ); - - let (options, actions) = split_menu_actions(options); +fn load_config_from_file(path: &Path) -> (PartialConfig, Vec) { + let mut context = ConvertContext::new(); + let config = match load_values_from_file(path) { + Ok(value) => PartialConfig::convert(value, &mut context), + Err((e, val)) => { + context.err(format!("Failed to load config: {}", e)); + PartialConfig::convert(val, &mut context) + } + }; - let menu_config = MenuConfig::new("Settings", options).subtitle("Changes are not saved"); + (config.unwrap_or_default(), context.errors()) +} - Self { - actions, - menu: Menu::new(menu_config), - } +fn load_values_from_file(path: &Path) -> Result { + macro_rules! pack_error { + ($err:expr) => { + match $err { + Ok(val) => Ok(val), + Err(e) => Err((e.into(), Value::Object(HashMap::new()))), + } + }; } - pub fn new_activity() -> Activity { - Activity::new_base_boxed("settings".to_string(), Self::new()) + // json5 doesn't support reading from reader + let content = pack_error!(std::fs::read_to_string(path))?; + let config_value = pack_error!(json5::from_str::(&content))?; + let values_with_extensions = load_extension_blocks(config_value)?; + Ok(values_with_extensions) +} + +mod config_file_constants { + pub const IMPORT_KEY: &str = "#from"; +} + +fn load_extension_blocks(config: Value) -> Result { + use config_file_constants::IMPORT_KEY; + + /// Merges `ext` into `base`. In case of conflict, `ext` takes precedence. + /// Note that in this case, `base` is file behing `#from`, and `ext` is the current file. + /// + /// For objects, merging is done recursively. + /// + /// TODO: Allow rules customization in the future, for example to support list contatenation + /// instead of replacement. + fn merge(base: &mut Value, ext: Value) { + match (base, ext) { + (Value::Object(base_map), Value::Object(ext_map)) => { + for (key, ext_value) in ext_map { + if key == IMPORT_KEY { + continue; // skip #from key during merge + } + + if let Some(base_value) = base_map.get_mut(&key) { + merge(base_value, ext_value); + } else { + base_map.insert(key, ext_value); + } + } + } + (base_val, ext) => { + *base_val = ext; + } + } } -} -impl ActivityHandler for SettingsActivity { - fn update(&mut self, events: Vec, data: &mut AppData) -> Option { - match self.menu.update(events, data)? { - Change::Pop { - res: Some(sub_activity), - .. - } => { - let index = *sub_activity - .downcast::() - .expect("menu should return index"); - Some((self.actions[index])(data)) + let value = match config { + Value::Object(map) => { + if map.contains_key(IMPORT_KEY) { + if let Value::String(file_path) = &map[IMPORT_KEY] { + let mut base_config = load_values_from_file(Path::new(file_path))?; + merge(&mut base_config, Value::Object(map)); + base_config + } else { + return Err(( + ConfigLoadError::SettingsFormatError(format!( + "{IMPORT_KEY} value must be a string", + )), + Value::Object(map), + )); + } + } else { + Value::Object(map) } - res => Some(res), } - } - fn screen(&mut self) -> &mut dyn Screen { - &mut self.menu - } -} + val => val, + }; -pub fn create_controls_settings(data: &mut AppData) -> Activity { - let menu_config = MenuConfig::new( - "Controls settings", - [ - MenuItem::Option(OptionDef { - text: "Enable mouse input".into(), - val: data.settings.get_enable_mouse(), - fun: Box::new(|enabled, data| { - *enabled = !*enabled; - data.settings.set_enable_mouse(*enabled); - }), - }), - MenuItem::Option(OptionDef { - text: "Enable dpad".into(), - val: data.settings.get_enable_dpad(), - fun: Box::new(|enabled, data| { - *enabled = !*enabled; - data.settings.set_enable_dpad(*enabled); - }), - }), - MenuItem::Option(OptionDef { - text: "Left-handed dpad".into(), - val: data.settings.get_landscape_dpad_on_left(), - fun: Box::new(|is_on_left, data| { - *is_on_left = !*is_on_left; - data.settings.set_landscape_dpad_on_left(*is_on_left); - }), - }), - MenuItem::Option(OptionDef { - text: "Swap Up and Down buttons".into(), - val: data.settings.get_dpad_swap_up_down(), - fun: Box::new(|do_swap, data| { - *do_swap = !*do_swap; - data.settings.set_dpad_swap_up_down(*do_swap); - }), - }), - MenuItem::Option(OptionDef { - text: "Enable margin around dpad".into(), - val: data.settings.get_enable_margin_around_dpad(), - fun: Box::new(|enabled, data| { - *enabled = !*enabled; - data.settings.set_enable_margin_around_dpad(*enabled); - }), - }), - MenuItem::Option(OptionDef { - text: "Enable dpad highlight".into(), - val: data.settings.get_enable_dpad_highlight(), - fun: Box::new(|enabled, data| { - *enabled = !*enabled; - data.settings.set_enable_dpad_highlight(*enabled); - }), - }), - MenuItem::Separator, - MenuItem::Text("Exit".into()), - ], - ); - - Activity::new_base_boxed("controls settings", Menu::new(menu_config)) + Ok(value) } diff --git a/tmaze/src/settings/model.rs b/tmaze/src/settings/model.rs new file mode 100644 index 00000000..2a5d9a95 --- /dev/null +++ b/tmaze/src/settings/model.rs @@ -0,0 +1,198 @@ +use std::ops::Deref; + +use cmaze::{ + algorithms::{MazeSpec, MazeSpecType}, + dims::{Dims, Offset}, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + config, impl_lenient_deserialize, impl_lenient_prims, impl_merge_prims, + settings::{ + config_utils::{ConvertContext, LenientConvert, Mergeable, Value}, + theme::{PartialTerminalColorScheme, TerminalColorScheme}, + }, +}; + +config! { + pub struct Config { + #[nest] general: General, + #[nest] viewport: Viewport, + #[nest] nagivation: Navigation, + #[nest] updates: Updates, + #[nest] audio: Audio, + presets: PresetList, + } + + pub struct General { + theme: String, + logging_level: log::Level = log::Level::Info, + debug_logging_level: log::Level = log::Level::Info, + file_logging_level: log::Level = log::Level::Info, + #[nest] terminal_scheme: TerminalColorScheme, + } + + pub struct Viewport { + slow: bool, + disable_tower_auto_up: bool, + camera_mode: CameraMode, + camera_smoothing: f64 = 0.5, + player_smoothing: f64 = 0.5, + viewport_margin: Dims = Dims(4, 3), + } + + pub struct Navigation { + enable_mouse: bool = true, + enable_dpad: bool, + landscape_dpad_on_left: bool, + dpad_swap_up_down: bool, + enable_margin_around_dpad: bool, + enable_dpad_highlight: bool = true, + } + + pub struct Updates { + check_interval: UpdateCheckInterval, + display_update_check_errors: bool, + } + + pub struct Audio { + enable_audio: bool, + audio_volume: f64, + enable_music: bool, + music_volume: f64, + } +} + +impl_merge_prims! { + i64 + bool + f64 + String + + Rgb + Dims + + log::Level + CameraMode + UpdateCheckInterval +} + +impl_lenient_prims! { + i64 => Int, + bool => Bool, + f64 => Float Int, + String => String, +} + +impl_lenient_deserialize! { + log::Level + CameraMode + UpdateCheckInterval + MazePreset + Dims + Rgb +} + +type Rgb = (u8, u8, u8); + +#[derive(Default, Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +#[serde(tag = "mode")] +pub enum CameraMode { + #[default] + CloseFollow, + EdgeFollow { + x: Offset, + y: Offset, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +pub enum UpdateCheckInterval { + Never, + #[default] + Daily, + Weekly, + Monthly, + Yearly, + Always, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PresetList(Vec); + +impl Deref for PresetList { + type Target = [MazePreset]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Mergeable for PresetList { + fn merge(&mut self, other: &Self) { + self.0.extend_from_slice(&other.0); + } +} + +impl LenientConvert for PresetList { + fn convert(value: Value, context: &mut ConvertContext) -> Option { + let Value::List(list) = value else { + context.err("expected a list of maze presets".to_string()); + return None; + }; + + let mut presets = vec![]; + for (i, item) in list.into_iter().enumerate() { + context.push_index(i); + match MazePreset::convert(item, context) { + Some(preset) => presets.push(preset), + None => { /* error already recorded */ } + } + context.pop(); + } + + Some(PresetList(presets)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MazePreset { + pub title: String, + pub description: Option, + + #[serde(default)] + pub default: bool, + + #[serde(flatten)] + pub maze_spec: MazeSpec, +} + +impl MazePreset { + pub fn short_desc(&self) -> Option { + let (size, cells): (_, usize) = match &self.maze_spec.inner_spec { + MazeSpecType::Regions { regions, .. } => ( + self.maze_spec.size()?, + regions.iter().map(|r| r.mask.enabled_count()).sum(), + ), + MazeSpecType::Simple { mask, .. } => ( + self.maze_spec.size()?, + mask.as_ref() + .map(|m| m.enabled_count()) + .unwrap_or(self.maze_spec.size()?.product() as usize), + ), + }; + + if size.2 == 1 { + Some(format!( + "{}: {}x{} ({} cells)", + self.title, size.0, size.1, cells + )) + } else { + Some(format!( + "{}: {}x{}x{} ({} cells)", + self.title, size.0, size.1, size.2, cells + )) + } + } +} diff --git a/tmaze/src/settings/theme.rs b/tmaze/src/settings/theme.rs index 771db8c8..e26c76e9 100644 --- a/tmaze/src/settings/theme.rs +++ b/tmaze/src/settings/theme.rs @@ -6,10 +6,12 @@ use serde::{de::Error, Deserialize, Serialize}; use thiserror::Error; use crate::{ - helpers::{constants::paths::theme_file_path, ToDebug}, - settings::attribute::deserialize_attributes, + config, + helpers::{constants::paths::theme_file, ToDebug}, }; +use super::attribute::deserialize_attributes; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Theme { styles: HashMap, @@ -54,7 +56,7 @@ macro_rules! default_theme_name { }; } const DEFAULT_THEME_NAME: &str = default_theme_name!(); -const DEFAULT_THEME: &str = include_str!(concat!("./", default_theme_name!())); +const DEFAULT_THEME: &str = include_str!(concat!("./files/", default_theme_name!())); impl ThemeDefinition { pub fn parse_default() -> Self { @@ -77,7 +79,7 @@ impl ThemeDefinition { } fn prepare_default_theme() -> Result { - let path = theme_file_path(DEFAULT_THEME_NAME); + let path = theme_file(DEFAULT_THEME_NAME); std::fs::create_dir_all(path.parent().unwrap())?; if !path.exists() { @@ -87,8 +89,8 @@ impl ThemeDefinition { Self::load_by_path(path) } - pub fn load_by_name(path: &str) -> Result { - Self::load_by_path(theme_file_path(path)) + pub fn load_by_name(name: &str) -> Result { + Self::load_by_path(theme_file(name)) } pub fn load_by_path(path: PathBuf) -> Result { @@ -99,6 +101,8 @@ impl ThemeDefinition { .and_then(|s| s.to_str()) .expect("No extension"); + // TODO: names without extension + match ext { "toml" => Self::load_toml(path), "json" | "json5" => Self::load_json(path), @@ -461,26 +465,27 @@ pub type Rgb = (u8, u8, u8); pub type SharedScheme = Rc; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalColorScheme { - primary_fg: Rgb, - primary_bg: Rgb, - black: Rgb, // grey - dark_grey: Rgb, // dark grey - red: Rgb, - dark_red: Rgb, - green: Rgb, - dark_green: Rgb, - yellow: Rgb, - dark_yellow: Rgb, - blue: Rgb, - dark_blue: Rgb, - magenta: Rgb, - dark_magenta: Rgb, - cyan: Rgb, - dark_cyan: Rgb, - white: Rgb, - grey: Rgb, +config! { + pub struct TerminalColorScheme { + primary_fg: Rgb = (255, 255, 255), + primary_bg: Rgb = (0, 0, 0), + black: Rgb = (0, 0, 0), + dark_grey: Rgb = (64, 64, 64), + red: Rgb = (255, 0, 0), + dark_red: Rgb = (128, 0, 0), + green: Rgb = (0, 255, 0), + dark_green: Rgb = (0, 128, 0), + yellow: Rgb = (255, 255, 0), + dark_yellow: Rgb = (128, 128, 0), + blue: Rgb = (0, 0, 255), + dark_blue: Rgb = (0, 0, 128), + magenta: Rgb = (255, 0, 255), + dark_magenta: Rgb = (128, 0, 128), + cyan: Rgb = (0, 255, 255), + dark_cyan: Rgb = (0, 128, 128), + white: Rgb = (255, 255, 255), + grey: Rgb = (192, 192, 192), + } } impl TerminalColorScheme { @@ -531,31 +536,6 @@ impl TerminalColorScheme { } } -impl Default for TerminalColorScheme { - fn default() -> Self { - TerminalColorScheme { - primary_fg: (255, 255, 255), - primary_bg: (0, 0, 0), - black: (0, 0, 0), - dark_grey: (64, 64, 64), - red: (255, 0, 0), - dark_red: (128, 0, 0), - green: (0, 255, 0), - dark_green: (0, 128, 0), - yellow: (255, 255, 0), - dark_yellow: (128, 128, 0), - blue: (0, 0, 255), - dark_blue: (0, 0, 128), - magenta: (255, 0, 255), - dark_magenta: (128, 0, 128), - cyan: (0, 255, 255), - dark_cyan: (0, 128, 128), - white: (255, 255, 255), - grey: (192, 192, 192), - } - } -} - #[derive(Clone, Debug, Default)] pub struct ThemeResolver(HashMap); diff --git a/tmaze/src/sound/mod.rs b/tmaze/src/sound/mod.rs index 17e48b95..8fd38169 100644 --- a/tmaze/src/sound/mod.rs +++ b/tmaze/src/sound/mod.rs @@ -4,7 +4,7 @@ use menu::OptionDef; use rodio::{OutputStream, OutputStreamHandle, Sink}; use crate::{ - app::{app::AppData, Activity}, + app::{app::AppData, event::EventReceiver, Activity, Event}, settings::Settings, ui::{menu, MenuItem, SliderDef}, }; @@ -73,7 +73,7 @@ impl SoundPlayer { return; }; let sink = Sink::try_new(handle).expect("Failed to create sink"); - sink.set_volume(self.settings.get_audio_volume()); + sink.set_volume(self.settings.read().audio.audio_volume as f32); sink.append(track); sink.play(); sink.detach(); @@ -89,58 +89,93 @@ impl SoundPlayer { } } -pub fn create_audio_settings(data: &mut AppData) -> Activity { - fn update_vol(data: &mut AppData) { - if data.settings.get_enable_audio() && data.settings.get_enable_music() { - data.sound_player - .set_volume(data.settings.get_audio_volume() * data.settings.get_music_volume()); - } else { - data.sound_player.set_volume(0.0); - } +impl EventReceiver for &SoundPlayer { + fn register(self) -> crate::app::event::EventReceiverFn { + Box::new(move |event, data| { + if let Event::SettingsChanged = event { + let cfg = &data.settings.read().audio; + + if cfg.enable_audio && cfg.enable_music { + data.sound_player + .set_volume((cfg.audio_volume * cfg.music_volume) as f32); + } else { + data.sound_player.set_volume(0.0); + } + } + }) } +} + +pub fn create_audio_settings(data: &mut AppData) -> Activity { + let config = &data.settings.read().audio; let menu_config = menu::MenuConfig::new( "Audio settings", [ MenuItem::Option(OptionDef { text: "Global mute".into(), - val: !data.settings.get_enable_audio(), - fun: Box::new(|mute, data| { - *mute = !*mute; - data.settings.set_enable_audio(!*mute); - update_vol(data); + val: !config.enable_audio, + update_fn: Box::new(|mute, data| { + data.settings.update_ui(|cfg| { + *cfg.audio().enable_audio() = !mute; + }); }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.audio().enable_audio = None; + }); + !data.settings.read().audio.enable_audio + })), }), MenuItem::Slider(SliderDef { text: "Global volume".into(), - val: (data.settings.get_audio_volume() * 5.0) as i32, + val: (config.audio_volume * 5.0) as i32, range: 0..=5, as_num: false, - fun: Box::new(|up, vol, data| { - *vol += if up { 1 } else { -1 }; - data.settings.set_audio_volume(*vol as f32 / 5.0); - update_vol(data); + update_fn: Box::new(|vol, data| { + data.settings.update_ui(|cfg| { + *cfg.audio().audio_volume() = vol as f64 / 5.0; + }); }), + + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.audio().audio_volume = None; + }); + (data.settings.read().audio.audio_volume * 5.0) as i32 + })), }), MenuItem::Option(OptionDef { text: "Music mute".into(), - val: !data.settings.get_enable_music(), - fun: Box::new(|mute, data| { - *mute = !*mute; - data.settings.set_enable_music(!*mute); - update_vol(data); + val: !config.enable_music, + update_fn: Box::new(|mute, data| { + data.settings.update_ui(|cfg| { + *cfg.audio().enable_music() = !mute; + }); }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.audio().enable_music = None; + }); + !data.settings.read().audio.enable_music + })), }), MenuItem::Slider(SliderDef { text: "Music volume".into(), - val: (data.settings.get_music_volume() * 5.0) as i32, + val: (config.music_volume * 5.0) as i32, range: 0..=5, as_num: false, - fun: Box::new(|up, vol, data| { - *vol += if up { 1 } else { -1 }; - data.settings.set_music_volume(*vol as f32 / 5.0); - update_vol(data); + update_fn: Box::new(|vol, data| { + data.settings.update_ui(|cfg| { + *cfg.audio().music_volume() = vol as f64 / 5.0; + }); }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.audio().music_volume = None; + }); + (data.settings.read().audio.music_volume * 5.0) as i32 + })), }), MenuItem::Separator, MenuItem::Text("Exit".into()), diff --git a/tmaze/src/ui/menu.rs b/tmaze/src/ui/menu.rs index 33a18424..3459f379 100644 --- a/tmaze/src/ui/menu.rs +++ b/tmaze/src/ui/menu.rs @@ -33,7 +33,8 @@ pub struct SliderDef { #[allow(clippy::type_complexity)] // FIXME: take value instead of change direction (bool), // this should allow for mouse support - pub fun: Box, + pub update_fn: Box, + pub reset_fn: Option i32>>, pub as_num: bool, } @@ -42,7 +43,8 @@ pub struct OptionDef { pub val: bool, #[allow(clippy::type_complexity)] // FIXME: return the bool instead - pub fun: Box, + pub update_fn: Box, + pub reset_fn: Option bool>>, } // TODO: styling individual items @@ -311,6 +313,7 @@ pub struct Menu { impl Menu { pub fn new(config: MenuConfig) -> Self { let MenuConfig { options, .. } = &config; + debug_assert!(!options.is_empty(), "Menu must have at least one option"); let default = config.default.unwrap_or(0).clamp(0, options.len() - 1); @@ -348,7 +351,14 @@ impl Menu { match selected_opt { MenuItem::Text(_) => return Some(Change::pop_top_with(self.selected)), - MenuItem::Option(OptionDef { val, fun, .. }) => fun(val, data), + MenuItem::Option(OptionDef { + val, + update_fn: fun, + .. + }) => { + *val = !*val; + fun(*val, data); + } MenuItem::Slider(_) | MenuItem::Separator => {} } @@ -357,11 +367,15 @@ impl Menu { fn update_slider(&mut self, right: bool, data: &mut AppData) { if let MenuItem::Slider(SliderDef { - val, range, fun, .. + val, + range, + update_fn: fun, + .. }) = &mut self.config.options[self.selected] { - fun(right, val, data); + *val += if right { 1 } else { -1 }; *val = (*val).clamp(*range.start(), *range.end()); + fun(*val, data); } } @@ -382,6 +396,24 @@ impl Menu { Some(selected) } + + fn reset(&mut self, data: &mut AppData) { + let selected_opt = &mut self.config.options[self.selected]; + match selected_opt { + MenuItem::Text(_) => {} + MenuItem::Option(OptionDef { val, reset_fn, .. }) => { + if let Some(reset_fn) = reset_fn { + *val = reset_fn(data); + } + } + MenuItem::Slider(SliderDef { val, reset_fn, .. }) => { + if let Some(reset_fn) = reset_fn { + *val = reset_fn(data); + } + } + MenuItem::Separator => {} + } + } } impl ActivityHandler for Menu { @@ -421,7 +453,7 @@ impl ActivityHandler for Menu { for event in events { match event { - Event::Term(TermEvent::Key(KeyEvent { code, kind, .. })) if !is_release(kind) => { + Event::Term(TermEvent::Key(KeyEvent { code, kind, modifiers, .. })) if !is_release(kind) => { match code { KeyCode::Up | KeyCode::Char('w') => { self.select(false); @@ -454,6 +486,9 @@ impl ActivityHandler for Menu { KeyCode::Right => { self.update_slider(true, app_data); } + KeyCode::Char('r') if modifiers.contains(KeyModifiers::CONTROL) => { + self.reset(app_data); + } _ => {} } } diff --git a/tmaze/src/ui/mod.rs b/tmaze/src/ui/mod.rs index 540aa290..fabff225 100644 --- a/tmaze/src/ui/mod.rs +++ b/tmaze/src/ui/mod.rs @@ -2,10 +2,7 @@ pub use std::time::Duration; use crate::{ renderer::GMutView, - settings::{ - style_browser, - theme::{Theme, ThemeResolver}, - }, + settings::theme::{Theme, ThemeResolver}, }; pub mod button; @@ -49,8 +46,7 @@ pub fn theme_resolver() -> ThemeResolver { .extend(popup::popup_theme_resolver()) .extend(progressbar::progressbar_theme_resolver()) .extend(rect::rect_theme_resolver()) - .extend(usecase::usedcase_ui_theme_resolver()) - .extend(style_browser::style_browser_theme_resolver()); + .extend(usecase::usecase_ui_theme_resolver()); resolver } diff --git a/tmaze/src/ui/usecase/dpad.rs b/tmaze/src/ui/usecase/dpad.rs index bec8b741..19dd2c15 100644 --- a/tmaze/src/ui/usecase/dpad.rs +++ b/tmaze/src/ui/usecase/dpad.rs @@ -101,11 +101,7 @@ impl DPad { /// Splits the screen into space for the viewport and the dpad. /// - /// Returns a tuple containing: - /// - Whether the dpad is vertical - /// - Whether the dpad is on the left side - /// - The split offset - // pub fn split_screen(screen_size: Dims, on_left: bool) -> (bool, bool, Offset) { + /// Returns (viewport_rect, dpad_rect) pub fn split_screen(data: &AppData) -> (Rect, Rect) { let screen_size = data.screen_size; let screen_ratio = (screen_size.0 as f32 / 2.0) / screen_size.1 as f32; @@ -123,10 +119,10 @@ impl DPad { if is_vertical { screen_rect.split_y_end(Offset::Abs(dpad_size)) } else { - let on_right = !data.settings.get_landscape_dpad_on_left(); + let on_left = data.settings.read().nagivation.landscape_dpad_on_left; let offset = Offset::Abs(dpad_size); - if !on_right { + if on_left { let (dpad, vp) = screen_rect.split_x(offset); (vp, dpad) } else { diff --git a/tmaze/src/ui/usecase/mod.rs b/tmaze/src/ui/usecase/mod.rs index a2fbca92..a3bc0c20 100644 --- a/tmaze/src/ui/usecase/mod.rs +++ b/tmaze/src/ui/usecase/mod.rs @@ -3,10 +3,15 @@ use dpad::dpad_theme_resolver; use crate::settings::theme::ThemeResolver; pub mod dpad; +mod screens; -pub fn usedcase_ui_theme_resolver() -> ThemeResolver { +pub use screens::*; + +pub fn usecase_ui_theme_resolver() -> ThemeResolver { let mut resolver = ThemeResolver::new(); - resolver.extend(dpad_theme_resolver()); + resolver + .extend(dpad_theme_resolver()) + .extend(style_browser::style_browser_theme_resolver()); resolver } diff --git a/tmaze/src/ui/usecase/screens/mod.rs b/tmaze/src/ui/usecase/screens/mod.rs new file mode 100644 index 00000000..636e716a --- /dev/null +++ b/tmaze/src/ui/usecase/screens/mod.rs @@ -0,0 +1,2 @@ +pub mod settings; +pub mod style_browser; diff --git a/tmaze/src/ui/usecase/screens/settings.rs b/tmaze/src/ui/usecase/screens/settings.rs new file mode 100644 index 00000000..4861dfa6 --- /dev/null +++ b/tmaze/src/ui/usecase/screens/settings.rs @@ -0,0 +1,198 @@ +use crate::{ + app::{self, app::AppData, Activity, ActivityHandler, Change}, + helpers::constants::paths::config, + menu_actions, + renderer::MouseGuard, + sound::create_audio_settings, + ui::{split_menu_actions, Menu, MenuAction, MenuConfig, MenuItem, OptionDef, Popup, Screen}, +}; + +struct OtherSettingsPopup(Popup, MouseGuard); + +impl OtherSettingsPopup { + fn new() -> Self { + let popup = Popup::new( + "Other settings".to_string(), + vec![ + "Path to the current settings:".to_string(), + format!(" {}", config().to_string_lossy()), + "".to_string(), + "Other settings are not implemented in UI yet.".to_string(), + "Please edit the settings file directly.".to_string(), + ], + ); + + Self(popup, MouseGuard::new().unwrap()) + } +} + +impl ActivityHandler for OtherSettingsPopup { + fn update(&mut self, events: Vec, data: &mut AppData) -> Option { + self.0.update(events, data) + } + + fn screen(&mut self) -> &mut dyn Screen { + &mut self.0 + } +} + +pub struct SettingsActivity { + actions: Vec>, + menu: Menu, +} + +impl SettingsActivity { + fn other_settings_popup() -> Activity { + Activity::new_base_boxed("settings".to_string(), OtherSettingsPopup::new()) + } +} + +#[allow(clippy::new_without_default)] +impl SettingsActivity { + pub fn new() -> Self { + let options = menu_actions!( + "Audio" on "sound" -> data => Change::push(create_audio_settings(data)), + "Controls" -> data => Change::push(create_controls_settings(data)), + "Other settings" -> _ => Change::push(SettingsActivity::other_settings_popup()), + "Back" -> _ => Change::pop_top(), + ); + + let (options, actions) = split_menu_actions(options); + + let menu_config = MenuConfig::new("Settings", options).subtitle("Changes are not saved"); + + Self { + actions, + menu: Menu::new(menu_config), + } + } + + pub fn new_activity() -> Activity { + Activity::new_base_boxed("settings".to_string(), Self::new()) + } +} + +impl ActivityHandler for SettingsActivity { + fn update(&mut self, events: Vec, data: &mut AppData) -> Option { + match self.menu.update(events, data)? { + Change::Pop { + res: Some(sub_activity), + .. + } => { + let index = *sub_activity + .downcast::() + .expect("menu should return index"); + Some((self.actions[index])(data)) + } + res => Some(res), + } + } + + fn screen(&mut self) -> &mut dyn Screen { + &mut self.menu + } +} + +pub fn create_controls_settings(data: &mut AppData) -> Activity { + let cfg = &data.settings.read().nagivation; + + let menu_config = MenuConfig::new( + "Controls settings", + [ + MenuItem::Option(OptionDef { + text: "Enable mouse input".into(), + val: cfg.enable_mouse, + update_fn: Box::new(|enabled, data| { + data.settings.update_ui(|cfg| { + *cfg.nagivation().enable_mouse() = enabled; + }); + }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.nagivation().enable_mouse = None; + }); + data.settings.read().nagivation.enable_mouse + })), + }), + MenuItem::Option(OptionDef { + text: "Enable dpad".into(), + val: cfg.enable_dpad, + update_fn: Box::new(|enabled, data| { + data.settings.update_ui(|cfg| { + *cfg.nagivation().enable_dpad() = enabled; + }); + }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.nagivation().enable_dpad = None; + }); + data.settings.read().nagivation.enable_dpad + })), + }), + MenuItem::Option(OptionDef { + text: "Left-handed dpad".into(), + val: cfg.landscape_dpad_on_left, + update_fn: Box::new(|is_on_left, data| { + data.settings.update_ui(|cfg| { + *cfg.nagivation().landscape_dpad_on_left() = is_on_left; + }); + }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.nagivation().landscape_dpad_on_left = None; + }); + data.settings.read().nagivation.landscape_dpad_on_left + })), + }), + MenuItem::Option(OptionDef { + text: "Swap Up and Down buttons".into(), + val: cfg.dpad_swap_up_down, + update_fn: Box::new(|do_swap, data| { + data.settings.update_ui(|cfg| { + *cfg.nagivation().dpad_swap_up_down() = do_swap; + }); + }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.nagivation().dpad_swap_up_down = None; + }); + data.settings.read().nagivation.dpad_swap_up_down + })), + }), + MenuItem::Option(OptionDef { + text: "Enable margin around dpad".into(), + val: cfg.enable_margin_around_dpad, + update_fn: Box::new(|enabled, data| { + data.settings.update_ui(|cfg| { + *cfg.nagivation().enable_margin_around_dpad() = enabled; + }); + }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.nagivation().enable_margin_around_dpad = None; + }); + data.settings.read().nagivation.enable_margin_around_dpad + })), + }), + MenuItem::Option(OptionDef { + text: "Enable dpad highlight".into(), + val: cfg.enable_dpad_highlight, + update_fn: Box::new(|enabled, data| { + data.settings.update_ui(|cfg| { + *cfg.nagivation().enable_dpad_highlight() = enabled; + }); + }), + reset_fn: Some(Box::new(|data| { + data.settings.update_ui(|cfg| { + cfg.nagivation().enable_dpad_highlight = None; + }); + data.settings.read().nagivation.enable_dpad_highlight + })), + }), + MenuItem::Separator, + MenuItem::Text("Exit".into()), + ], + ); + + Activity::new_base_boxed("controls settings", Menu::new(menu_config)) +} diff --git a/tmaze/src/settings/style_browser.rs b/tmaze/src/ui/usecase/screens/style_browser.rs similarity index 99% rename from tmaze/src/settings/style_browser.rs rename to tmaze/src/ui/usecase/screens/style_browser.rs index a2e42b79..bc6c8a94 100644 --- a/tmaze/src/settings/style_browser.rs +++ b/tmaze/src/ui/usecase/screens/style_browser.rs @@ -10,12 +10,10 @@ use crate::{ app::{app::AppData, ActivityHandler, Change, Event}, helpers::not_release, renderer::{CellContent, GMutView, Padding}, - settings::theme::Style, + settings::theme::{Style, StyleNode, Theme, ThemeResolver}, ui::{CapsuleText, Screen, ScreenError}, }; -use super::theme::{StyleNode, Theme, ThemeResolver}; - const CONTENT_MARGIN: Dims = Dims(4, 1); const LEFT_MARGIN: i32 = 1; const RIGHT_MARGIN: i32 = 1; diff --git a/tmaze/src/updates.rs b/tmaze/src/updates.rs index 0e8cb971..70a15457 100644 --- a/tmaze/src/updates.rs +++ b/tmaze/src/updates.rs @@ -34,11 +34,13 @@ pub async fn get_newer_async() -> Result, CratesError> { } pub fn check(app_data: &mut AppData) { - if app_data.save.is_update_checked(&app_data.settings) { + let cfg = &app_data.settings.read(); + + if app_data.save.is_update_checked(cfg) { return; } - let display_update_errors = app_data.settings.get_display_update_check_errors(); + let display_update_errors = cfg.updates.display_update_check_errors; let qer = app_data.queuer(); @@ -54,7 +56,7 @@ pub fn check(app_data: &mut AppData) { Ok(Some(version)) => { log::warn!("Newer version found: {}", version); qer.queue(Job::new(|data| { - if !data.settings.is_ro() { + if !data.is_ro() { data.save .update_last_check() .expect("Failed to save the save data"); @@ -64,7 +66,7 @@ pub fn check(app_data: &mut AppData) { Ok(None) => { log::info!("No newer version found"); qer.queue(Job::new(|data| { - if !data.settings.is_ro() { + if !data.is_ro() { data.save .update_last_check() .expect("Failed to save the save data");