use std::path::PathBuf; use std::time::Duration; use adw::prelude::{WidgetExt, AdwApplicationWindowExt, Cast}; use gtk::prelude::{BoxExt, ButtonExt, GtkWindowExt, OrientableExt}; use relm4::actions::{AccelsPlus, RelmActionGroup, RelmAction}; use relm4::*; use relm4::factory::FactoryVecDeque; use relm4_components::{save_dialog::*, open_dialog::*}; use mossfets_game_of_life::game::{Game, Coord}; use crate::factory::cell::Cell; use crate::widgets::runner::{RunModel, RunMsg, RunOutput}; use crate::widgets::preferences::{PreferencesModel, PreferencesMsg}; /// The model representing the application's state #[tracker::track] pub struct AppModel { #[do_not_track] pub game: Game, #[do_not_track] pub size: (Coord, Coord), #[do_not_track] running: bool, #[do_not_track] offset: Coord, toast: Option, #[do_not_track] pub cells: FactoryVecDeque, #[do_not_track] runner: Controller, #[do_not_track] preferences: Controller, #[do_not_track] save_dialog: Controller, #[do_not_track] open_dialog: Controller, } /// The messages given the the model to update its state #[derive(Debug)] pub enum AppMsg { Advance, Revert, ChangeAtPoint(Coord), ChangeAtGridSpacePoint(Coord), Move(Direction), Sync, Toggle, Size(Coord), Clear, SaveRequest, OpenRequest, SaveResponse(PathBuf), OpenResponse(PathBuf), ShowPreferences, } /// A direction to move the view of the grid #[derive(Debug)] pub enum Direction { Up, Down, Left, Right, } impl AppModel { /// Convert from a space on the visible grid (starting from 0,0 in top-left) to the board's /// coordinate system pub fn to_game_space(&self, coord: Coord) -> Coord { let c1 = self.size.0; (c1.0 + coord.0 + self.offset.0, c1.1 + coord.1 + self.offset.1) } /// Modify the factory vector to fit only cells in the appropriate range pub fn size(&mut self, c1: Coord, c2: Coord) { let mut cells_guard = self.cells.guard(); cells_guard.clear(); for i in 0..c2.0-c1.0 + 1 { for j in 0..c2.1-c1.1 + 1 { cells_guard.push_back(((i, j), false)); } } self.size = (c1, c2); } /// Modify the cells of the factory to match the game's state pub fn sync(&mut self) { let c1 = (self.size.0.0 + self.offset.0, self.size.0.1 + self.offset.1); let c2 = (self.size.1.0 + self.offset.0, self.size.1.1 + self.offset.1); let view = self.game.get_view(c1, c2); let len = self.cells.len(); let mut cells_guard = self.cells.guard(); for index in 0..len { let cell = cells_guard.get_mut(index).unwrap(); let coord = cell.get_coordinate(); let coord = (coord.0 + c1.0, coord.1 + c1.1); if view.contains(&&coord) { cell.value = true; } else { cell.value = false; } } } fn observe_window(sender: &ComponentSender, window: &adw::ApplicationWindow, exit_fullscreen: bool) { let window = window.clone(); let sender = sender.clone(); spawn_local(async move { async_std::task::sleep(Duration::from_millis(25)).await; if exit_fullscreen && (window.is_maximized() || window.is_fullscreened()) { return }; let (x, y) = (window.width(), window.height()); if x == 0 || y == 0 { return }; sender.input(AppMsg::Size((x as isize, y as isize))); }); } } #[relm4::component(pub)] impl SimpleComponent for AppModel { type Widgets = AppWidgets; type Init = Game; type Input = AppMsg; type Output = (); view! { main_window = adw::ApplicationWindow { set_title: Some("Game of Life"), set_default_width: 600, set_default_height: 500, #[wrap(Some)] set_content: toast_overlay = &adw::ToastOverlay { #[wrap(Some)] set_child = >k::Box { set_orientation: gtk::Orientation::Vertical, append = &adw::HeaderBar { set_show_start_title_buttons: true, // Main menu pack_end = >k::MenuButton { set_tooltip_text: Some("Main Menu"), set_icon_name: "open-menu-symbolic", set_menu_model: Some(&main_menu), set_primary: true, }, // Window controls #[wrap(Some)] set_title_widget = >k::Box { set_orientation: gtk::Orientation::Horizontal, append: prev = >k::Button { set_tooltip_text: Some("Step Backward"), set_icon_name: "go-previous-symbolic", connect_clicked[sender] => move |_| { sender.input(AppMsg::Revert) } }, append = >k::Button { set_tooltip_text: Some("Step Forward"), set_icon_name: "go-next-symbolic", connect_clicked[sender] => move |_| { sender.input(AppMsg::Advance); }, }, // Runner component has to be inserted after everything else insert_child_after[Some(&prev)]: model.runner.widget(), }, set_show_end_title_buttons: true, }, gtk::Overlay { add_overlay = >k::AspectFrame { #[local_ref] #[wrap(Some)] set_child = cell_grid -> gtk::Grid { set_valign: gtk::Align::Center, set_halign: gtk::Align::Center, } }, #[wrap(Some)] set_child = >k::Box { set_height_request: 200, set_width_request: 300, } } } } } } // The application's main menu menu! { main_menu: { "_Clear" => ClearAction, "_Open File" => OpenAction, "_Save File" => SaveAction, "_Preferences" => PreferencesAction, "_About" => AboutAction, } } fn init( init: Self::Init, root: &Self::Root, sender: ComponentSender, ) -> ComponentParts { let game = init; let runner = RunModel::builder() .launch(()) .forward(sender.input_sender(), |msg| match msg { RunOutput::Advance => AppMsg::Advance, }); let open_dialog = OpenDialog::builder() .transient_for_native(root) .launch(OpenDialogSettings::default()) .forward(sender.input_sender(), |response| match response { OpenDialogResponse::Accept(path) => AppMsg::OpenResponse(path), OpenDialogResponse::Cancel => AppMsg::Sync, }); let save_dialog = SaveDialog::builder() .transient_for_native(root) .launch(SaveDialogSettings::default()) .forward(sender.input_sender(), |response| match response { SaveDialogResponse::Accept(path) => AppMsg::SaveResponse(path), SaveDialogResponse::Cancel => AppMsg::Sync, }); let preferences = PreferencesModel::builder() .launch(root.clone().upcast()) .forward(sender.input_sender(), |_| AppMsg::Sync); let model = AppModel { cells: FactoryVecDeque::new(gtk::Grid::default(), sender.input_sender()), game, size: ((-1, -1), (1, 1)), running: false, toast: None, tracker: 0, offset: (0,0), runner, open_dialog, save_dialog, preferences, }; let cell_grid = model.cells.widget(); let widgets = view_output!(); let app = relm4::main_application(); app.set_accelerators_for_action::(&["k", "w", "Up"]); app.set_accelerators_for_action::(&["j", "s", "Down"]); app.set_accelerators_for_action::(&["l", "d", "Right"]); app.set_accelerators_for_action::(&["h", "a", "Left"]); let group = RelmActionGroup::::new(); let sender_clone = sender.clone(); let action: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::Move(Direction::Up)); }); let sender_clone = sender.clone(); let action2: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::Move(Direction::Down)); }); let sender_clone = sender.clone(); let action3: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::Move(Direction::Left)); }); let sender_clone = sender.clone(); let action4: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::Move(Direction::Right)); }); let sender_clone = sender.clone(); let action5: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::Clear); }); let sender_clone = sender.clone(); let action6: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::OpenRequest); }); let sender_clone = sender.clone(); let action7: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::SaveRequest); }); // Open the about menu let action8: RelmAction = RelmAction::new_stateless(move |_| { adw::builders::AboutWindowBuilder::new() .copyright("Copyright. Mossfet") .license_type(gtk::License::Gpl30) .application_name("Life") .comments("A GTK4 Conway's Game of Life emulator") .title("About Life") .developer_name("Mossfet") .application_icon("view-app-grid-symbolic") .height_request(600) .width_request(360) .build() .show(); }); let sender_clone = sender.clone(); let action9: RelmAction = RelmAction::new_stateless(move |_| { sender_clone.input(AppMsg::ShowPreferences); }); group.add_action(&action); group.add_action(&action2); group.add_action(&action3); group.add_action(&action4); group.add_action(&action5); group.add_action(&action6); group.add_action(&action7); group.add_action(&action8); group.add_action(&action9); let actions = group.into_action_group(); widgets.main_window.insert_action_group("win", Some(&actions)); let sender_clone = sender.clone(); widgets.main_window.connect_default_height_notify(move |window| { AppModel::observe_window(&sender_clone, &window, true); }); let sender_clone = sender.clone(); widgets.main_window.connect_default_width_notify(move |window| { AppModel::observe_window(&sender_clone, &window, true); }); let sender_clone = sender.clone(); widgets.main_window.connect_maximized_notify(move |window| { AppModel::observe_window(&sender_clone, &window, false); }); let sender_clone = sender.clone(); AppModel::observe_window(&sender_clone, &widgets.main_window, false); sender.input(AppMsg::Sync); ComponentParts { model, widgets } } fn update( &mut self, msg: Self::Input, _sender: ComponentSender, ) { self.reset(); match msg { AppMsg::Size(c) => { let (width, height) = c; let mut horizontal_count = width/56; // If there are less than 15 pixels of free horizontal space, shrink the grid if width%56 < 2 { horizontal_count -= 1; } let mut vertical_count = height/56; // If there are less than 15 pixels of free vertical space (offset to include // headerbar), shrink the grid if (height%56 - 47) < 1 { vertical_count -= 1; } // When there is an even number of rows/columns, the origin should be left/above // the center. //println!("{}, {}", horizontal_count, vertical_count); let c1 = (-(horizontal_count.div_ceil(2)-1), -(vertical_count.div_ceil(2)-1)); let c2 = (horizontal_count/2, vertical_count/2); if (c1, c2) != self.size { self.size(c1, c2); } } AppMsg::Advance => { self.game.advance_board(); } AppMsg::Revert => { self.game.revert_board(); } AppMsg::ChangeAtPoint(coord) => { self.game.flip_state(coord); } AppMsg::ChangeAtGridSpacePoint(coord) => { self.game.flip_state(self.to_game_space(coord)); } AppMsg::Move(direction) => { let mut offset = self.offset; match direction { Direction::Left => { offset = (offset.0 - 1, offset.1); } Direction::Right => { offset = (offset.0 + 1, offset.1); } Direction::Up => { offset = (offset.0, offset.1 - 1); } Direction::Down => { offset = (offset.0, offset.1 + 1); } } self.offset = offset; } AppMsg::Sync => {} AppMsg::Toggle => { self.runner.emit(RunMsg::Toggle); self.running = !self.running; } AppMsg::Clear => { self.game = Game::new(); } AppMsg::SaveRequest => { self.save_dialog.emit(SaveDialogMsg::SaveAs(format!("pattern.lif"))); } AppMsg::OpenRequest => { self.open_dialog.emit(OpenDialogMsg::Open); } AppMsg::SaveResponse(path) => { match mossfets_game_of_life::writer::write_game_at_path(path, &self.game) { Ok(_) => {}, Err(_) => self.set_toast(Some(adw::Toast::builder().title("Failed to write file!").priority(adw::ToastPriority::High).build())), } }, AppMsg::OpenResponse(path) => { let path = path.to_str().unwrap(); match mossfets_game_of_life::reader::read_game_at_path(path) { Ok(game) => self.game = game, Err(_) => self.set_toast(Some(adw::Toast::builder().title("Failed to read file!").priority(adw::ToastPriority::High).build())), } } AppMsg::ShowPreferences => { self.preferences.emit(PreferencesMsg::Show); } /*AppMsg::SetDark(theme_enabled) => { let style = adw::StyleManager::default(); match theme_enabled { ThemeVariant::Dark => { style.set_color_scheme(adw::ColorScheme::PreferDark); } ThemeVariant::Light => { style.set_color_scheme(adw::ColorScheme::PreferLight); } ThemeVariant::Default => { style.set_color_scheme(adw::ColorScheme::Default); } } }*/ } self.sync(); } fn pre_view() { if model.changed(AppModel::toast()) { toast_overlay.add_toast(model.toast.as_ref().unwrap()); sender.input(AppMsg::Sync); } } } /*fn setup_action (sender: Sender, f: F, group: RelmActionGroup) where F: Fn(&gio::SimpleAction) + 'static, U: ActionGroupName { let sender = sender.clone(); let action: RelmAction = RelmAction::new_stateless(f); group.add_action(action); }*/ relm4::new_action_group!(WindowActionGroup, "win"); relm4::new_stateless_action!(UpAction, WindowActionGroup, "up"); relm4::new_stateless_action!(DownAction, WindowActionGroup, "down"); relm4::new_stateless_action!(LeftAction, WindowActionGroup, "left"); relm4::new_stateless_action!(RightAction, WindowActionGroup, "right"); relm4::new_stateless_action!(ClearAction, WindowActionGroup, "clear"); relm4::new_stateless_action!(OpenAction, WindowActionGroup, "open-file"); relm4::new_stateless_action!(SaveAction, WindowActionGroup, "save"); relm4::new_stateless_action!(AboutAction, WindowActionGroup, "about"); relm4::new_stateless_action!(PreferencesAction, WindowActionGroup, "preferences");