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 331 additions and 40 deletions

No files matched your search

+10
View File
@@ -1,3 +1,13 @@
# wordsmith
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);
+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 insert_word(&self, word: Word) -> 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_lang_key(word_key: &str) -> String;
}
@@ -70,7 +70,7 @@ impl WordSource for FileDatabase {
fn get_all_words(&self) -> Vec<Word> {
let statement = self
.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![];
// TODO: make this pretty with functional programming
@@ -94,7 +94,7 @@ impl WordSource for FileDatabase {
fn find_by_word(&self, word: &str) -> Vec<Word> {
let statement = self
.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![];
if let Ok(mut query) = statement {
@@ -118,7 +118,7 @@ impl WordSource for FileDatabase {
fn find_by_lang(&self, lang: &str) -> Vec<Word> {
let statement = self
.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![];
// 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> {
let statement = self
.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![];
if let Ok(mut query) = statement {
@@ -180,13 +180,14 @@ impl WordSource for FileDatabase {
Err(())
}
fn edit_word(&self, word: Word) -> Result<Word, ()> {
fn edit_word(&self, word: Word, key: &str) -> Result<Word, ()> {
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),
&word.description,
&Self::normalize_lang_key(&word.lang),
key,
),
);
match result {
+146 -30
View File
@@ -1,5 +1,7 @@
use crate::modules::source::{FileDatabase, Word, WordSource};
use crate::modules::ui::app::UiState::FILTERLANG;
use crate::modules::ui::handler::handle_user_input;
use crossterm::event::KeyEvent;
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Direction, Flex, Layout},
@@ -41,6 +43,7 @@ pub struct App {
pub word_to_edit: Option<Word>,
pub word_filter: Option<String>,
pub lang_filter: Option<String>,
pub last_key: Option<KeyEvent>,
pub exit: bool,
}
@@ -59,6 +62,7 @@ impl App {
word_to_edit: None,
word_filter: None,
lang_filter: None,
last_key: None,
exit: false,
}
}
@@ -75,6 +79,12 @@ impl App {
}
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()
.direction(Direction::Vertical)
.spacing(0)
@@ -82,7 +92,7 @@ impl App {
.constraints(vec![
Constraint::Length(3),
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Length(1),
])
.split(frame.area());
@@ -103,8 +113,12 @@ impl App {
}
};
frame.render_widget(
Paragraph::new(word_filter.clone())
.block(Block::new().title("Word").borders(Borders::ALL)),
Paragraph::new(word_filter.clone()).block(
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],
);
@@ -116,41 +130,99 @@ impl App {
}
};
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],
);
/*
* 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 =
Layout::horizontal(vec![Constraint::Fill(1), Constraint::Length(8)])
.spacing(1)
.split(main_layout[0]);
// PERF: maybe not clone
let word_to_add = self.word_to_add.clone().unwrap_or(Word {
let word_to_add = {
if self.state == UiState::EDIT
&& let Some(w) = self.word_to_edit.clone()
{
w
} else {
self.word_to_add.clone().unwrap_or(Word {
key: "".to_string(),
description: "".to_string(),
lang: "".to_string(),
});
})
}
};
frame.render_widget(
Paragraph::new(word_to_add.key)
.block(Block::new().title("Word").borders(Borders::ALL)),
Paragraph::new(word_to_add.key).block(
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],
);
frame.render_widget(
Paragraph::new(word_to_add.lang)
.block(Block::new().title("Lang").borders(Borders::ALL)),
Paragraph::new(word_to_add.lang).block(
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],
);
frame.render_widget(
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],
);
} 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 =
Layout::horizontal(vec![Constraint::Percentage(30), Constraint::Percentage(70)])
.split(main_layout[1]);
@@ -165,30 +237,23 @@ impl App {
.direction(ListDirection::TopToBottom)
.block(
Block::default()
.title("Entries")
.title(self.format_ui_block_title("Entries", UiState::LIST))
.borders(Borders::ALL)
.style(Style::default().fg(color_fg_main).bg(color_bg_main))
.padding(Padding::horizontal(1)),
)
.highlight_style(Style::default().fg(Color::default()))
.highlight_symbol("> ")
.style(Style::default().fg(Color::DarkGray));
.highlight_style(Style::default().fg(highligh_color))
.highlight_symbol(highligh_symbol)
.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);
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 let Some(w) = selected_word {
if let Some(w) = selected_word.clone() {
frame.render_widget(
Paragraph::new(w.description)
.wrap(Wrap { trim: true })
.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],
);
}
@@ -201,14 +266,35 @@ impl App {
.flex(Flex::Center)
.split(main_layout[2]);
let help_text = Text::raw("(f)ilter | (l)ang | (d)elete | (e)dit | (a)dd | (q)uit ")
.style(Style::new().fg(Color::DarkGray));
let help_text = {
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(
Constraint::Length(help_text.width() as u16),
Constraint::Length(1),
);
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) {
@@ -226,11 +312,41 @@ impl App {
self.file_database.get_all_words()
}
};
self.list_state.select(Some(0));
self.description_scroll = 0;
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<()> {
return handle_user_input(self);
}
+158 -2
View File
@@ -6,12 +6,36 @@ use crate::modules::{
source::{Word, WordSource},
ui::app::{AddState, App, UiState},
};
pub fn handle_user_input(app: &mut App) -> io::Result<()> {
if let Event::Key(key) = event::read()? {
match key.code {
event::KeyCode::Char(c) => {
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 => {
if let Some(mut prev_word) = app.word_filter.clone() {
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
if c == 'q' || c == 'x' {
return Err(io::Error::last_os_error()); // FIXME: this is not right
app.exit = true;
}
// State change
if c == 'f' || c == '/' {
@@ -85,15 +147,41 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
}
if c == 'e' {
app.state = UiState::EDIT;
let selected_word = app.get_selected_word();
app.word_to_edit = selected_word;
}
if c == 'a' {
app.state = UiState::ADD;
}
// Movement
if c == 'j' {
let scroll_position: usize = {
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' {
app.list_state.select_previous();
app.description_scroll = 0;
@@ -114,6 +202,19 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
if let Some(w) = app.word_to_add.clone()
&& w.key.len() > 0
{
if app.add_state == AddState::DESCRIPTION {
let new_line_char = '\n';
if let Some(mut prev_word) = app.word_to_add.clone() {
prev_word.description.push(new_line_char);
app.word_to_add = Some(prev_word);
} 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(_) => {
@@ -125,12 +226,46 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
}
}
}
}
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),
}
}
}
}
_ => {}
},
event::KeyCode::Tab => match app.state {
UiState::ADD => {
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);
}
@@ -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 => {
@@ -171,6 +326,7 @@ pub fn handle_user_input(app: &mut App) -> io::Result<()> {
}
_ => return Ok(()),
}
app.last_key = Some(key);
}
Ok(())
}