feat: add edit mode to TUI #57
6 changed files with 426 additions and 376 deletions
645
Cargo.lock
generated
645
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,8 @@ axum = { version = "0.8.4", default-features = false, features = [
|
||||||
] }
|
] }
|
||||||
axum-msgpack = "0.5.0"
|
axum-msgpack = "0.5.0"
|
||||||
clap = { version = "4.5.40", features = ["derive"] }
|
clap = { version = "4.5.40", features = ["derive"] }
|
||||||
crossterm = { version = "0.29.0", default-features = false, features = [
|
# This must be the same version that ratatui depends on :(
|
||||||
|
crossterm = { version = "0.28.1", default-features = false, features = [
|
||||||
"bracketed-paste",
|
"bracketed-paste",
|
||||||
"event-stream",
|
"event-stream",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -6,49 +6,36 @@ 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) {
|
||||||
|
if state.confirmation.is_some() {
|
||||||
|
match key_event.code {
|
||||||
|
KeyCode::Char('y') => state.proceed_confirmation().await,
|
||||||
|
KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(),
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
match state.mode {
|
match state.mode {
|
||||||
Mode::List => {
|
Mode::List => match key_event.code {
|
||||||
if state.confirmation.is_some() {
|
KeyCode::Char('d') => state.confirm_deletion(),
|
||||||
match key_event.code {
|
KeyCode::Char('e') => {
|
||||||
KeyCode::Char('y') => {
|
state.set_edit_mode();
|
||||||
state.proceed_confirmation().await;
|
|
||||||
}
|
|
||||||
KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(),
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
match key_event.code {
|
|
||||||
KeyCode::Char('d') => state.confirm_deletion(),
|
|
||||||
KeyCode::Char('e') => {
|
|
||||||
state.set_edit_mode();
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
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) {
|
||||||
(_, KeyCode::Esc) => {
|
(_, KeyCode::Esc) => state.set_list_mode(),
|
||||||
state.set_list_mode();
|
(_, KeyCode::Tab) => state.edit_next(),
|
||||||
}
|
|
||||||
(_, KeyCode::Tab) => {
|
(_, KeyCode::BackTab) => state.edit_prev(),
|
||||||
state.edit_next();
|
(KeyModifiers::CONTROL, KeyCode::Char('s')) => state.start_save(),
|
||||||
}
|
_ => state.edit_input(key_event),
|
||||||
(_, KeyCode::BackTab) => {
|
|
||||||
state.edit_prev();
|
|
||||||
}
|
|
||||||
(KeyModifiers::CONTROL, KeyCode::Char('s')) => {}
|
|
||||||
_ => {}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ pub async fn tui(places_repository: DbPlacesRepository) -> Result<()> {
|
||||||
match tui.next().await? {
|
match tui.next().await? {
|
||||||
Event::Key(key_event) => keys::handle_key(&mut state, key_event).await,
|
Event::Key(key_event) => keys::handle_key(&mut state, key_event).await,
|
||||||
Event::Render => {
|
Event::Render => {
|
||||||
ui.handle_messages(state.ui_messages.drain(0..));
|
let messages = state.ui_messages.drain(0..).collect::<Vec<_>>();
|
||||||
|
ui.handle_messages(&mut state, messages);
|
||||||
tui.draw(|frame| ui.draw(&mut state, frame))?;
|
tui.draw(|frame| ui.draw(&mut state, frame))?;
|
||||||
}
|
}
|
||||||
Event::Tick => {
|
Event::Tick => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
//! TUI state
|
//! TUI state
|
||||||
|
|
||||||
|
use ratatui::crossterm::event::KeyEvent;
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
|
|
||||||
use crate::places::db_repository::DbPlacesRepository;
|
use crate::places::db_repository::DbPlacesRepository;
|
||||||
|
|
@ -99,6 +100,10 @@ impl State {
|
||||||
self.ui_messages.push(Message::EditPrev);
|
self.ui_messages.push(Message::EditPrev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn edit_input(&mut self, key_event: KeyEvent) {
|
||||||
|
self.ui_messages.push(Message::Input(key_event));
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_places(&mut self) {
|
pub async fn fetch_places(&mut self) {
|
||||||
if let DataStatus::Fresh = self.places_status {
|
if let DataStatus::Fresh = self.places_status {
|
||||||
return;
|
return;
|
||||||
|
|
@ -135,6 +140,16 @@ impl State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn start_save(&mut self) {
|
||||||
|
if let Some(Some(id)) = self
|
||||||
|
.selected_place
|
||||||
|
.selected()
|
||||||
|
.map(|index| self.places.get(index).map(|p| p.id))
|
||||||
|
{
|
||||||
|
self.ui_messages.push(Message::SavePlace(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn confirm_save(&mut self, place: Place) {
|
pub fn confirm_save(&mut self, place: Place) {
|
||||||
self.confirmation = Some(ConfirmationStatus::Save(place));
|
self.confirmation = Some(ConfirmationStatus::Save(place));
|
||||||
}
|
}
|
||||||
|
|
@ -158,6 +173,8 @@ impl State {
|
||||||
if let Err(err) = self.places_repository.update_place(place.clone()).await {
|
if let Err(err) = self.places_repository.update_place(place.clone()).await {
|
||||||
tracing::error!("{err}");
|
tracing::error!("{err}");
|
||||||
}
|
}
|
||||||
|
self.mode = Mode::List;
|
||||||
|
self.push_mode_change();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||||
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style, Stylize};
|
use ratatui::style::{Color, Modifier, Style, Stylize};
|
||||||
use ratatui::text::{Line, Span, Text, ToSpan};
|
use ratatui::text::{Line, Span, Text, ToSpan};
|
||||||
|
|
@ -19,6 +20,8 @@ pub enum Message {
|
||||||
EditPlace(Place),
|
EditPlace(Place),
|
||||||
EditNext,
|
EditNext,
|
||||||
EditPrev,
|
EditPrev,
|
||||||
|
SavePlace(i64),
|
||||||
|
Input(KeyEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UI {
|
pub struct UI {
|
||||||
|
|
@ -36,7 +39,11 @@ impl UI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_messages<M: IntoIterator<Item = Message>>(&mut self, messages: M) {
|
pub fn handle_messages<M: IntoIterator<Item = Message>>(
|
||||||
|
&mut self,
|
||||||
|
state: &mut State,
|
||||||
|
messages: M,
|
||||||
|
) {
|
||||||
for m in messages {
|
for m in messages {
|
||||||
match m {
|
match m {
|
||||||
Message::UpdateAppMode(mode) => {
|
Message::UpdateAppMode(mode) => {
|
||||||
|
|
@ -48,6 +55,8 @@ impl UI {
|
||||||
Message::EditPlace(place) => self.main.set_edit_textareas(place),
|
Message::EditPlace(place) => self.main.set_edit_textareas(place),
|
||||||
Message::EditNext => self.main.next_textarea(),
|
Message::EditNext => self.main.next_textarea(),
|
||||||
Message::EditPrev => self.main.prev_textarea(),
|
Message::EditPrev => self.main.prev_textarea(),
|
||||||
|
Message::SavePlace(id) => self.main.save_place(state, id),
|
||||||
|
Message::Input(key_event) => self.main.pass_input(key_event),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +187,7 @@ impl Main {
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
];
|
];
|
||||||
|
|
||||||
let places_table = Table::new(places, widths)
|
Table::new(places, widths)
|
||||||
.header(
|
.header(
|
||||||
Row::new([
|
Row::new([
|
||||||
"Id",
|
"Id",
|
||||||
|
|
@ -193,9 +202,7 @@ impl Main {
|
||||||
])
|
])
|
||||||
.style(Style::new().white().on_dark_gray().bold()),
|
.style(Style::new().white().on_dark_gray().bold()),
|
||||||
)
|
)
|
||||||
.row_highlight_style(Style::new().reversed());
|
.row_highlight_style(Style::new().reversed())
|
||||||
|
|
||||||
places_table
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_edit_textareas(&mut self, place: Place) {
|
fn set_edit_textareas(&mut self, place: Place) {
|
||||||
|
|
@ -261,6 +268,51 @@ impl Main {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn save_place(&self, state: &mut State, id: i64) {
|
||||||
|
let name = self.edit_textareas[0].lines().concat();
|
||||||
|
let Ok(latitude) = self.edit_textareas[1].lines().concat().parse::<f64>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(longitude) = self.edit_textareas[2].lines().concat().parse::<f64>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let icon = self.edit_textareas[3].lines().concat();
|
||||||
|
let address = self.edit_textareas[4].lines().concat();
|
||||||
|
let url = self.edit_textareas[5].lines().concat();
|
||||||
|
let url = if url.is_empty() { None } else { Some(url) };
|
||||||
|
let open_hours = self.edit_textareas[6].lines().concat();
|
||||||
|
let description = self.edit_textareas[7].lines().concat();
|
||||||
|
let place = Place {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
open_hours,
|
||||||
|
icon,
|
||||||
|
description,
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
state.confirm_save(place);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pass_input(&mut self, key_event: KeyEvent) {
|
||||||
|
let Some(active_textarea) = self.edit_textareas.get_mut(self.selected_textarea) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match key_event.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Only allow line breaking on open hours and description fields
|
||||||
|
if self.selected_textarea == 6 || self.selected_textarea == 7 {
|
||||||
|
active_textarea.input(key_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
active_textarea.input(key_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(&self, state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
fn draw(&self, state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
match state.mode {
|
match state.mode {
|
||||||
Mode::List => self.list_draw(state, f, area),
|
Mode::List => self.list_draw(state, f, area),
|
||||||
|
|
@ -337,7 +389,7 @@ impl Footer {
|
||||||
#[expect(unstable_name_collisions)]
|
#[expect(unstable_name_collisions)]
|
||||||
fn get_keybindings(mode: Mode) -> Paragraph<'static> {
|
fn get_keybindings(mode: Mode) -> Paragraph<'static> {
|
||||||
let separator = Span::styled(" ", Style::new().black().on_black());
|
let separator = Span::styled(" ", Style::new().black().on_black());
|
||||||
let keybindings = match mode {
|
match mode {
|
||||||
Mode::List => {
|
Mode::List => {
|
||||||
let keybindings = [
|
let keybindings = [
|
||||||
("j/k", "Next/Previous"),
|
("j/k", "Next/Previous"),
|
||||||
|
|
@ -365,8 +417,7 @@ impl Footer {
|
||||||
.flatten();
|
.flatten();
|
||||||
Paragraph::new(Line::from_iter(keybindings))
|
Paragraph::new(Line::from_iter(keybindings))
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
keybindings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
|
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue