Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a4b2d69c | ||
|
|
e6072d25c5 | ||
|
|
fc2b65601e | ||
|
|
597bdde7e1 | ||
|
|
f56092e86c | ||
|
|
d5cfe59f47 | ||
|
|
f281eaa662 | ||
|
|
cbb3ed7c48 | ||
|
|
41a0b85376 | ||
|
|
b5a31ee81c | ||
|
|
dceb031822 | ||
|
|
78bc9fc432 | ||
|
|
b9072e4d7c | ||
|
|
5d97e63f93 | ||
|
|
957f5bf9f0 | ||
|
|
6833ac5fad | ||
|
|
3dff2ced6c | ||
|
|
ea7ff3796f | ||
|
|
310617d62b | ||
|
|
1d94e82f4b | ||
|
|
00dad5d673 | ||
|
|
414c6957e7 | ||
|
|
f127298e5a | ||
|
|
f49899e66d | ||
|
|
5717c88857 | ||
|
|
ae8aa16208 | ||
|
|
4ed8e7b421 | ||
|
|
3dd6808ea2 | ||
|
|
f2b426851b | ||
|
|
f9e0833bcf | ||
|
|
11b073c2fd | ||
|
|
1320884409 | ||
|
|
aea2c39215 | ||
|
|
4c2464ab30 | ||
|
|
26053a5fd8 | ||
|
|
589220a2ba | ||
|
|
2cda54633f | ||
|
|
3eea6b9e88 | ||
|
|
db9bb7e168 | ||
|
|
3ccd094a22 | ||
|
|
032f21edaa | ||
|
|
42eb087363 | ||
|
|
d0ff449e3b | ||
|
|
858f5137d8 |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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>"]
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
376
canvas/examples/canvas_keymap.rs
Normal file
376
canvas/examples/canvas_keymap.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
228
canvas/src/editor/key_input.rs
Normal file
228
canvas/src/editor/key_input.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
344
canvas/src/keymap/mod.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -40,7 +40,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 +50,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 +91,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"]
|
||||
|
||||
@@ -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
|
||||
97
client/src/bottom_panel/layout.rs
Normal file
97
client/src/bottom_panel/layout.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
// 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;
|
||||
|
||||
/// 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,
|
||||
is_event_handler_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
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,
|
||||
is_event_handler_edit_mode,
|
||||
current_fps,
|
||||
app_state,
|
||||
);
|
||||
|
||||
// --- 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
client/src/bottom_panel/mod.rs
Normal file
6
client/src/bottom_panel/mod.rs
Normal 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;
|
||||
@@ -5,10 +5,9 @@ use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span, Text},
|
||||
widgets::Paragraph,
|
||||
widgets::{Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 {
|
||||
20
client/src/buffer/logic.rs
Normal file
20
client/src/buffer/logic.rs
Normal 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
11
client/src/buffer/mod.rs
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/state/app/buffer.rs
|
||||
// src/buffer/state/buffer.rs
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppView {
|
||||
@@ -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,
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/components/admin/add_logic.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 canvas::{render_canvas, FormEditor};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -11,18 +10,10 @@ 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,
|
||||
@@ -30,7 +21,6 @@ pub fn render_add_logic(
|
||||
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 ")
|
||||
@@ -168,19 +158,12 @@ 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 is_edit_mode && 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 {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/components/admin/add_table.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 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.
|
||||
@@ -31,7 +21,6 @@ pub fn render_add_table(
|
||||
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 +346,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| {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// src/components/form.rs
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
|
||||
pub use login::*;
|
||||
pub use register::*;
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/components/form.rs
|
||||
pub mod form;
|
||||
|
||||
pub use form::*;
|
||||
@@ -1,6 +0,0 @@
|
||||
// src/components/handlers.rs
|
||||
pub mod sidebar;
|
||||
pub mod buffer_list;
|
||||
|
||||
pub use sidebar::*;
|
||||
pub use buffer_list::*;
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/components/intro.rs
|
||||
pub mod intro;
|
||||
|
||||
pub use intro::*;
|
||||
@@ -1,16 +1,9 @@
|
||||
// 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::*;
|
||||
|
||||
@@ -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)]
|
||||
@@ -760,4 +761,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
85
client/src/dialog/functions.rs
Normal file
85
client/src/dialog/functions.rs
Normal 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
207
client/src/dialog/logic.rs
Normal 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::tui::functions::common::add_table::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
10
client/src/dialog/mod.rs
Normal 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;
|
||||
26
client/src/dialog/state.rs
Normal file
26
client/src/dialog/state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// src/dialog/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/functions/common.rs
|
||||
|
||||
pub mod buffer;
|
||||
|
||||
pub use buffer::*;
|
||||
@@ -1,6 +1,5 @@
|
||||
// src/functions/mod.rs
|
||||
|
||||
pub mod common;
|
||||
pub mod modes;
|
||||
|
||||
pub use modes::*;
|
||||
|
||||
@@ -3,16 +3,17 @@ 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 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; // Ensure this import is present
|
||||
use tui_textarea::CursorMove;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
@@ -20,37 +21,23 @@ 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
|
||||
save_logic_sender: SaveLogicResultSender,
|
||||
command_message: &mut String,
|
||||
router: &mut Router,
|
||||
) -> 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();
|
||||
if let Page::AddLogic(add_logic_state) = &mut router.current {
|
||||
|
||||
// === 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();
|
||||
@@ -61,180 +48,131 @@ pub fn handle_add_logic_navigation(
|
||||
&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,
|
||||
);
|
||||
}
|
||||
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
|
||||
return true;
|
||||
}
|
||||
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 {
|
||||
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();
|
||||
|
||||
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);
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
|
||||
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
|
||||
if let Some(pos) = trigger_pos {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
|
||||
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);
|
||||
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 {
|
||||
*command_message = format!("Inserted: {}", suggestion);
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
@@ -244,184 +182,252 @@ pub fn handle_add_logic_navigation(
|
||||
&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();
|
||||
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,
|
||||
);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
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();
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
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,
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
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("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("next_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
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,
|
||||
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 =>
|
||||
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();
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => 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);
|
||||
*command_message = "Cancelled Add Logic".to_string();
|
||||
*is_edit_mode = 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) {
|
||||
}
|
||||
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
|
||||
} else {
|
||||
return false; // not on AddLogic page
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
fn replace_autocomplete_text(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use crate::state::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::buffer::state::{BufferState, AppView};
|
||||
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
|
||||
|
||||
@@ -8,6 +8,12 @@ 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 use ui::run_ui;
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/client/modes/canvas.rs
|
||||
pub mod edit;
|
||||
pub mod common_mode;
|
||||
pub mod read_only;
|
||||
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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,13 +96,9 @@ async fn process_command(
|
||||
} else {
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
},
|
||||
}
|
||||
"save" => {
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
let outcome = save(app_state, grpc_client).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
|
||||
@@ -128,15 +106,12 @@ async fn process_command(
|
||||
};
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
},
|
||||
}
|
||||
"revert" => {
|
||||
let message = revert(
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
let message = revert(app_state, grpc_client).await?;
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
let message = format!("Unhandled action: {}", action);
|
||||
command_input.clear();
|
||||
|
||||
@@ -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,15 @@ 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(state) => state.has_unsaved_changes(),
|
||||
Page::Register(state) => state.has_unsaved_changes(),
|
||||
Page::Form(fs) => fs.has_unsaved_changes,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !has_unsaved {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/client/modes/general.rs
|
||||
pub mod navigation;
|
||||
pub mod dialog;
|
||||
pub mod command_navigation;
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -36,27 +29,31 @@ 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);
|
||||
move_up(app_state, router);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"move_down" => {
|
||||
move_down(app_state, intro_state, admin_state);
|
||||
move_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 Some(fs) = app_state.form_state_mut() {
|
||||
next_field(fs);
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"prev_field" => {
|
||||
prev_field(form_state);
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
prev_field(fs);
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"enter_command_mode" => {
|
||||
@@ -64,18 +61,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 +85,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 move_up(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Login(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.field_count().saturating_sub(1);
|
||||
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.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 move_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 +177,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
@@ -2,52 +2,62 @@
|
||||
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
|
||||
Highlight, // Canvas highlight/visual mode
|
||||
Command, // Command mode overlay
|
||||
}
|
||||
|
||||
impl From<canvas::AppMode> for AppMode {
|
||||
fn from(mode: canvas::AppMode) -> Self {
|
||||
match mode {
|
||||
canvas::AppMode::General => AppMode::General,
|
||||
canvas::AppMode::ReadOnly => AppMode::ReadOnly,
|
||||
canvas::AppMode::Edit => AppMode::Edit,
|
||||
canvas::AppMode::Highlight => AppMode::Highlight,
|
||||
canvas::AppMode::Command => AppMode::Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ModeManager;
|
||||
|
||||
impl ModeManager {
|
||||
// Determine current mode based on app state
|
||||
/// Determine current mode based on app state + router
|
||||
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 mode flag
|
||||
if event_handler.command_mode {
|
||||
return AppMode::Command;
|
||||
}
|
||||
|
||||
if !matches!(event_handler.highlight_state, HighlightState::Off) {
|
||||
return AppMode::Highlight;
|
||||
}
|
||||
match &router.current {
|
||||
// --- Form view ---
|
||||
Page::Form(_) if !app_state.ui.focus_outside_canvas => {
|
||||
if let Some(editor) = &app_state.form_editor {
|
||||
return AppMode::from(editor.mode());
|
||||
}
|
||||
AppMode::General
|
||||
}
|
||||
|
||||
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 {
|
||||
// --- AddLogic view ---
|
||||
Page::AddLogic(state) => match state.current_focus {
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription => {
|
||||
// These are canvas inputs
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
@@ -55,34 +65,35 @@ impl ModeManager {
|
||||
}
|
||||
}
|
||||
_ => AppMode::General,
|
||||
}
|
||||
} else if app_state.ui.show_add_table {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
AppMode::General
|
||||
} else {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
},
|
||||
|
||||
// --- AddTable view ---
|
||||
Page::AddTable(_) => {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
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 {
|
||||
|
||||
// --- Login/Register views ---
|
||||
Page::Login(_) | Page::Register(_) => {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AppMode::General
|
||||
|
||||
// --- Everything else (Intro, Admin, etc.) ---
|
||||
_ => AppMode::General,
|
||||
}
|
||||
}
|
||||
|
||||
// Mode transition rules
|
||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
!matches!(current_mode, AppMode::Edit)
|
||||
}
|
||||
|
||||
@@ -91,7 +102,10 @@ pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
}
|
||||
|
||||
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
|
||||
matches!(
|
||||
current_mode,
|
||||
AppMode::Edit | AppMode::Command | AppMode::Highlight
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// src/client/modes/highlight.rs
|
||||
pub mod highlight;
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
182
client/src/pages/forms/logic.rs
Normal file
182
client/src/pages/forms/logic.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
// 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,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<SaveOutcome> {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
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_mut() {
|
||||
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_mut() {
|
||||
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,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<String> {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
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())
|
||||
}
|
||||
9
client/src/pages/forms/mod.rs
Normal file
9
client/src/pages/forms/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/pages/forms/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod state;
|
||||
pub mod logic;
|
||||
|
||||
pub use ui::*;
|
||||
pub use state::*;
|
||||
pub use logic::*;
|
||||
@@ -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,
|
||||
@@ -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(¤t_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)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
// 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 crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::pages::forms::FormState;
|
||||
use canvas::{
|
||||
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme,
|
||||
};
|
||||
|
||||
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],
|
||||
app_state: &AppState,
|
||||
form_state: &FormState, // not needed directly anymore, editor holds it
|
||||
table_name: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
@@ -56,43 +54,25 @@ 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,
|
||||
);
|
||||
// --- FORM RENDERING (Using persistent FormEditor) ---
|
||||
if let Some(editor) = &app_state.form_editor {
|
||||
let active_field_rect = render_canvas(f, main_layout[1], editor, theme);
|
||||
|
||||
// --- RENDER RICH AUTOCOMPLETE ONLY ---
|
||||
if form_state.autocomplete_active {
|
||||
// --- SUGGESTIONS DROPDOWN ---
|
||||
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!
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
main_layout[1],
|
||||
active_rect,
|
||||
&DefaultCanvasTheme,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/tui/functions/intro.rs
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::app::buffer::{AppView, BufferState};
|
||||
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)
|
||||
9
client/src/pages/intro/mod.rs
Normal file
9
client/src/pages/intro/mod.rs
Normal 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::*;
|
||||
@@ -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()
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
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 crate::pages::login::LoginState;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, error};
|
||||
use anyhow::anyhow;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LoginResult {
|
||||
@@ -238,3 +238,15 @@ pub fn handle_login_result(
|
||||
login_state.current_cursor_pos = 0;
|
||||
true // Request redraw as dialog content changed
|
||||
}
|
||||
|
||||
pub async fn handle_action(action: &str,) -> Result<String> {
|
||||
match action {
|
||||
"previous_entry" => {
|
||||
Ok("Previous entry at tui/functions/login.rs not implemented".into())
|
||||
}
|
||||
"next_entry" => {
|
||||
Ok("Next entry at tui/functions/login.rs not implemented".into())
|
||||
}
|
||||
_ => Err(anyhow!("Unknown login action: {}", action))
|
||||
}
|
||||
}
|
||||
9
client/src/pages/login/mod.rs
Normal file
9
client/src/pages/login/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/pages/login/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
|
||||
pub use state::*;
|
||||
pub use ui::render_login;
|
||||
pub use logic::*;
|
||||
121
client/src/pages/login/state.rs
Normal file
121
client/src/pages/login/state.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/pages/login/state.rs
|
||||
|
||||
use canvas::{AppMode, DataProvider};
|
||||
|
||||
#[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: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app_mode: 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
|
||||
}
|
||||
}
|
||||
@@ -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,26 +10,23 @@ 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
|
||||
|
||||
// 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 canvas::{
|
||||
FormEditor,
|
||||
render_canvas,
|
||||
render_suggestions_dropdown,
|
||||
DefaultCanvasTheme,
|
||||
};
|
||||
use crate::pages::login::LoginState;
|
||||
use crate::dialog;
|
||||
|
||||
pub fn render_login(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
// FIX: take &LoginState (reference), not owned
|
||||
login_state: &LoginState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
// Main container
|
||||
let block = Block::default()
|
||||
@@ -58,15 +53,15 @@ pub fn render_login(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
render_canvas(
|
||||
// Wrap LoginState in FormEditor (no clone needed)
|
||||
let editor = FormEditor::new(login_state.clone());
|
||||
|
||||
// Use DefaultCanvasTheme instead of app Theme
|
||||
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 ---
|
||||
@@ -88,7 +83,7 @@ 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
|
||||
app_state.focused_button_index == login_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -115,7 +110,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 +134,19 @@ pub fn render_login(
|
||||
button_chunks[1],
|
||||
);
|
||||
|
||||
// --- SUGGESTIONS DROPDOWN (if active) ---
|
||||
if app_state.current_mode == crate::modes::handlers::mode_manager::AppMode::Edit {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
f.area(),
|
||||
input_rect,
|
||||
&DefaultCanvasTheme,
|
||||
&editor, // FIX: pass &editor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- DIALOG ---
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
7
client/src/pages/mod.rs
Normal file
7
client/src/pages/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/pages/mod.rs
|
||||
|
||||
pub mod routing;
|
||||
pub mod intro;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
pub mod forms;
|
||||
@@ -1,14 +1,13 @@
|
||||
// src/tui/functions/common/register.rs
|
||||
// src/pages/register/logic.rs
|
||||
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::state::{
|
||||
pages::auth::RegisterState,
|
||||
app::state::AppState,
|
||||
};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::app::buffer::{AppView, BufferState};
|
||||
use crate::buffer::state::{AppView, BufferState};
|
||||
use common::proto::komp_ac::auth::AuthResponse;
|
||||
use canvas::canvas::CanvasState;
|
||||
use crate::pages::register::RegisterState;
|
||||
use anyhow::Context;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
11
client/src/pages/register/mod.rs
Normal file
11
client/src/pages/register/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/pages/register/mod.rs
|
||||
|
||||
// pub mod state;
|
||||
pub mod ui;
|
||||
pub mod state;
|
||||
pub mod logic;
|
||||
|
||||
// pub use state::*;
|
||||
pub use ui::render_register;
|
||||
pub use logic::*;
|
||||
pub use state::*;
|
||||
184
client/src/pages/register/state.rs
Normal file
184
client/src/pages/register/state.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// src/pages/register/state.rs
|
||||
|
||||
use canvas::{DataProvider, AppMode};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref AVAILABLE_ROLES: Vec<String> = vec![
|
||||
"admin".to_string(),
|
||||
"moderator".to_string(),
|
||||
"accountant".to_string(),
|
||||
"viewer".to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
/// Represents the state of the Registration form UI
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegisterState {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub password_confirmation: String,
|
||||
pub role: String,
|
||||
pub error_message: Option<String>,
|
||||
pub current_field: usize,
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub app_mode: AppMode,
|
||||
pub role_suggestions: Vec<String>,
|
||||
pub role_suggestions_active: bool,
|
||||
}
|
||||
|
||||
impl Default for RegisterState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
email: String::new(),
|
||||
password: String::new(),
|
||||
password_confirmation: String::new(),
|
||||
role: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
app_mode: AppMode::Edit,
|
||||
role_suggestions: AVAILABLE_ROLES.clone(),
|
||||
role_suggestions_active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegisterState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app_mode: AppMode::Edit,
|
||||
role_suggestions: AVAILABLE_ROLES.clone(),
|
||||
role_suggestions_active: false,
|
||||
..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 < 5 {
|
||||
self.current_field = index;
|
||||
|
||||
if index == 4 {
|
||||
self.activate_role_suggestions();
|
||||
} else {
|
||||
self.deactivate_role_suggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.email,
|
||||
2 => &self.password,
|
||||
3 => &self.password_confirmation,
|
||||
4 => &self.role,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_current_input_mut(&mut self, index: usize) -> &mut String {
|
||||
match index {
|
||||
0 => &mut self.username,
|
||||
1 => &mut self.email,
|
||||
2 => &mut self.password,
|
||||
3 => &mut self.password_confirmation,
|
||||
4 => &mut self.role,
|
||||
_ => panic!("Invalid current_field index in RegisterState"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
|
||||
pub fn activate_role_suggestions(&mut self) {
|
||||
self.role_suggestions_active = true;
|
||||
let current_input = self.role.to_lowercase();
|
||||
self.role_suggestions = AVAILABLE_ROLES
|
||||
.iter()
|
||||
.filter(|role| role.to_lowercase().contains(¤t_input))
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub fn deactivate_role_suggestions(&mut self) {
|
||||
self.role_suggestions_active = false;
|
||||
}
|
||||
|
||||
pub fn is_role_suggestions_active(&self) -> bool {
|
||||
self.role_suggestions_active
|
||||
}
|
||||
|
||||
pub fn get_role_suggestions(&self) -> &[String] {
|
||||
&self.role_suggestions
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
impl DataProvider for RegisterState {
|
||||
fn field_count(&self) -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
match index {
|
||||
0 => "Username",
|
||||
1 => "Email (Optional)",
|
||||
2 => "Password (Optional)",
|
||||
3 => "Confirm Password",
|
||||
4 => "Role (Optional)",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn field_value(&self, index: usize) -> &str {
|
||||
match index {
|
||||
0 => &self.username,
|
||||
1 => &self.email,
|
||||
2 => &self.password,
|
||||
3 => &self.password_confirmation,
|
||||
4 => &self.role,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn set_field_value(&mut self, index: usize, value: String) {
|
||||
match index {
|
||||
0 => self.username = value,
|
||||
1 => self.email = value,
|
||||
2 => self.password = value,
|
||||
3 => self.password_confirmation = value,
|
||||
4 => self.role = value,
|
||||
_ => {}
|
||||
}
|
||||
self.has_unsaved_changes = true;
|
||||
}
|
||||
|
||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
||||
field_index == 4 // only Role field supports suggestions
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
// src/components/auth/register.rs
|
||||
// src/pages/register/ui.rs
|
||||
|
||||
use crate::{
|
||||
config::colors::themes::Theme,
|
||||
state::pages::auth::RegisterState,
|
||||
components::common::dialog,
|
||||
state::app::state::AppState,
|
||||
modes::handlers::mode_manager::AppMode,
|
||||
};
|
||||
@@ -13,19 +11,9 @@ 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::autocomplete::gui::render_autocomplete_dropdown;
|
||||
use canvas::autocomplete::AutocompleteCanvasState;
|
||||
|
||||
// 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;
|
||||
use crate::pages::register::RegisterState;
|
||||
use canvas::{FormEditor, render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
|
||||
|
||||
pub fn render_register(
|
||||
f: &mut Frame,
|
||||
@@ -34,7 +22,6 @@ pub fn render_register(
|
||||
state: &RegisterState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -60,15 +47,14 @@ pub fn render_register(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// --- FORM RENDERING (Using canvas library directly) ---
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
// Wrap RegisterState in FormEditor
|
||||
let editor = FormEditor::new(state.clone());
|
||||
|
||||
let input_rect = render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
state, // RegisterState implements CanvasState
|
||||
theme, // Theme implements CanvasTheme
|
||||
is_edit_mode,
|
||||
&canvas_highlight_state,
|
||||
&editor,
|
||||
theme,
|
||||
);
|
||||
|
||||
// --- HELP TEXT ---
|
||||
@@ -96,7 +82,7 @@ pub fn render_register(
|
||||
// Register Button
|
||||
let register_button_index = 0;
|
||||
let register_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index== register_button_index
|
||||
app_state.focused_button_index == register_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -123,7 +109,7 @@ pub fn render_register(
|
||||
// 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
|
||||
};
|
||||
@@ -147,18 +133,16 @@ pub fn render_register(
|
||||
button_chunks[1],
|
||||
);
|
||||
|
||||
// --- AUTOCOMPLETE DROPDOWN (Using canvas library directly) ---
|
||||
// --- AUTOCOMPLETE DROPDOWN (Using new canvas suggestions) ---
|
||||
if app_state.current_mode == AppMode::Edit {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state() {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_autocomplete_dropdown(
|
||||
f,
|
||||
f.area(), // Frame area
|
||||
input_rect, // Current input field rect
|
||||
theme, // Theme implements CanvasTheme
|
||||
autocomplete_state,
|
||||
);
|
||||
}
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
f.area(), // Frame area
|
||||
input_rect, // Current input field rect
|
||||
&DefaultCanvasTheme,
|
||||
&editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
5
client/src/pages/routing/mod.rs
Normal file
5
client/src/pages/routing/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/pages/routing/mod.rs
|
||||
|
||||
pub mod router;
|
||||
|
||||
pub use router::{Page, Router};
|
||||
38
client/src/pages/routing/router.rs
Normal file
38
client/src/pages/routing/router.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/pages/routing/router.rs
|
||||
use crate::state::pages::{
|
||||
admin::AdminState,
|
||||
auth::AuthState,
|
||||
add_logic::AddLogicState,
|
||||
add_table::AddTableState,
|
||||
};
|
||||
use crate::pages::forms::FormState;
|
||||
use crate::pages::login::LoginState;
|
||||
use crate::pages::register::RegisterState;
|
||||
use crate::pages::intro::IntroState;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Page {
|
||||
Intro(IntroState),
|
||||
Login(LoginState),
|
||||
Register(RegisterState),
|
||||
Admin(AdminState),
|
||||
AddLogic(AddLogicState),
|
||||
AddTable(AddTableState),
|
||||
Form(FormState),
|
||||
}
|
||||
|
||||
pub struct Router {
|
||||
pub current: Page,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current: Page::Intro(IntroState::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigate(&mut self, page: Page) {
|
||||
self.current = page;
|
||||
}
|
||||
}
|
||||
106
client/src/search/event.rs
Normal file
106
client/src/search/event.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
// src/search/event.rs
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::KeyCode;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
use std::collections::HashMap;
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn handle_search_palette_event(
|
||||
key_event: crossterm::event::KeyEvent,
|
||||
app_state: &mut AppState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
|
||||
) -> Result<Option<String>> {
|
||||
let mut should_close = false;
|
||||
let mut outcome_message = None;
|
||||
let mut trigger_search = false;
|
||||
|
||||
if let Some(search_state) = app_state.search_state.as_mut() {
|
||||
match key_event.code {
|
||||
KeyCode::Esc => {
|
||||
should_close = true;
|
||||
outcome_message = Some("Search cancelled".to_string());
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Step 1: Extract the data we need while holding the borrow
|
||||
let maybe_data = search_state
|
||||
.results
|
||||
.get(search_state.selected_index)
|
||||
.map(|hit| (hit.id, hit.content_json.clone()));
|
||||
|
||||
// Step 2: Process outside the borrow
|
||||
if let Some((id, content_json)) = maybe_data {
|
||||
if let Ok(data) = serde_json::from_str::<HashMap<String, String>>(&content_json) {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
let detached_pos = fs.total_count + 2;
|
||||
fs.update_from_response(&data, detached_pos);
|
||||
}
|
||||
should_close = true;
|
||||
outcome_message = Some(format!("Loaded record ID {}", id));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Up => search_state.previous_result(),
|
||||
KeyCode::Down => search_state.next_result(),
|
||||
KeyCode::Char(c) => {
|
||||
search_state.input.insert(search_state.cursor_position, c);
|
||||
search_state.cursor_position += 1;
|
||||
trigger_search = true;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if search_state.cursor_position > 0 {
|
||||
search_state.cursor_position -= 1;
|
||||
search_state.input.remove(search_state.cursor_position);
|
||||
trigger_search = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
search_state.cursor_position =
|
||||
search_state.cursor_position.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if search_state.cursor_position < search_state.input.len() {
|
||||
search_state.cursor_position += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if trigger_search {
|
||||
if let Some(search_state) = app_state.search_state.as_mut() {
|
||||
search_state.is_loading = true;
|
||||
search_state.results.clear();
|
||||
search_state.selected_index = 0;
|
||||
|
||||
let query = search_state.input.clone();
|
||||
let table_name = search_state.table_name.clone();
|
||||
let sender = search_result_sender.clone();
|
||||
let mut grpc_client = grpc_client.clone();
|
||||
|
||||
info!("Spawning search task for query: '{}'", query);
|
||||
tokio::spawn(async move {
|
||||
match grpc_client.search_table(table_name, query).await {
|
||||
Ok(response) => {
|
||||
let _ = sender.send(response.hits);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Search failed: {:?}", e);
|
||||
let _ = sender.send(vec![]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if should_close {
|
||||
app_state.search_state = None;
|
||||
app_state.ui.show_search_palette = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
}
|
||||
|
||||
Ok(outcome_message)
|
||||
}
|
||||
31
client/src/search/grpc.rs
Normal file
31
client/src/search/grpc.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
// src/search/grpc.rs
|
||||
|
||||
use common::proto::komp_ac::search::{
|
||||
searcher_client::SearcherClient, SearchRequest, SearchResponse,
|
||||
};
|
||||
use tonic::transport::Channel;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Internal search gRPC wrapper
|
||||
#[derive(Clone)]
|
||||
pub struct SearchGrpc {
|
||||
client: SearcherClient<Channel>,
|
||||
}
|
||||
|
||||
impl SearchGrpc {
|
||||
pub fn new(channel: Channel) -> Self {
|
||||
Self {
|
||||
client: SearcherClient::new(channel),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_table(
|
||||
&mut self,
|
||||
table_name: String,
|
||||
query: String,
|
||||
) -> Result<SearchResponse> {
|
||||
let request = tonic::Request::new(SearchRequest { table_name, query });
|
||||
let response = self.client.search_table(request).await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
}
|
||||
9
client/src/search/mod.rs
Normal file
9
client/src/search/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/search/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod event;
|
||||
pub mod grpc;
|
||||
|
||||
pub use ui::*;
|
||||
pub use grpc::SearchGrpc;
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/state/app/search.rs
|
||||
// src/search/state.rs
|
||||
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/common/search_palette.rs
|
||||
// src/search/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::search::SearchState;
|
||||
use crate::search::state::SearchState;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -22,9 +22,8 @@ use common::proto::komp_ac::tables_data::{
|
||||
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
|
||||
PutTableDataResponse,
|
||||
};
|
||||
use common::proto::komp_ac::search::{
|
||||
searcher_client::SearcherClient, SearchRequest, SearchResponse,
|
||||
};
|
||||
use crate::search::SearchGrpc;
|
||||
use common::proto::komp_ac::search::SearchResponse;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tonic::transport::Channel;
|
||||
@@ -36,7 +35,7 @@ pub struct GrpcClient {
|
||||
table_definition_client: TableDefinitionClient<Channel>,
|
||||
table_script_client: TableScriptClient<Channel>,
|
||||
tables_data_client: TablesDataClient<Channel>,
|
||||
search_client: SearcherClient<Channel>,
|
||||
search_client: SearchGrpc,
|
||||
}
|
||||
|
||||
impl GrpcClient {
|
||||
@@ -52,7 +51,7 @@ impl GrpcClient {
|
||||
TableDefinitionClient::new(channel.clone());
|
||||
let table_script_client = TableScriptClient::new(channel.clone());
|
||||
let tables_data_client = TablesDataClient::new(channel.clone());
|
||||
let search_client = SearcherClient::new(channel.clone());
|
||||
let search_client = SearchGrpc::new(channel.clone());
|
||||
|
||||
Ok(Self {
|
||||
table_structure_client,
|
||||
@@ -247,11 +246,6 @@ impl GrpcClient {
|
||||
table_name: String,
|
||||
query: String,
|
||||
) -> Result<SearchResponse> {
|
||||
let request = tonic::Request::new(SearchRequest { table_name, query });
|
||||
let response = self
|
||||
.search_client
|
||||
.search_table(request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
self.search_client.search_table(table_name, query).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::state::pages::form::{FieldDefinition, FormState};
|
||||
use crate::tui::functions::common::form::SaveOutcome;
|
||||
use crate::pages::forms::logic::SaveOutcome;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use crate::pages::forms::{FieldDefinition, FormState};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
21
client/src/sidebar/logic.rs
Normal file
21
client/src/sidebar/logic.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
// src/sidebar/state.rs
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::app::state::UiState;
|
||||
|
||||
pub fn toggle_sidebar(
|
||||
ui_state: &mut UiState,
|
||||
config: &Config,
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
) -> bool {
|
||||
if let Some(action) =
|
||||
config.get_action_for_key_in_mode(&config.keybindings.common, key, modifiers)
|
||||
{
|
||||
if action == "toggle_sidebar" {
|
||||
ui_state.show_sidebar = !ui_state.show_sidebar;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
7
client/src/sidebar/mod.rs
Normal file
7
client/src/sidebar/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/sidebar/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
|
||||
pub use ui::{calculate_sidebar_layout, render_sidebar};
|
||||
pub use logic::toggle_sidebar;
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/handlers/sidebar.rs
|
||||
// src/sidebar/ui.rs
|
||||
use ratatui::{
|
||||
widgets::{Block, List, ListItem},
|
||||
layout::{Rect, Direction, Layout, Constraint},
|
||||
@@ -1,6 +1,3 @@
|
||||
// src/state/app.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod buffer;
|
||||
pub mod search;
|
||||
pub mod highlight;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// src/state/app/highlight.rs
|
||||
|
||||
/// Represents the different states of text highlighting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HighlightState {
|
||||
/// Highlighting is inactive.
|
||||
Off,
|
||||
/// Highlighting character by character. Stores the anchor point (line index, char index).
|
||||
Characterwise { anchor: (usize, usize) },
|
||||
/// Highlighting line by line. Stores the anchor line index.
|
||||
Linewise { anchor_line: usize },
|
||||
}
|
||||
|
||||
impl Default for HighlightState {
|
||||
/// The default state is no highlighting.
|
||||
fn default() -> Self {
|
||||
HighlightState::Off
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,18 @@ use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||
// NEW: Import the types we need for the cache
|
||||
use common::proto::komp_ac::table_structure::TableStructureResponse;
|
||||
use crate::modes::handlers::mode_manager::AppMode;
|
||||
use crate::state::app::search::SearchState;
|
||||
use crate::search::state::SearchState;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::config::binds::Config;
|
||||
use crate::pages::forms::FormState;
|
||||
use canvas::FormEditor;
|
||||
use crate::dialog::DialogState;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "ui-debug")]
|
||||
use std::time::Instant;
|
||||
|
||||
// --- DialogState and UiState are unchanged ---
|
||||
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,
|
||||
}
|
||||
|
||||
pub struct UiState {
|
||||
pub show_sidebar: bool,
|
||||
pub show_buffer_list: bool,
|
||||
@@ -67,6 +60,8 @@ pub struct AppState {
|
||||
// UI preferences
|
||||
pub ui: UiState,
|
||||
|
||||
pub form_editor: Option<FormEditor<FormState>>,
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
pub debug_state: Option<DebugState>,
|
||||
}
|
||||
@@ -86,6 +81,7 @@ impl AppState {
|
||||
pending_table_structure_fetch: None,
|
||||
search_state: None,
|
||||
ui: UiState::default(),
|
||||
form_editor: None,
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
debug_state: None,
|
||||
@@ -103,82 +99,27 @@ impl AppState {
|
||||
self.current_view_table_name = Some(table_name);
|
||||
}
|
||||
|
||||
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 init_form_editor(&mut self, form_state: FormState, config: &Config) {
|
||||
let mut editor = FormEditor::new(form_state);
|
||||
editor.set_keymap(config.build_canvas_keymap()); // inject keymap
|
||||
self.form_editor = Some(editor);
|
||||
}
|
||||
|
||||
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;
|
||||
/// Replace the current form state and wrap it in a FormEditor with keymap
|
||||
pub fn set_form_state(&mut self, form_state: FormState, config: &Config) {
|
||||
let mut editor = FormEditor::new(form_state);
|
||||
editor.set_keymap(config.build_canvas_keymap());
|
||||
self.form_editor = Some(editor);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
/// Immutable access to the underlying FormState
|
||||
pub fn form_state(&self) -> Option<&FormState> {
|
||||
self.form_editor.as_ref().map(|e| e.data_provider())
|
||||
}
|
||||
|
||||
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())
|
||||
/// Mutable access to the underlying FormState
|
||||
pub fn form_state_mut(&mut self) -> Option<&mut FormState> {
|
||||
self.form_editor.as_mut().map(|e| e.data_provider_mut())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,17 +141,3 @@ impl Default for UiState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// src/state/pages.rs
|
||||
|
||||
pub mod form;
|
||||
pub mod auth;
|
||||
pub mod admin;
|
||||
pub mod intro;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/state/pages/add_logic.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;
|
||||
@@ -277,174 +277,41 @@ impl Default for AddLogicState {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/state/pages/add_table.rs
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
|
||||
use canvas::{DataProvider, AppMode};
|
||||
use ratatui::widgets::TableState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -170,137 +171,40 @@ impl AddTableState {
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for AddTableState
|
||||
impl CanvasState for AddTableState {
|
||||
fn current_field(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => 0,
|
||||
AddTableFocus::InputColumnName => 1,
|
||||
AddTableFocus::InputColumnType => 2,
|
||||
// If focus is elsewhere, return the last canvas field used
|
||||
_ => self.last_canvas_field,
|
||||
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 current_cursor_pos(&self) -> usize {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => self.table_name_cursor_pos,
|
||||
AddTableFocus::InputColumnName => self.column_name_cursor_pos,
|
||||
AddTableFocus::InputColumnType => self.column_type_cursor_pos,
|
||||
_ => 0, // Default if focus is not on an input field
|
||||
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_current_field(&mut self, index: usize) {
|
||||
// Update both current focus and last canvas field
|
||||
self.current_focus = match index {
|
||||
0 => {
|
||||
self.last_canvas_field = 0;
|
||||
AddTableFocus::InputTableName
|
||||
},
|
||||
1 => {
|
||||
self.last_canvas_field = 1;
|
||||
AddTableFocus::InputColumnName
|
||||
},
|
||||
2 => {
|
||||
self.last_canvas_field = 2;
|
||||
AddTableFocus::InputColumnType
|
||||
},
|
||||
_ => self.current_focus, // Stay on current focus if index is out of bounds
|
||||
};
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => self.table_name_cursor_pos = pos,
|
||||
AddTableFocus::InputColumnName => self.column_name_cursor_pos = pos,
|
||||
AddTableFocus::InputColumnType => self.column_type_cursor_pos = pos,
|
||||
_ => {} // Do nothing if focus is not on an input field
|
||||
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 get_current_input(&self) -> &str {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => &self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &self.column_type_input,
|
||||
_ => "", // Should not happen if called correctly
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_focus {
|
||||
AddTableFocus::InputTableName => &mut self.table_name_input,
|
||||
AddTableFocus::InputColumnName => &mut self.column_name_input,
|
||||
AddTableFocus::InputColumnType => &mut self.column_type_input,
|
||||
_ => &mut self.table_name_input, // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
// These must match the order used in render_add_table
|
||||
vec!["Table name", "Name", "Type"]
|
||||
}
|
||||
|
||||
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 adding column when user presses Enter on the Add button or uses specific action
|
||||
CanvasAction::Custom(action_str) if action_str == "add_column" => {
|
||||
self.add_column_from_inputs()
|
||||
}
|
||||
|
||||
// Handle table saving
|
||||
CanvasAction::Custom(action_str) if action_str == "save_table" => {
|
||||
if self.table_name_input.trim().is_empty() {
|
||||
Some("Table name is required".to_string())
|
||||
} else if self.columns.is_empty() {
|
||||
Some("At least one column is required".to_string())
|
||||
} else {
|
||||
Some(format!("Saving table: {}", self.table_name_input))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deleting selected items
|
||||
CanvasAction::Custom(action_str) if action_str == "delete_selected" => {
|
||||
self.delete_selected_items()
|
||||
}
|
||||
|
||||
// Handle canceling (clear form)
|
||||
CanvasAction::Custom(action_str) if action_str == "cancel" => {
|
||||
// Reset to defaults but keep profile_name
|
||||
let profile = self.profile_name.clone();
|
||||
*self = Self::default();
|
||||
self.profile_name = profile;
|
||||
Some("Form cleared".to_string())
|
||||
}
|
||||
|
||||
// Custom validation when moving between fields
|
||||
CanvasAction::NextField => {
|
||||
// When leaving table name field, update the table_name for display
|
||||
if self.current_field() == 0 && !self.table_name_input.trim().is_empty() {
|
||||
self.table_name = self.table_name_input.trim().to_string();
|
||||
}
|
||||
None // Let canvas library handle the normal field movement
|
||||
}
|
||||
|
||||
// Let canvas library handle everything else
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
||||
false // AddTableState doesn’t use suggestions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
// src/state/pages/auth.rs
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref AVAILABLE_ROLES: Vec<String> = vec![
|
||||
"admin".to_string(),
|
||||
"moderator".to_string(),
|
||||
"accountant".to_string(),
|
||||
"viewer".to_string(),
|
||||
];
|
||||
}
|
||||
use canvas::{DataProvider, AppMode};
|
||||
|
||||
/// Represents the authenticated session state
|
||||
#[derive(Default)]
|
||||
@@ -21,340 +11,8 @@ pub struct AuthState {
|
||||
pub decoded_username: Option<String>,
|
||||
}
|
||||
|
||||
/// Represents the state of the Login form UI
|
||||
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: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the Registration form UI
|
||||
#[derive(Clone)]
|
||||
pub struct RegisterState {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub password_confirmation: String,
|
||||
pub role: String,
|
||||
pub error_message: Option<String>,
|
||||
pub current_field: usize,
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub autocomplete: AutocompleteState<String>,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for RegisterState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
email: String::new(),
|
||||
password: String::new(),
|
||||
password_confirmation: String::new(),
|
||||
role: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegisterState {
|
||||
pub fn new() -> Self {
|
||||
let mut state = Self {
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Initialize autocomplete with role suggestions
|
||||
let suggestions: Vec<SuggestionItem<String>> = AVAILABLE_ROLES
|
||||
.iter()
|
||||
.map(|role| SuggestionItem::simple(role.clone(), role.clone()))
|
||||
.collect();
|
||||
|
||||
// Set suggestions but keep inactive initially
|
||||
state.autocomplete.set_suggestions(suggestions);
|
||||
state.autocomplete.is_active = false; // Not active by default
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for LoginState
|
||||
impl CanvasState for LoginState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
self.current_cursor_pos
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < 2 {
|
||||
self.current_field = index;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_field {
|
||||
0 => &self.username,
|
||||
1 => &self.password,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![&self.username, &self.password]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
vec!["Username/Email", "Password"]
|
||||
}
|
||||
|
||||
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 {
|
||||
CanvasAction::Custom(action_str) if action_str == "submit" => {
|
||||
if !self.username.is_empty() && !self.password.is_empty() {
|
||||
Some(format!("Submitting login for: {}", self.username))
|
||||
} else {
|
||||
Some("Please fill in all required fields".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for RegisterState
|
||||
impl CanvasState for RegisterState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
self.current_cursor_pos
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
if index < 5 {
|
||||
self.current_field = index;
|
||||
|
||||
// Auto-activate autocomplete when moving to role field (index 4)
|
||||
if index == 4 && !self.autocomplete.is_active {
|
||||
self.activate_autocomplete();
|
||||
} else if index != 4 && self.autocomplete.is_active {
|
||||
self.deactivate_autocomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.current_cursor_pos = pos;
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
match self.current_field {
|
||||
0 => &self.username,
|
||||
1 => &self.email,
|
||||
2 => &self.password,
|
||||
3 => &self.password_confirmation,
|
||||
4 => &self.role,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
match self.current_field {
|
||||
0 => &mut self.username,
|
||||
1 => &mut self.email,
|
||||
2 => &mut self.password,
|
||||
3 => &mut self.password_confirmation,
|
||||
4 => &mut self.role,
|
||||
_ => panic!("Invalid current_field index in RegisterState"),
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
vec![
|
||||
&self.username,
|
||||
&self.email,
|
||||
&self.password,
|
||||
&self.password_confirmation,
|
||||
&self.role,
|
||||
]
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
vec![
|
||||
"Username",
|
||||
"Email (Optional)",
|
||||
"Password (Optional)",
|
||||
"Confirm Password",
|
||||
"Role (Optional)"
|
||||
]
|
||||
}
|
||||
|
||||
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 {
|
||||
CanvasAction::Custom(action_str) if action_str == "submit" => {
|
||||
if !self.username.is_empty() {
|
||||
Some(format!("Submitting registration for: {}", self.username))
|
||||
} else {
|
||||
Some("Username is required".to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Add autocomplete support for RegisterState
|
||||
impl AutocompleteCanvasState for RegisterState {
|
||||
type SuggestionData = String;
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
field_index == 4 // Only role field supports autocomplete
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
|
||||
fn activate_autocomplete(&mut self) {
|
||||
let current_field = self.current_field();
|
||||
if self.supports_autocomplete(current_field) {
|
||||
self.autocomplete.activate(current_field);
|
||||
|
||||
// Re-filter suggestions based on current input
|
||||
let current_input = self.role.to_lowercase();
|
||||
let filtered_suggestions: Vec<SuggestionItem<String>> = AVAILABLE_ROLES
|
||||
.iter()
|
||||
.filter(|role| role.to_lowercase().contains(¤t_input))
|
||||
.map(|role| SuggestionItem::simple(role.clone(), role.clone()))
|
||||
.collect();
|
||||
|
||||
self.autocomplete.set_suggestions(filtered_suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
fn deactivate_autocomplete(&mut self) {
|
||||
self.autocomplete.deactivate();
|
||||
}
|
||||
|
||||
fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete.is_active
|
||||
}
|
||||
|
||||
fn is_autocomplete_ready(&self) -> bool {
|
||||
self.autocomplete.is_ready()
|
||||
}
|
||||
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
// First, get the data we need and clone it to avoid borrowing conflicts
|
||||
let selection_info = self.autocomplete.get_selected().map(|selected| {
|
||||
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||
});
|
||||
|
||||
// Now do the mutable operations
|
||||
if let Some((value, display_text)) = selection_info {
|
||||
self.role = value;
|
||||
self.set_has_unsaved_changes(true);
|
||||
self.deactivate_autocomplete();
|
||||
Some(format!("Selected role: {}", display_text))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<SuggestionItem<Self::SuggestionData>>) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.set_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.is_loading = loading;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
// src/tui/functions.rs
|
||||
|
||||
pub mod admin;
|
||||
pub mod intro;
|
||||
pub mod login;
|
||||
pub mod form;
|
||||
pub mod common;
|
||||
|
||||
pub use admin::*;
|
||||
pub use intro::*;
|
||||
pub use form::*;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// src/tui/functions/common.rs
|
||||
|
||||
pub mod form;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod register;
|
||||
pub mod add_table;
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
// src/tui/functions/common/form.rs
|
||||
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState; // NEW: Import AppState
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::utils::data_converter; // NEW: Import our translator
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SaveOutcome {
|
||||
NoChange,
|
||||
UpdatedExisting,
|
||||
CreatedNew(i64),
|
||||
}
|
||||
|
||||
// MODIFIED save function signature and logic
|
||||
pub async fn save(
|
||||
app_state: &AppState, // NEW: Pass in AppState
|
||||
form_state: &mut FormState,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<SaveOutcome> {
|
||||
if !form_state.has_unsaved_changes {
|
||||
return Ok(SaveOutcome::NoChange);
|
||||
}
|
||||
|
||||
// --- NEW: VALIDATION & CONVERSION STEP ---
|
||||
let cache_key =
|
||||
format!("{}.{}", form_state.profile_name, form_state.table_name);
|
||||
let schema = match app_state.schema_cache.get(&cache_key) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(anyhow!(
|
||||
"Schema for table '{}' not found in cache. Cannot save.",
|
||||
form_state.table_name
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let data_map: HashMap<String, String> = form_state
|
||||
.fields
|
||||
.iter()
|
||||
.zip(form_state.values.iter())
|
||||
.map(|(field_def, value)| (field_def.data_key.clone(), value.clone()))
|
||||
.collect();
|
||||
|
||||
// Use our new translator. It returns a user-friendly error on failure.
|
||||
let converted_data =
|
||||
match data_converter::convert_and_validate_data(&data_map, schema) {
|
||||
Ok(data) => data,
|
||||
Err(user_error) => return Err(anyhow!(user_error)),
|
||||
};
|
||||
// --- END OF NEW STEP ---
|
||||
|
||||
let outcome: SaveOutcome;
|
||||
let is_new_entry = form_state.id == 0
|
||||
|| (form_state.total_count > 0
|
||||
&& form_state.current_position > form_state.total_count)
|
||||
|| (form_state.total_count == 0 && form_state.current_position == 1);
|
||||
|
||||
if is_new_entry {
|
||||
let response = grpc_client
|
||||
.post_table_data(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
converted_data, // Use the validated & converted data
|
||||
)
|
||||
.await
|
||||
.context("Failed to post new table data")?;
|
||||
|
||||
if response.success {
|
||||
form_state.id = response.inserted_id;
|
||||
form_state.total_count += 1;
|
||||
form_state.current_position = form_state.total_count;
|
||||
outcome = SaveOutcome::CreatedNew(response.inserted_id);
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Server failed to insert data: {}",
|
||||
response.message
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if form_state.id == 0 {
|
||||
return Err(anyhow!(
|
||||
"Cannot update record: ID is 0, but not classified as new entry."
|
||||
));
|
||||
}
|
||||
let response = grpc_client
|
||||
.put_table_data(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
form_state.id,
|
||||
converted_data, // Use the validated & converted data
|
||||
)
|
||||
.await
|
||||
.context("Failed to put (update) table data")?;
|
||||
|
||||
if response.success {
|
||||
outcome = SaveOutcome::UpdatedExisting;
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Server failed to update data: {}",
|
||||
response.message
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
form_state.has_unsaved_changes = false;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn revert(
|
||||
form_state: &mut FormState, // Takes &mut FormState to update it
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<String> {
|
||||
if form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) {
|
||||
let old_total_count = form_state.total_count; // Preserve for correct new position
|
||||
form_state.reset_to_empty(); // reset_to_empty will clear values and set id=0
|
||||
form_state.total_count = old_total_count; // Restore total_count
|
||||
if form_state.total_count > 0 { // Correctly set current_position for new
|
||||
form_state.current_position = form_state.total_count + 1;
|
||||
} else {
|
||||
form_state.current_position = 1;
|
||||
}
|
||||
return Ok("New entry cleared".to_string());
|
||||
}
|
||||
|
||||
if form_state.current_position == 0 || form_state.current_position > form_state.total_count {
|
||||
if form_state.total_count > 0 {
|
||||
form_state.current_position = 1;
|
||||
} else {
|
||||
// No records to revert to, effectively a new entry state.
|
||||
form_state.reset_to_empty();
|
||||
return Ok("No saved data to revert to; form cleared.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let response = grpc_client
|
||||
.get_table_data_by_position(
|
||||
form_state.profile_name.clone(),
|
||||
form_state.table_name.clone(),
|
||||
form_state.current_position as i32,
|
||||
)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to get table data by position {} for table {}.{}",
|
||||
form_state.current_position,
|
||||
form_state.profile_name,
|
||||
form_state.table_name
|
||||
))?;
|
||||
|
||||
// FIX: Pass the current position as the second argument
|
||||
form_state.update_from_response(&response.data, form_state.current_position);
|
||||
Ok("Changes discarded, reloaded last saved version".to_string())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use crate::config::storage::delete_auth_data;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::app::buffer::{AppView, BufferState};
|
||||
use crate::buffer::state::{AppView, BufferState};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use tracing::{error, info};
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// src/tui/functions/form.rs
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use canvas::canvas::CanvasState;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
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" => {
|
||||
// Only decrement if the current position is greater than the first record.
|
||||
// This prevents wrapping from 1 to total_count.
|
||||
// It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
|
||||
if form_state.current_position > 1 {
|
||||
form_state.current_position -= 1;
|
||||
*ideal_cursor_column = 0;
|
||||
}
|
||||
}
|
||||
"next_entry" => {
|
||||
// Only increment if the current position is not yet at the "New Entry" stage.
|
||||
// The "New Entry" position is total_count + 1.
|
||||
// This allows moving from the last record to "New Entry", but stops there.
|
||||
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())
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// src/tui/functions/login.rs
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
pub async fn handle_action(action: &str,) -> Result<String> {
|
||||
match action {
|
||||
"previous_entry" => {
|
||||
Ok("Previous entry at tui/functions/login.rs not implemented".into())
|
||||
}
|
||||
"next_entry" => {
|
||||
Ok("Next entry at tui/functions/login.rs not implemented".into())
|
||||
}
|
||||
_ => Err(anyhow!("Unknown login action: {}", action))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user