Compare commits

..

76 Commits

Author SHA1 Message Date
filipriec
9ed558562b moved admin now 2025-08-30 19:26:12 +02:00
filipriec
43f5c1a764 login and register are now havving own handlers and loaders, moving logic out of event.rs and ui.rs 2025-08-30 19:13:12 +02:00
filipriec
46149c09db event.rs and ui.rs refactor for the forms page(moved logic to the forms page dir and just calling it now) 2025-08-30 16:42:04 +02:00
filipriec
a0757efe8b moved add_table and add_logic, needs more things done tho 2025-08-30 14:46:34 +02:00
filipriec
10f4b9d8e2 moved add_table to be feature based 2025-08-30 14:25:33 +02:00
filipriec
42db496ad7 admin page being rendered properly well now 2025-08-30 13:32:45 +02:00
filipriec
d6fd672409 roles are now better 2025-08-30 13:19:45 +02:00
filipriec
60eb1c9f51 register has dropdown now 2025-08-29 22:07:04 +02:00
filipriec
a09c804595 intro empty buffer fixed 2025-08-29 20:13:25 +02:00
filipriec
a17f73fd54 buffer bug fixed, now proper names are being displayed 2025-08-29 19:53:31 +02:00
filipriec
2373ae4b8c form pages robust finish chnages 2025-08-29 19:49:27 +02:00
filipriec
16dd460469 we compiled but buffer doesnt work 2025-08-29 18:11:27 +02:00
filipriec
58f109ca91 adding to have multiple forms pages 2025-08-29 16:18:42 +02:00
filipriec
75da9c0f4b login and register are sending data to the backend successfuly 2025-08-29 14:46:43 +02:00
filipriec
833b918c5b cursor style is handled properly now 2025-08-29 12:32:33 +02:00
filipriec
72c2691a17 registration now has working form 2025-08-29 12:22:25 +02:00
filipriec
cf79bc7bd5 bottom_panel decoupled 2025-08-29 08:25:24 +02:00
filipriec
f5f2f2cdef login page using canvas library 2025-08-28 21:26:21 +02:00
filipriec
19a9bab8c2 login page using canvas for forms 2025-08-28 21:07:23 +02:00
filipriec
6e221ef8c1 HARDEST COMMIT IN THE RECENT TIMES we fixed movement in the admin page 2025-08-28 13:43:17 +02:00
Priec
e142f56706 admin page 2025-08-28 09:15:14 +02:00
Priec
a794f22366 admin page is now featured 2025-08-27 16:32:20 +02:00
Priec
cfe4903c79 admin page is now featured 2025-08-27 16:32:09 +02:00
Priec
a0a473f96c admin page 2025-08-27 12:14:09 +02:00
Priec
9e4dd3b4c7 intro movement fully fixed 2025-08-27 01:38:51 +02:00
Priec
e5db0334c0 fixed intro movement, select not working yet 2025-08-27 01:34:56 +02:00
Priec
d641ad1bbb centralized general movement 2025-08-27 01:06:54 +02:00
filipriec
18393ff661 is edit mode is gone from the codebase 2025-08-24 16:54:18 +02:00
filipriec
b2a82fba30 fixing is_edit_mode flag removal 2025-08-24 16:37:30 +02:00
filipriec
f6c2fd627f fixing is_edit_mode 2025-08-24 16:00:58 +02:00
filipriec
15d9b31cb6 removing edit mode from the codebase 2025-08-24 15:32:24 +02:00
filipriec
06cc1663b3 working general mode only with canvas, removing highlight, readonly or edit 2025-08-23 23:34:14 +02:00
filipriec
88a4b2d69c intro is now separated 2025-08-23 21:58:29 +02:00
filipriec
e6072d25c5 register is now separated also 2025-08-23 21:47:18 +02:00
filipriec
fc2b65601e login function moved 2025-08-23 21:05:02 +02:00
filipriec
597bdde7e1 login moved to the pages 2025-08-23 20:58:12 +02:00
filipriec
f56092e86c login page now in a separate dir 2025-08-23 19:48:23 +02:00
filipriec
d5cfe59f47 dialog refactor comment, dialog crate finished for now 2025-08-23 13:36:46 +02:00
filipriec
f281eaa662 dialog is a feature 2025-08-23 13:29:28 +02:00
Priec
cbb3ed7c48 small cleanup 2025-08-23 00:22:07 +02:00
Priec
41a0b85376 forms page moved more2 2025-08-23 00:16:07 +02:00
Priec
b5a31ee81c forms page 2025-08-22 23:54:22 +02:00
Priec
dceb031822 removed docs book from git history 2025-08-22 23:31:08 +02:00
Priec
78bc9fc432 router4 compiled 2025-08-22 23:27:32 +02:00
Priec
b9072e4d7c router2, needs bug fixes3 2025-08-22 22:57:28 +02:00
Priec
5d97e63f93 router2, needs bug fixes 2025-08-22 22:52:20 +02:00
Priec
957f5bf9f0 router implementation 2025-08-22 22:19:59 +02:00
Priec
6833ac5fad find palette in the bottom panel 2025-08-22 17:11:52 +02:00
Priec
3dff2ced6c bottom panel moved 2025-08-22 16:48:25 +02:00
Priec
ea7ff3796f search grpc client isolated a bit mode 2025-08-22 16:09:16 +02:00
Priec
310617d62b cargo fix 2025-08-22 15:49:33 +02:00
Priec
1d94e82f4b search 2025-08-22 15:48:30 +02:00
Priec
00dad5d673 fixed buffer logic 2025-08-22 14:26:58 +02:00
Priec
414c6957e7 sidebar as a feature 2025-08-22 14:11:36 +02:00
Priec
f127298e5a buffer as a feature 2025-08-22 13:47:34 +02:00
Priec
f49899e66d general movement now works 2025-08-22 11:23:11 +02:00
Priec
5717c88857 proper config.toml 2025-08-22 10:55:37 +02:00
Priec
ae8aa16208 working entering to the edit mode 2025-08-22 09:56:55 +02:00
Priec
4ed8e7b421 fixed form state removed, but not won, aint working yet 2025-08-22 00:27:23 +02:00
Priec
3dd6808ea2 now no need for init_form_editor everywhere 2025-08-21 21:16:59 +02:00
Priec
f2b426851b compiled still not working 2025-08-21 13:23:21 +02:00
Priec
f9e0833bcf working keymap 2025-08-21 12:32:36 +02:00
Priec
11b073c2fd removing highlightmode from the app, handled by the library now 2025-08-21 10:33:52 +02:00
Priec
1320884409 more improvements 2025-08-20 23:52:14 +02:00
filipriec
aea2c39215 reverted core actions in canvas 2025-08-20 16:33:19 +02:00
filipriec
4c2464ab30 compiled 2025-08-20 16:28:31 +02:00
filipriec
26053a5fd8 fixes 9 2025-08-20 11:36:56 +02:00
filipriec
589220a2ba fixing 8 2025-08-20 11:33:34 +02:00
filipriec
2cda54633f not working, needs more fixes 2025-08-19 22:14:43 +02:00
filipriec
3eea6b9e88 fixes 7 2025-08-19 22:02:00 +02:00
filipriec
db9bb7e168 fixes 5 2025-08-19 20:05:18 +02:00
filipriec
3ccd094a22 fixing problems more6 2025-08-19 19:21:02 +02:00
filipriec
032f21edaa more canvas implementation4 2025-08-19 14:43:10 +02:00
Priec
42eb087363 canvas fixes3 2025-08-19 13:25:10 +02:00
Priec
d0ff449e3b going into a new canvas library structure 2025-08-19 13:24:48 +02:00
Priec
858f5137d8 migrating to the new canvas library 2025-08-19 10:56:54 +02:00
186 changed files with 6044 additions and 11971 deletions

10
Cargo.lock generated
View File

@@ -493,7 +493,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "canvas"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"async-trait",
@@ -584,7 +584,7 @@ dependencies = [
[[package]]
name = "client"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"async-trait",
@@ -635,7 +635,7 @@ dependencies = [
[[package]]
name = "common"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"prost",
"prost-types",
@@ -3022,7 +3022,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "search"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"common",
@@ -3121,7 +3121,7 @@ dependencies = [
[[package]]
name = "server"
version = "0.4.2"
version = "0.5.0"
dependencies = [
"anyhow",
"bcrypt",

View File

@@ -5,7 +5,7 @@ resolver = "2"
[workspace.package]
# TODO: idk how to do the name, fix later
# name = "komp_ac"
version = "0.4.2"
version = "0.5.0"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Filip Priečinský <filippriec@gmail.com>"]

View File

@@ -57,7 +57,7 @@ use canvas::canvas::CanvasState;
use canvas::canvas::CanvasAction;
use canvas::canvas::ActionContext;
use canvas::canvas::HighlightState;
use canvas::canvas::CanvasTheme;
use canvas::CanvasTheme;
use canvas::dispatcher::ActionDispatcher;
use canvas::canvas::ActionResult;
```
@@ -153,7 +153,7 @@ if editor.is_suggestions_active() {
**New rendering:**
```rust
// Canvas handles everything
use canvas::canvas::render_canvas;
use canvas::render_canvas_default;
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);

View File

@@ -39,6 +39,7 @@ validation = ["regex"]
computed = []
textarea = ["dep:ropey","gui"]
syntect = ["dep:syntect", "gui", "textarea"]
keymap = ["gui"]
# text modes (mutually exclusive; default to vim)
textmode-vim = []
@@ -50,7 +51,8 @@ all-nontextmodes = [
"cursor-style",
"validation",
"computed",
"textarea"
"textarea",
"keymap"
]
[[example]]
@@ -106,3 +108,8 @@ path = "examples/textarea_normal.rs"
name = "textarea_syntax"
required-features = ["gui", "cursor-style", "textarea", "textmode-normal", "syntect"]
path = "examples/textarea_syntax.rs"
[[example]]
name = "canvas_keymap"
required-features = ["gui", "keymap", "cursor-style"]
path = "examples/canvas_keymap.rs"

View File

@@ -0,0 +1,376 @@
// examples/canvas_keymap.rs
//! Demonstrates the centralized keymap system for canvas interactions
//!
//! This example shows how to use the canvas-keymap feature to delegate
//! all canvas key handling to the library, supporting complex sequences
//! like "gg", "ge", etc.
//!
//! Run with:
//! cargo run --example canvas_keymap --features "gui,keymap,cursor-style"
#[cfg(not(feature = "keymap"))]
compile_error!(
"This example requires the 'keymap' feature. \
Run with: cargo run --example canvas_keymap --features \"gui,keymap,cursor-style\""
);
use std::collections::HashMap;
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEvent},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode},
keymap::{CanvasKeyMap, KeyEventOutcome},
DataProvider, FormEditor,
};
/// Demo application using centralized keymap system
struct KeymapDemoApp {
editor: FormEditor<DemoData>,
message: String,
quit: bool,
}
impl KeymapDemoApp {
fn new() -> Self {
let data = DemoData::new();
let mut editor = FormEditor::new(data);
// Build and inject the keymap from our config
let keymap = Self::build_demo_keymap();
editor.set_keymap(keymap);
Self {
editor,
message: "🎯 Keymap system loaded! Try: gg, ge, hjkl, w/b/e, v, i, etc.".to_string(),
quit: false,
}
}
/// Build a comprehensive keymap configuration
fn build_demo_keymap() -> CanvasKeyMap {
let mut read_only = HashMap::new();
let mut edit = HashMap::new();
let mut highlight = HashMap::new();
// === READ-ONLY MODE KEYBINDINGS ===
// Basic movement
read_only.insert("move_left".to_string(), vec!["h".to_string(), "Left".to_string()]);
read_only.insert("move_right".to_string(), vec!["l".to_string(), "Right".to_string()]);
read_only.insert("move_up".to_string(), vec!["k".to_string(), "Up".to_string()]);
read_only.insert("move_down".to_string(), vec!["j".to_string(), "Down".to_string()]);
// Word movement
read_only.insert("move_word_next".to_string(), vec!["w".to_string()]);
read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]);
read_only.insert("move_word_end".to_string(), vec!["e".to_string()]);
read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]); // Multi-key!
// Big word movement
read_only.insert("move_big_word_next".to_string(), vec!["W".to_string()]);
read_only.insert("move_big_word_prev".to_string(), vec!["B".to_string()]);
read_only.insert("move_big_word_end".to_string(), vec!["E".to_string()]);
read_only.insert("move_big_word_end_prev".to_string(), vec!["gE".to_string()]); // Multi-key!
// Line movement
read_only.insert("move_line_start".to_string(), vec!["0".to_string(), "Home".to_string()]);
read_only.insert("move_line_end".to_string(), vec!["$".to_string(), "End".to_string()]);
// Field movement
read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]); // Multi-key!
read_only.insert("move_last_line".to_string(), vec!["G".to_string()]);
read_only.insert("next_field".to_string(), vec!["Tab".to_string()]);
read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
// Mode transitions
read_only.insert("enter_edit_mode_before".to_string(), vec!["i".to_string()]);
read_only.insert("enter_edit_mode_after".to_string(), vec!["a".to_string()]);
read_only.insert("enter_highlight_mode".to_string(), vec!["v".to_string()]);
read_only.insert("enter_highlight_mode_linewise".to_string(), vec!["V".to_string()]);
// Editing actions in normal mode
read_only.insert("delete_char_forward".to_string(), vec!["x".to_string()]);
read_only.insert("delete_char_backward".to_string(), vec!["X".to_string()]);
read_only.insert("open_line_below".to_string(), vec!["o".to_string()]);
read_only.insert("open_line_above".to_string(), vec!["O".to_string()]);
// === EDIT MODE KEYBINDINGS ===
edit.insert("exit_edit_mode".to_string(), vec!["esc".to_string()]);
edit.insert("move_left".to_string(), vec!["Left".to_string()]);
edit.insert("move_right".to_string(), vec!["Right".to_string()]);
edit.insert("move_up".to_string(), vec!["Up".to_string()]);
edit.insert("move_down".to_string(), vec!["Down".to_string()]);
edit.insert("move_line_start".to_string(), vec!["Home".to_string()]);
edit.insert("move_line_end".to_string(), vec!["End".to_string()]);
edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]);
edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]);
edit.insert("next_field".to_string(), vec!["Tab".to_string()]);
edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]);
// === HIGHLIGHT MODE KEYBINDINGS ===
highlight.insert("exit_highlight_mode".to_string(), vec!["esc".to_string()]);
highlight.insert("enter_highlight_mode_linewise".to_string(), vec!["V".to_string()]);
// Movement (extends selection)
highlight.insert("move_left".to_string(), vec!["h".to_string(), "Left".to_string()]);
highlight.insert("move_right".to_string(), vec!["l".to_string(), "Right".to_string()]);
highlight.insert("move_up".to_string(), vec!["k".to_string(), "Up".to_string()]);
highlight.insert("move_down".to_string(), vec!["j".to_string(), "Down".to_string()]);
highlight.insert("move_word_next".to_string(), vec!["w".to_string()]);
highlight.insert("move_word_prev".to_string(), vec!["b".to_string()]);
highlight.insert("move_word_end".to_string(), vec!["e".to_string()]);
highlight.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]);
highlight.insert("move_line_start".to_string(), vec!["0".to_string()]);
highlight.insert("move_line_end".to_string(), vec!["$".to_string()]);
highlight.insert("move_first_line".to_string(), vec!["gg".to_string()]);
highlight.insert("move_last_line".to_string(), vec!["G".to_string()]);
CanvasKeyMap::from_mode_maps(&read_only, &edit, &highlight)
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> io::Result<()> {
// First, try canvas keymap
match self.editor.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
self.message = format!("🎯 Canvas: {}", msg);
return Ok(());
}
KeyEventOutcome::Consumed(None) => {
self.message = "🎯 Canvas action executed".to_string();
return Ok(());
}
KeyEventOutcome::Pending => {
self.message = "⏳ Waiting for next key in sequence...".to_string();
return Ok(());
}
KeyEventOutcome::NotMatched => {
// Fall through to client actions
}
}
// Handle client-specific actions (non-canvas)
use crossterm::event::{KeyCode, KeyModifiers};
match (key_event.code, key_event.modifiers) {
(KeyCode::Char('q'), KeyModifiers::CONTROL) |
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.quit = true;
self.message = "👋 Goodbye!".to_string();
}
(KeyCode::F(1), _) => {
self.message = " F1: This is a client action (not handled by canvas keymap)".to_string();
}
(KeyCode::F(2), _) => {
// Demonstrate saving
self.message = "💾 F2: Save action (client-side)".to_string();
}
(KeyCode::Char('?'), _) if self.editor.mode() == AppMode::ReadOnly => {
self.show_help();
}
_ => {
// Unknown key
self.message = format!(
"❓ Unhandled key: {:?} (mode: {:?})",
key_event.code,
self.editor.mode()
);
}
}
Ok(())
}
fn show_help(&mut self) {
self.message = "📖 Help: Multi-key sequences work! Try gg, ge, gE. Also: hjkl, w/b/e, v/V, i/a/o".to_string();
}
fn should_quit(&self) -> bool {
self.quit
}
fn editor(&self) -> &FormEditor<DemoData> {
&self.editor
}
fn message(&self) -> &str {
&self.message
}
}
/// Demo form data with interesting examples for keymap testing
struct DemoData {
fields: Vec<(String, String)>,
}
impl DemoData {
fn new() -> Self {
Self {
fields: vec![
("🎯 Name".to_string(), "John-Paul McDonald-Smith".to_string()),
("📧 Email".to_string(), "user@long-domain-name.example.com".to_string()),
("📱 Phone".to_string(), "+1 (555) 123-4567 ext. 890".to_string()),
("🏠 Address".to_string(), "123 Main Street, Apartment 4B, Suite 100".to_string()),
("🏷️ Tags".to_string(), "urgent,important,follow-up,high-priority".to_string()),
("📝 Notes".to_string(), "Test word movements: w=next-word, b=prev-word, e=word-end, ge=prev-word-end".to_string()),
("🔥 Multi-key".to_string(), "Try multi-key sequences: gg=first-field, ge=prev-word-end, gE=prev-WORD-end".to_string()),
("⚡ Vim Actions".to_string(), "Normal mode: x=delete-char, o=open-line-below, v=visual, i=insert".to_string()),
],
}
}
}
impl DataProvider for DemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: KeymapDemoApp) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &app))?;
if let Event::Key(key) = event::read()? {
app.handle_key_event(key)?;
if app.should_quit() {
break;
}
}
}
Ok(())
}
fn ui(f: &mut Frame, app: &KeymapDemoApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(12)])
.split(f.area());
// Render the canvas
render_canvas_default(f, chunks[0], app.editor());
// Render status and help
render_status_and_help(f, chunks[1], app);
}
fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, app: &KeymapDemoApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(9)])
.split(area);
// Status message
let status_text = format!(
"Mode: {:?} | Field: {}/{} | Pos: {} | {}",
app.editor().mode(),
app.editor().current_field() + 1,
app.editor().data_provider().field_count(),
app.editor().cursor_position(),
app.message()
);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Keymap Demo Status"));
f.render_widget(status, chunks[0]);
// Help text based on current mode
let help_text = match app.editor().mode() {
AppMode::ReadOnly => {
"🎯 KEYMAP DEMO - All keys handled by centralized keymap system!\n\
\n\
📍 MOVEMENT: hjkl(basic) | w/b/e(words) | W/B/E(WORDS) | 0/$(line) | gg/G(fields)\n\
🔥 MULTI-KEY: gg=first-field, ge=prev-word-end, gE=prev-WORD-end\n\
✏️ MODES: i/a(insert) | v/V(visual) | o/O(open-line)\n\
🗑️ DELETE: x/X(delete-char)\n\
📂 FIELDS: Tab/Shift+Tab\n\
\n\
💡 Try multi-key sequences like 'gg' or 'ge' - watch the status for 'Waiting...'\n\
🚪 Ctrl+C=quit | ?=help | F1/F2=client actions (not canvas)"
}
AppMode::Edit => {
"✏️ INSERT MODE - Keys handled by keymap system\n\
\n\
🔄 NAVIGATION: arrows | Ctrl+arrows(words) | Home/End(line) | Tab/Shift+Tab(fields)\n\
🗑️ DELETE: Backspace/Delete\n\
🚪 EXIT: Esc=normal\n\
\n\
💡 Type text normally - the keymap handles navigation!"
}
AppMode::Highlight => {
"🎯 VISUAL MODE - Selection extended by keymap movements\n\
\n\
📍 EXTEND: hjkl(basic) | w/b/e(words) | 0/$(line) | gg/G(fields)\n\
🔄 SWITCH: V=toggle-line-mode\n\
🚪 EXIT: Esc=normal\n\
\n\
💡 All movements extend the selection automatically!"
}
_ => "🎯 Keymap system active!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Centralized Keymap System"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎯 Canvas Keymap Demo");
println!("✅ canvas-keymap feature: ENABLED");
println!("🚀 Centralized key handling: ACTIVE");
println!("📖 Multi-key sequences: SUPPORTED (gg, ge, gE, etc.)");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let app = KeymapDemoApp::new();
let res = run_app(&mut terminal, app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
println!("🎯 Keymap demo completed!");
Ok(())
}

View File

@@ -9,6 +9,10 @@ use crate::DataProvider;
#[cfg(feature = "suggestions")]
use crate::SuggestionItem;
// NEW: Import keymap types when keymap feature is enabled
#[cfg(feature = "keymap")]
use crate::keymap::{CanvasKeyMap, KeySequenceTracker};
pub struct FormEditor<D: DataProvider> {
pub(crate) ui_state: EditorState,
pub(crate) data_provider: D,
@@ -23,6 +27,12 @@ pub struct FormEditor<D: DataProvider> {
+ Sync,
>,
>,
// NEW: Injected keymap and sequence tracker (keymap feature only)
#[cfg(feature = "keymap")]
pub(crate) keymap: Option<CanvasKeyMap>,
#[cfg(feature = "keymap")]
pub(crate) seq_tracker: KeySequenceTracker,
}
impl<D: DataProvider> FormEditor<D> {
@@ -47,6 +57,11 @@ impl<D: DataProvider> FormEditor<D> {
suggestions: Vec::new(),
#[cfg(feature = "validation")]
external_validation_callback: None,
// NEW: Initialize keymap fields
#[cfg(feature = "keymap")]
keymap: None,
#[cfg(feature = "keymap")]
seq_tracker: KeySequenceTracker::new(400), // 400ms default timeout
};
#[cfg(feature = "validation")]
@@ -70,6 +85,26 @@ impl<D: DataProvider> FormEditor<D> {
}
}
// NEW: Keymap management methods (keymap feature only)
/// Set the keymap for this editor instance
#[cfg(feature = "keymap")]
pub fn set_keymap(&mut self, keymap: CanvasKeyMap) {
self.keymap = Some(keymap);
}
/// Check if this editor has a keymap configured
#[cfg(feature = "keymap")]
pub fn has_keymap(&self) -> bool {
self.keymap.is_some()
}
/// Set the timeout for multi-key sequences (in milliseconds)
#[cfg(feature = "keymap")]
pub fn set_key_sequence_timeout_ms(&mut self, timeout_ms: u64) {
self.seq_tracker = KeySequenceTracker::new(timeout_ms);
}
// Library-internal, used by multiple modules
pub(crate) fn current_text(&self) -> &str {
let field_index = self.ui_state.current_field;

View File

@@ -0,0 +1,228 @@
// src/editor/key_input.rs
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
#[cfg(feature = "keymap")]
use crate::keymap::{KeyEventOutcome, KeyStroke};
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "keymap")]
pub fn handle_key_event(&mut self, evt: KeyEvent) -> KeyEventOutcome {
// Check if keymap exists first
if self.keymap.is_none() {
return KeyEventOutcome::NotMatched;
}
let mode = self.ui_state.current_mode;
// Convert event to normalized stroke
let stroke = KeyStroke {
code: evt.code,
modifiers: evt.modifiers,
};
// Add key to sequence tracker
self.seq_tracker.add_key(stroke);
// Look up the action in keymap
let (matched, is_prefix) = {
let km = self.keymap.as_ref().unwrap();
km.lookup(mode, self.seq_tracker.sequence())
};
if let Some(action) = matched {
// Clone the action string to avoid borrow checker issues
let action_owned = action.to_string();
let msg = self.dispatch_canvas_action(&action_owned);
self.seq_tracker.reset();
return KeyEventOutcome::Consumed(msg);
}
if is_prefix {
// Wait for more keys
return KeyEventOutcome::Pending;
}
// No match: reset sequence and try insert-char fallback in Edit
self.seq_tracker.reset();
if mode == AppMode::Edit {
if let KeyCode::Char(c) = evt.code {
// Skip control/alt combos
let m = evt.modifiers;
let is_plain =
m.is_empty() || m == KeyModifiers::SHIFT;
if is_plain {
if self.insert_char(c).is_ok() {
return KeyEventOutcome::Consumed(None);
}
}
}
}
KeyEventOutcome::NotMatched
}
#[cfg(feature = "keymap")]
fn dispatch_canvas_action(&mut self, action: &str) -> Option<String> {
match action {
// Movement
"move_left" => {
let _ = self.move_left();
None
}
"move_right" => {
let _ = self.move_right();
None
}
"move_up" => {
let _ = self.move_up();
None
}
"move_down" => {
let _ = self.move_down();
None
}
"next_field" => {
let _ = self.next_field();
None
}
"prev_field" => {
let _ = self.prev_field();
None
}
"move_line_start" => {
self.move_line_start();
None
}
"move_line_end" => {
self.move_line_end();
None
}
"move_first_line" => {
let _ = self.move_first_line();
None
}
"move_last_line" => {
let _ = self.move_last_line();
None
}
// Word/big-word movement (cross-field aware)
"move_word_next" => {
self.move_word_next();
None
}
"move_word_prev" => {
self.move_word_prev();
None
}
"move_word_end" => {
self.move_word_end();
None
}
"move_word_end_prev" => {
self.move_word_end_prev();
None
}
"move_big_word_next" => {
self.move_big_word_next();
None
}
"move_big_word_prev" => {
self.move_big_word_prev();
None
}
"move_big_word_end" => {
self.move_big_word_end();
None
}
"move_big_word_end_prev" => {
self.move_big_word_end_prev();
None
}
// Editing
"delete_char_backward" => {
let _ = self.delete_backward();
None
}
"delete_char_forward" => {
let _ = self.delete_forward();
None
}
"open_line_below" => {
let _ = self.open_line_below();
None
}
"open_line_above" => {
let _ = self.open_line_above();
None
}
// Suggestions (only when feature is enabled)
#[cfg(feature = "suggestions")]
"open_suggestions" => {
let idx = self.current_field();
self.open_suggestions(idx);
None
}
#[cfg(feature = "suggestions")]
"apply_suggestion" | "enter_decider" => {
if let Some(_applied) = self.apply_suggestion() {
None
} else {
None
}
}
#[cfg(feature = "suggestions")]
"suggestion_down" => {
self.suggestions_next();
None
}
#[cfg(feature = "suggestions")]
"suggestion_up" => {
self.suggestions_prev();
None
}
// Mode transitions (vim-like)
"enter_edit_mode_before" => {
self.enter_edit_mode();
None
}
"enter_edit_mode_after" => {
// Move forward 1 char if possible (vim 'a'), then enter insert
let txt_len = self.current_text().chars().count();
let pos = self.ui_state.cursor_pos;
if pos < txt_len {
self.ui_state.cursor_pos = pos + 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
self.enter_edit_mode();
None
}
"exit" | "exit_edit_mode" => {
let _ = self.exit_edit_mode();
None
}
"enter_highlight_mode" => {
self.enter_highlight_mode();
None
}
"enter_highlight_mode_linewise" => {
self.enter_highlight_line_mode();
None
}
"exit_highlight_mode" => {
self.exit_highlight_mode();
None
}
_ => None,
}
}
}

View File

@@ -21,5 +21,8 @@ pub mod validation_helpers;
#[cfg(feature = "computed")]
pub mod computed_helpers;
#[cfg(feature = "keymap")]
pub mod key_input;
// Re-export the main type
pub use core::FormEditor;

View File

@@ -133,6 +133,21 @@ impl<D: DataProvider> FormEditor<D> {
self.update_inline_completion();
}
pub fn suggestions_prev(&mut self) {
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty() {
return;
}
let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
let prev = if current == 0 {
self.suggestions.len() - 1
} else {
current - 1
};
self.ui_state.suggestions.selected_index = Some(prev);
self.update_inline_completion();
}
pub fn apply_suggestion(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
if let Some(suggestion) = self.suggestions.get(selected_index).cloned()

344
canvas/src/keymap/mod.rs Normal file
View File

@@ -0,0 +1,344 @@
// src/keymap/mod.rs
use std::collections::HashMap;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers};
use crate::canvas::modes::AppMode;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct KeyStroke {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
#[derive(Clone, Debug)]
struct Binding {
action: String,
sequence: Vec<KeyStroke>,
}
#[derive(Clone, Debug, Default)]
pub struct CanvasKeyMap {
ro: Vec<Binding>,
edit: Vec<Binding>,
hl: Vec<Binding>,
}
// FIXED: Removed Copy because Option<String> is not Copy
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyEventOutcome {
Consumed(Option<String>),
Pending,
NotMatched,
}
#[derive(Debug, Clone)]
pub struct KeySequenceTracker {
sequence: Vec<KeyStroke>,
last_key_time: Instant,
timeout: Duration,
}
impl KeySequenceTracker {
pub fn new(timeout_ms: u64) -> Self {
Self {
sequence: Vec::new(),
last_key_time: Instant::now(),
timeout: Duration::from_millis(timeout_ms),
}
}
pub fn reset(&mut self) {
self.sequence.clear();
self.last_key_time = Instant::now();
}
pub fn add_key(&mut self, stroke: KeyStroke) {
let now = Instant::now();
if now.duration_since(self.last_key_time) > self.timeout {
self.reset();
}
self.sequence.push(normalize_stroke(stroke));
self.last_key_time = now;
}
pub fn sequence(&self) -> &[KeyStroke] {
&self.sequence
}
}
fn normalize_stroke(mut s: KeyStroke) -> KeyStroke {
// Normalize Shift+Tab to BackTab
let is_shift_tab =
s.code == KeyCode::Tab && s.modifiers.contains(KeyModifiers::SHIFT);
if is_shift_tab {
s.code = KeyCode::BackTab;
s.modifiers.remove(KeyModifiers::SHIFT);
return s;
}
// Normalize Shift+char to uppercase char without SHIFT when possible
if let KeyCode::Char(c) = s.code {
if s.modifiers.contains(KeyModifiers::SHIFT) {
let mut up = c;
// Only letters transform meaningfully
if c.is_ascii_alphabetic() {
up = c.to_ascii_uppercase();
}
s.code = KeyCode::Char(up);
s.modifiers.remove(KeyModifiers::SHIFT);
return s;
}
}
s
}
impl CanvasKeyMap {
pub fn from_mode_maps(
read_only: &HashMap<String, Vec<String>>,
edit: &HashMap<String, Vec<String>>,
highlight: &HashMap<String, Vec<String>>,
) -> Self {
let mut km = Self::default();
km.ro = collect_bindings(read_only);
km.edit = collect_bindings(edit);
km.hl = collect_bindings(highlight);
km
}
pub fn lookup(
&self,
mode: AppMode,
seq: &[KeyStroke],
) -> (Option<&str>, bool) {
let bindings = match mode {
AppMode::ReadOnly => &self.ro,
AppMode::Edit => &self.edit,
AppMode::Highlight => &self.hl,
_ => return (None, false),
};
if seq.is_empty() {
return (None, false);
}
// Exact match
for b in bindings {
if sequences_equal(&b.sequence, seq) {
return (Some(b.action.as_str()), false);
}
}
// Prefix match
for b in bindings {
if is_prefix(&b.sequence, seq) {
return (None, true);
}
}
(None, false)
}
}
fn sequences_equal(a: &[KeyStroke], b: &[KeyStroke]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| strokes_equal(x, y))
}
fn strokes_equal(a: &KeyStroke, b: &KeyStroke) -> bool {
// Both KeyStroke are already normalized
a.code == b.code && a.modifiers == b.modifiers
}
fn is_prefix(binding: &[KeyStroke], seq: &[KeyStroke]) -> bool {
if seq.len() >= binding.len() {
return false;
}
binding
.iter()
.zip(seq.iter())
.all(|(b, s)| strokes_equal(b, s))
}
fn collect_bindings(
mode_map: &HashMap<String, Vec<String>>,
) -> Vec<Binding> {
let mut out = Vec::new();
for (action, list) in mode_map {
for binding_str in list {
if let Some(seq) = parse_binding_to_sequence(binding_str) {
out.push(Binding {
action: action.to_string(),
sequence: seq,
});
}
}
}
out
}
fn parse_binding_to_sequence(input: &str) -> Option<Vec<KeyStroke>> {
let s = input.trim();
if s.is_empty() {
return None;
}
let has_space = s.contains(' ');
let has_plus = s.contains('+');
if has_space {
let mut seq = Vec::new();
for part in s.split_whitespace() {
if let Some(mut strokes) = parse_part_to_sequence(part) {
seq.append(&mut strokes);
} else {
return None;
}
}
return Some(seq);
}
if has_plus {
if contains_modifier_token(s) {
if let Some(k) = parse_chord_with_modifiers(s) {
return Some(vec![k]);
}
return None;
} else {
let mut seq = Vec::new();
for t in s.split('+') {
if let Some(mut strokes) = parse_part_to_sequence(t) {
seq.append(&mut strokes);
} else {
return None;
}
}
return Some(seq);
}
}
if is_compound_key(s) {
if let Some(k) = parse_simple_key(s) {
return Some(vec![k]);
}
return None;
}
if s.len() > 1 {
let mut seq = Vec::new();
for ch in s.chars() {
seq.push(KeyStroke {
code: KeyCode::Char(ch),
modifiers: KeyModifiers::empty(),
});
}
return Some(seq);
}
if let Some(k) = parse_simple_key(s) {
return Some(vec![k]);
}
None
}
fn parse_part_to_sequence(part: &str) -> Option<Vec<KeyStroke>> {
let p = part.trim();
if p.is_empty() {
return None;
}
if p.contains('+') && contains_modifier_token(p) {
if let Some(k) = parse_chord_with_modifiers(p) {
return Some(vec![k]);
}
return None;
}
if is_compound_key(p) {
if let Some(k) = parse_simple_key(p) {
return Some(vec![k]);
}
return None;
}
if p.len() > 1 {
let mut seq = Vec::new();
for ch in p.chars() {
seq.push(KeyStroke {
code: KeyCode::Char(ch),
modifiers: KeyModifiers::empty(),
});
}
return Some(seq);
}
parse_simple_key(p).map(|k| vec![k])
}
fn contains_modifier_token(s: &str) -> bool {
let low = s.to_lowercase();
low.contains("ctrl") || low.contains("shift") || low.contains("alt") ||
low.contains("super") || low.contains("cmd") || low.contains("meta")
}
fn parse_chord_with_modifiers(s: &str) -> Option<KeyStroke> {
let mut mods = KeyModifiers::empty();
let mut key: Option<KeyCode> = None;
for comp in s.split('+') {
match comp.to_lowercase().as_str() {
"ctrl" => mods |= KeyModifiers::CONTROL,
"shift" => mods |= KeyModifiers::SHIFT,
"alt" => mods |= KeyModifiers::ALT,
"super" | "cmd" => mods |= KeyModifiers::SUPER,
"meta" => mods |= KeyModifiers::META,
other => {
key = string_to_keycode(other);
}
}
}
key.map(|k| normalize_stroke(KeyStroke { code: k, modifiers: mods }))
}
fn is_compound_key(s: &str) -> bool {
matches!(s.to_lowercase().as_str(),
"left" | "right" | "up" | "down" | "esc" | "enter" | "backspace" |
"delete" | "tab" | "home" | "end" | "$" | "0"
)
}
fn parse_simple_key(s: &str) -> Option<KeyStroke> {
if let Some(kc) = string_to_keycode(&s.to_lowercase()) {
return Some(KeyStroke { code: kc, modifiers: KeyModifiers::empty() });
}
if s.chars().count() == 1 {
let ch = s.chars().next().unwrap();
return Some(KeyStroke { code: KeyCode::Char(ch), modifiers: KeyModifiers::empty() });
}
None
}
fn string_to_keycode(s: &str) -> Option<KeyCode> {
Some(match s {
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"esc" => KeyCode::Esc,
"enter" => KeyCode::Enter,
"backspace" => KeyCode::Backspace,
"delete" => KeyCode::Delete,
"tab" => KeyCode::Tab,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"$" => KeyCode::Char('$'),
"0" => KeyCode::Char('0'),
_ => return None,
})
}

View File

@@ -4,22 +4,21 @@ pub mod canvas;
pub mod editor;
pub mod data_provider;
// Only include suggestions module if feature is enabled
#[cfg(feature = "suggestions")]
pub mod suggestions;
// Only include validation module if feature is enabled
#[cfg(feature = "validation")]
pub mod validation;
// First-class textarea module and exports
#[cfg(feature = "textarea")]
pub mod textarea;
// Only include computed module if feature is enabled
#[cfg(feature = "computed")]
pub mod computed;
#[cfg(feature = "keymap")]
pub mod keymap;
#[cfg(feature = "cursor-style")]
pub use canvas::CursorManager;
@@ -71,6 +70,8 @@ pub use canvas::gui::{CanvasDisplayOptions, OverflowMode};
#[cfg(all(feature = "gui", feature = "suggestions"))]
pub use suggestions::gui::render_suggestions_dropdown;
#[cfg(feature = "keymap")]
pub use keymap::{CanvasKeyMap, KeyEventOutcome};
#[cfg(feature = "textarea")]
pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor};

