diff --git a/examples/tic-tac-toe.rs b/examples/tic-tac-toe.rs new file mode 100644 index 0000000..8e798a4 --- /dev/null +++ b/examples/tic-tac-toe.rs @@ -0,0 +1,198 @@ +use std::fmt::Display; +use std::iter; +use std::ops::Not; +use std::time::SystemTime; + +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::widgets::button::ButtonKind; +use gooey::{Run, WithClone}; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + let app = Dynamic::new(AppState::Winner(None)); + app.map_each(app.with_clone(|app| { + move |state: &AppState| match state { + AppState::Playing => play_screen(&app).make_widget(), + AppState::Winner(winner) => game_end(*winner, &app).make_widget(), + } + })) + .switcher() + .contain() + .width(Lp::inches(2)..Lp::inches(6)) + .height(Lp::inches(2)..Lp::inches(6)) + .centered() + .expand() + .run() +} + +#[derive(Default, Debug)] +enum AppState { + #[default] + Playing, + Winner(Option), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Player { + X, + O, +} + +impl Display for Player { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Player::X => f.write_str("X"), + Player::O => f.write_str("O"), + } + } +} + +impl Not for Player { + type Output = Self; + + fn not(self) -> Self::Output { + match self { + Self::X => Self::O, + Self::O => Self::X, + } + } +} + +struct GameState { + app: Dynamic, + current_player: Player, + cells: Vec>, +} + +impl GameState { + fn new_game(app: &Dynamic) -> Self { + Self { + app: app.clone(), + // Bad RNG: if we have an even milliseconds in the current + // timestamp, it's O's turn first. + current_player: if SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("invalid system time") + .as_millis() + % 2 + == 0 + { + Player::O + } else { + Player::X + }, + cells: iter::repeat(None).take(9).collect(), + } + } + + fn play(&mut self, row: usize, column: usize) { + let player = self.current_player; + self.current_player = !player; + + self.cells[row * 3 + column] = Some(player); + + if let Some(winner) = self.check_for_winner() { + self.app.set(AppState::Winner(Some(winner))); + } else if self.cells.iter().all(Option::is_some) { + self.app.set(AppState::Winner(None)); + } + } + + fn check_for_winner(&self) -> Option { + // Rows and columns + for i in 0..3 { + if let Some(winner) = self + .winner_in_cells([[i, 0], [i, 1], [i, 2]]) + .or_else(|| self.winner_in_cells([[0, i], [1, i], [2, i]])) + { + return Some(winner); + } + } + + // Diagonals + self.winner_in_cells([[0, 0], [1, 1], [2, 2]]) + .or_else(|| self.winner_in_cells([[2, 0], [1, 1], [0, 2]])) + } + + fn winner_in_cells(&self, cells: [[usize; 2]; 3]) -> Option { + match ( + self.cell(cells[0][0], cells[0][1]), + self.cell(cells[1][0], cells[1][1]), + self.cell(cells[2][0], cells[2][1]), + ) { + (Some(a), Some(b), Some(c)) if a == b && b == c => Some(a), + _ => None, + } + } + + fn cell(&self, row: usize, column: usize) -> Option { + self.cells[row * 3 + column] + } +} + +fn game_end(winner: Option, app: &Dynamic) -> impl MakeWidget { + // TODO we need typography styles + let app = app.clone(); + let label = if let Some(winner) = winner { + format!("{winner:?} wins!") + } else { + String::from("No winner") + }; + + label + .and("Play Again".into_button().on_click(move |_| { + app.set(AppState::Playing); + })) + .into_rows() + .centered() + .expand() +} + +fn play_screen(app: &Dynamic) -> impl MakeWidget { + let game = Dynamic::new(GameState::new_game(app)); + let current_player_label = game.map_each(|state| format!("{}'s Turn", state.current_player)); + + current_player_label.and(play_grid(&game)).into_rows() +} + +fn play_grid(game: &Dynamic) -> impl MakeWidget { + row_of_squares(0, game) + .expand() + .and(row_of_squares(1, game).expand()) + .and(row_of_squares(2, game).expand()) + .into_rows() +} + +fn row_of_squares(row: usize, game: &Dynamic) -> impl MakeWidget { + square(row, 0, game) + .expand() + .and(square(row, 1, game).expand()) + .and(square(row, 2, game).expand()) + .into_columns() +} + +fn square(row: usize, column: usize, game: &Dynamic) -> impl MakeWidget { + let game = game.clone(); + let enabled = Dynamic::new(true); + let label = Dynamic::default(); + (&enabled, &label).with_clone(|(enabled, label)| { + game.for_each(move |state| { + let Some(player) = state.cell(row, column) else { + return; + }; + + if enabled.update(false) { + label.update(player.to_string()); + } + }); + }); + + label + .clone() + .into_button() + .enabled(enabled) + .kind(ButtonKind::Outline) + .on_click(move |_| game.lock().play(row, column)) + .expand() +} diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 75f2b40..6295b81 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -218,7 +218,7 @@ impl Button { } } - fn determine_stateful_colors(&mut self, context: &WidgetContext<'_, '_>) -> ButtonColors { + fn determine_stateful_colors(&mut self, context: &mut WidgetContext<'_, '_>) -> ButtonColors { let kind = self.kind.get_tracked(context); let visual_state = self.visual_style(context); @@ -227,6 +227,10 @@ impl Button { kind, }; + if !self.cached_state.enabled { + context.blur(); + } + if context.is_default() { kind.colors_for_default(visual_state, context) } else { @@ -238,7 +242,7 @@ impl Button { } } - fn update_colors(&mut self, context: &WidgetContext<'_, '_>, immediate: bool) { + fn update_colors(&mut self, context: &mut WidgetContext<'_, '_>, immediate: bool) { let new_style = self.determine_stateful_colors(context); match (immediate, &self.active_colors) { @@ -261,7 +265,7 @@ impl Button { } } - fn current_style(&mut self, context: &WidgetContext<'_, '_>) -> ButtonColors { + fn current_style(&mut self, context: &mut WidgetContext<'_, '_>) -> ButtonColors { if self.active_colors.is_none() { self.update_colors(context, false); } @@ -463,13 +467,26 @@ impl Widget for Button { context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); + let double_padding = padding * 2; let mounted = self.content.mounted(&mut context.as_event_context()); + let available_space = Size::new( + available_space.width - double_padding, + available_space.height - double_padding, + ); let size = context.for_other(&mounted).layout(available_space); + let size = Size::new( + available_space + .width + .fit_measured(size.width, context.gfx.scale()), + available_space + .height + .fit_measured(size.height, context.gfx.scale()), + ); context.set_child_layout( &mounted, Rect::new(Point::new(padding, padding), size).into_signed(), ); - size + padding * 2 + size + double_padding } fn keyboard_input(