Add tab navigation for building and recruitment UI
Nix package / build (push) Successful in 7m59s
Linux package builds (test) / linux (push) Successful in 2m29s
Linux package builds (test) / aur (push) Successful in 7m18s
Rust checks / check (push) Successful in 3m21s

- Add selection indices and option lists to SkirmishState.
- Implement `cycle_popup_option` and expose Left/Right actions in
  keybindings.
- Introduce `cancel_focused_tasks` in BoardState for Delete handling.
- CellWidget now displays recruitment progress on Base structures.
- Update README and documentation to describe new tabs and arrow
  navigation.
- Adjust test counts and add extensive task‑cancellation tests.
This commit is contained in:
GarandPLG committed 2026-10-23 17:53:46 +02:00
1 parent 6c1141a711
commit 110d4c411a
16 files changed
+546 -48

No files matched your search

+26 -5
View File
@@ -14,14 +14,20 @@ use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if state.recruitment_view {
let mut actions: Vec<Action> = vec![Action::Recruitment, Action::Esc, Action::Quit2];
let mut actions: Vec<Action> = vec![
Action::Recruitment,
Action::Left,
Action::Right,
Action::Quit2,
];
if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) {
actions.push(Action::Enter);
}
return actions;
}
if state.building_view {
let mut actions: Vec<Action> = vec![Action::Building, Action::Esc, Action::Quit2];
let mut actions: Vec<Action> =
vec![Action::Building, Action::Left, Action::Right, Action::Quit2];
let coords: (usize, usize) = state.board.get_focused_cell().get_coords();
if let Structures::MineConstruction(site) =
state.board.get_ref_cell(coords.0, coords.1).get_structure()
@@ -72,6 +78,9 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if unit.get_owner() == Players::Player && unit.get_can_dig() {
actions.push(Action::Space);
}
if state.side_panel && unit.get_owner() == Players::Player && !unit.get_ref_tasks().is_empty() {
actions.extend([Action::Backspace, Action::Delete]);
}
actions
}
@@ -83,7 +92,10 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
};
if states.skirmish.recruitment_view {
match action {
Action::Recruitment | Action::Esc => states.skirmish.recruitment_view = false,
Action::Recruitment => states.skirmish.recruitment_view = false,
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::Esc => (),
Action::Enter => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
@@ -95,7 +107,10 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
}
if states.skirmish.building_view {
match action {
Action::Building | Action::Esc => states.skirmish.building_view = false,
Action::Building => states.skirmish.building_view = false,
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::Esc => (),
Action::Enter => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
@@ -201,7 +216,13 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
Action::Space => board.toggle_marking(),
Action::Building => states.skirmish.building_view = true,
Action::Recruitment => states.skirmish.recruitment_view = true,
Action::Backspace => board.pop_marked_cell(),
Action::Backspace if board.marked_cells.marking_cells => board.pop_marked_cell(),
Action::Backspace => {
board.cancel_focused_tasks(false);
}
Action::Delete if !board.marked_cells.marking_cells => {
board.cancel_focused_tasks(true);
}
Action::Delete => {
board.marked_cells.marking_cells = false;
board.end_marking_cells(true)
+2
View File
@@ -40,6 +40,8 @@ impl GameStates {
enemy_resources: Resources::new(args.starting_wood, args.starting_metal),
building_view: false,
recruitment_view: false,
selected_building: 0,
selected_recruit: 0,
},
perk_decks: PerkDecksState {
id: 2,
+20
View File
@@ -17,9 +17,29 @@ pub struct SkirmishState {
pub enemy_resources: Resources,
pub building_view: bool,
pub recruitment_view: bool,
pub selected_building: usize,
pub selected_recruit: usize,
}
impl SkirmishState {
pub const BUILDING_OPTIONS: &'static [&'static str] = &["Mine"];
pub const RECRUITMENT_OPTIONS: &'static [&'static str] = &["Miner"];
pub fn cycle_popup_option(&mut self, next: bool) -> () {
let (selected, count): (&mut usize, usize) = if self.recruitment_view {
(&mut self.selected_recruit, Self::RECRUITMENT_OPTIONS.len())
} else if self.building_view {
(&mut self.selected_building, Self::BUILDING_OPTIONS.len())
} else {
return;
};
*selected = if next {
(*selected + 1) % count
} else {
(*selected + count - 1) % count
};
}
pub fn can_recruit_miner(&self, (row, col): (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
+19
View File
@@ -500,6 +500,25 @@ impl BoardState {
}
}
pub fn cancel_focused_tasks(&mut self, all: bool) -> bool {
if self.marked_cells.marking_cells {
return false;
}
let (row, col): (usize, usize) = self.focused_cell.get_coords();
let Some(unit) = self.cells[row][col].get_mut_option_unit().as_mut() else {
return false;
};
if unit.get_owner() != Players::Player || unit.get_ref_tasks().is_empty() {
return false;
}
if all {
unit.get_mut_tasks().clear();
} else {
unit.get_mut_tasks().pop_back();
}
true
}
pub fn advance_turn(&mut self) {
// Snapshot positions so moving to a later cell cannot grant another turn.
let active: Vec<(usize, usize)> = self
+53 -24
View File
@@ -2,6 +2,7 @@ use crate::app::{
App,
helpers::block_title_helper,
keybindings::{Action, available_skirmish_actions},
states::SkirmishState,
states::skirmish_states::{
BoardState, Players,
resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS},
@@ -13,9 +14,9 @@ use crate::app::{
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Stylize},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
widgets::{Block, Borders, Clear, Padding, Paragraph, Tabs, Widget},
};
pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
@@ -137,7 +138,31 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
{
let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 11] = [
let descriptions: [(Action, &'static str); 14] = [
(
Action::Left,
if states.skirmish.building_view || states.skirmish.recruitment_view {
"Previous option"
} else {
"Left"
},
),
(
Action::Right,
if states.skirmish.building_view || states.skirmish.recruitment_view {
"Next option"
} else {
"Right"
},
),
(
Action::Backspace,
if board.marked_cells.marking_cells {
"Undo last cell"
} else {
"Cancel last task"
},
),
(
Action::Enter,
if states.skirmish.recruitment_view {
@@ -158,6 +183,8 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
Action::Delete,
if states.skirmish.building_view {
"Cancel construction"
} else if !board.marked_cells.marking_cells {
"Cancel all tasks"
} else {
"Cancel marking"
},
@@ -172,11 +199,7 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
),
(
Action::Esc,
if states.skirmish.recruitment_view {
"Close recruitment"
} else if states.skirmish.building_view {
"Close buildings"
} else if board.marked_cells.marking_cells {
if board.marked_cells.marking_cells {
"Cancel route"
} else {
"Menu"
@@ -228,7 +251,6 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
vec![Line::from("Construction unavailable: check site, builder and resources").red()]
};
let mut lines: Vec<Line<'_>> = vec![
Line::from("Mine").yellow().bold(),
Line::from(format!(
"Cost: {} Wood + {} Metal | Build time: 10s",
Mine::COST.wood,
@@ -240,13 +262,17 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
];
lines.extend(status);
Clear.render(popup, buf);
Paragraph::new(lines)
.block(
Block::bordered()
.title(Line::from("Buildings").magenta().bold())
.padding(Padding::uniform(1)),
)
.render(popup, buf);
let block: Block<'_> = Block::bordered()
.title(Line::from("Buildings").magenta().bold())
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(block.inner(popup));
block.render(popup, buf);
Tabs::new(SkirmishState::BUILDING_OPTIONS.iter().copied())
.select(states.skirmish.selected_building)
.highlight_style(Style::default().fg(Color::LightMagenta).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
}
if states.skirmish.recruitment_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
@@ -263,7 +289,6 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
Line::from("Insufficient resources or queue full").red()
};
vec![
Line::from("Miner").yellow().bold(),
Line::from(format!(
"Cost: {} Wood + {} Metal | Recruit time: 10s",
MinerUnit::RECRUIT_COST.wood,
@@ -287,12 +312,16 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
vec![Line::from("Select your Base to recruit Miners").red()]
};
Clear.render(popup, buf);
Paragraph::new(lines)
.block(
Block::bordered()
.title(Line::from("Recruitment").magenta().bold())
.padding(Padding::uniform(1)),
)
.render(popup, buf);
let block: Block<'_> = Block::bordered()
.title(Line::from("Recruitment").magenta().bold())
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(block.inner(popup));
block.render(popup, buf);
Tabs::new(SkirmishState::RECRUITMENT_OPTIONS.iter().copied())
.select(states.skirmish.selected_recruit)
.highlight_style(Style::default().fg(Color::LightMagenta).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
}
}
+26 -3
View File
@@ -1,10 +1,11 @@
use crate::app::states::skirmish_states::BoardState;
use crate::app::states::skirmish_states::{BoardState, Players, tasks::Task, units::Unit};
use crate::app::widgets::CellWidget;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
widgets::Widget,
};
use std::rc::Rc;
use std::{collections::HashSet, rc::Rc};
pub struct BoardWidget<'a> {
state: &'a BoardState,
@@ -18,6 +19,18 @@ impl<'a> BoardWidget<'a> {
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut queued_path: HashSet<(usize, usize)> = HashSet::new();
for row in &self.state.cells {
for cell in row {
if let Some(unit) = cell.get_ref_option_unit()
&& unit.get_owner() == Players::Player
{
for task in unit.get_ref_tasks() {
queued_path.extend(task.get_path().iter().copied());
}
}
}
}
let horizontal: Rc<[Rect]> =
Layout::horizontal(vec![
Constraint::Length(self.state.cell_width as u16);
@@ -38,7 +51,17 @@ impl Widget for BoardWidget<'_> {
.cells
.get(row_idx + self.state.vertical_offset.get_value())
.and_then(|rows| rows.get(col_idx + self.state.horizontal_offset.get_value()))
.map(|cell| cell.clone().render(*cell_area, buf));
.map(|cell: &CellWidget| -> () {
let mut cell: CellWidget = cell.clone();
let coords: (usize, usize) = (
row_idx + self.state.vertical_offset.get_value(),
col_idx + self.state.horizontal_offset.get_value(),
);
if queued_path.contains(&coords) {
cell.set_marked(true);
}
cell.render(*cell_area, buf);
});
}
}
}
+12
View File
@@ -143,6 +143,18 @@ impl CellWidget {
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(unit.get_owner().get_color());
}
if let Structures::Base(base) = self.structure
&& base.queued_miners() > 0
{
let middle: usize = lines.len() / 2;
let percent: u16 = base.recruitment_progress();
let label: String = if self.zoom_level == ZoomLevel::ZoomedOut && percent == 100 {
percent.to_string()
} else {
format!("{percent}%")
};
lines[middle] = Line::from(label).cyan();
}
lines
}