View File

@@ -8,7 +8,7 @@ license.workspace = true
anyhow = { workspace = true }
async-trait = "0.1.88"
common = { path = "../common" }
canvas = { path = "../canvas", features = ["gui"] }
canvas = { path = "../canvas", features = ["gui", "suggestions", "cursor-style", "keymap"] }
ratatui = { workspace = true }
crossterm = { workspace = true }

View File

@@ -7,16 +7,14 @@ previous_buffer = ["space+b+p"]
close_buffer = ["space+b+d"]
[keybindings.general]
move_up = ["k", "Up"]
move_down = ["j", "Down"]
next_option = ["l", "Right"]
previous_option = ["h", "Left"]
up = ["k", "Up"]
down = ["j", "Down"]
left = ["h", "Left"]
right = ["l", "Right"]
next = ["Tab"]
previous = ["Shift+Tab"]
select = ["Enter"]
toggle_sidebar = ["ctrl+t"]
toggle_buffer_list = ["ctrl+b"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
exit_table_scroll = ["esc"]
esc = ["esc"]
open_search = ["ctrl+f"]
[keybindings.common]
@@ -40,7 +38,7 @@ previous_entry = ["left","q"]
next_entry = ["right","1"]
enter_highlight_mode = ["v"]
enter_highlight_mode_linewise = ["ctrl+v"]
enter_highlight_mode_linewise = ["shift+v"]
### AUTOGENERATED CANVAS CONFIG
# Required
@@ -50,7 +48,7 @@ move_right = ["l", "Right"]
move_down = ["j", "Down"]
# Optional
move_line_end = ["$"]
# move_word_next = ["w"]
move_word_next = ["w"]
next_field = ["Tab"]
move_word_prev = ["b"]
move_word_end = ["e"]
@@ -91,23 +89,23 @@ suggestion_up = ["ctrl+p", "shift+tab"]
### AUTOGENERATED CANVAS CONFIG
# Required
move_right = ["Right", "l"]
move_right = ["Right"]
delete_char_backward = ["Backspace"]
next_field = ["Tab", "Enter"]
move_up = ["Up", "k"]
move_down = ["Down", "j"]
move_up = ["Up"]
move_down = ["Down"]
prev_field = ["Shift+Tab"]
move_left = ["Left", "h"]
move_left = ["Left"]
# Optional
move_last_line = ["Ctrl+End", "G"]
move_last_line = ["Ctrl+End"]
delete_char_forward = ["Delete"]
move_word_prev = ["Ctrl+Left", "b"]
move_word_end = ["e"]
move_word_end_prev = ["ge"]
move_first_line = ["Ctrl+Home", "gg"]
move_word_next = ["Ctrl+Right", "w"]
move_line_start = ["Home", "0"]
move_line_end = ["End", "$"]
move_word_prev = ["Ctrl+Left"]
# move_word_end = ["e"]
# move_word_end_prev = ["ge"]
move_first_line = ["Ctrl+Home"]
move_word_next = ["Ctrl+Right"]
move_line_start = ["Home"]
move_line_end = ["End"]
[keybindings.command]
exit_command_mode = ["ctrl+g", "esc"]

View File

@@ -1,4 +1,4 @@
// src/components/common/find_file_palette.rs
// src/bottom_panel/find_file_palette.rs
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState; // Corrected path

View File

@@ -0,0 +1,98 @@
// src/bottom_panel/layout.rs
use ratatui::{layout::Constraint, layout::Rect, Frame};
use crate::bottom_panel::{status_line::render_status_line, command_line::render_command_line};
use crate::bottom_panel::find_file_palette;
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState;
use crate::state::app::state::AppState;
use crate::pages::routing::Router;
/// Calculate the layout constraints for the bottom panel (status line + command line/palette).
pub fn bottom_panel_constraints(
app_state: &AppState,
navigation_state: &NavigationState,
event_handler_command_mode_active: bool,
) -> Vec<Constraint> {
let mut status_line_height = 1;
#[cfg(feature = "ui-debug")]
{
if let Some(debug_state) = &app_state.debug_state {
if debug_state.is_error {
status_line_height = 4;
}
}
}
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
let command_palette_area_height = if navigation_state.active {
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
} else if event_handler_command_mode_active {
1
} else {
0
};
let mut constraints = vec![Constraint::Length(status_line_height)];
if command_palette_area_height > 0 {
constraints.push(Constraint::Length(command_palette_area_height));
}
constraints
}
/// Render the bottom panel (status line + command line/palette).
pub fn render_bottom_panel(
f: &mut Frame,
root_chunks: &[Rect],
chunk_idx: &mut usize,
current_dir: &str,
theme: &Theme,
current_fps: f64,
app_state: &AppState,
router: &Router,
navigation_state: &NavigationState,
event_handler_command_input: &str,
event_handler_command_mode_active: bool,
event_handler_command_message: &str,
) {
// --- Status line area ---
let status_line_area = root_chunks[*chunk_idx];
*chunk_idx += 1;
// --- Command line / palette area ---
let command_render_area = if root_chunks.len() > *chunk_idx {
Some(root_chunks[*chunk_idx])
} else {
None
};
if command_render_area.is_some() {
*chunk_idx += 1;
}
// --- Render status line ---
render_status_line(
f,
status_line_area,
current_dir,
theme,
current_fps,
app_state,
router,
);
// --- Render command line or palette ---
if let Some(area) = command_render_area {
if navigation_state.active {
find_file_palette::render_find_file_palette(f, area, theme, navigation_state);
} else if event_handler_command_mode_active {
render_command_line(
f,
area,
event_handler_command_input,
true,
theme,
event_handler_command_message,
);
}
}
}

View File

@@ -0,0 +1,6 @@
// src/bottom_panel/mod.rs
pub mod status_line;
pub mod command_line;
pub mod layout;
pub mod find_file_palette;

View File

@@ -5,10 +5,11 @@ use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span, Text},
widgets::Paragraph,
widgets::{Paragraph, Wrap},
Frame,
};
use ratatui::widgets::Wrap;
use crate::pages::routing::Page;
use crate::pages::routing::Router;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
@@ -17,9 +18,9 @@ pub fn render_status_line(
area: Rect,
current_dir: &str,
theme: &Theme,
is_edit_mode: bool,
current_fps: f64,
app_state: &AppState,
router: &Router,
) {
#[cfg(feature = "ui-debug")]
{
@@ -49,7 +50,20 @@ pub fn render_status_line(
// --- The normal status line rendering logic (unchanged) ---
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
let mode_text = if let Page::Form(path) = &router.current {
if let Some(editor) = app_state.editor_for_path_ref(path) {
match editor.mode() {
canvas::AppMode::Edit => "[EDIT]",
canvas::AppMode::ReadOnly => "[READ-ONLY]",
canvas::AppMode::Highlight => "[VISUAL]",
_ => "",
}
} else {
""
}
} else {
"" // No canvas active
};
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())

View File

@@ -1,13 +1,13 @@
// src/functions/common/buffer.rs
// src/buffer/functions/buffer.rs
use crate::state::app::buffer::BufferState;
use crate::state::app::buffer::AppView;
use crate::buffer::state::BufferState;
use crate::buffer::state::AppView;
pub fn get_view_layer(view: &AppView) -> u8 {
match view {
AppView::Intro => 1,
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
AppView::Form | AppView::Scratch => 3,
AppView::Form(_) | AppView::Scratch => 3,
}
}

View File

@@ -0,0 +1,20 @@
// src/buffer/logic.rs
use crossterm::event::{KeyCode, KeyModifiers};
use crate::config::binds::config::Config;
use crate::state::app::state::UiState;
/// Toggle the buffer list visibility based on keybindings.
pub fn toggle_buffer_list(
ui_state: &mut UiState,
config: &Config,
key: KeyCode,
modifiers: KeyModifiers,
) -> bool {
if let Some(action) = config.get_common_action(key, modifiers) {
if action == "toggle_buffer_list" {
ui_state.show_buffer_list = !ui_state.show_buffer_list;
return true;
}
}
false
}

11
client/src/buffer/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
// src/buffer/mod.rs
pub mod state;
pub mod functions;
pub mod ui;
pub mod logic;
pub use state::{AppView, BufferState};
pub use functions::*;
pub use ui::render_buffer_list;
pub use logic::toggle_buffer_list;

View File

@@ -1,4 +1,4 @@
// src/state/app/buffer.rs
// src/buffer/state/buffer.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppView {
@@ -8,7 +8,7 @@ pub enum AppView {
Admin,
AddTable,
AddLogic,
Form,
Form(String),
Scratch,
}
@@ -23,7 +23,7 @@ impl AppView {
AppView::Admin => "Admin_Panel",
AppView::AddTable => "Add_Table",
AppView::AddLogic => "Add_Logic",
AppView::Form => "Form",
AppView::Form(_) => "Form",
AppView::Scratch => "*scratch*",
}
}
@@ -31,10 +31,14 @@ impl AppView {
/// Returns the display name with dynamic context (for Form buffers)
pub fn display_name_with_context(&self, current_table_name: Option<&str>) -> String {
match self {
AppView::Form => {
current_table_name
.unwrap_or("Data Form")
.to_string()
AppView::Form(path) => {
// Derive table name from "profile/table" path
let table = path.split('/').nth(1).unwrap_or("");
if !table.is_empty() {
table.to_string()
} else {
current_table_name.unwrap_or("Data Form").to_string()
}
}
_ => self.display_name().to_string(),
}

View File

@@ -1,7 +1,7 @@
// src/components/handlers/buffer_list.rs
// src/buffer/ui.rs
use crate::config::colors::themes::Theme;
use crate::state::app::buffer::BufferState;
use crate::buffer::state::BufferState;
use crate::state::app::state::AppState; // Add this import
use ratatui::{
layout::{Alignment, Rect},
@@ -11,7 +11,7 @@ use ratatui::{
Frame,
};
use unicode_width::UnicodeWidthStr;
use crate::functions::common::buffer::get_view_layer;
use crate::buffer::functions::get_view_layer;
pub fn render_buffer_list(
f: &mut Frame,

View File

@@ -1,10 +0,0 @@
// src/components/admin.rs
pub mod admin_panel;
pub mod admin_panel_admin;
pub mod add_table;
pub mod add_logic;
pub use admin_panel::*;
pub use admin_panel_admin::*;
pub use add_table::*;
pub use add_logic::*;

View File

@@ -1,6 +0,0 @@
// src/components/form.rs
pub mod login;
pub mod register;
pub use login::*;
pub use register::*;

View File

@@ -1,18 +1,9 @@
// src/components/common.rs
pub mod command_line;
pub mod status_line;
pub mod text_editor;
pub mod background;
pub mod dialog;
pub mod autocomplete;
pub mod search_palette;
pub mod find_file_palette;
pub use command_line::*;
pub use status_line::*;
pub use text_editor::*;
pub use background::*;
pub use dialog::*;
pub use autocomplete::*;
pub use search_palette::*;
pub use find_file_palette::*;

View File

@@ -1,8 +1,8 @@
// src/components/common/autocomplete.rs
use crate::config::colors::themes::Theme;
use crate::state::pages::form::FormState;
use common::proto::komp_ac::search::search_response::Hit;
use crate::pages::forms::FormState;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},

View File

@@ -1,4 +0,0 @@
// src/components/form.rs
pub mod form;
pub use form::*;

View File

@@ -1,6 +0,0 @@
// src/components/handlers.rs
pub mod sidebar;
pub mod buffer_list;
pub use sidebar::*;
pub use buffer_list::*;

View File

@@ -1,4 +0,0 @@
// src/components/intro.rs
pub mod intro;
pub use intro::*;

View File

@@ -1,16 +1,7 @@
// src/components/mod.rs
pub mod handlers;
pub mod intro;
pub mod admin;
pub mod common;
pub mod form;
pub mod auth;
pub mod utils;
pub use handlers::*;
pub use intro::*;
pub use admin::*;
pub use common::*;
pub use form::*;
pub use auth::*;
pub use utils::*;

View File

@@ -5,6 +5,7 @@ use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers};
use canvas::CanvasKeyMap;
// NEW: Editor Keybinding Mode Enum
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -147,19 +148,17 @@ impl Config {
/// Context-aware keybinding resolution
pub fn get_action_for_current_context(
&self,
is_edit_mode: bool,
command_mode: bool,
key: KeyCode,
modifiers: KeyModifiers
) -> Option<&str> {
match (command_mode, is_edit_mode) {
(true, _) => self.get_command_action_for_key(key, modifiers),
(_, true) => self.get_edit_action_for_key(key, modifiers)
.or_else(|| self.get_common_action(key, modifiers)),
_ => self.get_read_only_action_for_key(key, modifiers)
if command_mode {
self.get_command_action_for_key(key, modifiers)
} else {
// fallback: read-only + common + global
self.get_read_only_action_for_key(key, modifiers)
.or_else(|| self.get_common_action(key, modifiers))
// Add global bindings check for read-only mode
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)),
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
}
}
@@ -760,4 +759,43 @@ impl Config {
}
false
}
/// Unified action resolver for app-level actions
pub fn get_app_action(
&self,
key_code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<&str> {
// First check common actions
if let Some(action) = self.get_common_action(key_code, modifiers) {
return Some(action);
}
// Then check read-only mode actions
if let Some(action) = self.get_read_only_action_for_key(key_code, modifiers) {
return Some(action);
}
// Then check highlight mode actions
if let Some(action) = self.get_highlight_action_for_key(key_code, modifiers) {
return Some(action);
}
// Then check edit mode actions
if let Some(action) = self.get_edit_action_for_key(key_code, modifiers) {
return Some(action);
}
None
}
pub fn build_canvas_keymap(&self) -> CanvasKeyMap {
CanvasKeyMap::from_mode_maps(
&self.keybindings.read_only,
&self.keybindings.edit,
&self.keybindings.highlight,
)
}
}

View File

@@ -1,6 +1,6 @@
// src/config/colors/themes.rs
use ratatui::style::Color;
use canvas::canvas::CanvasTheme;
use canvas::CanvasTheme;
#[derive(Debug, Clone)]
pub struct Theme {
@@ -12,7 +12,7 @@ pub struct Theme {
pub warning: Color,
pub border: Color,
pub highlight_bg: Color,
pub inactive_highlight_bg: Color,// admin panel no idea what it really is
pub inactive_highlight_bg: Color, // admin panel no idea what it really is
}
impl Theme {
@@ -108,4 +108,9 @@ impl CanvasTheme for Theme {
fn warning(&self) -> Color {
self.warning
}
fn suggestion_gray(&self) -> Color {
// Neutral gray for suggestions
Color::Rgb(128, 128, 128)
}
}

View File

@@ -0,0 +1,85 @@
// src/dialog/functions.rs
use crate::dialog::DialogState;
use crate::state::app::state::AppState;
use crate::ui::handlers::context::DialogPurpose;
impl AppState {
pub fn show_dialog(
&mut self,
title: &str,
message: &str,
buttons: Vec<String>,
purpose: DialogPurpose,
) {
self.ui.dialog.dialog_title = title.to_string();
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons = buttons;
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true;
}
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
self.ui.dialog.dialog_title = title.to_string();
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons.clear();
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None;
self.ui.dialog.is_loading = true;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true;
}
pub fn update_dialog_content(
&mut self,
message: &str,
buttons: Vec<String>,
purpose: DialogPurpose,
) {
if self.ui.dialog.dialog_show {
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons = buttons;
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false;
}
}
pub fn hide_dialog(&mut self) {
self.ui.dialog.dialog_show = false;
self.ui.dialog.dialog_title.clear();
self.ui.dialog.dialog_message.clear();
self.ui.dialog.dialog_buttons.clear();
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false;
}
pub fn next_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
% self.ui.dialog.dialog_buttons.len();
self.ui.dialog.dialog_active_button_index = next_index;
}
}
pub fn previous_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let len = self.ui.dialog.dialog_buttons.len();
let prev_index =
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
self.ui.dialog.dialog_active_button_index = prev_index;
}
}
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
self.ui.dialog
.dialog_buttons
.get(self.ui.dialog.dialog_active_button_index)
.map(|s| s.as_str())
}
}

