feat: add edit mode to TUI #57

Merged
pitbuster merged 2 commits from tui-edit into main 2025-06-22 20:11:50 -04:00
6 changed files with 839 additions and 485 deletions

655
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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",
@ -33,13 +34,13 @@ sqlx = { version = "0.8.6", default-features = false, features = [
"sqlite", "sqlite",
"tls-rustls", "tls-rustls",
] } ] }
thiserror = "2.0.12"
tokio = { version = "1.45.1", default-features = false, features = [ tokio = { version = "1.45.1", default-features = false, features = [
"macros", "macros",
"rt-multi-thread", "rt-multi-thread",
"signal", "signal",
] } ] }
tokio-util = "0.7.15" tokio-util = "0.7.15"
thiserror = "2.0.12"
tower-http = { version = "0.6.6", default-features = false, features = ["fs"] } tower-http = { version = "0.6.6", default-features = false, features = ["fs"] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", default-features = false, features = [ tracing-subscriber = { version = "0.3.19", default-features = false, features = [
@ -48,6 +49,10 @@ tracing-subscriber = { version = "0.3.19", default-features = false, features =
"tracing", "tracing",
"tracing-log", "tracing-log",
] } ] }
tui-textarea = { version = "0.7.0", default-features = false, features = [
"ratatui",
"crossterm",
] }
[dev-dependencies] [dev-dependencies]
axum-test = { version = "17.3.0", features = ["msgpack"] } axum-test = { version = "17.3.0", features = ["msgpack"] }

View file

