Add zoom support and dynamic keybinding layout

Introduce ZoomIn/ZoomOut actions and a Zoom group with corresponding
keybindings.
Add a CLI option to set the initial zoom level and a ZoomLevel enum.
Expose a utility to compute the largest keybinding group size for layout
sizing.
Refactor board widget to adjust cell dimensions based on zoom level and
simplify offset calculations.
Update views to use the new layout sizing helper and integrate zoom
handling in skirmish logic.
This commit is contained in:
2026-03-29 14:12:03 +02:00
parent edf491457e
commit 1d1dc0f2f7
11 changed files with 145 additions and 49 deletions
+34
View File
@@ -1,5 +1,6 @@
use clap::ValueEnum; use clap::ValueEnum;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action { pub enum Action {
@@ -17,6 +18,8 @@ pub enum Action {
Enter, Enter,
Esc, Esc,
Backspace, Backspace,
ZoomIn,
ZoomOut,
WildCard(char), WildCard(char),
} }
@@ -27,6 +30,7 @@ pub enum Group {
Scroll, Scroll,
Select, Select,
Input, Input,
Zoom,
Quit, Quit,
} }
@@ -168,6 +172,24 @@ pub static KEYBINDINGS: &[KeyBinding] = &[
symbol: "Backspace", symbol: "Backspace",
description: "Delete character", description: "Delete character",
}, },
KeyBinding {
action: Action::ZoomIn,
code: KeyCode::Char(','),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Zoom,
symbol: ",",
description: "Zoom in",
},
KeyBinding {
action: Action::ZoomOut,
code: KeyCode::Char('.'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Zoom,
symbol: ".",
description: "Zoom out",
},
KeyBinding { KeyBinding {
action: Action::WildCard('_'), action: Action::WildCard('_'),
code: KeyCode::Char('_'), code: KeyCode::Char('_'),
@@ -202,3 +224,15 @@ pub fn event_to_action(event: &KeyEvent) -> Option<Action> {
None None
} }
pub fn count_largest_group(actions: &Vec<Action>) -> u16 {
let mut group_counts: HashMap<Group, u16> = HashMap::new();
for action in actions {
if let Some(binding) = binding_for(*action) {
*group_counts.entry(binding.group).or_insert(0) += 1;
}
}
group_counts.values().copied().max().map_or(0, |v| v)
}
+3 -1
View File
@@ -4,6 +4,8 @@ pub mod main_menu;
pub mod skirmish; pub mod skirmish;
pub use default::{common_keybindings, default_keybindings}; pub use default::{common_keybindings, default_keybindings};
pub use keybindings::{Action, Group, KEYBINDINGS, KeyBinding, binding_for, event_to_action}; pub use keybindings::{
Action, Group, KEYBINDINGS, KeyBinding, binding_for, count_largest_group, event_to_action,
};
pub use main_menu::main_menu_keybindings; pub use main_menu::main_menu_keybindings;
pub use skirmish::skirmish_keybindings; pub use skirmish::skirmish_keybindings;
+19
View File
@@ -1,6 +1,7 @@
use crate::app::{ use crate::app::{
App, App,
keybindings::{Action, common_keybindings, event_to_action}, keybindings::{Action, common_keybindings, event_to_action},
states::ZoomLevel,
}; };
use ratatui::crossterm::event::KeyEvent; use ratatui::crossterm::event::KeyEvent;
@@ -12,6 +13,24 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) {
Action::ScrollDown => app.states.skirmish.vertical_offset.next(), Action::ScrollDown => app.states.skirmish.vertical_offset.next(),
Action::ScrollLeft => app.states.skirmish.horizontal_offset.prev(), Action::ScrollLeft => app.states.skirmish.horizontal_offset.prev(),
Action::ScrollRight => app.states.skirmish.horizontal_offset.next(), Action::ScrollRight => app.states.skirmish.horizontal_offset.next(),
Action::ZoomIn => match app.states.skirmish.zoom_level {
ZoomLevel::ZoomedIn => {}
ZoomLevel::Default => {
app.states.skirmish.zoom_level = ZoomLevel::ZoomedIn;
}
ZoomLevel::ZoomedOut => {
app.states.skirmish.zoom_level = ZoomLevel::Default;
}
},
Action::ZoomOut => match app.states.skirmish.zoom_level {
ZoomLevel::ZoomedIn => {
app.states.skirmish.zoom_level = ZoomLevel::Default;
}
ZoomLevel::Default => {
app.states.skirmish.zoom_level = ZoomLevel::ZoomedOut;
}
ZoomLevel::ZoomedOut => {}
},
_ => (), _ => (),
} }
} }
+1
View File
@@ -30,6 +30,7 @@ impl GameStates {
map_width: args.map_width as usize, map_width: args.map_width as usize,
map_height: args.map_height as usize, map_height: args.map_height as usize,
board_cells: Vec::new(), board_cells: Vec::new(),
zoom_level: args.zoom_level,
}, },
perk_decks: PerkDecksState { perk_decks: PerkDecksState {
id: 2, id: 2,
+1 -1
View File
@@ -8,4 +8,4 @@ pub use main_menu::MainMenuState;
pub use perk_decks::{PerkDecks, PerkDecksState}; pub use perk_decks::{PerkDecks, PerkDecksState};
pub use settings::SettingsState; pub use settings::SettingsState;
pub use skills_config::SkillsConfigState; pub use skills_config::SkillsConfigState;
pub use skirmish::{GameMode, Offset, SkirmishState}; pub use skirmish::{GameMode, Offset, SkirmishState, ZoomLevel};
+11 -3
View File
@@ -33,9 +33,9 @@ impl Offset {
} }
pub fn set_max(&mut self, max: usize) { pub fn set_max(&mut self, max: usize) {
if self.max_initiated { // if self.max_initiated {
return; // return;
} // }
self.max = max; self.max = max;
self.max_initiated = true; self.max_initiated = true;
@@ -59,6 +59,7 @@ pub struct SkirmishState {
pub vertical_offset: Offset, pub vertical_offset: Offset,
pub horizontal_offset: Offset, pub horizontal_offset: Offset,
pub board_cells: Vec<CellWidget>, pub board_cells: Vec<CellWidget>,
pub zoom_level: ZoomLevel,
} }
impl SkirmishState { impl SkirmishState {
@@ -80,3 +81,10 @@ pub enum GameMode {
LastManStanding, LastManStanding,
FrontLines, FrontLines,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum ZoomLevel {
ZoomedIn,
Default,
ZoomedOut,
}
+10 -4
View File
@@ -1,4 +1,7 @@
use crate::app::{keybindings::Action, widgets::KeybindingsWidget}; use crate::app::{
keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget,
};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect}, layout::{Alignment, Constraint, Layout, Rect},
@@ -7,7 +10,12 @@ use ratatui::{
}; };
pub fn default_view(area: Rect, buf: &mut Buffer) { pub fn default_view(area: Rect, buf: &mut Buffer) {
let vertical_layout: Layout = Layout::vertical([Constraint::Fill(1), Constraint::Length(4)]); let actions: Vec<Action> = vec![Action::Quit, Action::Quit2, Action::Esc];
let vertical_layout: Layout = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(count_largest_group(&actions) + 2),
]);
let [main_area, keybindings_area] = vertical_layout.areas(area); let [main_area, keybindings_area] = vertical_layout.areas(area);
@@ -24,8 +32,6 @@ pub fn default_view(area: Rect, buf: &mut Buffer) {
} }
{ {
let actions: Vec<Action> = vec![Action::Quit, Action::Quit2, Action::Esc];
KeybindingsWidget::new(actions).render(keybindings_area, buf); KeybindingsWidget::new(actions).render(keybindings_area, buf);
} }
} }
+17 -10
View File
@@ -1,4 +1,8 @@
use crate::app::{App, View, keybindings::Action, widgets::KeybindingsWidget}; use crate::app::{
App, View,
keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget,
};
use clap::ValueEnum; use clap::ValueEnum;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
@@ -23,7 +27,18 @@ fn format_view_string(s: String) -> String {
} }
pub fn main_menu_view(app: &App, area: Rect, buf: &mut Buffer) { pub fn main_menu_view(app: &App, area: Rect, buf: &mut Buffer) {
let vertical_layout: Layout = Layout::vertical([Constraint::Fill(1), Constraint::Length(4)]); let actions: Vec<Action> = vec![
Action::Up,
Action::Down,
Action::Space,
Action::Quit,
Action::Quit2,
];
let vertical_layout: Layout = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(count_largest_group(&actions) + 2),
]);
let [main_menu_area, keybindings_area] = vertical_layout.areas(area); let [main_menu_area, keybindings_area] = vertical_layout.areas(area);
@@ -83,14 +98,6 @@ pub fn main_menu_view(app: &App, area: Rect, buf: &mut Buffer) {
} }
{ {
let actions: Vec<Action> = vec![
Action::Up,
Action::Down,
Action::Space,
Action::Quit,
Action::Quit2,
];
KeybindingsWidget::new(actions).render(keybindings_area, buf); KeybindingsWidget::new(actions).render(keybindings_area, buf);
} }
} }
+14 -12
View File
@@ -1,6 +1,6 @@
use crate::app::{ use crate::app::{
App, App,
keybindings::Action, keybindings::{Action, count_largest_group},
widgets::{BoardWidget, KeybindingsWidget}, widgets::{BoardWidget, KeybindingsWidget},
}; };
use ratatui::{ use ratatui::{
@@ -12,10 +12,22 @@ use ratatui::{
}; };
pub fn skirmish_view(app: &mut App, area: Rect, buf: &mut Buffer) { pub fn skirmish_view(app: &mut App, area: Rect, buf: &mut Buffer) {
let actions: Vec<Action> = vec![
Action::ScrollUp,
Action::ScrollDown,
Action::ScrollLeft,
Action::ScrollRight,
Action::ZoomIn,
Action::ZoomOut,
Action::Quit,
Action::Quit2,
Action::Esc,
];
let vertical_layout: Layout = Layout::vertical([ let vertical_layout: Layout = Layout::vertical([
Constraint::Length(4), Constraint::Length(4),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(6), Constraint::Length(count_largest_group(&actions) + 2),
]); ]);
let [title_area, main_area, keybindings_area] = vertical_layout.areas(area); let [title_area, main_area, keybindings_area] = vertical_layout.areas(area);
@@ -78,16 +90,6 @@ pub fn skirmish_view(app: &mut App, area: Rect, buf: &mut Buffer) {
} }
{ {
let actions: Vec<Action> = vec![
Action::ScrollUp,
Action::ScrollDown,
Action::ScrollLeft,
Action::ScrollRight,
Action::Quit,
Action::Quit2,
Action::Esc,
];
KeybindingsWidget::new(actions).render(keybindings_area, buf); KeybindingsWidget::new(actions).render(keybindings_area, buf);
} }
} }
+25 -17
View File
@@ -1,4 +1,4 @@
use crate::app::{App, widgets::CellWidget}; use crate::app::{App, states::ZoomLevel, widgets::CellWidget};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
@@ -18,24 +18,32 @@ pub struct BoardWidget<'a> {
} }
impl<'a> BoardWidget<'a> { impl<'a> BoardWidget<'a> {
fn max_offset(map_size: u16, size: u16) -> usize {
if map_size > size {
(map_size - size) as usize
} else {
0
}
}
pub fn new(app: &'a mut App, area_width: u16, area_height: u16) -> Self { pub fn new(app: &'a mut App, area_width: u16, area_height: u16) -> Self {
const CELL_HIGHT: u16 = 5; let cell_height: u16 = match app.states.skirmish.zoom_level {
const CELL_WIDTH: u16 = 9; ZoomLevel::ZoomedIn => 7,
ZoomLevel::Default => 5,
ZoomLevel::ZoomedOut => 3,
};
let rows: u16 = area_height / CELL_HIGHT; let cell_width: u16 = match app.states.skirmish.zoom_level {
let cols: u16 = area_width / CELL_WIDTH; ZoomLevel::ZoomedIn => 13,
ZoomLevel::Default => 9,
ZoomLevel::ZoomedOut => 5,
};
let v_max_offset: usize = if app.states.skirmish.map_height as u16 > rows { let rows: u16 = area_height / cell_height;
app.states.skirmish.map_height as u16 - rows let cols: u16 = area_width / cell_width;
} else {
0
} as usize;
let h_max_offset: usize = if app.states.skirmish.map_width as u16 > cols { let v_max_offset: usize = Self::max_offset(app.states.skirmish.map_height as u16, rows);
app.states.skirmish.map_width as u16 - cols let h_max_offset: usize = Self::max_offset(app.states.skirmish.map_width as u16, cols);
} else {
0
} as usize;
app.states.skirmish.horizontal_offset.set_max(h_max_offset); app.states.skirmish.horizontal_offset.set_max(h_max_offset);
app.states.skirmish.vertical_offset.set_max(v_max_offset); app.states.skirmish.vertical_offset.set_max(v_max_offset);
@@ -49,8 +57,8 @@ impl<'a> BoardWidget<'a> {
Self { Self {
map_width: app.states.skirmish.map_width as usize, map_width: app.states.skirmish.map_width as usize,
cell_width: CELL_WIDTH, cell_width,
cell_height: CELL_HIGHT, cell_height,
cols, cols,
rows, rows,
h_offset: app.states.skirmish.horizontal_offset.get_value(), h_offset: app.states.skirmish.horizontal_offset.get_value(),
+10 -1
View File
@@ -1,5 +1,5 @@
use crate::app::{ use crate::app::{
states::{GameMode, PerkDecks}, states::{GameMode, PerkDecks, ZoomLevel},
view::View, view::View,
}; };
use clap::{Error, Parser, error::ErrorKind, value_parser}; use clap::{Error, Parser, error::ErrorKind, value_parser};
@@ -52,6 +52,15 @@ pub struct Cli {
)] )]
pub map_height: u8, pub map_height: u8,
#[arg(
long,
help = "Zoom level",
value_name = "...",
default_value_t = ZoomLevel::Default,
value_enum
)]
pub zoom_level: ZoomLevel,
#[arg( #[arg(
long, long,
help = "Perk Deck", help = "Perk Deck",