207
client/src/dialog/logic.rs Normal file
View File

@@ -0,0 +1,207 @@
// src/dialog/logic.rs
// TODO(dialog-refactor):
// Currently this module (`handle_dialog_event`) contains page-specific logic
// (e.g. Login, Register, Admin, SaveTable). This couples the dialog crate
// to application pages and business logic.
//
// Refactor plan:
// 1. Keep dialog generic: only handle navigation (next/prev/select) and return
// a `DialogResult` (Dismissed | Selected { purpose, index }).
// 2. Move all page-specific actions (e.g. login::back_to_main, register::back_to_login,
// handle_delete_selected_columns, buffer_state.update_history) into the
// respective page or event handler (e.g. modes/handlers/event.rs).
// 3. Dialog crate should only provide state, rendering, and generic navigation.
// Pages decide what to do when a dialog button is pressed.
use crossterm::event::{Event, KeyCode};
use crate::config::binds::config::Config;
use crate::ui::handlers::context::DialogPurpose;
use crate::state::app::state::AppState;
use crate::buffer::AppView;
use crate::buffer::state::BufferState;
use crate::modes::handlers::event::EventOutcome;
use crate::pages::register;
use crate::pages::login;
use crate::pages::admin_panel::add_table::logic::handle_delete_selected_columns;
use crate::pages::routing::{Router, Page};
use anyhow::Result;
/// Handles key events specifically when a dialog is active.
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
/// otherwise returns None.
pub async fn handle_dialog_event(
event: &Event,
config: &Config,
app_state: &mut AppState,
buffer_state: &mut BufferState,
router: &mut Router,
) -> Option<Result<EventOutcome>> {
if let Event::Key(key) = event {
// Always allow Esc to dismiss
if key.code == KeyCode::Esc {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
}
// Check general bindings for dialog actions
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_down" | "next_option" => {
let current_index = app_state.ui.dialog.dialog_active_button_index;
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
if num_buttons > 0 && current_index < num_buttons - 1 {
app_state.ui.dialog.dialog_active_button_index += 1;
}
return Some(Ok(EventOutcome::Ok(String::new())));
}
"move_up" | "previous_option" => {
let current_index = app_state.ui.dialog.dialog_active_button_index;
if current_index > 0 {
app_state.ui.dialog.dialog_active_button_index -= 1;
}
return Some(Ok(EventOutcome::Ok(String::new())));
}
"select" => {
let selected_index = app_state.ui.dialog.dialog_active_button_index;
let purpose = match app_state.ui.dialog.purpose {
Some(p) => p,
None => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(
"Internal Error: Dialog context lost".to_string(),
)));
}
};
// Handle Dialog Actions Directly Here
match purpose {
DialogPurpose::LoginSuccess => match selected_index {
0 => {
// "Menu" button selected
app_state.hide_dialog();
if let Page::Login(state) = &mut router.current {
let message =
login::back_to_main(state, app_state, buffer_state).await;
return Some(Ok(EventOutcome::Ok(message)));
}
return Some(Ok(EventOutcome::Ok(
"Login state not active".to_string(),
)));
}
1 => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(
"Unknown dialog button selected".to_string(),
)));
}
},
DialogPurpose::LoginFailed => match selected_index {
0 => {
// "OK" button selected
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(
"Login failed dialog dismissed".to_string(),
)));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(
"Unknown dialog button selected".to_string(),
)));
}
},
DialogPurpose::RegisterSuccess => match selected_index {
0 => {
// "OK" button for RegisterSuccess
app_state.hide_dialog();
if let Page::Register(state) = &mut router.current {
let message =
register::back_to_login(state, app_state, buffer_state)
.await;
return Some(Ok(EventOutcome::Ok(message)));
}
return Some(Ok(EventOutcome::Ok(
"Register state not active".to_string(),
)));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(
"Unknown dialog button selected".to_string(),
)));
}
},
DialogPurpose::RegisterFailed => match selected_index {
0 => {
// "OK" button for RegisterFailed
app_state.hide_dialog(); // Just dismiss
return Some(Ok(EventOutcome::Ok(
"Register failed dialog dismissed".to_string(),
)));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(
"Unknown dialog button selected".to_string(),
)));
}
},
DialogPurpose::ConfirmDeleteColumns => match selected_index {
0 => {
// "Confirm" button selected
if let Page::Admin(state) = &mut router.current {
let outcome_message =
handle_delete_selected_columns(&mut state.add_table_state);
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(outcome_message)));
}
return Some(Ok(EventOutcome::Ok(
"Admin state not active".to_string(),
)));
}
1 => {
// "Cancel" button selected
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
}
_ => { /* Handle unexpected index */ }
},
DialogPurpose::SaveTableSuccess => match selected_index {
0 => {
// "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin); // Navigate back
return Some(Ok(EventOutcome::Ok(
"Save success dialog dismissed.".to_string(),
)));
}
_ => { /* Handle unexpected index */ }
},
DialogPurpose::SaveLogicSuccess => match selected_index {
0 => {
// "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin);
return Some(Ok(EventOutcome::Ok(
"Save success dialog dismissed.".to_string(),
)));
}
_ => { /* Handle unexpected index */ }
},
}
}
_ => {} // Ignore other general actions when dialog is shown
}
}
// If it was a key event but not handled above, consume it
Some(Ok(EventOutcome::Ok(String::new())))
} else {
// If it wasn't a key event, consume it too while dialog is active
Some(Ok(EventOutcome::Ok(String::new())))
}
}

10
client/src/dialog/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
// src/dialog/mod.rs
pub mod ui;
pub mod logic;
pub mod state;
pub mod functions;
pub use ui::render_dialog;
pub use logic::handle_dialog_event;
pub use state::DialogState;

View File

@@ -0,0 +1,26 @@
// src/dialog/state.rs
use crate::ui::handlers::context::DialogPurpose;
pub struct DialogState {
pub dialog_show: bool,
pub dialog_title: String,
pub dialog_message: String,
pub dialog_buttons: Vec<String>,
pub dialog_active_button_index: usize,
pub purpose: Option<DialogPurpose>,
pub is_loading: bool,
}
impl Default for DialogState {
fn default() -> Self {
Self {
dialog_show: false,
dialog_title: String::new(),
dialog_message: String::new(),
dialog_buttons: Vec::new(),
dialog_active_button_index: 0,
purpose: None,
is_loading: false,
}
}
}

View File

@@ -1,3 +1,5 @@
// src/dialog/ui.rs
use crate::config::colors::themes::Theme;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},

View File

@@ -1,5 +0,0 @@
// src/functions/common.rs
pub mod buffer;
pub use buffer::*;

View File

@@ -1,6 +0,0 @@
// src/functions/mod.rs
pub mod common;
pub mod modes;
pub use modes::*;

View File

@@ -1,5 +0,0 @@
// src/functions/modes.rs
pub mod navigation;
pub use navigation::*;

View File

@@ -1,5 +0,0 @@
// src/functions/modes/navigation.rs
pub mod admin_nav;
pub mod add_table_nav;
pub mod add_logic_nav;

View File

@@ -1,440 +0,0 @@
// src/functions/modes/navigation/add_logic_nav.rs
use crate::config::binds::config::{Config, EditorKeybindingMode};
use crate::state::{
app::state::AppState,
pages::add_logic::{AddLogicFocus, AddLogicState},
app::buffer::AppView,
app::buffer::BufferState,
};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::services::GrpcClient;
use tokio::sync::mpsc;
use anyhow::Result;
use crate::components::common::text_editor::TextEditor;
use crate::services::ui_service::UiService;
use tui_textarea::CursorMove; // Ensure this import is present
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
pub fn handle_add_logic_navigation(
key_event: KeyEvent,
config: &Config,
app_state: &mut AppState,
add_logic_state: &mut AddLogicState,
is_edit_mode: &mut bool,
buffer_state: &mut BufferState,
grpc_client: GrpcClient,
_save_logic_sender: SaveLogicResultSender, // Marked as unused
command_message: &mut String,
) -> bool {
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
// === AUTOCOMPLETE HANDLING ===
if add_logic_state.script_editor_autocomplete_active {
match key_event.code {
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
add_logic_state.script_editor_filter_text.push(c);
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
return true;
}
KeyCode::Backspace => {
if !add_logic_state.script_editor_filter_text.is_empty() {
add_logic_state.script_editor_filter_text.pop();
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
"Autocomplete: @".to_string()
} else {
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
};
} else {
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
let current_cursor = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
} else {
false
};
if should_deactivate {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
}
return true;
}
KeyCode::Tab | KeyCode::Down => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
add_logic_state.script_editor_selected_suggestion_index = Some(next);
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
}
return true;
}
KeyCode::Up => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
let prev = if current == 0 {
add_logic_state.script_editor_suggestions.len() - 1
} else {
current - 1
};
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
}
return true;
}
KeyCode::Enter => {
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
let trigger_pos = add_logic_state.script_editor_trigger_position;
let filter_len = add_logic_state.script_editor_filter_text.len();
add_logic_state.deactivate_script_editor_autocomplete();
add_logic_state.has_unsaved_changes = true;
if let Some(pos) = trigger_pos {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
if suggestion == "sql" {
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
editor_borrow.insert_str("('')");
// Move cursor back twice to be between the single quotes
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
*command_message = "Inserted: @sql('')".to_string();
} else {
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
if is_table_selection {
editor_borrow.insert_str(".");
let new_cursor = editor_borrow.cursor();
drop(editor_borrow); // Release borrow before calling add_logic_state methods
add_logic_state.script_editor_trigger_position = Some(new_cursor);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
let profile_name = add_logic_state.profile_name.clone();
let table_name_for_fetch = suggestion.clone();
let mut client_clone = grpc_client.clone();
tokio::spawn(async move {
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
Ok(_columns) => {
// Result handled by main UI loop
}
Err(e) => {
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
}
}
});
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
} else {
*command_message = format!("Inserted: {}", suggestion);
}
}
}
return true;
}
}
add_logic_state.deactivate_script_editor_autocomplete();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
KeyCode::Esc => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
_ => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
}
}
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
let should_trigger = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => *is_edit_mode,
_ => true,
};
if should_trigger {
let cursor_before = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
add_logic_state.script_editor_trigger_position = Some(cursor_before);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state.update_script_editor_suggestions();
add_logic_state.has_unsaved_changes = true;
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
return true;
}
}
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
if *is_edit_mode {
{
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
*is_edit_mode = false;
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
}
} else {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
*command_message = "Exited script editing.".to_string();
}
}
_ => {
if *is_edit_mode {
*is_edit_mode = false;
*command_message = "Exited script edit. Esc again to exit script.".to_string();
} else {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*is_edit_mode = false;
*command_message = "Exited script editing.".to_string();
}
}
}
return true;
}
let changed = {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
)
};
if changed {
add_logic_state.has_unsaved_changes = true;
}
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
}
return true;
}
let action = config.get_general_action(key_event.code, key_event.modifiers);
let current_focus = add_logic_state.current_focus;
let mut handled = true;
let mut new_focus = current_focus;
match action.as_deref() {
Some("exit_table_scroll") => {
handled = false;
}
Some("move_up") => {
match current_focus {
AddLogicFocus::InputLogicName => {}
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("move_down") => {
match current_focus {
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => {
add_logic_state.last_canvas_field = 2;
new_focus = AddLogicFocus::ScriptContentPreview;
},
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => {}
_ => handled = false,
}
}
Some("next_option") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
{ new_focus = AddLogicFocus::ScriptContentPreview; }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => { }
_ => handled = false,
}
}
Some("previous_option") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
{ }
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("next_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
_ => current_focus,
};
}
Some("prev_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
_ => current_focus,
};
}
Some("select") => {
match current_focus {
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::InsideScriptContent;
*is_edit_mode = false;
app_state.ui.focus_outside_canvas = false;
let mode_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
_ => "Enter/Ctrl+E to edit",
};
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
}
AddLogicFocus::SaveButton => {
*command_message = "Save logic action".to_string();
}
AddLogicFocus::CancelButton => {
buffer_state.update_history(AppView::Admin);
app_state.ui.show_add_logic = false;
*command_message = "Cancelled Add Logic".to_string();
*is_edit_mode = false;
}
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
}
_ => handled = false,
}
}
Some("toggle_edit_mode") => {
match current_focus {
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
*is_edit_mode = !*is_edit_mode;
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
}
_ => {
*command_message = "Cannot toggle edit mode here.".to_string();
}
}
}
_ => handled = false,
}
if handled && current_focus != new_focus {
add_logic_state.current_focus = new_focus;
let new_is_canvas_input_focus = matches!(new_focus,
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
);
if new_is_canvas_input_focus {
*is_edit_mode = false;
app_state.ui.focus_outside_canvas = false;
} else {
app_state.ui.focus_outside_canvas = true;
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
*is_edit_mode = false;
}
}
}
handled
}
fn replace_autocomplete_text(
editor: &mut tui_textarea::TextArea,
trigger_pos: (usize, usize),
filter_len: usize,
replacement: &str,
) {
// use tui_textarea::CursorMove; // Already imported at the top of the module
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
for _ in 0..filter_len {
editor.delete_next_char();
}
editor.insert_str(replacement);
}

View File

@@ -5,9 +5,15 @@ pub mod config;
pub mod state;
pub mod components;
pub mod modes;
pub mod functions;
pub mod services;
pub mod utils;
pub mod buffer;
pub mod sidebar;
pub mod dialog;
pub mod search;
pub mod bottom_panel;
pub mod pages;
pub mod movement;
pub use ui::run_ui;

View File

@@ -1,4 +0,0 @@
// src/client/modes/canvas.rs
pub mod edit;
pub mod common_mode;
pub mod read_only;

View File

