Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 146e4d7812 | |||
| 53ed75133c | |||
| 93fb08e310 | |||
| b9e706e36e | |||
| aac318e7b8 | |||
| 71a96f722e |
8 changed files with 871 additions and 484 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.3.4] - 2025-06-15
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Show IG urls as @username ([#55](https://oolong.ludwig.dog/pitbuster/huellas/issues/55))
|
||||||
|
|
||||||
|
## [0.3.3] - 2025-06-14
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Docker build ([#52](https://oolong.ludwig.dog/pitbuster/huellas/issues/52))
|
||||||
|
- Add address column ([#53](https://oolong.ludwig.dog/pitbuster/huellas/issues/53))
|
||||||
|
|
||||||
## [0.3.2] - 2025-06-14
|
## [0.3.2] - 2025-06-14
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
|
||||||
657
Cargo.lock
generated
657
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
11
Cargo.toml
11
Cargo.toml
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "huellas"
|
name = "huellas"
|
||||||
version = "0.3.2"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
##### Builder ####
|
##### Builder ####
|
||||||
FROM rust:1.76-alpine3.19 as builder
|
FROM rust:1.87-alpine3.20 AS builder
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN apk add --no-cache sqlite npm musl-dev fd minify && npm install -g typescript
|
RUN apk add --no-cache sqlite npm musl-dev fd minify && npm install -g typescript
|
||||||
|
|
@ -46,7 +46,7 @@ RUN fd -e html . '/usr/src/huellas/static/' -x minify -r -o {} {} \
|
||||||
|
|
||||||
################
|
################
|
||||||
##### Runtime
|
##### Runtime
|
||||||
FROM alpine:3.19 AS Runtime
|
FROM alpine:3.20 AS runtime
|
||||||
|
|
||||||
RUN apk add --no-cache sqlite
|
RUN apk add --no-cache sqlite
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,34 +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.proceed_confirmation().await,
|
state.set_edit_mode();
|
||||||
KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
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,
|
(_, 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),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
533
src/tui/ui.rs
533
src/tui/ui.rs
|
|
@ -2,59 +2,347 @@
|
||||||
|
|
||||||
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),
|
||||||
let main_split = Layout::default()
|
UpdatePage(u32),
|
||||||
.direction(Direction::Vertical)
|
UpdatePlaces(Vec<Place>),
|
||||||
.constraints([
|
EditPlace(Place),
|
||||||
Constraint::Length(1),
|
EditNext,
|
||||||
Constraint::Fill(1),
|
EditPrev,
|
||||||
Constraint::Length(1),
|
SavePlace(i64),
|
||||||
])
|
Input(KeyEvent),
|
||||||
.split(f.area());
|
}
|
||||||
|
|
||||||
header_draw(state, f, main_split[0]);
|
pub struct UI {
|
||||||
main_draw(state, f, main_split[1]);
|
header: Header,
|
||||||
footer_draw(state, f, main_split[2]);
|
main: Main,
|
||||||
|
footer: Footer,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
impl UI {
|
||||||
let split = Layout::default()
|
pub fn new(state: &State) -> Self {
|
||||||
.direction(Direction::Horizontal)
|
Self {
|
||||||
.constraints([
|
header: Header::new(state),
|
||||||
Constraint::Length(9),
|
main: Main::new(state),
|
||||||
Constraint::Fill(1),
|
footer: Footer::new(state),
|
||||||
Constraint::Length(6),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
self.header.draw(f, main_split[0]);
|
||||||
|
self.main.draw(state, f, main_split[1]);
|
||||||
|
self.footer.draw(f, main_split[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
])
|
])
|
||||||
.split(area);
|
.right_aligned()
|
||||||
let app_name = Span::styled(
|
}
|
||||||
" huellas ",
|
|
||||||
Style::new().bg(Color::Gray).fg(Color::Black).bold(),
|
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
|
||||||
);
|
let split = Layout::default()
|
||||||
let page =
|
.direction(Direction::Horizontal)
|
||||||
Line::from_iter(["Page: ".to_span(), state.page.to_span(), " ".to_span()]).right_aligned();
|
.constraints([
|
||||||
let app_mode = match state.mode {
|
Constraint::Length(9),
|
||||||
Mode::List => Span::styled(" LIST ", Style::new().black().on_light_green().bold()),
|
Constraint::Fill(1),
|
||||||
Mode::Edit => Span::styled(" EDIT ", Style::new().black().on_light_blue().bold()),
|
Constraint::Length(6),
|
||||||
};
|
])
|
||||||
f.render_widget(app_name, split[0]);
|
.split(area);
|
||||||
f.render_widget(page, split[1]);
|
f.render_widget(&self.app_name, split[0]);
|
||||||
f.render_widget(app_mode, split[2]);
|
f.render_widget(&self.page, split[1]);
|
||||||
|
f.render_widget(&self.app_mode, split[2]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
struct Main {
|
||||||
match state.mode {
|
places_table: Table<'static>,
|
||||||
Mode::List => list_draw(state, f, area),
|
edit_textareas: Vec<TextArea<'static>>,
|
||||||
Mode::Edit => edit_draw(state, f, area),
|
selected_textarea: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Main {
|
||||||
|
fn new(state: &State) -> Self {
|
||||||
|
let places_table = Self::get_places_table(state.places.clone());
|
||||||
|
let edit_textareas = Vec::new();
|
||||||
|
let selected_textarea = 0;
|
||||||
|
Self {
|
||||||
|
places_table,
|
||||||
|
edit_textareas,
|
||||||
|
selected_textarea,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_places_table(&mut self, new_places: Vec<Place>) {
|
||||||
|
self.places_table = Self::get_places_table(new_places);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_places_table(places: Vec<Place>) -> Table<'static> {
|
||||||
|
let places = places.into_iter().map(|p| {
|
||||||
|
Row::new([
|
||||||
|
p.id.to_string(),
|
||||||
|
p.name,
|
||||||
|
p.latitude.to_string(),
|
||||||
|
p.longitude.to_string(),
|
||||||
|
p.icon,
|
||||||
|
p.address,
|
||||||
|
p.open_hours,
|
||||||
|
p.description,
|
||||||
|
url(p.url),
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
let widths = [
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
];
|
||||||
|
|
||||||
|
Table::new(places, widths)
|
||||||
|
.header(
|
||||||
|
Row::new([
|
||||||
|
"Id",
|
||||||
|
"Name",
|
||||||
|
"Lat",
|
||||||
|
"Long",
|
||||||
|
"Icon",
|
||||||
|
"Address",
|
||||||
|
"Open Hours",
|
||||||
|
"Description",
|
||||||
|
"URL",
|
||||||
|
])
|
||||||
|
.style(Style::new().white().on_dark_gray().bold()),
|
||||||
|
)
|
||||||
|
.row_highlight_style(Style::new().reversed())
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
confirmation_dialog_draw(state, f, area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
|
|
@ -65,7 +353,7 @@ fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60));
|
let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60));
|
||||||
let (action, id) = match confirmation {
|
let (action, id) = match confirmation {
|
||||||
ConfirmationStatus::Deletion(id) => ("delete", id),
|
ConfirmationStatus::Deletion(id) => ("delete", id),
|
||||||
ConfirmationStatus::Save(id) => ("save", id),
|
ConfirmationStatus::Save(place) => ("save", &place.id),
|
||||||
};
|
};
|
||||||
let confirmation_dialog = Paragraph::new(Text::from_iter([
|
let confirmation_dialog = Paragraph::new(Text::from_iter([
|
||||||
Line::from_iter([
|
Line::from_iter([
|
||||||
|
|
@ -84,94 +372,74 @@ fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
||||||
f.render_widget(confirmation_dialog, dialog_area);
|
f.render_widget(confirmation_dialog, dialog_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
struct Footer {
|
||||||
let places = state.places.iter().map(|p| {
|
keybindings: Paragraph<'static>,
|
||||||
Row::new([
|
|
||||||
p.id.to_string(),
|
|
||||||
p.name.clone(),
|
|
||||||
p.latitude.to_string(),
|
|
||||||
p.longitude.to_string(),
|
|
||||||
p.icon.clone(),
|
|
||||||
p.open_hours.clone(),
|
|
||||||
p.description.clone(),
|
|
||||||
p.url.clone().unwrap_or_default(),
|
|
||||||
])
|
|
||||||
});
|
|
||||||
|
|
||||||
let widths = [
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
Constraint::Length(7),
|
|
||||||
Constraint::Length(7),
|
|
||||||
Constraint::Length(8),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
];
|
|
||||||
|
|
||||||
let places_table = Table::new(places, widths)
|
|
||||||
.header(
|
|
||||||
Row::new([
|
|
||||||
"Id",
|
|
||||||
"Name",
|
|
||||||
"Lat",
|
|
||||||
"Long",
|
|
||||||
"Icon",
|
|
||||||
"Open Hours",
|
|
||||||
"Description",
|
|
||||||
"URL",
|
|
||||||
])
|
|
||||||
.style(Style::new().white().on_dark_gray().bold()),
|
|
||||||
)
|
|
||||||
.row_highlight_style(Style::new().reversed());
|
|
||||||
|
|
||||||
f.render_stateful_widget(places_table, area, &mut state.selected_place);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit_draw(_state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
impl Footer {
|
||||||
let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60));
|
fn new(state: &State) -> Self {
|
||||||
let confirmation_dialog =
|
let keybindings = Self::get_keybindings(state.mode);
|
||||||
Paragraph::new(Text::from_iter([
|
Self { keybindings }
|
||||||
Line::from("Not implemented yet :(").italic()
|
}
|
||||||
]))
|
|
||||||
.centered()
|
|
||||||
.block(Block::bordered().padding(Padding::uniform(1)));
|
|
||||||
|
|
||||||
f.render_widget(Clear, dialog_area);
|
fn update_keybindings(&mut self, new_mode: Mode) {
|
||||||
f.render_widget(confirmation_dialog, dialog_area);
|
self.keybindings = Self::get_keybindings(new_mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(unstable_name_collisions)]
|
#[expect(unstable_name_collisions)]
|
||||||
fn footer_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
|
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"),
|
("PgUp", "Prev Page"),
|
||||||
("End", "Last"),
|
("PgDown", "Next Page"),
|
||||||
("PgUp", "Prev Page"),
|
("e", "Edit"),
|
||||||
("PgDown", "Next Page"),
|
("d", "Delete"),
|
||||||
("e", "Edit"),
|
]
|
||||||
("d", "Delete"),
|
|
||||||
]
|
|
||||||
.map(|(key, action)| keybinding(key, action).to_vec())
|
|
||||||
.into_iter()
|
|
||||||
.intersperse(vec![separator])
|
|
||||||
.flatten();
|
|
||||||
Paragraph::new(Line::from_iter(keybindings))
|
|
||||||
}
|
|
||||||
Mode::Edit => {
|
|
||||||
let keybindings = [("Esc", "Close w/o saving")]
|
|
||||||
.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))
|
||||||
|
}
|
||||||
|
Mode::Edit => {
|
||||||
|
let keybindings = [
|
||||||
|
("Esc", "Close w/o saving"),
|
||||||
|
("Tab/S-Tab", "Next/prev field"),
|
||||||
|
("C-s", "Save"),
|
||||||
|
]
|
||||||
|
.map(|(key, action)| keybinding(key, action).to_vec())
|
||||||
|
.into_iter()
|
||||||
|
.intersperse(vec![separator])
|
||||||
|
.flatten();
|
||||||
|
Paragraph::new(Line::from_iter(keybindings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
|
||||||
|
f.render_widget(&self.keybindings, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(url: Option<String>) -> String {
|
||||||
|
match url {
|
||||||
|
Some(url) => {
|
||||||
|
if url.starts_with("https://instagram.com/") {
|
||||||
|
format!(
|
||||||
|
"@{}",
|
||||||
|
url.trim_start_matches("https://instagram.com/")
|
||||||
|
.trim_end_matches("/")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
None => String::new(),
|
||||||
f.render_widget(keybindings, area);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keybinding(key: &'static str, action: &'static str) -> [Span<'static>; 5] {
|
fn keybinding(key: &'static str, action: &'static str) -> [Span<'static>; 5] {
|
||||||
|
|
@ -194,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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue