game-of-life-gui/src/app.rs

465 lines
18 KiB
Rust

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<adw::Toast>,
#[do_not_track]
pub cells: FactoryVecDeque<Cell>,
#[do_not_track]
runner: Controller<RunModel>,
#[do_not_track]
preferences: Controller<PreferencesModel>,
#[do_not_track]
save_dialog: Controller<SaveDialog>,
#[do_not_track]
open_dialog: Controller<OpenDialog>,
}
/// 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<Self>, 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 = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
append = &adw::HeaderBar {
set_show_start_title_buttons: true,
// Main menu
pack_end = &gtk::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 = &gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
append: prev = &gtk::Button {
set_tooltip_text: Some("Step Backward"),
set_icon_name: "go-previous-symbolic",
connect_clicked[sender] => move |_| {
sender.input(AppMsg::Revert)
}
},
append = &gtk::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 = &gtk::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 = &gtk::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<Self>,
) -> ComponentParts<Self> {
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::<UpAction>(&["k", "w", "Up"]);
app.set_accelerators_for_action::<DownAction>(&["j", "s", "Down"]);
app.set_accelerators_for_action::<RightAction>(&["l", "d", "Right"]);
app.set_accelerators_for_action::<LeftAction>(&["h", "a", "Left"]);
let group = RelmActionGroup::<WindowActionGroup>::new();
let sender_clone = sender.clone();
let action: RelmAction<UpAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Up));
});
let sender_clone = sender.clone();
let action2: RelmAction<DownAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Down));
});
let sender_clone = sender.clone();
let action3: RelmAction<LeftAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Left));
});
let sender_clone = sender.clone();
let action4: RelmAction<RightAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Right));
});
let sender_clone = sender.clone();
let action5: RelmAction<ClearAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Clear);
});
let sender_clone = sender.clone();
let action6: RelmAction<OpenAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::OpenRequest);
});
let sender_clone = sender.clone();
let action7: RelmAction<SaveAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::SaveRequest);
});
// Open the about menu
let action8: RelmAction<AboutAction> = 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<PreferencesAction> = 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>,
) {
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<T, F, U> (sender: Sender<T>, f: F, group: RelmActionGroup<U>)
where F: Fn(&gio::SimpleAction) + 'static,
U: ActionGroupName
{
let sender = sender.clone();
let action: RelmAction<U> = 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");