@@ -1,86 +1,95 @@
// src/modes/canvas/common_mode.rs
use crate::tui::terminal::core::TerminalCore;
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState, auth::AuthState};
use crate::state::pages::auth::AuthState;
use crate::state::app::state::AppState;
use crate::services::grpc_client::GrpcClient;
use crate::services::auth::AuthClient;
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::form::SaveOutcome;
crate::pages::forms::logic::SaveOutcome;
use anyhow::{Context, Result};
use crate::tui::functions::common::{
form::{save as form_save, revert as form_revert},
login::{save as login_save, revert as login_revert},
register::{revert as register_revert},
};
use crate::pages::routing::{Router, Page};
pub async fn handle_core_action(
action: &str,
form_state: &mut FormState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
grpc_client: &mut GrpcClient,
auth_client: &mut AuthClient,
terminal: &mut TerminalCore,
app_state: &mut AppState,
router: &mut Router,
) -> Result<EventOutcome> {
match action {
"save" => {
if app_state.ui.show_login {
let message = login_save(auth_state, login_state, auth_client, app_state).await.context("Login save action failed")?;
Ok(EventOutcome::Ok(message))
} else {
let save_outcome = form_save(
app_state,
form_state,
grpc_client,
).await.context("Register save action failed")?;
let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
};
Ok(EventOutcome::DataSaved(save_outcome, message))
match &mut router.current {
Page::Login(state) => {
let message = login_save(auth_state, state, auth_client, app_state)
.await
.context("Login save action failed")?;
Ok(EventOutcome::Ok(message))
}
Page::Form(form_state) => {
let save_outcome = form_save(app_state, form_state, grpc_client)
.await
.context("Form save action failed")?;
let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
};
Ok(EventOutcome::DataSaved(save_outcome, message))
}
_ => Ok(EventOutcome::Ok("Save not applicable".into())),
}
},
}
"force_quit" => {
terminal.cleanup()?;
Ok(EventOutcome::Exit("Force exiting without saving.".to_string()))
},
}
"save_and_quit" => {
let message = if app_state.ui.show_login {
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
} else {
let save_outcome = form_save(
app_state,
form_state,
grpc_client,
).await?;
match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
let message = match &mut router.current {
Page::Login(state) => {
login_save(auth_state, state, auth_client, app_state)
.await
.context("Login save and quit action failed")?
}
Page::Form(form_state) => {
let save_outcome = form_save(app_state, form_state, grpc_client).await?;
match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
}
}
_ => "Save not applicable".to_string(),
};
terminal.cleanup()?;
Ok(EventOutcome::Exit(format!("{}. Exiting application.", message)))
},
}
"revert" => {
if app_state.ui.show_login {
let message = login_revert(login_state, app_state).await;
Ok(EventOutcome::Ok(message))
} else if app_state.ui.show_register {
let message = register_revert(register_state, app_state).await;
Ok(EventOutcome::Ok(message))
} else {
let message = form_revert(
form_state,
grpc_client,
).await.context("Form revert x action failed")?;
Ok(EventOutcome::Ok(message))
match &mut router.current {
Page::Login(state) => {
let message = login_revert(state, app_state).await;
Ok(EventOutcome::Ok(message))
}
Page::Register(state) => {
let message = register_revert(state, app_state).await;
Ok(EventOutcome::Ok(message))
}
Page::Form(form_state) => {
let message = form_revert(form_state, grpc_client)
.await
.context("Form revert action failed")?;
Ok(EventOutcome::Ok(message))
}
_ => Ok(EventOutcome::Ok("Revert not applicable".into())),
}
},
}
_ => Ok(EventOutcome::Ok(format!("Core action not handled: {}", action))),
}
}

View File

@@ -1,500 +0,0 @@
// src/modes/canvas/edit.rs
use crate::config::binds::config::Config;
use crate::modes::handlers::event::EventHandler;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::{
auth::{LoginState, RegisterState},
form::FormState,
};
use canvas::canvas::CanvasState;
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
use anyhow::Result;
use common::proto::komp_ac::search::search_response::Hit;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tokio::sync::mpsc;
use tracing::info;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome {
Message(String),
ExitEditMode,
}
/// Helper function to spawn a non-blocking search task for autocomplete.
async fn trigger_form_autocomplete_search(
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
sender: mpsc::UnboundedSender<Vec<Hit>>,
) {
if let Some(field_def) = form_state.fields.get(form_state.current_field) {
if field_def.is_link {
if let Some(target_table) = &field_def.link_target_table {
// 1. Update state for immediate UI feedback
form_state.autocomplete_loading = true;
form_state.autocomplete_active = true;
form_state.autocomplete_suggestions.clear();
form_state.selected_suggestion_index = None;
// 2. Clone everything needed for the background task
let query = form_state.get_current_input().to_string();
let table_to_search = target_table.clone();
let mut grpc_client_clone = grpc_client.clone();
info!(
"[Autocomplete] Spawning search in '{}' for query: '{}'",
table_to_search, query
);
// 3. Spawn the non-blocking task
tokio::spawn(async move {
match grpc_client_clone
.search_table(table_to_search, query)
.await
{
Ok(response) => {
// Send results back through the channel
let _ = sender.send(response.hits);
}
Err(e) => {
tracing::error!(
"[Autocomplete] Search failed: {:?}",
e
);
// Send an empty vec on error so the UI can stop loading
let _ = sender.send(vec![]);
}
}
});
}
}
}
}
pub async fn handle_form_edit_with_canvas(
key_event: KeyEvent,
config: &Config,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try canvas action from key first
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// Fall through to try config mapping
}
}
}
// Try config-mapped action
if let Some(action_str) = config.get_edit_action_for_key(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
}
Ok(String::new())
}
/// Helper function to execute a specific action using canvas library
async fn execute_canvas_action(
action: &str,
key: KeyEvent,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => Ok(msg.unwrap_or_default()),
Ok(ActionResult::HandledByFeature(msg)) => Ok(msg),
Ok(ActionResult::Error(msg)) => Ok(format!("Error: {}", msg)),
Ok(ActionResult::RequiresContext(msg)) => Ok(format!("Context needed: {}", msg)),
Err(e) => Ok(format!("Action failed: {}", e)),
}
}
/// FIXED: Unified canvas action handler with proper priority order for edit mode
async fn handle_canvas_state_edit<S: CanvasState>(
key: KeyEvent,
config: &Config,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
// PRIORITY 1: Character insertion in edit mode comes FIRST
if let KeyCode::Char(c) = key.code {
// Only insert if no modifiers or just shift (for uppercase)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
// println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG
let canvas_action = CanvasAction::InsertChar(c);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
// println!("DEBUG: Character insertion failed: {:?}, trying config", e);
// Fall through to try config mappings
}
}
}
}
// PRIORITY 2: Check canvas config for special keys/combinations
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
// println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
}
}
} else {
// println!("DEBUG: No canvas config mapping found"); // DEBUG
}
// PRIORITY 3: Check client config ONLY for non-character keys or modified keys
if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() {
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
// println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
} else {
// println!("DEBUG: No client config mapping found for non-char key"); // DEBUG
}
} else {
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
}
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
Ok(String::new())
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_edit_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
current_position: &mut u64,
total_count: u64,
event_handler: &mut EventHandler,
app_state: &AppState,
) -> Result<EditEventOutcome> {
// --- AUTOCOMPLETE-SPECIFIC KEY HANDLING ---
if app_state.ui.show_form && form_state.autocomplete_active {
if let Some(action) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
match action {
"suggestion_down" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let next = (current + 1)
% form_state.autocomplete_suggestions.len();
form_state.selected_suggestion_index = Some(next);
}
return Ok(EditEventOutcome::Message(String::new()));
}
"suggestion_up" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let prev = if current == 0 {
form_state.autocomplete_suggestions.len() - 1
} else {
current - 1
};
form_state.selected_suggestion_index = Some(prev);
}
return Ok(EditEventOutcome::Message(String::new()));
}
"exit" => {
form_state.deactivate_autocomplete();
return Ok(EditEventOutcome::Message(
"Autocomplete cancelled".to_string(),
));
}
"enter_decider" => {
if let Some(selected_idx) =
form_state.selected_suggestion_index
{
if let Some(selection) = form_state
.autocomplete_suggestions
.get(selected_idx)
.cloned()
{
// --- THIS IS THE CORE LOGIC CHANGE ---
// 1. Get the friendly display name for the UI
let display_name =
form_state.get_display_name_for_hit(&selection);
// 2. Store the REAL ID in the form's values
let current_input =
form_state.get_current_input_mut();
*current_input = selection.id.to_string();
// 3. Set the persistent display override in the map
form_state.link_display_map.insert(
form_state.current_field,
display_name,
);
// 4. Finalize state
form_state.deactivate_autocomplete();
form_state.set_has_unsaved_changes(true);
return Ok(EditEventOutcome::Message(
"Selection made".to_string(),
));
}
}
form_state.deactivate_autocomplete();
// Fall through to default 'enter' behavior
}
_ => {} // Let other keys fall through to the live search logic
}
}
}
// --- LIVE AUTOCOMPLETE TRIGGER LOGIC ---
let mut trigger_search = false;
if app_state.ui.show_form {
// Manual trigger
if let Some("trigger_autocomplete") =
config.get_edit_action_for_key(key.code, key.modifiers)
{
if !form_state.autocomplete_active {
trigger_search = true;
}
}
// Live search trigger while typing
else if form_state.autocomplete_active {
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
let action = if let KeyCode::Backspace = key.code {
"delete_char_backward"
} else {
"insert_char"
};
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
trigger_search = true;
}
}
}
if trigger_search {
trigger_form_autocomplete_search(
form_state,
&mut event_handler.grpc_client,
event_handler.autocomplete_result_sender.clone(),
)
.await;
return Ok(EditEventOutcome::Message("Searching...".to_string()));
}
// --- GENERAL EDIT MODE EVENT HANDLING (IF NOT AUTOCOMPLETE) ---
if let Some(action_str) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
// Handle Enter key (next field)
if action_str == "enter_decider" {
// FIXED: Use canvas library instead of form_e::execute_edit_action
let msg = execute_canvas_action(
"next_field",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
}
// Handle exiting edit mode
if action_str == "exit" {
return Ok(EditEventOutcome::ExitEditMode);
}
// Handle all other edit actions - NOW USING CANVAS LIBRARY
let msg = if app_state.ui.show_login {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action_str,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
if let KeyCode::Char(_) = key.code {
let msg = if app_state.ui.show_login {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
"insert_char",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
Ok(EditEventOutcome::Message(String::new())) // No action taken
}

View File

@@ -1,313 +0,0 @@
// src/modes/canvas/read_only.rs
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::form::FormState;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::add_table::AddTableState;
use crate::state::app::state::AppState;
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
use crossterm::event::KeyEvent;
use anyhow::Result;
/// Helper function to dispatch canvas action for any CanvasState
async fn dispatch_canvas_action<S: CanvasState>(
action: &str,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> String {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => msg.unwrap_or_default(),
Ok(ActionResult::HandledByFeature(msg)) => msg,
Ok(ActionResult::Error(msg)) => format!("Error: {}", msg),
Ok(ActionResult::RequiresContext(msg)) => format!("Context needed: {}", msg),
Err(e) => format!("Action failed: {}", e),
}
}
/// Helper function to dispatch canvas action to the appropriate state based on UI
async fn dispatch_to_active_state(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> String {
if app_state.ui.show_add_table {
dispatch_canvas_action(action, add_table_state, ideal_cursor_column).await
} else if app_state.ui.show_add_logic {
dispatch_canvas_action(action, add_logic_state, ideal_cursor_column).await
} else if app_state.ui.show_register {
dispatch_canvas_action(action, register_state, ideal_cursor_column).await
} else if app_state.ui.show_login {
dispatch_canvas_action(action, login_state, ideal_cursor_column).await
} else {
dispatch_canvas_action(action, form_state, ideal_cursor_column).await
}
}
/// Helper function to handle context-specific actions that need special treatment
async fn handle_context_action(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
ideal_cursor_column: &mut usize,
) -> Result<Option<String>> {
const CONTEXT_ACTIONS_FORM: &[&str] = &[
"previous_entry",
"next_entry",
];
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
"previous_entry",
"next_entry",
];
if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
Ok(Some(crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client,
ideal_cursor_column,
).await?))
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
Ok(Some(crate::tui::functions::login::handle_action(action).await?))
} else {
Ok(None) // Not a context action, use regular canvas dispatch
}
}
pub async fn handle_form_readonly_with_canvas(
key_event: KeyEvent,
config: &Config,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try canvas action from key first
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// Fall through to try config mapping
}
}
}
// Try config-mapped action
if let Some(action_str) = config.get_read_only_action_for_key(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
}
Ok(String::new())
}
pub async fn handle_read_only_event(
app_state: &mut AppState,
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker,
grpc_client: &mut GrpcClient,
command_message: &mut String,
edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize,
) -> Result<(bool, String)> {
if config.is_enter_edit_mode_before(key.code, key.modifiers) {
*edit_mode_cooldown = true;
*command_message = "Entering Edit mode".to_string();
return Ok((false, command_message.clone()));
}
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
// Determine target state to adjust cursor - all states now use CanvasState trait
if app_state.ui.show_login {
let current_input = login_state.get_current_input();
let current_pos = login_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
login_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = login_state.current_cursor_pos();
}
} else if app_state.ui.show_add_logic {
let current_input = add_logic_state.get_current_input();
let current_pos = add_logic_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
add_logic_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = add_logic_state.current_cursor_pos();
}
} else if app_state.ui.show_register {
let current_input = register_state.get_current_input();
let current_pos = register_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
register_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = register_state.current_cursor_pos();
}
} else if app_state.ui.show_add_table {
let current_input = add_table_state.get_current_input();
let current_pos = add_table_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
add_table_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = add_table_state.current_cursor_pos();
}
} else {
// Handle FormState
let current_input = form_state.get_current_input();
let current_pos = form_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
form_state.set_current_cursor_pos(current_pos + 1);
*ideal_cursor_column = form_state.current_cursor_pos();
}
}
*edit_mode_cooldown = true;
*command_message = "Entering Edit mode (after cursor)".to_string();
return Ok((false, command_message.clone()));
}
if key.modifiers.is_empty() {
key_sequence_tracker.add_key(key.code);
let sequence = key_sequence_tracker.get_sequence();
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
login_state,
register_state,
add_table_state,
add_logic_state,
ideal_cursor_column,
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
}
if config.is_key_sequence_prefix(&sequence) {
return Ok((false, command_message.clone()));
}
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
login_state,
register_state,
add_table_state,
add_logic_state,
ideal_cursor_column,
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
}
}
key_sequence_tracker.reset();
} else {
key_sequence_tracker.reset();
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
login_state,
register_state,
add_table_state,
add_logic_state,
ideal_cursor_column,
).await
};
return Ok((false, result));
}
}
if !*edit_mode_cooldown {
let default_key = "i".to_string();
let edit_key = config
.keybindings
.read_only
.get("enter_edit_mode_before")
.and_then(|keys| keys.first())
.map(|k| k.to_string())
.unwrap_or(default_key);
*command_message = format!("Read-only mode - press {} to edit", edit_key);
}
*edit_mode_cooldown = false;
Ok((false, command_message.clone()))
}

View File

