Add side panel and refactor title helpers

Introduce a `side_panel` flag throughout the skirmish state, board
creation,
and cell area calculation to enable a detachable side panel. Refactor
the
title helper to own its strings and use a static separator, updating the
single‑title helper accordingly. Add a new `SidePanelWidget` and expose
the
`skirmish_main_area_layout` helper. Extend keybindings with `Tab` and
`ShiftTab` actions under a new `Opener` group. Update structures and
units
to implement a `get_name` method and adjust related traits and imports.
This commit is contained in:
2026-04-24 11:41:09 +02:00
parent 06a439ff88
commit cf843057c3
23 changed files with 302 additions and 123 deletions
+4 -1
View File
@@ -60,7 +60,10 @@ impl App {
let Some(state) = self.states_mut() else {
panic!("State issue")
};
state.skirmish.board.change_resize(&window_area);
state
.skirmish
.board
.change_resize(&window_area, state.skirmish.side_panel);
}
}
}
+14 -75
View File
@@ -5,53 +5,19 @@ use ratatui::{
/// Creates a styled title `Line` for a UI block.
///
/// This helper builds a `Line` that looks like:
/// ```text
/// [ <title1> <separator> <title2> ... ]
/// ```
/// where each title fragment is rendered in the color supplied by the caller,
/// and the surrounding brackets and any separator are rendered in gray.
///
/// # Arguments
///
/// * `texts` A slice of tuples. Each tuple contains a title fragment (`&str`)
/// and a `Color` that should be applied to that fragment. The order of the
/// tuples determines the order of the fragments in the final line.
///
/// * `separator` An optional string that will be inserted (in gray) between
/// successive title fragments. If `None` the fragments are concatenated
/// directly without any separator.
///
/// # Returns
///
/// A `Line<'a>` that starts with a gray “\[ ”, contains the colored title
/// fragments separated (if requested) by a gray separator, and ends with a
/// gray “ \]”. The line can be passed directly to widgets such as `Block::title`.
///
/// # Example
///
/// ```rust
/// use war_in_tunnels::app::helpers::block_title::block_title_helper;
/// use ratatui::style::Color;
/// use ratatui::text::Line;
///
/// let title: Line<'_> = block_title_helper(
/// &[
/// ("Skirmish", Color::Magenta),
/// ("Map", Color::Green),
/// ],
/// Some(" - ")
/// );
/// // `title` now represents: [ Skirmish - Map ] with the appropriate colors.
/// ```
pub fn block_title_helper<'a>(texts: &[(&'a str, Color)], separator: Option<&'a str>) -> Line<'a> {
let mut line: Line<'a> = Line::default();
/// `texts` is a slice of `(String, Color)` tuples. The `String`s are cloned into
/// the spans, so the returned `Line` owns all its data and does not borrow from
/// the caller.
pub fn block_title_helper(
texts: &[(String, Color)],
separator: Option<&'static str>,
) -> Line<'static> {
let mut line: Line<'static> = Line::default();
line.spans.push(Span::from("[ ").gray());
for (i, (text, color)) in texts.iter().enumerate() {
let span: Span<'a> = Span::styled(*text, Style::new().fg(*color));
let span = Span::styled(text.clone(), Style::new().fg(*color));
line.spans.push(span);
if let Some(sep) = separator {
@@ -66,38 +32,11 @@ pub fn block_title_helper<'a>(texts: &[(&'a str, Color)], separator: Option<&'a
line
}
/// Convenience wrapper for `block_title_helper` that creates a title line for a
/// single piece of text.
/// Convenience wrapper for a singletitle line.
///
/// This function builds a `Line` that looks like:
/// ```text
/// [ <text> ]
/// ```
/// where `<text>` is rendered with the supplied color, and the surrounding
/// brackets are rendered in gray. It simply forwards the arguments to
/// `block_title_helper` with `separator` set to `None`.
///
/// # Arguments
///
/// * `text` The title string to display.
///
/// * `color` The `Color` that should be applied to the title text.
///
/// # Returns
///
/// A `Line<'a>` containing the grey brackets and the coloured title text,
/// ready to be passed to widgets such as `Block::title`.
///
/// # Example
///
/// ```rust
/// use war_in_tunnels::app::helpers::block_title::block_single_title_helper;
/// use ratatui::style::Color;
/// use ratatui::text::Line;
///
/// let title: Line<'_> = block_single_title_helper("Keybindings", Color::Magenta);
/// // `title` now represents: [ Keybindings ] with “Keybindings” coloured magenta.
/// ```
pub fn block_single_title_helper<'a>(text: &'a str, color: Color) -> Line<'a> {
/// The function builds a slice that contains the supplied `String` and then
/// forwards it to `block_title_helper`. Because `block_title_helper` clones the
/// string, the temporary slice is safe.
pub fn block_single_title_helper(text: String, color: Color) -> Line<'static> {
block_title_helper(&[(text, color)], None)
}
+5 -3
View File
@@ -1,13 +1,15 @@
use crate::app::views::skirmish_layout;
use crate::app::views::{skirmish_layout, skirmish_main_area_layout};
use ratatui::{
layout::{Margin, Rect},
widgets::{Block, Borders},
};
pub fn cells_area_helper(area: &Rect) -> Rect {
pub fn cells_area_helper(area: &Rect, side_panel: bool) -> Rect {
let board_area: Rect = skirmish_main_area_layout(skirmish_layout(*area)[1], side_panel)[0];
Block::new()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.inner(skirmish_layout(*area)[1])
.inner(board_area)
.inner(Margin {
horizontal: 1,
vertical: 1,
+24
View File
@@ -43,6 +43,10 @@ pub enum Action {
ZoomIn,
/// Zoom the view out.
ZoomOut,
/// Open side panel.
Tab,
/// Toggle skirmish board/skills view.
ShiftTab,
/// Mute music.
Mute,
/// Volume up.
@@ -71,6 +75,8 @@ pub enum Group {
Zoom,
/// Music related bindings.
Music,
/// Opens/Closes/Toggles elements in current view.
Opener,
/// Quit related bindings.
Quit,
}
@@ -282,6 +288,24 @@ static KEYBINDINGS: &[KeyBinding] = &[
symbol: "n",
description: "🔉",
},
KeyBinding {
action: Action::Tab,
code: KeyCode::Tab,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "Tab",
description: "Toggle side panel",
},
KeyBinding {
action: Action::ShiftTab,
code: KeyCode::Tab,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::SHIFT,
group: Group::Opener,
symbol: "Shift + Tab",
description: "Toggle Map/Skills window",
},
KeyBinding {
action: Action::WildCard('_'),
code: KeyCode::Char('_'),
+7 -1
View File
@@ -3,12 +3,14 @@ use crate::app::{
keybindings::{Action, common_keybindings, event_to_action},
states::skirmish_states::{BoardState, MoveFocusedCell, ZoomLevel},
};
use ratatui::crossterm::event::KeyEvent;
use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) {
if let Some(action) = event_to_action(&key_event) {
common_keybindings(app, action);
let window_area: Rect = app.window_area;
let Some(states) = app.states_mut() else {
return;
};
@@ -86,6 +88,10 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) {
board.marking_cells = false;
board.clear_marked_cells()
}
Action::Tab => {
states.skirmish.side_panel = !states.skirmish.side_panel;
board.change_resize(&window_area, states.skirmish.side_panel);
}
_ => (),
}
}
+2
View File
@@ -32,7 +32,9 @@ impl GameStates {
args.map_width as usize,
args.map_height as usize,
args.zoom_level,
false,
),
side_panel: false,
},
perk_decks: PerkDecksState {
id: 2,
+1
View File
@@ -6,6 +6,7 @@ pub struct SkirmishState {
pub id: usize,
pub name: &'static str,
pub board: BoardState,
pub side_panel: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
+20 -6
View File
@@ -33,8 +33,14 @@ pub struct BoardState {
}
impl BoardState {
pub fn new(area: &Rect, map_width: usize, map_height: usize, zoom_level: ZoomLevel) -> Self {
let cells_area: Rect = cells_area_helper(area);
pub fn new(
area: &Rect,
map_width: usize,
map_height: usize,
zoom_level: ZoomLevel,
side_panel: bool,
) -> Self {
let cells_area: Rect = cells_area_helper(area, side_panel);
let cell_width: usize = zoom_level.get_cell_size(CellSizes::Width);
let cell_height: usize = zoom_level.get_cell_size(CellSizes::Height);
@@ -114,6 +120,10 @@ impl BoardState {
}
}
pub fn get_ref_cell(&self, row: usize, col: usize) -> &CellWidget {
&self.cells[row][col]
}
fn get_mut_cell(&mut self, row: usize, col: usize) -> &mut CellWidget {
&mut self.cells[row][col]
}
@@ -188,6 +198,10 @@ impl BoardState {
if map_size > size { map_size - size } else { 0 }
}
pub fn get_focused_cell(&self) -> &FocusedCell {
&self.focused_cell
}
pub fn is_focused_cell_visible(&self) -> bool {
let vertical_offset: usize = self.vertical_offset.get_value();
let horizontal_offset: usize = self.horizontal_offset.get_value();
@@ -209,14 +223,14 @@ impl BoardState {
true
}
pub fn change_resize(&mut self, area: &Rect) {
self.cells_area = cells_area_helper(area);
pub fn change_resize(&mut self, area: &Rect, side_panel: bool) {
self.cells_area = cells_area_helper(area, side_panel);
self.cols = (self.cells_area.width / self.cell_width as u16) as usize;
self.rows = (self.cells_area.height / self.cell_height as u16) as usize;
let h_max_offset: usize = Self::max_offset(self.map_height, self.cols);
let v_max_offset: usize = Self::max_offset(self.map_width, self.rows);
let v_max_offset: usize = Self::max_offset(self.map_height, self.rows);
let h_max_offset: usize = Self::max_offset(self.map_width, self.cols);
self.horizontal_offset =
Offset::new(Some(self.horizontal_offset.get_value()), Some(h_max_offset));
@@ -47,4 +47,8 @@ impl Structure for BaseBuilding {
fn get_stress(&self) -> u8 {
self.stress
}
fn get_name(&self) -> &'static str {
"Base"
}
}
@@ -36,4 +36,8 @@ impl Structure for Stone {
fn get_stress(&self) -> u8 {
self.stress
}
fn get_name(&self) -> &'static str {
"Stone"
}
}
@@ -8,28 +8,52 @@ pub enum Structures {
Stone(Stone),
}
impl Structures {
pub fn get_color(&self) -> Color {
impl Structure for Structures {
fn get_color(&self) -> Color {
match self {
Structures::Base(b) => b.get_color(),
Structures::Tunnel(t) => t.get_color(),
Structures::Stone(s) => s.get_color(),
Self::Base(b) => b.get_color(),
Self::Tunnel(t) => t.get_color(),
Self::Stone(s) => s.get_color(),
}
}
pub fn get_tag(&self) -> char {
fn get_tag(&self) -> char {
match self {
Structures::Base(b) => b.get_tag(),
Structures::Tunnel(t) => t.get_tag(),
Structures::Stone(s) => s.get_tag(),
Self::Base(b) => b.get_tag(),
Self::Tunnel(t) => t.get_tag(),
Self::Stone(s) => s.get_tag(),
}
}
pub fn get_level(&self) -> char {
fn get_level(&self) -> char {
match self {
Structures::Base(b) => b.get_level(),
Structures::Tunnel(t) => t.get_level(),
Structures::Stone(s) => s.get_level(),
Self::Base(b) => b.get_level(),
Self::Tunnel(t) => t.get_level(),
Self::Stone(s) => s.get_level(),
}
}
fn get_name(&self) -> &'static str {
match self {
Self::Base(b) => b.get_name(),
Self::Tunnel(t) => t.get_name(),
Self::Stone(s) => s.get_name(),
}
}
fn get_durability(&self) -> u16 {
match self {
Self::Base(b) => b.get_durability(),
Self::Tunnel(t) => t.get_durability(),
Self::Stone(s) => s.get_durability(),
}
}
fn get_stress(&self) -> u8 {
match self {
Self::Base(b) => b.get_stress(),
Self::Tunnel(t) => t.get_stress(),
Self::Stone(s) => s.get_stress(),
}
}
}
@@ -6,4 +6,5 @@ pub trait Structure {
fn get_level(&self) -> char;
fn get_stress(&self) -> u8;
fn get_durability(&self) -> u16;
fn get_name(&self) -> &'static str;
}
@@ -42,4 +42,8 @@ impl Structure for Tunnel {
fn get_stress(&self) -> u8 {
self.stress
}
fn get_name(&self) -> &'static str {
"Tunnel"
}
}
@@ -19,4 +19,8 @@ impl Unit for MinerUnit {
fn get_tag(&self) -> char {
'M'
}
fn get_name(&self) -> &'static str {
"Miner"
}
}
+1 -1
View File
@@ -4,4 +4,4 @@ mod units_trait;
pub use miner::MinerUnit;
pub use units_enum::Units;
pub use units_trait::{OptionalUnit, Unit};
pub use units_trait::Unit;
@@ -1,20 +1,43 @@
use crate::app::states::skirmish_states::units::{MinerUnit, OptionalUnit, Unit};
use crate::app::states::{
Players,
skirmish_states::units::{MinerUnit, Unit},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Units {
Miner(MinerUnit),
}
impl Units {
pub fn get_tag(&self) -> char {
impl Unit for Units {
fn get_tag(&self) -> char {
match self {
Units::Miner(m) => m.get_tag(),
Self::Miner(m) => m.get_tag(),
}
}
fn get_name(&self) -> &'static str {
match self {
Self::Miner(m) => m.get_name(),
}
}
fn get_owner(&self) -> Players {
match self {
Self::Miner(m) => m.get_owner(),
}
}
}
impl OptionalUnit for Option<Units> {
fn try_get_tag(&self) -> char {
impl Unit for Option<Units> {
fn get_tag(&self) -> char {
self.map_or(' ', |u| u.get_tag())
}
fn get_name(&self) -> &'static str {
self.map_or("", |u| u.get_name())
}
fn get_owner(&self) -> Players {
self.map_or(Players::Enemy, |u| u.get_owner())
}
}
@@ -3,8 +3,5 @@ use crate::app::states::Players;
pub trait Unit {
fn get_owner(&self) -> Players;
fn get_tag(&self) -> char;
}
pub trait OptionalUnit {
fn try_get_tag(&self) -> char;
fn get_name(&self) -> &'static str;
}
+1 -1
View File
@@ -4,4 +4,4 @@ mod skirmish;
pub use default::default_view;
pub use main_menu::main_menu_view;
pub use skirmish::{skirmish_layout, skirmish_view};
pub use skirmish::{skirmish_layout, skirmish_main_area_layout, skirmish_view};
+43 -5
View File
@@ -2,7 +2,8 @@ use crate::app::{
App,
helpers::block_title_helper,
keybindings::{Action, count_largest_group},
widgets::{BoardWidget, KeybindingsWidget},
states::skirmish_states::{structures::Structure, units::Unit},
widgets::{BoardWidget, KeybindingsWidget, SidePanelWidget},
};
use ratatui::{
buffer::Buffer,
@@ -29,6 +30,7 @@ const ACTIONS: &[Action] = &[
Action::Backspace,
Action::Delete,
Action::Mute,
Action::Tab,
Action::Quit,
Action::Quit2,
Action::Esc,
@@ -43,6 +45,16 @@ pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
.areas(area)
}
pub fn skirmish_main_area_layout(area: Rect, side_panel: bool) -> [Rect; 2] {
let constraint: [Constraint; 2] = if side_panel {
[Constraint::Percentage(80), Constraint::Percentage(20)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
};
Layout::horizontal(constraint).areas(area)
}
pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
let Some(states) = app.states() else { return };
@@ -80,16 +92,22 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
}
{
let board_block: Block = Block::new()
let main_block: Block = Block::new()
.gray()
.title(block_title_helper(
&[("Skirmish", Color::Magenta), ("Map", Color::Green)],
&[
("Skirmish".to_string(), Color::Magenta),
("Map".to_string(), Color::Green),
],
Some(" - "),
))
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT);
let board_area: Rect = board_block.inner(main_area);
board_block.render(main_area, buf);
let main_inner_area: Rect = main_block.inner(main_area);
main_block.render(main_area, buf);
let [board_area, side_panel_area] =
skirmish_main_area_layout(main_inner_area, states.skirmish.side_panel);
let cells_area: Rect = board_area.inner(Margin {
horizontal: 1,
@@ -105,6 +123,26 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
// );
BoardWidget::new(&states.skirmish.board).render(cells_area, buf);
if states.skirmish.side_panel {
let row: usize = states.skirmish.board.get_focused_cell().get_row();
let col: usize = states.skirmish.board.get_focused_cell().get_col();
let coords: (usize, usize) = (row, col);
let structure_name: &str = states
.skirmish
.board
.get_ref_cell(row, col)
.get_structure()
.get_name();
let unit_name: &str = states
.skirmish
.board
.get_ref_cell(row, col)
.get_unit_option()
.get_name();
SidePanelWidget::new(coords, structure_name, unit_name).render(side_panel_area, buf);
}
}
{
+13 -4
View File
@@ -1,14 +1,14 @@
use crate::app::states::skirmish_states::{
ZoomLevel,
structures::{Stone, Structures},
units::{OptionalUnit, Units},
structures::{Stone, Structure, Structures},
units::{Unit, Units},
};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style, Stylize},
text::{Line, Span, ToSpan},
widgets::{Block, Borders, Paragraph, Widget},
widgets::{Block, BorderType, Borders, Paragraph, Widget},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -52,6 +52,14 @@ impl CellWidget {
self
}
pub fn get_structure(&self) -> Structures {
self.structure
}
pub fn get_unit_option(&self) -> Option<Units> {
self.unit
}
pub fn set_structure(&mut self, sctructure: Structures) -> &mut Self {
self.structure = sctructure;
self
@@ -102,7 +110,7 @@ impl CellWidget {
self.zoom_level.get_cell_text_area(
self.structure.get_tag(),
self.structure.get_level(),
self.unit.try_get_tag(),
self.unit.get_tag(),
)
}
@@ -111,6 +119,7 @@ impl CellWidget {
.borders(Borders::ALL)
.style(Style::default().fg(self.fg_color()))
.title(self.display_coords())
.border_type(BorderType::Rounded)
}
}
+7 -3
View File
@@ -115,9 +115,13 @@ impl Widget for KeybindingsWidget {
return;
}
let block: Block<'_> = Block::default()
.borders(Borders::ALL)
.title(block_single_title_helper("Keybindings", Color::Magenta));
let block: Block<'_> =
Block::default()
.borders(Borders::ALL)
.title(block_single_title_helper(
"Keybindings".to_string(),
Color::Magenta,
));
let inner: Rect = block.inner(area);
+2
View File
@@ -1,7 +1,9 @@
mod board;
mod cell;
mod keybindings;
mod side_panel;
pub use board::BoardWidget;
pub use cell::CellWidget;
pub use keybindings::KeybindingsWidget;
pub use side_panel::SidePanelWidget;
+74
View File
@@ -0,0 +1,74 @@
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Color,
text::Line,
widgets::{Block, BorderType, Borders, Paragraph, Widget},
};
use crate::app::helpers::block_title_helper;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SidePanelWidget {
coords: (usize, usize),
structure_name: &'static str,
unit_name: &'static str,
}
impl SidePanelWidget {
pub fn new(
coords: (usize, usize),
structure_name: &'static str,
unit_name: &'static str,
) -> Self {
Self {
coords,
structure_name,
unit_name,
}
}
fn col_to_letters(&self) -> String {
let mut col: usize = self.coords.1 + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
}
letters.iter().rev().collect()
}
fn get_title(&self) -> Line<'_> {
let cell_coords: String = format!("{}{}", self.col_to_letters(), self.coords.0);
let mut texts: Vec<(String, Color)> = Vec::with_capacity(3);
texts.push((cell_coords, Color::Yellow));
texts.push((self.structure_name.to_string(), Color::Cyan));
if !self.unit_name.is_empty() {
texts.push((self.unit_name.to_string(), Color::Green));
}
block_title_helper(&texts, Some(" - "))
}
fn get_block(&self) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.title(self.get_title())
.title_alignment(Alignment::Center)
}
}
impl Widget for SidePanelWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
Paragraph::default()
.alignment(Alignment::Center)
.block(self.get_block())
.render(area, buf);
}
}