Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49899e66d | ||
|
|
5717c88857 | ||
|
|
ae8aa16208 | ||
|
|
4ed8e7b421 | ||
|
|
3dd6808ea2 | ||
|
|
f2b426851b | ||
|
|
f9e0833bcf | ||
|
|
11b073c2fd | ||
|
|
1320884409 |
@@ -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", "suggestions"] }
|
||||
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,6 +1,5 @@
|
||||
// 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::{render_canvas, FormEditor};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// 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::{render_canvas_default, render_canvas, FormEditor};
|
||||
|
||||
@@ -12,7 +12,6 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::{
|
||||
FormEditor,
|
||||
render_canvas,
|
||||
|
||||
@@ -13,18 +13,7 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::state::app::highlight::HighlightState;
|
||||
use canvas::{FormEditor, render_canvas_default, render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
|
||||
use canvas::canvas::HighlightState as CanvasHighlightState;
|
||||
|
||||
// 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_register(
|
||||
f: &mut Frame,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/components/form/form.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
@@ -8,19 +9,17 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use canvas::canvas::HighlightState;
|
||||
use canvas::{FormEditor, render_canvas_default, render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
|
||||
use canvas::{
|
||||
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor,
|
||||
};
|
||||
|
||||
pub fn render_form(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &FormState,
|
||||
fields: &[&str], // no longer needed, FormEditor handles this
|
||||
current_field_idx: &usize, // no longer needed
|
||||
inputs: &[&String], // no longer needed
|
||||
app_state: &AppState,
|
||||
form_state: &FormState, // not needed directly anymore, editor holds it
|
||||
table_name: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool, // FormEditor tracks mode internally
|
||||
highlight_state: &HighlightState,
|
||||
total_count: u64,
|
||||
current_position: u64,
|
||||
) {
|
||||
@@ -62,24 +61,19 @@ pub fn render_form(
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// --- FORM RENDERING (Using new canvas API) ---
|
||||
let editor = FormEditor::new(form_state.clone());
|
||||
// --- 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);
|
||||
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
main_layout[1],
|
||||
&editor,
|
||||
theme,
|
||||
);
|
||||
|
||||
// --- SUGGESTIONS DROPDOWN ---
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
main_layout[1],
|
||||
active_rect,
|
||||
&DefaultCanvasTheme,
|
||||
&editor,
|
||||
);
|
||||
// --- SUGGESTIONS DROPDOWN ---
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
main_layout[1],
|
||||
active_rect,
|
||||
&DefaultCanvasTheme,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ pub async fn handle_command_event(
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
form_state: &mut FormState,
|
||||
command_input: &mut String,
|
||||
command_message: &mut String,
|
||||
grpc_client: &mut GrpcClient,
|
||||
@@ -38,7 +37,6 @@ pub async fn handle_command_event(
|
||||
if config.is_command_execute(key.code, key.modifiers) {
|
||||
return process_command(
|
||||
config,
|
||||
form_state,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -73,7 +71,6 @@ pub async fn handle_command_event(
|
||||
|
||||
async fn process_command(
|
||||
config: &Config,
|
||||
form_state: &mut FormState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
@@ -103,7 +100,6 @@ async fn process_command(
|
||||
action,
|
||||
terminal,
|
||||
app_state,
|
||||
form_state,
|
||||
login_state,
|
||||
register_state,
|
||||
)
|
||||
@@ -118,7 +114,6 @@ async fn process_command(
|
||||
"save" => {
|
||||
let outcome = save(
|
||||
app_state,
|
||||
form_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
let message = match outcome {
|
||||
@@ -131,7 +126,7 @@ async fn process_command(
|
||||
},
|
||||
"revert" => {
|
||||
let message = revert(
|
||||
form_state,
|
||||
app_state,
|
||||
grpc_client,
|
||||
).await?;
|
||||
command_input.clear();
|
||||
|
||||
@@ -15,13 +15,12 @@ impl CommandHandler {
|
||||
&mut self,
|
||||
action: &str,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &AppState,
|
||||
form_state: &FormState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
) -> 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, login_state, register_state).await,
|
||||
"force_quit" => self.handle_force_quit(terminal).await,
|
||||
"save_and_quit" => self.handle_save_quit(terminal).await,
|
||||
_ => Ok((false, format!("Unknown command: {}", action))),
|
||||
@@ -31,8 +30,7 @@ impl CommandHandler {
|
||||
async fn handle_quit(
|
||||
&self,
|
||||
terminal: &mut TerminalCore,
|
||||
app_state: &AppState,
|
||||
form_state: &FormState,
|
||||
app_state: &mut AppState,
|
||||
login_state: &LoginState,
|
||||
register_state: &RegisterState,
|
||||
) -> Result<(bool, String)> {
|
||||
@@ -41,8 +39,10 @@ impl CommandHandler {
|
||||
login_state.has_unsaved_changes()
|
||||
} else if app_state.ui.show_register {
|
||||
register_state.has_unsaved_changes()
|
||||
} else if let Some(fs) = app_state.form_state_mut() {
|
||||
fs.has_unsaved_changes
|
||||
} else {
|
||||
form_state.has_unsaved_changes
|
||||
false
|
||||
};
|
||||
|
||||
if !has_unsaved {
|
||||
|
||||
@@ -17,7 +17,6 @@ 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,
|
||||
@@ -52,11 +51,15 @@ pub async fn handle_navigation_event(
|
||||
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" => {
|
||||
|
||||
@@ -16,11 +16,10 @@ use crate::modes::{
|
||||
};
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use canvas::FormEditor;
|
||||
use canvas::{FormEditor, AppMode as CanvasMode};
|
||||
use crate::state::{
|
||||
app::{
|
||||
buffer::{AppView, BufferState},
|
||||
highlight::HighlightState,
|
||||
search::SearchState,
|
||||
state::AppState,
|
||||
},
|
||||
@@ -40,9 +39,9 @@ use crate::tui::{
|
||||
};
|
||||
use crate::ui::handlers::context::UiContext;
|
||||
use crate::ui::handlers::rat_state::UiStateHandler;
|
||||
use canvas::KeyEventOutcome;
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
use tokio::sync::mpsc;
|
||||
@@ -72,7 +71,6 @@ pub struct EventHandler {
|
||||
pub command_input: String,
|
||||
pub command_message: String,
|
||||
pub is_edit_mode: bool,
|
||||
pub highlight_state: HighlightState,
|
||||
pub edit_mode_cooldown: bool,
|
||||
pub ideal_cursor_column: usize,
|
||||
pub key_sequence_tracker: KeySequenceTracker,
|
||||
@@ -104,7 +102,6 @@ impl EventHandler {
|
||||
command_input: String::new(),
|
||||
command_message: String::new(),
|
||||
is_edit_mode: false,
|
||||
highlight_state: HighlightState::Off,
|
||||
edit_mode_cooldown: false,
|
||||
ideal_cursor_column: 0,
|
||||
key_sequence_tracker: KeySequenceTracker::new(400),
|
||||
@@ -223,66 +220,90 @@ impl EventHandler {
|
||||
async fn handle_search_palette_event(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
form_state: &mut FormState,
|
||||
app_state: &mut AppState,
|
||||
) -> Result<EventOutcome> {
|
||||
let mut should_close = false;
|
||||
let mut outcome_message = String::new();
|
||||
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 = "Search cancelled".to_string();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected_hit) =
|
||||
search_state.results.get(search_state.selected_index)
|
||||
{
|
||||
if let Ok(data) = serde_json::from_str::<
|
||||
std::collections::HashMap<String, String>,
|
||||
>(&selected_hit.content_json)
|
||||
{
|
||||
let detached_pos = form_state.total_count + 2;
|
||||
form_state
|
||||
.update_from_response(&data, detached_pos);
|
||||
}
|
||||
// Step 1: Handle search_state logic in a short scope
|
||||
let (maybe_data, maybe_id) = {
|
||||
if let Some(search_state) = app_state.search_state.as_mut() {
|
||||
match key_event.code {
|
||||
KeyCode::Esc => {
|
||||
should_close = true;
|
||||
outcome_message =
|
||||
format!("Loaded record ID {}", selected_hit.id);
|
||||
outcome_message = "Search cancelled".to_string();
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
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::Enter => {
|
||||
if let Some(selected_hit) =
|
||||
search_state.results.get(search_state.selected_index)
|
||||
{
|
||||
if let Ok(data) = serde_json::from_str::<
|
||||
std::collections::HashMap<String, String>,
|
||||
>(&selected_hit.content_json)
|
||||
{
|
||||
(Some(data), Some(selected_hit.id))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
search_state.cursor_position =
|
||||
search_state.cursor_position.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if search_state.cursor_position < search_state.input.len()
|
||||
{
|
||||
KeyCode::Up => {
|
||||
search_state.previous_result();
|
||||
(None, None)
|
||||
}
|
||||
KeyCode::Down => {
|
||||
search_state.next_result();
|
||||
(None, None)
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
search_state.input.insert(search_state.cursor_position, c);
|
||||
search_state.cursor_position += 1;
|
||||
trigger_search = true;
|
||||
(None, None)
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if search_state.cursor_position > 0 {
|
||||
search_state.cursor_position -= 1;
|
||||
search_state.input.remove(search_state.cursor_position);
|
||||
trigger_search = true;
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
KeyCode::Left => {
|
||||
search_state.cursor_position =
|
||||
search_state.cursor_position.saturating_sub(1);
|
||||
(None, None)
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if search_state.cursor_position < search_state.input.len() {
|
||||
search_state.cursor_position += 1;
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
_ => (None, None),
|
||||
}
|
||||
_ => {}
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
|
||||
if trigger_search {
|
||||
// Step 2: Now safe to borrow form_state
|
||||
if let (Some(data), Some(id)) = (maybe_data, maybe_id) {
|
||||
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 = format!("Loaded record ID {}", id);
|
||||
}
|
||||
|
||||
// Step 3: Trigger async search if needed
|
||||
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;
|
||||
@@ -292,10 +313,7 @@ impl EventHandler {
|
||||
let sender = self.search_result_sender.clone();
|
||||
let mut grpc_client = self.grpc_client.clone();
|
||||
|
||||
info!(
|
||||
"--- 1. Spawning search task for query: '{}' ---",
|
||||
query
|
||||
);
|
||||
info!("--- 1. Spawning search task for query: '{}' ---", query);
|
||||
tokio::spawn(async move {
|
||||
info!("--- 2. Background task started. ---");
|
||||
match grpc_client.search_table(table_name, query).await {
|
||||
@@ -331,7 +349,6 @@ impl EventHandler {
|
||||
config: &Config,
|
||||
terminal: &mut TerminalCore,
|
||||
command_handler: &mut CommandHandler,
|
||||
form_state: &mut FormState,
|
||||
auth_state: &mut AuthState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
@@ -342,13 +359,7 @@ impl EventHandler {
|
||||
) -> Result<EventOutcome> {
|
||||
if app_state.ui.show_search_palette {
|
||||
if let Event::Key(key_event) = event {
|
||||
return self
|
||||
.handle_search_palette_event(
|
||||
key_event,
|
||||
form_state,
|
||||
app_state,
|
||||
)
|
||||
.await;
|
||||
return self.handle_search_palette_event(key_event, app_state).await;
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
@@ -573,7 +584,6 @@ impl EventHandler {
|
||||
let nav_outcome = navigation::handle_navigation_event(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -583,9 +593,7 @@ impl EventHandler {
|
||||
&mut self.command_input,
|
||||
&mut self.command_message,
|
||||
&mut self.navigation_state,
|
||||
)
|
||||
.await;
|
||||
|
||||
).await;
|
||||
match nav_outcome {
|
||||
Ok(EventOutcome::ButtonSelected { context, index }) => {
|
||||
let message = match context {
|
||||
@@ -654,96 +662,39 @@ impl EventHandler {
|
||||
}
|
||||
|
||||
AppMode::ReadOnly => {
|
||||
// Handle highlight mode transitions
|
||||
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
|
||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||
{
|
||||
let current_field_index = Self::get_current_field_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
self.highlight_state = HighlightState::Linewise {
|
||||
anchor_line: current_field_index
|
||||
};
|
||||
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
|
||||
&& ModeManager::can_enter_highlight_mode(current_mode)
|
||||
{
|
||||
let current_field_index = Self::get_current_field_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
let anchor = (current_field_index, current_cursor_pos);
|
||||
self.highlight_state = HighlightState::Characterwise { anchor };
|
||||
self.command_message = "-- HIGHLIGHT --".to_string();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
|
||||
// Handle edit mode transitions
|
||||
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_before")
|
||||
&& ModeManager::can_enter_edit_mode(current_mode)
|
||||
{
|
||||
self.is_edit_mode = true;
|
||||
self.edit_mode_cooldown = true;
|
||||
self.command_message = "Edit mode".to_string();
|
||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after")
|
||||
&& ModeManager::can_enter_edit_mode(current_mode)
|
||||
{
|
||||
let current_input = Self::get_current_input_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
let current_cursor_pos = Self::get_cursor_pos_for_mixed_state(
|
||||
app_state,
|
||||
login_state,
|
||||
form_state
|
||||
);
|
||||
|
||||
// Move cursor forward if possible
|
||||
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
|
||||
let new_cursor_pos = current_cursor_pos + 1;
|
||||
Self::set_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state,
|
||||
new_cursor_pos
|
||||
);
|
||||
self.ideal_cursor_column = Self::get_current_cursor_pos_for_state(
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state
|
||||
);
|
||||
// First let the canvas editor try to handle the key
|
||||
if app_state.ui.show_form {
|
||||
if let Some(editor) = &mut app_state.form_editor {
|
||||
let outcome = editor.handle_key_event(key_event);
|
||||
let new_mode = AppMode::from(editor.mode());
|
||||
match outcome {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
app_state.update_mode(new_mode);
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.is_edit_mode = true;
|
||||
self.edit_mode_cooldown = true;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
self.command_message = "Edit mode (after cursor)".to_string();
|
||||
terminal.set_cursor_style(SetCursorStyle::BlinkingBar)?;
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_command_mode")
|
||||
|
||||
// Entering command mode is still a client-level action
|
||||
if config.get_app_action(key_code, modifiers) == Some("enter_command_mode")
|
||||
&& ModeManager::can_enter_command_mode(current_mode)
|
||||
{
|
||||
if let Some(editor) = &mut app_state.form_editor {
|
||||
editor.set_mode(CanvasMode::Command);
|
||||
}
|
||||
self.command_mode = true;
|
||||
self.command_input.clear();
|
||||
self.command_message.clear();
|
||||
@@ -751,13 +702,12 @@ impl EventHandler {
|
||||
}
|
||||
|
||||
// Handle common actions (save, quit, etc.)
|
||||
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
||||
if let Some(action) = config.get_app_action(key_code, modifiers) {
|
||||
match action {
|
||||
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||
return self
|
||||
.handle_core_action(
|
||||
action,
|
||||
form_state,
|
||||
auth_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -770,35 +720,33 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Try canvas action for form first
|
||||
if app_state.ui.show_form {
|
||||
let mut editor = FormEditor::new(form_state.clone());
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
&mut editor,
|
||||
config,
|
||||
false,
|
||||
).await {
|
||||
return Ok(EventOutcome::Ok(canvas_message));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
}
|
||||
|
||||
AppMode::Highlight => {
|
||||
if config.get_highlight_action_for_key(key_code, modifiers) == Some("exit_highlight_mode") {
|
||||
self.highlight_state = HighlightState::Off;
|
||||
self.command_message = "Exited highlight mode".to_string();
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
} else if config.get_highlight_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise") {
|
||||
if let HighlightState::Characterwise { anchor } = self.highlight_state {
|
||||
self.highlight_state = HighlightState::Linewise { anchor_line: anchor.0 };
|
||||
self.command_message = "-- LINE HIGHLIGHT --".to_string();
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
if app_state.ui.show_form {
|
||||
if let Some(editor) = &mut app_state.form_editor {
|
||||
let outcome = editor.handle_key_event(key_event);
|
||||
let new_mode = AppMode::from(editor.mode());
|
||||
match outcome {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
app_state.update_mode(new_mode);
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(EventOutcome::Ok("".to_string()));
|
||||
}
|
||||
|
||||
return Ok(EventOutcome::Ok(self.command_message.clone()));
|
||||
@@ -806,13 +754,12 @@ impl EventHandler {
|
||||
|
||||
AppMode::Edit => {
|
||||
// Handle common actions (save, quit, etc.)
|
||||
if let Some(action) = config.get_common_action(key_code, modifiers) {
|
||||
if let Some(action) = config.get_app_action(key_code, modifiers) {
|
||||
match action {
|
||||
"save" | "force_quit" | "save_and_quit" | "revert" => {
|
||||
return self
|
||||
.handle_core_action(
|
||||
action,
|
||||
form_state,
|
||||
auth_state,
|
||||
login_state,
|
||||
register_state,
|
||||
@@ -825,19 +772,30 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Try canvas action for form first
|
||||
// Let the canvas editor handle edit-mode keys
|
||||
if app_state.ui.show_form {
|
||||
let mut editor = FormEditor::new(form_state.clone());
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
&mut editor,
|
||||
config,
|
||||
true,
|
||||
).await {
|
||||
if !canvas_message.is_empty() {
|
||||
self.command_message = canvas_message.clone();
|
||||
if let Some(editor) = &mut app_state.form_editor {
|
||||
let outcome = editor.handle_key_event(key_event);
|
||||
let new_mode = AppMode::from(editor.mode());
|
||||
match outcome {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
self.command_message = msg.clone();
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
app_state.update_mode(new_mode);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
app_state.update_mode(new_mode);
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
return Ok(EventOutcome::Ok(canvas_message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,21 +808,27 @@ impl EventHandler {
|
||||
self.command_message.clear();
|
||||
self.command_mode = false;
|
||||
self.key_sequence_tracker.reset();
|
||||
if let Some(editor) = &mut app_state.form_editor {
|
||||
editor.set_mode(CanvasMode::ReadOnly);
|
||||
}
|
||||
return Ok(EventOutcome::Ok(
|
||||
"Exited command mode".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if config.is_command_execute(key_code, modifiers) {
|
||||
let mut current_position = form_state.current_position;
|
||||
let total_count = form_state.total_count;
|
||||
let (mut current_position, total_count) = if let Some(fs) = app_state.form_state() {
|
||||
(fs.current_position, fs.total_count)
|
||||
} else {
|
||||
(1, 0)
|
||||
};
|
||||
|
||||
let outcome = command_mode::handle_command_event(
|
||||
key_event,
|
||||
config,
|
||||
app_state,
|
||||
login_state,
|
||||
register_state,
|
||||
form_state,
|
||||
&mut self.command_input,
|
||||
&mut self.command_message,
|
||||
&mut self.grpc_client,
|
||||
@@ -872,9 +836,10 @@ impl EventHandler {
|
||||
terminal,
|
||||
&mut current_position,
|
||||
total_count,
|
||||
)
|
||||
.await?;
|
||||
form_state.current_position = current_position;
|
||||
).await?;
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
fs.current_position = current_position;
|
||||
}
|
||||
self.command_mode = false;
|
||||
self.key_sequence_tracker.reset();
|
||||
let new_mode = ModeManager::derive_mode(
|
||||
@@ -982,70 +947,9 @@ impl EventHandler {
|
||||
matches!(command, "w" | "q" | "q!" | "wq" | "r")
|
||||
}
|
||||
|
||||
async fn handle_form_canvas_action(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
editor: &mut FormEditor<FormState>,
|
||||
config: &Config,
|
||||
is_edit_mode: bool,
|
||||
) -> Result<Option<String>> {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
if is_edit_mode {
|
||||
if let KeyCode::Char(c) = key_event.code {
|
||||
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
|
||||
editor.insert_char(c)?;
|
||||
return Ok(Some(format!("Inserted '{}'", c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use your config to resolve actions
|
||||
if let Some(action) = config.get_edit_action_for_key(key_event.code, key_event.modifiers) {
|
||||
match action {
|
||||
"delete_char_backward" => { editor.delete_backward()?; return Ok(Some("Deleted backward".to_string())); }
|
||||
"delete_char_forward" => { editor.delete_forward()?; return Ok(Some("Deleted forward".to_string())); }
|
||||
"move_left" => { editor.move_left()?; return Ok(Some("Moved left".to_string())); }
|
||||
"move_right" => { editor.move_right()?; return Ok(Some("Moved right".to_string())); }
|
||||
"move_up" => { editor.move_up()?; return Ok(Some("Moved up".to_string())); }
|
||||
"move_down" => { editor.move_down()?; return Ok(Some("Moved down".to_string())); }
|
||||
|
||||
"move_line_start" => { editor.move_line_start(); return Ok(Some("Line start".to_string())); }
|
||||
"move_line_end" => { editor.move_line_end(); return Ok(Some("Line end".to_string())); }
|
||||
"move_word_next" => { editor.move_word_next(); return Ok(Some("Next word".to_string())); }
|
||||
"move_word_prev" => { editor.move_word_prev(); return Ok(Some("Prev word".to_string())); }
|
||||
"move_word_end" => { editor.move_word_end(); return Ok(Some("Word end".to_string())); }
|
||||
"move_word_end_prev" => { editor.move_word_end_prev(); return Ok(Some("Prev word end".to_string())); }
|
||||
|
||||
"next_field" => { editor.next_field()?; return Ok(Some("Next field".to_string())); }
|
||||
"prev_field" => { editor.prev_field()?; return Ok(Some("Prev field".to_string())); }
|
||||
"open_suggestions" => {
|
||||
let field_index = editor.current_field();
|
||||
editor.open_suggestions(field_index);
|
||||
return Ok(Some("Opened suggestions".to_string()));
|
||||
}
|
||||
"apply_suggestion" | "enter_decider" => {
|
||||
if let Some(s) = editor.apply_suggestion() {
|
||||
return Ok(Some(format!("Applied suggestion: {}", s)));
|
||||
} else {
|
||||
return Ok(Some("No suggestion applied".to_string()));
|
||||
}
|
||||
}
|
||||
"exit" | "exit_edit_mode" => {
|
||||
editor.exit_edit_mode()?;
|
||||
return Ok(Some("Exited edit mode".to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_core_action(
|
||||
&mut self,
|
||||
action: &str,
|
||||
form_state: &mut FormState,
|
||||
auth_state: &mut AuthState,
|
||||
login_state: &mut LoginState,
|
||||
register_state: &mut RegisterState,
|
||||
@@ -1064,12 +968,15 @@ impl EventHandler {
|
||||
.await?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
let save_outcome = crate::tui::functions::common::form::save(
|
||||
app_state,
|
||||
form_state,
|
||||
&mut self.grpc_client,
|
||||
)
|
||||
.await?;
|
||||
let save_outcome = if let Some(fs) = app_state.form_state_mut() {
|
||||
crate::tui::functions::common::form::save(
|
||||
app_state,
|
||||
&mut self.grpc_client,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
SaveOutcome::NoChange
|
||||
};
|
||||
let message = match save_outcome {
|
||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
@@ -1079,6 +986,9 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
"force_quit" => {
|
||||
if let Some(editor) = &mut app_state.form_editor {
|
||||
editor.cleanup_cursor()?;
|
||||
}
|
||||
terminal.cleanup()?;
|
||||
Ok(EventOutcome::Exit(
|
||||
"Force exiting without saving.".to_string(),
|
||||
@@ -1096,16 +1006,17 @@ impl EventHandler {
|
||||
} else {
|
||||
let save_outcome = crate::tui::functions::common::form::save(
|
||||
app_state,
|
||||
form_state,
|
||||
&mut self.grpc_client,
|
||||
)
|
||||
.await?;
|
||||
).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(),
|
||||
}
|
||||
};
|
||||
if let Some(editor) = &mut app_state.form_editor {
|
||||
editor.cleanup_cursor()?;
|
||||
}
|
||||
terminal.cleanup()?;
|
||||
Ok(EventOutcome::Exit(format!(
|
||||
"{}. Exiting application.",
|
||||
@@ -1121,13 +1032,17 @@ impl EventHandler {
|
||||
register_state,
|
||||
app_state,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
} else {
|
||||
crate::tui::functions::common::form::revert(
|
||||
form_state,
|
||||
&mut self.grpc_client,
|
||||
)
|
||||
.await?
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
crate::tui::functions::common::form::revert(
|
||||
app_state,
|
||||
&mut self.grpc_client,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
"Nothing to revert".to_string()
|
||||
}
|
||||
};
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
|
||||
@@ -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 canvas::AppMode as CanvasMode;
|
||||
|
||||
#[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
|
||||
pub fn derive_mode(
|
||||
app_state: &AppState,
|
||||
event_handler: &EventHandler,
|
||||
admin_state: &AdminState,
|
||||
) -> 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;
|
||||
// Always trust the FormEditor when a form is active
|
||||
if app_state.ui.show_form && !app_state.ui.focus_outside_canvas {
|
||||
if let Some(editor) = &app_state.form_editor {
|
||||
return AppMode::from(editor.mode());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// --- Non-form views (add_logic, add_table, etc.) ---
|
||||
if app_state.ui.show_add_logic {
|
||||
// Specific logic for AddLogic view
|
||||
match admin_state.add_logic_state.current_focus {
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription => {
|
||||
// These are canvas inputs
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
@@ -59,22 +69,19 @@ impl ModeManager {
|
||||
} 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 {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
} else if is_canvas_view {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
AppMode::General
|
||||
} else if app_state.ui.show_login
|
||||
|| app_state.ui.show_register
|
||||
{
|
||||
// login/register still use the old flag
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
} else {
|
||||
AppMode::General
|
||||
@@ -82,7 +89,7 @@ impl ModeManager {
|
||||
}
|
||||
|
||||
// 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 +98,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 {
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ use common::proto::komp_ac::table_structure::TableStructureResponse;
|
||||
use crate::modes::handlers::mode_manager::AppMode;
|
||||
use crate::state::app::search::SearchState;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::config::binds::Config;
|
||||
use canvas::FormEditor;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
@@ -67,6 +70,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 +91,7 @@ impl AppState {
|
||||
pending_table_structure_fetch: None,
|
||||
search_state: None,
|
||||
ui: UiState::default(),
|
||||
form_editor: None,
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
debug_state: None,
|
||||
@@ -180,6 +186,29 @@ impl AppState {
|
||||
.get(self.ui.dialog.dialog_active_button_index)
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Immutable access to the underlying FormState
|
||||
pub fn form_state(&self) -> Option<&FormState> {
|
||||
self.form_editor.as_ref().map(|e| e.data_provider())
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use canvas::{DataProvider, AppMode, EditorState, FormEditor};
|
||||
use canvas::canvas::HighlightState;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
@@ -117,27 +116,6 @@ impl FormState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) {
|
||||
// Wrap in FormEditor for new API
|
||||
let mut editor = FormEditor::new(self.clone());
|
||||
|
||||
// Use new canvas rendering
|
||||
canvas::render_canvas_default(f, area, &editor);
|
||||
|
||||
// If autocomplete is active, render suggestions
|
||||
if self.autocomplete_active && !self.autocomplete_suggestions.is_empty() {
|
||||
// Note: This will need to be updated when suggestions are integrated
|
||||
// canvas::render_suggestions_dropdown(f, area, input_rect, &canvas::DefaultCanvasTheme, &editor);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_to_empty(&mut self) {
|
||||
self.id = 0;
|
||||
self.values.iter_mut().for_each(|v| v.clear());
|
||||
@@ -228,15 +206,6 @@ 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;
|
||||
}
|
||||
|
||||
// Legacy method compatibility
|
||||
pub fn fields(&self) -> Vec<&str> {
|
||||
self.fields
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// 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 crate::state::app::state::AppState;
|
||||
use crate::utils::data_converter;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -14,143 +12,137 @@ pub enum SaveOutcome {
|
||||
CreatedNew(i64),
|
||||
}
|
||||
|
||||
// MODIFIED save function signature and logic
|
||||
pub async fn save(
|
||||
app_state: &AppState, // NEW: Pass in AppState
|
||||
form_state: &mut FormState,
|
||||
app_state: &mut AppState,
|
||||
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
|
||||
));
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
if !fs.has_unsaved_changes {
|
||||
return Ok(SaveOutcome::NoChange);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
// Copy out what we need before dropping the mutable borrow
|
||||
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;
|
||||
|
||||
// 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)),
|
||||
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));
|
||||
}
|
||||
};
|
||||
// --- 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
|
||||
));
|
||||
}
|
||||
Ok(outcome)
|
||||
} 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
|
||||
));
|
||||
}
|
||||
Ok(SaveOutcome::NoChange)
|
||||
}
|
||||
|
||||
form_state.has_unsaved_changes = false;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn revert(
|
||||
form_state: &mut FormState, // Takes &mut FormState to update it
|
||||
app_state: &mut AppState,
|
||||
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;
|
||||
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());
|
||||
}
|
||||
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());
|
||||
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())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ use crate::components::{
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState;
|
||||
use crate::state::app::buffer::BufferState;
|
||||
use crate::state::app::highlight::HighlightState as LocalHighlightState;
|
||||
use canvas::canvas::HighlightState as CanvasHighlightState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
@@ -26,20 +24,12 @@ use crate::state::pages::auth::LoginState;
|
||||
use crate::state::pages::auth::RegisterState;
|
||||
use crate::state::pages::form::FormState;
|
||||
use crate::state::pages::intro::IntroState;
|
||||
use crate::components::render_form;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
Frame,
|
||||
};
|
||||
|
||||
// Helper function to convert between HighlightState types
|
||||
fn convert_highlight_state(local: &LocalHighlightState) -> CanvasHighlightState {
|
||||
match local {
|
||||
LocalHighlightState::Off => CanvasHighlightState::Off,
|
||||
LocalHighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
|
||||
LocalHighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_ui(
|
||||
f: &mut Frame,
|
||||
@@ -52,7 +42,6 @@ pub fn render_ui(
|
||||
buffer_state: &BufferState,
|
||||
theme: &Theme,
|
||||
is_event_handler_edit_mode: bool,
|
||||
highlight_state: &LocalHighlightState, // Keep using local version
|
||||
event_handler_command_input: &str,
|
||||
event_handler_command_mode_active: bool,
|
||||
event_handler_command_message: &str,
|
||||
@@ -204,14 +193,15 @@ pub fn render_ui(
|
||||
.split(form_actual_area)[1]
|
||||
};
|
||||
|
||||
// CHANGED: Convert local HighlightState to canvas HighlightState for FormState
|
||||
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||
form_state.render(
|
||||
render_form(
|
||||
f,
|
||||
form_render_area,
|
||||
app_state,
|
||||
form_state,
|
||||
app_state.current_view_table_name.as_deref().unwrap_or(""),
|
||||
theme,
|
||||
is_event_handler_edit_mode,
|
||||
&canvas_highlight_state, // Use converted version
|
||||
form_state.total_count,
|
||||
form_state.current_position,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::tui::functions::common::login;
|
||||
use crate::tui::functions::common::register;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use canvas::keymap::KeyEventOutcome;
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event as crossterm_event;
|
||||
@@ -102,25 +103,28 @@ pub async fn run_ui() -> Result<()> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut form_state = FormState::new(
|
||||
initial_profile.clone(),
|
||||
initial_table.clone(),
|
||||
initial_field_defs,
|
||||
// Replace local form_state with app_state.form_editor
|
||||
app_state.set_form_state(
|
||||
FormState::new(initial_profile.clone(), initial_table.clone(), initial_field_defs),
|
||||
&config,
|
||||
);
|
||||
|
||||
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to fetch initial count for table {}.{}",
|
||||
initial_profile, initial_table
|
||||
))?;
|
||||
// Fetch initial count using app_state accessor
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
UiService::fetch_and_set_table_count(&mut grpc_client, form_state)
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to fetch initial count for table {}.{}",
|
||||
initial_profile, initial_table
|
||||
))?;
|
||||
|
||||
if form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||
event_handler.command_message = format!("Error loading initial data: {}", e);
|
||||
if form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, form_state).await {
|
||||
event_handler.command_message = format!("Error loading initial data: {}", e);
|
||||
}
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
}
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
}
|
||||
|
||||
if auto_logged_in {
|
||||
@@ -137,7 +141,9 @@ pub async fn run_ui() -> Result<()> {
|
||||
let mut table_just_switched = false;
|
||||
|
||||
loop {
|
||||
let position_before_event = form_state.current_position;
|
||||
let position_before_event = app_state.form_state()
|
||||
.map(|fs| fs.current_position)
|
||||
.unwrap_or(1);
|
||||
let mut event_processed = false;
|
||||
|
||||
// --- CHANNEL RECEIVERS ---
|
||||
@@ -162,15 +168,17 @@ pub async fn run_ui() -> Result<()> {
|
||||
// --- ADDED: For live form autocomplete ---
|
||||
match event_handler.autocomplete_result_receiver.try_recv() {
|
||||
Ok(hits) => {
|
||||
if form_state.autocomplete_active {
|
||||
form_state.autocomplete_suggestions = hits;
|
||||
form_state.autocomplete_loading = false;
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
form_state.selected_suggestion_index = Some(0);
|
||||
} else {
|
||||
form_state.selected_suggestion_index = None;
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
if form_state.autocomplete_active {
|
||||
form_state.autocomplete_suggestions = hits;
|
||||
form_state.autocomplete_loading = false;
|
||||
if !form_state.autocomplete_suggestions.is_empty() {
|
||||
form_state.selected_suggestion_index = Some(0);
|
||||
} else {
|
||||
form_state.selected_suggestion_index = None;
|
||||
}
|
||||
event_handler.command_message = format!("Found {} suggestions.", form_state.autocomplete_suggestions.len());
|
||||
}
|
||||
event_handler.command_message = format!("Found {} suggestions.", form_state.autocomplete_suggestions.len());
|
||||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
@@ -180,19 +188,46 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if app_state.ui.show_search_palette {
|
||||
needs_redraw = true;
|
||||
}
|
||||
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
|
||||
let event = event_reader.read_event().context("Failed to read terminal event")?;
|
||||
event_processed = true;
|
||||
|
||||
if let crossterm_event::Event::Key(key_event) = &event {
|
||||
if app_state.ui.show_form {
|
||||
if let Some(editor) = app_state.form_editor.as_mut() {
|
||||
match editor.handle_key_event(*key_event) {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
event_handler.command_message = msg;
|
||||
needs_redraw = true;
|
||||
continue;
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
needs_redraw = true;
|
||||
continue;
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
needs_redraw = true;
|
||||
continue;
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
// fall through to client-level handling
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get form state from app_state and pass to handle_event
|
||||
let form_state = app_state.form_state_mut().unwrap();
|
||||
|
||||
let event_outcome_result = event_handler.handle_event(
|
||||
event,
|
||||
&config,
|
||||
&mut terminal,
|
||||
&mut command_handler,
|
||||
&mut form_state,
|
||||
&mut auth_state,
|
||||
&mut login_state,
|
||||
&mut register_state,
|
||||
@@ -201,7 +236,6 @@ pub async fn run_ui() -> Result<()> {
|
||||
&mut buffer_state,
|
||||
&mut app_state,
|
||||
).await;
|
||||
|
||||
let mut should_exit = false;
|
||||
match event_outcome_result {
|
||||
Ok(outcome) => match outcome {
|
||||
@@ -216,15 +250,21 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
EventOutcome::DataSaved(save_outcome, message) => {
|
||||
event_handler.command_message = message;
|
||||
// Clone form_state to avoid double borrow
|
||||
let mut temp_form_state = app_state.form_state().unwrap().clone();
|
||||
if let Err(e) = UiService::handle_save_outcome(
|
||||
save_outcome,
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
&mut form_state,
|
||||
&mut temp_form_state,
|
||||
).await {
|
||||
event_handler.command_message =
|
||||
format!("Error handling save outcome: {}", e);
|
||||
}
|
||||
// Update app_state with changes
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
*form_state = temp_form_state;
|
||||
}
|
||||
}
|
||||
EventOutcome::ButtonSelected { .. } => {}
|
||||
EventOutcome::TableSelected { path } => {
|
||||
@@ -348,7 +388,7 @@ pub async fn run_ui() -> Result<()> {
|
||||
|
||||
// Continue with the rest of the function...
|
||||
// (The rest remains the same, but now CanvasState trait methods are available)
|
||||
|
||||
|
||||
if app_state.ui.show_form {
|
||||
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||
let current_view_table = app_state.current_view_table_name.clone();
|
||||
@@ -373,39 +413,43 @@ pub async fn run_ui() -> Result<()> {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut new_form_state) => {
|
||||
if let Err(e) = UiService::fetch_and_set_table_count(
|
||||
&mut grpc_client,
|
||||
&mut new_form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error fetching count: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
} else if new_form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(
|
||||
Ok(new_form_state) => {
|
||||
// Set the new form state and fetch count
|
||||
app_state.set_form_state(new_form_state, &config);
|
||||
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
if let Err(e) = UiService::fetch_and_set_table_count(
|
||||
&mut grpc_client,
|
||||
&mut new_form_state,
|
||||
form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error loading data: {}", e),
|
||||
&format!("Error fetching count: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
} else if form_state.total_count > 0 {
|
||||
if let Err(e) = UiService::load_table_data_by_position(
|
||||
&mut grpc_client,
|
||||
form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
app_state.update_dialog_content(
|
||||
&format!("Error loading data: {}", e),
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
} else {
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
} else {
|
||||
new_form_state.reset_to_empty();
|
||||
app_state.hide_dialog();
|
||||
}
|
||||
|
||||
form_state = new_form_state;
|
||||
prev_view_profile_name = current_view_profile;
|
||||
prev_view_table_name = current_view_table;
|
||||
table_just_switched = true;
|
||||
@@ -429,7 +473,7 @@ pub async fn run_ui() -> Result<()> {
|
||||
|
||||
// Continue with the rest of the positioning logic...
|
||||
// Now we can use CanvasState methods like get_current_input(), current_field(), etc.
|
||||
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if app_state.ui.show_add_logic {
|
||||
if admin_state.add_logic_state.profile_name == profile_name &&
|
||||
@@ -488,47 +532,53 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let position_changed = form_state.current_position != position_before_event;
|
||||
let current_position = app_state.form_state()
|
||||
.map(|fs| fs.current_position)
|
||||
.unwrap_or(1);
|
||||
let position_changed = current_position != position_before_event;
|
||||
let mut position_logic_needs_redraw = false;
|
||||
|
||||
if app_state.ui.show_form && !table_just_switched {
|
||||
if position_changed && !event_handler.is_edit_mode {
|
||||
position_logic_needs_redraw = true;
|
||||
|
||||
if form_state.current_position > form_state.total_count {
|
||||
form_state.reset_to_empty();
|
||||
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
|
||||
} else {
|
||||
match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
|
||||
Ok(load_message) => {
|
||||
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
|
||||
event_handler.command_message = load_message;
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
if form_state.current_position > form_state.total_count {
|
||||
form_state.reset_to_empty();
|
||||
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
|
||||
} else {
|
||||
match UiService::load_table_data_by_position(&mut grpc_client, form_state).await {
|
||||
Ok(load_message) => {
|
||||
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
|
||||
event_handler.command_message = load_message;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Error loading data: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Error loading data: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let current_input_after_load_str = form_state.get_current_input();
|
||||
let current_input_len_after_load = current_input_after_load_str.chars().count();
|
||||
let max_cursor_pos = if current_input_len_after_load > 0 {
|
||||
current_input_len_after_load.saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
}
|
||||
|
||||
let current_input_after_load_str = form_state.get_current_input();
|
||||
let current_input_len_after_load = current_input_after_load_str.chars().count();
|
||||
let max_cursor_pos = if current_input_len_after_load > 0 {
|
||||
current_input_len_after_load.saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
|
||||
} else if !position_changed && !event_handler.is_edit_mode {
|
||||
let current_input_str = form_state.get_current_input();
|
||||
let current_input_len = current_input_str.chars().count();
|
||||
let max_cursor_pos = if current_input_len > 0 {
|
||||
current_input_len.saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
let current_input_str = form_state.get_current_input();
|
||||
let current_input_len = current_input_str.chars().count();
|
||||
let max_cursor_pos = if current_input_len > 0 {
|
||||
current_input_len.saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
}
|
||||
}
|
||||
} else if app_state.ui.show_register {
|
||||
if !event_handler.is_edit_mode {
|
||||
@@ -587,10 +637,23 @@ pub async fn run_ui() -> Result<()> {
|
||||
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
|
||||
}
|
||||
|
||||
// Temporarily work around borrow checker by extracting needed values
|
||||
let current_dir = app_state.current_dir.clone();
|
||||
|
||||
// Since we can't borrow app_state both mutably and immutably,
|
||||
// we'll need to either:
|
||||
// 1. Modify render_ui to take just app_state and access form_state internally, OR
|
||||
// 2. Extract the specific fields render_ui needs from app_state
|
||||
|
||||
// For now, using approach where we temporarily clone what we need
|
||||
let form_state_clone = app_state.form_state().unwrap().clone();
|
||||
|
||||
terminal.draw(|f| {
|
||||
// Use a mutable clone for rendering
|
||||
let mut temp_form_state = form_state_clone.clone();
|
||||
render_ui(
|
||||
f,
|
||||
&mut form_state,
|
||||
&mut temp_form_state,
|
||||
&mut auth_state,
|
||||
&login_state,
|
||||
®ister_state,
|
||||
@@ -599,15 +662,17 @@ pub async fn run_ui() -> Result<()> {
|
||||
&buffer_state,
|
||||
&theme,
|
||||
event_handler.is_edit_mode,
|
||||
&event_handler.highlight_state,
|
||||
&event_handler.command_input,
|
||||
event_handler.command_mode,
|
||||
&event_handler.command_message,
|
||||
&event_handler.navigation_state,
|
||||
&app_state.current_dir,
|
||||
¤t_dir,
|
||||
current_fps,
|
||||
&app_state,
|
||||
);
|
||||
|
||||
// If render_ui modified the form_state, we'd need to sync it back
|
||||
// But typically render functions don't modify state, just read it
|
||||
}).context("Terminal draw call failed")?;
|
||||
needs_redraw = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user