465 lines
18 KiB
Rust
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 = >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<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");
|
|
|