Compare commits

...
10 Commits
Author SHA1 Message Date
dqnid d5c83ab5af docs: translate API option 2026-08-16 23:45:47 +02:00
dqnid c8050f3e69 docs: simple todo 2026-08-10 18:02:19 +02:00
dqnid c69f4b0e38 feat: config read from command line as '--c' 2026-08-08 00:58:41 +02:00
dqnid be82f8638a style: basic colors added as variables 2026-08-08 00:16:00 +02:00
dqnid 914538241f feat: enter allowed on edit and add 2026-08-08 00:03:48 +02:00
dqnid dbdce75996 fix: edit + order 2026-08-07 00:07:34 +02:00
dqnid 311f2fe7b4 WIP: edit word 2026-08-06 23:19:00 +02:00
dqnid 7a8a1cb340 feat: delete + styling 2026-08-06 18:15:24 +02:00
dqnid 2526179d0b fix: small quit fix 2026-08-05 23:57:50 +02:00
dqnid 844fc57f1a WIP: current view highlight 2026-08-05 15:58:56 +02:00
5 changed files with 343 additions and 52 deletions

No files matched your search

+10
View File
@@ -1,3 +1,13 @@
# wordsmith # wordsmith
Language learning utility. Language learning utility.
## TODO:
- [ ] Docs
- [ ] On screen cursor
- [ ] Remove unwraps
- [ ] Default config
- [ ] No db fallbacks
- [ ] Better logs
- [ ] Translate / define window (https://libretranslate.com/?source=auto&target=en&q=Testt)
+9 -1
View File
@@ -30,7 +30,15 @@ fn main() -> io::Result<()> {
} }
}; };
let config = read_config(DEFAULT_CONFIG_PATH, default_config); // TODO: read config from param let config_path: &str = {
if args.len() > 2 && args[1] == "--c" {
&args[2]
} else {
DEFAULT_CONFIG_PATH
}
};
let config = read_config(config_path, default_config);
let word_source_result = FileDatabase::new(&config.database_path); let word_source_result = FileDatabase::new(&config.database_path);
+8 -7
View File
@@ -53,7 +53,7 @@ pub trait WordSource {
fn find_by_word_and_lang(&self, word: &str, lang: &str) -> Vec<Word>; fn find_by_word_and_lang(&self, word: &str, lang: &str) -> Vec<Word>;
fn insert_word(&self, word: Word) -> Result<(), ()>; fn insert_word(&self, word: Word) -> Result<(), ()>;
fn remove_word(&self, word_key: &str) -> Result<(), ()>; fn remove_word(&self, word_key: &str) -> Result<(), ()>;
fn edit_word(&self, word: Word) -> Result<Word, ()>; fn edit_word(&self, word: Word, key: &str) -> Result<Word, ()>;
fn normalize_word_key(word_key: &str) -> String; fn normalize_word_key(word_key: &str) -> String;
fn normalize_lang_key(word_key: &str) -> String; fn normalize_lang_key(word_key: &str) -> String;
} }
@@ -70,7 +70,7 @@ impl WordSource for FileDatabase {
fn get_all_words(&self) -> Vec<Word> { fn get_all_words(&self) -> Vec<Word> {
let statement = self let statement = self
.connection .connection
.prepare("select key, description, lang from word"); .prepare("select key, description, lang from word ORDER BY key ASC");
let mut word_list: Vec<Word> = vec![]; let mut word_list: Vec<Word> = vec![];
// TODO: make this pretty with functional programming // TODO: make this pretty with functional programming
@@ -94,7 +94,7 @@ impl WordSource for FileDatabase {
fn find_by_word(&self, word: &str) -> Vec<Word> { fn find_by_word(&self, word: &str) -> Vec<Word> {
let statement = self let statement = self
.connection .connection
.prepare("SELECT key, description, lang FROM word"); .prepare("SELECT key, description, lang FROM word ORDER BY key ASC");
let mut word_list: Vec<Word> = vec![]; let mut word_list: Vec<Word> = vec![];
if let Ok(mut query) = statement { if let Ok(mut query) = statement {
@@ -118,7 +118,7 @@ impl WordSource for FileDatabase {
fn find_by_lang(&self, lang: &str) -> Vec<Word> { fn find_by_lang(&self, lang: &str) -> Vec<Word> {
let statement = self let statement = self
.connection .connection
.prepare("select key, description, lang from word where lang = ?1"); .prepare("select key, description, lang from word where lang = ?1 ORDER BY key ASC");
let mut word_list: Vec<Word> = vec![]; let mut word_list: Vec<Word> = vec![];
// TODO: make this pretty with functional programming // TODO: make this pretty with functional programming
@@ -142,7 +142,7 @@ impl WordSource for FileDatabase {
fn find_by_word_and_lang(&self, word: &str, lang: &str) -> Vec<Word> { fn find_by_word_and_lang(&self, word: &str, lang: &str) -> Vec<Word> {
let statement = self let statement = self
.connection .connection
.prepare("SELECT key, description, lang FROM word where lang = ?1"); .prepare("SELECT key, description, lang FROM word where lang = ?1 ORDER BY key ASC");
let mut word_list: Vec<Word> = vec![]; let mut word_list: Vec<Word> = vec![];
if let Ok(mut query) = statement { if let Ok(mut query) = statement {
@@ -180,13 +180,14 @@ impl WordSource for FileDatabase {
Err(()) Err(())
} }
fn edit_word(&self, word: Word) -> Result<Word, ()> { fn edit_word(&self, word: Word, key: &str) -> Result<Word, ()> {
let result = self.connection.execute( let result = self.connection.execute(
"UPDATE word SET key = ?1, description = ?2, lang = ?3 WHERE key = ?1", "UPDATE word SET key = ?1, description = ?2, lang = ?3 WHERE key = ?4",
( (
&Self::normalize_word_key(&word.key), &Self::normalize_word_key(&word.key),
&word.description, &word.description,
&Self::normalize_lang_key(&word.lang), &Self::normalize_lang_key(&word.lang),
key,
), ),
); );
match result { match result {
+149 -33
View File
@@ -1,5 +1,7 @@
use crate::modules::source::{FileDatabase, Word, WordSource}; use crate::modules::source::{FileDatabase, Word, WordSource};
use crate::modules::ui::app::UiState::FILTERLANG;
use crate::modules::ui::handler::handle_user_input; use crate::modules::ui::handler::handle_user_input;
use crossterm::event::KeyEvent;
use ratatui::{ use ratatui::{
DefaultTerminal, Frame, DefaultTerminal, Frame,
layout::{Constraint, Direction, Flex, Layout}, layout::{Constraint, Direction, Flex, Layout},
@@ -41,6 +43,7 @@ pub struct App {
pub word_to_edit: Option<Word>, pub word_to_edit: Option<Word>,
pub word_filter: Option<String>, pub word_filter: Option<String>,
pub lang_filter: Option<String>, pub lang_filter: Option<String>,
pub last_key: Option<KeyEvent>,
pub exit: bool, pub exit: bool,
} }
@@ -59,6 +62,7 @@ impl App {
word_to_edit: None, word_to_edit: None,
word_filter: None, word_filter: None,
lang_filter: None, lang_filter: None,
last_key: None,
exit: false, exit: false,
} }
} }
@@ -75,6 +79,12 @@ impl App {
} }
fn draw(&mut self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
// PERF: this should not be declared on every frame
let color_bg_main = Color::default();
let color_fg_main = Color::default();
let color_fg_highlight = Color::Yellow;
let color_fg_delete = Color::LightRed;
let main_layout = Layout::default() let main_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.spacing(0) .spacing(0)
@@ -82,7 +92,7 @@ impl App {
.constraints(vec![ .constraints(vec![
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(1),
]) ])
.split(frame.area()); .split(frame.area());
@@ -103,8 +113,12 @@ impl App {
} }
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(word_filter.clone()) Paragraph::new(word_filter.clone()).block(
.block(Block::new().title("Word").borders(Borders::ALL)), Block::new()
.title(self.format_ui_block_title("Word", UiState::FILTERWORD))
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
.borders(Borders::ALL),
),
filters_layout[0], filters_layout[0],
); );
@@ -116,41 +130,99 @@ impl App {
} }
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(lang_filter).block(Block::new().title("Lang").borders(Borders::ALL)), Paragraph::new(lang_filter).block(
Block::new()
.title(self.format_ui_block_title("Lang", UiState::FILTERLANG))
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
.borders(Borders::ALL),
),
filters_layout[1], filters_layout[1],
); );
/* /*
* Word list / add / edit block * Word list / add / edit block
* */ * */
if self.state == UiState::ADD { let scroll_position: usize = {
if let Some(i) = self.list_state.selected() {
i
} else {
0
}
};
// PERF: do not clone
let selected_word = self.word_list.clone().into_iter().nth(scroll_position);
if self.state == UiState::ADD || self.state == UiState::EDIT {
let add_words_heading_layout = let add_words_heading_layout =
Layout::horizontal(vec![Constraint::Fill(1), Constraint::Length(8)]) Layout::horizontal(vec![Constraint::Fill(1), Constraint::Length(8)])
.spacing(1) .spacing(1)
.split(main_layout[0]); .split(main_layout[0]);
// PERF: maybe not clone // PERF: maybe not clone
let word_to_add = self.word_to_add.clone().unwrap_or(Word { let word_to_add = {
key: "".to_string(), if self.state == UiState::EDIT
description: "".to_string(), && let Some(w) = self.word_to_edit.clone()
lang: "".to_string(), {
}); w
} else {
self.word_to_add.clone().unwrap_or(Word {
key: "".to_string(),
description: "".to_string(),
lang: "".to_string(),
})
}
};
frame.render_widget( frame.render_widget(
Paragraph::new(word_to_add.key) Paragraph::new(word_to_add.key).block(
.block(Block::new().title("Word").borders(Borders::ALL)), Block::new()
.title(self.format_add_block_title("Word", AddState::KEY))
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
.borders(Borders::ALL),
),
add_words_heading_layout[0], add_words_heading_layout[0],
); );
frame.render_widget( frame.render_widget(
Paragraph::new(word_to_add.lang) Paragraph::new(word_to_add.lang).block(
.block(Block::new().title("Lang").borders(Borders::ALL)), Block::new()
.title(self.format_add_block_title("Lang", AddState::LANG))
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
.borders(Borders::ALL),
),
add_words_heading_layout[1], add_words_heading_layout[1],
); );
frame.render_widget( frame.render_widget(
Paragraph::new(word_to_add.description) Paragraph::new(word_to_add.description)
.block(Block::new().title("Description").borders(Borders::ALL)), .wrap(Wrap { trim: true })
.block(
Block::new()
.title(
self.format_add_block_title("Description", AddState::DESCRIPTION),
)
.borders(Borders::ALL),
)
.style(Style::default().fg(color_fg_main).bg(color_bg_main)),
main_layout[1], main_layout[1],
); );
} else { } else {
let highligh_symbol: &str = {
if self.state == UiState::LIST {
"> "
} else if self.state == UiState::DELETE {
"x "
} else {
" "
}
};
let highligh_color: Color = {
if self.state == UiState::LIST {
Color::Yellow
} else if self.state == UiState::DELETE {
Color::LightRed
} else {
Color::default()
}
};
let words_layout = let words_layout =
Layout::horizontal(vec![Constraint::Percentage(30), Constraint::Percentage(70)]) Layout::horizontal(vec![Constraint::Percentage(30), Constraint::Percentage(70)])
.split(main_layout[1]); .split(main_layout[1]);
@@ -165,30 +237,23 @@ impl App {
.direction(ListDirection::TopToBottom) .direction(ListDirection::TopToBottom)
.block( .block(
Block::default() Block::default()
.title("Entries") .title(self.format_ui_block_title("Entries", UiState::LIST))
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
.padding(Padding::horizontal(1)), .padding(Padding::horizontal(1)),
) )
.highlight_style(Style::default().fg(Color::default())) .highlight_style(Style::default().fg(highligh_color))
.highlight_symbol("> ") .highlight_symbol(highligh_symbol)
.style(Style::default().fg(Color::DarkGray)); .style(Style::default().fg(color_fg_main).bg(color_bg_main));
frame.render_stateful_widget(word_key_list, words_layout[0], &mut self.list_state); frame.render_stateful_widget(word_key_list, words_layout[0], &mut self.list_state);
let scroll_position: usize = { if let Some(w) = selected_word.clone() {
if let Some(i) = self.list_state.selected() {
i
} else {
0
}
};
// PERF: do not clone
let selected_word = self.word_list.clone().into_iter().nth(scroll_position);
if let Some(w) = selected_word {
frame.render_widget( frame.render_widget(
Paragraph::new(w.description) Paragraph::new(w.description)
.wrap(Wrap { trim: true }) .wrap(Wrap { trim: true })
.scroll((self.description_scroll, 0)) .scroll((self.description_scroll, 0))
.block(Block::new().borders(Borders::ALL)), .block(Block::new().borders(Borders::ALL))
.style(Style::default().fg(color_fg_main).bg(color_bg_main)),
words_layout[1], words_layout[1],
); );
} }
@@ -201,14 +266,35 @@ impl App {
.flex(Flex::Center) .flex(Flex::Center)
.split(main_layout[2]); .split(main_layout[2]);
let help_text = Text::raw("(f)ilter | (l)ang | (d)elete | (e)dit | (a)dd | (q)uit ") let help_text = {
.style(Style::new().fg(Color::DarkGray)); if self.state == UiState::DELETE
&& let Some(s_w) = selected_word.clone()
{
Text::raw(format!("Sure you want to delete '{0}'? (y)es", s_w.key))
.style(Style::default().fg(color_fg_delete).bg(color_bg_main))
} else if self.state == UiState::ADD {
Text::raw("Adding item (enter) to confirm (esc) to exit")
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
} else {
Text::raw("(f)ilter | (l)ang | (d)elete | (e)dit | (a)dd | (q)uit")
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
}
};
let help_area = help_layout[0].centered( let help_area = help_layout[0].centered(
Constraint::Length(help_text.width() as u16), Constraint::Length(help_text.width() as u16),
Constraint::Length(1), Constraint::Length(1),
); );
frame.render_widget(help_text, help_area); frame.render_widget(help_text, help_area);
/*
* State block
* */
// let state_layout = Layout::horizontal(vec![Constraint::Fill(1)])
// .flex(Flex::Center)
// .split(main_layout[2]);
// frame.render_widget(help_text, help_area);
} }
pub fn update_word_list(&mut self) { pub fn update_word_list(&mut self) {
@@ -226,11 +312,41 @@ impl App {
self.file_database.get_all_words() self.file_database.get_all_words()
} }
}; };
self.list_state.select(Some(0));
self.description_scroll = 0; self.description_scroll = 0;
self.word_list = word_list; self.word_list = word_list;
} }
fn format_ui_block_title(&self, input: &str, matching_state: UiState) -> String {
if matching_state == self.state {
let mut result = input.to_string();
result.push_str(" ● ");
result
} else {
input.to_string()
}
}
fn format_add_block_title(&self, input: &str, matching_state: AddState) -> String {
if matching_state == self.add_state {
let mut result = input.to_string();
result.push_str(" ● ");
result
} else {
input.to_string()
}
}
pub fn get_selected_word(&self) -> Option<Word> {
let scroll_position: usize = {
if let Some(i) = self.list_state.selected() {
i
} else {
0
}
};
self.word_list.clone().into_iter().nth(scroll_position)
}
fn handle_events(&mut self) -> io::Result<()> { fn handle_events(&mut self) -> io::Result<()> {
return handle_user_input(self); return handle_user_input(self);
} }
+167 -11
View File
@@ -6,12 +6,36 @@ use crate::modules::{
source::{Word, WordSource}, source::{Word, WordSource},
ui::app::{AddState, App, UiState}, ui::app::{AddState, App, UiState},
}; };
pub fn handle_user_input(app: &mut App) -> io::Result<()> { pub fn handle_user_input(app: &mut App) -> io::Result<()> {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
match key.code { match key.code {
event::KeyCode::Char(c) => { event::KeyCode::Char(c) => {
match app.state { match app.state {
UiState::DELETE => {
if c == 'y' || c == 'Y' || c == 's' || c == 'S' {
// delete word
let scroll_position: usize = {
if let Some(i) = app.list_state.selected() {
i
} else {
0
}
};
let selected_word =
app.word_list.clone().into_iter().nth(scroll_position);
if let Some(w) = selected_word {
// TODO: manage result with some notification context
let _ = app.file_database.remove_word(&w.key);
app.update_word_list();
if scroll_position > 0 {
app.list_state.select(Some(scroll_position - 1));
}
app.state = UiState::LIST;
}
} else {
app.state = UiState::LIST;
}
}
UiState::FILTERWORD => { UiState::FILTERWORD => {
if let Some(mut prev_word) = app.word_filter.clone() { if let Some(mut prev_word) = app.word_filter.clone() {
prev_word.push(c); prev_word.push(c);
@@ -68,10 +92,48 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
} }
} }
}, },
UiState::EDIT => match app.add_state {
AddState::KEY => {
if let Some(mut prev_word) = app.word_to_edit.clone() {
prev_word.key.push(c);
app.word_to_edit = Some(prev_word);
} else {
app.word_to_edit = Some(Word {
key: c.to_string(),
description: "".to_string(),
lang: "".to_string(),
});
}
}
AddState::DESCRIPTION => {
if let Some(mut prev_word) = app.word_to_edit.clone() {
prev_word.description.push(c);
app.word_to_edit = Some(prev_word);
} else {
app.word_to_edit = Some(Word {
key: c.to_string(),
description: "".to_string(),
lang: "".to_string(),
});
}
}
AddState::LANG => {
if let Some(mut prev_word) = app.word_to_edit.clone() {
prev_word.lang.push(c);
app.word_to_edit = Some(prev_word);
} else {
app.word_to_edit = Some(Word {
key: c.to_string(),
description: "".to_string(),
lang: "".to_string(),
});
}
}
},
_ => { _ => {
// Quit // Quit
if c == 'q' || c == 'x' { if c == 'q' || c == 'x' {
return Err(io::Error::last_os_error()); // FIXME: this is not right app.exit = true;
} }
// State change // State change
if c == 'f' || c == '/' { if c == 'f' || c == '/' {
@@ -85,14 +147,40 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
} }
if c == 'e' { if c == 'e' {
app.state = UiState::EDIT; app.state = UiState::EDIT;
let selected_word = app.get_selected_word();
app.word_to_edit = selected_word;
} }
if c == 'a' { if c == 'a' {
app.state = UiState::ADD; app.state = UiState::ADD;
} }
// Movement // Movement
if c == 'j' { if c == 'j' {
app.list_state.select_next(); let scroll_position: usize = {
app.description_scroll = 0; if let Some(i) = app.list_state.selected() {
i
} else {
0
}
};
if scroll_position < app.word_list.len() - 1 {
app.list_state.select_next();
app.description_scroll = 0;
}
}
if c == 'g' {
if let Some(g) = app.last_key {
match g.code {
event::KeyCode::Char(g_c) => {
if g_c == 'g' {
app.list_state.select_first();
}
}
_ => {}
}
}
}
if c == 'G' {
app.list_state.select_last();
} }
if c == 'k' { if c == 'k' {
app.list_state.select_previous(); app.list_state.select_previous();
@@ -114,14 +202,58 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
if let Some(w) = app.word_to_add.clone() if let Some(w) = app.word_to_add.clone()
&& w.key.len() > 0 && w.key.len() > 0
{ {
let res = app.file_database.insert_word(w); if app.add_state == AddState::DESCRIPTION {
match res { let new_line_char = '\n';
Ok(_) => { if let Some(mut prev_word) = app.word_to_add.clone() {
app.update_word_list(); prev_word.description.push(new_line_char);
app.word_to_add = None; app.word_to_add = Some(prev_word);
app.state = get_next_ui_state(app.state); } else {
app.word_to_add = Some(Word {
key: new_line_char.to_string(),
description: "".to_string(),
lang: "".to_string(),
});
}
} else {
let res = app.file_database.insert_word(w);
match res {
Ok(_) => {
app.update_word_list();
app.word_to_add = None;
app.state = get_next_ui_state(app.state);
}
Err(_) => app.state = get_next_ui_state(app.state),
}
}
}
}
UiState::EDIT => {
if let Some(w) = app.word_to_edit.clone()
&& let Some(w_k) = app.get_selected_word()
{
if app.add_state == AddState::DESCRIPTION {
let new_line_char = '\n';
if let Some(mut prev_word) = app.word_to_edit.clone() {
let new_line_char = '\n';
prev_word.description.push(new_line_char);
app.word_to_edit = Some(prev_word);
} else {
app.word_to_edit = Some(Word {
key: new_line_char.to_string(),
description: "".to_string(),
lang: "".to_string(),
});
}
} else {
let res = app.file_database.edit_word(w.clone(), &w_k.key);
match res {
Ok(_) => {
app.update_word_list();
app.word_to_edit = None;
app.state = get_next_ui_state(app.state);
}
Err(_) => app.state = get_next_ui_state(app.state),
} }
Err(_) => app.state = get_next_ui_state(app.state),
} }
} }
} }
@@ -131,6 +263,9 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
UiState::ADD => { UiState::ADD => {
app.add_state = get_next_add_state(app.add_state); app.add_state = get_next_add_state(app.add_state);
} }
UiState::EDIT => {
app.add_state = get_next_add_state(app.add_state);
}
_ => { _ => {
app.state = get_next_ui_state(app.state); app.state = get_next_ui_state(app.state);
} }
@@ -164,6 +299,26 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
} }
} }
}, },
UiState::EDIT => match app.add_state {
AddState::KEY => {
if let Some(mut prev_word) = app.word_to_edit.clone() {
prev_word.key.pop();
app.word_to_edit = Some(prev_word);
}
}
AddState::DESCRIPTION => {
if let Some(mut prev_word) = app.word_to_edit.clone() {
prev_word.description.pop();
app.word_to_edit = Some(prev_word);
}
}
AddState::LANG => {
if let Some(mut prev_word) = app.word_to_edit.clone() {
prev_word.lang.pop();
app.word_to_edit = Some(prev_word);
}
}
},
_ => {} _ => {}
}, },
event::KeyCode::Esc => { event::KeyCode::Esc => {
@@ -171,6 +326,7 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
} }
_ => return Ok(()), _ => return Ok(()),
} }
app.last_key = Some(key);
} }
Ok(()) Ok(())
} }