@ -6,18 +6,20 @@ 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 {
Mode::List => {
if state.confirmation.is_some() { if state.confirmation.is_some() {
match key_event.code { match key_event.code {
KeyCode::Char('y') => state.proceed_confirmation().await, KeyCode::Char('y') => state.proceed_confirmation().await,
KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(), KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(),
_ => {} _ => {}
};
return;
} }
} else { match state.mode {
match key_event.code { Mode::List => match key_event.code {
KeyCode::Char('d') => state.confirm_deletion(), KeyCode::Char('d') => state.confirm_deletion(),
KeyCode::Char('e') => state.mode = Mode::Edit, KeyCode::Char('e') => {
state.set_edit_mode();
}
KeyCode::Home => state.selected_place.select_first(), KeyCode::Home => state.selected_place.select_first(),
KeyCode::End => state.selected_place.select_last(), KeyCode::End => state.selected_place.select_last(),
KeyCode::PageUp => state.prev_page(), KeyCode::PageUp => state.prev_page(),
@ -26,14 +28,14 @@ pub async fn handle_key(state: &mut State, key_event: KeyEvent) {
KeyCode::Down | KeyCode::Char('j') => state.selected_place.select_next(), KeyCode::Down | KeyCode::Char('j') => state.selected_place.select_next(),
KeyCode::Esc | KeyCode::Char('q') => state.quit = true, 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, (_, KeyCode::Esc) => state.set_list_mode(),
(KeyModifiers::NONE, KeyCode::Tab) => {} (_, KeyCode::Tab) => state.edit_next(),
(KeyModifiers::SHIFT, KeyCode::Tab) => {}
_ => {} (_, KeyCode::BackTab) => state.edit_prev(),
(KeyModifiers::CONTROL, KeyCode::Char('s')) => state.start_save(),
_ => state.edit_input(key_event),
}, },
} }
} }

View file

@ -9,6 +9,7 @@ use anyhow::Result;
use state::State; use state::State;
use terminal::Event; use terminal::Event;
use ui::UI;
use crate::places::db_repository::DbPlacesRepository; use crate::places::db_repository::DbPlacesRepository;
@ -16,13 +17,16 @@ use crate::places::db_repository::DbPlacesRepository;
pub async fn tui(places_repository: DbPlacesRepository) -> Result<()> { pub async fn tui(places_repository: DbPlacesRepository) -> Result<()> {
let (_, terminal_height) = crossterm::terminal::size()?; let (_, terminal_height) = crossterm::terminal::size()?;
let mut state = State::new(places_repository, terminal_height); let mut state = State::new(places_repository, terminal_height);
let mut ui = UI::new(&state);
let mut tui = terminal::Tui::new()?; let mut tui = terminal::Tui::new()?;
let result = loop { let result = loop {
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 => {
tui.draw(|frame| ui::ui_draw(&mut state, frame))?; let messages = state.ui_messages.drain(0..).collect::<Vec<_>>();
ui.handle_messages(&mut state, messages);
tui.draw(|frame| ui.draw(&mut state, frame))?;
} }
Event::Tick => { Event::Tick => {
state.fetch_places().await; state.fetch_places().await;

View file

@ -1,10 +1,13 @@
//! TUI state //! TUI state
use ratatui::crossterm::event::KeyEvent;
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
use crate::places::{ use crate::places::db_repository::DbPlacesRepository;
db_repository::DbPlacesRepository, models::Place, repository::PlacesRepository, use crate::places::models::Place;
}; use crate::places::repository::PlacesRepository;
use super::ui::Message;
pub struct State { pub struct State {
pub height: u16, pub height: u16,
@ -15,9 +18,11 @@ pub struct State {
places_status: DataStatus, places_status: DataStatus,
pub confirmation: Option<ConfirmationStatus>, pub confirmation: Option<ConfirmationStatus>,
pub selected_place: TableState, pub selected_place: TableState,
pub ui_messages: Vec<Message>,
pub quit: bool, pub quit: bool,
} }
#[derive(Copy, Clone)]
pub enum Mode { pub enum Mode {
List, List,
Edit, Edit,
@ -30,8 +35,7 @@ enum DataStatus {
pub enum ConfirmationStatus { pub enum ConfirmationStatus {
Deletion(i64), Deletion(i64),
#[expect(dead_code)] Save(Place),
Save(i64),
} }
impl State { impl State {
@ -45,18 +49,59 @@ impl State {
places: vec![], places: vec![],
selected_place: TableState::default(), selected_place: TableState::default(),
confirmation: None, confirmation: None,
ui_messages: Vec::new(),
quit: false, quit: false,
} }
} }
pub fn set_list_mode(&mut self) {
self.mode = Mode::List;
self.push_mode_change();
}
pub fn set_edit_mode(&mut self) {
self.mode = Mode::Edit;
self.push_mode_change();
let Some(selection) = self.selected_place.selected() else {
return;
};
let Some(place) = self.places.get(selection) else {
return;
};
self.ui_messages.push(Message::EditPlace(place.clone()))
}
fn push_mode_change(&mut self) {
self.ui_messages.push(Message::UpdateAppMode(self.mode));
}
pub fn next_page(&mut self) { pub fn next_page(&mut self) {
self.page += 1; self.page += 1;
self.places_status = DataStatus::Old; self.places_status = DataStatus::Old;
self.push_page_change();
} }
pub fn prev_page(&mut self) { pub fn prev_page(&mut self) {
self.page = self.page.saturating_sub(1); self.page = self.page.saturating_sub(1);
self.places_status = DataStatus::Old; self.places_status = DataStatus::Old;
self.push_page_change();
}
fn push_page_change(&mut self) {
self.ui_messages.push(Message::UpdatePage(self.page));
}
pub fn edit_next(&mut self) {
self.ui_messages.push(Message::EditNext);
}
pub fn edit_prev(&mut self) {
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) {
@ -81,6 +126,8 @@ impl State {
} }
} }
self.places_status = DataStatus::Fresh; self.places_status = DataStatus::Fresh;
self.ui_messages
.push(Message::UpdatePlaces(self.places.clone()))
} }
pub fn confirm_deletion(&mut self) { pub fn confirm_deletion(&mut self) {
@ -93,6 +140,20 @@ 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) {
self.confirmation = Some(ConfirmationStatus::Save(place));
}
pub fn cancel_confirmation(&mut self) { pub fn cancel_confirmation(&mut self) {
self.confirmation = None; self.confirmation = None;
} }
@ -108,7 +169,13 @@ impl State {
tracing::error!("{err}"); tracing::error!("{err}");
} }
} }
ConfirmationStatus::Save(_) => todo!(), ConfirmationStatus::Save(place) => {
if let Err(err) = self.places_repository.update_place(place.clone()).await {
tracing::error!("{err}");
}
self.mode = Mode::List;
self.push_mode_change();
}
} }
self.confirmation = None; self.confirmation = None;

View file

@ -2,15 +2,67 @@
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, Style, Stylize}; use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text, ToSpan}; use ratatui::text::{Line, Span, Text, ToSpan};
use ratatui::widgets::{Block, Clear, Padding, Paragraph, Row, Table}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Row, Table};
use tui_textarea::TextArea;
use crate::places::models::Place;
use super::state::{ConfirmationStatus, Mode, State}; use super::state::{ConfirmationStatus, Mode, State};
/// UI drawing pub enum Message {
pub fn ui_draw(state: &mut State, f: &mut Frame<'_>) { UpdateAppMode(Mode),
UpdatePage(u32),
UpdatePlaces(Vec<Place>),
EditPlace(Place),
EditNext,
EditPrev,
SavePlace(i64),
Input(KeyEvent),
}
pub struct UI {
header: Header,
main: Main,
footer: Footer,
}
impl UI {
pub fn new(state: &State) -> Self {
Self {
header: Header::new(state),
main: Main::new(state),
footer: Footer::new(state),
}
}
pub fn handle_messages<M: IntoIterator<Item = Message>>(
&mut self,
state: &mut State,
messages: M,
) {
for m in messages {
match m {
Message::UpdateAppMode(mode) => {
self.header.update_app_mode(mode);
self.footer.update_keybindings(mode);
}
Message::UpdatePage(page) => self.header.update_page(page),
Message::UpdatePlaces(places) => self.main.update_places_table(places),
Message::EditPlace(place) => self.main.set_edit_textareas(place),
Message::EditNext => self.main.next_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),
}
}
}
/// UI drawing
pub fn draw(&mut self, state: &mut State, f: &mut Frame<'_>) {
let main_split = Layout::default() let main_split = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@ -20,12 +72,58 @@ pub fn ui_draw(state: &mut State, f: &mut Frame<'_>) {
]) ])
.split(f.area()); .split(f.area());
header_draw(state, f, main_split[0]); self.header.draw(f, main_split[0]);
main_draw(state, f, main_split[1]); self.main.draw(state, f, main_split[1]);
footer_draw(state, f, main_split[2]); self.footer.draw(f, main_split[2]);
}
} }
fn header_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { struct Header {
app_name: Span<'static>,
app_mode: Span<'static>,
page: Line<'static>,
}
impl Header {
fn new(state: &State) -> Self {
let app_name = Span::styled(
" huellas ",
Style::new().bg(Color::Gray).fg(Color::Black).bold(),
);
let app_mode = Self::get_app_mode(state.mode);
let page = Self::get_page(state.page);
Self {
app_name,
app_mode,
page,
}
}
fn update_app_mode(&mut self, new_mode: Mode) {
self.app_mode = Self::get_app_mode(new_mode)
}
fn get_app_mode(mode: Mode) -> Span<'static> {
match mode {
Mode::List => " LIST ".to_span().black().on_light_green().bold(),
Mode::Edit => " EDIT ".to_span().black().on_light_blue().bold(),
}
}
fn update_page(&mut self, new_page: u32) {
self.page = Self::get_page(new_page)
}
fn get_page(page: u32) -> Line<'static> {
Line::from_iter([
"Page: ".to_span(),
Span::raw(page.to_string()),
" ".to_span(),
])
.right_aligned()
}
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
let split = Layout::default() let split = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
@ -34,68 +132,46 @@ fn header_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
Constraint::Length(6), Constraint::Length(6),
]) ])
.split(area); .split(area);
let app_name = Span::styled( f.render_widget(&self.app_name, split[0]);
" huellas ", f.render_widget(&self.page, split[1]);
Style::new().bg(Color::Gray).fg(Color::Black).bold(), f.render_widget(&self.app_mode, split[2]);
);
let page =
Line::from_iter(["Page: ".to_span(), state.page.to_span(), " ".to_span()]).right_aligned();
let app_mode = match state.mode {
Mode::List => Span::styled(" LIST ", Style::new().black().on_light_green().bold()),
Mode::Edit => Span::styled(" EDIT ", Style::new().black().on_light_blue().bold()),
};
f.render_widget(app_name, split[0]);
f.render_widget(page, split[1]);
f.render_widget(app_mode, split[2]);
}
fn main_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
match state.mode {
Mode::List => list_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) { struct Main {
let Some(confirmation) = &state.confirmation else { places_table: Table<'static>,
return; edit_textareas: Vec<TextArea<'static>>,
}; selected_textarea: usize,
}
let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60)); impl Main {
let (action, id) = match confirmation { fn new(state: &State) -> Self {
ConfirmationStatus::Deletion(id) => ("delete", id), let places_table = Self::get_places_table(state.places.clone());
ConfirmationStatus::Save(id) => ("save", id), let edit_textareas = Vec::new();
}; let selected_textarea = 0;
let confirmation_dialog = Paragraph::new(Text::from_iter([ Self {
Line::from_iter([ places_table,
"Do you want to ".to_span(), edit_textareas,
action.to_span(), selected_textarea,
" 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); fn update_places_table(&mut self, new_places: Vec<Place>) {
f.render_widget(confirmation_dialog, dialog_area); self.places_table = Self::get_places_table(new_places);
} }
fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { fn get_places_table(places: Vec<Place>) -> Table<'static> {
let places = state.places.iter().map(|p| { let places = places.into_iter().map(|p| {
Row::new([ Row::new([
p.id.to_string(), p.id.to_string(),
p.name.clone(), p.name,
p.latitude.to_string(), p.latitude.to_string(),
p.longitude.to_string(), p.longitude.to_string(),
p.icon.clone(), p.icon,
p.address.clone(), p.address,
p.open_hours.clone(), p.open_hours,
p.description.clone(), p.description,
url(p.url.as_ref().map(|u| u.as_ref())), url(p.url),
]) ])
}); });
@ -111,7 +187,7 @@ fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
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",
@ -126,16 +202,168 @@ fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
]) ])
.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())
}
f.render_stateful_widget(places_table, area, &mut state.selected_place); fn set_edit_textareas(&mut self, place: Place) {
let mut name = TextArea::new(vec![place.name]);
name.set_block(Block::default().title("Name"));
let mut latitude = TextArea::new(vec![place.latitude.to_string()]);
latitude.set_block(Block::default().title("Latitude"));
let mut longitude = TextArea::new(vec![place.longitude.to_string()]);
longitude.set_block(Block::default().title("Longitude"));
let mut icon = TextArea::new(vec![place.icon]);
icon.set_block(Block::default().title("Icon"));
let mut address = TextArea::new(vec![place.address]);
address.set_block(Block::default().title("Address"));
let mut url = TextArea::new(vec![place.url.unwrap_or_default()]);
url.set_block(Block::default().title("URL"));
let mut open_hours = TextArea::new(vec![place.open_hours]);
open_hours.set_block(Block::default().title("Open Hours"));
let mut description = TextArea::new(vec![place.description]);
description.set_block(Block::default().title("Description"));
self.edit_textareas = vec![
name,
latitude,
longitude,
icon,
address,
url,
open_hours,
description,
];
for textarea in &mut self.edit_textareas {
inactive_textarea(textarea);
}
active_textarea(&mut self.edit_textareas[0]);
self.selected_textarea = 0;
}
fn next_textarea(&mut self) {
let n = self.edit_textareas.len();
if n != 0 {
if let Some(prev_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
inactive_textarea(prev_textarea);
}
self.selected_textarea = (self.selected_textarea + 1) % n;
if let Some(next_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
active_textarea(next_textarea);
}
}
}
fn prev_textarea(&mut self) {
let n = self.edit_textareas.len();
if n != 0 {
if let Some(prev_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
inactive_textarea(prev_textarea);
}
self.selected_textarea = (self.selected_textarea + n - 1) % n;
if let Some(next_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
active_textarea(next_textarea);
}
}
}
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) {
match state.mode {
Mode::List => self.list_draw(state, f, area),
Mode::Edit => self.edit_draw(state, f, area),
}
confirmation_dialog_draw(state, f, area);
}
fn list_draw(&self, state: &mut State, f: &mut Frame<'_>, area: Rect) {
f.render_stateful_widget(&self.places_table, area, &mut state.selected_place);
}
fn edit_draw(&self, _state: &mut State, f: &mut Frame<'_>, area: Rect) {
let areas: [_; 8] = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Min(5),
])
.areas(area);
for (textarea, area) in self.edit_textareas.iter().zip(areas.into_iter()) {
f.render_widget(textarea, area);
}
}
} }
fn edit_draw(_state: &mut State, f: &mut Frame<'_>, area: Rect) { 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 dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60));
let confirmation_dialog = let (action, id) = match confirmation {
Paragraph::new(Text::from_iter([ ConfirmationStatus::Deletion(id) => ("delete", id),
Line::from("Not implemented yet :(").italic() ConfirmationStatus::Save(place) => ("save", &place.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() .centered()
.block(Block::bordered().padding(Padding::uniform(1))); .block(Block::bordered().padding(Padding::uniform(1)));
@ -144,16 +372,28 @@ fn edit_draw(_state: &mut State, f: &mut Frame<'_>, area: Rect) {
f.render_widget(confirmation_dialog, dialog_area); f.render_widget(confirmation_dialog, dialog_area);
} }
#[expect(unstable_name_collisions)] struct Footer {
fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) { keybindings: Paragraph<'static>,
}
impl Footer {
fn new(state: &State) -> Self {
let keybindings = Self::get_keybindings(state.mode);
Self { keybindings }
}
fn update_keybindings(&mut self, new_mode: Mode) {
self.keybindings = Self::get_keybindings(new_mode)
}
#[expect(unstable_name_collisions)]
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 state.mode { match mode {
Mode::List => { Mode::List => {
let keybindings = [ let keybindings = [
("j", "Next"), ("j/k", "Next/Previous"),
("k", "Previous"), ("Home/End", "First/Last"),
("Home", "First"),
("End", "Last"),
("PgUp", "Prev Page"), ("PgUp", "Prev Page"),
("PgDown", "Next Page"), ("PgDown", "Next Page"),
("e", "Edit"), ("e", "Edit"),
@ -166,18 +406,26 @@ fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
Paragraph::new(Line::from_iter(keybindings)) Paragraph::new(Line::from_iter(keybindings))
} }
Mode::Edit => { Mode::Edit => {
let keybindings = [("Esc", "Close w/o saving")] let keybindings = [
("Esc", "Close w/o saving"),
("Tab/S-Tab", "Next/prev field"),
("C-s", "Save"),
]
.map(|(key, action)| keybinding(key, action).to_vec()) .map(|(key, action)| keybinding(key, action).to_vec())
.into_iter() .into_iter()
.intersperse(vec![separator]) .intersperse(vec![separator])
.flatten(); .flatten();
Paragraph::new(Line::from_iter(keybindings)) Paragraph::new(Line::from_iter(keybindings))
} }
}; }
f.render_widget(keybindings, area); }
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
f.render_widget(&self.keybindings, area);
}
} }
fn url(url: Option<&str>) -> String { fn url(url: Option<String>) -> String {
match url { match url {
Some(url) => { Some(url) => {
if url.starts_with("https://instagram.com/") { if url.starts_with("https://instagram.com/") {
@ -187,7 +435,7 @@ fn url(url: Option<&str>) -> String {
.trim_end_matches("/") .trim_end_matches("/")
) )
} else { } else {
url.to_owned() url
} }
} }
None => String::new(), None => String::new(),
@ -214,3 +462,26 @@ fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect {
let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area);
area area
} }
fn inactive_textarea(textarea: &mut TextArea<'_>) {
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_style(Style::default());
if let Some(block) = textarea.block().map(|block| {
block
.clone()
.borders(Borders::ALL)
.style(Style::default().fg(Color::Gray))
}) {
textarea.set_block(block);
};
}
fn active_textarea(textarea: &mut TextArea<'_>) {
textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
if let Some(block) = textarea
.block()
.map(|block| block.clone().style(Style::default().bold()))
{
textarea.set_block(block);
};
}