feat: initial TUI for administration #49
5 changed files with 117 additions and 29 deletions
|
|
@ -7,17 +7,28 @@ use super::state::{Mode, State};
|
||||||
/// Event handling
|
/// Event handling
|
||||||
pub async fn handle_key(state: &mut State, key_event: KeyEvent) {
|
pub async fn handle_key(state: &mut State, key_event: KeyEvent) {
|
||||||
match state.mode {
|
match state.mode {
|
||||||
Mode::List => match key_event.code {
|
Mode::List => {
|
||||||
KeyCode::Char('e') => state.mode = Mode::Edit,
|
if state.confirmation.is_some() {
|
||||||
KeyCode::Home => state.selected_place.select_first(),
|
match key_event.code {
|
||||||
KeyCode::End => state.selected_place.select_last(),
|
KeyCode::Char('y') => state.proceed_confirmation().await,
|
||||||
KeyCode::PageUp => state.prev_page(),
|
KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(),
|
||||||
KeyCode::PageDown => state.next_page(),
|
_ => {}
|
||||||
KeyCode::Up | KeyCode::Char('k') => state.selected_place.select_previous(),
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') => state.selected_place.select_next(),
|
} else {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => state.quit = true,
|
match key_event.code {
|
||||||
_ => {}
|
KeyCode::Char('d') => state.confirm_deletion(),
|
||||||
},
|
KeyCode::Char('e') => state.mode = Mode::Edit,
|
||||||
|
KeyCode::Home => state.selected_place.select_first(),
|
||||||
|
KeyCode::End => state.selected_place.select_last(),
|
||||||
|
KeyCode::PageUp => state.prev_page(),
|
||||||
|
KeyCode::PageDown => state.next_page(),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => state.selected_place.select_previous(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => state.selected_place.select_next(),
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => state.quit = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Mode::Edit => match (key_event.modifiers, key_event.code) {
|
Mode::Edit => match (key_event.modifiers, key_event.code) {
|
||||||
(KeyModifiers::NONE, KeyCode::Esc) => state.mode = Mode::List,
|
(KeyModifiers::NONE, KeyCode::Esc) => state.mode = Mode::List,
|
||||||
(KeyModifiers::NONE, KeyCode::Tab) => {}
|
(KeyModifiers::NONE, KeyCode::Tab) => {}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,6 @@ pub async fn tui(places_repository: DbPlacesRepository) -> Result<()> {
|
||||||
Event::Tick => {
|
Event::Tick => {
|
||||||
state.fetch_places().await;
|
state.fetch_places().await;
|
||||||
}
|
}
|
||||||
Event::SlowTick => {
|
|
||||||
// state.cleanup_finished_tasks();
|
|
||||||
}
|
|
||||||
Event::Resize(_, h) => state.height = h,
|
Event::Resize(_, h) => state.height = h,
|
||||||
Event::Quit => state.quit = true,
|
Event::Quit => state.quit = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ pub struct State {
|
||||||
places_repository: DbPlacesRepository,
|
places_repository: DbPlacesRepository,
|
||||||
pub places: Vec<Place>,
|
pub places: Vec<Place>,
|
||||||
places_status: DataStatus,
|
places_status: DataStatus,
|
||||||
|
pub confirmation: Option<ConfirmationStatus>,
|
||||||
pub selected_place: TableState,
|
pub selected_place: TableState,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +28,12 @@ enum DataStatus {
|
||||||
Old,
|
Old,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum ConfirmationStatus {
|
||||||
|
Deletion(i64),
|
||||||
|
#[expect(dead_code)]
|
||||||
|
Save(i64),
|
||||||
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
pub fn new(places_repository: DbPlacesRepository, height: u16) -> Self {
|
pub fn new(places_repository: DbPlacesRepository, height: u16) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -37,6 +44,7 @@ impl State {
|
||||||
places_status: DataStatus::Old,
|
places_status: DataStatus::Old,
|
||||||
places: vec![],
|
places: vec![],
|
||||||
selected_place: TableState::default(),
|
selected_place: TableState::default(),
|
||||||
|
confirmation: None,
|
||||||
quit: false,
|
quit: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,4 +82,36 @@ impl State {
|
||||||
}
|
}
|
||||||
self.places_status = DataStatus::Fresh;
|
self.places_status = DataStatus::Fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn confirm_deletion(&mut self) {
|
||||||
|
if let Some(Some(id)) = self
|
||||||
|
.selected_place
|
||||||
|
.selected()
|
||||||
|
.map(|index| self.places.get(index).map(|p| p.id))
|
||||||
|
{
|
||||||
|
self.confirmation = Some(ConfirmationStatus::Deletion(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_confirmation(&mut self) {
|
||||||
|
self.confirmation = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn proceed_confirmation(&mut self) {
|
||||||
|
let Some(confirmation) = &self.confirmation else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match confirmation {
|
||||||
|
ConfirmationStatus::Deletion(id) => {
|
||||||
|
if let Err(err) = self.places_repository.delete_place(*id).await {
|
||||||
|
tracing::error!("{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConfirmationStatus::Save(_) => todo!(),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.confirmation = None;
|
||||||
|
self.places_status = DataStatus::Old;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ pub enum Event {
|
||||||
Closed,
|
Closed,
|
||||||
/// Triggers background actions
|
/// Triggers background actions
|
||||||
Tick,
|
Tick,
|
||||||
/// Triggers less frequent background actions
|
|
||||||
SlowTick,
|
|
||||||
/// UI Render
|
/// UI Render
|
||||||
Render,
|
Render,
|
||||||
FocusGained,
|
FocusGained,
|
||||||
|
|
@ -49,8 +47,7 @@ pub struct Tui {
|
||||||
impl Tui {
|
impl Tui {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let tick_rate = 4.0;
|
let tick_rate = 4.0;
|
||||||
let slow_tick_rate = 0.25;
|
let frame_rate = 20.0;
|
||||||
let frame_rate = 30.0;
|
|
||||||
let terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
|
let terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
|
||||||
let cancellation_token = CancellationToken::new();
|
let cancellation_token = CancellationToken::new();
|
||||||
|
|
||||||
|
|
@ -60,17 +57,14 @@ impl Tui {
|
||||||
let cancellation_token = cancellation_token.clone();
|
let cancellation_token = cancellation_token.clone();
|
||||||
let sender = sender.clone();
|
let sender = sender.clone();
|
||||||
let tick_delay = std::time::Duration::from_secs_f64(1.0 / tick_rate);
|
let tick_delay = std::time::Duration::from_secs_f64(1.0 / tick_rate);
|
||||||
let slow_tick_delay = std::time::Duration::from_secs_f64(1.0 / slow_tick_rate);
|
|
||||||
let render_delay = std::time::Duration::from_secs_f64(1.0 / frame_rate);
|
let render_delay = std::time::Duration::from_secs_f64(1.0 / frame_rate);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut reader = crossterm::event::EventStream::new();
|
let mut reader = crossterm::event::EventStream::new();
|
||||||
let mut tick_interval = tokio::time::interval(tick_delay);
|
let mut tick_interval = tokio::time::interval(tick_delay);
|
||||||
let mut slow_tick_interval = tokio::time::interval(slow_tick_delay);
|
|
||||||
let mut render_interval = tokio::time::interval(render_delay);
|
let mut render_interval = tokio::time::interval(render_delay);
|
||||||
sender.send(Event::Init)?;
|
sender.send(Event::Init)?;
|
||||||
loop {
|
loop {
|
||||||
let tick_delay = tick_interval.tick();
|
let tick_delay = tick_interval.tick();
|
||||||
let slow_tick_delay = slow_tick_interval.tick();
|
|
||||||
let render_delay = render_interval.tick();
|
let render_delay = render_interval.tick();
|
||||||
let crossterm_event = reader.next().fuse();
|
let crossterm_event = reader.next().fuse();
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
|
@ -104,9 +98,6 @@ impl Tui {
|
||||||
_ = tick_delay => {
|
_ = tick_delay => {
|
||||||
sender.send(Event::Tick)?;
|
sender.send(Event::Tick)?;
|
||||||
},
|
},
|
||||||
_ = slow_tick_delay => {
|
|
||||||
sender.send(Event::SlowTick)?;
|
|
||||||
},
|
|
||||||
_ = render_delay => {
|
_ = render_delay => {
|
||||||
sender.send(Event::Render)?;
|
sender.send(Event::Render)?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
|
||||||
use ratatui::style::{Color, Style, Stylize};
|
use ratatui::style::{Color, Style, Stylize};
|
||||||
use ratatui::text::{Line, Span, ToSpan};
|
use ratatui::text::{Line, Span, Text, ToSpan};
|
||||||
use ratatui::widgets::{Paragraph, Row, Table};
|
use ratatui::widgets::{Block, Clear, Padding, Paragraph, Row, Table};
|
||||||
|
|
||||||
use super::state::{Mode, State};
|
use super::state::{ConfirmationStatus, Mode, State};
|
||||||
|
|
||||||
/// UI drawing
|
/// UI drawing
|
||||||
pub fn ui_draw(state: &mut State, f: &mut Frame<'_>) {
|
pub fn ui_draw(state: &mut State, f: &mut Frame<'_>) {
|
||||||
|
|
@ -54,7 +54,36 @@ fn main_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
Mode::List => list_draw(state, f, area),
|
Mode::List => list_draw(state, f, area),
|
||||||
Mode::Edit => edit_draw(state, f, area),
|
Mode::Edit => edit_draw(state, f, area),
|
||||||
}
|
}
|
||||||
|
confirmation_dialog_draw(state, f, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
|
let Some(confirmation) = &state.confirmation else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60));
|
||||||
|
let (action, id) = match confirmation {
|
||||||
|
ConfirmationStatus::Deletion(id) => ("delete", id),
|
||||||
|
ConfirmationStatus::Save(id) => ("save", id),
|
||||||
|
};
|
||||||
|
let confirmation_dialog = Paragraph::new(Text::from_iter([
|
||||||
|
Line::from_iter([
|
||||||
|
"Do you want to ".to_span(),
|
||||||
|
action.to_span(),
|
||||||
|
" place with id: ".to_span(),
|
||||||
|
id.to_span(),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("Y/N".to_span().bold()),
|
||||||
|
]))
|
||||||
|
.centered()
|
||||||
|
.block(Block::bordered().padding(Padding::uniform(1)));
|
||||||
|
|
||||||
|
f.render_widget(Clear, dialog_area);
|
||||||
|
f.render_widget(confirmation_dialog, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
let places = state.places.iter().map(|p| {
|
let places = state.places.iter().map(|p| {
|
||||||
Row::new([
|
Row::new([
|
||||||
|
|
@ -99,7 +128,18 @@ fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
f.render_stateful_widget(places_table, area, &mut state.selected_place);
|
f.render_stateful_widget(places_table, area, &mut state.selected_place);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit_draw(_state: &mut State, _f: &mut Frame<'_>, _area: Rect) {}
|
fn edit_draw(_state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
|
let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60));
|
||||||
|
let confirmation_dialog =
|
||||||
|
Paragraph::new(Text::from_iter([
|
||||||
|
Line::from("Not implemented yet :(").italic()
|
||||||
|
]))
|
||||||
|
.centered()
|
||||||
|
.block(Block::bordered().padding(Padding::uniform(1)));
|
||||||
|
|
||||||
|
f.render_widget(Clear, dialog_area);
|
||||||
|
f.render_widget(confirmation_dialog, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
#[expect(unstable_name_collisions)]
|
#[expect(unstable_name_collisions)]
|
||||||
fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
|
|
@ -114,6 +154,7 @@ fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
("PgUp", "Prev Page"),
|
("PgUp", "Prev Page"),
|
||||||
("PgDown", "Next Page"),
|
("PgDown", "Next Page"),
|
||||||
("e", "Edit"),
|
("e", "Edit"),
|
||||||
|
("d", "Delete"),
|
||||||
]
|
]
|
||||||
.map(|(key, action)| keybinding(key, action).to_vec())
|
.map(|(key, action)| keybinding(key, action).to_vec())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -145,3 +186,11 @@ fn keybinding(key: &'static str, action: &'static str) -> [Span<'static>; 5] {
|
||||||
Span::styled(" ", black),
|
Span::styled(" ", black),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect {
|
||||||
|
let [area] = Layout::horizontal([horizontal])
|
||||||
|
.flex(Flex::Center)
|
||||||
|
.areas(area);
|
||||||
|
let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area);
|
||||||
|
area
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue