mirror of
https://github.com/danbulant/cushy
synced 2026-05-24 12:28:23 +00:00
Tic-tac-toe, Buttons labels now stretch to fill
This commit is contained in:
parent
bc83b81687
commit
5a9aa6b55d
2 changed files with 219 additions and 4 deletions
198
examples/tic-tac-toe.rs
Normal file
198
examples/tic-tac-toe.rs
Normal file
|
|
@ -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<Player>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<AppState>,
|
||||||
|
current_player: Player,
|
||||||
|
cells: Vec<Option<Player>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameState {
|
||||||
|
fn new_game(app: &Dynamic<AppState>) -> 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<Player> {
|
||||||
|
// 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<Player> {
|
||||||
|
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<Player> {
|
||||||
|
self.cells[row * 3 + column]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn game_end(winner: Option<Player>, app: &Dynamic<AppState>) -> 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<AppState>) -> 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<GameState>) -> 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<GameState>) -> 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<GameState>) -> 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()
|
||||||
|
}
|
||||||
|
|
@ -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 kind = self.kind.get_tracked(context);
|
||||||
let visual_state = self.visual_style(context);
|
let visual_state = self.visual_style(context);
|
||||||
|
|
||||||
|
|
@ -227,6 +227,10 @@ impl Button {
|
||||||
kind,
|
kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if !self.cached_state.enabled {
|
||||||
|
context.blur();
|
||||||
|
}
|
||||||
|
|
||||||
if context.is_default() {
|
if context.is_default() {
|
||||||
kind.colors_for_default(visual_state, context)
|
kind.colors_for_default(visual_state, context)
|
||||||
} else {
|
} 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);
|
let new_style = self.determine_stateful_colors(context);
|
||||||
|
|
||||||
match (immediate, &self.active_colors) {
|
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() {
|
if self.active_colors.is_none() {
|
||||||
self.update_colors(context, false);
|
self.update_colors(context, false);
|
||||||
}
|
}
|
||||||
|
|
@ -463,13 +467,26 @@ impl Widget for Button {
|
||||||
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
|
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
|
||||||
) -> Size<UPx> {
|
) -> Size<UPx> {
|
||||||
let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale());
|
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 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 = 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(
|
context.set_child_layout(
|
||||||
&mounted,
|
&mounted,
|
||||||
Rect::new(Point::new(padding, padding), size).into_signed(),
|
Rect::new(Point::new(padding, padding), size).into_signed(),
|
||||||
);
|
);
|
||||||
size + padding * 2
|
size + double_padding
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keyboard_input(
|
fn keyboard_input(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue