Something went wrong. Try again.
A TUI for parliament
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},};
use crate::app::{App, InputMode};use crate::state::Stage;
const HELP_TEXT: &str = "\ Parliamentary Process Tracker — Keybindings
Presenting m New moción (opens $EDITOR, only when stack is empty) e New enmienda (opens $EDITOR, amends current item) E New enmienda sustitutiva (opens $EDITOR, replaces current item) r Re-edit current item's text in $EDITOR R Correct the last passed moción's text in $EDITOR
Moción flow s Secundar current item n Not secundada — remove from stack o There is opposition → open voting O No opposition → item passes f / c +1 vote in favor / contra (optional — v opens editor instead) v Enter final vote counts in $EDITOR → auto-finalizes
Overrides p Force pass current item (bypass stages) x Skip / fail current item
Output w Save passed mociones to the output file
? Toggle this help q Quit (auto-saves)
Press any key to close.";
pub fn draw(frame: &mut Frame, app: &App) { let [title_area, main_area, bottom_area] = Layout::vertical([ Constraint::Length(1), Constraint::Min(0), Constraint::Length(3), ]) .areas(frame.area());
let [stack_area, passed_area] = Layout::horizontal([ Constraint::Percentage(55), Constraint::Percentage(45), ]) .areas(main_area);
render_title(frame, app, title_area); render_stack(frame, app, stack_area); render_passed(frame, app, passed_area); render_bottom(frame, app, bottom_area);
if matches!(app.mode, InputMode::ShowingHelp) { render_help_overlay(frame); }}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let margin_v = (100 - percent_y) / 2; let margin_h = (100 - percent_x) / 2; let [_, mid_v, _] = Layout::vertical([ Constraint::Percentage(margin_v), Constraint::Percentage(percent_y), Constraint::Percentage(margin_v), ]) .areas(area); let [_, center, _] = Layout::horizontal([ Constraint::Percentage(margin_h), Constraint::Percentage(percent_x), Constraint::Percentage(margin_h), ]) .areas(mid_v); center}
fn render_help_overlay(frame: &mut Frame) { let area = centered_rect(60, 85, frame.area()); frame.render_widget(Clear, area); let block = Block::default() .title(" Help ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); let p = Paragraph::new(HELP_TEXT) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(p, area);}
fn render_title(frame: &mut Frame, app: &App, area: Rect) { let title = Line::from(vec![ Span::styled(" parl ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::raw("─ "), Span::styled( format!("{} passed", app.passed.len()), Style::default().fg(Color::Green), ), if app.stack.is_empty() { Span::raw("") } else { Span::styled( format!(" {} on stack", app.stack.len()), Style::default().fg(Color::Yellow), ) }, ]); frame.render_widget(Paragraph::new(title), area);}
fn render_stack(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Stack (current on top) ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray));
if app.stack.is_empty() { let p = Paragraph::new("(empty — press 'm' to present a moción)") .style(Style::default().fg(Color::DarkGray)) .block(block); frame.render_widget(p, area); return; }
let inner = block.inner(area); frame.render_widget(block, area);
let list_height = u16::try_from(app.stack.len()) .unwrap_or(u16::MAX) .min(inner.height.saturating_sub(4)); let [list_area, text_area] = Layout::vertical([ Constraint::Length(list_height), Constraint::Min(0), ]) .areas(inner);
let items: Vec<ListItem> = app .stack .iter() .rev() .enumerate() .map(|(i, item)| { let is_current = i == 0; let prefix = if is_current { "► " } else { " " }; let nesting: String = " ".repeat(i); let line = Line::from(vec![ Span::raw(format!("{prefix}{nesting}")), Span::styled( item.kind.label(), if is_current { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }, ), Span::styled( format!(" {}", item.stage.label()), if is_current { Style::default().fg(Color::White) } else { Style::default().fg(Color::DarkGray) }, ), ]); ListItem::new(line) }) .collect();
frame.render_widget(List::new(items), list_area);
if let Some(current) = app.stack.last() { let text_block = Block::default() .title(format!(" {} text ", current.kind.label())) .borders(Borders::TOP) .border_style(Style::default().fg(Color::DarkGray)); let p = Paragraph::new(current.text.as_str()) .block(text_block) .wrap(Wrap { trim: false }) .style(Style::default().fg(Color::White)); frame.render_widget(p, text_area); }}
fn render_passed(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Passed Mociones ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray));
if app.passed.is_empty() { let p = Paragraph::new("(none yet)") .style(Style::default().fg(Color::DarkGray)) .block(block); frame.render_widget(p, area); return; }
let items: Vec<ListItem> = app .passed .iter() .rev() .map(|m| { let preview: String = m.text.chars().take(60).collect(); let ellipsis = if m.text.len() > 60 { "…" } else { "" }; ListItem::new(Line::from(vec![ Span::styled( format!("{}. ", m.number), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), ), Span::raw(format!("{preview}{ellipsis}")), ])) }) .collect();
frame.render_widget(List::new(items).block(block), area);}
fn render_bottom(frame: &mut Frame, app: &App, area: Rect) { let [status_area, hints_area] = Layout::vertical([Constraint::Length(1), Constraint::Length(2)]).areas(area);
frame.render_widget( Paragraph::new(app.status.as_str()).style(Style::default().fg(Color::White)), status_area, );
frame.render_widget( Paragraph::new(context_hints(app)).style(Style::default().fg(Color::DarkGray)), hints_area, );}
fn context_hints(app: &App) -> String { let always = " r: edit R: fix w: save ?: help q: quit"; if app.stack.is_empty() { return format!("m: new moción{always}"); } let stage_hints = match app.stack.last().map(|i| &i.stage) { Some(Stage::AwaitingSecundar) => "s: sec. n: no sec. e: enm. E: susti. p: pasar x: skip", Some(Stage::AwaitingOpposition) => "o: opos. O: no opos. e: enm. E: susti. p: pasar x: skip", Some(Stage::Voting(_)) => "f: +1 c: -1 v: final p: pasar x: skip", None => "", }; format!("{stage_hints}{always}")}