@@ -3,22 +3,19 @@
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::config::binds::config::Config;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::state::{app::state::AppState, pages::auth::LoginState, pages::auth::RegisterState};
use crate::state::app::state::AppState;
use crate::modes::common::commands::CommandHandler;
use crate::tui::terminal::core::TerminalCore;
use crate::tui::functions::common::form::{save, revert};
use crate::pages::forms::logic::{save, revert ,SaveOutcome};
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::form::SaveOutcome;
use crate::pages::routing::{Router, Page};
use anyhow::Result;
pub async fn handle_command_event(
key: KeyEvent,
config: &Config,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &mut FormState,
router: &mut Router,
command_input: &mut String,
command_message: &mut String,
grpc_client: &mut GrpcClient,
@@ -27,21 +24,19 @@ pub async fn handle_command_event(
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome> {
// Exit command mode (via configurable keybinding)
// Exit command mode
if config.is_exit_command_mode(key.code, key.modifiers) {
command_input.clear();
*command_message = "".to_string();
return Ok(EventOutcome::Ok("Exited command mode".to_string()));
}
// Execute command (via configurable keybinding, defaults to Enter)
// Execute command
if config.is_command_execute(key.code, key.modifiers) {
return process_command(
config,
form_state,
app_state,
login_state,
register_state,
router,
command_input,
command_message,
grpc_client,
@@ -49,34 +44,31 @@ pub async fn handle_command_event(
terminal,
current_position,
total_count,
).await;
)
.await;
}
// Backspace (via configurable keybinding, defaults to Backspace)
// Backspace
if config.is_command_backspace(key.code, key.modifiers) {
command_input.pop();
return Ok(EventOutcome::Ok("".to_string()));
}
// Regular character input - accept any character in command mode
// Regular character input
if let KeyCode::Char(c) = key.code {
// Accept regular or shifted characters (e.g., 'a' or 'A')
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
command_input.push(c);
return Ok(EventOutcome::Ok("".to_string()));
}
}
// Ignore all other keys
Ok(EventOutcome::Ok("".to_string()))
}
async fn process_command(
config: &Config,
form_state: &mut FormState,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
router: &mut Router,
command_input: &mut String,
command_message: &mut String,
grpc_client: &mut GrpcClient,
@@ -85,28 +77,18 @@ async fn process_command(
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome> {
// Clone the trimmed command to avoid borrow issues
let command = command_input.trim().to_string();
if command.is_empty() {
*command_message = "Empty command".to_string();
return Ok(EventOutcome::Ok(command_message.clone()));
}
// Get the action for the command (now checks global and common bindings too)
let action = config.get_action_for_command(&command)
.unwrap_or("unknown");
let action = config.get_action_for_command(&command).unwrap_or("unknown");
match action {
"force_quit" | "save_and_quit" | "quit" => {
let (should_exit, message) = command_handler
.handle_command(
action,
terminal,
app_state,
form_state,
login_state,
register_state,
)
.handle_command(action, terminal, app_state, router)
.await?;
command_input.clear();
if should_exit {
@@ -114,29 +96,30 @@ async fn process_command(
} else {
Ok(EventOutcome::Ok(message))
}
},
}
"save" => {
let outcome = save(
app_state,
form_state,
grpc_client,
).await?;
let message = match outcome {
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
SaveOutcome::NoChange => "No changes to save".to_string(),
};
command_input.clear();
Ok(EventOutcome::DataSaved(outcome, message))
},
if let Page::Form(path) = &router.current {
let outcome = save(app_state, path, grpc_client).await?;
let message = match outcome {
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
SaveOutcome::NoChange => "No changes to save".to_string(),
};
command_input.clear();
Ok(EventOutcome::DataSaved(outcome, message))
} else {
Ok(EventOutcome::Ok("Not in a form page".to_string()))
}
}
"revert" => {
let message = revert(
form_state,
grpc_client,
).await?;
command_input.clear();
Ok(EventOutcome::Ok(message))
},
if let Page::Form(path) = &router.current {
let message = revert(app_state, path, grpc_client).await?;
command_input.clear();
Ok(EventOutcome::Ok(message))
} else {
Ok(EventOutcome::Ok("Not in a form page".to_string()))
}
}
_ => {
let message = format!("Unhandled action: {}", action);
command_input.clear();

View File

@@ -1,8 +1,7 @@
// src/modes/common/commands.rs
use crate::tui::terminal::core::TerminalCore;
use crate::state::app::state::AppState;
use crate::state::pages::{form::FormState, auth::LoginState, auth::RegisterState};
use canvas::canvas::CanvasState;
use crate::pages::routing::{Router, Page};
use anyhow::Result;
pub struct CommandHandler;
@@ -16,13 +15,11 @@ impl CommandHandler {
&mut self,
action: &str,
terminal: &mut TerminalCore,
app_state: &AppState,
form_state: &FormState,
login_state: &LoginState,
register_state: &RegisterState,
app_state: &mut AppState,
router: &Router,
) -> Result<(bool, String)> {
match action {
"quit" => self.handle_quit(terminal, app_state, form_state, login_state, register_state).await,
"quit" => self.handle_quit(terminal, app_state, router).await,
"force_quit" => self.handle_force_quit(terminal).await,
"save_and_quit" => self.handle_save_quit(terminal).await,
_ => Ok((false, format!("Unknown command: {}", action))),
@@ -32,18 +29,18 @@ impl CommandHandler {
async fn handle_quit(
&self,
terminal: &mut TerminalCore,
app_state: &AppState,
form_state: &FormState,
login_state: &LoginState,
register_state: &RegisterState,
app_state: &mut AppState,
router: &Router,
) -> Result<(bool, String)> {
// Use actual unsaved changes state instead of is_saved flag
let has_unsaved = if app_state.ui.show_login {
login_state.has_unsaved_changes()
} else if app_state.ui.show_register {
register_state.has_unsaved_changes()
} else {
form_state.has_unsaved_changes
// Use router to check unsaved changes
let has_unsaved = match &router.current {
Page::Login(page) => page.state.has_unsaved_changes(),
Page::Register(state) => state.has_unsaved_changes(),
Page::Form(path) => app_state
.form_state_for_path_ref(path)
.map(|fs| fs.has_unsaved_changes())
.unwrap_or(false),
_ => false,
};
if !has_unsaved {

View File

@@ -1,4 +1,3 @@
// src/client/modes/general.rs
pub mod navigation;
pub mod dialog;
pub mod command_navigation;

View File

@@ -82,8 +82,6 @@ impl TableDependencyGraph {
}
}
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
pub struct NavigationState {
pub active: bool,
pub input: String,

View File

@@ -1,163 +0,0 @@
// src/modes/general/dialog.rs
use crossterm::event::{Event, KeyCode};
use crate::config::binds::config::Config;
use crate::ui::handlers::context::DialogPurpose;
use crate::state::app::{state::AppState, buffer::AppView};
use crate::state::app::buffer::BufferState;
use crate::state::pages::auth::{LoginState, RegisterState};
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome;
use crate::tui::functions::common::{login, register};
use crate::tui::functions::common::add_table::handle_delete_selected_columns;
use anyhow::Result;
/// Handles key events specifically when a dialog is active.
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
/// otherwise returns None.
pub async fn handle_dialog_event(
event: &Event,
config: &Config,
app_state: &mut AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
buffer_state: &mut BufferState,
admin_state: &mut AdminState,
) -> Option<Result<EventOutcome>> {
if let Event::Key(key) = event {
// Always allow Esc to dismiss
if key.code == KeyCode::Esc {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
}
// Check general bindings for dialog actions
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_down" | "next_option" => {
let current_index = app_state.ui.dialog.dialog_active_button_index;
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
if num_buttons > 0 && current_index < num_buttons - 1 {
app_state.ui.dialog.dialog_active_button_index += 1;
}
return Some(Ok(EventOutcome::Ok(String::new())));
}
"move_up" | "previous_option" => {
let current_index = app_state.ui.dialog.dialog_active_button_index;
if current_index > 0 {
app_state.ui.dialog.dialog_active_button_index -= 1;
}
return Some(Ok(EventOutcome::Ok(String::new())));
}
"select" => {
let selected_index = app_state.ui.dialog.dialog_active_button_index;
let purpose = match app_state.ui.dialog.purpose {
Some(p) => p,
None => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Internal Error: Dialog context lost".to_string())));
}
};
// Handle Dialog Actions Directly Here
match purpose {
DialogPurpose::LoginSuccess => {
match selected_index {
0 => { // "Menu" button selected
app_state.hide_dialog();
let message = login::back_to_main(login_state, app_state, buffer_state).await;
return Some(Ok(EventOutcome::Ok(message)));
}
1 => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::LoginFailed => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Login failed dialog dismissed".to_string())));
}
_ => {
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::RegisterSuccess => { // Add this arm
match selected_index {
0 => { // "OK" button for RegisterSuccess
app_state.hide_dialog();
let message = register::back_to_login(register_state, app_state, buffer_state).await;
return Some(Ok(EventOutcome::Ok(message)));
}
_ => { // Default for RegisterSuccess
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::RegisterFailed => { // Add this arm
match selected_index {
0 => { // "OK" button for RegisterFailed
app_state.hide_dialog(); // Just dismiss
return Some(Ok(EventOutcome::Ok("Register failed dialog dismissed".to_string())));
}
_ => { // Default for RegisterFailed
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Unknown dialog button selected".to_string())));
}
}
}
DialogPurpose::ConfirmDeleteColumns => {
match selected_index {
0 => { // "Confirm" button selected
let outcome_message = handle_delete_selected_columns(&mut admin_state.add_table_state);
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok(outcome_message)));
}
1 => { // "Cancel" button selected
app_state.hide_dialog();
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
DialogPurpose::SaveTableSuccess => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin); // Navigate back
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
DialogPurpose::SaveLogicSuccess => {
match selected_index {
0 => { // "OK" button selected
app_state.hide_dialog();
buffer_state.update_history(AppView::Admin);
return Some(Ok(EventOutcome::Ok("Save success dialog dismissed.".to_string())));
}
_ => { /* Handle unexpected index */ }
}
}
}
}
_ => {} // Ignore other general actions when dialog is shown
}
}
// If it was a key event but not handled above, consume it
Some(Ok(EventOutcome::Ok(String::new())))
} else {
// If it wasn't a key event, consume it too while dialog is active
Some(Ok(EventOutcome::Ok(String::new())))
}
}

View File

@@ -3,26 +3,19 @@
use crossterm::event::KeyEvent;
use crate::config::binds::config::Config;
use crate::state::app::state::AppState;
use crate::state::pages::form::FormState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::intro::IntroState;
use crate::state::pages::admin::AdminState;
use crate::pages::routing::{Router, Page};
use crate::pages::forms::FormState;
use crate::ui::handlers::context::UiContext;
use crate::modes::handlers::event::EventOutcome;
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
use canvas::canvas::CanvasState;
use canvas::DataProvider;
use anyhow::Result;
pub async fn handle_navigation_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
app_state: &mut AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
intro_state: &mut IntroState,
admin_state: &mut AdminState,
router: &mut Router,
command_mode: &mut bool,
command_input: &mut String,
command_message: &mut String,
@@ -35,28 +28,36 @@ pub async fn handle_navigation_event(
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {
move_up(app_state, login_state, register_state, intro_state, admin_state);
"up" => {
up(app_state, router);
return Ok(EventOutcome::Ok(String::new()));
}
"move_down" => {
move_down(app_state, intro_state, admin_state);
"down" => {
down(app_state, router);
return Ok(EventOutcome::Ok(String::new()));
}
"next_option" => {
next_option(app_state, intro_state);
next_option(app_state, router);
return Ok(EventOutcome::Ok(String::new()));
}
"previous_option" => {
previous_option(app_state, intro_state);
previous_option(app_state, router);
return Ok(EventOutcome::Ok(String::new()));
}
"next_field" => {
next_field(form_state);
if let Page::Form(path) = &router.current {
if let Some(fs) = app_state.form_state_for_path(path) {
next_field(fs);
}
}
return Ok(EventOutcome::Ok(String::new()));
}
"prev_field" => {
prev_field(form_state);
if let Page::Form(path) = &router.current {
if let Some(fs) = app_state.form_state_for_path(path) {
prev_field(fs);
}
}
return Ok(EventOutcome::Ok(String::new()));
}
"enter_command_mode" => {
@@ -64,18 +65,21 @@ pub async fn handle_navigation_event(
return Ok(EventOutcome::Ok(String::new()));
}
"select" => {
let (context, index) = if app_state.ui.show_intro {
(UiContext::Intro, intro_state.selected_option)
} else if app_state.ui.show_login && app_state.ui.focus_outside_canvas {
(UiContext::Login, app_state.focused_button_index)
} else if app_state.ui.show_register && app_state.ui.focus_outside_canvas {
(UiContext::Register, app_state.focused_button_index)
} else if app_state.ui.show_admin {
(UiContext::Admin, admin_state.get_selected_index().unwrap_or(0))
} else if app_state.ui.dialog.dialog_show {
(UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index)
} else {
return Ok(EventOutcome::Ok("Select (No Action)".to_string()));
let (context, index) = match &router.current {
Page::Intro(state) => (UiContext::Intro, state.selected_option),
Page::Login(_) if app_state.ui.focus_outside_canvas => {
(UiContext::Login, app_state.focused_button_index)
}
Page::Register(_) if app_state.ui.focus_outside_canvas => {
(UiContext::Register, app_state.focused_button_index)
}
Page::Admin(state) => {
(UiContext::Admin, state.get_selected_index().unwrap_or(0))
}
_ if app_state.ui.dialog.dialog_show => {
(UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index)
}
_ => return Ok(EventOutcome::Ok("Select (No Action)".to_string())),
};
return Ok(EventOutcome::ButtonSelected { context, index });
}
@@ -85,60 +89,76 @@ pub async fn handle_navigation_event(
Ok(EventOutcome::Ok(String::new()))
}
pub fn move_up(app_state: &mut AppState, login_state: &mut LoginState, register_state: &mut RegisterState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register{
if app_state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false;
if app_state.ui.show_login {
let last_field_index = login_state.fields().len().saturating_sub(1);
login_state.set_current_field(last_field_index);
pub fn up(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Login(page) if app_state.ui.focus_outside_canvas => {
if app_state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false;
let last_field_index = page.state.field_count().saturating_sub(1);
page.state.set_current_field(last_field_index);
} else {
let last_field_index = register_state.fields().len().saturating_sub(1);
register_state.set_current_field(last_field_index);
app_state.focused_button_index =
app_state.focused_button_index.saturating_sub(1);
}
} else {
app_state.focused_button_index = app_state.focused_button_index.saturating_sub(1);
}
} else if app_state.ui.show_intro {
intro_state.previous_option();
} else if app_state.ui.show_admin {
admin_state.previous();
}
}
pub fn move_down(app_state: &mut AppState, intro_state: &mut IntroState, admin_state: &mut AdminState) {
if app_state.ui.focus_outside_canvas && app_state.ui.show_login || app_state.ui.show_register {
let num_general_elements = 2;
if app_state.focused_button_index < num_general_elements - 1 {
app_state.focused_button_index += 1;
Page::Register(state) if app_state.ui.focus_outside_canvas => {
if app_state.focused_button_index == 0 {
app_state.ui.focus_outside_canvas = false;
let last_field_index = state.state.field_count().saturating_sub(1);
state.set_current_field(last_field_index);
} else {
app_state.focused_button_index =
app_state.focused_button_index.saturating_sub(1);
}
}
} else if app_state.ui.show_intro {
intro_state.next_option();
} else if app_state.ui.show_admin {
admin_state.next();
Page::Intro(state) => state.previous_option(),
Page::Admin(state) => state.previous(),
_ => {}
}
}
pub fn next_option(app_state: &mut AppState, intro_state: &mut IntroState) {
if app_state.ui.show_intro {
intro_state.next_option();
} else {
// Get option count from state instead of parameter
let option_count = app_state.profile_tree.profiles.len();
app_state.focused_button_index = (app_state.focused_button_index + 1) % option_count;
pub fn down(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Login(_) | Page::Register(_) if app_state.ui.focus_outside_canvas => {
let num_general_elements = 2;
if app_state.focused_button_index < num_general_elements - 1 {
app_state.focused_button_index += 1;
}
}
Page::Intro(state) => state.next_option(),
Page::Admin(state) => state.next(),
_ => {}
}
}
pub fn previous_option(app_state: &mut AppState, intro_state: &mut IntroState) {
if app_state.ui.show_intro {
intro_state.previous_option();
} else {
let option_count = app_state.profile_tree.profiles.len();
app_state.focused_button_index = if app_state.focused_button_index == 0 {
option_count.saturating_sub(1)
} else {
app_state.focused_button_index - 1
};
pub fn next_option(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Intro(state) => state.next_option(),
Page::Admin(_) => {
let option_count = app_state.profile_tree.profiles.len();
if option_count > 0 {
app_state.focused_button_index =
(app_state.focused_button_index + 1) % option_count;
}
}
_ => {}
}
}
pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
match &mut router.current {
Page::Intro(state) => state.previous_option(),
Page::Admin(_) => {
let option_count = app_state.profile_tree.profiles.len();
if option_count > 0 {
app_state.focused_button_index = if app_state.focused_button_index == 0 {
option_count.saturating_sub(1)
} else {
app_state.focused_button_index - 1
};
}
}
_ => {}
}
}
@@ -161,7 +181,7 @@ pub fn prev_field(form_state: &mut FormState) {
pub fn handle_enter_command_mode(
command_mode: &mut bool,
command_input: &mut String,
command_message: &mut String
command_message: &mut String,
) {
*command_mode = true;
command_input.clear();

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,59 @@
// src/modes/handlers/mode_manager.rs
use crate::state::app::state::AppState;
use crate::modes::handlers::event::EventHandler;
use crate::state::pages::add_logic::AddLogicFocus;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::admin::AdminState;
use crate::pages::routing::{Router, Page};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
General, // For intro and admin screens
ReadOnly, // Canvas read-only mode
Edit, // Canvas edit mode
Highlight, // Cnavas highlight/visual mode
Command, // Command mode overlay
/// General mode = when focus is outside any canvas
/// (Intro, Admin, Login/Register buttons, AddTable/AddLogic menus, dialogs, etc.)
General,
/// Command overlay (":" or "ctrl+;"), available globally
Command,
}
pub struct ModeManager;
impl ModeManager {
// Determine current mode based on app state
/// Determine current mode:
/// - If navigation palette is active → General
/// - If command overlay is active → Command
/// - If focus is inside a canvas (Form, Login, Register, AddTable, AddLogic) → let canvas handle its own mode
/// - Otherwise → General
pub fn derive_mode(
app_state: &AppState,
event_handler: &EventHandler,
admin_state: &AdminState,
router: &Router,
) -> AppMode {
// Navigation palette always forces General
if event_handler.navigation_state.active {
return AppMode::General;
}
// Explicit command overlay flag
if event_handler.command_mode {
return AppMode::Command;
}
if !matches!(event_handler.highlight_state, HighlightState::Off) {
return AppMode::Highlight;
}
let is_canvas_view = app_state.ui.show_login
|| app_state.ui.show_register
|| app_state.ui.show_form
|| app_state.ui.show_add_table
|| app_state.ui.show_add_logic;
if app_state.ui.show_add_logic {
// Specific logic for AddLogic view
match admin_state.add_logic_state.current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
// These are canvas inputs
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
_ => AppMode::General,
}
} else if app_state.ui.show_add_table {
if app_state.ui.focus_outside_canvas {
// If focus is inside a canvas, we don't duplicate canvas modes here.
// Canvas crate owns ReadOnly/Edit/Highlight internally.
match &router.current {
Page::Form(_)
| Page::Login(_)
| Page::Register(_)
| Page::AddTable(_)
| Page::AddLogic(_) if !app_state.ui.focus_outside_canvas => {
// Canvas active → let canvas handle its own AppMode
AppMode::General
} else {
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
} else if is_canvas_view {
if app_state.ui.focus_outside_canvas {
AppMode::General
} else {
if event_handler.is_edit_mode {
AppMode::Edit
} else {
AppMode::ReadOnly
}
}
} else {
AppMode::General
_ => AppMode::General,
}
}
// Mode transition rules
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit)
}
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
}
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
/// Command overlay can be entered from anywhere (General or Canvas).
pub fn can_enter_command_mode(_current_mode: AppMode) -> bool {
true
}
}

View File

@@ -1,2 +0,0 @@
// src/client/modes/highlight.rs
pub mod highlight;

View File

@@ -1,65 +0,0 @@
// src/modes/highlight/highlight.rs
// (This file is intentionally simple for now, reusing ReadOnly logic)
use crate::config::binds::config::Config;
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::state::pages::auth::{LoginState, RegisterState};
use crate::state::pages::admin::AdminState;
use crate::state::pages::form::FormState;
use crate::modes::handlers::event::EventOutcome;
use crate::modes::read_only;
use crossterm::event::KeyEvent;
use anyhow::Result;
/// Handles events when in Highlight mode.
/// Currently, it mostly delegates to the read_only handler for movement.
/// Exiting highlight mode is handled directly in the main event handler.
pub async fn handle_highlight_event(
app_state: &mut AppState,
key: KeyEvent,
config: &Config,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
command_message: &mut String,
edit_mode_cooldown: &mut bool,
ideal_cursor_column: &mut usize,
) -> Result<EventOutcome> {
// Delegate movement and other actions to the read_only handler
// The rendering logic will use the highlight_anchor to draw the selection
let (should_exit, message) = read_only::handle_read_only_event(
app_state,
key,
config,
form_state,
login_state,
register_state,
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
key_sequence_tracker,
grpc_client,
command_message, // Pass the message buffer
edit_mode_cooldown,
ideal_cursor_column,
)
.await?;
// ReadOnly handler doesn't return EventOutcome directly, adapt if needed
// For now, assume Ok outcome unless ReadOnly signals an exit (which we ignore here)
if should_exit {
// This exit is likely for the whole app, let the main loop handle it
// We just return the message from read_only
Ok(EventOutcome::Ok(message))
} else {
Ok(EventOutcome::Ok(message))
}
}

View File

@@ -1,11 +1,9 @@
// src/client/modes/mod.rs
pub mod handlers;
pub mod canvas;
pub mod general;
pub mod common;
pub mod highlight;
pub mod canvas;
pub use handlers::*;
pub use canvas::*;
pub use general::*;
pub use common::*;

View File

@@ -0,0 +1,12 @@
// src/movement/actions.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MovementAction {
Next,
Previous,
Up,
Down,
Left,
Right,
Select,
Esc,
}

View File

@@ -0,0 +1,32 @@
// src/movement/lib.rs
use crate::movement::MovementAction;
#[inline]
pub fn move_focus<T: Copy + Eq>(
order: &[T],
current: &mut T,
action: MovementAction,
) -> bool {
if order.is_empty() {
return false;
}
if let Some(pos) = order.iter().position(|k| *k == *current) {
match action {
MovementAction::Previous | MovementAction::Up | MovementAction::Left => {
if pos > 0 {
*current = order[pos - 1];
return true;
}
}
MovementAction::Next | MovementAction::Down | MovementAction::Right => {
if pos + 1 < order.len() {
*current = order[pos + 1];
return true;
}
}
_ => {}
}
}
false
}

View File

@@ -0,0 +1,6 @@
// src/movement/mod.rs
pub mod actions;
pub mod lib;
pub use actions::MovementAction;
pub use lib::move_focus;

View File

@@ -0,0 +1,60 @@
// src/pages/admin/admin/event.rs
use anyhow::Result;
use crossterm::event::KeyEvent;
use crate::buffer::state::BufferState;
use crate::config::binds::config::Config;
use crate::pages::admin::AdminState;
use crate::pages::admin::main::logic::handle_admin_navigation;
use crate::state::app::state::AppState;
/// Handle all Admin page-specific key events (movement + actions).
/// Returns true if the key was handled (so the caller should stop propagation).
pub fn handle_admin_event(
key_event: KeyEvent,
config: &Config,
app_state: &mut AppState,
admin_state: &mut AdminState,
buffer_state: &mut BufferState,
command_message: &mut String,
) -> Result<bool> {
// 1) Map general action to MovementAction (same mapping used in event.rs)
let movement_action = if let Some(act) =
config.get_general_action(key_event.code, key_event.modifiers)
{
use crate::movement::MovementAction;
match act {
"up" => Some(MovementAction::Up),
"down" => Some(MovementAction::Down),
"left" => Some(MovementAction::Left),
"right" => Some(MovementAction::Right),
"next" => Some(MovementAction::Next),
"previous" => Some(MovementAction::Previous),
"select" => Some(MovementAction::Select),
"esc" => Some(MovementAction::Esc),
_ => None,
}
} else {
None
};
if let Some(ma) = movement_action {
if admin_state.handle_movement(app_state, ma) {
return Ok(true);
}
}
// 2) Rich Admin navigation (buttons, selections, etc.)
if handle_admin_navigation(
key_event,
config,
app_state,
admin_state,
buffer_state,
command_message,
) {
return Ok(true);
}
Ok(false)
}

View File

@@ -0,0 +1,54 @@
// src/pages/admin/admin/loader.rs
use anyhow::{Context, Result};
use crate::pages::admin::{AdminFocus, AdminState};
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
/// Refresh admin data and ensure focus and selections are valid.
pub async fn refresh_admin_state(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
admin_state: &mut AdminState,
) -> Result<()> {
// Fetch latest profile tree
let refreshed_tree = grpc_client
.get_profile_tree()
.await
.context("Failed to refresh profile tree for Admin panel")?;
app_state.profile_tree = refreshed_tree;
// Populate profile names for AdminState's list
let profile_names = app_state
.profile_tree
.profiles
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>();
admin_state.set_profiles(profile_names);
// Ensure a sane focus
if admin_state.current_focus == AdminFocus::default()
|| !matches!(
admin_state.current_focus,
AdminFocus::InsideProfilesList
| AdminFocus::Tables
| AdminFocus::InsideTablesList
| AdminFocus::Button1
| AdminFocus::Button2
| AdminFocus::Button3
| AdminFocus::ProfilesPane
)
{
admin_state.current_focus = AdminFocus::ProfilesPane;
}
// Ensure a selection exists when profiles are present
if admin_state.profile_list_state.selected().is_none()
&& !app_state.profile_tree.profiles.is_empty()
{
admin_state.profile_list_state.select(Some(0));
}
Ok(())
}

View File

@@ -0,0 +1,9 @@
// src/pages/admin/admin/mod.rs
pub mod state;
pub mod ui;
pub mod tui;
pub mod event;
pub mod loader;
pub use state::{AdminState, AdminFocus};

View File

@@ -0,0 +1,195 @@
// src/pages/admin/admin/state.rs
use ratatui::widgets::ListState;
use crate::pages::admin_panel::add_table::state::AddTableState;
use crate::pages::admin_panel::add_logic::state::AddLogicState;
use crate::movement::{move_focus, MovementAction};
use crate::state::app::state::AppState;
/// Focus states for the admin panel
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AdminFocus {
#[default]
ProfilesPane,
InsideProfilesList,
Tables,
InsideTablesList,
Button1,
Button2,
Button3,
}
/// Full admin panel state (for logged-in admins)
#[derive(Default, Clone, Debug)]
pub struct AdminState {
pub profiles: Vec<String>,
pub profile_list_state: ListState,
pub table_list_state: ListState,
pub selected_profile_index: Option<usize>,
pub selected_table_index: Option<usize>,
pub current_focus: AdminFocus,
pub add_table_state: AddTableState,
pub add_logic_state: AddLogicState,
}
impl AdminState {
pub fn get_selected_index(&self) -> Option<usize> {
self.profile_list_state.selected()
}
pub fn get_selected_profile_name(&self) -> Option<&String> {
self.profile_list_state.selected().and_then(|i| self.profiles.get(i))
}
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
let current_selection_index = self.profile_list_state.selected();
self.profiles = new_profiles;
if self.profiles.is_empty() {
self.profile_list_state.select(None);
} else {
let new_selection = match current_selection_index {
Some(index) => Some(index.min(self.profiles.len() - 1)),
None => Some(0),
};
self.profile_list_state.select(new_selection);
}
}
pub fn next(&mut self) {
if self.profiles.is_empty() {
self.profile_list_state.select(None);
return;
}
let i = match self.profile_list_state.selected() {
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
None => 0,
};
self.profile_list_state.select(Some(i));
}
pub fn previous(&mut self) {
if self.profiles.is_empty() {
self.profile_list_state.select(None);
return;
}
let i = match self.profile_list_state.selected() {
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
None => self.profiles.len() - 1,
};
self.profile_list_state.select(Some(i));
}
pub fn handle_movement(
&mut self,
app: &AppState,
action: MovementAction,
) -> bool {
use AdminFocus::*;
const ORDER: [AdminFocus; 5] = [
ProfilesPane,
Tables,
Button1,
Button2,
Button3,
];
match (self.current_focus, action) {
(ProfilesPane, MovementAction::Select) => {
if !app.profile_tree.profiles.is_empty()
&& self.profile_list_state.selected().is_none()
{
self.profile_list_state.select(Some(0));
}
self.current_focus = InsideProfilesList;
return true;
}
(Tables, MovementAction::Select) => {
let p_idx = self
.selected_profile_index
.or_else(|| self.profile_list_state.selected());
if let Some(pi) = p_idx {
let len = app
.profile_tree
.profiles
.get(pi)
.map(|p| p.tables.len())
.unwrap_or(0);
if len > 0 && self.table_list_state.selected().is_none() {
self.table_list_state.select(Some(0));
}
}
self.current_focus = InsideTablesList;
return true;
}
_ => {}
}
match self.current_focus {
InsideProfilesList => match action {
MovementAction::Up => {
if !app.profile_tree.profiles.is_empty() {
let curr = self.profile_list_state.selected().unwrap_or(0);
let next = curr.saturating_sub(1);
self.profile_list_state.select(Some(next));
}
true
}
MovementAction::Down => {
let len = app.profile_tree.profiles.len();
if len > 0 {
let curr = self.profile_list_state.selected().unwrap_or(0);
let next = if curr + 1 < len { curr + 1 } else { curr };
self.profile_list_state.select(Some(next));
}
true
}
MovementAction::Esc => {
self.current_focus = ProfilesPane;
true
}
MovementAction::Next | MovementAction::Previous => true,
MovementAction::Select => false,
_ => false,
},
InsideTablesList => {
let tables_len = {
let p_idx = self
.selected_profile_index
.or_else(|| self.profile_list_state.selected());
p_idx.and_then(|pi| app.profile_tree.profiles.get(pi))
.map(|p| p.tables.len())
.unwrap_or(0)
};
match action {
MovementAction::Up => {
if tables_len > 0 {
let curr = self.table_list_state.selected().unwrap_or(0);
let next = curr.saturating_sub(1);
self.table_list_state.select(Some(next));
}
true
}
MovementAction::Down => {
if tables_len > 0 {
let curr = self.table_list_state.selected().unwrap_or(0);
let next = if curr + 1 < tables_len { curr + 1 } else { curr };
self.table_list_state.select(Some(next));
}
true
}
MovementAction::Esc => {
self.current_focus = Tables;
true
}
MovementAction::Next | MovementAction::Previous => true,
MovementAction::Select => false,
_ => false,
}
}
_ => {
move_focus(&ORDER, &mut self.current_focus, action)
}
}
}
}

View File

@@ -1,5 +1,6 @@
// src/pages/admin/admin/tui.rs
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::pages::admin::AdminState;
pub fn handle_admin_selection(app_state: &mut AppState, admin_state: &AdminState) {
let profiles = &app_state.profile_tree.profiles;

View File

@@ -1,7 +1,7 @@
// src/components/admin/admin_panel_admin.rs
// src/pages/admin/admin/ui.rs
use crate::config::colors::themes::Theme;
use crate::state::pages::admin::{AdminFocus, AdminState};
use crate::pages::admin::{AdminFocus, AdminState};
use crate::state::app::state::AppState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},

View File

@@ -1,11 +1,11 @@
// src/functions/modes/navigation/admin_nav.rs
use crate::state::pages::admin::{AdminFocus, AdminState};
// src/pages/admin/main/logic.rs
use crate::pages::admin::{AdminFocus, AdminState};
use crate::state::app::state::AppState;
use crate::config::binds::config::Config;
use crate::state::app::buffer::{BufferState, AppView};
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
use crate::buffer::state::{BufferState, AppView};
use crate::pages::admin_panel::add_table::state::{AddTableState, LinkDefinition};
use ratatui::widgets::ListState;
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
use crate::pages::admin_panel::add_logic::state::{AddLogicState, AddLogicFocus};
// Helper functions list_select_next and list_select_previous remain the same
fn list_select_next(list_state: &mut ListState, item_count: usize) {

View File

@@ -0,0 +1,7 @@
// src/pages/admin/main/mod.rs
pub mod state;
pub mod ui;
pub mod logic;
pub use state::NonAdminState;

View File

@@ -0,0 +1,55 @@
// src/pages/admin/main/state.rs
use ratatui::widgets::ListState;
/// State for non-admin users (simple profile browser)
#[derive(Default, Clone, Debug)]
pub struct NonAdminState {
pub profiles: Vec<String>, // profile names
pub profile_list_state: ListState, // highlight state
pub selected_profile_index: Option<usize>, // persistent selection
}
impl NonAdminState {
pub fn get_selected_index(&self) -> Option<usize> {
self.profile_list_state.selected()
}
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
let current_selection_index = self.profile_list_state.selected();
self.profiles = new_profiles;
if self.profiles.is_empty() {
self.profile_list_state.select(None);
} else {
let new_selection = match current_selection_index {
Some(index) => Some(index.min(self.profiles.len() - 1)),
None => Some(0),
};
self.profile_list_state.select(new_selection);
}
}
pub fn next(&mut self) {
if self.profiles.is_empty() {
self.profile_list_state.select(None);
return;
}
let i = match self.profile_list_state.selected() {
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
None => 0,
};
self.profile_list_state.select(Some(i));
}
pub fn previous(&mut self) {
if self.profiles.is_empty() {
self.profile_list_state.select(None);
return;
}
let i = match self.profile_list_state.selected() {
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
None => self.profiles.len() - 1,
};
self.profile_list_state.select(Some(i));
}
}

View File

@@ -1,9 +1,9 @@
// src/components/admin/admin_panel.rs
// src/pages/admin/main/ui.rs
use crate::config::colors::themes::Theme;
use crate::state::pages::auth::AuthState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::pages::admin::AdminState;
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
@@ -12,7 +12,8 @@ use ratatui::{
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
use super::admin_panel_admin::render_admin_panel_admin;
use crate::state::pages::auth::UserRole;
use crate::pages::admin::admin::ui::render_admin_panel_admin;
pub fn render_admin_panel(
f: &mut Frame,
@@ -44,30 +45,27 @@ pub fn render_admin_panel(
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[1]);
if auth_state.role.as_deref() != Some("admin") {
render_admin_panel_non_admin(
f,
admin_state,
&content_chunks,
theme,
profile_tree,
selected_profile,
);
} else {
render_admin_panel_admin(
f,
chunks[1],
app_state,
admin_state,
theme,
);
match auth_state.role {
Some(UserRole::Admin) => {
render_admin_panel_admin(f, chunks[1], app_state, admin_state, theme);
}
_ => {
render_admin_panel_non_admin(
f,
admin_state,
&content_chunks,
theme,
profile_tree,
selected_profile,
);
}
}
}
/// Renders the view for non-admin users (profile list and details).
fn render_admin_panel_non_admin(
f: &mut Frame,
admin_state: &AdminState,
admin_state: &mut AdminState,
content_chunks: &[Rect],
theme: &Theme,
profile_tree: &ProfileTreeResponse,
@@ -92,8 +90,7 @@ fn render_admin_panel_non_admin(
.block(Block::default().title("Profiles"))
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
let mut profile_list_state_clone = admin_state.profile_list_state.clone();
f.render_stateful_widget(list, content_chunks[0], &mut profile_list_state_clone);
f.render_stateful_widget(list, content_chunks[0], &mut admin_state.profile_list_state);
// Profile details - Use selection info from admin_state
if let Some(profile) = admin_state

View File

@@ -0,0 +1,7 @@
// src/pages/admin/mod.rs
pub mod main; // non-admin
pub mod admin; // full admin panel
pub use main::NonAdminState;
pub use admin::{AdminState, AdminFocus};

View File

@@ -0,0 +1,5 @@
// src/pages/admin_panel/add_logic/mod.rs
pub mod ui;
pub mod nav;
pub mod state;

View File

@@ -0,0 +1,531 @@
// src/pages/admin_panel/add_logic/nav.rs
use crate::config::binds::config::{Config, EditorKeybindingMode};
use crate::state::app::state::AppState;
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState};
use crate::buffer::{AppView, BufferState};
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
use crate::services::GrpcClient;
use tokio::sync::mpsc;
use anyhow::Result;
use crate::components::common::text_editor::TextEditor;
use crate::services::ui_service::UiService;
use tui_textarea::CursorMove;
use crate::pages::admin::AdminState;
use crate::pages::routing::{Router, Page};
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
pub fn handle_add_logic_navigation(
key_event: KeyEvent,
config: &Config,
app_state: &mut AppState,
buffer_state: &mut BufferState,
grpc_client: GrpcClient,
save_logic_sender: SaveLogicResultSender,
command_message: &mut String,
router: &mut Router,
) -> bool {
if let Page::AddLogic(add_logic_state) = &mut router.current {
// === FULLSCREEN SCRIPT EDITING ===
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
// === AUTOCOMPLETE HANDLING ===
if add_logic_state.script_editor_autocomplete_active {
match key_event.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
add_logic_state.script_editor_filter_text.push(c);
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message =
format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
return true;
}
KeyCode::Backspace => {
if !add_logic_state.script_editor_filter_text.is_empty() {
add_logic_state.script_editor_filter_text.pop();
add_logic_state.update_script_editor_suggestions();
{
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
*command_message =
if add_logic_state.script_editor_filter_text.is_empty() {
"Autocomplete: @".to_string()
} else {
format!(
"Filtering: @{}",
add_logic_state.script_editor_filter_text
)
};
} else {
let should_deactivate =
if let Some((trigger_line, trigger_col)) =
add_logic_state.script_editor_trigger_position
{
let current_cursor = {
let editor_borrow =
add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
current_cursor.0 == trigger_line
&& current_cursor.1 == trigger_col + 1
} else {
false
};
if should_deactivate {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
{
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
}
return true;
}
KeyCode::Tab | KeyCode::Down => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state
.script_editor_selected_suggestion_index
.unwrap_or(0);
let next =
(current + 1) % add_logic_state.script_editor_suggestions.len();
add_logic_state.script_editor_selected_suggestion_index = Some(next);
*command_message = format!(
"Selected: {}",
add_logic_state.script_editor_suggestions[next]
);
}
return true;
}
KeyCode::Up => {
if !add_logic_state.script_editor_suggestions.is_empty() {
let current = add_logic_state
.script_editor_selected_suggestion_index
.unwrap_or(0);
let prev = if current == 0 {
add_logic_state.script_editor_suggestions.len() - 1
} else {
current - 1
};
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
*command_message = format!(
"Selected: {}",
add_logic_state.script_editor_suggestions[prev]
);
}
return true;
}
KeyCode::Enter => {
if let Some(selected_idx) =
add_logic_state.script_editor_selected_suggestion_index
{
if let Some(suggestion) = add_logic_state
.script_editor_suggestions
.get(selected_idx)
.cloned()
{
let trigger_pos =
add_logic_state.script_editor_trigger_position;
let filter_len =
add_logic_state.script_editor_filter_text.len();
add_logic_state.deactivate_script_editor_autocomplete();
add_logic_state.has_unsaved_changes = true;
if let Some(pos) = trigger_pos {
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
if suggestion == "sql" {
replace_autocomplete_text(
&mut editor_borrow,
pos,
filter_len,
"sql",
);
editor_borrow.insert_str("('')");
editor_borrow.move_cursor(CursorMove::Back);
editor_borrow.move_cursor(CursorMove::Back);
*command_message = "Inserted: @sql('')".to_string();
} else {
let is_table_selection =
add_logic_state.is_table_name_suggestion(&suggestion);
replace_autocomplete_text(
&mut editor_borrow,
pos,
filter_len,
&suggestion,
);
if is_table_selection {
editor_borrow.insert_str(".");
let new_cursor = editor_borrow.cursor();
drop(editor_borrow);
add_logic_state.script_editor_trigger_position =
Some(new_cursor);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state
.trigger_column_autocomplete_for_table(
suggestion.clone(),
);
let profile_name =
add_logic_state.profile_name.clone();
let table_name_for_fetch = suggestion.clone();
let mut client_clone = grpc_client.clone();
tokio::spawn(async move {
if let Err(e) = UiService::fetch_columns_for_table(
&mut client_clone,
&profile_name,
&table_name_for_fetch,
)
.await
{
tracing::error!(
"Failed to fetch columns for {}.{}: {}",
profile_name,
table_name_for_fetch,
e
);
}
});
*command_message = format!(
"Selected table '{}', fetching columns...",
suggestion
);
} else {
*command_message =
format!("Inserted: {}", suggestion);
}
}
}
return true;
}
}
add_logic_state.deactivate_script_editor_autocomplete();
{
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
KeyCode::Esc => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
}
_ => {
add_logic_state.deactivate_script_editor_autocomplete();
*command_message = "Autocomplete cancelled".to_string();
{
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
return true;
}
}
}
// Trigger autocomplete with '@'
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
let should_trigger = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
TextEditor::is_vim_insert_mode(&add_logic_state.vim_state)
}
_ => true,
};
if should_trigger {
let cursor_before = {
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor()
};
{
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
add_logic_state.script_editor_trigger_position = Some(cursor_before);
add_logic_state.script_editor_autocomplete_active = true;
add_logic_state.script_editor_filter_text.clear();
add_logic_state.update_script_editor_suggestions();
add_logic_state.has_unsaved_changes = true;
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
return true;
}
}
// Esc handling
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
let was_insert =
TextEditor::is_vim_insert_mode(&add_logic_state.vim_state);
{
let mut editor_borrow =
add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
);
}
if was_insert {
*command_message =
"VIM: Normal Mode. Esc again to exit script.".to_string();
} else {
add_logic_state.current_focus =
AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*command_message = "Exited script editing.".to_string();
}
}
_ => {
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
app_state.ui.focus_outside_canvas = true;
*command_message = "Exited script editing.".to_string();
}
}
return true;
}
// Normal text input
let changed = {
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
TextEditor::handle_input(
&mut editor_borrow,
key_event,
&add_logic_state.editor_keybinding_mode,
&mut add_logic_state.vim_state,
)
};
if changed {
add_logic_state.has_unsaved_changes = true;
}
return true;
}
// === NON-FULLSCREEN NAVIGATION ===
let action = config.get_general_action(key_event.code, key_event.modifiers);
let current_focus = add_logic_state.current_focus;
let mut handled = true;
let mut new_focus = current_focus;
match action.as_deref() {
Some("exit_table_scroll") => {
handled = false;
}
Some("move_up") => {
match current_focus {
AddLogicFocus::InputLogicName => {}
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => {
new_focus = AddLogicFocus::InputTargetColumn
}
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::InputDescription
}
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("move_down") => {
match current_focus {
AddLogicFocus::InputLogicName => {
new_focus = AddLogicFocus::InputTargetColumn
}
AddLogicFocus::InputTargetColumn => {
new_focus = AddLogicFocus::InputDescription
}
AddLogicFocus::InputDescription => {
add_logic_state.last_canvas_field = 2;
new_focus = AddLogicFocus::ScriptContentPreview;
}
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::SaveButton
}
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => {}
_ => handled = false,
}
}
Some("next_option") => {
match current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
new_focus = AddLogicFocus::ScriptContentPreview
}
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::SaveButton
}
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => {}
_ => handled = false,
}
}
Some("previous_option") => {
match current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {}
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::InputDescription
}
AddLogicFocus::SaveButton => {
new_focus = AddLogicFocus::ScriptContentPreview
}
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
_ => handled = false,
}
}
Some("next_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
_ => current_focus,
};
}
Some("prev_field") => {
new_focus = match current_focus {
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
_ => current_focus,
};
}
Some("select") => {
match current_focus {
AddLogicFocus::ScriptContentPreview => {
new_focus = AddLogicFocus::InsideScriptContent;
app_state.ui.focus_outside_canvas = false;
let mode_hint = match add_logic_state.editor_keybinding_mode {
EditorKeybindingMode::Vim => {
"VIM mode - 'i'/'a'/'o' to edit"
}
_ => "Enter/Ctrl+E to edit",
};
*command_message = format!(
"Fullscreen script editing. {} or Esc to exit.",
mode_hint
);
handled = true;
}
AddLogicFocus::SaveButton => {
*command_message = "Save logic action".to_string();
handled = true;
}
AddLogicFocus::CancelButton => {
buffer_state.update_history(AppView::Admin);
*command_message = "Cancelled Add Logic".to_string();
handled = true;
}
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
// Focus canvas inputs; let canvas keymap handle editing
app_state.ui.focus_outside_canvas = false;
handled = false; // forward to canvas
}
_ => handled = false,
}
}
Some("toggle_edit_mode") => {
match current_focus {
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription => {
app_state.ui.focus_outside_canvas = false;
*command_message =
"Focus moved to input. Use i/a (Vim) or type to edit.".to_string();
handled = true;
}
_ => {
*command_message = "Cannot toggle edit mode here.".to_string();
}
}
}
_ => handled = false,
}
if handled && current_focus != new_focus {
add_logic_state.current_focus = new_focus;
let new_is_canvas_input_focus = matches!(
new_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
if new_is_canvas_input_focus {
app_state.ui.focus_outside_canvas = false;
} else {
app_state.ui.focus_outside_canvas = true;
}
}
handled
} else {
false
}
}
fn replace_autocomplete_text(
editor: &mut tui_textarea::TextArea,
trigger_pos: (usize, usize),
filter_len: usize,
replacement: &str,
) {
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
for _ in 0..filter_len {
editor.delete_next_char();
}
editor.insert_str(replacement);
}

View File

@@ -1,7 +1,7 @@
// src/state/pages/add_logic.rs
// src/pages/admin_panel/add_logic/state.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
use crate::components::common::text_editor::{TextEditor, VimState};
use canvas::{DataProvider, AppMode};
use std::cell::RefCell;
use std::rc::Rc;
use tui_textarea::TextArea;
@@ -54,7 +54,7 @@ pub struct AddLogicState {
// New fields for same-profile table names and column autocomplete
pub same_profile_table_names: Vec<String>, // Tables from same profile only
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
pub app_mode: AppMode,
pub app_mode: canvas::AppMode,
}
impl AddLogicState {
@@ -92,7 +92,7 @@ impl AddLogicState {
same_profile_table_names: Vec::new(),
script_editor_awaiting_column_autocomplete: None,
app_mode: AppMode::Edit,
app_mode: canvas::AppMode::Edit,
}
}
@@ -272,179 +272,46 @@ impl AddLogicState {
impl Default for AddLogicState {
fn default() -> Self {
let mut state = Self::new(&EditorConfig::default());
state.app_mode = AppMode::Edit;
state.app_mode = canvas::AppMode::Edit;
state
}
}
// Implement external library's CanvasState for AddLogicState
impl CanvasState for AddLogicState {
fn current_field(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => 0,
AddLogicFocus::InputTargetColumn => 1,
AddLogicFocus::InputDescription => 2,
// If focus is elsewhere, return the last canvas field used
_ => self.last_canvas_field,
impl DataProvider for AddLogicState {
fn field_count(&self) -> usize {
3 // Logic Name, Target Column, Description
}
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Logic Name",
1 => "Target Column",
2 => "Description",
_ => "",
}
}
fn set_current_field(&mut self, index: usize) {
let new_focus = match index {
0 => AddLogicFocus::InputLogicName,
1 => AddLogicFocus::InputTargetColumn,
2 => AddLogicFocus::InputDescription,
_ => return,
};
if self.current_focus != new_focus {
if self.current_focus == AddLogicFocus::InputTargetColumn {
self.in_target_column_suggestion_mode = false;
self.show_target_column_suggestions = false;
}
self.current_focus = new_focus;
self.last_canvas_field = index;
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.logic_name_input,
1 => &self.target_column_input,
2 => &self.description_input,
_ => "",
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
AddLogicFocus::InputDescription => self.description_cursor_pos,
_ => 0,
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
match self.current_focus {
AddLogicFocus::InputLogicName => {
self.logic_name_cursor_pos = pos.min(self.logic_name_input.len());
}
AddLogicFocus::InputTargetColumn => {
self.target_column_cursor_pos = pos.min(self.target_column_input.len());
}
AddLogicFocus::InputDescription => {
self.description_cursor_pos = pos.min(self.description_input.len());
}
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.logic_name_input = value,
1 => self.target_column_input = value,
2 => self.description_input = value,
_ => {}
}
self.has_unsaved_changes = true;
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddLogicFocus::InputLogicName => &self.logic_name_input,
AddLogicFocus::InputTargetColumn => &self.target_column_input,
AddLogicFocus::InputDescription => &self.description_input,
_ => "", // Should not happen if called correctly
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
AddLogicFocus::InputDescription => &mut self.description_input,
_ => &mut self.logic_name_input, // Fallback
}
}
fn inputs(&self) -> Vec<&String> {
vec![
&self.logic_name_input,
&self.target_column_input,
&self.description_input,
]
}
fn fields(&self) -> Vec<&str> {
vec!["Logic Name", "Target Column", "Description"]
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
// Handle saving logic script
CanvasAction::Custom(action_str) if action_str == "save_logic" => {
self.save_logic()
}
// Handle clearing the form
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
self.clear_form()
}
// Handle target column autocomplete activation
CanvasAction::Custom(action_str) if action_str == "activate_autocomplete" => {
if self.current_field() == 1 { // Target Column field
self.in_target_column_suggestion_mode = true;
self.update_target_column_suggestions();
Some("Autocomplete activated".to_string())
} else {
None
}
}
// Handle target column suggestion selection
CanvasAction::Custom(action_str) if action_str == "select_suggestion" => {
if self.current_field() == 1 && self.in_target_column_suggestion_mode {
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
if let Some(suggestion) = self.target_column_suggestions.get(selected_idx) {
self.target_column_input = suggestion.clone();
self.target_column_cursor_pos = suggestion.len();
self.in_target_column_suggestion_mode = false;
self.show_target_column_suggestions = false;
self.has_unsaved_changes = true;
return Some(format!("Selected: {}", suggestion));
}
}
}
None
}
// Custom validation when moving between fields
CanvasAction::NextField => {
match self.current_field() {
0 => { // Logic Name field
if self.logic_name_input.trim().is_empty() {
Some("Logic name cannot be empty".to_string())
} else {
None // Let canvas library handle the normal field movement
}
}
1 => { // Target Column field
// Update suggestions when entering target column field
self.update_target_column_suggestions();
None
}
_ => None,
}
}
// Handle character insertion with validation
CanvasAction::InsertChar(c) => {
if self.current_field() == 1 { // Target Column field
// Update suggestions after character insertion
// Note: Canvas library will handle the actual insertion
// This is just for triggering suggestion updates
None // Let canvas handle insertion, then we'll update suggestions
} else {
None // Let canvas handle normally
}
}
// Let canvas library handle everything else
_ => None,
}
}
fn current_mode(&self) -> AppMode {
self.app_mode
fn supports_suggestions(&self, field_index: usize) -> bool {
// Only Target Column supports suggestions
field_index == 1
}
}

View File

@@ -1,9 +1,8 @@
// src/components/admin/add_logic.rs
// src/pages/admin_panel/add_logic/ui.rs
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState};
use canvas::{render_canvas, FormEditor};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
@@ -11,26 +10,16 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::components::common::autocomplete;
use crate::dialog;
use crate::config::binds::config::EditorKeybindingMode;
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
pub fn render_add_logic(
f: &mut Frame,
area: Rect,
theme: &Theme,
app_state: &AppState,
add_logic_state: &mut AddLogicState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
let main_block = Block::default()
.title(" Add New Logic Script ")
@@ -45,7 +34,11 @@ pub fn render_add_logic(
// Handle full-screen script editing
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
let border_style_color = if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(&add_logic_state.vim_state) {
theme.highlight
} else {
theme.secondary
};
let border_style = Style::default().fg(border_style_color);
editor_ref.set_cursor_line_style(Style::default());
@@ -57,7 +50,7 @@ pub fn render_add_logic(
format!("Script {}", vim_mode_status)
}
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
if is_edit_mode {
if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(&add_logic_state.vim_state) {
"Script (Editing)".to_string()
} else {
"Script".to_string()
@@ -168,19 +161,11 @@ pub fn render_add_logic(
| AddLogicFocus::InputDescription
);
let canvas_highlight_state = convert_highlight_state(highlight_state);
let active_field_rect = render_canvas(
f,
canvas_area,
add_logic_state, // AddLogicState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode && focus_on_canvas_inputs,
&canvas_highlight_state,
);
let editor = FormEditor::new(add_logic_state.clone());
let active_field_rect = render_canvas(f, canvas_area, &editor, theme);
// --- Render Autocomplete for Target Column ---
// `is_edit_mode` here refers to the general edit mode of the EventHandler
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
if editor.mode() == canvas::AppMode::Edit && editor.current_field() == 1 { // Target Column field
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
if !add_logic_state.target_column_suggestions.is_empty() {
if let Some(input_rect) = active_field_rect {

View File

@@ -1,7 +1,6 @@
// src/tui/functions/common/add_table.rs
use crate::state::pages::add_table::{
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition,
};
// src/pages/admin_panel/add_table/logic.rs
use crate::pages::admin_panel::add_table::state;
use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus, IndexDefinition, ColumnDefinition};
use crate::services::GrpcClient;
use anyhow::{anyhow, Result};
use common::proto::komp_ac::table_definition::{

View File

@@ -0,0 +1,6 @@
// src/pages/admin_panel/add_table/mod.rs
pub mod ui;
pub mod nav;
pub mod state;
pub mod logic;

View File

@@ -1,12 +1,13 @@
// src/functions/modes/navigation/add_table_nav.rs
// src/pages/admin_panel/add_table/nav.rs
use crate::config::binds::config::Config;
use crate::state::{
app::state::AppState,
pages::add_table::{AddTableFocus, AddTableState},
};
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableState};
use crossterm::event::{KeyEvent};
use ratatui::widgets::TableState;
use crate::tui::functions::common::add_table::{handle_add_column_action, handle_save_table_action};
use crate::pages::admin_panel::add_table::logic::{handle_add_column_action, handle_save_table_action};
use crate::ui::handlers::context::DialogPurpose;
use crate::services::GrpcClient;
use tokio::sync::mpsc;

View File

@@ -0,0 +1,383 @@
// src/pages/admin_panel/add_table/state.rs
use canvas::{DataProvider, AppMode};
use ratatui::widgets::TableState;
use serde::{Deserialize, Serialize};
use crate::movement::{move_focus, MovementAction};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDefinition {
pub name: String,
pub data_type: String,
pub selected: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexDefinition {
pub name: String,
pub selected: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkDefinition {
pub linked_table_name: String,
pub is_required: bool,
pub selected: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AddTableFocus {
#[default]
InputTableName, // Field 0 for CanvasState
InputColumnName, // Field 1 for CanvasState
InputColumnType, // Field 2 for CanvasState
AddColumnButton,
// Result Tables
ColumnsTable,
IndexesTable,
LinksTable,
// Inside Tables (Scrolling Focus)
InsideColumnsTable,
InsideIndexesTable,
InsideLinksTable,
// Buttons
SaveButton,
DeleteSelectedButton,
CancelButton,
}
#[derive(Debug, Clone)]
pub struct AddTableState {
pub profile_name: String,
pub table_name: String,
pub table_name_input: String,
pub column_name_input: String,
pub column_type_input: String,
pub columns: Vec<ColumnDefinition>,
pub indexes: Vec<IndexDefinition>,
pub links: Vec<LinkDefinition>,
pub current_focus: AddTableFocus,
pub last_canvas_field: usize,
pub column_table_state: TableState,
pub index_table_state: TableState,
pub link_table_state: TableState,
pub table_name_cursor_pos: usize,
pub column_name_cursor_pos: usize,
pub column_type_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub app_mode: canvas::AppMode,
}
impl Default for AddTableState {
fn default() -> Self {
AddTableState {
profile_name: "default".to_string(),
table_name: String::new(),
table_name_input: String::new(),
column_name_input: String::new(),
column_type_input: String::new(),
columns: Vec::new(),
indexes: Vec::new(),
links: Vec::new(),
current_focus: AddTableFocus::InputTableName,
last_canvas_field: 2,
column_table_state: TableState::default(),
index_table_state: TableState::default(),
link_table_state: TableState::default(),
table_name_cursor_pos: 0,
column_name_cursor_pos: 0,
column_type_cursor_pos: 0,
has_unsaved_changes: false,
app_mode: canvas::AppMode::Edit,
}
}
}
impl AddTableState {
pub const INPUT_FIELD_COUNT: usize = 3;
/// Helper method to add a column from current inputs
pub fn add_column_from_inputs(&mut self) -> Option<String> {
if self.column_name_input.trim().is_empty() || self.column_type_input.trim().is_empty() {
return Some("Both column name and type are required".to_string());
}
// Check for duplicate column names
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) {
return Some("Column name already exists".to_string());
}
// Add the column
self.columns.push(ColumnDefinition {
name: self.column_name_input.trim().to_string(),
data_type: self.column_type_input.trim().to_string(),
selected: false,
});
// Clear inputs and reset focus to column name for next entry
self.column_name_input.clear();
self.column_type_input.clear();
self.column_name_cursor_pos = 0;
self.column_type_cursor_pos = 0;
self.current_focus = AddTableFocus::InputColumnName;
self.last_canvas_field = 1;
self.has_unsaved_changes = true;
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name))
}
/// Helper method to delete selected items
pub fn delete_selected_items(&mut self) -> Option<String> {
let mut deleted_items = Vec::new();
// Remove selected columns
let initial_column_count = self.columns.len();
self.columns.retain(|col| {
if col.selected {
deleted_items.push(format!("column '{}'", col.name));
false
} else {
true
}
});
// Remove selected indexes
let initial_index_count = self.indexes.len();
self.indexes.retain(|idx| {
if idx.selected {
deleted_items.push(format!("index '{}'", idx.name));
false
} else {
true
}
});
// Remove selected links
let initial_link_count = self.links.len();
self.links.retain(|link| {
if link.selected {
deleted_items.push(format!("link to '{}'", link.linked_table_name));
false
} else {
true
}
});
if deleted_items.is_empty() {
Some("No items selected for deletion".to_string())
} else {
self.has_unsaved_changes = true;
Some(format!("Deleted: {}", deleted_items.join(", ")))
}
}
}
impl DataProvider for AddTableState {
fn field_count(&self) -> usize {
3 // Table name, Column name, Column type
}
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Table name",
1 => "Name",
2 => "Type",
_ => "",
}
}
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.table_name_input,
1 => &self.column_name_input,
2 => &self.column_type_input,
_ => "",
}
}
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.table_name_input = value,
1 => self.column_name_input = value,
2 => self.column_type_input = value,
_ => {}
}
self.has_unsaved_changes = true;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false // AddTableState doesnt use suggestions
}
}
impl AddTableState {
pub fn handle_movement(&mut self, action: MovementAction) -> bool {
use AddTableFocus::*;
// Linear outer focus order
const ORDER: [AddTableFocus; 10] = [
InputTableName,
InputColumnName,
InputColumnType,
AddColumnButton,
ColumnsTable,
IndexesTable,
LinksTable,
SaveButton,
DeleteSelectedButton,
CancelButton,
];
// Enter "inside" on Select from outer panes
match (self.current_focus, action) {
(ColumnsTable, MovementAction::Select) => {
if !self.columns.is_empty() && self.column_table_state.selected().is_none() {
self.column_table_state.select(Some(0));
}
self.current_focus = InsideColumnsTable;
return true;
}
(IndexesTable, MovementAction::Select) => {
if !self.indexes.is_empty() && self.index_table_state.selected().is_none() {
self.index_table_state.select(Some(0));
}
self.current_focus = InsideIndexesTable;
return true;
}
(LinksTable, MovementAction::Select) => {
if !self.links.is_empty() && self.link_table_state.selected().is_none() {
self.link_table_state.select(Some(0));
}
self.current_focus = InsideLinksTable;
return true;
}
_ => {}
}
// Handle "inside" states: Up/Down/Select/Esc; block outer movement keys
match self.current_focus {
InsideColumnsTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.column_table_state.selected() {
let next = i.saturating_sub(1);
self.column_table_state.select(Some(next));
} else if !self.columns.is_empty() {
self.column_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.column_table_state.selected() {
let last = self.columns.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.column_table_state.select(Some(next));
} else if !self.columns.is_empty() {
self.column_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.column_table_state.selected() {
if let Some(col) = self.columns.get_mut(i) {
col.selected = !col.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.column_table_state.select(None);
self.current_focus = ColumnsTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
InsideIndexesTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.index_table_state.selected() {
let next = i.saturating_sub(1);
self.index_table_state.select(Some(next));
} else if !self.indexes.is_empty() {
self.index_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.index_table_state.selected() {
let last = self.indexes.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.index_table_state.select(Some(next));
} else if !self.indexes.is_empty() {
self.index_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.index_table_state.selected() {
if let Some(ix) = self.indexes.get_mut(i) {
ix.selected = !ix.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.index_table_state.select(None);
self.current_focus = IndexesTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
InsideLinksTable => {
match action {
MovementAction::Up => {
if let Some(i) = self.link_table_state.selected() {
let next = i.saturating_sub(1);
self.link_table_state.select(Some(next));
} else if !self.links.is_empty() {
self.link_table_state.select(Some(0));
}
return true;
}
MovementAction::Down => {
if let Some(i) = self.link_table_state.selected() {
let last = self.links.len().saturating_sub(1);
let next = if i < last { i + 1 } else { i };
self.link_table_state.select(Some(next));
} else if !self.links.is_empty() {
self.link_table_state.select(Some(0));
}
return true;
}
MovementAction::Select => {
if let Some(i) = self.link_table_state.selected() {
if let Some(link) = self.links.get_mut(i) {
link.selected = !link.selected;
self.has_unsaved_changes = true;
}
}
return true;
}
MovementAction::Esc => {
self.link_table_state.select(None);
self.current_focus = LinksTable;
return true;
}
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
_ => {}
}
}
_ => {}
}
// Default: outer navigation via helper
move_focus(&ORDER, &mut self.current_focus, action)
}
}

View File

@@ -1,9 +1,8 @@
// src/components/admin/add_table.rs
// src/pages/admin_panel/add_table/ui.rs
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableState};
use canvas::{render_canvas, FormEditor};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
@@ -11,16 +10,7 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
Frame,
};
use crate::components::common::dialog;
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
use crate::dialog;
/// Renders the Add New Table page layout, structuring the display of table information,
/// input fields, and action buttons. Adapts layout based on terminal width.
@@ -30,8 +20,6 @@ pub fn render_add_table(
theme: &Theme,
app_state: &AppState,
add_table_state: &mut AddTableState,
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
highlight_state: &HighlightState, // For text highlighting in canvas
) {
// --- Configuration ---
// Threshold width to switch between wide and narrow layouts
@@ -357,15 +345,8 @@ pub fn render_add_table(
);
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
let _active_field_rect = render_canvas(
f,
canvas_area,
add_table_state, // AddTableState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode && focus_on_canvas_inputs,
&canvas_highlight_state,
);
let editor = FormEditor::new(add_table_state.clone());
let _active_field_rect = render_canvas(f, canvas_area, &editor, theme);
// --- Button Style Helpers ---
let get_button_style = |button_focus: AddTableFocus, current_focus| {

View File

@@ -0,0 +1,4 @@
// src/pages/admin_panel/mod.rs
pub mod add_table;
pub mod add_logic;

View File

@@ -0,0 +1,62 @@
// src/pages/forms/event.rs
use anyhow::Result;
use crossterm::event::Event;
use canvas::keymap::KeyEventOutcome;
use crate::{
state::app::state::AppState,
pages::forms::{FormState, logic},
modes::handlers::event::EventOutcome,
};
pub fn handle_form_event(
event: Event,
app_state: &mut AppState,
path: &str,
ideal_cursor_column: &mut usize,
) -> Result<EventOutcome> {
if let Event::Key(key_event) = event {
if let Some(editor) = app_state.editor_for_path(path) {
match editor.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
return Ok(EventOutcome::Ok(msg));
}
KeyEventOutcome::Consumed(None) => {
return Ok(EventOutcome::Ok("Form input updated".into()));
}
KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
}
KeyEventOutcome::NotMatched => {
// fall through to navigation / save / revert
}
}
}
}
Ok(EventOutcome::Ok(String::new()))
}
// Save wrapper
pub async fn save_form(
app_state: &mut AppState,
path: &str,
grpc_client: &mut crate::services::grpc_client::GrpcClient,
) -> Result<EventOutcome> {
let outcome = logic::save(app_state, path, grpc_client).await?;
let message = match outcome {
logic::SaveOutcome::NoChange => "No changes to save.".to_string(),
logic::SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
logic::SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
};
Ok(EventOutcome::DataSaved(outcome, message))
}
pub async fn revert_form(
app_state: &mut AppState,
path: &str,
grpc_client: &mut crate::services::grpc_client::GrpcClient,
) -> Result<EventOutcome> {
let message = logic::revert(app_state, path, grpc_client).await?;
Ok(EventOutcome::Ok(message))
}

View File

@@ -0,0 +1,39 @@
// src/pages/forms/loader.rs
use anyhow::{Context, Result};
use crate::{
state::app::state::AppState,
services::grpc_client::GrpcClient,
services::ui_service::UiService, // ✅ import UiService
config::binds::Config,
pages::forms::FormState,
};
pub async fn ensure_form_loaded_and_count(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
config: &Config,
profile: &str,
table: &str,
) -> Result<()> {
let path = format!("{}/{}", profile, table);
app_state.ensure_form_editor(&path, config, || {
FormState::new(profile.to_string(), table.to_string(), vec![])
});
if let Some(form_state) = app_state.form_state_for_path(&path) {
UiService::fetch_and_set_table_count(grpc_client, form_state)
.await
.context("Failed to fetch table count")?;
if form_state.total_count > 0 {
UiService::load_table_data_by_position(grpc_client, form_state)
.await
.context("Failed to load table data")?;
} else {
form_state.reset_to_empty();
}
}
Ok(())
}

View File

@@ -0,0 +1,184 @@
// src/pages/forms/logic.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::pages::forms::FormState;
use crate::utils::data_converter;
use anyhow::{anyhow, Context, Result};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveOutcome {
NoChange,
UpdatedExisting,
CreatedNew(i64),
}
pub async fn save(
app_state: &mut AppState,
path: &str,
grpc_client: &mut GrpcClient,
) -> Result<SaveOutcome> {
if let Some(fs) = app_state.form_state_for_path(path) {
if !fs.has_unsaved_changes {
return Ok(SaveOutcome::NoChange);
}
let profile_name = fs.profile_name.clone();
let table_name = fs.table_name.clone();
let fields = fs.fields.clone();
let values = fs.values.clone();
let id = fs.id;
let total_count = fs.total_count;
let current_position = fs.current_position;
let cache_key = format!("{}.{}", profile_name, table_name);
let schema = app_state
.schema_cache
.get(&cache_key)
.ok_or_else(|| {
anyhow!(
"Schema for table '{}' not found in cache. Cannot save.",
table_name
)
})?;
let data_map: HashMap<String, String> = fields
.iter()
.zip(values.iter())
.map(|(field_def, value)| (field_def.data_key.clone(), value.clone()))
.collect();
let converted_data =
data_converter::convert_and_validate_data(&data_map, schema)
.map_err(|user_error| anyhow!(user_error))?;
let is_new_entry = id == 0
|| (total_count > 0 && current_position > total_count)
|| (total_count == 0 && current_position == 1);
let outcome = if is_new_entry {
let response = grpc_client
.post_table_data(profile_name.clone(), table_name.clone(), converted_data)
.await
.context("Failed to post new table data")?;
if response.success {
if let Some(fs) = app_state.form_state_for_path(path) {
fs.id = response.inserted_id;
fs.total_count += 1;
fs.current_position = fs.total_count;
fs.has_unsaved_changes = false;
}
SaveOutcome::CreatedNew(response.inserted_id)
} else {
return Err(anyhow!("Server failed to insert data: {}", response.message));
}
} else {
if id == 0 {
return Err(anyhow!(
"Cannot update record: ID is 0, but not classified as new entry."
));
}
let response = grpc_client
.put_table_data(profile_name.clone(), table_name.clone(), id, converted_data)
.await
.context("Failed to put (update) table data")?;
if response.success {
if let Some(fs) = app_state.form_state_for_path(path) {
fs.has_unsaved_changes = false;
}
SaveOutcome::UpdatedExisting
} else {
return Err(anyhow!("Server failed to update data: {}", response.message));
}
};
Ok(outcome)
} else {
Ok(SaveOutcome::NoChange)
}
}
pub async fn revert(
app_state: &mut AppState,
path: &str,
grpc_client: &mut GrpcClient,
) -> Result<String> {
if let Some(fs) = app_state.form_state_for_path(path) {
if fs.id == 0
|| (fs.total_count > 0 && fs.current_position > fs.total_count)
|| (fs.total_count == 0 && fs.current_position == 1)
{
let old_total_count = fs.total_count;
fs.reset_to_empty();
fs.total_count = old_total_count;
if fs.total_count > 0 {
fs.current_position = fs.total_count + 1;
} else {
fs.current_position = 1;
}
return Ok("New entry cleared".to_string());
}
if fs.current_position == 0 || fs.current_position > fs.total_count {
if fs.total_count > 0 {
fs.current_position = 1;
} else {
fs.reset_to_empty();
return Ok("No saved data to revert to; form cleared.".to_string());
}
}
let response = grpc_client
.get_table_data_by_position(
fs.profile_name.clone(),
fs.table_name.clone(),
fs.current_position as i32,
)
.await
.context(format!(
"Failed to get table data by position {} for table {}.{}",
fs.current_position, fs.profile_name, fs.table_name
))?;
fs.update_from_response(&response.data, fs.current_position);
Ok("Changes discarded, reloaded last saved version".to_string())
} else {
Ok("Nothing to revert".to_string())
}
}
pub async fn handle_action(
action: &str,
form_state: &mut FormState,
_grpc_client: &mut GrpcClient,
ideal_cursor_column: &mut usize,
) -> Result<String> {
if form_state.has_unsaved_changes() {
return Ok(
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
.to_string(),
);
}
let total_count = form_state.total_count;
match action {
"previous_entry" => {
if form_state.current_position > 1 {
form_state.current_position -= 1;
*ideal_cursor_column = 0;
}
}
"next_entry" => {
if form_state.current_position <= total_count {
form_state.current_position += 1;
*ideal_cursor_column = 0;
}
}
_ => return Err(anyhow!("Unknown form action: {}", action)),
}
Ok(String::new())
}

View File

@@ -0,0 +1,13 @@
// src/pages/forms/mod.rs
pub mod ui;
pub mod state;
pub mod logic;
pub mod event;
pub mod loader;
pub use ui::*;
pub use state::*;
pub use logic::*;
pub use event::*;
pub use loader::*;

View File

@@ -1,10 +1,7 @@
// src/state/pages/form.rs
// src/pages/forms/state.rs
use crate::config::colors::themes::Theme;
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState, AppMode};
use canvas::{DataProvider, AppMode};
use common::proto::komp_ac::search::search_response::Hit;
use ratatui::layout::Rect;
use ratatui::Frame;
use std::collections::HashMap;
fn json_value_to_string(value: &serde_json::Value) -> String {
@@ -24,7 +21,7 @@ pub struct FieldDefinition {
pub link_target_table: Option<String>,
}
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct FormState {
pub id: i64,
pub profile_name: String,
@@ -75,7 +72,7 @@ impl FormState {
selected_suggestion_index: None,
autocomplete_loading: false,
link_display_map: HashMap::new(),
app_mode: AppMode::Edit,
app_mode: canvas::AppMode::Edit,
}
}
@@ -116,34 +113,6 @@ impl FormState {
}
}
pub fn render(
&self,
f: &mut Frame,
area: Rect,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState, // Now using canvas::HighlightState
) {
let fields_str_slice: Vec<&str> =
self.fields().iter().map(|s| *s).collect();
let values_str_slice: Vec<&String> = self.values.iter().collect();
crate::components::form::form::render_form(
f,
area,
self,
&fields_str_slice,
&self.current_field,
&values_str_slice,
&self.table_name,
theme,
is_edit_mode,
highlight_state,
self.total_count,
self.current_position,
);
}
pub fn reset_to_empty(&mut self) {
self.id = 0;
self.values.iter_mut().for_each(|v| v.clear());
@@ -234,105 +203,83 @@ impl FormState {
self.autocomplete_loading = false;
}
// NEW: Add these methods to change modes
pub fn set_edit_mode(&mut self) {
self.app_mode = AppMode::Edit;
}
pub fn set_readonly_mode(&mut self) {
self.app_mode = AppMode::ReadOnly;
}
}
impl CanvasState for FormState {
fn current_field(&self) -> usize {
self.current_field
}
fn current_cursor_pos(&self) -> usize {
self.current_cursor_pos
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
self.values.iter().collect()
}
fn get_current_input(&self) -> &str {
FormState::get_current_input(self)
}
fn get_current_input_mut(&mut self) -> &mut String {
FormState::get_current_input_mut(self)
}
fn fields(&self) -> Vec<&str> {
// Legacy method compatibility
pub fn fields(&self) -> Vec<&str> {
self.fields
.iter()
.map(|f| f.display_name.as_str())
.collect()
}
fn set_current_field(&mut self, index: usize) {
pub fn get_display_value_for_field(&self, index: usize) -> &str {
if let Some(display_text) = self.link_display_map.get(&index) {
return display_text.as_str();
}
self.values
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
pub fn has_display_override(&self, index: usize) -> bool {
self.link_display_map.contains_key(&index)
}
pub fn current_mode(&self) -> AppMode {
self.app_mode
}
// Add missing methods that used to come from CanvasState trait
pub fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
pub fn current_field(&self) -> usize {
self.current_field
}
pub fn set_current_field(&mut self, index: usize) {
if index < self.fields.len() {
self.current_field = index;
}
self.deactivate_autocomplete();
}
fn set_current_cursor_pos(&mut self, pos: usize) {
pub fn current_cursor_pos(&self) -> usize {
self.current_cursor_pos
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.current_cursor_pos = pos;
}
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
// Step 2: Implement DataProvider for FormState
impl DataProvider for FormState {
fn field_count(&self) -> usize {
self.fields.len()
}
// --- FEATURE-SPECIFIC ACTION HANDLING ---
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::SelectSuggestion => {
if let Some(selected_idx) = self.selected_suggestion_index {
if let Some(hit) = self.autocomplete_suggestions.get(selected_idx).cloned() {
// Extract the value from the selected suggestion
if let Ok(content_map) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&hit.content_json) {
let current_field_def = &self.fields[self.current_field];
if let Some(value) = content_map.get(&current_field_def.data_key) {
let new_value = json_value_to_string(value);
let display_name = self.get_display_name_for_hit(&hit);
*self.get_current_input_mut() = new_value.clone();
self.set_current_cursor_pos(new_value.len());
self.set_has_unsaved_changes(true);
self.deactivate_autocomplete();
return Some(format!("Selected: {}", display_name));
}
}
}
}
None
}
_ => None, // Let canvas handle other actions
fn field_name(&self, index: usize) -> &str {
&self.fields[index].display_name
}
fn field_value(&self, index: usize) -> &str {
&self.values[index]
}
fn set_field_value(&mut self, index: usize, value: String) {
if let Some(v) = self.values.get_mut(index) {
*v = value;
self.has_unsaved_changes = true;
}
}
fn get_display_value_for_field(&self, index: usize) -> &str {
if let Some(display_text) = self.link_display_map.get(&index) {
return display_text.as_str();
}
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
fn has_display_override(&self, index: usize) -> bool {
self.link_display_map.contains_key(&index)
}
fn current_mode(&self) -> AppMode {
self.app_mode
fn supports_suggestions(&self, field_index: usize) -> bool {
self.fields.get(field_index).map(|f| f.is_link).unwrap_or(false)
}
}

View File

@@ -1,26 +1,22 @@
// src/components/form/form.rs
use crate::components::common::autocomplete;
// src/pages/forms/ui.rs
use crate::config::colors::themes::Theme;
use canvas::canvas::{CanvasState, render_canvas, HighlightState};
use crate::state::pages::form::FormState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::Style,
widgets::{Block, Borders, Paragraph},
Frame,
};
use canvas::{
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor,
};
use crate::pages::forms::FormState;
pub fn render_form(
pub fn render_form_page(
f: &mut Frame,
area: Rect,
form_state: &FormState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
editor: &FormEditor<FormState>,
table_name: &str,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
@@ -56,43 +52,21 @@ pub fn render_form(
total_count, current_position, total_count
)
};
let count_para = Paragraph::new(count_position_text)
.style(Style::default().fg(theme.fg))
.alignment(Alignment::Left);
f.render_widget(count_para, main_layout[0]);
// Use the canvas library's render_canvas function
let active_field_rect = render_canvas(
f,
main_layout[1],
form_state,
theme,
is_edit_mode,
highlight_state,
);
// --- RENDER RICH AUTOCOMPLETE ONLY ---
if form_state.autocomplete_active {
if let Some(active_rect) = active_field_rect {
// Get selected index directly from form_state
let selected_index = form_state.selected_suggestion_index;
// Only render rich suggestions (your Hit objects)
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
if !rich_suggestions.is_empty() {
autocomplete::render_hit_autocomplete_dropdown(
f,
active_rect,
f.area(),
theme,
rich_suggestions,
selected_index,
form_state,
);
}
}
// Removed simple suggestions - we only use rich ones now!
}
// --- FORM RENDERING (Using persistent FormEditor) ---
let active_field_rect = render_canvas(f, main_layout[1], editor, theme);
if let Some(active_rect) = active_field_rect {
render_suggestions_dropdown(
f,
main_layout[1],
active_rect,
&DefaultCanvasTheme,
editor,
);
}
}

View File

@@ -0,0 +1,76 @@
// src/pages/intro/logic.rs
use crate::state::app::state::AppState;
use crate::buffer::state::{AppView, BufferState};
/// Handles intro screen selection by updating view history and managing focus state.
/// 0: Continue (restores last form or default)
/// 1: Admin view
/// 2: Login view
/// 3: Register view (with focus reset)
pub fn handle_intro_selection(
app_state: &mut AppState,
buffer_state: &mut BufferState,
index: usize,
) {
match index {
// Continue: go to the most recent existing Form tab, or open a sensible default
0 => {
// 1) Try to switch to an already open Form buffer (most recent)
if let Some(existing_path) = buffer_state
.history
.iter()
.rev()
.find_map(|view| {
if let AppView::Form(p) = view {
Some(p.clone())
} else {
None
}
})
{
buffer_state.update_history(AppView::Form(existing_path));
return;
}
// 2) Otherwise pick a fallback path
let fallback_path = if let (Some(profile), Some(table)) = (
app_state.current_view_profile_name.clone(),
app_state.current_view_table_name.clone(),
) {
Some(format!("{}/{}", profile, table))
} else if let Some(any_key) = app_state.form_editor.keys().next().cloned() {
// Use any existing editor key if available
Some(any_key)
} else {
// Otherwise pick the first available table from the profile tree
let mut found: Option<String> = None;
for prof in &app_state.profile_tree.profiles {
if let Some(tbl) = prof.tables.first() {
found = Some(format!("{}/{}", prof.name, tbl.name));
break;
}
}
found
};
if let Some(path) = fallback_path {
buffer_state.update_history(AppView::Form(path));
} else {
// No sensible default; stay on Intro
}
}
1 => {
buffer_state.update_history(AppView::Admin);
}
2 => {
buffer_state.update_history(AppView::Login);
}
3 => {
buffer_state.update_history(AppView::Register);
// Register view requires focus reset
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index = 0;
}
_ => return,
}
}

View File

@@ -0,0 +1,9 @@
// src/pages/intro/mod.rs
pub mod state;
pub mod ui;
pub mod logic;
pub use state::*;
pub use ui::render_intro;
pub use logic::*;

View File

@@ -0,0 +1,48 @@
// src/state/pages/intro.rs
use crate::movement::MovementAction;
#[derive(Default, Clone, Debug)]
pub struct IntroState {
pub selected_option: usize,
}
impl IntroState {
pub fn new() -> Self {
Self::default()
}
pub fn next_option(&mut self) {
if self.selected_option < 3 {
self.selected_option += 1;
}
}
pub fn previous_option(&mut self) {
if self.selected_option > 0 {
self.selected_option -= 1
}
}
}
impl IntroState {
pub fn handle_movement(&mut self, action: MovementAction) -> bool {
match action {
MovementAction::Next | MovementAction::Right | MovementAction::Down => {
self.next_option();
true
}
MovementAction::Previous | MovementAction::Left | MovementAction::Up => {
self.previous_option();
true
}
MovementAction::Select => {
// Actual selection handled in event loop (UiContext::Intro)
false
}
MovementAction::Esc => {
// Nothing special for Intro, but could be used to quit
true
}
}
}
}

View File

@@ -1,4 +1,4 @@
// src/components/intro/intro.rs
// src/pages/intro/ui.rs
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style,
@@ -8,7 +8,7 @@ use ratatui::{
Frame,
};
use crate::config::colors::themes::Theme;
use crate::state::pages::intro::IntroState;
use crate::pages::intro::IntroState;
pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme: &Theme) {
let block = Block::default()

View File

@@ -0,0 +1,73 @@
// src/pages/login/event.rs
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
use crate::{
state::app::state::AppState,
pages::login::LoginFormState,
modes::handlers::event::EventOutcome,
};
use canvas::DataProvider;
/// Handles all Login page-specific events
pub fn handle_login_event(
event: Event,
app_state: &mut AppState,
login_page: &mut LoginFormState,
) -> Result<EventOutcome> {
if let Event::Key(key_event) = event {
let key_code = key_event.code;
let modifiers = key_event.modifiers;
// From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button
if login_page.focus_outside_canvas
&& login_page.focused_button_index == 0
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
&& modifiers.is_empty()
{
login_page.focus_outside_canvas = false;
app_state.ui.focus_outside_canvas = false; // 🔑 keep global in sync
login_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok(String::new()));
}
// Focus handoff: inside canvas → buttons
if !login_page.focus_outside_canvas {
let last_idx = login_page.editor.data_provider().field_count().saturating_sub(1);
let at_last = login_page.editor.current_field() >= last_idx;
if at_last
&& matches!(
(key_code, modifiers),
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
)
{
login_page.focus_outside_canvas = true;
login_page.focused_button_index = 0; // focus "Login" button
app_state.ui.focus_outside_canvas = true;
app_state.focused_button_index = 0;
login_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
}
}
// Forward to canvas if focus is inside
if !login_page.focus_outside_canvas {
match login_page.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
return Ok(EventOutcome::Ok(msg));
}
KeyEventOutcome::Consumed(None) => {
return Ok(EventOutcome::Ok("Login input updated".into()));
}
KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
}
KeyEventOutcome::NotMatched => {
// fall through to button handling
}
}
}
}
Ok(EventOutcome::Ok(String::new()))
}

View File

@@ -1,15 +1,15 @@
// src/tui/functions/common/login.rs
// src/pages/login/logic.rs
use crate::services::auth::AuthClient;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::app::state::AppState;
use crate::state::app::buffer::{AppView, BufferState};
use crate::buffer::state::{AppView, BufferState};
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
use crate::ui::handlers::context::DialogPurpose;
use common::proto::komp_ac::auth::LoginResponse;
use canvas::canvas::CanvasState;
use anyhow::{Context, Result};
use crate::pages::login::LoginFormState;
use crate::state::pages::auth::UserRole;
use anyhow::{Context, Result, anyhow};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{info, error};
@@ -25,15 +25,14 @@ pub enum LoginResult {
/// Updates AuthState and AppState on success or failure.
pub async fn save(
auth_state: &mut AuthState,
login_state: &mut LoginState,
login_state: &mut LoginFormState,
auth_client: &mut AuthClient,
app_state: &mut AppState,
) -> Result<String> {
let identifier = login_state.username.clone();
let password = login_state.password.clone();
let identifier = login_state.username().to_string();
let password = login_state.password().to_string();
// --- Client-side validation ---
// Prevent login attempt if the identifier field is empty or whitespace.
if identifier.trim().is_empty() {
let error_message = "Username/Email cannot be empty.".to_string();
app_state.show_dialog(
@@ -42,33 +41,33 @@ pub async fn save(
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
return Err(anyhow::anyhow!(error_message));
login_state.set_error_message(Some(error_message.clone()));
return Err(anyhow!(error_message));
}
// Clear previous error/dialog state before attempting
login_state.error_message = None;
app_state.hide_dialog(); // Hide any previous dialog
login_state.set_error_message(None);
app_state.hide_dialog();
// Call the gRPC login method
match auth_client.login(identifier.clone(), password).await
.with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier))
{
Ok(response) => {
// Store authentication details using correct field names
// Store authentication details
auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone());
auth_state.role = Some(UserRole::from_str(&response.role));
auth_state.decoded_username = Some(response.username.clone());
login_state.set_has_unsaved_changes(false);
login_state.error_message = None;
// Format the success message using response data
login_state.set_has_unsaved_changes(false);
login_state.set_error_message(None);
let success_message = format!(
"Login Successful!\n\n\
Username: {}\n\
User ID: {}\n\
Role: {}",
Username: {}\n\
User ID: {}\n\
Role: {}",
response.username,
response.user_id,
response.role
@@ -80,9 +79,11 @@ pub async fn save(
vec!["Menu".to_string(), "Exit".to_string()],
DialogPurpose::LoginSuccess,
);
login_state.password.clear();
login_state.username.clear();
login_state.current_cursor_pos = 0;
login_state.username_mut().clear();
login_state.password_mut().clear();
login_state.set_current_cursor_pos(0);
Ok("Login successful, details shown in dialog.".to_string())
}
Err(e) => {
@@ -93,10 +94,10 @@ pub async fn save(
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.error_message = Some(error_message.clone());
login_state.set_error_message(Some(error_message.clone()));
login_state.set_has_unsaved_changes(true);
login_state.username.clear();
login_state.password.clear();
login_state.username_mut().clear();
login_state.password_mut().clear();
Err(e)
}
}
@@ -104,56 +105,43 @@ pub async fn save(
/// Reverts the login form fields to empty and returns to the previous screen (Intro).
pub async fn revert(
login_state: &mut LoginState,
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere
login_state: &mut LoginFormState,
app_state: &mut AppState,
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset on revert
login_state.clear();
app_state.hide_dialog();
"Login reverted".to_string()
}
/// Clears login form and navigates back to main menu.
pub async fn back_to_main(
login_state: &mut LoginState,
login_state: &mut LoginFormState,
app_state: &mut AppState,
buffer_state: &mut BufferState,
) -> String {
// Clear the input fields
login_state.username.clear();
login_state.password.clear();
login_state.error_message = None;
login_state.set_has_unsaved_changes(false);
login_state.login_request_pending = false; // Ensure flag is reset
// Ensure dialog is hidden if revert is called
login_state.clear();
app_state.hide_dialog();
// Navigation logic
buffer_state.close_active_buffer();
buffer_state.update_history(AppView::Intro);
// Reset focus state
app_state.ui.focus_outside_canvas = false;
app_state.focused_button_index= 0;
app_state.focused_button_index = 0;
"Returned to main menu".to_string()
}
/// Validates input, shows loading, and spawns the login task.
pub fn initiate_login(
login_state: &LoginState,
login_state: &mut LoginFormState,
app_state: &mut AppState,
mut auth_client: AuthClient,
sender: mpsc::Sender<LoginResult>,
) -> String {
let username = login_state.username.clone();
let password = login_state.password.clone();
login_state.sync_from_editor();
let username = login_state.username().to_string();
let password = login_state.password().to_string();
// 1. Client-side validation
if username.trim().is_empty() {
app_state.show_dialog(
"Login Failed",
@@ -163,25 +151,20 @@ pub fn initiate_login(
);
"Username cannot be empty.".to_string()
} else {
// 2. Show Loading Dialog
app_state.show_loading_dialog("Logging In", "Please wait...");
// 3. Spawn the login task
spawn(async move {
// Use the passed-in (and moved) auth_client directly
let login_outcome = match auth_client.login(username.clone(), password).await
.with_context(|| format!("Spawned login task failed for identifier: {}", username))
{
Ok(response) => LoginResult::Success(response),
Err(e) => LoginResult::Failure(format!("{}", e)),
};
// Send result back to the main UI thread
{
Ok(response) => LoginResult::Success(response),
Err(e) => LoginResult::Failure(format!("{}", e)),
};
if let Err(e) = sender.send(login_outcome).await {
error!("Failed to send login result: {}", e);
}
});
// 4. Return immediately
"Login initiated.".to_string()
}
}
@@ -192,28 +175,24 @@ pub fn handle_login_result(
result: LoginResult,
app_state: &mut AppState,
auth_state: &mut AuthState,
login_state: &mut LoginState,
login_state: &mut LoginFormState,
) -> bool {
match result {
LoginResult::Success(response) => {
auth_state.auth_token = Some(response.access_token.clone());
auth_state.user_id = Some(response.user_id.clone());
auth_state.role = Some(response.role.clone());
auth_state.role = Some(UserRole::from_str(&response.role));
auth_state.decoded_username = Some(response.username.clone());
// --- NEW: Save auth data to file ---
let data_to_store = StoredAuthData {
access_token: response.access_token.clone(),
user_id: response.user_id.clone(),
role: response.role.clone(),
username: response.username.clone(),
};
if let Err(e) = save_auth_data(&data_to_store) {
error!("Failed to save auth data to file: {}", e);
// Continue anyway - user is still logged in for this session
}
// --- END NEW ---
let success_message = format!(
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
@@ -227,14 +206,28 @@ pub fn handle_login_result(
info!(message = %success_message, "Login successful");
}
LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => {
app_state.update_dialog_content(&err_msg, vec!["OK".to_string()], DialogPurpose::LoginFailed);
login_state.error_message = Some(err_msg.clone());
app_state.update_dialog_content(
&err_msg,
vec!["OK".to_string()],
DialogPurpose::LoginFailed,
);
login_state.set_error_message(Some(err_msg.clone()));
error!(error = %err_msg, "Login failed/connection error");
}
}
login_state.username.clear();
login_state.password.clear();
login_state.username_mut().clear();
login_state.password_mut().clear();
login_state.set_has_unsaved_changes(false);
login_state.current_cursor_pos = 0;
true // Request redraw as dialog content changed
login_state.set_current_cursor_pos(0);
true
}
pub async fn handle_action(action: &str) -> Result<String> {
match action {
"previous_entry" => Ok("Previous entry not implemented".into()),
"next_entry" => Ok("Next entry not implemented".into()),
_ => Err(anyhow!("Unknown login action: {}", action)),
}
}

View File

@@ -0,0 +1,11 @@
// src/pages/login/mod.rs
pub mod state;
pub mod ui;
pub mod logic;
pub mod event;
pub use state::*;
pub use ui::render_login;
pub use logic::*;
pub use event::*;

View File

@@ -0,0 +1,248 @@
// src/pages/login/state.rs
use canvas::{AppMode, DataProvider};
use canvas::FormEditor;
use std::fmt;
#[derive(Debug, Clone)]
pub struct LoginState {
pub username: String,
pub password: String,
pub error_message: Option<String>,
pub current_field: usize,
pub current_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub login_request_pending: bool,
pub app_mode: AppMode,
}
impl Default for LoginState {
fn default() -> Self {
Self {
username: String::new(),
password: String::new(),
error_message: None,
current_field: 0,
current_cursor_pos: 0,
has_unsaved_changes: false,
login_request_pending: false,
app_mode: canvas::AppMode::Edit,
}
}
}
impl LoginState {
pub fn new() -> Self {
Self {
app_mode: canvas::AppMode::Edit,
..Default::default()
}
}
pub fn current_field(&self) -> usize {
self.current_field
}
pub fn current_cursor_pos(&self) -> usize {
self.current_cursor_pos
}
pub fn set_current_field(&mut self, index: usize) {
if index < 2 {
self.current_field = index;
}
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.current_cursor_pos = pos;
}
pub fn get_current_input(&self) -> &str {
match self.current_field {
0 => &self.username,
1 => &self.password,
_ => "",
}
}
pub fn get_current_input_mut(&mut self) -> &mut String {
match self.current_field {
0 => &mut self.username,
1 => &mut self.password,
_ => panic!("Invalid current_field index in LoginState"),
}
}
pub fn current_mode(&self) -> AppMode {
self.app_mode
}
pub fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
}
// Implement DataProvider for LoginState
impl DataProvider for LoginState {
fn field_count(&self) -> usize {
2
}
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Username/Email",
1 => "Password",
_ => "",
}
}
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.username,
1 => &self.password,
_ => "",
}
}
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.username = value,
1 => self.password = value,
_ => {}
}
self.has_unsaved_changes = true;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false // Login form doesn't support suggestions
}
}
/// Wrapper that owns both the raw login state and its editor
pub struct LoginFormState {
pub state: LoginState,
pub editor: FormEditor<LoginState>,
pub focus_outside_canvas: bool,
pub focused_button_index: usize,
}
// manual debug because FormEditor doesnt implement debug
impl fmt::Debug for LoginFormState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LoginFormState")
.field("state", &self.state) // ✅ only print the data
.finish()
}
}
impl LoginFormState {
/// Sync the editor's data provider back into our state
pub fn sync_from_editor(&mut self) {
// FormEditor holds the authoritative data
let dp = self.editor.data_provider();
self.state = dp.clone(); // LoginState implements Clone
}
/// Create a new LoginFormState with default LoginState and FormEditor
pub fn new() -> Self {
let state = LoginState::default();
let editor = FormEditor::new(state.clone());
Self {
state,
editor,
focus_outside_canvas: false,
focused_button_index: 0,
}
}
// === Delegates to LoginState fields ===
pub fn username(&self) -> &str {
&self.state.username
}
pub fn username_mut(&mut self) -> &mut String {
&mut self.state.username
}
pub fn password(&self) -> &str {
&self.state.password
}
pub fn password_mut(&mut self) -> &mut String {
&mut self.state.password
}
pub fn error_message(&self) -> Option<&String> {
self.state.error_message.as_ref()
}
pub fn set_error_message(&mut self, msg: Option<String>) {
self.state.error_message = msg;
}
pub fn has_unsaved_changes(&self) -> bool {
self.state.has_unsaved_changes
}
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
self.state.has_unsaved_changes = changed;
}
pub fn clear(&mut self) {
self.state.username.clear();
self.state.password.clear();
self.state.error_message = None;
self.state.has_unsaved_changes = false;
self.state.login_request_pending = false;
self.state.current_cursor_pos = 0;
}
// === Delegates to LoginState cursor/input ===
pub fn current_field(&self) -> usize {
self.state.current_field()
}
pub fn set_current_field(&mut self, index: usize) {
self.state.set_current_field(index);
}
pub fn current_cursor_pos(&self) -> usize {
self.state.current_cursor_pos()
}
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.state.set_current_cursor_pos(pos);
}
pub fn get_current_input(&self) -> &str {
self.state.get_current_input()
}
pub fn get_current_input_mut(&mut self) -> &mut String {
self.state.get_current_input_mut()
}
// === Delegates to FormEditor ===
pub fn mode(&self) -> AppMode {
self.editor.mode()
}
pub fn cursor_position(&self) -> usize {
self.editor.cursor_position()
}
pub fn handle_key_event(
&mut self,
key_event: crossterm::event::KeyEvent,
) -> canvas::keymap::KeyEventOutcome {
self.editor.handle_key_event(key_event)
}
}

View File

@@ -1,9 +1,7 @@
// src/components/auth/login.rs
// src/pages/login/ui.rs
use crate::{
config::colors::themes::Theme,
state::pages::auth::LoginState,
components::common::dialog,
state::app::state::AppState,
};
use ratatui::{
@@ -12,27 +10,26 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::state::app::highlight::HighlightState;
use canvas::canvas::{render_canvas, HighlightState as CanvasHighlightState}; // Use canvas library's render function
use canvas::{
FormEditor,
render_canvas,
render_suggestions_dropdown,
DefaultCanvasTheme,
};
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
use crate::pages::login::LoginFormState;
use crate::dialog;
pub fn render_login(
f: &mut Frame,
area: Rect,
theme: &Theme,
login_state: &LoginState,
login_page: &LoginFormState,
app_state: &AppState,
is_edit_mode: bool,
highlight_state: &HighlightState,
) {
let login_state = &login_page.state;
let editor = &login_page.editor;
// Main container
let block = Block::default()
.borders(Borders::ALL)
@@ -58,15 +55,11 @@ pub fn render_login(
])
.split(inner_area);
// --- FORM RENDERING (Using canvas library directly) ---
let canvas_highlight_state = convert_highlight_state(highlight_state);
render_canvas(
let input_rect = render_canvas(
f,
chunks[0],
login_state, // LoginState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode,
&canvas_highlight_state,
editor,
&DefaultCanvasTheme,
);
// --- ERROR MESSAGE ---
@@ -87,8 +80,8 @@ pub fn render_login(
// Login Button
let login_button_index = 0;
let login_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index== login_button_index
let login_active = if login_page.focus_outside_canvas {
app_state.focused_button_index == login_button_index
} else {
false
};
@@ -115,7 +108,7 @@ pub fn render_login(
// Return Button
let return_button_index = 1;
let return_active = if app_state.ui.focus_outside_canvas {
app_state.focused_button_index== return_button_index
app_state.focused_button_index == return_button_index
} else {
false
};
@@ -139,6 +132,19 @@ pub fn render_login(
button_chunks[1],
);
// --- SUGGESTIONS DROPDOWN (if active) ---
if editor.mode() == canvas::AppMode::Edit {
if let Some(input_rect) = input_rect {
render_suggestions_dropdown(
f,
chunks[0],
input_rect,
&DefaultCanvasTheme,
editor,
);
}
}
// --- DIALOG ---
if app_state.ui.dialog.dialog_show {
dialog::render_dialog(

9
client/src/pages/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
// src/pages/mod.rs
pub mod routing;
pub mod intro;
pub mod login;
pub mod register;
pub mod forms;
pub mod admin;
pub mod admin_panel;

View File

@@ -0,0 +1,76 @@
// src/pages/register/event.rs
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
use canvas::DataProvider;
use crate::{
state::app::state::AppState,
pages::register::RegisterFormState,
modes::handlers::event::EventOutcome,
};
/// Handles all Register page-specific events.
/// Return a non-empty Ok(message) only when the page actually consumed the key,
/// otherwise return Ok("") to let global handling proceed.
pub fn handle_register_event(
event: Event,
app_state: &mut AppState,
register_page: &mut RegisterFormState,
)-> Result<EventOutcome> {
if let Event::Key(key_event) = event {
let key_code = key_event.code;
let modifiers = key_event.modifiers;
// From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button
if register_page.focus_outside_canvas
&& register_page.focused_button_index == 0
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
&& modifiers.is_empty()
{
register_page.focus_outside_canvas = false;
// Keep global in sync for now (cursor styling elsewhere still reads it)
app_state.ui.focus_outside_canvas = false;
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok(String::new()));
}
// Focus handoff: inside canvas → buttons
if !register_page.focus_outside_canvas {
let last_idx = register_page.editor.data_provider().field_count().saturating_sub(1);
let at_last = register_page.editor.current_field() >= last_idx;
if at_last
&& matches!(
(key_code, modifiers),
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
)
{
register_page.focus_outside_canvas = true;
register_page.focused_button_index = 0; // focus "Register" button
// Keep global in sync for now
app_state.ui.focus_outside_canvas = true;
app_state.focused_button_index = 0;
register_page.editor.set_mode(CanvasMode::ReadOnly);
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
}
}
// Forward to canvas if focus is inside
if !register_page.focus_outside_canvas {
match register_page.handle_key_event(key_event) {
KeyEventOutcome::Consumed(Some(msg)) => {
return Ok(EventOutcome::Ok(msg));
}
KeyEventOutcome::Consumed(None) => {
return Ok(EventOutcome::Ok("Register input updated".into()));
}
KeyEventOutcome::Pending => {
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
}
KeyEventOutcome::NotMatched => {
// fall through
}
}
}
}
Ok(EventOutcome::Ok(String::new()))
}

Some files were not shown because too many files have changed in this diff Show More