Compare commits
55 Commits
0.5.4
...
a604d62d44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a604d62d44 | ||
|
|
2cbbfd21aa | ||
|
|
1c17d07497 | ||
|
|
ad15becd7a | ||
|
|
c2a6272413 | ||
|
|
c51af13fb1 | ||
|
|
d9d8562539 | ||
|
|
6891631b8d | ||
|
|
738d58b5f1 | ||
|
|
3081125716 | ||
|
|
6073c7ab43 | ||
|
|
8157dc7a60 | ||
|
|
3b130e9208 | ||
|
|
ab81434c4e | ||
|
|
62c54dc1eb | ||
|
|
347802b2a4 | ||
|
|
a5a8d98984 | ||
|
|
5b42da8290 | ||
|
|
4e041f36ce | ||
|
|
22926b7266 | ||
|
|
0a7f032028 | ||
|
|
4edec5e72d | ||
|
|
c7d524c76a | ||
|
|
9ed558562b | ||
|
|
43f5c1a764 | ||
|
|
46149c09db | ||
|
|
a0757efe8b | ||
|
|
10f4b9d8e2 | ||
|
|
42db496ad7 | ||
|
|
d6fd672409 | ||
|
|
60eb1c9f51 | ||
|
|
a09c804595 | ||
|
|
a17f73fd54 | ||
|
|
2373ae4b8c | ||
|
|
16dd460469 | ||
|
|
58f109ca91 | ||
|
|
75da9c0f4b | ||
|
|
833b918c5b | ||
|
|
72c2691a17 | ||
|
|
cf79bc7bd5 | ||
|
|
f5f2f2cdef | ||
|
|
19a9bab8c2 | ||
|
|
6e221ef8c1 | ||
|
|
e142f56706 | ||
|
|
a794f22366 | ||
|
|
cfe4903c79 | ||
|
|
a0a473f96c | ||
|
|
9e4dd3b4c7 | ||
|
|
e5db0334c0 | ||
|
|
d641ad1bbb | ||
|
|
18393ff661 | ||
|
|
b2a82fba30 | ||
|
|
f6c2fd627f | ||
|
|
15d9b31cb6 | ||
|
|
06cc1663b3 |
@@ -6,6 +6,49 @@ use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
|
||||
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Whitelist of allowed exact values for a field.
|
||||
/// If configured, the field is valid when it is empty (by default) or when the
|
||||
/// content exactly matches one of the allowed values. This does not block field
|
||||
/// switching (unlike minimum length in CharacterLimits).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AllowedValues {
|
||||
allowed: Vec<String>,
|
||||
allow_empty: bool,
|
||||
case_insensitive: bool,
|
||||
}
|
||||
|
||||
impl AllowedValues {
|
||||
pub fn new(allowed: Vec<String>) -> Self {
|
||||
Self {
|
||||
allowed,
|
||||
allow_empty: true,
|
||||
case_insensitive: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow or disallow empty value to be considered valid (default: true).
|
||||
pub fn allow_empty(mut self, allow: bool) -> Self {
|
||||
self.allow_empty = allow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable/disable ASCII case-insensitive matching (default: false).
|
||||
pub fn case_insensitive(mut self, ci: bool) -> Self {
|
||||
self.case_insensitive = ci;
|
||||
self
|
||||
}
|
||||
|
||||
fn matches(&self, text: &str) -> bool {
|
||||
if self.case_insensitive {
|
||||
self.allowed
|
||||
.iter()
|
||||
.any(|s| s.eq_ignore_ascii_case(text))
|
||||
} else {
|
||||
self.allowed.iter().any(|s| s == text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main validation configuration for a field
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValidationConfig {
|
||||
@@ -22,6 +65,9 @@ pub struct ValidationConfig {
|
||||
#[cfg(feature = "validation")]
|
||||
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
|
||||
|
||||
/// Optional: restrict the field to one of exact allowed values (or empty)
|
||||
pub allowed_values: Option<AllowedValues>,
|
||||
|
||||
/// Enable external validation indicator UI (feature 5)
|
||||
pub external_validation_enabled: bool,
|
||||
|
||||
@@ -50,6 +96,7 @@ impl std::fmt::Debug for ValidationConfig {
|
||||
}
|
||||
},
|
||||
)
|
||||
.field("allowed_values", &self.allowed_values)
|
||||
.field("external_validation_enabled", &self.external_validation_enabled)
|
||||
.field("external_validation", &self.external_validation)
|
||||
.finish()
|
||||
@@ -167,6 +214,18 @@ impl ValidationConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed values (whitelist) validation
|
||||
if let Some(ref allowed) = self.allowed_values {
|
||||
// Empty value is allowed (default) or required (if allow_empty is false)
|
||||
if text.is_empty() {
|
||||
if !allowed.allow_empty {
|
||||
return ValidationResult::warning("Value required");
|
||||
}
|
||||
} else if !allowed.matches(text) {
|
||||
return ValidationResult::error("Value must be one of the allowed options");
|
||||
}
|
||||
}
|
||||
|
||||
// Future: Add other validation types here
|
||||
|
||||
ValidationResult::Valid
|
||||
@@ -183,6 +242,12 @@ impl ValidationConfig {
|
||||
#[cfg(not(feature = "validation"))]
|
||||
{ false }
|
||||
}
|
||||
|| self.allowed_values.is_some()
|
||||
}
|
||||
|
||||
/// Check if whitelist is configured
|
||||
pub fn has_allowed_values(&self) -> bool {
|
||||
self.allowed_values.is_some()
|
||||
}
|
||||
|
||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||
@@ -289,6 +354,41 @@ impl ValidationConfigBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Restrict content to one of the provided exact values (or empty).
|
||||
/// - Empty is considered valid by default.
|
||||
/// - Matching is case-sensitive by default.
|
||||
pub fn with_allowed_values<S>(mut self, values: Vec<S>) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
|
||||
self.config.allowed_values = Some(AllowedValues::new(vals));
|
||||
self
|
||||
}
|
||||
|
||||
/// Same as with_allowed_values, but case-insensitive (ASCII).
|
||||
pub fn with_allowed_values_ci<S>(mut self, values: Vec<S>) -> Self
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
|
||||
self.config.allowed_values = Some(AllowedValues::new(vals).case_insensitive(true));
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure whether empty value should be allowed when using AllowedValues.
|
||||
pub fn with_allowed_values_allow_empty(mut self, allow_empty: bool) -> Self {
|
||||
if let Some(av) = self.config.allowed_values.take() {
|
||||
self.config.allowed_values = Some(AllowedValues {
|
||||
allow_empty,
|
||||
..av
|
||||
});
|
||||
} else {
|
||||
self.config.allowed_values = Some(AllowedValues::new(vec![]).allow_empty(allow_empty));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable external validation indicator UI (feature 5)
|
||||
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
|
||||
self.config.external_validation_enabled = enabled;
|
||||
@@ -391,6 +491,47 @@ mod tests {
|
||||
assert!(config.display_mask.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_values() {
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_allowed_values(vec!["alpha", "beta", "gamma", "delta", "epsilon"])
|
||||
.build();
|
||||
|
||||
// Empty should be valid by default
|
||||
let result = config.validate_content("");
|
||||
assert!(result.is_acceptable());
|
||||
|
||||
// Exact allowed values are valid
|
||||
assert!(config.validate_content("alpha").is_acceptable());
|
||||
assert!(config.validate_content("beta").is_acceptable());
|
||||
|
||||
// Anything else is an error
|
||||
let res = config.validate_content("alph");
|
||||
assert!(res.is_error());
|
||||
let res = config.validate_content("ALPHA");
|
||||
assert!(res.is_error()); // case-sensitive by default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_values_case_insensitive_and_required() {
|
||||
let config = ValidationConfigBuilder::new()
|
||||
.with_allowed_values_ci(vec!["Yes", "No"])
|
||||
.with_allowed_values_allow_empty(false)
|
||||
.build();
|
||||
|
||||
// Empty is not allowed now (warning so it's still acceptable for typing)
|
||||
let res = config.validate_content("");
|
||||
assert!(res.is_acceptable());
|
||||
|
||||
// Case-insensitive matches
|
||||
assert!(config.validate_content("yes").is_acceptable());
|
||||
assert!(config.validate_content("NO").is_acceptable());
|
||||
|
||||
// Random text is an error
|
||||
let res = config.validate_content("maybe");
|
||||
assert!(res.is_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_result() {
|
||||
let valid = ValidationResult::Valid;
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
[keybindings]
|
||||
|
||||
enter_command_mode = [":", "ctrl+;"]
|
||||
next_buffer = ["space+b+n"]
|
||||
previous_buffer = ["space+b+p"]
|
||||
close_buffer = ["space+b+d"]
|
||||
next_buffer = ["ctrl+b+n"]
|
||||
previous_buffer = ["ctrl+b+p"]
|
||||
close_buffer = ["ctrl+b+d"]
|
||||
# SPACE NOT WORKING, NEEDS REDESIGN
|
||||
# next_buffer = ["space+b+n"]
|
||||
# previous_buffer = ["space+b+p"]
|
||||
# close_buffer = ["space+b+d"]
|
||||
# revert = ["space+b+r"]
|
||||
|
||||
[keybindings.general]
|
||||
move_up = ["k", "Up"]
|
||||
move_down = ["j", "Down"]
|
||||
next_option = ["l", "Right"]
|
||||
previous_option = ["h", "Left"]
|
||||
up = ["k", "Up"]
|
||||
down = ["j", "Down"]
|
||||
left = ["h", "Left"]
|
||||
right = ["l", "Right"]
|
||||
next = ["Tab"]
|
||||
previous = ["Shift+Tab"]
|
||||
select = ["Enter"]
|
||||
toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
exit_table_scroll = ["esc"]
|
||||
esc = ["esc"]
|
||||
open_search = ["ctrl+f"]
|
||||
|
||||
[keybindings.common]
|
||||
@@ -29,7 +32,6 @@ move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
toggle_sidebar = ["ctrl+t"]
|
||||
toggle_buffer_list = ["ctrl+b"]
|
||||
revert = ["space+b+r"]
|
||||
|
||||
# MODE SPECIFIC
|
||||
# READ ONLY MODE
|
||||
@@ -62,7 +64,7 @@ prev_field = ["Shift+Tab"]
|
||||
|
||||
[keybindings.highlight]
|
||||
exit_highlight_mode = ["esc"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
enter_highlight_mode_linewise = ["shift+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::bottom_panel::find_file_palette;
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::modes::general::command_navigation::NavigationState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::pages::routing::Router;
|
||||
|
||||
/// Calculate the layout constraints for the bottom panel (status line + command line/palette).
|
||||
pub fn bottom_panel_constraints(
|
||||
@@ -46,9 +47,9 @@ pub fn render_bottom_panel(
|
||||
chunk_idx: &mut usize,
|
||||
current_dir: &str,
|
||||
theme: &Theme,
|
||||
is_event_handler_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
router: &Router,
|
||||
navigation_state: &NavigationState,
|
||||
event_handler_command_input: &str,
|
||||
event_handler_command_mode_active: bool,
|
||||
@@ -74,9 +75,9 @@ pub fn render_bottom_panel(
|
||||
status_line_area,
|
||||
current_dir,
|
||||
theme,
|
||||
is_event_handler_edit_mode,
|
||||
current_fps,
|
||||
app_state,
|
||||
router,
|
||||
);
|
||||
|
||||
// --- Render command line or palette ---
|
||||
|
||||
@@ -8,6 +8,8 @@ use ratatui::{
|
||||
widgets::{Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use crate::pages::routing::Page;
|
||||
use crate::pages::routing::Router;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -16,9 +18,9 @@ pub fn render_status_line(
|
||||
area: Rect,
|
||||
current_dir: &str,
|
||||
theme: &Theme,
|
||||
is_edit_mode: bool,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
router: &Router,
|
||||
) {
|
||||
#[cfg(feature = "ui-debug")]
|
||||
{
|
||||
@@ -48,7 +50,20 @@ pub fn render_status_line(
|
||||
|
||||
// --- The normal status line rendering logic (unchanged) ---
|
||||
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
|
||||
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
|
||||
let mode_text = if let Page::Form(path) = &router.current {
|
||||
if let Some(editor) = app_state.editor_for_path_ref(path) {
|
||||
match editor.mode() {
|
||||
canvas::AppMode::Edit => "[EDIT]",
|
||||
canvas::AppMode::ReadOnly => "[READ-ONLY]",
|
||||
canvas::AppMode::Highlight => "[VISUAL]",
|
||||
_ => "",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
"" // No canvas active
|
||||
};
|
||||
|
||||
let home_dir = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
|
||||
@@ -7,7 +7,7 @@ pub fn get_view_layer(view: &AppView) -> u8 {
|
||||
match view {
|
||||
AppView::Intro => 1,
|
||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
||||
AppView::Form | AppView::Scratch => 3,
|
||||
AppView::Form(_) | AppView::Scratch => 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ pub enum AppView {
|
||||
Admin,
|
||||
AddTable,
|
||||
AddLogic,
|
||||
Form,
|
||||
Form(String),
|
||||
Scratch,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ impl AppView {
|
||||
AppView::Admin => "Admin_Panel",
|
||||
AppView::AddTable => "Add_Table",
|
||||
AppView::AddLogic => "Add_Logic",
|
||||
AppView::Form => "Form",
|
||||
AppView::Form(_) => "Form",
|
||||
AppView::Scratch => "*scratch*",
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,14 @@ impl AppView {
|
||||
/// Returns the display name with dynamic context (for Form buffers)
|
||||
pub fn display_name_with_context(&self, current_table_name: Option<&str>) -> String {
|
||||
match self {
|
||||
AppView::Form => {
|
||||
current_table_name
|
||||
.unwrap_or("Data Form")
|
||||
.to_string()
|
||||
AppView::Form(path) => {
|
||||
// Derive table name from "profile/table" path
|
||||
let table = path.split('/').nth(1).unwrap_or("");
|
||||
if !table.is_empty() {
|
||||
table.to_string()
|
||||
} else {
|
||||
current_table_name.unwrap_or("Data Form").to_string()
|
||||
}
|
||||
}
|
||||
_ => self.display_name().to_string(),
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// src/components/admin.rs
|
||||
pub mod admin_panel;
|
||||
pub mod admin_panel_admin;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
|
||||
pub use admin_panel::*;
|
||||
pub use admin_panel_admin::*;
|
||||
pub use add_table::*;
|
||||
pub use add_logic::*;
|
||||
@@ -1,9 +1,7 @@
|
||||
// src/components/mod.rs
|
||||
|
||||
pub mod admin;
|
||||
pub mod common;
|
||||
pub mod utils;
|
||||
|
||||
pub use admin::*;
|
||||
pub use common::*;
|
||||
pub use utils::*;
|
||||
|
||||
@@ -148,19 +148,17 @@ impl Config {
|
||||
/// Context-aware keybinding resolution
|
||||
pub fn get_action_for_current_context(
|
||||
&self,
|
||||
is_edit_mode: bool,
|
||||
command_mode: bool,
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers
|
||||
) -> Option<&str> {
|
||||
match (command_mode, is_edit_mode) {
|
||||
(true, _) => self.get_command_action_for_key(key, modifiers),
|
||||
(_, true) => self.get_edit_action_for_key(key, modifiers)
|
||||
.or_else(|| self.get_common_action(key, modifiers)),
|
||||
_ => self.get_read_only_action_for_key(key, modifiers)
|
||||
if command_mode {
|
||||
self.get_command_action_for_key(key, modifiers)
|
||||
} else {
|
||||
// fallback: read-only + common + global
|
||||
self.get_read_only_action_for_key(key, modifiers)
|
||||
.or_else(|| self.get_common_action(key, modifiers))
|
||||
// Add global bindings check for read-only mode
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)),
|
||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,28 +250,44 @@ impl Config {
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
) -> bool {
|
||||
// Special handling for shift+character combinations
|
||||
if binding.to_lowercase().starts_with("shift+") {
|
||||
|
||||
// Normalize binding once
|
||||
let binding_lc = binding.to_lowercase();
|
||||
|
||||
// Robust handling for Shift+Tab
|
||||
// Accept either BackTab (with or without SHIFT flagged) or Tab+SHIFT
|
||||
if binding_lc == "shift+tab" || binding_lc == "backtab" {
|
||||
return match key {
|
||||
KeyCode::BackTab => true,
|
||||
KeyCode::Tab => modifiers.contains(KeyModifiers::SHIFT),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
// Robust handling for shift+<char> (letters)
|
||||
// Many terminals send uppercase Char without SHIFT bit.
|
||||
if binding_lc.starts_with("shift+") {
|
||||
let parts: Vec<&str> = binding.split('+').collect();
|
||||
if parts.len() == 2 && parts[1].len() == 1 {
|
||||
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
|
||||
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
|
||||
if let KeyCode::Char(actual_char) = key {
|
||||
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
|
||||
if parts.len() == 2 && parts[1].chars().count() == 1 {
|
||||
let base = parts[1].chars().next().unwrap();
|
||||
let upper = base.to_ascii_uppercase();
|
||||
let lower = base.to_ascii_lowercase();
|
||||
if let KeyCode::Char(actual) = key {
|
||||
// Accept uppercase char regardless of SHIFT bit
|
||||
if actual == upper {
|
||||
return true;
|
||||
}
|
||||
// Also accept lowercase char with SHIFT flagged (some terms do this)
|
||||
if actual == lower && modifiers.contains(KeyModifiers::SHIFT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Shift+Tab -> BackTab
|
||||
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle multi-character bindings (all standard keys without modifiers)
|
||||
if binding.len() > 1 && !binding.contains('+') {
|
||||
return match binding.to_lowercase().as_str() {
|
||||
return match binding_lc.as_str() {
|
||||
// Navigation keys
|
||||
"left" => key == KeyCode::Left,
|
||||
"right" => key == KeyCode::Right,
|
||||
@@ -373,6 +387,7 @@ impl Config {
|
||||
let mut expected_key = None;
|
||||
|
||||
for part in parts {
|
||||
let part_lc = part.to_lowercase();
|
||||
match part.to_lowercase().as_str() {
|
||||
// Modifiers
|
||||
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
|
||||
@@ -791,12 +806,43 @@ impl Config {
|
||||
None
|
||||
}
|
||||
|
||||
// Normalize bindings for canvas consumption:
|
||||
// - "shift+<char>" -> also add "<CHAR>"
|
||||
// - "shift+tab" -> also add "backtab"
|
||||
// This keeps your config human-friendly while making the canvas happy.
|
||||
fn normalize_for_canvas(
|
||||
map: &HashMap<String, Vec<String>>,
|
||||
) -> HashMap<String, Vec<String>> {
|
||||
let mut out: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (action, bindings) in map {
|
||||
let mut new_list: Vec<String> = Vec::new();
|
||||
for b in bindings {
|
||||
new_list.push(b.clone());
|
||||
let blc = b.to_lowercase();
|
||||
if blc.starts_with("shift+") {
|
||||
let parts: Vec<&str> = b.split('+').collect();
|
||||
if parts.len() == 2 && parts[1].chars().count() == 1 {
|
||||
let ch = parts[1].chars().next().unwrap();
|
||||
new_list.push(ch.to_ascii_uppercase().to_string());
|
||||
}
|
||||
if blc == "shift+tab" {
|
||||
new_list.push("backtab".to_string());
|
||||
}
|
||||
}
|
||||
if blc == "shift+tab" {
|
||||
new_list.push("backtab".to_string());
|
||||
}
|
||||
}
|
||||
out.insert(action.clone(), new_list);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn build_canvas_keymap(&self) -> CanvasKeyMap {
|
||||
CanvasKeyMap::from_mode_maps(
|
||||
&self.keybindings.read_only,
|
||||
&self.keybindings.edit,
|
||||
&self.keybindings.highlight,
|
||||
)
|
||||
let ro = Self::normalize_for_canvas(&self.keybindings.read_only);
|
||||
let ed = Self::normalize_for_canvas(&self.keybindings.edit);
|
||||
let hl = Self::normalize_for_canvas(&self.keybindings.highlight);
|
||||
CanvasKeyMap::from_mode_maps(&ro, &ed, &hl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ impl KeySequenceTracker {
|
||||
// Helper function to convert any KeyCode to a string representation
|
||||
pub fn key_to_string(key: &KeyCode) -> String {
|
||||
match key {
|
||||
KeyCode::Char(' ') => "space".to_string(),
|
||||
KeyCode::Char(c) => c.to_string(),
|
||||
KeyCode::Left => "left".to_string(),
|
||||
KeyCode::Right => "right".to_string(),
|
||||
@@ -90,6 +91,7 @@ pub fn key_to_string(key: &KeyCode) -> String {
|
||||
// Helper function to convert a string to a KeyCode
|
||||
pub fn string_to_keycode(s: &str) -> Option<KeyCode> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"space" => Some(KeyCode::Char(' ')),
|
||||
"left" => Some(KeyCode::Left),
|
||||
"right" => Some(KeyCode::Right),
|
||||
"up" => Some(KeyCode::Up),
|
||||
@@ -140,7 +142,7 @@ fn is_compound_key(part: &str) -> bool {
|
||||
matches!(part.to_lowercase().as_str(),
|
||||
"esc" | "up" | "down" | "left" | "right" | "enter" |
|
||||
"backspace" | "delete" | "tab" | "backtab" | "home" |
|
||||
"end" | "pageup" | "pagedown" | "insert"
|
||||
"end" | "pageup" | "pagedown" | "insert" | "space"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ impl AppState {
|
||||
self.ui.dialog.purpose = Some(purpose);
|
||||
self.ui.dialog.is_loading = false;
|
||||
self.ui.dialog.dialog_show = true;
|
||||
self.ui.focus_outside_canvas = true;
|
||||
}
|
||||
|
||||
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
|
||||
@@ -30,7 +29,6 @@ impl AppState {
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.dialog.is_loading = true;
|
||||
self.ui.dialog.dialog_show = true;
|
||||
self.ui.focus_outside_canvas = true;
|
||||
}
|
||||
|
||||
pub fn update_dialog_content(
|
||||
@@ -55,7 +53,6 @@ impl AppState {
|
||||
self.ui.dialog.dialog_buttons.clear();
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.focus_outside_canvas = false;
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::buffer::state::BufferState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::pages::register;
|
||||
use crate::pages::login;
|
||||
use crate::tui::functions::common::add_table::handle_delete_selected_columns;
|
||||
use crate::pages::admin_panel::add_table::logic::handle_delete_selected_columns;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -154,15 +154,14 @@ pub async fn handle_dialog_event(
|
||||
DialogPurpose::ConfirmDeleteColumns => match selected_index {
|
||||
0 => {
|
||||
// "Confirm" button selected
|
||||
if let Page::Admin(state) = &mut router.current {
|
||||
let outcome_message =
|
||||
handle_delete_selected_columns(&mut state.add_table_state);
|
||||
if let Page::AddTable(page) = &mut router.current {
|
||||
let outcome_message = handle_delete_selected_columns(&mut page.state);
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(outcome_message)));
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Admin state not active".to_string(),
|
||||
)));
|
||||
"AddTable page not active".to_string(),
|
||||
)));
|
||||
}
|
||||
1 => {
|
||||
// "Cancel" button selected
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/functions/mod.rs
|
||||
|
||||
pub mod modes;
|
||||
|
||||
pub use modes::*;
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/functions/modes.rs
|
||||
|
||||
pub mod navigation;
|
||||
|
||||
pub use navigation::*;
|
||||
@@ -1,5 +0,0 @@
|
||||
// src/functions/modes/navigation.rs
|
||||
|
||||
pub mod admin_nav;
|
||||
pub mod add_table_nav;
|
||||
pub mod add_logic_nav;
|
||||
@@ -1,446 +0,0 @@
|
||||
// src/functions/modes/navigation/add_logic_nav.rs
|
||||
use crate::config::binds::config::{Config, EditorKeybindingMode};
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_logic::{AddLogicFocus, AddLogicState},
|
||||
};
|
||||
use crate::buffer::{AppView, BufferState};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
use crate::components::common::text_editor::TextEditor;
|
||||
use crate::services::ui_service::UiService;
|
||||
use tui_textarea::CursorMove;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
pub fn handle_add_logic_navigation(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
is_edit_mode: &mut bool,
|
||||
buffer_state: &mut BufferState,
|
||||
grpc_client: GrpcClient,
|
||||
save_logic_sender: SaveLogicResultSender,
|
||||
command_message: &mut String,
|
||||
router: &mut Router,
|
||||
) -> bool {
|
||||
if let Page::AddLogic(add_logic_state) = &mut router.current {
|
||||
|
||||
// === FULLSCREEN SCRIPT EDITING - COMPLETE ISOLATION ===
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
// === AUTOCOMPLETE HANDLING ===
|
||||
if add_logic_state.script_editor_autocomplete_active {
|
||||
match key_event.code {
|
||||
// ... (Char, Backspace, Tab, Down, Up cases remain the same) ...
|
||||
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
|
||||
add_logic_state.script_editor_filter_text.push(c);
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = format!("Filtering: @{}", add_logic_state.script_editor_filter_text);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !add_logic_state.script_editor_filter_text.is_empty() {
|
||||
add_logic_state.script_editor_filter_text.pop();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
*command_message = if add_logic_state.script_editor_filter_text.is_empty() {
|
||||
"Autocomplete: @".to_string()
|
||||
} else {
|
||||
format!("Filtering: @{}", add_logic_state.script_editor_filter_text)
|
||||
};
|
||||
} else {
|
||||
let should_deactivate = if let Some((trigger_line, trigger_col)) = add_logic_state.script_editor_trigger_position {
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
current_cursor.0 == trigger_line && current_cursor.1 == trigger_col + 1
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_deactivate {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let next = (current + 1) % add_logic_state.script_editor_suggestions.len();
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(next);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[next]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
let current = add_logic_state.script_editor_selected_suggestion_index.unwrap_or(0);
|
||||
let prev = if current == 0 {
|
||||
add_logic_state.script_editor_suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
add_logic_state.script_editor_selected_suggestion_index = Some(prev);
|
||||
*command_message = format!("Selected: {}", add_logic_state.script_editor_suggestions[prev]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected_idx) = add_logic_state.script_editor_selected_suggestion_index {
|
||||
if let Some(suggestion) = add_logic_state.script_editor_suggestions.get(selected_idx).cloned() {
|
||||
let trigger_pos = add_logic_state.script_editor_trigger_position;
|
||||
let filter_len = add_logic_state.script_editor_filter_text.len();
|
||||
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
|
||||
if let Some(pos) = trigger_pos {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
|
||||
if suggestion == "sql" {
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, "sql");
|
||||
editor_borrow.insert_str("('')");
|
||||
// Move cursor back twice to be between the single quotes
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ')'
|
||||
editor_borrow.move_cursor(CursorMove::Back); // Before ''' (inside '')
|
||||
*command_message = "Inserted: @sql('')".to_string();
|
||||
} else {
|
||||
let is_table_selection = add_logic_state.is_table_name_suggestion(&suggestion);
|
||||
replace_autocomplete_text(&mut editor_borrow, pos, filter_len, &suggestion);
|
||||
|
||||
if is_table_selection {
|
||||
editor_borrow.insert_str(".");
|
||||
let new_cursor = editor_borrow.cursor();
|
||||
drop(editor_borrow); // Release borrow before calling add_logic_state methods
|
||||
|
||||
add_logic_state.script_editor_trigger_position = Some(new_cursor);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.trigger_column_autocomplete_for_table(suggestion.clone());
|
||||
|
||||
let profile_name = add_logic_state.profile_name.clone();
|
||||
let table_name_for_fetch = suggestion.clone();
|
||||
let mut client_clone = grpc_client.clone();
|
||||
tokio::spawn(async move {
|
||||
match UiService::fetch_columns_for_table(&mut client_clone, &profile_name, &table_name_for_fetch).await {
|
||||
Ok(_columns) => {
|
||||
// Result handled by main UI loop
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name_for_fetch, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
*command_message = format!("Selected table '{}', fetching columns...", suggestion);
|
||||
} else {
|
||||
*command_message = format!("Inserted: {}", suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
}
|
||||
_ => {
|
||||
add_logic_state.deactivate_script_editor_autocomplete();
|
||||
*command_message = "Autocomplete cancelled".to_string();
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Char('@') && key_event.modifiers == KeyModifiers::NONE {
|
||||
let should_trigger = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => *is_edit_mode,
|
||||
_ => true,
|
||||
};
|
||||
if should_trigger {
|
||||
let cursor_before = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
editor_borrow.cursor()
|
||||
};
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
add_logic_state.script_editor_trigger_position = Some(cursor_before);
|
||||
add_logic_state.script_editor_autocomplete_active = true;
|
||||
add_logic_state.script_editor_filter_text.clear();
|
||||
add_logic_state.update_script_editor_suggestions();
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
*command_message = "Autocomplete: @ (Tab/↑↓ to navigate, Enter to select, Esc to cancel)".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE {
|
||||
match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => {
|
||||
if *is_edit_mode {
|
||||
{
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
);
|
||||
}
|
||||
if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "VIM: Normal Mode. Esc again to exit script.".to_string();
|
||||
}
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if *is_edit_mode {
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script edit. Esc again to exit script.".to_string();
|
||||
} else {
|
||||
add_logic_state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
*is_edit_mode = false;
|
||||
*command_message = "Exited script editing.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let changed = {
|
||||
let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_state.editor_keybinding_mode,
|
||||
&mut add_logic_state.vim_state,
|
||||
)
|
||||
};
|
||||
if changed {
|
||||
add_logic_state.has_unsaved_changes = true;
|
||||
}
|
||||
if add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim {
|
||||
*is_edit_mode = !TextEditor::is_vim_normal_mode(&add_logic_state.vim_state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let action = config.get_general_action(key_event.code, key_event.modifiers);
|
||||
let current_focus = add_logic_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
handled = false;
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => {}
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName => new_focus = AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => {
|
||||
add_logic_state.last_canvas_field = 2;
|
||||
new_focus = AddLogicFocus::ScriptContentPreview;
|
||||
},
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => {}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ new_focus = AddLogicFocus::ScriptContentPreview; }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => { }
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription =>
|
||||
{ }
|
||||
AddLogicFocus::ScriptContentPreview => new_focus = AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => new_focus = AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => new_focus = AddLogicFocus::SaveButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton,
|
||||
AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::ScriptContentPreview => AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::SaveButton => AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::CancelButton => AddLogicFocus::SaveButton,
|
||||
_ => current_focus,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::ScriptContentPreview => {
|
||||
new_focus = AddLogicFocus::InsideScriptContent;
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
let mode_hint = match add_logic_state.editor_keybinding_mode {
|
||||
EditorKeybindingMode::Vim => "VIM mode - 'i'/'a'/'o' to edit",
|
||||
_ => "Enter/Ctrl+E to edit",
|
||||
};
|
||||
*command_message = format!("Fullscreen script editing. {} or Esc to exit.", mode_hint);
|
||||
}
|
||||
AddLogicFocus::SaveButton => {
|
||||
*command_message = "Save logic action".to_string();
|
||||
}
|
||||
AddLogicFocus::CancelButton => {
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
*command_message = "Cancelled Add Logic".to_string();
|
||||
*is_edit_mode = false;
|
||||
|
||||
}
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("toggle_edit_mode") => {
|
||||
match current_focus {
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => {
|
||||
*is_edit_mode = !*is_edit_mode;
|
||||
*command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" });
|
||||
}
|
||||
_ => {
|
||||
*command_message = "Cannot toggle edit mode here.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_logic_state.current_focus = new_focus;
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription
|
||||
);
|
||||
if new_is_canvas_input_focus {
|
||||
*is_edit_mode = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
} else {
|
||||
app_state.ui.focus_outside_canvas = true;
|
||||
if matches!(new_focus, AddLogicFocus::ScriptContentPreview) {
|
||||
*is_edit_mode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
} else {
|
||||
return false; // not on AddLogic page
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_autocomplete_text(
|
||||
editor: &mut tui_textarea::TextArea,
|
||||
trigger_pos: (usize, usize),
|
||||
filter_len: usize,
|
||||
replacement: &str,
|
||||
) {
|
||||
// use tui_textarea::CursorMove; // Already imported at the top of the module
|
||||
let filter_start_pos = (trigger_pos.0, trigger_pos.1 + 1);
|
||||
editor.move_cursor(CursorMove::Jump(filter_start_pos.0 as u16, filter_start_pos.1 as u16));
|
||||
for _ in 0..filter_len {
|
||||
editor.delete_next_char();
|
||||
}
|
||||
editor.insert_str(replacement);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// src/functions/modes/navigation/add_table_nav.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
pages::add_table::{AddTableFocus, AddTableState},
|
||||
};
|
||||
use crossterm::event::{KeyEvent};
|
||||
use ratatui::widgets::TableState;
|
||||
use crate::tui::functions::common::add_table::{handle_add_column_action, handle_save_table_action};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::services::GrpcClient;
|
||||
use tokio::sync::mpsc;
|
||||
use anyhow::Result;
|
||||
|
||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
||||
|
||||
fn navigate_table_up(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index > 0 { table_state.select(Some(index - 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_table_down(table_state: &mut TableState, item_count: usize) -> bool {
|
||||
if item_count == 0 { return false; }
|
||||
let current_selection = table_state.selected();
|
||||
match current_selection {
|
||||
Some(index) => {
|
||||
if index < item_count - 1 { table_state.select(Some(index + 1)); true }
|
||||
else { false }
|
||||
}
|
||||
None => { table_state.select(Some(0)); true }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_add_table_navigation(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
grpc_client: GrpcClient,
|
||||
save_result_sender: SaveTableResultSender,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers);
|
||||
let current_focus = add_table_state.current_focus;
|
||||
let mut handled = true;
|
||||
let mut new_focus = current_focus;
|
||||
|
||||
if matches!(current_focus, AddTableFocus::InsideColumnsTable | AddTableFocus::InsideIndexesTable | AddTableFocus::InsideLinksTable) {
|
||||
if matches!(action.as_deref(), Some("next_option") | Some("previous_option")) {
|
||||
*command_message = "Press Esc to exit table item navigation first.".to_string();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
match action.as_deref() {
|
||||
Some("exit_table_scroll") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InsideColumnsTable => {
|
||||
add_table_state.column_table_state.select(None);
|
||||
new_focus = AddTableFocus::ColumnsTable;
|
||||
// *command_message = "Exited Columns Table".to_string(); // Minimal change: remove message
|
||||
}
|
||||
AddTableFocus::InsideIndexesTable => {
|
||||
add_table_state.index_table_state.select(None);
|
||||
new_focus = AddTableFocus::IndexesTable;
|
||||
// *command_message = "Exited Indexes Table".to_string();
|
||||
}
|
||||
AddTableFocus::InsideLinksTable => {
|
||||
add_table_state.link_table_state.select(None);
|
||||
new_focus = AddTableFocus::LinksTable;
|
||||
// *command_message = "Exited Links Table".to_string();
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("move_up") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At top of form.".to_string(); // Remove message
|
||||
}
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnType => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_up(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_up(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_up(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
}
|
||||
}
|
||||
Some("move_down") => {
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName => new_focus = AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnName => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::InputColumnType => {
|
||||
add_table_state.last_canvas_field = 2;
|
||||
new_focus = AddTableFocus::AddColumnButton;
|
||||
},
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::InsideColumnsTable => { navigate_table_down(&mut add_table_state.column_table_state, add_table_state.columns.len()); }
|
||||
AddTableFocus::InsideIndexesTable => { navigate_table_down(&mut add_table_state.index_table_state, add_table_state.indexes.len()); }
|
||||
AddTableFocus::InsideLinksTable => { navigate_table_down(&mut add_table_state.link_table_state, add_table_state.links.len()); }
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => {
|
||||
// MINIMAL CHANGE: Do nothing, new_focus remains current_focus
|
||||
// *command_message = "At bottom of form.".to_string(); // Remove message
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("next_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ new_focus = AddTableFocus::AddColumnButton; }
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::CancelButton,
|
||||
AddTableFocus::CancelButton => { /* *command_message = "At last focusable area.".to_string(); */ } // No change in focus
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("previous_option") => { // This logic should already be non-wrapping
|
||||
match current_focus {
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType =>
|
||||
{ /* *command_message = "At first focusable area.".to_string(); */ } // No change in focus
|
||||
AddTableFocus::AddColumnButton => new_focus = AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable => new_focus = AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::IndexesTable => new_focus = AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::LinksTable => new_focus = AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => new_focus = AddTableFocus::LinksTable,
|
||||
AddTableFocus::DeleteSelectedButton => new_focus = AddTableFocus::SaveButton,
|
||||
AddTableFocus::CancelButton => new_focus = AddTableFocus::DeleteSelectedButton,
|
||||
_ => handled = false,
|
||||
}
|
||||
}
|
||||
Some("next_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::InputColumnName, AddTableFocus::InputColumnName => AddTableFocus::InputColumnType, AddTableFocus::InputColumnType => AddTableFocus::AddColumnButton, AddTableFocus::AddColumnButton => AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::IndexesTable, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::LinksTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::SaveButton,
|
||||
AddTableFocus::SaveButton => AddTableFocus::DeleteSelectedButton, AddTableFocus::DeleteSelectedButton => AddTableFocus::CancelButton, AddTableFocus::CancelButton => AddTableFocus::InputTableName,
|
||||
};
|
||||
}
|
||||
Some("prev_field") => {
|
||||
new_focus = match current_focus {
|
||||
AddTableFocus::InputTableName => AddTableFocus::CancelButton, AddTableFocus::InputColumnName => AddTableFocus::InputTableName, AddTableFocus::InputColumnType => AddTableFocus::InputColumnName, AddTableFocus::AddColumnButton => AddTableFocus::InputColumnType,
|
||||
AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable => AddTableFocus::AddColumnButton, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable => AddTableFocus::ColumnsTable, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable => AddTableFocus::IndexesTable,
|
||||
AddTableFocus::SaveButton => AddTableFocus::LinksTable, AddTableFocus::DeleteSelectedButton => AddTableFocus::SaveButton, AddTableFocus::CancelButton => AddTableFocus::DeleteSelectedButton,
|
||||
};
|
||||
}
|
||||
Some("select") => {
|
||||
match current_focus {
|
||||
AddTableFocus::ColumnsTable => { new_focus = AddTableFocus::InsideColumnsTable; if add_table_state.column_table_state.selected().is_none() && !add_table_state.columns.is_empty() { add_table_state.column_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::IndexesTable => { new_focus = AddTableFocus::InsideIndexesTable; if add_table_state.index_table_state.selected().is_none() && !add_table_state.indexes.is_empty() { add_table_state.index_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::LinksTable => { new_focus = AddTableFocus::InsideLinksTable; if add_table_state.link_table_state.selected().is_none() && !add_table_state.links.is_empty() { add_table_state.link_table_state.select(Some(0)); } /* Message removed */ }
|
||||
AddTableFocus::InsideColumnsTable => { if let Some(index) = add_table_state.column_table_state.selected() { if let Some(col) = add_table_state.columns.get_mut(index) { col.selected = !col.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideIndexesTable => { if let Some(index) = add_table_state.index_table_state.selected() { if let Some(idx_def) = add_table_state.indexes.get_mut(index) { idx_def.selected = !idx_def.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::InsideLinksTable => { if let Some(index) = add_table_state.link_table_state.selected() { if let Some(link) = add_table_state.links.get_mut(index) { link.selected = !link.selected; add_table_state.has_unsaved_changes = true; /* Message removed */ }} /* else { Message removed } */ }
|
||||
AddTableFocus::AddColumnButton => { if let Some(focus_after_add) = handle_add_column_action(add_table_state, command_message) { new_focus = focus_after_add; } else { /* Message already set by handle_add_column_action */ }}
|
||||
AddTableFocus::SaveButton => { if add_table_state.table_name.is_empty() { *command_message = "Cannot save: Table name is empty.".to_string(); } else if add_table_state.columns.is_empty() { *command_message = "Cannot save: No columns defined.".to_string(); } else { *command_message = "Saving table...".to_string(); app_state.show_loading_dialog("Saving", "Please wait..."); let mut client_clone = grpc_client.clone(); let state_clone = add_table_state.clone(); let sender_clone = save_result_sender.clone(); tokio::spawn(async move { let result = handle_save_table_action(&mut client_clone, &state_clone).await; let _ = sender_clone.send(result).await; }); }}
|
||||
AddTableFocus::DeleteSelectedButton => { let columns_to_delete: Vec<(usize, String, String)> = add_table_state.columns.iter().enumerate().filter(|(_, col)| col.selected).map(|(index, col)| (index, col.name.clone(), col.data_type.clone())).collect(); if columns_to_delete.is_empty() { *command_message = "No columns selected for deletion.".to_string(); } else { let column_details: String = columns_to_delete.iter().map(|(index, name, dtype)| format!("{}. {} ({})", index + 1, name, dtype)).collect::<Vec<String>>().join("\n"); let message = format!("Delete the following columns?\n\n{}", column_details); app_state.show_dialog("Confirm Deletion", &message, vec!["Confirm".to_string(), "Cancel".to_string()], DialogPurpose::ConfirmDeleteColumns); }}
|
||||
AddTableFocus::CancelButton => { *command_message = "Action: Cancel Add Table (Not Implemented)".to_string(); }
|
||||
_ => { handled = false; }
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
}
|
||||
|
||||
if handled && current_focus != new_focus {
|
||||
add_table_state.current_focus = new_focus;
|
||||
// Minimal change: Command message update logic can be simplified or removed if not desired
|
||||
// For now, let's keep it minimal and only update if it was truly a focus change,
|
||||
// and not a boundary message.
|
||||
if !command_message.starts_with("At ") && current_focus != new_focus { // Avoid overwriting boundary messages
|
||||
// *command_message = format!("Focus: {:?}", add_table_state.current_focus); // Optional: restore if needed
|
||||
}
|
||||
|
||||
|
||||
let new_is_canvas_input_focus = matches!(new_focus,
|
||||
AddTableFocus::InputTableName | AddTableFocus::InputColumnName | AddTableFocus::InputColumnType
|
||||
);
|
||||
app_state.ui.focus_outside_canvas = !new_is_canvas_input_focus;
|
||||
}
|
||||
// If not handled, command_message remains as it was (e.g., from a deeper function call or previous event)
|
||||
// or can be cleared if that's the desired default. For minimal change, we leave it.
|
||||
|
||||
handled
|
||||
}
|
||||
41
client/src/input/action.rs
Normal file
41
client/src/input/action.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/input/action.rs
|
||||
use crate::movement::MovementAction;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BufferAction {
|
||||
Next,
|
||||
Previous,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CoreAction {
|
||||
Save,
|
||||
ForceQuit,
|
||||
SaveAndQuit,
|
||||
Revert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppAction {
|
||||
// Global/UI
|
||||
ToggleSidebar,
|
||||
ToggleBufferList,
|
||||
OpenSearch,
|
||||
FindFilePaletteToggle,
|
||||
|
||||
// Buffers
|
||||
Buffer(BufferAction),
|
||||
|
||||
// Command mode
|
||||
EnterCommandMode,
|
||||
ExitCommandMode,
|
||||
CommandExecute,
|
||||
CommandBackspace,
|
||||
|
||||
// Navigation across UI
|
||||
Navigate(MovementAction),
|
||||
|
||||
// Core actions
|
||||
Core(CoreAction),
|
||||
}
|
||||
176
client/src/input/engine.rs
Normal file
176
client/src/input/engine.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
// src/input/engine.rs
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||
use crate::input::action::{AppAction, BufferAction, CoreAction};
|
||||
use crate::movement::MovementAction;
|
||||
use crate::modes::handlers::mode_manager::AppMode;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct InputContext {
|
||||
pub app_mode: AppMode,
|
||||
pub overlay_active: bool,
|
||||
pub allow_navigation_capture: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InputOutcome {
|
||||
Action(AppAction),
|
||||
Pending, // sequence in progress
|
||||
PassThrough, // let page/canvas handle it
|
||||
}
|
||||
|
||||
pub struct InputEngine {
|
||||
seq: KeySequenceTracker,
|
||||
}
|
||||
|
||||
impl InputEngine {
|
||||
pub fn new(timeout_ms: u64) -> Self {
|
||||
Self {
|
||||
seq: KeySequenceTracker::new(timeout_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_sequence(&mut self) {
|
||||
self.seq.reset();
|
||||
}
|
||||
|
||||
pub fn process_key(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
ctx: &InputContext,
|
||||
config: &Config,
|
||||
) -> InputOutcome {
|
||||
// Command mode keys are special (exit/execute/backspace) and typed chars
|
||||
if ctx.app_mode == AppMode::Command {
|
||||
if config.is_exit_command_mode(key_event.code, key_event.modifiers) {
|
||||
self.seq.reset();
|
||||
return InputOutcome::Action(AppAction::ExitCommandMode);
|
||||
}
|
||||
if config.is_command_execute(key_event.code, key_event.modifiers) {
|
||||
self.seq.reset();
|
||||
return InputOutcome::Action(AppAction::CommandExecute);
|
||||
}
|
||||
if config.is_command_backspace(key_event.code, key_event.modifiers) {
|
||||
self.seq.reset();
|
||||
return InputOutcome::Action(AppAction::CommandBackspace);
|
||||
}
|
||||
// Let command-line collect characters and other keys pass through
|
||||
self.seq.reset();
|
||||
return InputOutcome::PassThrough;
|
||||
}
|
||||
|
||||
// If overlays are active, do not intercept (palette, navigation, etc.)
|
||||
if ctx.overlay_active {
|
||||
self.seq.reset();
|
||||
return InputOutcome::PassThrough;
|
||||
}
|
||||
|
||||
// Space-led multi-key sequences (leader = space)
|
||||
if ctx.allow_navigation_capture {
|
||||
let space = KeyCode::Char(' ');
|
||||
let seq_active = !self.seq.current_sequence.is_empty()
|
||||
&& self.seq.current_sequence[0] == space;
|
||||
|
||||
if seq_active {
|
||||
self.seq.add_key(key_event.code);
|
||||
let sequence = self.seq.get_sequence();
|
||||
|
||||
if let Some(action_str) = config.matches_key_sequence_generalized(&sequence) {
|
||||
if let Some(app_action) = map_action_string(action_str, ctx) {
|
||||
self.seq.reset();
|
||||
return InputOutcome::Action(app_action);
|
||||
}
|
||||
// A non-app action sequence (canvas stuff) → pass-through
|
||||
self.seq.reset();
|
||||
return InputOutcome::PassThrough;
|
||||
}
|
||||
|
||||
if config.is_key_sequence_prefix(&sequence) {
|
||||
return InputOutcome::Pending;
|
||||
}
|
||||
|
||||
// Not matched and not a prefix → reset and continue to single key
|
||||
self.seq.reset();
|
||||
} else if key_event.code == space && config.is_key_sequence_prefix(&[space]) {
|
||||
self.seq.reset();
|
||||
self.seq.add_key(space);
|
||||
return InputOutcome::Pending;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-key mapping: try general binds first (arrows, open_search, enter_command_mode)
|
||||
if let Some(action_str) =
|
||||
config.get_general_action(key_event.code, key_event.modifiers)
|
||||
{
|
||||
if let Some(app_action) = map_action_string(action_str, ctx) {
|
||||
return InputOutcome::Action(app_action);
|
||||
}
|
||||
// Unknown to app layer (likely canvas movement etc.) → pass
|
||||
return InputOutcome::PassThrough;
|
||||
}
|
||||
|
||||
// Then app-level common/read-only/edit/highlight for UI toggles or core actions
|
||||
if let Some(action_str) = config.get_app_action(key_event.code, key_event.modifiers) {
|
||||
if let Some(app_action) = map_action_string(action_str, ctx) {
|
||||
return InputOutcome::Action(app_action);
|
||||
}
|
||||
}
|
||||
|
||||
InputOutcome::PassThrough
|
||||
}
|
||||
|
||||
/// Check if a key sequence is currently active
|
||||
pub fn has_active_sequence(&self) -> bool {
|
||||
!self.seq.current_sequence.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn str_to_movement(s: &str) -> Option<MovementAction> {
|
||||
match s {
|
||||
"up" => Some(MovementAction::Up),
|
||||
"down" => Some(MovementAction::Down),
|
||||
"left" => Some(MovementAction::Left),
|
||||
"right" => Some(MovementAction::Right),
|
||||
"next" => Some(MovementAction::Next),
|
||||
"previous" => Some(MovementAction::Previous),
|
||||
"select" => Some(MovementAction::Select),
|
||||
"esc" => Some(MovementAction::Esc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_action_string(action: &str, ctx: &InputContext) -> Option<AppAction> {
|
||||
match action {
|
||||
// Global/UI
|
||||
"toggle_sidebar" => Some(AppAction::ToggleSidebar),
|
||||
"toggle_buffer_list" => Some(AppAction::ToggleBufferList),
|
||||
"open_search" => Some(AppAction::OpenSearch),
|
||||
"find_file_palette_toggle" => Some(AppAction::FindFilePaletteToggle),
|
||||
|
||||
// Buffers
|
||||
"next_buffer" => Some(AppAction::Buffer(BufferAction::Next)),
|
||||
"previous_buffer" => Some(AppAction::Buffer(BufferAction::Previous)),
|
||||
"close_buffer" => Some(AppAction::Buffer(BufferAction::Close)),
|
||||
|
||||
// Command mode
|
||||
"enter_command_mode" => Some(AppAction::EnterCommandMode),
|
||||
"exit_command_mode" => Some(AppAction::ExitCommandMode),
|
||||
"command_execute" => Some(AppAction::CommandExecute),
|
||||
"command_backspace" => Some(AppAction::CommandBackspace),
|
||||
|
||||
// Navigation across UI (only if allowed)
|
||||
s if str_to_movement(s).is_some() && ctx.allow_navigation_capture => {
|
||||
Some(AppAction::Navigate(str_to_movement(s).unwrap()))
|
||||
}
|
||||
|
||||
// Core actions
|
||||
"save" => Some(AppAction::Core(CoreAction::Save)),
|
||||
"force_quit" => Some(AppAction::Core(CoreAction::ForceQuit)),
|
||||
"save_and_quit" => Some(AppAction::Core(CoreAction::SaveAndQuit)),
|
||||
"revert" => Some(AppAction::Core(CoreAction::Revert)),
|
||||
|
||||
// Unknown to app layer: ignore (canvas-specific actions, etc.)
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
3
client/src/input/mod.rs
Normal file
3
client/src/input/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// src/input/mod.rs
|
||||
pub mod action;
|
||||
pub mod engine;
|
||||
@@ -5,7 +5,6 @@ pub mod config;
|
||||
pub mod state;
|
||||
pub mod components;
|
||||
pub mod modes;
|
||||
pub mod functions;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
pub mod buffer;
|
||||
@@ -14,6 +13,8 @@ pub mod dialog;
|
||||
pub mod search;
|
||||
pub mod bottom_panel;
|
||||
pub mod pages;
|
||||
pub mod movement;
|
||||
pub mod input;
|
||||
|
||||
pub use ui::run_ui;
|
||||
|
||||
|
||||
@@ -98,19 +98,27 @@ async fn process_command(
|
||||
}
|
||||
}
|
||||
"save" => {
|
||||
let outcome = save(app_state, grpc_client).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
|
||||
SaveOutcome::NoChange => "No changes to save".to_string(),
|
||||
};
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
if let Page::Form(path) = &router.current {
|
||||
let outcome = save(app_state, path, grpc_client).await?;
|
||||
let message = match outcome {
|
||||
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
|
||||
SaveOutcome::UpdatedExisting => "Entry updated".to_string(),
|
||||
SaveOutcome::NoChange => "No changes to save".to_string(),
|
||||
};
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
} else {
|
||||
Ok(EventOutcome::Ok("Not in a form page".to_string()))
|
||||
}
|
||||
}
|
||||
"revert" => {
|
||||
let message = revert(app_state, grpc_client).await?;
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
if let Page::Form(path) = &router.current {
|
||||
let message = revert(app_state, path, grpc_client).await?;
|
||||
command_input.clear();
|
||||
Ok(EventOutcome::Ok(message))
|
||||
} else {
|
||||
Ok(EventOutcome::Ok("Not in a form page".to_string()))
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let message = format!("Unhandled action: {}", action);
|
||||
|
||||
@@ -34,9 +34,12 @@ impl CommandHandler {
|
||||
) -> Result<(bool, String)> {
|
||||
// Use router to check unsaved changes
|
||||
let has_unsaved = match &router.current {
|
||||
Page::Login(state) => state.has_unsaved_changes(),
|
||||
Page::Login(page) => page.state.has_unsaved_changes(),
|
||||
Page::Register(state) => state.has_unsaved_changes(),
|
||||
Page::Form(fs) => fs.has_unsaved_changes,
|
||||
Page::Form(path) => app_state
|
||||
.form_state_for_path_ref(path)
|
||||
.map(|fs| fs.has_unsaved_changes())
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
|
||||
@@ -82,8 +82,6 @@ impl TableDependencyGraph {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
|
||||
pub struct NavigationState {
|
||||
pub active: bool,
|
||||
pub input: String,
|
||||
|
||||
@@ -28,12 +28,12 @@ pub async fn handle_navigation_event(
|
||||
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_up" => {
|
||||
move_up(app_state, router);
|
||||
"up" => {
|
||||
up(app_state, router);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"move_down" => {
|
||||
move_down(app_state, router);
|
||||
"down" => {
|
||||
down(app_state, router);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"next_option" => {
|
||||
@@ -45,14 +45,18 @@ pub async fn handle_navigation_event(
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"next_field" => {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
next_field(fs);
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
next_field(fs);
|
||||
}
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
"prev_field" => {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
prev_field(fs);
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
prev_field(fs);
|
||||
}
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
@@ -62,12 +66,12 @@ pub async fn handle_navigation_event(
|
||||
}
|
||||
"select" => {
|
||||
let (context, index) = match &router.current {
|
||||
Page::Intro(state) => (UiContext::Intro, state.selected_option),
|
||||
Page::Login(_) if app_state.ui.focus_outside_canvas => {
|
||||
(UiContext::Login, app_state.focused_button_index)
|
||||
Page::Intro(state) => (UiContext::Intro, state.focused_button_index),
|
||||
Page::Login(state) if state.focus_outside_canvas => {
|
||||
(UiContext::Login, state.focused_button_index)
|
||||
}
|
||||
Page::Register(_) if app_state.ui.focus_outside_canvas => {
|
||||
(UiContext::Register, app_state.focused_button_index)
|
||||
Page::Register(state) if state.focus_outside_canvas => {
|
||||
(UiContext::Register, state.focused_button_index)
|
||||
}
|
||||
Page::Admin(state) => {
|
||||
(UiContext::Admin, state.get_selected_index().unwrap_or(0))
|
||||
@@ -85,26 +89,26 @@ pub async fn handle_navigation_event(
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
|
||||
pub fn move_up(app_state: &mut AppState, router: &mut Router) {
|
||||
pub fn up(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Login(state) if app_state.ui.focus_outside_canvas => {
|
||||
if app_state.focused_button_index == 0 {
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
let last_field_index = state.field_count().saturating_sub(1);
|
||||
state.set_current_field(last_field_index);
|
||||
Page::Login(page) if page.focus_outside_canvas => {
|
||||
if page.focused_button_index == 0 {
|
||||
page.focus_outside_canvas = false;
|
||||
let last_field_index = page.state.field_count().saturating_sub(1);
|
||||
page.state.set_current_field(last_field_index);
|
||||
} else {
|
||||
app_state.focused_button_index =
|
||||
app_state.focused_button_index.saturating_sub(1);
|
||||
page.focused_button_index =
|
||||
page.focused_button_index.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
Page::Register(state) if app_state.ui.focus_outside_canvas => {
|
||||
if app_state.focused_button_index == 0 {
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
let last_field_index = state.field_count().saturating_sub(1);
|
||||
Page::Register(state) if state.focus_outside_canvas => {
|
||||
if state.focused_button_index == 0 {
|
||||
state.focus_outside_canvas = false;
|
||||
let last_field_index = state.state.field_count().saturating_sub(1);
|
||||
state.set_current_field(last_field_index);
|
||||
} else {
|
||||
app_state.focused_button_index =
|
||||
app_state.focused_button_index.saturating_sub(1);
|
||||
state.focused_button_index =
|
||||
state.focused_button_index.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
Page::Intro(state) => state.previous_option(),
|
||||
@@ -113,12 +117,18 @@ pub fn move_up(app_state: &mut AppState, router: &mut Router) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(app_state: &mut AppState, router: &mut Router) {
|
||||
pub fn down(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Login(_) | Page::Register(_) if app_state.ui.focus_outside_canvas => {
|
||||
Page::Login(state) if state.focus_outside_canvas => {
|
||||
let num_general_elements = 2;
|
||||
if app_state.focused_button_index < num_general_elements - 1 {
|
||||
app_state.focused_button_index += 1;
|
||||
if state.focused_button_index < num_general_elements - 1 {
|
||||
state.focused_button_index += 1;
|
||||
}
|
||||
}
|
||||
Page::Register(state) if state.focus_outside_canvas => {
|
||||
let num_general_elements = 2;
|
||||
if state.focused_button_index < num_general_elements - 1 {
|
||||
state.focused_button_index += 1;
|
||||
}
|
||||
}
|
||||
Page::Intro(state) => state.next_option(),
|
||||
@@ -130,11 +140,11 @@ pub fn move_down(app_state: &mut AppState, router: &mut Router) {
|
||||
pub fn next_option(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Intro(state) => state.next_option(),
|
||||
Page::Admin(_) => {
|
||||
Page::Admin(state) => {
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
if option_count > 0 {
|
||||
app_state.focused_button_index =
|
||||
(app_state.focused_button_index + 1) % option_count;
|
||||
state.focused_button_index =
|
||||
(state.focused_button_index + 1) % option_count;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -144,13 +154,13 @@ pub fn next_option(app_state: &mut AppState, router: &mut Router) {
|
||||
pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
|
||||
match &mut router.current {
|
||||
Page::Intro(state) => state.previous_option(),
|
||||
Page::Admin(_) => {
|
||||
Page::Admin(state) => {
|
||||
let option_count = app_state.profile_tree.profiles.len();
|
||||
if option_count > 0 {
|
||||
app_state.focused_button_index = if app_state.focused_button_index == 0 {
|
||||
state.focused_button_index = if state.focused_button_index == 0 {
|
||||
option_count.saturating_sub(1)
|
||||
} else {
|
||||
app_state.focused_button_index - 1
|
||||
state.focused_button_index - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,27 @@
|
||||
// src/modes/handlers/mode_manager.rs
|
||||
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::modes::handlers::event::EventHandler;
|
||||
use crate::state::pages::add_logic::AddLogicFocus;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppMode {
|
||||
General, // For intro and admin screens
|
||||
ReadOnly, // Canvas read-only mode
|
||||
Edit, // Canvas edit mode
|
||||
Highlight, // Canvas highlight/visual mode
|
||||
Command, // Command mode overlay
|
||||
}
|
||||
/// General mode = when focus is outside any canvas
|
||||
/// (Intro, Admin, Login/Register buttons, AddTable/AddLogic menus, dialogs, etc.)
|
||||
General,
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
/// Command overlay (":" or "ctrl+;"), available globally
|
||||
Command,
|
||||
}
|
||||
|
||||
pub struct ModeManager;
|
||||
|
||||
impl ModeManager {
|
||||
/// Determine current mode based on app state + router
|
||||
/// Determine current mode:
|
||||
/// - If navigation palette is active → General
|
||||
/// - If command overlay is active → Command
|
||||
/// - If focus is inside a canvas (Form, Login, Register, AddTable, AddLogic) → let canvas handle its own mode
|
||||
/// - Otherwise → General
|
||||
pub fn derive_mode(
|
||||
app_state: &AppState,
|
||||
event_handler: &EventHandler,
|
||||
@@ -39,76 +32,25 @@ impl ModeManager {
|
||||
return AppMode::General;
|
||||
}
|
||||
|
||||
// Explicit command mode flag
|
||||
// Explicit command overlay flag
|
||||
if event_handler.command_mode {
|
||||
return AppMode::Command;
|
||||
}
|
||||
|
||||
// If focus is inside a canvas, we don't duplicate canvas modes here.
|
||||
// Canvas crate owns ReadOnly/Edit/Highlight internally.
|
||||
match &router.current {
|
||||
// --- Form view ---
|
||||
Page::Form(_) if !app_state.ui.focus_outside_canvas => {
|
||||
if let Some(editor) = &app_state.form_editor {
|
||||
return AppMode::from(editor.mode());
|
||||
}
|
||||
AppMode::General
|
||||
}
|
||||
|
||||
// --- AddLogic view ---
|
||||
Page::AddLogic(state) => match state.current_focus {
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription => {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
_ => AppMode::General,
|
||||
},
|
||||
|
||||
// --- AddTable view ---
|
||||
Page::AddTable(_) => {
|
||||
if app_state.ui.focus_outside_canvas {
|
||||
AppMode::General
|
||||
} else if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
|
||||
// --- Login/Register views ---
|
||||
Page::Login(_) | Page::Register(_) => {
|
||||
if event_handler.is_edit_mode {
|
||||
AppMode::Edit
|
||||
} else {
|
||||
AppMode::ReadOnly
|
||||
}
|
||||
}
|
||||
|
||||
// --- Everything else (Intro, Admin, etc.) ---
|
||||
Page::Form(_) => AppMode::General, // Form always has its own canvas
|
||||
Page::Login(state) if !state.focus_outside_canvas => AppMode::General,
|
||||
Page::Register(state) if !state.focus_outside_canvas => AppMode::General,
|
||||
Page::AddTable(state) if !state.focus_outside_canvas => AppMode::General,
|
||||
Page::AddLogic(state) if !state.focus_outside_canvas => AppMode::General,
|
||||
_ => AppMode::General,
|
||||
}
|
||||
}
|
||||
|
||||
// Mode transition rules
|
||||
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
|
||||
!matches!(current_mode, AppMode::Edit)
|
||||
}
|
||||
|
||||
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
}
|
||||
|
||||
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
|
||||
matches!(
|
||||
current_mode,
|
||||
AppMode::Edit | AppMode::Command | AppMode::Highlight
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
|
||||
matches!(current_mode, AppMode::ReadOnly)
|
||||
/// Command overlay can be entered from anywhere (General or Canvas).
|
||||
pub fn can_enter_command_mode(_current_mode: AppMode) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
12
client/src/movement/actions.rs
Normal file
12
client/src/movement/actions.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/movement/actions.rs
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MovementAction {
|
||||
Next,
|
||||
Previous,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Select,
|
||||
Esc,
|
||||
}
|
||||
32
client/src/movement/lib.rs
Normal file
32
client/src/movement/lib.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/movement/lib.rs
|
||||
|
||||
use crate::movement::MovementAction;
|
||||
|
||||
#[inline]
|
||||
pub fn move_focus<T: Copy + Eq>(
|
||||
order: &[T],
|
||||
current: &mut T,
|
||||
action: MovementAction,
|
||||
) -> bool {
|
||||
if order.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if let Some(pos) = order.iter().position(|k| *k == *current) {
|
||||
match action {
|
||||
MovementAction::Previous | MovementAction::Up | MovementAction::Left => {
|
||||
if pos > 0 {
|
||||
*current = order[pos - 1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MovementAction::Next | MovementAction::Down | MovementAction::Right => {
|
||||
if pos + 1 < order.len() {
|
||||
*current = order[pos + 1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
6
client/src/movement/mod.rs
Normal file
6
client/src/movement/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/movement/mod.rs
|
||||
pub mod actions;
|
||||
pub mod lib;
|
||||
|
||||
pub use actions::MovementAction;
|
||||
pub use lib::move_focus;
|
||||
65
client/src/pages/admin/admin/event.rs
Normal file
65
client/src/pages/admin/admin/event.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/pages/admin/admin/event.rs
|
||||
use anyhow::Result;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::buffer::state::BufferState;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::pages::admin::main::logic::handle_admin_navigation;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
|
||||
/// Handle all Admin page-specific key events (movement + actions).
|
||||
/// Returns true if the key was handled (so the caller should stop propagation).
|
||||
pub fn handle_admin_event(
|
||||
key_event: KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
router: &mut Router,
|
||||
command_message: &mut String,
|
||||
) -> Result<bool> {
|
||||
if let Page::Admin(admin_state) = &mut router.current {
|
||||
// 1) Map general action to MovementAction (same mapping used in event.rs)
|
||||
let movement_action = if let Some(act) =
|
||||
config.get_general_action(key_event.code, key_event.modifiers)
|
||||
{
|
||||
use crate::movement::MovementAction;
|
||||
match act {
|
||||
"up" => Some(MovementAction::Up),
|
||||
"down" => Some(MovementAction::Down),
|
||||
"left" => Some(MovementAction::Left),
|
||||
"right" => Some(MovementAction::Right),
|
||||
"next" => Some(MovementAction::Next),
|
||||
"previous" => Some(MovementAction::Previous),
|
||||
"select" => Some(MovementAction::Select),
|
||||
"esc" => Some(MovementAction::Esc),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ma) = movement_action {
|
||||
if admin_state.handle_movement(app_state, ma) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Rich Admin navigation (buttons, selections, etc.)
|
||||
if handle_admin_navigation(
|
||||
key_event,
|
||||
config,
|
||||
app_state,
|
||||
buffer_state,
|
||||
router,
|
||||
command_message,
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// If we reached here, nothing was handled
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
54
client/src/pages/admin/admin/loader.rs
Normal file
54
client/src/pages/admin/admin/loader.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/pages/admin/admin/loader.rs
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
|
||||
/// Refresh admin data and ensure focus and selections are valid.
|
||||
pub async fn refresh_admin_state(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
admin_state: &mut AdminState,
|
||||
) -> Result<()> {
|
||||
// Fetch latest profile tree
|
||||
let refreshed_tree = grpc_client
|
||||
.get_profile_tree()
|
||||
.await
|
||||
.context("Failed to refresh profile tree for Admin panel")?;
|
||||
app_state.profile_tree = refreshed_tree;
|
||||
|
||||
// Populate profile names for AdminState's list
|
||||
let profile_names = app_state
|
||||
.profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
admin_state.set_profiles(profile_names);
|
||||
|
||||
// Ensure a sane focus
|
||||
if admin_state.current_focus == AdminFocus::default()
|
||||
|| !matches!(
|
||||
admin_state.current_focus,
|
||||
AdminFocus::InsideProfilesList
|
||||
| AdminFocus::Tables
|
||||
| AdminFocus::InsideTablesList
|
||||
| AdminFocus::Button1
|
||||
| AdminFocus::Button2
|
||||
| AdminFocus::Button3
|
||||
| AdminFocus::ProfilesPane
|
||||
)
|
||||
{
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
}
|
||||
|
||||
// Ensure a selection exists when profiles are present
|
||||
if admin_state.profile_list_state.selected().is_none()
|
||||
&& !app_state.profile_tree.profiles.is_empty()
|
||||
{
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
9
client/src/pages/admin/admin/mod.rs
Normal file
9
client/src/pages/admin/admin/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// src/pages/admin/admin/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod tui;
|
||||
pub mod event;
|
||||
pub mod loader;
|
||||
|
||||
pub use state::{AdminState, AdminFocus};
|
||||
193
client/src/pages/admin/admin/state.rs
Normal file
193
client/src/pages/admin/admin/state.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
// src/pages/admin/admin/state.rs
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::movement::{move_focus, MovementAction};
|
||||
use crate::state::app::state::AppState;
|
||||
|
||||
/// Focus states for the admin panel
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AdminFocus {
|
||||
#[default]
|
||||
ProfilesPane,
|
||||
InsideProfilesList,
|
||||
Tables,
|
||||
InsideTablesList,
|
||||
Button1,
|
||||
Button2,
|
||||
Button3,
|
||||
}
|
||||
|
||||
/// Full admin panel state (for logged-in admins)
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct AdminState {
|
||||
pub profiles: Vec<String>,
|
||||
pub profile_list_state: ListState,
|
||||
pub table_list_state: ListState,
|
||||
pub selected_profile_index: Option<usize>,
|
||||
pub selected_table_index: Option<usize>,
|
||||
pub current_focus: AdminFocus,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
impl AdminState {
|
||||
pub fn get_selected_index(&self) -> Option<usize> {
|
||||
self.profile_list_state.selected()
|
||||
}
|
||||
|
||||
pub fn get_selected_profile_name(&self) -> Option<&String> {
|
||||
self.profile_list_state.selected().and_then(|i| self.profiles.get(i))
|
||||
}
|
||||
|
||||
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
|
||||
let current_selection_index = self.profile_list_state.selected();
|
||||
self.profiles = new_profiles;
|
||||
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
} else {
|
||||
let new_selection = match current_selection_index {
|
||||
Some(index) => Some(index.min(self.profiles.len() - 1)),
|
||||
None => Some(0),
|
||||
};
|
||||
self.profile_list_state.select(new_selection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
|
||||
None => self.profiles.len() - 1,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn handle_movement(
|
||||
&mut self,
|
||||
app: &AppState,
|
||||
action: MovementAction,
|
||||
) -> bool {
|
||||
use AdminFocus::*;
|
||||
|
||||
const ORDER: [AdminFocus; 5] = [
|
||||
ProfilesPane,
|
||||
Tables,
|
||||
Button1,
|
||||
Button2,
|
||||
Button3,
|
||||
];
|
||||
|
||||
match (self.current_focus, action) {
|
||||
(ProfilesPane, MovementAction::Select) => {
|
||||
if !app.profile_tree.profiles.is_empty()
|
||||
&& self.profile_list_state.selected().is_none()
|
||||
{
|
||||
self.profile_list_state.select(Some(0));
|
||||
}
|
||||
self.current_focus = InsideProfilesList;
|
||||
return true;
|
||||
}
|
||||
(Tables, MovementAction::Select) => {
|
||||
let p_idx = self
|
||||
.selected_profile_index
|
||||
.or_else(|| self.profile_list_state.selected());
|
||||
if let Some(pi) = p_idx {
|
||||
let len = app
|
||||
.profile_tree
|
||||
.profiles
|
||||
.get(pi)
|
||||
.map(|p| p.tables.len())
|
||||
.unwrap_or(0);
|
||||
if len > 0 && self.table_list_state.selected().is_none() {
|
||||
self.table_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
self.current_focus = InsideTablesList;
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match self.current_focus {
|
||||
InsideProfilesList => match action {
|
||||
MovementAction::Up => {
|
||||
if !app.profile_tree.profiles.is_empty() {
|
||||
let curr = self.profile_list_state.selected().unwrap_or(0);
|
||||
let next = curr.saturating_sub(1);
|
||||
self.profile_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Down => {
|
||||
let len = app.profile_tree.profiles.len();
|
||||
if len > 0 {
|
||||
let curr = self.profile_list_state.selected().unwrap_or(0);
|
||||
let next = if curr + 1 < len { curr + 1 } else { curr };
|
||||
self.profile_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
self.current_focus = ProfilesPane;
|
||||
true
|
||||
}
|
||||
MovementAction::Next | MovementAction::Previous => true,
|
||||
MovementAction::Select => false,
|
||||
_ => false,
|
||||
},
|
||||
InsideTablesList => {
|
||||
let tables_len = {
|
||||
let p_idx = self
|
||||
.selected_profile_index
|
||||
.or_else(|| self.profile_list_state.selected());
|
||||
p_idx.and_then(|pi| app.profile_tree.profiles.get(pi))
|
||||
.map(|p| p.tables.len())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
match action {
|
||||
MovementAction::Up => {
|
||||
if tables_len > 0 {
|
||||
let curr = self.table_list_state.selected().unwrap_or(0);
|
||||
let next = curr.saturating_sub(1);
|
||||
self.table_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Down => {
|
||||
if tables_len > 0 {
|
||||
let curr = self.table_list_state.selected().unwrap_or(0);
|
||||
let next = if curr + 1 < tables_len { curr + 1 } else { curr };
|
||||
self.table_list_state.select(Some(next));
|
||||
}
|
||||
true
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
self.current_focus = Tables;
|
||||
true
|
||||
}
|
||||
MovementAction::Next | MovementAction::Previous => true,
|
||||
MovementAction::Select => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
move_focus(&ORDER, &mut self.current_focus, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/pages/admin/admin/tui.rs
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::admin::AdminState;
|
||||
|
||||
pub fn handle_admin_selection(app_state: &mut AppState, admin_state: &AdminState) {
|
||||
let profiles = &app_state.profile_tree.profiles;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/admin/admin_panel_admin.rs
|
||||
// src/pages/admin/admin/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
@@ -1,11 +1,12 @@
|
||||
// src/functions/modes/navigation/admin_nav.rs
|
||||
use crate::state::pages::admin::{AdminFocus, AdminState};
|
||||
// src/pages/admin/main/logic.rs
|
||||
use crate::pages::admin::{AdminFocus, AdminState};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::buffer::state::{BufferState, AppView};
|
||||
use crate::state::pages::add_table::{AddTableState, LinkDefinition};
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::state::pages::add_logic::{AddLogicState, AddLogicFocus}; // Added AddLogicFocus import
|
||||
use crate::pages::admin_panel::add_table::state::{AddTableFormState, LinkDefinition};
|
||||
use crate::pages::admin_panel::add_logic::state::{AddLogicState, AddLogicFocus, AddLogicFormState};
|
||||
use crate::pages::routing::{Page, Router};
|
||||
|
||||
// Helper functions list_select_next and list_select_previous remain the same
|
||||
fn list_select_next(list_state: &mut ListState, item_count: usize) {
|
||||
@@ -36,17 +37,35 @@ pub fn handle_admin_navigation(
|
||||
key: crossterm::event::KeyEvent,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
admin_state: &mut AdminState,
|
||||
buffer_state: &mut BufferState,
|
||||
router: &mut Router,
|
||||
command_message: &mut String,
|
||||
) -> bool {
|
||||
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
|
||||
let current_focus = admin_state.current_focus;
|
||||
|
||||
// Check if we're in admin page, but don't borrow mutably yet
|
||||
let is_admin = matches!(&router.current, Page::Admin(_));
|
||||
if !is_admin {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the current focus without borrowing mutably
|
||||
let current_focus = if let Page::Admin(admin_state) = &router.current {
|
||||
admin_state.current_focus
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let profile_count = app_state.profile_tree.profiles.len();
|
||||
let mut handled = false;
|
||||
|
||||
match current_focus {
|
||||
AdminFocus::ProfilesPane => {
|
||||
// Now we can borrow mutably since we're not reassigning router.current
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideProfilesList;
|
||||
@@ -64,7 +83,6 @@ pub fn handle_admin_navigation(
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
|
||||
*command_message = "At first focusable pane.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
@@ -73,6 +91,10 @@ pub fn handle_admin_navigation(
|
||||
}
|
||||
|
||||
AdminFocus::InsideProfilesList => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
if profile_count > 0 {
|
||||
@@ -90,11 +112,11 @@ pub fn handle_admin_navigation(
|
||||
}
|
||||
Some("select") => {
|
||||
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
|
||||
admin_state.selected_table_index = None; // Deselect table when profile changes
|
||||
admin_state.selected_table_index = None;
|
||||
if let Some(profile_idx) = admin_state.selected_profile_index {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||
if !profile.tables.is_empty() {
|
||||
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
|
||||
admin_state.table_list_state.select(Some(0));
|
||||
} else {
|
||||
admin_state.table_list_state.select(None);
|
||||
}
|
||||
@@ -118,6 +140,10 @@ pub fn handle_admin_navigation(
|
||||
}
|
||||
|
||||
AdminFocus::Tables => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
admin_state.current_focus = AdminFocus::InsideTablesList;
|
||||
@@ -147,7 +173,7 @@ pub fn handle_admin_navigation(
|
||||
} else {
|
||||
*command_message = "No tables in selected profile.".to_string();
|
||||
}
|
||||
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
@@ -166,6 +192,10 @@ pub fn handle_admin_navigation(
|
||||
}
|
||||
|
||||
AdminFocus::InsideTablesList => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match action.as_deref() {
|
||||
Some("move_up") => {
|
||||
let current_profile_idx = admin_state.selected_profile_index
|
||||
@@ -205,7 +235,7 @@ pub fn handle_admin_navigation(
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Some("select") => { // This is for persistently selecting a table with [*]
|
||||
Some("select") => {
|
||||
admin_state.selected_table_index = admin_state.table_list_state.selected();
|
||||
let table_name = admin_state.selected_profile_index
|
||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
||||
@@ -225,29 +255,36 @@ pub fn handle_admin_navigation(
|
||||
|
||||
AdminFocus::Button1 => { // Add Logic Button
|
||||
match action.as_deref() {
|
||||
Some("select") => { // Typically "Enter" key
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
Some("select") => {
|
||||
// Extract needed data first, before any router reassignment
|
||||
let (selected_profile_idx, selected_table_idx) = if let Page::Admin(admin_state) = &router.current {
|
||||
(admin_state.selected_profile_index, admin_state.selected_table_index)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(p_idx) = selected_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
if let Some(t_idx) = admin_state.selected_table_index {
|
||||
if let Some(t_idx) = selected_table_idx {
|
||||
if let Some(table) = profile.tables.get(t_idx) {
|
||||
// Both profile and table are selected, proceed
|
||||
admin_state.add_logic_state = AddLogicState {
|
||||
profile_name: profile.name.clone(),
|
||||
selected_table_name: Some(table.name.clone()),
|
||||
selected_table_id: Some(table.id), // If you have table IDs
|
||||
editor_keybinding_mode: config.editor.keybinding_mode.clone(),
|
||||
current_focus: AddLogicFocus::default(),
|
||||
..AddLogicState::default()
|
||||
};
|
||||
|
||||
// Create AddLogic page with selected profile & table
|
||||
let add_logic_form = AddLogicFormState::new_with_table(
|
||||
&config.editor,
|
||||
profile.name.clone(),
|
||||
Some(table.id),
|
||||
table.name.clone(),
|
||||
);
|
||||
|
||||
// Store table info for later fetching
|
||||
app_state.pending_table_structure_fetch = Some((
|
||||
profile.name.clone(),
|
||||
table.name.clone()
|
||||
table.name.clone(),
|
||||
));
|
||||
|
||||
|
||||
// Now it's safe to reassign router.current
|
||||
router.current = Page::AddLogic(add_logic_form);
|
||||
buffer_state.update_history(AppView::AddLogic);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
|
||||
*command_message = format!(
|
||||
"Opening Add Logic for table '{}' in profile '{}'...",
|
||||
table.name, profile.name
|
||||
@@ -267,11 +304,17 @@ pub fn handle_admin_navigation(
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
admin_state.current_focus = AdminFocus::Tables;
|
||||
*command_message = "Focus: Tables Pane".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
@@ -283,25 +326,36 @@ pub fn handle_admin_navigation(
|
||||
AdminFocus::Button2 => { // Add Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
||||
// Extract needed data first
|
||||
let selected_profile_idx = if let Page::Admin(admin_state) = &router.current {
|
||||
admin_state.selected_profile_index
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(p_idx) = selected_profile_idx {
|
||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||
let selected_profile_name = profile.name.clone();
|
||||
// Prepare links from the selected profile's existing tables
|
||||
let available_links: Vec<LinkDefinition> = profile.tables.iter()
|
||||
.map(|table| LinkDefinition {
|
||||
linked_table_name: table.name.clone(),
|
||||
is_required: false, // Default, can be changed in AddTable screen
|
||||
is_required: false,
|
||||
selected: false,
|
||||
}).collect();
|
||||
|
||||
admin_state.add_table_state = AddTableState {
|
||||
profile_name: selected_profile_name,
|
||||
links: available_links,
|
||||
..AddTableState::default() // Reset other fields
|
||||
};
|
||||
// Build decoupled AddTable page and route into it
|
||||
let mut page = AddTableFormState::new(selected_profile_name.clone());
|
||||
page.state.links = available_links;
|
||||
|
||||
// Now safe to reassign router.current
|
||||
router.current = Page::AddTable(page);
|
||||
buffer_state.update_history(AppView::AddTable);
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
|
||||
|
||||
*command_message = format!(
|
||||
"Opening Add Table for profile '{}'...",
|
||||
selected_profile_name
|
||||
);
|
||||
handled = true;
|
||||
} else {
|
||||
*command_message = "Error: Selected profile index out of bounds.".to_string();
|
||||
@@ -313,11 +367,17 @@ pub fn handle_admin_navigation(
|
||||
}
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
admin_state.current_focus = AdminFocus::Button1;
|
||||
*command_message = "Focus: Add Logic Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
admin_state.current_focus = AdminFocus::Button3;
|
||||
*command_message = "Focus: Change Table Button".to_string();
|
||||
handled = true;
|
||||
@@ -329,17 +389,18 @@ pub fn handle_admin_navigation(
|
||||
AdminFocus::Button3 => { // Change Table Button
|
||||
match action.as_deref() {
|
||||
Some("select") => {
|
||||
// Future: Logic to load selected table into AddTableState for editing
|
||||
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("previous_option") | Some("move_up") => {
|
||||
let Page::Admin(admin_state) = &mut router.current else {
|
||||
return false;
|
||||
};
|
||||
admin_state.current_focus = AdminFocus::Button2;
|
||||
*command_message = "Focus: Add Table Button".to_string();
|
||||
handled = true;
|
||||
}
|
||||
Some("next_option") | Some("move_down") => {
|
||||
// No wrap-around: Stay on Button3 if trying to go "after" it
|
||||
*command_message = "At last focusable button.".to_string();
|
||||
handled = true;
|
||||
}
|
||||
7
client/src/pages/admin/main/mod.rs
Normal file
7
client/src/pages/admin/main/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/pages/admin/main/mod.rs
|
||||
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
|
||||
pub use state::NonAdminState;
|
||||
55
client/src/pages/admin/main/state.rs
Normal file
55
client/src/pages/admin/main/state.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/pages/admin/main/state.rs
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
/// State for non-admin users (simple profile browser)
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct NonAdminState {
|
||||
pub profiles: Vec<String>, // profile names
|
||||
pub profile_list_state: ListState, // highlight state
|
||||
pub selected_profile_index: Option<usize>, // persistent selection
|
||||
}
|
||||
|
||||
impl NonAdminState {
|
||||
pub fn get_selected_index(&self) -> Option<usize> {
|
||||
self.profile_list_state.selected()
|
||||
}
|
||||
|
||||
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
|
||||
let current_selection_index = self.profile_list_state.selected();
|
||||
self.profiles = new_profiles;
|
||||
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
} else {
|
||||
let new_selection = match current_selection_index {
|
||||
Some(index) => Some(index.min(self.profiles.len() - 1)),
|
||||
None => Some(0),
|
||||
};
|
||||
self.profile_list_state.select(new_selection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
|
||||
None => self.profiles.len() - 1,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// src/components/admin/admin_panel.rs
|
||||
// src/pages/admin/main/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::pages::admin::AdminState;
|
||||
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
@@ -12,7 +12,8 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use super::admin_panel_admin::render_admin_panel_admin;
|
||||
use crate::state::pages::auth::UserRole;
|
||||
use crate::pages::admin::admin::ui::render_admin_panel_admin;
|
||||
|
||||
pub fn render_admin_panel(
|
||||
f: &mut Frame,
|
||||
@@ -44,30 +45,27 @@ pub fn render_admin_panel(
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(chunks[1]);
|
||||
|
||||
if auth_state.role.as_deref() != Some("admin") {
|
||||
render_admin_panel_non_admin(
|
||||
f,
|
||||
admin_state,
|
||||
&content_chunks,
|
||||
theme,
|
||||
profile_tree,
|
||||
selected_profile,
|
||||
);
|
||||
} else {
|
||||
render_admin_panel_admin(
|
||||
f,
|
||||
chunks[1],
|
||||
app_state,
|
||||
admin_state,
|
||||
theme,
|
||||
);
|
||||
match auth_state.role {
|
||||
Some(UserRole::Admin) => {
|
||||
render_admin_panel_admin(f, chunks[1], app_state, admin_state, theme);
|
||||
}
|
||||
_ => {
|
||||
render_admin_panel_non_admin(
|
||||
f,
|
||||
admin_state,
|
||||
&content_chunks,
|
||||
theme,
|
||||
profile_tree,
|
||||
selected_profile,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the view for non-admin users (profile list and details).
|
||||
fn render_admin_panel_non_admin(
|
||||
f: &mut Frame,
|
||||
admin_state: &AdminState,
|
||||
admin_state: &mut AdminState,
|
||||
content_chunks: &[Rect],
|
||||
theme: &Theme,
|
||||
profile_tree: &ProfileTreeResponse,
|
||||
@@ -92,8 +90,7 @@ fn render_admin_panel_non_admin(
|
||||
.block(Block::default().title("Profiles"))
|
||||
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
|
||||
|
||||
let mut profile_list_state_clone = admin_state.profile_list_state.clone();
|
||||
f.render_stateful_widget(list, content_chunks[0], &mut profile_list_state_clone);
|
||||
f.render_stateful_widget(list, content_chunks[0], &mut admin_state.profile_list_state);
|
||||
|
||||
// Profile details - Use selection info from admin_state
|
||||
if let Some(profile) = admin_state
|
||||
7
client/src/pages/admin/mod.rs
Normal file
7
client/src/pages/admin/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/pages/admin/mod.rs
|
||||
|
||||
pub mod main; // non-admin
|
||||
pub mod admin; // full admin panel
|
||||
|
||||
pub use main::NonAdminState;
|
||||
pub use admin::{AdminState, AdminFocus};
|
||||
159
client/src/pages/admin_panel/add_logic/event.rs
Normal file
159
client/src/pages/admin_panel/add_logic/event.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
// src/pages/admin_panel/add_logic/event.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::movement::{move_focus, MovementAction};
|
||||
use crate::pages::admin_panel::add_logic::nav::SaveLogicResultSender;
|
||||
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicFormState};
|
||||
use crate::components::common::text_editor::TextEditor;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use canvas::{AppMode as CanvasMode, DataProvider};
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Focus traversal order for non-canvas navigation
|
||||
const ADD_LOGIC_FOCUS_ORDER: [AddLogicFocus; 6] = [
|
||||
AddLogicFocus::InputLogicName,
|
||||
AddLogicFocus::InputTargetColumn,
|
||||
AddLogicFocus::InputDescription,
|
||||
AddLogicFocus::ScriptContentPreview,
|
||||
AddLogicFocus::SaveButton,
|
||||
AddLogicFocus::CancelButton,
|
||||
];
|
||||
|
||||
/// Handles all AddLogic page-specific events.
|
||||
/// Return a non-empty Ok(message) only when the page actually consumed the key,
|
||||
/// otherwise return Ok("") to let global handling proceed.
|
||||
pub fn handle_add_logic_event(
|
||||
key_event: KeyEvent,
|
||||
movement: Option<MovementAction>,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
add_logic_page: &mut AddLogicFormState,
|
||||
grpc_client: GrpcClient,
|
||||
save_logic_sender: SaveLogicResultSender,
|
||||
) -> Result<EventOutcome> {
|
||||
// 1) Script editor fullscreen mode
|
||||
if add_logic_page.state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
match key_event.code {
|
||||
crossterm::event::KeyCode::Esc => {
|
||||
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
add_logic_page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok("Exited script editing.".to_string()));
|
||||
}
|
||||
_ => {
|
||||
let changed = {
|
||||
let mut editor_borrow =
|
||||
add_logic_page.state.script_content_editor.borrow_mut();
|
||||
TextEditor::handle_input(
|
||||
&mut editor_borrow,
|
||||
key_event,
|
||||
&add_logic_page.state.editor_keybinding_mode,
|
||||
&mut add_logic_page.state.vim_state,
|
||||
)
|
||||
};
|
||||
if changed {
|
||||
add_logic_page.state.has_unsaved_changes = true;
|
||||
return Ok(EventOutcome::Ok("Script updated".to_string()));
|
||||
}
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Inside canvas: forward to FormEditor
|
||||
let inside_canvas_inputs = matches!(
|
||||
add_logic_page.state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
|
||||
if inside_canvas_inputs {
|
||||
// Only allow leaving the canvas with Down/Next when the form editor
|
||||
// is in ReadOnly mode. In Edit mode, keep focus inside the canvas.
|
||||
let in_edit_mode = add_logic_page.editor.mode() == CanvasMode::Edit;
|
||||
if !in_edit_mode {
|
||||
if let Some(ma) = movement {
|
||||
let last_idx = add_logic_page
|
||||
.editor
|
||||
.data_provider()
|
||||
.field_count()
|
||||
.saturating_sub(1);
|
||||
let at_last = add_logic_page.editor.current_field() >= last_idx;
|
||||
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
|
||||
add_logic_page.state.last_canvas_field = last_idx;
|
||||
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||
add_logic_page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok("Moved to Script Preview".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match add_logic_page.handle_key_event(key_event) {
|
||||
canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
add_logic_page.sync_from_editor();
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Consumed(None) => {
|
||||
add_logic_page.sync_from_editor();
|
||||
return Ok(EventOutcome::Ok("Input updated".into()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::NotMatched => {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Outside canvas
|
||||
if let Some(ma) = movement {
|
||||
let mut current = add_logic_page.state.current_focus;
|
||||
if move_focus(&ADD_LOGIC_FOCUS_ORDER, &mut current, ma) {
|
||||
add_logic_page.state.current_focus = current;
|
||||
add_logic_page.focus_outside_canvas = !matches!(
|
||||
add_logic_page.state.current_focus,
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
match ma {
|
||||
MovementAction::Select => match add_logic_page.state.current_focus {
|
||||
AddLogicFocus::ScriptContentPreview => {
|
||||
add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent;
|
||||
add_logic_page.focus_outside_canvas = false;
|
||||
return Ok(EventOutcome::Ok(
|
||||
"Fullscreen script editing. Esc to exit.".to_string(),
|
||||
));
|
||||
}
|
||||
AddLogicFocus::SaveButton => {
|
||||
if let Some(msg) = add_logic_page.state.save_logic() {
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
} else {
|
||||
return Ok(EventOutcome::Ok("Saved (no changes)".to_string()));
|
||||
}
|
||||
}
|
||||
AddLogicFocus::CancelButton => {
|
||||
return Ok(EventOutcome::Ok("Cancelled Add Logic".to_string()));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
MovementAction::Esc => {
|
||||
if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview {
|
||||
add_logic_page.state.current_focus = AddLogicFocus::InputDescription;
|
||||
add_logic_page.focus_outside_canvas = false;
|
||||
return Ok(EventOutcome::Ok("Back to Description".to_string()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
115
client/src/pages/admin_panel/add_logic/loader.rs
Normal file
115
client/src/pages/admin_panel/add_logic/loader.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
// src/pages/admin_panel/add_logic/loader.rs
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::pages::admin_panel::add_logic::state::AddLogicFormState;
|
||||
use crate::pages::routing::{Page, Router};
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::services::ui_service::UiService;
|
||||
use crate::state::app::state::AppState;
|
||||
|
||||
/// Process pending table structure fetch for AddLogic page.
|
||||
/// Returns true if UI needs a redraw.
|
||||
pub async fn process_pending_table_structure_fetch(
|
||||
app_state: &mut AppState,
|
||||
router: &mut Router,
|
||||
grpc_client: &mut GrpcClient,
|
||||
command_message: &mut String,
|
||||
) -> Result<bool> {
|
||||
let mut needs_redraw = false;
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if let Page::AddLogic(page) = &mut router.current {
|
||||
if page.profile_name() == profile_name
|
||||
&& page.selected_table_name().map(|s| s.as_str()) == Some(table_name.as_str())
|
||||
{
|
||||
info!(
|
||||
"Fetching table structure for {}.{}",
|
||||
profile_name, table_name
|
||||
);
|
||||
|
||||
let fetch_message = UiService::initialize_add_logic_table_data(
|
||||
grpc_client,
|
||||
&mut page.state, // keep state here, UiService expects AddLogicState
|
||||
&app_state.profile_tree,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
"Error initializing add_logic_table_data for {}.{}: {}",
|
||||
profile_name, table_name, e
|
||||
);
|
||||
format!("Error fetching table structure: {}", e)
|
||||
});
|
||||
|
||||
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
|
||||
info!("{}", fetch_message);
|
||||
} else {
|
||||
*command_message = fetch_message;
|
||||
}
|
||||
|
||||
// 🔑 Rebuild FormEditor with updated state (so suggestions work)
|
||||
page.editor = canvas::FormEditor::new(page.state.clone());
|
||||
|
||||
needs_redraw = true;
|
||||
} else {
|
||||
error!(
|
||||
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, \
|
||||
but AddLogic state is for {}.{:?}",
|
||||
profile_name,
|
||||
table_name,
|
||||
page.profile_name(),
|
||||
page.selected_table_name()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Pending table structure fetch for {}.{} but AddLogic view is not active. Ignored.",
|
||||
profile_name, table_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(needs_redraw)
|
||||
}
|
||||
|
||||
/// If the AddLogic page is awaiting columns for a selected table in the script editor,
|
||||
/// fetch them and update the state. Returns true if UI needs a redraw.
|
||||
pub async fn maybe_fetch_columns_for_awaiting_table(
|
||||
grpc_client: &mut GrpcClient,
|
||||
page: &mut AddLogicFormState,
|
||||
command_message: &mut String,
|
||||
) -> Result<bool> {
|
||||
if let Some(table_name) = page
|
||||
.state
|
||||
.script_editor_awaiting_column_autocomplete
|
||||
.clone()
|
||||
{
|
||||
let profile_name = page.state.profile_name.clone();
|
||||
|
||||
info!(
|
||||
"Fetching columns for table selection: {}.{}",
|
||||
profile_name, table_name
|
||||
);
|
||||
match UiService::fetch_columns_for_table(grpc_client, &profile_name, &table_name).await {
|
||||
Ok(columns) => {
|
||||
page.state.set_columns_for_table_autocomplete(columns.clone());
|
||||
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
|
||||
*command_message =
|
||||
format!("Columns for '{}' loaded. Select a column.", table_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to fetch columns for {}.{}: {}",
|
||||
profile_name, table_name, e
|
||||
);
|
||||
page.state.script_editor_awaiting_column_autocomplete = None;
|
||||
page.state.deactivate_script_editor_autocomplete();
|
||||
*command_message = format!("Error loading columns for '{}': {}", table_name, e);
|
||||
}
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
7
client/src/pages/admin_panel/add_logic/mod.rs
Normal file
7
client/src/pages/admin_panel/add_logic/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/pages/admin_panel/add_logic/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod nav;
|
||||
pub mod state;
|
||||
pub mod loader;
|
||||
pub mod event;
|
||||
6
client/src/pages/admin_panel/add_logic/nav.rs
Normal file
6
client/src/pages/admin_panel/add_logic/nav.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/pages/admin_panel/add_logic/nav.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
||||
@@ -1,7 +1,8 @@
|
||||
// src/state/pages/add_logic.rs
|
||||
// src/pages/admin_panel/add_logic/state.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||
use canvas::{DataProvider, AppMode};
|
||||
use canvas::{DataProvider, AppMode, FormEditor, SuggestionItem};
|
||||
use crossterm::event::KeyCode;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use tui_textarea::TextArea;
|
||||
@@ -54,7 +55,7 @@ pub struct AddLogicState {
|
||||
// New fields for same-profile table names and column autocomplete
|
||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
||||
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||
pub app_mode: AppMode,
|
||||
pub app_mode: canvas::AppMode,
|
||||
}
|
||||
|
||||
impl AddLogicState {
|
||||
@@ -92,12 +93,25 @@ impl AddLogicState {
|
||||
|
||||
same_profile_table_names: Vec::new(),
|
||||
script_editor_awaiting_column_autocomplete: None,
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
||||
|
||||
/// Build canvas SuggestionItem list for target column
|
||||
pub fn column_suggestions_sync(&self, query: &str) -> Vec<SuggestionItem> {
|
||||
let q = query.to_lowercase();
|
||||
self.table_columns_for_suggestions
|
||||
.iter()
|
||||
.filter(|c| q.is_empty() || c.to_lowercase().contains(&q))
|
||||
.map(|c| SuggestionItem {
|
||||
display_text: c.clone(),
|
||||
value_to_store: c.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Updates the target_column_suggestions based on current input.
|
||||
pub fn update_target_column_suggestions(&mut self) {
|
||||
let current_input = self.target_column_input.to_lowercase();
|
||||
@@ -272,7 +286,7 @@ impl AddLogicState {
|
||||
impl Default for AddLogicState {
|
||||
fn default() -> Self {
|
||||
let mut state = Self::new(&EditorConfig::default());
|
||||
state.app_mode = AppMode::Edit;
|
||||
state.app_mode = canvas::AppMode::Edit;
|
||||
state
|
||||
}
|
||||
}
|
||||
@@ -315,3 +329,242 @@ impl DataProvider for AddLogicState {
|
||||
field_index == 1
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper that owns both the raw state and its FormEditor (like LoginFormState)
|
||||
pub struct AddLogicFormState {
|
||||
pub state: AddLogicState,
|
||||
pub editor: FormEditor<AddLogicState>,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
// manual Debug because FormEditor may not implement Debug
|
||||
impl std::fmt::Debug for AddLogicFormState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AddLogicFormState")
|
||||
.field("state", &self.state)
|
||||
.field("focus_outside_canvas", &self.focus_outside_canvas)
|
||||
.field("focused_button_index", &self.focused_button_index)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AddLogicFormState {
|
||||
pub fn new(editor_config: &EditorConfig) -> Self {
|
||||
let state = AddLogicState::new(editor_config);
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_table(
|
||||
editor_config: &EditorConfig,
|
||||
profile_name: String,
|
||||
table_id: Option<i64>,
|
||||
table_name: String,
|
||||
) -> Self {
|
||||
let mut state = AddLogicState::new(editor_config);
|
||||
state.profile_name = profile_name;
|
||||
state.selected_table_id = table_id;
|
||||
state.selected_table_name = Some(table_name);
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_state(state: AddLogicState) -> Self {
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync state from editor's data provider snapshot
|
||||
pub fn sync_from_editor(&mut self) {
|
||||
self.state = self.editor.data_provider().clone();
|
||||
}
|
||||
|
||||
// === Delegates to AddLogicState fields ===
|
||||
|
||||
pub fn current_focus(&self) -> AddLogicFocus {
|
||||
self.state.current_focus
|
||||
}
|
||||
|
||||
pub fn set_current_focus(&mut self, focus: AddLogicFocus) {
|
||||
self.state.current_focus = focus;
|
||||
}
|
||||
|
||||
pub fn has_unsaved_changes(&self) -> bool {
|
||||
self.state.has_unsaved_changes
|
||||
}
|
||||
|
||||
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.state.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
pub fn profile_name(&self) -> &str {
|
||||
&self.state.profile_name
|
||||
}
|
||||
|
||||
pub fn selected_table_name(&self) -> Option<&String> {
|
||||
self.state.selected_table_name.as_ref()
|
||||
}
|
||||
|
||||
pub fn selected_table_id(&self) -> Option<i64> {
|
||||
self.state.selected_table_id
|
||||
}
|
||||
|
||||
pub fn script_content_editor(&self) -> &Rc<RefCell<TextArea<'static>>> {
|
||||
&self.state.script_content_editor
|
||||
}
|
||||
|
||||
pub fn script_content_editor_mut(&mut self) -> &mut Rc<RefCell<TextArea<'static>>> {
|
||||
&mut self.state.script_content_editor
|
||||
}
|
||||
|
||||
pub fn vim_state(&self) -> &VimState {
|
||||
&self.state.vim_state
|
||||
}
|
||||
|
||||
pub fn vim_state_mut(&mut self) -> &mut VimState {
|
||||
&mut self.state.vim_state
|
||||
}
|
||||
|
||||
pub fn editor_keybinding_mode(&self) -> &EditorKeybindingMode {
|
||||
&self.state.editor_keybinding_mode
|
||||
}
|
||||
|
||||
pub fn script_editor_autocomplete_active(&self) -> bool {
|
||||
self.state.script_editor_autocomplete_active
|
||||
}
|
||||
|
||||
pub fn script_editor_suggestions(&self) -> &Vec<String> {
|
||||
&self.state.script_editor_suggestions
|
||||
}
|
||||
|
||||
pub fn script_editor_selected_suggestion_index(&self) -> Option<usize> {
|
||||
self.state.script_editor_selected_suggestion_index
|
||||
}
|
||||
|
||||
pub fn target_column_suggestions(&self) -> &Vec<String> {
|
||||
&self.state.target_column_suggestions
|
||||
}
|
||||
|
||||
pub fn selected_target_column_suggestion_index(&self) -> Option<usize> {
|
||||
self.state.selected_target_column_suggestion_index
|
||||
}
|
||||
|
||||
pub fn in_target_column_suggestion_mode(&self) -> bool {
|
||||
self.state.in_target_column_suggestion_mode
|
||||
}
|
||||
|
||||
pub fn show_target_column_suggestions(&self) -> bool {
|
||||
self.state.show_target_column_suggestions
|
||||
}
|
||||
|
||||
// === Delegates to FormEditor ===
|
||||
|
||||
pub fn mode(&self) -> AppMode {
|
||||
self.editor.mode()
|
||||
}
|
||||
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.editor.cursor_position()
|
||||
}
|
||||
|
||||
pub fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: crossterm::event::KeyEvent,
|
||||
) -> canvas::keymap::KeyEventOutcome {
|
||||
// Customize behavior for Target Column (field index 1) in Edit mode,
|
||||
// mirroring how Register page does suggestions for Role.
|
||||
let in_target_col_field = self.editor.current_field() == 1;
|
||||
let in_edit_mode = self.editor.mode() == canvas::AppMode::Edit;
|
||||
|
||||
if in_target_col_field && in_edit_mode {
|
||||
match key_event.code {
|
||||
// Tab: open suggestions if inactive; otherwise cycle next
|
||||
KeyCode::Tab => {
|
||||
if !self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(1) {
|
||||
let items = self.state.column_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(1, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.editor.suggestions_next();
|
||||
}
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
// Shift+Tab: cycle suggestions too (fallback to next)
|
||||
KeyCode::BackTab => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
self.editor.suggestions_next();
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
// Enter: apply selected suggestion (if active)
|
||||
KeyCode::Enter => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
let _ = self.editor.apply_suggestion();
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
// Esc: close suggestions if active
|
||||
KeyCode::Esc => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
self.editor.close_suggestions();
|
||||
return canvas::keymap::KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
// Character input: mutate then refresh suggestions if active
|
||||
KeyCode::Char(_) => {
|
||||
let outcome = self.editor.handle_key_event(key_event);
|
||||
if self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(1) {
|
||||
let items = self.state.column_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(1, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
// Backspace/Delete: mutate then refresh suggestions if active
|
||||
KeyCode::Backspace | KeyCode::Delete => {
|
||||
let outcome = self.editor.handle_key_event(key_event);
|
||||
if self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(1) {
|
||||
let items = self.state.column_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(1, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
_ => { /* fall through */ }
|
||||
}
|
||||
}
|
||||
// Default: let canvas handle it
|
||||
self.editor.handle_key_event(key_event)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/components/admin/add_logic.rs
|
||||
// src/pages/admin_panel/add_logic/ui.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
|
||||
use canvas::{render_canvas, FormEditor};
|
||||
use crate::pages::admin_panel::add_logic::state::{AddLogicFocus, AddLogicState, AddLogicFormState};
|
||||
use canvas::{render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -19,8 +19,7 @@ pub fn render_add_logic(
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_logic_state: &mut AddLogicState,
|
||||
is_edit_mode: bool,
|
||||
add_logic_state: &mut AddLogicFormState,
|
||||
) {
|
||||
let main_block = Block::default()
|
||||
.title(" Add New Logic Script ")
|
||||
@@ -33,21 +32,29 @@ pub fn render_add_logic(
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// Handle full-screen script editing
|
||||
if add_logic_state.current_focus == AddLogicFocus::InsideScriptContent {
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary };
|
||||
if add_logic_state.current_focus() == AddLogicFocus::InsideScriptContent {
|
||||
let mut editor_ref = add_logic_state
|
||||
.state
|
||||
.script_content_editor
|
||||
.borrow_mut();
|
||||
|
||||
let border_style_color = if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) {
|
||||
theme.highlight
|
||||
} else {
|
||||
theme.secondary
|
||||
};
|
||||
let border_style = Style::default().fg(border_style_color);
|
||||
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
|
||||
let script_title_hint = match add_logic_state.editor_keybinding_mode {
|
||||
let script_title_hint = match add_logic_state.editor_keybinding_mode() {
|
||||
EditorKeybindingMode::Vim => {
|
||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(&add_logic_state.vim_state);
|
||||
let vim_mode_status = crate::components::common::text_editor::TextEditor::get_vim_mode_status(add_logic_state.vim_state());
|
||||
format!("Script {}", vim_mode_status)
|
||||
}
|
||||
EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => {
|
||||
if is_edit_mode {
|
||||
if crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) {
|
||||
"Script (Editing)".to_string()
|
||||
} else {
|
||||
"Script".to_string()
|
||||
@@ -69,10 +76,10 @@ pub fn render_add_logic(
|
||||
drop(editor_ref);
|
||||
|
||||
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
|
||||
if add_logic_state.script_editor_autocomplete_active && !add_logic_state.script_editor_suggestions.is_empty() {
|
||||
if add_logic_state.script_editor_autocomplete_active() && !add_logic_state.script_editor_suggestions().is_empty() {
|
||||
// Get the current cursor position from textarea
|
||||
let current_cursor = {
|
||||
let editor_borrow = add_logic_state.script_content_editor.borrow();
|
||||
let editor_borrow = add_logic_state.script_content_editor().borrow();
|
||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
||||
};
|
||||
|
||||
@@ -100,8 +107,8 @@ pub fn render_add_logic(
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.script_editor_suggestions,
|
||||
add_logic_state.script_editor_selected_suggestion_index,
|
||||
add_logic_state.script_editor_suggestions(),
|
||||
add_logic_state.script_editor_selected_suggestion_index(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,67 +132,61 @@ pub fn render_add_logic(
|
||||
let buttons_area = main_chunks[3];
|
||||
|
||||
// Top info
|
||||
let table_label = if let Some(name) = add_logic_state.selected_table_name() {
|
||||
name.clone()
|
||||
} else if let Some(id) = add_logic_state.selected_table_id() {
|
||||
format!("ID {}", id)
|
||||
} else {
|
||||
"Global (Not Selected)".to_string()
|
||||
};
|
||||
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_logic_state.profile_name),
|
||||
Style::default().fg(theme.fg),
|
||||
format!("Profile: {}", add_logic_state.profile_name()),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
"Table: {}",
|
||||
add_logic_state
|
||||
.selected_table_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| add_logic_state.selected_table_id
|
||||
.map(|id| format!("ID {}", id))
|
||||
.unwrap_or_else(|| "Global (Not Selected)".to_string()))
|
||||
),
|
||||
Style::default().fg(theme.fg),
|
||||
format!("Table: {}", table_label),
|
||||
Style::default().fg(theme.fg),
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(theme.secondary)),
|
||||
);
|
||||
);
|
||||
f.render_widget(profile_text, top_info_area);
|
||||
|
||||
// Canvas - USING CANVAS LIBRARY
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_logic_state.current_focus,
|
||||
add_logic_state.current_focus(),
|
||||
AddLogicFocus::InputLogicName
|
||||
| AddLogicFocus::InputTargetColumn
|
||||
| AddLogicFocus::InputDescription
|
||||
);
|
||||
|
||||
let editor = FormEditor::new(add_logic_state.clone());
|
||||
let active_field_rect = render_canvas(f, canvas_area, &editor, theme);
|
||||
let editor = &add_logic_state.editor;
|
||||
let active_field_rect = render_canvas(f, canvas_area, editor, theme);
|
||||
|
||||
// --- Render Autocomplete for Target Column ---
|
||||
// `is_edit_mode` here refers to the general edit mode of the EventHandler
|
||||
if is_edit_mode && editor.current_field() == 1 { // Target Column field
|
||||
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
|
||||
if !add_logic_state.target_column_suggestions.is_empty() {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
autocomplete::render_autocomplete_dropdown(
|
||||
f,
|
||||
input_rect,
|
||||
f.area(), // Full frame area for clamping
|
||||
theme,
|
||||
&add_logic_state.target_column_suggestions,
|
||||
add_logic_state.selected_target_column_suggestion_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
// --- Canvas suggestions dropdown (Target Column, etc.) ---
|
||||
if editor.mode() == canvas::AppMode::Edit {
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
f.area(),
|
||||
input_rect,
|
||||
&DefaultCanvasTheme,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Script content preview
|
||||
{
|
||||
let mut editor_ref = add_logic_state.script_content_editor.borrow_mut();
|
||||
let mut editor_ref = add_logic_state.script_content_editor().borrow_mut();
|
||||
editor_ref.set_cursor_line_style(Style::default());
|
||||
|
||||
let is_script_preview_focused = add_logic_state.current_focus == AddLogicFocus::ScriptContentPreview;
|
||||
let is_script_preview_focused = add_logic_state.current_focus() == AddLogicFocus::ScriptContentPreview;
|
||||
|
||||
if is_script_preview_focused {
|
||||
editor_ref.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
@@ -254,7 +255,7 @@ pub fn render_add_logic(
|
||||
let save_button = Paragraph::new(" Save Logic ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::SaveButton,
|
||||
add_logic_state.current_focus,
|
||||
add_logic_state.current_focus(),
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
@@ -262,7 +263,7 @@ pub fn render_add_logic(
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::SaveButton,
|
||||
add_logic_state.current_focus() == AddLogicFocus::SaveButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
@@ -271,7 +272,7 @@ pub fn render_add_logic(
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddLogicFocus::CancelButton,
|
||||
add_logic_state.current_focus,
|
||||
add_logic_state.current_focus(),
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
@@ -279,7 +280,7 @@ pub fn render_add_logic(
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_logic_state.current_focus == AddLogicFocus::CancelButton,
|
||||
add_logic_state.current_focus() == AddLogicFocus::CancelButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
287
client/src/pages/admin_panel/add_table/event.rs
Normal file
287
client/src/pages/admin_panel/add_table/event.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
// src/pages/admin_panel/add_table/event.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::movement::{move_focus, MovementAction};
|
||||
use crate::pages::admin_panel::add_table::logic::{
|
||||
handle_add_column_action, handle_delete_selected_columns,
|
||||
};
|
||||
use crate::pages::admin_panel::add_table::loader::handle_save_table_action;
|
||||
use crate::pages::admin_panel::add_table::nav::SaveTableResultSender;
|
||||
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use canvas::{AppMode as CanvasMode, DataProvider};
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Focus traversal order for AddTable (outside canvas)
|
||||
const ADD_TABLE_FOCUS_ORDER: [AddTableFocus; 10] = [
|
||||
AddTableFocus::InputTableName,
|
||||
AddTableFocus::InputColumnName,
|
||||
AddTableFocus::InputColumnType,
|
||||
AddTableFocus::AddColumnButton,
|
||||
AddTableFocus::ColumnsTable,
|
||||
AddTableFocus::IndexesTable,
|
||||
AddTableFocus::LinksTable,
|
||||
AddTableFocus::SaveButton,
|
||||
AddTableFocus::DeleteSelectedButton,
|
||||
AddTableFocus::CancelButton,
|
||||
];
|
||||
|
||||
/// Handles all AddTable page-specific events.
|
||||
/// Return a non-empty Ok(message) only when the page actually consumed the key,
|
||||
/// otherwise return Ok("") to let global handling proceed.
|
||||
pub fn handle_add_table_event(
|
||||
key_event: KeyEvent,
|
||||
movement: Option<MovementAction>,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
page: &mut AddTableFormState,
|
||||
mut grpc_client: GrpcClient,
|
||||
save_result_sender: SaveTableResultSender,
|
||||
) -> Result<EventOutcome> {
|
||||
// 1) Inside canvas (FormEditor)
|
||||
let inside_canvas_inputs = matches!(
|
||||
page.current_focus(),
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
);
|
||||
|
||||
if inside_canvas_inputs {
|
||||
// Disable global shortcuts while typing
|
||||
page.focus_outside_canvas = false;
|
||||
|
||||
// Only allow leaving the canvas with Down/Next when in ReadOnly mode
|
||||
let in_edit_mode = page.editor.mode() == CanvasMode::Edit;
|
||||
if !in_edit_mode {
|
||||
if let Some(ma) = movement {
|
||||
let last_idx = page.editor.data_provider().field_count().saturating_sub(1);
|
||||
let at_last = page.editor.current_field() >= last_idx;
|
||||
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
|
||||
page.state.last_canvas_field = last_idx;
|
||||
page.set_current_focus(AddTableFocus::AddColumnButton);
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok("Moved to Add button".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Let the FormEditor handle typing
|
||||
match page.editor.handle_key_event(key_event) {
|
||||
canvas::keymap::KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
page.sync_from_editor();
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Consumed(None) => {
|
||||
page.sync_from_editor();
|
||||
return Ok(EventOutcome::Ok("Input updated".into()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
canvas::keymap::KeyEventOutcome::NotMatched => {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Outside canvas
|
||||
if let Some(ma) = movement {
|
||||
// Block outer moves when "inside" any table and handle locally
|
||||
match page.current_focus() {
|
||||
AddTableFocus::InsideColumnsTable => {
|
||||
match ma {
|
||||
MovementAction::Up => {
|
||||
if let Some(i) = page.state.column_table_state.selected() {
|
||||
let next = i.saturating_sub(1);
|
||||
page.state.column_table_state.select(Some(next));
|
||||
} else if !page.state.columns.is_empty() {
|
||||
page.state.column_table_state.select(Some(0));
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Down => {
|
||||
if let Some(i) = page.state.column_table_state.selected() {
|
||||
let last = page.state.columns.len().saturating_sub(1);
|
||||
let next = if i < last { i + 1 } else { i };
|
||||
page.state.column_table_state.select(Some(next));
|
||||
} else if !page.state.columns.is_empty() {
|
||||
page.state.column_table_state.select(Some(0));
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Select => {
|
||||
if let Some(i) = page.state.column_table_state.selected() {
|
||||
if let Some(col) = page.state.columns.get_mut(i) {
|
||||
col.selected = !col.selected;
|
||||
page.state.has_unsaved_changes = true;
|
||||
}
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
page.state.column_table_state.select(None);
|
||||
page.set_current_focus(AddTableFocus::ColumnsTable);
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Next | MovementAction::Previous => {
|
||||
// Block outer movement while inside
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
AddTableFocus::InsideIndexesTable => {
|
||||
match ma {
|
||||
MovementAction::Up => {
|
||||
if let Some(i) = page.state.index_table_state.selected() {
|
||||
let next = i.saturating_sub(1);
|
||||
page.state.index_table_state.select(Some(next));
|
||||
} else if !page.state.indexes.is_empty() {
|
||||
page.state.index_table_state.select(Some(0));
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Down => {
|
||||
if let Some(i) = page.state.index_table_state.selected() {
|
||||
let last = page.state.indexes.len().saturating_sub(1);
|
||||
let next = if i < last { i + 1 } else { i };
|
||||
page.state.index_table_state.select(Some(next));
|
||||
} else if !page.state.indexes.is_empty() {
|
||||
page.state.index_table_state.select(Some(0));
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Select => {
|
||||
if let Some(i) = page.state.index_table_state.selected() {
|
||||
if let Some(ix) = page.state.indexes.get_mut(i) {
|
||||
ix.selected = !ix.selected;
|
||||
page.state.has_unsaved_changes = true;
|
||||
}
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
page.state.index_table_state.select(None);
|
||||
page.set_current_focus(AddTableFocus::IndexesTable);
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Next | MovementAction::Previous => {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
AddTableFocus::InsideLinksTable => {
|
||||
match ma {
|
||||
MovementAction::Up => {
|
||||
if let Some(i) = page.state.link_table_state.selected() {
|
||||
let next = i.saturating_sub(1);
|
||||
page.state.link_table_state.select(Some(next));
|
||||
} else if !page.state.links.is_empty() {
|
||||
page.state.link_table_state.select(Some(0));
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Down => {
|
||||
if let Some(i) = page.state.link_table_state.selected() {
|
||||
let last = page.state.links.len().saturating_sub(1);
|
||||
let next = if i < last { i + 1 } else { i };
|
||||
page.state.link_table_state.select(Some(next));
|
||||
} else if !page.state.links.is_empty() {
|
||||
page.state.link_table_state.select(Some(0));
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Select => {
|
||||
if let Some(i) = page.state.link_table_state.selected() {
|
||||
if let Some(link) = page.state.links.get_mut(i) {
|
||||
link.selected = !link.selected;
|
||||
page.state.has_unsaved_changes = true;
|
||||
}
|
||||
}
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
page.state.link_table_state.select(None);
|
||||
page.set_current_focus(AddTableFocus::LinksTable);
|
||||
page.focus_outside_canvas = true;
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
MovementAction::Next | MovementAction::Previous => {
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut current = page.current_focus();
|
||||
if move_focus(&ADD_TABLE_FOCUS_ORDER, &mut current, ma) {
|
||||
page.set_current_focus(current);
|
||||
page.focus_outside_canvas = !matches!(
|
||||
page.current_focus(),
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
// 3) Rich actions
|
||||
match ma {
|
||||
MovementAction::Select => match page.current_focus() {
|
||||
AddTableFocus::AddColumnButton => {
|
||||
if let Some(msg) = page.state.add_column_from_inputs() {
|
||||
// Focus is set by the state method; just bubble message
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
}
|
||||
AddTableFocus::SaveButton => {
|
||||
if page.state.table_name.is_empty() {
|
||||
return Ok(EventOutcome::Ok("Cannot save: Table name is empty".into()));
|
||||
}
|
||||
if page.state.columns.is_empty() {
|
||||
return Ok(EventOutcome::Ok("Cannot save: No columns defined".into()));
|
||||
}
|
||||
app_state.show_loading_dialog("Saving", "Please wait...");
|
||||
let state_clone = page.state.clone();
|
||||
let sender_clone = save_result_sender.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = handle_save_table_action(&mut grpc_client, &state_clone).await;
|
||||
let _ = sender_clone.send(result).await;
|
||||
});
|
||||
return Ok(EventOutcome::Ok("Saving table...".into()));
|
||||
}
|
||||
AddTableFocus::DeleteSelectedButton => {
|
||||
let msg = page
|
||||
.state
|
||||
.delete_selected_items()
|
||||
.unwrap_or_else(|| "No items selected for deletion".to_string());
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
AddTableFocus::CancelButton => {
|
||||
return Ok(EventOutcome::Ok("Cancelled Add Table".to_string()));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
78
client/src/pages/admin_panel/add_table/loader.rs
Normal file
78
client/src/pages/admin_panel/add_table/loader.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
// src/pages/admin_panel/add_table/loader.rs
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::pages::admin_panel::add_table::state::AddTableState;
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use common::proto::komp_ac::table_definition::{
|
||||
ColumnDefinition as ProtoColumnDefinition, PostTableDefinitionRequest, TableLink as ProtoTableLink,
|
||||
};
|
||||
|
||||
/// Prepares and sends the request to save the new table definition via gRPC.
|
||||
pub async fn handle_save_table_action(
|
||||
grpc_client: &mut GrpcClient,
|
||||
add_table_state: &AddTableState,
|
||||
) -> Result<String> {
|
||||
if add_table_state.table_name.is_empty() {
|
||||
return Err(anyhow!("Table name cannot be empty."));
|
||||
}
|
||||
if add_table_state.columns.is_empty() {
|
||||
return Err(anyhow!("Table must have at least one column."));
|
||||
}
|
||||
|
||||
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col| ProtoColumnDefinition {
|
||||
name: col.name.clone(),
|
||||
field_type: col.data_type.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let proto_indexes: Vec<String> = add_table_state
|
||||
.indexes
|
||||
.iter()
|
||||
.filter(|idx| idx.selected)
|
||||
.map(|idx| idx.name.clone())
|
||||
.collect();
|
||||
|
||||
let proto_links: Vec<ProtoTableLink> = add_table_state
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| link.selected)
|
||||
.map(|link| ProtoTableLink {
|
||||
linked_table_name: link.linked_table_name.clone(),
|
||||
required: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let request = PostTableDefinitionRequest {
|
||||
table_name: add_table_state.table_name.clone(),
|
||||
columns: proto_columns,
|
||||
indexes: proto_indexes,
|
||||
links: proto_links,
|
||||
profile_name: add_table_state.profile_name.clone(),
|
||||
};
|
||||
|
||||
debug!("Sending PostTableDefinitionRequest: {:?}", request);
|
||||
|
||||
match grpc_client.post_table_definition(request).await {
|
||||
Ok(response) => {
|
||||
if response.success {
|
||||
Ok(format!(
|
||||
"Table '{}' saved successfully.",
|
||||
add_table_state.table_name
|
||||
))
|
||||
} else {
|
||||
let error_message = if !response.sql.is_empty() {
|
||||
format!("Server failed to save table: {}", response.sql)
|
||||
} else {
|
||||
"Server failed to save table (unknown reason).".to_string()
|
||||
};
|
||||
Err(anyhow!(error_message))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
|
||||
}
|
||||
}
|
||||
24
client/src/pages/admin_panel/add_table/logic.rs
Normal file
24
client/src/pages/admin_panel/add_table/logic.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/pages/admin_panel/add_table/logic.rs
|
||||
|
||||
use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus};
|
||||
|
||||
/// Thin wrapper around AddTableState::add_column_from_inputs
|
||||
/// Returns Some(AddTableFocus) for compatibility with old call sites.
|
||||
pub fn handle_add_column_action(
|
||||
add_table_state: &mut AddTableState,
|
||||
command_message: &mut String,
|
||||
) -> Option<AddTableFocus> {
|
||||
if let Some(msg) = add_table_state.add_column_from_inputs() {
|
||||
*command_message = msg;
|
||||
// State sets focus internally; return it explicitly for old call sites
|
||||
return Some(add_table_state.current_focus);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Thin wrapper around AddTableState::delete_selected_items
|
||||
pub fn handle_delete_selected_columns(add_table_state: &mut AddTableState) -> String {
|
||||
add_table_state
|
||||
.delete_selected_items()
|
||||
.unwrap_or_else(|| "No items selected for deletion".to_string())
|
||||
}
|
||||
8
client/src/pages/admin_panel/add_table/mod.rs
Normal file
8
client/src/pages/admin_panel/add_table/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/pages/admin_panel/add_table/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod nav;
|
||||
pub mod state;
|
||||
pub mod logic;
|
||||
pub mod event;
|
||||
pub mod loader;
|
||||
6
client/src/pages/admin_panel/add_table/nav.rs
Normal file
6
client/src/pages/admin_panel/add_table/nav.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/pages/admin_panel/add_table/nav.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/state/pages/add_table.rs
|
||||
// src/pages/admin_panel/add_table/state.rs
|
||||
|
||||
use canvas::{DataProvider, AppMode};
|
||||
use canvas::FormEditor;
|
||||
use ratatui::widgets::TableState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -64,7 +65,7 @@ pub struct AddTableState {
|
||||
pub column_name_cursor_pos: usize,
|
||||
pub column_type_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub app_mode: AppMode,
|
||||
pub app_mode: canvas::AppMode,
|
||||
}
|
||||
|
||||
impl Default for AddTableState {
|
||||
@@ -87,7 +88,7 @@ impl Default for AddTableState {
|
||||
column_name_cursor_pos: 0,
|
||||
column_type_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,23 +98,48 @@ impl AddTableState {
|
||||
|
||||
/// Helper method to add a column from current inputs
|
||||
pub fn add_column_from_inputs(&mut self) -> Option<String> {
|
||||
if self.column_name_input.trim().is_empty() || self.column_type_input.trim().is_empty() {
|
||||
return Some("Both column name and type are required".to_string());
|
||||
let table_name_in = self.table_name_input.trim().to_string();
|
||||
let column_name_in = self.column_name_input.trim().to_string();
|
||||
let column_type_in = self.column_type_input.trim().to_string();
|
||||
|
||||
// Case: "only table name" provided → set it and stay on TableName
|
||||
if !table_name_in.is_empty() && column_name_in.is_empty() && column_type_in.is_empty() {
|
||||
self.table_name = table_name_in;
|
||||
self.table_name_input.clear();
|
||||
self.table_name_cursor_pos = 0;
|
||||
self.current_focus = AddTableFocus::InputTableName;
|
||||
self.has_unsaved_changes = true;
|
||||
return Some(format!("Table name set to '{}'.", self.table_name));
|
||||
}
|
||||
|
||||
// Check for duplicate column names
|
||||
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) {
|
||||
// Column validation
|
||||
if column_name_in.is_empty() || column_type_in.is_empty() {
|
||||
return Some("Both column name and type are required".to_string());
|
||||
}
|
||||
if self.columns.iter().any(|col| col.name == column_name_in) {
|
||||
return Some("Column name already exists".to_string());
|
||||
}
|
||||
|
||||
// If table_name input present while adding first column, apply it too
|
||||
if !table_name_in.is_empty() {
|
||||
self.table_name = table_name_in;
|
||||
self.table_name_input.clear();
|
||||
self.table_name_cursor_pos = 0;
|
||||
}
|
||||
|
||||
// Add the column
|
||||
self.columns.push(ColumnDefinition {
|
||||
name: self.column_name_input.trim().to_string(),
|
||||
data_type: self.column_type_input.trim().to_string(),
|
||||
name: column_name_in.clone(),
|
||||
data_type: column_type_in.clone(),
|
||||
selected: false,
|
||||
});
|
||||
// Add a corresponding (unselected) index with the same name
|
||||
self.indexes.push(IndexDefinition {
|
||||
name: column_name_in.clone(),
|
||||
selected: false,
|
||||
});
|
||||
|
||||
// Clear inputs and reset focus to column name for next entry
|
||||
// Clear column inputs and set focus for next entry
|
||||
self.column_name_input.clear();
|
||||
self.column_type_input.clear();
|
||||
self.column_name_cursor_pos = 0;
|
||||
@@ -122,23 +148,33 @@ impl AddTableState {
|
||||
self.last_canvas_field = 1;
|
||||
self.has_unsaved_changes = true;
|
||||
|
||||
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name))
|
||||
Some(format!("Column '{}' added successfully", column_name_in))
|
||||
}
|
||||
|
||||
/// Helper method to delete selected items
|
||||
pub fn delete_selected_items(&mut self) -> Option<String> {
|
||||
let mut deleted_items = Vec::new();
|
||||
let mut deleted_items: Vec<String> = Vec::new();
|
||||
|
||||
// Remove selected columns
|
||||
let initial_column_count = self.columns.len();
|
||||
self.columns.retain(|col| {
|
||||
if col.selected {
|
||||
deleted_items.push(format!("column '{}'", col.name));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let selected_col_names: std::collections::HashSet<String> = self
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| c.selected)
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
if !selected_col_names.is_empty() {
|
||||
self.columns.retain(|col| {
|
||||
if selected_col_names.contains(&col.name) {
|
||||
deleted_items.push(format!("column '{}'", col.name));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Also purge indexes for deleted columns
|
||||
self.indexes
|
||||
.retain(|idx| !selected_col_names.contains(&idx.name));
|
||||
}
|
||||
|
||||
// Remove selected indexes
|
||||
let initial_index_count = self.indexes.len();
|
||||
@@ -166,6 +202,8 @@ impl AddTableState {
|
||||
Some("No items selected for deletion".to_string())
|
||||
} else {
|
||||
self.has_unsaved_changes = true;
|
||||
self.column_table_state.select(None);
|
||||
self.index_table_state.select(None);
|
||||
Some(format!("Deleted: {}", deleted_items.join(", ")))
|
||||
}
|
||||
}
|
||||
@@ -208,3 +246,87 @@ impl DataProvider for AddTableState {
|
||||
false // AddTableState doesn’t use suggestions
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AddTableFormState {
|
||||
pub state: AddTableState,
|
||||
pub editor: FormEditor<AddTableState>,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AddTableFormState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AddTableFormState")
|
||||
.field("state", &self.state)
|
||||
.field("focus_outside_canvas", &self.focus_outside_canvas)
|
||||
.field("focused_button_index", &self.focused_button_index)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AddTableFormState {
|
||||
pub fn new(profile_name: String) -> Self {
|
||||
let mut state = AddTableState::default();
|
||||
state.profile_name = profile_name;
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_state(state: AddTableState) -> Self {
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync state from editor’s snapshot
|
||||
pub fn sync_from_editor(&mut self) {
|
||||
self.state = self.editor.data_provider().clone();
|
||||
}
|
||||
|
||||
// === Delegates to AddTableState fields ===
|
||||
pub fn current_focus(&self) -> AddTableFocus {
|
||||
self.state.current_focus
|
||||
}
|
||||
pub fn set_current_focus(&mut self, focus: AddTableFocus) {
|
||||
self.state.current_focus = focus;
|
||||
}
|
||||
pub fn profile_name(&self) -> &str {
|
||||
&self.state.profile_name
|
||||
}
|
||||
pub fn table_name(&self) -> &str {
|
||||
&self.state.table_name
|
||||
}
|
||||
pub fn columns(&self) -> &Vec<ColumnDefinition> {
|
||||
&self.state.columns
|
||||
}
|
||||
pub fn indexes(&self) -> &Vec<IndexDefinition> {
|
||||
&self.state.indexes
|
||||
}
|
||||
pub fn links(&self) -> &Vec<LinkDefinition> {
|
||||
&self.state.links
|
||||
}
|
||||
pub fn column_table_state(&mut self) -> &mut TableState {
|
||||
&mut self.state.column_table_state
|
||||
}
|
||||
pub fn index_table_state(&mut self) -> &mut TableState {
|
||||
&mut self.state.index_table_state
|
||||
}
|
||||
pub fn link_table_state(&mut self) -> &mut TableState {
|
||||
&mut self.state.link_table_state
|
||||
}
|
||||
pub fn set_focused_button(&mut self, index: usize) {
|
||||
self.focused_button_index = index;
|
||||
}
|
||||
pub fn focused_button(&self) -> usize {
|
||||
self.focused_button_index
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/components/admin/add_table.rs
|
||||
// src/pages/admin_panel/add_table/ui.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||
use canvas::{render_canvas, FormEditor};
|
||||
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
|
||||
use canvas::render_canvas;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
@@ -19,8 +19,7 @@ pub fn render_add_table(
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
app_state: &AppState,
|
||||
add_table_state: &mut AddTableState,
|
||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||
add_table_state: &mut AddTableFormState,
|
||||
) {
|
||||
// --- Configuration ---
|
||||
// Threshold width to switch between wide and narrow layouts
|
||||
@@ -28,7 +27,7 @@ pub fn render_add_table(
|
||||
|
||||
// --- State Checks ---
|
||||
let focus_on_canvas_inputs = matches!(
|
||||
add_table_state.current_focus,
|
||||
add_table_state.current_focus(),
|
||||
AddTableFocus::InputTableName
|
||||
| AddTableFocus::InputColumnName
|
||||
| AddTableFocus::InputColumnType
|
||||
@@ -46,11 +45,11 @@ pub fn render_add_table(
|
||||
f.render_widget(main_block, area);
|
||||
|
||||
// --- Fullscreen Columns Table Check (Narrow Screens Only) ---
|
||||
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus == AddTableFocus::InsideColumnsTable {
|
||||
if area.width < NARROW_LAYOUT_THRESHOLD && add_table_state.current_focus() == AddTableFocus::InsideColumnsTable {
|
||||
// Render ONLY the columns table taking the full inner area
|
||||
let columns_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
@@ -81,16 +80,16 @@ pub fn render_add_table(
|
||||
.fg(theme.highlight),
|
||||
)
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(columns_table, inner_area, &mut add_table_state.column_table_state);
|
||||
f.render_stateful_widget(columns_table, inner_area, add_table_state.column_table_state());
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Fullscreen Indexes Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideIndexesTable { // Remove width check
|
||||
if add_table_state.current_focus() == AddTableFocus::InsideIndexesTable { // Remove width check
|
||||
// Render ONLY the indexes table taking the full inner area
|
||||
let indexes_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.indexes()
|
||||
.iter()
|
||||
.map(|index_def| {
|
||||
Row::new(vec![
|
||||
@@ -116,16 +115,16 @@ pub fn render_add_table(
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state);
|
||||
f.render_stateful_widget(indexes_table, inner_area, &mut add_table_state.index_table_state());
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
// --- Fullscreen Links Table Check ---
|
||||
if add_table_state.current_focus == AddTableFocus::InsideLinksTable {
|
||||
if add_table_state.current_focus() == AddTableFocus::InsideLinksTable {
|
||||
// Render ONLY the links table taking the full inner area
|
||||
let links_border_style = Style::default().fg(theme.highlight); // Always highlighted when fullscreen
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.links()
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
@@ -152,7 +151,7 @@ pub fn render_add_table(
|
||||
)
|
||||
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED).fg(theme.highlight))
|
||||
.highlight_symbol(" > "); // Use the inside symbol
|
||||
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state);
|
||||
f.render_stateful_widget(links_table, inner_area, &mut add_table_state.link_table_state());
|
||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
||||
}
|
||||
|
||||
@@ -221,11 +220,11 @@ pub fn render_add_table(
|
||||
// --- Top Info Rendering (Wide - 2 lines) ---
|
||||
let profile_text = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
format!("Profile: {}", add_table_state.profile_name()),
|
||||
theme.fg,
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Table name: {}", add_table_state.table_name),
|
||||
format!("Table name: {}", add_table_state.table_name()),
|
||||
theme.fg,
|
||||
)),
|
||||
])
|
||||
@@ -277,14 +276,14 @@ pub fn render_add_table(
|
||||
.split(top_info_area);
|
||||
|
||||
let profile_text = Paragraph::new(Span::styled(
|
||||
format!("Profile: {}", add_table_state.profile_name),
|
||||
format!("Profile: {}", add_table_state.profile_name()),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
f.render_widget(profile_text, top_info_chunks[0]);
|
||||
|
||||
let table_name_text = Paragraph::new(Span::styled(
|
||||
format!("Table: {}", add_table_state.table_name),
|
||||
format!("Table: {}", add_table_state.table_name()),
|
||||
theme.fg,
|
||||
))
|
||||
.alignment(Alignment::Left);
|
||||
@@ -294,14 +293,14 @@ pub fn render_add_table(
|
||||
// --- Common Widget Rendering (Uses calculated areas) ---
|
||||
|
||||
// --- Columns Table Rendering ---
|
||||
let columns_focused = matches!(add_table_state.current_focus, AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
|
||||
let columns_focused = matches!(add_table_state.current_focus(), AddTableFocus::ColumnsTable | AddTableFocus::InsideColumnsTable);
|
||||
let columns_border_style = if columns_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let column_rows: Vec<Row<'_>> = add_table_state
|
||||
.columns
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|col_def| {
|
||||
Row::new(vec![
|
||||
@@ -342,12 +341,11 @@ pub fn render_add_table(
|
||||
f.render_stateful_widget(
|
||||
columns_table,
|
||||
columns_area,
|
||||
&mut add_table_state.column_table_state,
|
||||
&mut add_table_state.column_table_state(),
|
||||
);
|
||||
|
||||
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
|
||||
let editor = FormEditor::new(add_table_state.clone());
|
||||
let _active_field_rect = render_canvas(f, canvas_area, &editor, theme);
|
||||
let _active_field_rect = render_canvas(f, canvas_area, &add_table_state.editor, theme);
|
||||
|
||||
// --- Button Style Helpers ---
|
||||
let get_button_style = |button_focus: AddTableFocus, current_focus| {
|
||||
@@ -375,11 +373,11 @@ pub fn render_add_table(
|
||||
|
||||
// --- Add Button Rendering ---
|
||||
// Determine if the add button is focused
|
||||
let is_add_button_focused = add_table_state.current_focus == AddTableFocus::AddColumnButton;
|
||||
let is_add_button_focused = add_table_state.current_focus() == AddTableFocus::AddColumnButton;
|
||||
|
||||
// Create the Add button Paragraph widget
|
||||
let add_button = Paragraph::new(" Add ")
|
||||
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus)) // Use existing closure
|
||||
.style(get_button_style(AddTableFocus::AddColumnButton, add_table_state.current_focus())) // Use existing closure
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
@@ -392,14 +390,14 @@ pub fn render_add_table(
|
||||
f.render_widget(add_button, add_button_area);
|
||||
|
||||
// --- Indexes Table Rendering ---
|
||||
let indexes_focused = matches!(add_table_state.current_focus, AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
|
||||
let indexes_focused = matches!(add_table_state.current_focus(), AddTableFocus::IndexesTable | AddTableFocus::InsideIndexesTable);
|
||||
let indexes_border_style = if indexes_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let index_rows: Vec<Row<'_>> = add_table_state
|
||||
.indexes
|
||||
.indexes()
|
||||
.iter()
|
||||
.map(|index_def| { // Use index_def now
|
||||
Row::new(vec![
|
||||
@@ -433,18 +431,18 @@ pub fn render_add_table(
|
||||
f.render_stateful_widget(
|
||||
indexes_table,
|
||||
indexes_area,
|
||||
&mut add_table_state.index_table_state,
|
||||
&mut add_table_state.index_table_state(),
|
||||
);
|
||||
|
||||
// --- Links Table Rendering ---
|
||||
let links_focused = matches!(add_table_state.current_focus, AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
|
||||
let links_focused = matches!(add_table_state.current_focus(), AddTableFocus::LinksTable | AddTableFocus::InsideLinksTable);
|
||||
let links_border_style = if links_focused {
|
||||
Style::default().fg(theme.highlight)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
};
|
||||
let link_rows: Vec<Row<'_>> = add_table_state
|
||||
.links
|
||||
.links()
|
||||
.iter()
|
||||
.map(|link_def| {
|
||||
Row::new(vec![
|
||||
@@ -478,7 +476,7 @@ pub fn render_add_table(
|
||||
f.render_stateful_widget(
|
||||
links_table,
|
||||
links_area,
|
||||
&mut add_table_state.link_table_state,
|
||||
&mut add_table_state.link_table_state(),
|
||||
);
|
||||
|
||||
// --- Save/Cancel Buttons Rendering ---
|
||||
@@ -492,51 +490,54 @@ pub fn render_add_table(
|
||||
.split(bottom_buttons_area);
|
||||
|
||||
let save_button = Paragraph::new(" Save table ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::SaveButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.style(if add_table_state.current_focus() == AddTableFocus::SaveButton {
|
||||
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
})
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::SaveButton, // Pass bool
|
||||
add_table_state.current_focus() == AddTableFocus::SaveButton, // Pass bool
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(save_button, bottom_button_chunks[0]);
|
||||
|
||||
let delete_button = Paragraph::new(" Delete Selected ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::DeleteSelectedButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.style(if add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton {
|
||||
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
})
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::DeleteSelectedButton, // Pass bool
|
||||
add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
f.render_widget(delete_button, bottom_button_chunks[1]);
|
||||
|
||||
let cancel_button = Paragraph::new(" Cancel ")
|
||||
.style(get_button_style(
|
||||
AddTableFocus::CancelButton,
|
||||
add_table_state.current_focus,
|
||||
))
|
||||
.style(if add_table_state.current_focus() == AddTableFocus::CancelButton {
|
||||
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.secondary)
|
||||
})
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(get_button_border_style(
|
||||
add_table_state.current_focus == AddTableFocus::CancelButton, // Pass bool
|
||||
add_table_state.current_focus() == AddTableFocus::CancelButton,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
4
client/src/pages/admin_panel/mod.rs
Normal file
4
client/src/pages/admin_panel/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// src/pages/admin_panel/mod.rs
|
||||
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
62
client/src/pages/forms/event.rs
Normal file
62
client/src/pages/forms/event.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
// src/pages/forms/event.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::Event;
|
||||
use canvas::keymap::KeyEventOutcome;
|
||||
use crate::{
|
||||
state::app::state::AppState,
|
||||
pages::forms::{FormState, logic},
|
||||
modes::handlers::event::EventOutcome,
|
||||
};
|
||||
|
||||
pub fn handle_form_event(
|
||||
event: Event,
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<EventOutcome> {
|
||||
if let Event::Key(key_event) = event {
|
||||
if let Some(editor) = app_state.editor_for_path(path) {
|
||||
match editor.handle_key_event(key_event) {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
return Ok(EventOutcome::Ok("Form input updated".into()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
// fall through to navigation / save / revert
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
|
||||
// Save wrapper
|
||||
pub async fn save_form(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut crate::services::grpc_client::GrpcClient,
|
||||
) -> Result<EventOutcome> {
|
||||
let outcome = logic::save(app_state, path, grpc_client).await?;
|
||||
let message = match outcome {
|
||||
logic::SaveOutcome::NoChange => "No changes to save.".to_string(),
|
||||
logic::SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
||||
logic::SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
||||
};
|
||||
Ok(EventOutcome::DataSaved(outcome, message))
|
||||
}
|
||||
|
||||
pub async fn revert_form(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut crate::services::grpc_client::GrpcClient,
|
||||
) -> Result<EventOutcome> {
|
||||
let message = logic::revert(app_state, path, grpc_client).await?;
|
||||
Ok(EventOutcome::Ok(message))
|
||||
}
|
||||
39
client/src/pages/forms/loader.rs
Normal file
39
client/src/pages/forms/loader.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
// src/pages/forms/loader.rs
|
||||
use anyhow::{Context, Result};
|
||||
use crate::{
|
||||
state::app::state::AppState,
|
||||
services::grpc_client::GrpcClient,
|
||||
services::ui_service::UiService, // ✅ import UiService
|
||||
config::binds::Config,
|
||||
pages::forms::FormState,
|
||||
};
|
||||
|
||||
pub async fn ensure_form_loaded_and_count(
|
||||
grpc_client: &mut GrpcClient,
|
||||
app_state: &mut AppState,
|
||||
config: &Config,
|
||||
profile: &str,
|
||||
table: &str,
|
||||
) -> Result<()> {
|
||||
let path = format!("{}/{}", profile, table);
|
||||
|
||||
app_state.ensure_form_editor(&path, config, || {
|
||||
FormState::new(profile.to_string(), table.to_string(), vec![])
|
||||
});
|
||||
|
||||
if let Some(form_state) = app_state.form_state_for_path(&path) {
|
||||
UiService::fetch_and_set_table_count(grpc_client, form_state)
|
||||
.await
|
||||
.context("Failed to fetch table count")?;
|
||||
|
||||
if form_state.total_count > 0 {
|
||||
UiService::load_table_data_by_position(grpc_client, form_state)
|
||||
.await
|
||||
.context("Failed to load table data")?;
|
||||
} else {
|
||||
form_state.reset_to_empty();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,9 +15,10 @@ pub enum SaveOutcome {
|
||||
|
||||
pub async fn save(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<SaveOutcome> {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
if !fs.has_unsaved_changes {
|
||||
return Ok(SaveOutcome::NoChange);
|
||||
}
|
||||
@@ -62,7 +63,7 @@ pub async fn save(
|
||||
.context("Failed to post new table data")?;
|
||||
|
||||
if response.success {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
fs.id = response.inserted_id;
|
||||
fs.total_count += 1;
|
||||
fs.current_position = fs.total_count;
|
||||
@@ -84,7 +85,7 @@ pub async fn save(
|
||||
.context("Failed to put (update) table data")?;
|
||||
|
||||
if response.success {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
fs.has_unsaved_changes = false;
|
||||
}
|
||||
SaveOutcome::UpdatedExisting
|
||||
@@ -101,9 +102,10 @@ pub async fn save(
|
||||
|
||||
pub async fn revert(
|
||||
app_state: &mut AppState,
|
||||
path: &str,
|
||||
grpc_client: &mut GrpcClient,
|
||||
) -> Result<String> {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
||||
if fs.id == 0
|
||||
|| (fs.total_count > 0 && fs.current_position > fs.total_count)
|
||||
|| (fs.total_count == 0 && fs.current_position == 1)
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
pub mod ui;
|
||||
pub mod state;
|
||||
pub mod logic;
|
||||
pub mod event;
|
||||
pub mod loader;
|
||||
|
||||
pub use ui::*;
|
||||
pub use state::*;
|
||||
pub use logic::*;
|
||||
pub use event::*;
|
||||
pub use loader::*;
|
||||
|
||||
@@ -72,7 +72,7 @@ impl FormState {
|
||||
selected_suggestion_index: None,
|
||||
autocomplete_loading: false,
|
||||
link_display_map: HashMap::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
// src/pages/forms/ui.rs
|
||||
use crate::config::colors::themes::Theme;
|
||||
use crate::state::app::state::AppState;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::pages::forms::FormState;
|
||||
use canvas::{
|
||||
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme,
|
||||
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor,
|
||||
};
|
||||
use crate::pages::forms::FormState;
|
||||
|
||||
pub fn render_form_page(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app_state: &AppState,
|
||||
form_state: &FormState, // not needed directly anymore, editor holds it
|
||||
editor: &FormEditor<FormState>,
|
||||
table_name: &str,
|
||||
theme: &Theme,
|
||||
total_count: u64,
|
||||
@@ -61,18 +59,14 @@ pub fn render_form_page(
|
||||
f.render_widget(count_para, main_layout[0]);
|
||||
|
||||
// --- 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);
|
||||
|
||||
// --- SUGGESTIONS DROPDOWN ---
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
main_layout[1],
|
||||
active_rect,
|
||||
&DefaultCanvasTheme,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
let active_field_rect = render_canvas(f, main_layout[1], editor, theme);
|
||||
if let Some(active_rect) = active_field_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
main_layout[1],
|
||||
active_rect,
|
||||
&DefaultCanvasTheme,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/tui/functions/intro.rs
|
||||
// src/pages/intro/logic.rs
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::buffer::state::{AppView, BufferState};
|
||||
|
||||
@@ -12,19 +12,62 @@ pub fn handle_intro_selection(
|
||||
buffer_state: &mut BufferState,
|
||||
index: usize,
|
||||
) {
|
||||
let target_view = match index {
|
||||
0 => AppView::Form,
|
||||
1 => AppView::Admin,
|
||||
2 => AppView::Login,
|
||||
3 => AppView::Register,
|
||||
_ => return,
|
||||
};
|
||||
match index {
|
||||
// Continue: go to the most recent existing Form tab, or open a sensible default
|
||||
0 => {
|
||||
// 1) Try to switch to an already open Form buffer (most recent)
|
||||
if let Some(existing_path) = buffer_state
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|view| {
|
||||
if let AppView::Form(p) = view {
|
||||
Some(p.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
buffer_state.update_history(AppView::Form(existing_path));
|
||||
return;
|
||||
}
|
||||
|
||||
buffer_state.update_history(target_view);
|
||||
|
||||
// Register view requires focus reset
|
||||
if index == 3 {
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
app_state.focused_button_index = 0;
|
||||
// 2) Otherwise pick a fallback path
|
||||
let fallback_path = if let (Some(profile), Some(table)) = (
|
||||
app_state.current_view_profile_name.clone(),
|
||||
app_state.current_view_table_name.clone(),
|
||||
) {
|
||||
Some(format!("{}/{}", profile, table))
|
||||
} else if let Some(any_key) = app_state.form_editor.keys().next().cloned() {
|
||||
// Use any existing editor key if available
|
||||
Some(any_key)
|
||||
} else {
|
||||
// Otherwise pick the first available table from the profile tree
|
||||
let mut found: Option<String> = None;
|
||||
for prof in &app_state.profile_tree.profiles {
|
||||
if let Some(tbl) = prof.tables.first() {
|
||||
found = Some(format!("{}/{}", prof.name, tbl.name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
if let Some(path) = fallback_path {
|
||||
buffer_state.update_history(AppView::Form(path));
|
||||
} else {
|
||||
// No sensible default; stay on Intro
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
}
|
||||
2 => {
|
||||
buffer_state.update_history(AppView::Login);
|
||||
}
|
||||
3 => {
|
||||
buffer_state.update_history(AppView::Register);
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
// src/state/pages/intro.rs
|
||||
use crate::movement::MovementAction;
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct IntroState {
|
||||
pub selected_option: usize,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
impl IntroState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
Self {
|
||||
focus_outside_canvas: true,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_option(&mut self) {
|
||||
if self.selected_option < 3 {
|
||||
self.selected_option += 1;
|
||||
if self.focused_button_index < 3 {
|
||||
self.focused_button_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_option(&mut self) {
|
||||
if self.selected_option > 0 {
|
||||
self.selected_option -= 1
|
||||
if self.focused_button_index > 0 {
|
||||
self.focused_button_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntroState {
|
||||
pub fn handle_movement(&mut self, action: MovementAction) -> bool {
|
||||
match action {
|
||||
MovementAction::Next | MovementAction::Right | MovementAction::Down => {
|
||||
self.next_option();
|
||||
true
|
||||
}
|
||||
MovementAction::Previous | MovementAction::Left | MovementAction::Up => {
|
||||
self.previous_option();
|
||||
true
|
||||
}
|
||||
MovementAction::Select => {
|
||||
// Actual selection handled in event loop (UiContext::Intro)
|
||||
false
|
||||
}
|
||||
MovementAction::Esc => {
|
||||
// Nothing special for Intro, but could be used to quit
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme:
|
||||
|
||||
let buttons = ["Continue", "Admin", "Login", "Register"];
|
||||
for (i, &text) in buttons.iter().enumerate() {
|
||||
render_button(f, button_area[i], text, intro_state.selected_option == i, theme);
|
||||
let active = intro_state.focused_button_index == i;
|
||||
render_button(f, button_area[i], text, active, theme);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
client/src/pages/login/event.rs
Normal file
71
client/src/pages/login/event.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
// src/pages/login/event.rs
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
|
||||
use crate::{
|
||||
state::app::state::AppState,
|
||||
pages::login::LoginFormState,
|
||||
modes::handlers::event::EventOutcome,
|
||||
};
|
||||
use canvas::DataProvider;
|
||||
|
||||
/// Handles all Login page-specific events
|
||||
pub fn handle_login_event(
|
||||
event: Event,
|
||||
app_state: &mut AppState,
|
||||
login_page: &mut LoginFormState,
|
||||
) -> Result<EventOutcome> {
|
||||
if let Event::Key(key_event) = event {
|
||||
let key_code = key_event.code;
|
||||
let modifiers = key_event.modifiers;
|
||||
|
||||
// From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button
|
||||
if login_page.focus_outside_canvas
|
||||
&& login_page.focused_button_index == 0
|
||||
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
|
||||
&& modifiers.is_empty()
|
||||
{
|
||||
login_page.focus_outside_canvas = false;
|
||||
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
// Focus handoff: inside canvas → buttons
|
||||
if !login_page.focus_outside_canvas {
|
||||
let last_idx = login_page.editor.data_provider().field_count().saturating_sub(1);
|
||||
let at_last = login_page.editor.current_field() >= last_idx;
|
||||
if login_page.editor.mode() == CanvasMode::ReadOnly
|
||||
&& at_last
|
||||
&& matches!(
|
||||
(key_code, modifiers),
|
||||
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
|
||||
)
|
||||
{
|
||||
login_page.focus_outside_canvas = true;
|
||||
login_page.focused_button_index = 0;
|
||||
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to canvas if focus is inside
|
||||
if !login_page.focus_outside_canvas {
|
||||
match login_page.handle_key_event(key_event) {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
return Ok(EventOutcome::Ok("Login input updated".into()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
// fall through to button handling
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/tui/functions/common/login.rs
|
||||
// src/pages/login/logic.rs
|
||||
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
@@ -7,12 +7,13 @@ use crate::buffer::state::{AppView, BufferState};
|
||||
use crate::config::storage::storage::{StoredAuthData, save_auth_data};
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use common::proto::komp_ac::auth::LoginResponse;
|
||||
use crate::pages::login::LoginState;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::pages::login::LoginFormState;
|
||||
use crate::state::pages::auth::UserRole;
|
||||
use canvas::DataProvider;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, error};
|
||||
use anyhow::anyhow;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LoginResult {
|
||||
@@ -25,15 +26,14 @@ pub enum LoginResult {
|
||||
/// Updates AuthState and AppState on success or failure.
|
||||
pub async fn save(
|
||||
auth_state: &mut AuthState,
|
||||
login_state: &mut LoginState,
|
||||
login_state: &mut LoginFormState,
|
||||
auth_client: &mut AuthClient,
|
||||
app_state: &mut AppState,
|
||||
) -> Result<String> {
|
||||
let identifier = login_state.username.clone();
|
||||
let password = login_state.password.clone();
|
||||
let identifier = login_state.username().to_string();
|
||||
let password = login_state.password().to_string();
|
||||
|
||||
// --- Client-side validation ---
|
||||
// Prevent login attempt if the identifier field is empty or whitespace.
|
||||
if identifier.trim().is_empty() {
|
||||
let error_message = "Username/Email cannot be empty.".to_string();
|
||||
app_state.show_dialog(
|
||||
@@ -42,33 +42,33 @@ pub async fn save(
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
login_state.error_message = Some(error_message.clone());
|
||||
return Err(anyhow::anyhow!(error_message));
|
||||
login_state.set_error_message(Some(error_message.clone()));
|
||||
return Err(anyhow!(error_message));
|
||||
}
|
||||
|
||||
// Clear previous error/dialog state before attempting
|
||||
login_state.error_message = None;
|
||||
app_state.hide_dialog(); // Hide any previous dialog
|
||||
login_state.set_error_message(None);
|
||||
app_state.hide_dialog();
|
||||
|
||||
// Call the gRPC login method
|
||||
match auth_client.login(identifier.clone(), password).await
|
||||
.with_context(|| format!("gRPC login attempt failed for identifier: {}", identifier))
|
||||
{
|
||||
Ok(response) => {
|
||||
// Store authentication details using correct field names
|
||||
// Store authentication details
|
||||
auth_state.auth_token = Some(response.access_token.clone());
|
||||
auth_state.user_id = Some(response.user_id.clone());
|
||||
auth_state.role = Some(response.role.clone());
|
||||
auth_state.role = Some(UserRole::from_str(&response.role));
|
||||
auth_state.decoded_username = Some(response.username.clone());
|
||||
login_state.set_has_unsaved_changes(false);
|
||||
login_state.error_message = None;
|
||||
|
||||
// Format the success message using response data
|
||||
login_state.set_has_unsaved_changes(false);
|
||||
login_state.set_error_message(None);
|
||||
|
||||
let success_message = format!(
|
||||
"Login Successful!\n\n\
|
||||
Username: {}\n\
|
||||
User ID: {}\n\
|
||||
Role: {}",
|
||||
Username: {}\n\
|
||||
User ID: {}\n\
|
||||
Role: {}",
|
||||
response.username,
|
||||
response.user_id,
|
||||
response.role
|
||||
@@ -80,9 +80,11 @@ pub async fn save(
|
||||
vec!["Menu".to_string(), "Exit".to_string()],
|
||||
DialogPurpose::LoginSuccess,
|
||||
);
|
||||
login_state.password.clear();
|
||||
login_state.username.clear();
|
||||
login_state.current_cursor_pos = 0;
|
||||
|
||||
login_state.username_mut().clear();
|
||||
login_state.password_mut().clear();
|
||||
login_state.set_current_cursor_pos(0);
|
||||
|
||||
Ok("Login successful, details shown in dialog.".to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -93,10 +95,10 @@ pub async fn save(
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
login_state.error_message = Some(error_message.clone());
|
||||
login_state.set_error_message(Some(error_message.clone()));
|
||||
login_state.set_has_unsaved_changes(true);
|
||||
login_state.username.clear();
|
||||
login_state.password.clear();
|
||||
login_state.username_mut().clear();
|
||||
login_state.password_mut().clear();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -104,56 +106,52 @@ pub async fn save(
|
||||
|
||||
/// Reverts the login form fields to empty and returns to the previous screen (Intro).
|
||||
pub async fn revert(
|
||||
login_state: &mut LoginState,
|
||||
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere
|
||||
login_state: &mut LoginFormState,
|
||||
app_state: &mut AppState,
|
||||
) -> String {
|
||||
// Clear the input fields
|
||||
login_state.username.clear();
|
||||
login_state.password.clear();
|
||||
login_state.error_message = None;
|
||||
login_state.set_has_unsaved_changes(false);
|
||||
login_state.login_request_pending = false; // Ensure flag is reset on revert
|
||||
// Clear the underlying state
|
||||
login_state.clear();
|
||||
|
||||
// Also clear values inside the editor’s data provider
|
||||
{
|
||||
let dp = login_state.editor.data_provider_mut();
|
||||
dp.set_field_value(0, "".to_string());
|
||||
dp.set_field_value(1, "".to_string());
|
||||
dp.set_current_field(0);
|
||||
dp.set_current_cursor_pos(0);
|
||||
dp.set_has_unsaved_changes(false);
|
||||
}
|
||||
|
||||
app_state.hide_dialog();
|
||||
"Login reverted".to_string()
|
||||
}
|
||||
|
||||
/// Clears login form and navigates back to main menu.
|
||||
pub async fn back_to_main(
|
||||
login_state: &mut LoginState,
|
||||
login_state: &mut LoginFormState,
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
) -> String {
|
||||
// Clear the input fields
|
||||
login_state.username.clear();
|
||||
login_state.password.clear();
|
||||
login_state.error_message = None;
|
||||
login_state.set_has_unsaved_changes(false);
|
||||
login_state.login_request_pending = false; // Ensure flag is reset
|
||||
|
||||
// Ensure dialog is hidden if revert is called
|
||||
login_state.clear();
|
||||
app_state.hide_dialog();
|
||||
|
||||
// Navigation logic
|
||||
buffer_state.close_active_buffer();
|
||||
buffer_state.update_history(AppView::Intro);
|
||||
|
||||
// Reset focus state
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
app_state.focused_button_index= 0;
|
||||
|
||||
"Returned to main menu".to_string()
|
||||
}
|
||||
|
||||
/// Validates input, shows loading, and spawns the login task.
|
||||
pub fn initiate_login(
|
||||
login_state: &LoginState,
|
||||
login_state: &mut LoginFormState,
|
||||
app_state: &mut AppState,
|
||||
mut auth_client: AuthClient,
|
||||
sender: mpsc::Sender<LoginResult>,
|
||||
) -> String {
|
||||
let username = login_state.username.clone();
|
||||
let password = login_state.password.clone();
|
||||
login_state.sync_from_editor();
|
||||
let username = login_state.username().to_string();
|
||||
let password = login_state.password().to_string();
|
||||
|
||||
// 1. Client-side validation
|
||||
if username.trim().is_empty() {
|
||||
app_state.show_dialog(
|
||||
"Login Failed",
|
||||
@@ -163,25 +161,20 @@ pub fn initiate_login(
|
||||
);
|
||||
"Username cannot be empty.".to_string()
|
||||
} else {
|
||||
// 2. Show Loading Dialog
|
||||
app_state.show_loading_dialog("Logging In", "Please wait...");
|
||||
|
||||
// 3. Spawn the login task
|
||||
spawn(async move {
|
||||
// Use the passed-in (and moved) auth_client directly
|
||||
let login_outcome = match auth_client.login(username.clone(), password).await
|
||||
.with_context(|| format!("Spawned login task failed for identifier: {}", username))
|
||||
{
|
||||
Ok(response) => LoginResult::Success(response),
|
||||
Err(e) => LoginResult::Failure(format!("{}", e)),
|
||||
};
|
||||
// Send result back to the main UI thread
|
||||
{
|
||||
Ok(response) => LoginResult::Success(response),
|
||||
Err(e) => LoginResult::Failure(format!("{}", e)),
|
||||
};
|
||||
if let Err(e) = sender.send(login_outcome).await {
|
||||
error!("Failed to send login result: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Return immediately
|
||||
"Login initiated.".to_string()
|
||||
}
|
||||
}
|
||||
@@ -192,28 +185,24 @@ pub fn handle_login_result(
|
||||
result: LoginResult,
|
||||
app_state: &mut AppState,
|
||||
auth_state: &mut AuthState,
|
||||
login_state: &mut LoginState,
|
||||
login_state: &mut LoginFormState,
|
||||
) -> bool {
|
||||
match result {
|
||||
LoginResult::Success(response) => {
|
||||
auth_state.auth_token = Some(response.access_token.clone());
|
||||
auth_state.user_id = Some(response.user_id.clone());
|
||||
auth_state.role = Some(response.role.clone());
|
||||
auth_state.role = Some(UserRole::from_str(&response.role));
|
||||
auth_state.decoded_username = Some(response.username.clone());
|
||||
|
||||
// --- NEW: Save auth data to file ---
|
||||
let data_to_store = StoredAuthData {
|
||||
access_token: response.access_token.clone(),
|
||||
user_id: response.user_id.clone(),
|
||||
role: response.role.clone(),
|
||||
username: response.username.clone(),
|
||||
};
|
||||
|
||||
if let Err(e) = save_auth_data(&data_to_store) {
|
||||
error!("Failed to save auth data to file: {}", e);
|
||||
// Continue anyway - user is still logged in for this session
|
||||
}
|
||||
// --- END NEW ---
|
||||
|
||||
let success_message = format!(
|
||||
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
|
||||
@@ -227,26 +216,28 @@ pub fn handle_login_result(
|
||||
info!(message = %success_message, "Login successful");
|
||||
}
|
||||
LoginResult::Failure(err_msg) | LoginResult::ConnectionError(err_msg) => {
|
||||
app_state.update_dialog_content(&err_msg, vec!["OK".to_string()], DialogPurpose::LoginFailed);
|
||||
login_state.error_message = Some(err_msg.clone());
|
||||
app_state.update_dialog_content(
|
||||
&err_msg,
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
login_state.set_error_message(Some(err_msg.clone()));
|
||||
error!(error = %err_msg, "Login failed/connection error");
|
||||
}
|
||||
}
|
||||
login_state.username.clear();
|
||||
login_state.password.clear();
|
||||
|
||||
login_state.username_mut().clear();
|
||||
login_state.password_mut().clear();
|
||||
login_state.set_has_unsaved_changes(false);
|
||||
login_state.current_cursor_pos = 0;
|
||||
true // Request redraw as dialog content changed
|
||||
login_state.set_current_cursor_pos(0);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn handle_action(action: &str,) -> Result<String> {
|
||||
pub async fn handle_action(action: &str) -> Result<String> {
|
||||
match action {
|
||||
"previous_entry" => {
|
||||
Ok("Previous entry at tui/functions/login.rs not implemented".into())
|
||||
}
|
||||
"next_entry" => {
|
||||
Ok("Next entry at tui/functions/login.rs not implemented".into())
|
||||
}
|
||||
_ => Err(anyhow!("Unknown login action: {}", action))
|
||||
"previous_entry" => Ok("Previous entry not implemented".into()),
|
||||
"next_entry" => Ok("Next entry not implemented".into()),
|
||||
_ => Err(anyhow!("Unknown login action: {}", action)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
pub mod state;
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
pub mod event;
|
||||
|
||||
pub use state::*;
|
||||
pub use ui::render_login;
|
||||
pub use logic::*;
|
||||
pub use event::*;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// src/pages/login/state.rs
|
||||
|
||||
use canvas::{AppMode, DataProvider};
|
||||
use canvas::FormEditor;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginState {
|
||||
@@ -24,7 +26,7 @@ impl Default for LoginState {
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
login_request_pending: false,
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +34,7 @@ impl Default for LoginState {
|
||||
impl LoginState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app_mode: AppMode::Edit,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -119,3 +121,128 @@ impl DataProvider for LoginState {
|
||||
false // Login form doesn't support suggestions
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper that owns both the raw login state and its editor
|
||||
|
||||
pub struct LoginFormState {
|
||||
pub state: LoginState,
|
||||
pub editor: FormEditor<LoginState>,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
// manual debug because FormEditor doesnt implement debug
|
||||
impl fmt::Debug for LoginFormState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("LoginFormState")
|
||||
.field("state", &self.state) // ✅ only print the data
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginFormState {
|
||||
/// Sync the editor's data provider back into our state
|
||||
pub fn sync_from_editor(&mut self) {
|
||||
// FormEditor holds the authoritative data
|
||||
let dp = self.editor.data_provider();
|
||||
self.state = dp.clone(); // LoginState implements Clone
|
||||
}
|
||||
|
||||
/// Create a new LoginFormState with default LoginState and FormEditor
|
||||
pub fn new() -> Self {
|
||||
let state = LoginState::default();
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// === Delegates to LoginState fields ===
|
||||
|
||||
pub fn username(&self) -> &str {
|
||||
&self.state.username
|
||||
}
|
||||
|
||||
pub fn username_mut(&mut self) -> &mut String {
|
||||
&mut self.state.username
|
||||
}
|
||||
|
||||
pub fn password(&self) -> &str {
|
||||
&self.state.password
|
||||
}
|
||||
|
||||
pub fn password_mut(&mut self) -> &mut String {
|
||||
&mut self.state.password
|
||||
}
|
||||
|
||||
pub fn error_message(&self) -> Option<&String> {
|
||||
self.state.error_message.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_error_message(&mut self, msg: Option<String>) {
|
||||
self.state.error_message = msg;
|
||||
}
|
||||
|
||||
pub fn has_unsaved_changes(&self) -> bool {
|
||||
self.state.has_unsaved_changes
|
||||
}
|
||||
|
||||
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.state.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.state.username.clear();
|
||||
self.state.password.clear();
|
||||
self.state.error_message = None;
|
||||
self.state.has_unsaved_changes = false;
|
||||
self.state.login_request_pending = false;
|
||||
self.state.current_cursor_pos = 0;
|
||||
}
|
||||
|
||||
// === Delegates to LoginState cursor/input ===
|
||||
|
||||
pub fn current_field(&self) -> usize {
|
||||
self.state.current_field()
|
||||
}
|
||||
|
||||
pub fn set_current_field(&mut self, index: usize) {
|
||||
self.state.set_current_field(index);
|
||||
}
|
||||
|
||||
pub fn current_cursor_pos(&self) -> usize {
|
||||
self.state.current_cursor_pos()
|
||||
}
|
||||
|
||||
pub fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.state.set_current_cursor_pos(pos);
|
||||
}
|
||||
|
||||
pub fn get_current_input(&self) -> &str {
|
||||
self.state.get_current_input()
|
||||
}
|
||||
|
||||
pub fn get_current_input_mut(&mut self) -> &mut String {
|
||||
self.state.get_current_input_mut()
|
||||
}
|
||||
|
||||
// === Delegates to FormEditor ===
|
||||
|
||||
pub fn mode(&self) -> AppMode {
|
||||
self.editor.mode()
|
||||
}
|
||||
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.editor.cursor_position()
|
||||
}
|
||||
|
||||
pub fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: crossterm::event::KeyEvent,
|
||||
) -> canvas::keymap::KeyEventOutcome {
|
||||
self.editor.handle_key_event(key_event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,20 @@ use canvas::{
|
||||
render_suggestions_dropdown,
|
||||
DefaultCanvasTheme,
|
||||
};
|
||||
use crate::pages::login::LoginState;
|
||||
|
||||
use crate::pages::login::LoginFormState;
|
||||
use crate::dialog;
|
||||
|
||||
pub fn render_login(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
// FIX: take &LoginState (reference), not owned
|
||||
login_state: &LoginState,
|
||||
login_page: &LoginFormState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
) {
|
||||
let login_state = &login_page.state;
|
||||
let editor = &login_page.editor;
|
||||
|
||||
// Main container
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -53,14 +55,10 @@ pub fn render_login(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// Wrap LoginState in FormEditor (no clone needed)
|
||||
let editor = FormEditor::new(login_state.clone());
|
||||
|
||||
// Use DefaultCanvasTheme instead of app Theme
|
||||
let input_rect = render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
&editor,
|
||||
editor,
|
||||
&DefaultCanvasTheme,
|
||||
);
|
||||
|
||||
@@ -82,11 +80,8 @@ pub fn render_login(
|
||||
|
||||
// Login Button
|
||||
let login_button_index = 0;
|
||||
let login_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index == login_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let login_active = login_page.focus_outside_canvas
|
||||
&& login_page.focused_button_index == login_button_index;
|
||||
let mut login_style = Style::default().fg(theme.fg);
|
||||
let mut login_border = Style::default().fg(theme.border);
|
||||
if login_active {
|
||||
@@ -109,11 +104,8 @@ pub fn render_login(
|
||||
|
||||
// Return Button
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index == return_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let return_active = login_page.focus_outside_canvas
|
||||
&& login_page.focused_button_index == return_button_index;
|
||||
let mut return_style = Style::default().fg(theme.fg);
|
||||
let mut return_border = Style::default().fg(theme.border);
|
||||
if return_active {
|
||||
@@ -135,14 +127,14 @@ pub fn render_login(
|
||||
);
|
||||
|
||||
// --- SUGGESTIONS DROPDOWN (if active) ---
|
||||
if app_state.current_mode == crate::modes::handlers::mode_manager::AppMode::Edit {
|
||||
if editor.mode() == canvas::AppMode::Edit {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
f.area(),
|
||||
chunks[0],
|
||||
input_rect,
|
||||
&DefaultCanvasTheme,
|
||||
&editor, // FIX: pass &editor
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,5 @@ pub mod intro;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
pub mod forms;
|
||||
pub mod admin;
|
||||
pub mod admin_panel;
|
||||
|
||||
72
client/src/pages/register/event.rs
Normal file
72
client/src/pages/register/event.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/pages/register/event.rs
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use canvas::{keymap::KeyEventOutcome, AppMode as CanvasMode};
|
||||
use canvas::DataProvider;
|
||||
use crate::{
|
||||
state::app::state::AppState,
|
||||
pages::register::RegisterFormState,
|
||||
modes::handlers::event::EventOutcome,
|
||||
};
|
||||
|
||||
/// Handles all Register page-specific events.
|
||||
/// Return a non-empty Ok(message) only when the page actually consumed the key,
|
||||
/// otherwise return Ok("") to let global handling proceed.
|
||||
pub fn handle_register_event(
|
||||
event: Event,
|
||||
app_state: &mut AppState,
|
||||
register_page: &mut RegisterFormState,
|
||||
)-> Result<EventOutcome> {
|
||||
if let Event::Key(key_event) = event {
|
||||
let key_code = key_event.code;
|
||||
let modifiers = key_event.modifiers;
|
||||
|
||||
// From buttons (outside) back into the canvas (ReadOnly) with Up/k from the left-most button
|
||||
if register_page.focus_outside_canvas
|
||||
&& register_page.focused_button_index == 0
|
||||
&& matches!(key_code, KeyCode::Up | KeyCode::Char('k'))
|
||||
&& modifiers.is_empty()
|
||||
{
|
||||
register_page.focus_outside_canvas = false;
|
||||
register_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||
return Ok(EventOutcome::Ok(String::new()));
|
||||
}
|
||||
|
||||
// Focus handoff: inside canvas → buttons
|
||||
if !register_page.focus_outside_canvas {
|
||||
let last_idx = register_page.editor.data_provider().field_count().saturating_sub(1);
|
||||
let at_last = register_page.editor.current_field() >= last_idx;
|
||||
if register_page.editor.mode() == CanvasMode::ReadOnly
|
||||
&& at_last
|
||||
&& matches!(
|
||||
(key_code, modifiers),
|
||||
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _)
|
||||
)
|
||||
{
|
||||
register_page.focus_outside_canvas = true;
|
||||
register_page.focused_button_index = 0; // focus "Register" button
|
||||
register_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to canvas if focus is inside
|
||||
if !register_page.focus_outside_canvas {
|
||||
match register_page.handle_key_event(key_event) {
|
||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||
return Ok(EventOutcome::Ok(msg));
|
||||
}
|
||||
KeyEventOutcome::Consumed(None) => {
|
||||
return Ok(EventOutcome::Ok("Register input updated".into()));
|
||||
}
|
||||
KeyEventOutcome::Pending => {
|
||||
return Ok(EventOutcome::Ok("Waiting for next key...".into()));
|
||||
}
|
||||
KeyEventOutcome::NotMatched => {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(EventOutcome::Ok(String::new()))
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/pages/register/logic.rs
|
||||
|
||||
use crate::services::auth::AuthClient;
|
||||
use crate::state::{
|
||||
app::state::AppState,
|
||||
};
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::buffer::state::{AppView, BufferState};
|
||||
use common::proto::komp_ac::auth::AuthResponse;
|
||||
use crate::pages::register::RegisterState;
|
||||
use crate::pages::register::RegisterFormState;
|
||||
use anyhow::Context;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -22,24 +20,26 @@ pub enum RegisterResult {
|
||||
|
||||
/// Clears the registration form fields.
|
||||
pub async fn revert(
|
||||
register_state: &mut RegisterState,
|
||||
_app_state: &mut AppState, // Keep signature consistent if needed elsewhere
|
||||
register_state: &mut RegisterFormState,
|
||||
app_state: &mut AppState,
|
||||
) -> String {
|
||||
register_state.username.clear();
|
||||
register_state.email.clear();
|
||||
register_state.password.clear();
|
||||
register_state.password_confirmation.clear();
|
||||
register_state.role.clear();
|
||||
register_state.error_message = None;
|
||||
register_state.username_mut().clear();
|
||||
register_state.email_mut().clear();
|
||||
register_state.password_mut().clear();
|
||||
register_state.password_confirmation_mut().clear();
|
||||
register_state.role_mut().clear();
|
||||
register_state.set_error_message(None);
|
||||
register_state.set_has_unsaved_changes(false);
|
||||
register_state.current_field = 0; // Reset focus to first field
|
||||
register_state.current_cursor_pos = 0;
|
||||
register_state.set_current_field(0); // Reset focus to first field
|
||||
register_state.set_current_cursor_pos(0);
|
||||
|
||||
app_state.hide_dialog();
|
||||
"Registration form cleared".to_string()
|
||||
}
|
||||
|
||||
/// Clears the form and returns to the intro screen.
|
||||
pub async fn back_to_login(
|
||||
register_state: &mut RegisterState,
|
||||
register_state: &mut RegisterFormState,
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
) -> String {
|
||||
@@ -54,33 +54,43 @@ pub async fn back_to_login(
|
||||
buffer_state.update_history(AppView::Login);
|
||||
|
||||
// Reset focus state
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
app_state.focused_button_index = 0;
|
||||
register_state.focus_outside_canvas = false;
|
||||
register_state.focused_button_index = 0;
|
||||
|
||||
"Returned to main menu".to_string()
|
||||
}
|
||||
|
||||
/// Validates input, shows loading, and spawns the registration task.
|
||||
pub fn initiate_registration(
|
||||
register_state: &RegisterState,
|
||||
register_state: &mut RegisterFormState,
|
||||
app_state: &mut AppState,
|
||||
mut auth_client: AuthClient,
|
||||
sender: mpsc::Sender<RegisterResult>,
|
||||
) -> String {
|
||||
// Clone necessary data
|
||||
let username = register_state.username.clone();
|
||||
let email = register_state.email.clone();
|
||||
let password = register_state.password.clone();
|
||||
let password_confirmation = register_state.password_confirmation.clone();
|
||||
let role = register_state.role.clone();
|
||||
register_state.sync_from_editor();
|
||||
let username = register_state.username().to_string();
|
||||
let email = register_state.email().to_string();
|
||||
let password = register_state.password().to_string();
|
||||
let password_confirmation = register_state.password_confirmation().to_string();
|
||||
let role = register_state.role().to_string();
|
||||
|
||||
// 1. Client-side validation
|
||||
if username.trim().is_empty() {
|
||||
app_state.show_dialog("Registration Failed", "Username cannot be empty.", vec!["OK".to_string()], DialogPurpose::RegisterFailed);
|
||||
app_state.show_dialog(
|
||||
"Registration Failed",
|
||||
"Username cannot be empty.",
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::RegisterFailed,
|
||||
);
|
||||
"Username cannot be empty.".to_string()
|
||||
} else if !password.is_empty() && password != password_confirmation {
|
||||
app_state.show_dialog("Registration Failed", "Passwords do not match.", vec!["OK".to_string()], DialogPurpose::RegisterFailed);
|
||||
"Passwords do not match.".to_string()
|
||||
app_state.show_dialog(
|
||||
"Registration Failed",
|
||||
"Passwords do not match.",
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::RegisterFailed,
|
||||
);
|
||||
"Passwords do not match.".to_string()
|
||||
} else {
|
||||
// 2. Show Loading Dialog
|
||||
app_state.show_loading_dialog("Registering", "Please wait...");
|
||||
@@ -88,14 +98,19 @@ pub fn initiate_registration(
|
||||
// 3. Spawn the registration task
|
||||
spawn(async move {
|
||||
let password_opt = if password.is_empty() { None } else { Some(password) };
|
||||
let password_conf_opt = if password_confirmation.is_empty() { None } else { Some(password_confirmation) };
|
||||
let password_conf_opt =
|
||||
if password_confirmation.is_empty() { None } else { Some(password_confirmation) };
|
||||
let role_opt = if role.is_empty() { None } else { Some(role) };
|
||||
let register_outcome = match auth_client.register(username.clone(), email, password_opt, password_conf_opt, role_opt).await
|
||||
|
||||
let register_outcome = match auth_client
|
||||
.register(username.clone(), email, password_opt, password_conf_opt, role_opt)
|
||||
.await
|
||||
.with_context(|| format!("Spawned register task failed for username: {}", username))
|
||||
{
|
||||
Ok(response) => RegisterResult::Success(response),
|
||||
Err(e) => RegisterResult::Failure(format!("{}", e)),
|
||||
};
|
||||
|
||||
// Send result back to the main UI thread
|
||||
if let Err(e) = sender.send(register_outcome).await {
|
||||
error!("Failed to send registration result: {}", e);
|
||||
@@ -112,7 +127,7 @@ pub fn initiate_registration(
|
||||
pub fn handle_registration_result(
|
||||
result: RegisterResult,
|
||||
app_state: &mut AppState,
|
||||
register_state: &mut RegisterState,
|
||||
register_state: &mut RegisterFormState,
|
||||
) -> bool {
|
||||
match result {
|
||||
RegisterResult::Success(response) => {
|
||||
@@ -133,7 +148,7 @@ pub fn handle_registration_result(
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::RegisterFailed,
|
||||
);
|
||||
register_state.error_message = Some(err_msg.clone());
|
||||
register_state.set_error_message(Some(err_msg.clone()));
|
||||
error!(error = %err_msg, "Registration failed/connection error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
pub mod ui;
|
||||
pub mod state;
|
||||
pub mod logic;
|
||||
pub mod suggestions;
|
||||
pub mod event;
|
||||
|
||||
// pub use state::*;
|
||||
pub use ui::render_register;
|
||||
pub use logic::*;
|
||||
pub use state::*;
|
||||
pub use event::*;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// src/pages/register/state.rs
|
||||
|
||||
use canvas::{DataProvider, AppMode};
|
||||
use lazy_static::lazy_static;
|
||||
use canvas::{DataProvider, AppMode, FormEditor};
|
||||
use std::fmt;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref AVAILABLE_ROLES: Vec<String> = vec![
|
||||
"admin".to_string(),
|
||||
"moderator".to_string(),
|
||||
"accountant".to_string(),
|
||||
"viewer".to_string(),
|
||||
];
|
||||
}
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use canvas::keymap::KeyEventOutcome;
|
||||
use crate::pages::register::suggestions::role_suggestions_sync;
|
||||
|
||||
/// Represents the state of the Registration form UI
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -24,9 +19,7 @@ pub struct RegisterState {
|
||||
pub current_field: usize,
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub app_mode: AppMode,
|
||||
pub role_suggestions: Vec<String>,
|
||||
pub role_suggestions_active: bool,
|
||||
pub app_mode: canvas::AppMode,
|
||||
}
|
||||
|
||||
impl Default for RegisterState {
|
||||
@@ -41,9 +34,7 @@ impl Default for RegisterState {
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
app_mode: AppMode::Edit,
|
||||
role_suggestions: AVAILABLE_ROLES.clone(),
|
||||
role_suggestions_active: false,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +42,7 @@ impl Default for RegisterState {
|
||||
impl RegisterState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app_mode: AppMode::Edit,
|
||||
role_suggestions: AVAILABLE_ROLES.clone(),
|
||||
role_suggestions_active: false,
|
||||
app_mode: canvas::AppMode::Edit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -69,12 +58,6 @@ impl RegisterState {
|
||||
pub fn set_current_field(&mut self, index: usize) {
|
||||
if index < 5 {
|
||||
self.current_field = index;
|
||||
|
||||
if index == 4 {
|
||||
self.activate_role_suggestions();
|
||||
} else {
|
||||
self.deactivate_role_suggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,28 +91,6 @@ impl RegisterState {
|
||||
self.app_mode
|
||||
}
|
||||
|
||||
pub fn activate_role_suggestions(&mut self) {
|
||||
self.role_suggestions_active = true;
|
||||
let current_input = self.role.to_lowercase();
|
||||
self.role_suggestions = AVAILABLE_ROLES
|
||||
.iter()
|
||||
.filter(|role| role.to_lowercase().contains(¤t_input))
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub fn deactivate_role_suggestions(&mut self) {
|
||||
self.role_suggestions_active = false;
|
||||
}
|
||||
|
||||
pub fn is_role_suggestions_active(&self) -> bool {
|
||||
self.role_suggestions_active
|
||||
}
|
||||
|
||||
pub fn get_role_suggestions(&self) -> &[String] {
|
||||
&self.role_suggestions
|
||||
}
|
||||
|
||||
pub fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_unsaved_changes
|
||||
}
|
||||
@@ -140,9 +101,7 @@ impl RegisterState {
|
||||
}
|
||||
|
||||
impl DataProvider for RegisterState {
|
||||
fn field_count(&self) -> usize {
|
||||
5
|
||||
}
|
||||
fn field_count(&self) -> usize { 5 }
|
||||
|
||||
fn field_name(&self, index: usize) -> &str {
|
||||
match index {
|
||||
@@ -182,3 +141,230 @@ impl DataProvider for RegisterState {
|
||||
field_index == 4 // only Role field supports suggestions
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper that owns both the raw register state and its editor
|
||||
pub struct RegisterFormState {
|
||||
pub state: RegisterState,
|
||||
pub editor: FormEditor<RegisterState>,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub focused_button_index: usize,
|
||||
}
|
||||
|
||||
impl Default for RegisterFormState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// manual Debug because FormEditor doesn’t implement Debug
|
||||
impl fmt::Debug for RegisterFormState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RegisterFormState")
|
||||
.field("state", &self.state)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RegisterFormState {
|
||||
/// Sync the editor's data provider back into our state
|
||||
pub fn sync_from_editor(&mut self) {
|
||||
// The FormEditor holds the authoritative data
|
||||
let dp = self.editor.data_provider();
|
||||
self.state = dp.clone(); // because RegisterState: Clone
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
let state = RegisterState::default();
|
||||
let editor = FormEditor::new(state.clone());
|
||||
Self {
|
||||
state,
|
||||
editor,
|
||||
focus_outside_canvas: false,
|
||||
focused_button_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// === Delegates to RegisterState ===
|
||||
pub fn username(&self) -> &str {
|
||||
&self.state.username
|
||||
}
|
||||
pub fn username_mut(&mut self) -> &mut String {
|
||||
&mut self.state.username
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
&self.state.email
|
||||
}
|
||||
pub fn email_mut(&mut self) -> &mut String {
|
||||
&mut self.state.email
|
||||
}
|
||||
|
||||
pub fn password(&self) -> &str {
|
||||
&self.state.password
|
||||
}
|
||||
pub fn password_mut(&mut self) -> &mut String {
|
||||
&mut self.state.password
|
||||
}
|
||||
|
||||
pub fn password_confirmation(&self) -> &str {
|
||||
&self.state.password_confirmation
|
||||
}
|
||||
pub fn password_confirmation_mut(&mut self) -> &mut String {
|
||||
&mut self.state.password_confirmation
|
||||
}
|
||||
|
||||
pub fn role(&self) -> &str {
|
||||
&self.state.role
|
||||
}
|
||||
pub fn role_mut(&mut self) -> &mut String {
|
||||
&mut self.state.role
|
||||
}
|
||||
|
||||
pub fn error_message(&self) -> Option<&String> {
|
||||
self.state.error_message.as_ref()
|
||||
}
|
||||
pub fn set_error_message(&mut self, msg: Option<String>) {
|
||||
self.state.error_message = msg;
|
||||
}
|
||||
|
||||
pub fn has_unsaved_changes(&self) -> bool {
|
||||
self.state.has_unsaved_changes
|
||||
}
|
||||
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.state.has_unsaved_changes = changed;
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.state.username.clear();
|
||||
self.state.email.clear();
|
||||
self.state.password.clear();
|
||||
self.state.password_confirmation.clear();
|
||||
self.state.role.clear();
|
||||
self.state.error_message = None;
|
||||
self.state.has_unsaved_changes = false;
|
||||
self.state.current_field = 0;
|
||||
self.state.current_cursor_pos = 0;
|
||||
}
|
||||
|
||||
// === Delegates to cursor/input ===
|
||||
pub fn current_field(&self) -> usize {
|
||||
self.state.current_field()
|
||||
}
|
||||
pub fn set_current_field(&mut self, index: usize) {
|
||||
self.state.set_current_field(index);
|
||||
}
|
||||
pub fn current_cursor_pos(&self) -> usize {
|
||||
self.state.current_cursor_pos()
|
||||
}
|
||||
pub fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
self.state.set_current_cursor_pos(pos);
|
||||
}
|
||||
pub fn get_current_input(&self) -> &str {
|
||||
self.state.get_current_input()
|
||||
}
|
||||
pub fn get_current_input_mut(&mut self) -> &mut String {
|
||||
self.state.get_current_input_mut(self.state.current_field)
|
||||
}
|
||||
|
||||
// === Delegates to FormEditor ===
|
||||
pub fn mode(&self) -> canvas::AppMode {
|
||||
self.editor.mode()
|
||||
}
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.editor.cursor_position()
|
||||
}
|
||||
|
||||
pub fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: crossterm::event::KeyEvent,
|
||||
) -> canvas::keymap::KeyEventOutcome {
|
||||
// Only customize behavior for the Role field (index 4) in Edit mode
|
||||
let in_role_field = self.editor.current_field() == 4;
|
||||
let in_edit_mode = self.editor.mode() == canvas::AppMode::Edit;
|
||||
|
||||
if in_role_field && in_edit_mode {
|
||||
match key_event.code {
|
||||
// Tab: open suggestions if inactive; otherwise cycle next
|
||||
KeyCode::Tab => {
|
||||
if !self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(4) {
|
||||
let items = role_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(4, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cycle to next suggestion
|
||||
self.editor.suggestions_next();
|
||||
}
|
||||
return KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
|
||||
// Shift+Tab (BackTab): cycle suggestions too (fallback to next)
|
||||
KeyCode::BackTab => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
// If your canvas exposes suggestions_prev(), use it here.
|
||||
// Fallback: cycle next.
|
||||
self.editor.suggestions_next();
|
||||
return KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter: if suggestions active — apply selected suggestion
|
||||
KeyCode::Enter => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
let _ = self.editor.apply_suggestion();
|
||||
return KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Esc: close suggestions if active
|
||||
KeyCode::Esc => {
|
||||
if self.editor.is_suggestions_active() {
|
||||
self.editor.close_suggestions();
|
||||
return KeyEventOutcome::Consumed(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Character input: first let editor mutate text, then refilter if active
|
||||
KeyCode::Char(_) => {
|
||||
let outcome = self.editor.handle_key_event(key_event);
|
||||
if self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(4) {
|
||||
let items = role_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(4, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// Backspace/Delete: mutate then refilter if active
|
||||
KeyCode::Backspace | KeyCode::Delete => {
|
||||
let outcome = self.editor.handle_key_event(key_event);
|
||||
if self.editor.is_suggestions_active() {
|
||||
if let Some(query) = self.editor.start_suggestions(4) {
|
||||
let items = role_suggestions_sync(&query);
|
||||
let applied =
|
||||
self.editor.apply_suggestions_result(4, &query, items);
|
||||
if applied {
|
||||
self.editor.update_inline_completion();
|
||||
}
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
_ => { /* fall through to default */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Default: let canvas handle it
|
||||
self.editor.handle_key_event(key_event)
|
||||
}
|
||||
}
|
||||
|
||||
36
client/src/pages/register/suggestions.rs
Normal file
36
client/src/pages/register/suggestions.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
// src/pages/register/suggestions.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use canvas::{SuggestionItem, SuggestionsProvider};
|
||||
|
||||
// Keep the async provider if you want, but add this sync helper and shared data.
|
||||
const ROLES: &[&str] = &["admin", "moderator", "accountant", "viewer"];
|
||||
|
||||
pub fn role_suggestions_sync(query: &str) -> Vec<SuggestionItem> {
|
||||
let q = query.to_lowercase();
|
||||
ROLES
|
||||
.iter()
|
||||
.filter(|r| q.is_empty() || r.to_lowercase().contains(&q))
|
||||
.map(|r| SuggestionItem {
|
||||
display_text: (*r).to_string(),
|
||||
value_to_store: (*r).to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct RoleSuggestionsProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl SuggestionsProvider for RoleSuggestionsProvider {
|
||||
async fn fetch_suggestions(
|
||||
&mut self,
|
||||
field_index: usize,
|
||||
query: &str,
|
||||
) -> Result<Vec<SuggestionItem>> {
|
||||
if field_index != 4 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(role_suggestions_sync(query))
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
use crate::{
|
||||
config::colors::themes::Theme,
|
||||
state::app::state::AppState,
|
||||
modes::handlers::mode_manager::AppMode,
|
||||
};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
|
||||
@@ -12,17 +11,23 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
use crate::dialog;
|
||||
use crate::pages::register::RegisterState;
|
||||
use canvas::{FormEditor, render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
|
||||
use crate::pages::register::RegisterFormState;
|
||||
use crate::pages::register::suggestions::RoleSuggestionsProvider;
|
||||
use tokio::runtime::Handle;
|
||||
use canvas::{render_canvas, render_suggestions_dropdown, DefaultCanvasTheme};
|
||||
use canvas::SuggestionsProvider;
|
||||
|
||||
pub fn render_register(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
state: &RegisterState,
|
||||
register_page: &RegisterFormState,
|
||||
app_state: &AppState,
|
||||
is_edit_mode: bool,
|
||||
) {
|
||||
let state = ®ister_page.state;
|
||||
let editor = ®ister_page.editor;
|
||||
|
||||
// Outer block
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
@@ -47,15 +52,9 @@ pub fn render_register(
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// Wrap RegisterState in FormEditor
|
||||
let editor = FormEditor::new(state.clone());
|
||||
// Render the form canvas
|
||||
let input_rect = render_canvas(f, chunks[0], editor, theme);
|
||||
|
||||
let input_rect = render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
&editor,
|
||||
theme,
|
||||
);
|
||||
|
||||
// --- HELP TEXT ---
|
||||
let help_text = Paragraph::new("* are optional fields")
|
||||
@@ -81,11 +80,9 @@ pub fn render_register(
|
||||
|
||||
// Register Button
|
||||
let register_button_index = 0;
|
||||
let register_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index == register_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let register_active =
|
||||
register_page.focus_outside_canvas
|
||||
&& register_page.focused_button_index == register_button_index;
|
||||
let mut register_style = Style::default().fg(theme.fg);
|
||||
let mut register_border = Style::default().fg(theme.border);
|
||||
if register_active {
|
||||
@@ -108,11 +105,9 @@ pub fn render_register(
|
||||
|
||||
// Return Button
|
||||
let return_button_index = 1;
|
||||
let return_active = if app_state.ui.focus_outside_canvas {
|
||||
app_state.focused_button_index == return_button_index
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let return_active =
|
||||
register_page.focus_outside_canvas
|
||||
&& register_page.focused_button_index == return_button_index;
|
||||
let mut return_style = Style::default().fg(theme.fg);
|
||||
let mut return_border = Style::default().fg(theme.border);
|
||||
if return_active {
|
||||
@@ -133,19 +128,6 @@ pub fn render_register(
|
||||
button_chunks[1],
|
||||
);
|
||||
|
||||
// --- AUTOCOMPLETE DROPDOWN (Using new canvas suggestions) ---
|
||||
if app_state.current_mode == AppMode::Edit {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
f.area(), // Frame area
|
||||
input_rect, // Current input field rect
|
||||
&DefaultCanvasTheme,
|
||||
&editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- DIALOG ---
|
||||
if app_state.ui.dialog.dialog_show {
|
||||
dialog::render_dialog(
|
||||
@@ -159,4 +141,17 @@ pub fn render_register(
|
||||
app_state.ui.dialog.is_loading,
|
||||
);
|
||||
}
|
||||
|
||||
// Render suggestions dropdown if active (library GUI)
|
||||
if editor.mode() == canvas::AppMode::Edit {
|
||||
if let Some(input_rect) = input_rect {
|
||||
render_suggestions_dropdown(
|
||||
f,
|
||||
f.area(),
|
||||
input_rect,
|
||||
&DefaultCanvasTheme,
|
||||
editor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
// src/pages/routing/router.rs
|
||||
use crate::state::pages::{
|
||||
admin::AdminState,
|
||||
auth::AuthState,
|
||||
add_logic::AddLogicState,
|
||||
add_table::AddTableState,
|
||||
};
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::pages::admin_panel::add_logic::state::AddLogicFormState;
|
||||
use crate::pages::admin_panel::add_table::state::AddTableFormState;
|
||||
use crate::pages::admin::AdminState;
|
||||
use crate::pages::forms::FormState;
|
||||
use crate::pages::login::LoginState;
|
||||
use crate::pages::register::RegisterState;
|
||||
use crate::pages::login::LoginFormState;
|
||||
use crate::pages::register::RegisterFormState;
|
||||
use crate::pages::intro::IntroState;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Page {
|
||||
Intro(IntroState),
|
||||
Login(LoginState),
|
||||
Register(RegisterState),
|
||||
Login(LoginFormState),
|
||||
Register(RegisterFormState),
|
||||
Admin(AdminState),
|
||||
AddLogic(AddLogicState),
|
||||
AddTable(AddTableState),
|
||||
Form(FormState),
|
||||
AddLogic(AddLogicFormState),
|
||||
AddTable(AddTableFormState),
|
||||
Form(String),
|
||||
}
|
||||
|
||||
pub struct Router {
|
||||
|
||||
@@ -34,9 +34,16 @@ pub async fn handle_search_palette_event(
|
||||
// Step 2: Process outside the borrow
|
||||
if let Some((id, content_json)) = maybe_data {
|
||||
if let Ok(data) = serde_json::from_str::<HashMap<String, String>>(&content_json) {
|
||||
if let Some(fs) = app_state.form_state_mut() {
|
||||
let detached_pos = fs.total_count + 2;
|
||||
fs.update_from_response(&data, detached_pos);
|
||||
// Use current view path to access the active form
|
||||
if let (Some(profile), Some(table)) = (
|
||||
app_state.current_view_profile_name.clone(),
|
||||
app_state.current_view_table_name.clone(),
|
||||
) {
|
||||
let path = format!("{}/{}", profile, table);
|
||||
if let Some(fs) = app_state.form_state_for_path(&path) {
|
||||
let detached_pos = fs.total_count + 2;
|
||||
fs.update_from_response(&data, detached_pos);
|
||||
}
|
||||
}
|
||||
should_close = true;
|
||||
outcome_message = Some(format!("Loaded record ID {}", id));
|
||||
@@ -99,7 +106,6 @@ pub async fn handle_search_palette_event(
|
||||
if should_close {
|
||||
app_state.search_state = None;
|
||||
app_state.ui.show_search_palette = false;
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
}
|
||||
|
||||
Ok(outcome_message)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::services::grpc_client::GrpcClient;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
use crate::pages::admin_panel::add_logic::state::AddLogicState;
|
||||
use crate::pages::forms::logic::SaveOutcome;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use crate::pages::forms::{FieldDefinition, FormState};
|
||||
|
||||
@@ -28,7 +28,6 @@ pub struct UiState {
|
||||
pub show_login: bool,
|
||||
pub show_register: bool,
|
||||
pub show_search_palette: bool,
|
||||
pub focus_outside_canvas: bool,
|
||||
pub dialog: DialogState,
|
||||
}
|
||||
|
||||
@@ -52,7 +51,6 @@ pub struct AppState {
|
||||
// NEW: The "Rulebook" cache. We use Arc for efficient sharing.
|
||||
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
|
||||
|
||||
pub focused_button_index: usize,
|
||||
pub pending_table_structure_fetch: Option<(String, String)>,
|
||||
|
||||
pub search_state: Option<SearchState>,
|
||||
@@ -60,7 +58,7 @@ pub struct AppState {
|
||||
// UI preferences
|
||||
pub ui: UiState,
|
||||
|
||||
pub form_editor: Option<FormEditor<FormState>>,
|
||||
pub form_editor: HashMap<String, FormEditor<FormState>>, // key = "profile/table"
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
pub debug_state: Option<DebugState>,
|
||||
@@ -77,11 +75,10 @@ impl AppState {
|
||||
current_view_table_name: None,
|
||||
current_mode: AppMode::General,
|
||||
schema_cache: HashMap::new(), // NEW: Initialize the cache
|
||||
focused_button_index: 0,
|
||||
pending_table_structure_fetch: None,
|
||||
search_state: None,
|
||||
ui: UiState::default(),
|
||||
form_editor: None,
|
||||
form_editor: HashMap::new(),
|
||||
|
||||
#[cfg(feature = "ui-debug")]
|
||||
debug_state: None,
|
||||
@@ -99,27 +96,58 @@ impl AppState {
|
||||
self.current_view_table_name = Some(table_name);
|
||||
}
|
||||
|
||||
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);
|
||||
/// Returns true if the current view's editor is in Edit mode.
|
||||
/// Uses current_view_profile_name/current_view_table_name to build the path.
|
||||
pub fn is_canvas_edit_mode(&self) -> bool {
|
||||
if let (Some(profile), Some(table)) =
|
||||
(self.current_view_profile_name.as_ref(), self.current_view_table_name.as_ref())
|
||||
{
|
||||
let path = format!("{}/{}", profile, table);
|
||||
if let Some(editor) = self.form_editor.get(&path) {
|
||||
return matches!(editor.mode(), canvas::AppMode::Edit);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Replace the current form state and wrap it in a FormEditor with keymap
|
||||
pub fn set_form_state(&mut self, form_state: FormState, config: &Config) {
|
||||
let mut editor = FormEditor::new(form_state);
|
||||
editor.set_keymap(config.build_canvas_keymap());
|
||||
self.form_editor = Some(editor);
|
||||
pub fn is_canvas_edit_mode_at(&self, path: &str) -> bool {
|
||||
self.form_editor
|
||||
.get(path)
|
||||
.map(|e| matches!(e.mode(), canvas::AppMode::Edit))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Immutable access to the underlying FormState
|
||||
pub fn form_state(&self) -> Option<&FormState> {
|
||||
self.form_editor.as_ref().map(|e| e.data_provider())
|
||||
// Mutable editor accessor
|
||||
pub fn editor_for_path(&mut self, path: &str) -> Option<&mut FormEditor<FormState>> {
|
||||
self.form_editor.get_mut(path)
|
||||
}
|
||||
|
||||
/// 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())
|
||||
// Mutable FormState accessor
|
||||
pub fn form_state_for_path(&mut self, path: &str) -> Option<&mut FormState> {
|
||||
self.form_editor
|
||||
.get_mut(path)
|
||||
.map(|e| e.data_provider_mut())
|
||||
}
|
||||
|
||||
// Immutable editor accessor
|
||||
pub fn editor_for_path_ref(&self, path: &str) -> Option<&FormEditor<FormState>> {
|
||||
self.form_editor.get(path)
|
||||
}
|
||||
|
||||
// Immutable FormState accessor
|
||||
pub fn form_state_for_path_ref(&self, path: &str) -> Option<&FormState> {
|
||||
self.form_editor.get(path).map(|e| e.data_provider())
|
||||
}
|
||||
|
||||
pub fn ensure_form_editor<F>(&mut self, path: &str, config: &Config, loader: F)
|
||||
where
|
||||
F: FnOnce() -> FormState,
|
||||
{
|
||||
if !self.form_editor.contains_key(path) {
|
||||
let mut editor = FormEditor::new(loader());
|
||||
editor.set_keymap(config.build_canvas_keymap());
|
||||
self.form_editor.insert(path.to_string(), editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +163,7 @@ impl Default for UiState {
|
||||
show_login: false,
|
||||
show_register: false,
|
||||
show_buffer_list: true,
|
||||
show_search_palette: false, // ADDED
|
||||
focus_outside_canvas: false,
|
||||
show_search_palette: false,
|
||||
dialog: DialogState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// src/state/pages.rs
|
||||
|
||||
pub mod auth;
|
||||
pub mod admin;
|
||||
pub mod add_table;
|
||||
pub mod add_logic;
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
// src/state/pages/admin.rs
|
||||
|
||||
use ratatui::widgets::ListState;
|
||||
use crate::state::pages::add_table::AddTableState;
|
||||
use crate::state::pages::add_logic::AddLogicState;
|
||||
|
||||
// Define the focus states for the admin panel panes
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AdminFocus {
|
||||
#[default] // Default focus is on the profiles list
|
||||
ProfilesPane,
|
||||
InsideProfilesList,
|
||||
Tables,
|
||||
InsideTablesList,
|
||||
Button1,
|
||||
Button2,
|
||||
Button3,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct AdminState {
|
||||
pub profiles: Vec<String>, // Holds profile names (used by non-admin view)
|
||||
pub profile_list_state: ListState, // Tracks navigation highlight (>) in profiles
|
||||
pub table_list_state: ListState, // Tracks navigation highlight (>) in tables
|
||||
pub selected_profile_index: Option<usize>, // Index with [*] in profiles (persistent)
|
||||
pub selected_table_index: Option<usize>, // Index with [*] in tables (persistent)
|
||||
pub current_focus: AdminFocus, // Tracks which pane is focused
|
||||
pub add_table_state: AddTableState,
|
||||
pub add_logic_state: AddLogicState,
|
||||
}
|
||||
|
||||
impl AdminState {
|
||||
/// Gets the index of the currently selected item.
|
||||
pub fn get_selected_index(&self) -> Option<usize> {
|
||||
self.profile_list_state.selected()
|
||||
}
|
||||
|
||||
/// Gets the name of the currently selected profile.
|
||||
pub fn get_selected_profile_name(&self) -> Option<&String> {
|
||||
self.profile_list_state.selected().and_then(|i| self.profiles.get(i))
|
||||
}
|
||||
|
||||
/// Populates the profile list and updates/resets the selection.
|
||||
pub fn set_profiles(&mut self, new_profiles: Vec<String>) {
|
||||
let current_selection_index = self.profile_list_state.selected();
|
||||
self.profiles = new_profiles;
|
||||
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
} else {
|
||||
let new_selection = match current_selection_index {
|
||||
Some(index) => Some(index.min(self.profiles.len() - 1)),
|
||||
None => Some(0),
|
||||
};
|
||||
self.profile_list_state.select(new_selection);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the next profile in the list, wrapping around.
|
||||
pub fn next(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i >= self.profiles.len() - 1 { 0 } else { i + 1 },
|
||||
None => 0,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Selects the previous profile in the list, wrapping around.
|
||||
pub fn previous(&mut self) {
|
||||
if self.profiles.is_empty() {
|
||||
self.profile_list_state.select(None);
|
||||
return;
|
||||
}
|
||||
let i = match self.profile_list_state.selected() {
|
||||
Some(i) => if i == 0 { self.profiles.len() - 1 } else { i - 1 },
|
||||
None => self.profiles.len() - 1,
|
||||
};
|
||||
self.profile_list_state.select(Some(i));
|
||||
}
|
||||
|
||||
/// Gets the index of the currently selected profile.
|
||||
pub fn get_selected_profile_index(&self) -> Option<usize> {
|
||||
self.profile_list_state.selected()
|
||||
}
|
||||
|
||||
/// Gets the index of the currently selected table.
|
||||
pub fn get_selected_table_index(&self) -> Option<usize> {
|
||||
self.table_list_state.selected()
|
||||
}
|
||||
|
||||
/// Selects a profile by index and resets table selection.
|
||||
pub fn select_profile(&mut self, index: Option<usize>) {
|
||||
self.profile_list_state.select(index);
|
||||
self.table_list_state.select(None);
|
||||
}
|
||||
|
||||
/// Selects a table by index.
|
||||
pub fn select_table(&mut self, index: Option<usize>) {
|
||||
self.table_list_state.select(index);
|
||||
}
|
||||
|
||||
/// Selects the next profile, wrapping around.
|
||||
/// `profile_count` should be the total number of profiles available.
|
||||
pub fn next_profile(&mut self, profile_count: usize) {
|
||||
if profile_count == 0 {
|
||||
return;
|
||||
}
|
||||
let i = match self.get_selected_profile_index() {
|
||||
Some(i) => {
|
||||
if i >= profile_count - 1 {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.select_profile(Some(i)); // Use the helper method
|
||||
}
|
||||
|
||||
/// Selects the previous profile, wrapping around.
|
||||
/// `profile_count` should be the total number of profiles available.
|
||||
pub fn previous_profile(&mut self, profile_count: usize) {
|
||||
if profile_count == 0 {
|
||||
return;
|
||||
}
|
||||
let i = match self.get_selected_profile_index() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
profile_count - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0, // Or profile_count - 1 if you prefer wrapping from None
|
||||
};
|
||||
self.select_profile(Some(i)); // Use the helper method
|
||||
}
|
||||
|
||||
/// Selects the next table, wrapping around.
|
||||
/// `table_count` should be the number of tables in the *currently selected* profile.
|
||||
pub fn next_table(&mut self, table_count: usize) {
|
||||
if table_count == 0 {
|
||||
return;
|
||||
}
|
||||
let i = match self.get_selected_table_index() {
|
||||
Some(i) => {
|
||||
if i >= table_count - 1 {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.select_table(Some(i));
|
||||
}
|
||||
|
||||
/// Selects the previous table, wrapping around.
|
||||
/// `table_count` should be the number of tables in the *currently selected* profile.
|
||||
pub fn previous_table(&mut self, table_count: usize) {
|
||||
if table_count == 0 {
|
||||
return;
|
||||
}
|
||||
let i = match self.get_selected_table_index() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
table_count - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0, // Or table_count - 1
|
||||
};
|
||||
self.select_table(Some(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,44 @@
|
||||
|
||||
use canvas::{DataProvider, AppMode};
|
||||
|
||||
/// Strongly typed user roles
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UserRole {
|
||||
Admin,
|
||||
Moderator,
|
||||
Accountant,
|
||||
Viewer,
|
||||
Unknown(String), // fallback for unexpected roles
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"admin" => UserRole::Admin,
|
||||
"moderator" => UserRole::Moderator,
|
||||
"accountant" => UserRole::Accountant,
|
||||
"viewer" => UserRole::Viewer,
|
||||
other => UserRole::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
UserRole::Admin => "admin",
|
||||
UserRole::Moderator => "moderator",
|
||||
UserRole::Accountant => "accountant",
|
||||
UserRole::Viewer => "viewer",
|
||||
UserRole::Unknown(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the authenticated session state
|
||||
#[derive(Default)]
|
||||
pub struct AuthState {
|
||||
pub auth_token: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub role: Option<UserRole>,
|
||||
pub decoded_username: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// src/tui/functions.rs
|
||||
|
||||
pub mod admin;
|
||||
pub mod common;
|
||||
|
||||
pub use admin::*;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/tui/functions/common.rs
|
||||
|
||||
pub mod logout;
|
||||
pub mod add_table;
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
// src/tui/functions/common/add_table.rs
|
||||
use crate::state::pages::add_table::{
|
||||
AddTableFocus, AddTableState, ColumnDefinition, IndexDefinition,
|
||||
};
|
||||
use crate::services::GrpcClient;
|
||||
use anyhow::{anyhow, Result};
|
||||
use common::proto::komp_ac::table_definition::{
|
||||
PostTableDefinitionRequest,
|
||||
ColumnDefinition as ProtoColumnDefinition,
|
||||
TableLink as ProtoTableLink,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Handles the logic for adding a column when the "Add" button is activated.
|
||||
///
|
||||
/// Takes the mutable state and command message string.
|
||||
/// Returns `Some(AddTableFocus)` indicating the desired focus state after a successful add,
|
||||
/// or `None` if the action failed (e.g., validation error).
|
||||
pub fn handle_add_column_action(
|
||||
add_table_state: &mut AddTableState,
|
||||
command_message: &mut String,
|
||||
) -> Option<AddTableFocus> {
|
||||
|
||||
// Trim and create owned Strings from inputs
|
||||
let table_name_in = add_table_state.table_name_input.trim();
|
||||
let column_name_in = add_table_state.column_name_input.trim();
|
||||
let column_type_in = add_table_state.column_type_input.trim();
|
||||
|
||||
// Validate all inputs needed for this combined action
|
||||
let has_table_name = !table_name_in.is_empty();
|
||||
let has_column_name = !column_name_in.is_empty();
|
||||
let has_column_type = !column_type_in.is_empty();
|
||||
|
||||
match (has_table_name, has_column_name, has_column_type) {
|
||||
// Case 1: Both column fields have input (Table name is optional here)
|
||||
(_, true, true) => {
|
||||
let mut msg = String::new();
|
||||
// Optionally update table name if provided
|
||||
if has_table_name {
|
||||
add_table_state.table_name = table_name_in.to_string();
|
||||
msg.push_str(&format!("Table name set to '{}'. ", add_table_state.table_name));
|
||||
}
|
||||
// Add the column
|
||||
let new_column = ColumnDefinition {
|
||||
name: column_name_in.to_string(),
|
||||
data_type: column_type_in.to_string(),
|
||||
selected: false,
|
||||
};
|
||||
add_table_state.columns.push(new_column.clone()); // Clone for msg
|
||||
msg.push_str(&format!("Column '{}' added.", new_column.name));
|
||||
|
||||
// Add corresponding index definition (initially unselected)
|
||||
let new_index = IndexDefinition {
|
||||
name: column_name_in.to_string(),
|
||||
selected: false,
|
||||
};
|
||||
add_table_state.indexes.push(new_index);
|
||||
*command_message = msg;
|
||||
|
||||
// Clear all inputs and reset cursors
|
||||
add_table_state.table_name_input.clear();
|
||||
add_table_state.column_name_input.clear();
|
||||
add_table_state.column_type_input.clear();
|
||||
add_table_state.table_name_cursor_pos = 0;
|
||||
add_table_state.column_name_cursor_pos = 0;
|
||||
add_table_state.column_type_cursor_pos = 0;
|
||||
add_table_state.has_unsaved_changes = true;
|
||||
Some(AddTableFocus::InputColumnName) // Focus for next column
|
||||
}
|
||||
// Case 2: Only one column field has input (Error)
|
||||
(_, true, false) | (_, false, true) => {
|
||||
*command_message = "Both Column Name and Type are required to add a column.".to_string();
|
||||
None // Indicate validation failure
|
||||
}
|
||||
// Case 3: Only Table name has input (No column input)
|
||||
(true, false, false) => {
|
||||
add_table_state.table_name = table_name_in.to_string();
|
||||
*command_message = format!("Table name set to '{}'.", add_table_state.table_name);
|
||||
// Clear only table name input
|
||||
add_table_state.table_name_input.clear();
|
||||
add_table_state.table_name_cursor_pos = 0;
|
||||
add_table_state.has_unsaved_changes = true;
|
||||
Some(AddTableFocus::InputTableName) // Keep focus here
|
||||
}
|
||||
// Case 4: All fields are empty
|
||||
(false, false, false) => {
|
||||
*command_message = "No input provided.".to_string();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles deleting columns marked as selected in the AddTableState.
|
||||
pub fn handle_delete_selected_columns(
|
||||
add_table_state: &mut AddTableState,
|
||||
) -> String {
|
||||
let initial_count = add_table_state.columns.len();
|
||||
// Keep only the columns that are NOT selected
|
||||
let initial_selected_indices: std::collections::HashSet<String> = add_table_state
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|col| col.selected)
|
||||
.map(|col| col.name.clone())
|
||||
.collect();
|
||||
add_table_state.columns.retain(|col| !col.selected);
|
||||
let deleted_count = initial_count - add_table_state.columns.len();
|
||||
|
||||
if deleted_count > 0 {
|
||||
add_table_state.indexes.retain(|index| !initial_selected_indices.contains(&index.name));
|
||||
add_table_state.has_unsaved_changes = true;
|
||||
// Reset selection highlight as indices have changed
|
||||
add_table_state.column_table_state.select(None);
|
||||
// Optionally, select the first item if the list is not empty
|
||||
// if !add_table_state.columns.is_empty() {
|
||||
// add_table_state.column_table_state.select(Some(0));
|
||||
// }
|
||||
add_table_state.index_table_state.select(None);
|
||||
format!("Deleted {} selected column(s).", deleted_count)
|
||||
} else {
|
||||
"No columns marked for deletion.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepares and sends the request to save the new table definition via gRPC.
|
||||
pub async fn handle_save_table_action(
|
||||
grpc_client: &mut GrpcClient,
|
||||
add_table_state: &AddTableState,
|
||||
) -> Result<String> {
|
||||
// --- Basic Validation ---
|
||||
if add_table_state.table_name.is_empty() {
|
||||
return Err(anyhow!("Table name cannot be empty."));
|
||||
}
|
||||
if add_table_state.columns.is_empty() {
|
||||
return Err(anyhow!("Table must have at least one column."));
|
||||
}
|
||||
|
||||
// --- Prepare Proto Data ---
|
||||
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col| ProtoColumnDefinition {
|
||||
name: col.name.clone(),
|
||||
field_type: col.data_type.clone(), // Assuming data_type maps directly
|
||||
})
|
||||
.collect();
|
||||
|
||||
let proto_indexes: Vec<String> = add_table_state
|
||||
.indexes
|
||||
.iter()
|
||||
.filter(|idx| idx.selected) // Only include selected indexes
|
||||
.map(|idx| idx.name.clone())
|
||||
.collect();
|
||||
|
||||
let proto_links: Vec<ProtoTableLink> = add_table_state
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| link.selected) // Only include selected links
|
||||
.map(|link| ProtoTableLink {
|
||||
linked_table_name: link.linked_table_name.clone(),
|
||||
// Assuming 'required' maps directly, adjust if needed
|
||||
// For now, the proto only seems to use linked_table_name based on example
|
||||
// If your proto evolves, map link.is_required here.
|
||||
required: false, // Set based on your proto definition/needs
|
||||
})
|
||||
.collect();
|
||||
|
||||
// --- Create Request ---
|
||||
let request = PostTableDefinitionRequest {
|
||||
table_name: add_table_state.table_name.clone(),
|
||||
columns: proto_columns,
|
||||
indexes: proto_indexes,
|
||||
links: proto_links,
|
||||
profile_name: add_table_state.profile_name.clone(),
|
||||
};
|
||||
|
||||
debug!("Sending PostTableDefinitionRequest: {:?}", request);
|
||||
|
||||
// --- Call gRPC Service ---
|
||||
match grpc_client.post_table_definition(request).await {
|
||||
Ok(response) => {
|
||||
if response.success {
|
||||
Ok(format!(
|
||||
"Table '{}' saved successfully.",
|
||||
add_table_state.table_name
|
||||
))
|
||||
} else {
|
||||
// Use the SQL message from the response if available, otherwise generic error
|
||||
let error_message = if !response.sql.is_empty() {
|
||||
format!("Server failed to save table: {}", response.sql)
|
||||
} else {
|
||||
"Server failed to save table (unknown reason).".to_string()
|
||||
};
|
||||
Err(anyhow!(error_message))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
|
||||
}
|
||||
}
|
||||
@@ -16,32 +16,27 @@ pub fn logout(
|
||||
auth_state.user_id = None;
|
||||
auth_state.role = None;
|
||||
auth_state.decoded_username = None;
|
||||
|
||||
|
||||
// Delete stored auth data
|
||||
if let Err(e) = delete_auth_data() {
|
||||
error!("Failed to delete stored auth data: {}", e);
|
||||
// Continue anyway - user is logged out in memory
|
||||
}
|
||||
|
||||
|
||||
// Navigate to intro screen
|
||||
buffer_state.history = vec![AppView::Intro];
|
||||
buffer_state.active_index = 0;
|
||||
|
||||
// Reset UI state
|
||||
app_state.ui.focus_outside_canvas = false;
|
||||
app_state.focused_button_index = 0;
|
||||
|
||||
|
||||
// Hide any open dialogs
|
||||
app_state.hide_dialog();
|
||||
|
||||
|
||||
// Show logout confirmation dialog
|
||||
app_state.show_dialog(
|
||||
"Logged Out",
|
||||
"You have been successfully logged out.",
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginSuccess, // Reuse or create a new purpose
|
||||
DialogPurpose::LoginSuccess,
|
||||
);
|
||||
|
||||
|
||||
info!("User logged out successfully.");
|
||||
"Logged out successfully".to_string()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crossterm::{
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
cursor::{SetCursorStyle, EnableBlinking, Show, Hide, MoveTo},
|
||||
};
|
||||
use crossterm::ExecutableCommand;
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use std::io::{self, stdout, Write};
|
||||
use anyhow::Result;
|
||||
@@ -81,6 +82,12 @@ impl TerminalCore {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the cursor to a specific (x, y) position on screen.
|
||||
pub fn set_cursor_position(&mut self, x: u16, y: u16) -> io::Result<()> {
|
||||
self.terminal.backend_mut().execute(MoveTo(x, y))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalCore {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// src/ui/handlers/render.rs
|
||||
|
||||
use crate::components::{
|
||||
admin::add_logic::render_add_logic,
|
||||
admin::render_add_table,
|
||||
render_background,
|
||||
};
|
||||
use crate::components::render_background;
|
||||
use crate::pages::admin_panel::add_logic::ui::render_add_logic;
|
||||
use crate::pages::admin_panel::add_table::ui::render_add_table;
|
||||
use crate::pages::login::render_login;
|
||||
use crate::pages::register::render_register;
|
||||
use crate::pages::intro::render_intro;
|
||||
@@ -22,6 +20,7 @@ use crate::buffer::state::BufferState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::bottom_panel::layout::{bottom_panel_constraints, render_bottom_panel};
|
||||
use canvas::FormEditor;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
Frame,
|
||||
@@ -35,7 +34,6 @@ pub fn render_ui(
|
||||
router: &mut Router,
|
||||
buffer_state: &BufferState,
|
||||
theme: &Theme,
|
||||
is_event_handler_edit_mode: bool,
|
||||
event_handler_command_input: &str,
|
||||
event_handler_command_mode_active: bool,
|
||||
event_handler_command_message: &str,
|
||||
@@ -43,6 +41,7 @@ pub fn render_ui(
|
||||
current_dir: &str,
|
||||
current_fps: f64,
|
||||
app_state: &AppState,
|
||||
auth_state: &AuthState,
|
||||
) {
|
||||
render_background(f, f.area(), theme);
|
||||
|
||||
@@ -77,13 +76,12 @@ pub fn render_ui(
|
||||
// Page rendering is now fully router-driven
|
||||
match &mut router.current {
|
||||
Page::Intro(state) => render_intro(f, state, main_content_area, theme),
|
||||
Page::Login(state) => render_login(
|
||||
Page::Login(page) => render_login(
|
||||
f,
|
||||
main_content_area,
|
||||
theme,
|
||||
state,
|
||||
page,
|
||||
app_state,
|
||||
state.current_field() < 2,
|
||||
),
|
||||
Page::Register(state) => render_register(
|
||||
f,
|
||||
@@ -91,12 +89,11 @@ pub fn render_ui(
|
||||
theme,
|
||||
state,
|
||||
app_state,
|
||||
state.current_field() < 4,
|
||||
),
|
||||
Page::Admin(state) => crate::components::admin::admin_panel::render_admin_panel(
|
||||
Page::Admin(state) => crate::pages::admin::main::ui::render_admin_panel(
|
||||
f,
|
||||
app_state,
|
||||
&mut AuthState::default(), // TODO: later move AuthState into Router
|
||||
auth_state,
|
||||
state,
|
||||
main_content_area,
|
||||
theme,
|
||||
@@ -109,7 +106,6 @@ pub fn render_ui(
|
||||
theme,
|
||||
app_state,
|
||||
state,
|
||||
is_event_handler_edit_mode,
|
||||
),
|
||||
Page::AddTable(state) => render_add_table(
|
||||
f,
|
||||
@@ -117,9 +113,8 @@ pub fn render_ui(
|
||||
theme,
|
||||
app_state,
|
||||
state,
|
||||
is_event_handler_edit_mode,
|
||||
),
|
||||
Page::Form(state) => {
|
||||
Page::Form(path) => {
|
||||
let (sidebar_area, form_actual_area) =
|
||||
calculate_sidebar_layout(app_state.ui.show_sidebar, main_content_area);
|
||||
if let Some(sidebar_rect) = sidebar_area {
|
||||
@@ -148,16 +143,25 @@ pub fn render_ui(
|
||||
.split(form_actual_area)[1]
|
||||
};
|
||||
|
||||
render_form_page(
|
||||
f,
|
||||
form_render_area,
|
||||
app_state,
|
||||
state,
|
||||
app_state.current_view_table_name.as_deref().unwrap_or(""),
|
||||
theme,
|
||||
state.total_count,
|
||||
state.current_position,
|
||||
);
|
||||
if let Some(editor) = app_state.editor_for_path_ref(path) {
|
||||
let (total_count, current_position) =
|
||||
if let Some(fs) = app_state.form_state_for_path_ref(path) {
|
||||
(fs.total_count, fs.current_position)
|
||||
} else {
|
||||
(0, 1)
|
||||
};
|
||||
let table_name = path.split('/').nth(1).unwrap_or("");
|
||||
|
||||
render_form_page(
|
||||
f,
|
||||
form_render_area,
|
||||
editor,
|
||||
table_name,
|
||||
theme,
|
||||
total_count,
|
||||
current_position,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,9 +193,9 @@ pub fn render_ui(
|
||||
&mut chunk_idx,
|
||||
current_dir,
|
||||
theme,
|
||||
is_event_handler_edit_mode,
|
||||
current_fps,
|
||||
app_state,
|
||||
router,
|
||||
navigation_state,
|
||||
event_handler_command_input,
|
||||
event_handler_command_mode_active,
|
||||
|
||||
@@ -9,11 +9,17 @@ use crate::modes::common::commands::CommandHandler;
|
||||
use crate::modes::handlers::event::{EventHandler, EventOutcome};
|
||||
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
|
||||
use crate::state::pages::auth::AuthState;
|
||||
use crate::pages::register::RegisterState;
|
||||
use crate::state::pages::admin::AdminState;
|
||||
use crate::state::pages::admin::AdminFocus;
|
||||
use crate::state::pages::auth::UserRole;
|
||||
use crate::pages::login::LoginFormState;
|
||||
use crate::pages::register::RegisterFormState;
|
||||
use crate::pages::admin_panel::add_table;
|
||||
use crate::pages::admin_panel::add_logic;
|
||||
use crate::pages::admin::AdminState;
|
||||
use crate::pages::admin::AdminFocus;
|
||||
use crate::pages::admin::admin;
|
||||
use crate::pages::intro::IntroState;
|
||||
use crate::pages::forms::{FormState, FieldDefinition};
|
||||
use crate::pages::forms;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
use crate::buffer::state::BufferState;
|
||||
use crate::buffer::state::AppView;
|
||||
@@ -28,9 +34,12 @@ use crate::pages::register::RegisterResult;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::utils::columns::filter_user_columns;
|
||||
use canvas::keymap::KeyEventOutcome;
|
||||
use canvas::CursorManager;
|
||||
use canvas::FormEditor;
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::cursor::{SetCursorStyle, MoveTo};
|
||||
use crossterm::event as crossterm_event;
|
||||
use crossterm::ExecutableCommand;
|
||||
use tracing::{error, info, warn};
|
||||
use tokio::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
@@ -65,8 +74,10 @@ pub async fn run_ui() -> Result<()> {
|
||||
let event_reader = EventReader::new();
|
||||
|
||||
let mut auth_state = AuthState::default();
|
||||
let mut login_state = LoginState::default();
|
||||
let mut register_state = RegisterState::default();
|
||||
let mut login_state = LoginFormState::new();
|
||||
login_state.editor.set_keymap(config.build_canvas_keymap());
|
||||
let mut register_state = RegisterFormState::default();
|
||||
register_state.editor.set_keymap(config.build_canvas_keymap());
|
||||
let mut intro_state = IntroState::default();
|
||||
let mut admin_state = AdminState::default();
|
||||
let mut router = Router::new();
|
||||
@@ -78,7 +89,7 @@ pub async fn run_ui() -> Result<()> {
|
||||
Ok(Some(stored_data)) => {
|
||||
auth_state.auth_token = Some(stored_data.access_token);
|
||||
auth_state.user_id = Some(stored_data.user_id);
|
||||
auth_state.role = Some(stored_data.role);
|
||||
auth_state.role = Some(UserRole::from_str(&stored_data.role));
|
||||
auth_state.decoded_username = Some(stored_data.username);
|
||||
auto_logged_in = true;
|
||||
info!("Auth data loaded from file. User is auto-logged in.");
|
||||
@@ -107,13 +118,15 @@ pub async fn run_ui() -> Result<()> {
|
||||
.collect();
|
||||
|
||||
// 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,
|
||||
);
|
||||
let path = format!("{}/{}", initial_profile, initial_table);
|
||||
app_state.ensure_form_editor(&path, &config, || {
|
||||
FormState::new(initial_profile.clone(), initial_table.clone(), initial_field_defs)
|
||||
});
|
||||
buffer_state.update_history(AppView::Form(path.clone()));
|
||||
router.navigate(Page::Form(path.clone()));
|
||||
|
||||
// Fetch initial count using app_state accessor
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
if let Some(form_state) = app_state.form_state_for_path(&path) {
|
||||
UiService::fetch_and_set_table_count(&mut grpc_client, form_state)
|
||||
.await
|
||||
.context(format!(
|
||||
@@ -131,7 +144,9 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
|
||||
if auto_logged_in {
|
||||
buffer_state.history = vec![AppView::Form];
|
||||
let path = format!("{}/{}", initial_profile, initial_table);
|
||||
buffer_state.history = vec![AppView::Form(path.clone())];
|
||||
router.navigate(Page::Form(path));
|
||||
buffer_state.active_index = 0;
|
||||
info!("Initial view set to Form due to auto-login.");
|
||||
}
|
||||
@@ -144,9 +159,11 @@ pub async fn run_ui() -> Result<()> {
|
||||
let mut table_just_switched = false;
|
||||
|
||||
loop {
|
||||
let position_before_event = app_state.form_state()
|
||||
.map(|fs| fs.current_position)
|
||||
.unwrap_or(1);
|
||||
let position_before_event = if let Page::Form(path) = &router.current {
|
||||
app_state.form_state_for_path(path).map(|fs| fs.current_position).unwrap_or(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let mut event_processed = false;
|
||||
|
||||
// --- CHANNEL RECEIVERS ---
|
||||
@@ -171,16 +188,18 @@ pub async fn run_ui() -> Result<()> {
|
||||
// --- ADDED: For live form autocomplete ---
|
||||
match event_handler.autocomplete_result_receiver.try_recv() {
|
||||
Ok(hits) => {
|
||||
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;
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(form_state) = app_state.form_state_for_path(path) {
|
||||
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;
|
||||
@@ -198,34 +217,53 @@ pub async fn run_ui() -> Result<()> {
|
||||
let event = event_reader.read_event().context("Failed to read terminal event")?;
|
||||
event_processed = true;
|
||||
|
||||
// Decouple Command Line and palettes from canvas:
|
||||
// Only forward keys to Form canvas when:
|
||||
// - not in command mode
|
||||
// - no search/palette active
|
||||
// - focus is inside the canvas
|
||||
if let crossterm_event::Event::Key(key_event) = &event {
|
||||
if let Page::Form(_) = &router.current {
|
||||
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
|
||||
let overlay_active = event_handler.command_mode
|
||||
|| app_state.ui.show_search_palette
|
||||
|| event_handler.navigation_state.active;
|
||||
if !overlay_active {
|
||||
let inside_canvas = match &router.current {
|
||||
Page::Form(_) => true,
|
||||
Page::Login(state) => !state.focus_outside_canvas,
|
||||
Page::Register(state) => !state.focus_outside_canvas,
|
||||
Page::AddTable(state) => !state.focus_outside_canvas,
|
||||
Page::AddLogic(state) => !state.focus_outside_canvas,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if inside_canvas {
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(editor) = app_state.editor_for_path(path) {
|
||||
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();
|
||||
|
||||
// Call handle_event directly
|
||||
let event_outcome_result = event_handler.handle_event(
|
||||
event,
|
||||
&config,
|
||||
@@ -250,20 +288,22 @@ 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 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;
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(mut temp_form_state) = app_state.form_state_for_path(path).cloned() {
|
||||
if let Err(e) = UiService::handle_save_outcome(
|
||||
save_outcome,
|
||||
&mut grpc_client,
|
||||
&mut app_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_for_path(path) {
|
||||
*form_state = temp_form_state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EventOutcome::ButtonSelected { .. } => {}
|
||||
@@ -274,7 +314,7 @@ pub async fn run_ui() -> Result<()> {
|
||||
let table_name = parts[1].to_string();
|
||||
|
||||
app_state.set_current_view_table(profile_name, table_name);
|
||||
buffer_state.update_history(AppView::Form);
|
||||
buffer_state.update_history(AppView::Form(path.clone()));
|
||||
event_handler.command_message = format!("Loading table: {}", path);
|
||||
} else {
|
||||
event_handler.command_message = format!("Invalid table path: {}", path);
|
||||
@@ -292,9 +332,19 @@ pub async fn run_ui() -> Result<()> {
|
||||
|
||||
match login_result_receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
|
||||
needs_redraw = true;
|
||||
}
|
||||
// Apply result to the active router Login page if present,
|
||||
// otherwise update the local copy.
|
||||
let updated = if let Page::Login(page) = &mut router.current {
|
||||
login::handle_login_result(
|
||||
result,
|
||||
&mut app_state,
|
||||
&mut auth_state,
|
||||
page,
|
||||
)
|
||||
} else {
|
||||
login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state)
|
||||
};
|
||||
if updated { needs_redraw = true; }
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
@@ -325,7 +375,9 @@ pub async fn run_ui() -> Result<()> {
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::SaveTableSuccess,
|
||||
);
|
||||
admin_state.add_table_state.has_unsaved_changes = false;
|
||||
if let Page::AddTable(page) = &mut router.current {
|
||||
page.state.has_unsaved_changes = false;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
event_handler.command_message = format!("Save failed: {}", e);
|
||||
@@ -341,54 +393,78 @@ pub async fn run_ui() -> Result<()> {
|
||||
|
||||
if let Some(active_view) = buffer_state.get_active_view() {
|
||||
match active_view {
|
||||
AppView::Intro => router.navigate(Page::Intro(intro_state.clone())),
|
||||
AppView::Login => router.navigate(Page::Login(login_state.clone())),
|
||||
AppView::Register => router.navigate(Page::Register(register_state.clone())),
|
||||
AppView::Intro => {
|
||||
// Keep external intro_state in sync with the live Router state
|
||||
if let Page::Intro(current) = &router.current {
|
||||
intro_state = current.clone();
|
||||
}
|
||||
// Navigate with the up-to-date state
|
||||
router.navigate(Page::Intro(intro_state.clone()));
|
||||
}
|
||||
AppView::Login => {
|
||||
// Do not re-create the page every frame. If we're already on Login,
|
||||
// keep it. If we just switched into Login, create it once and
|
||||
// inject the keymap.
|
||||
if let Page::Login(_) = &router.current {
|
||||
// Already on login page; keep existing state
|
||||
} else {
|
||||
let mut page = LoginFormState::new();
|
||||
page.editor.set_keymap(config.build_canvas_keymap());
|
||||
router.navigate(Page::Login(page));
|
||||
}
|
||||
}
|
||||
AppView::Register => {
|
||||
if let Page::Register(_) = &router.current {
|
||||
// already on register page
|
||||
} else {
|
||||
let mut page = RegisterFormState::new();
|
||||
page.editor.set_keymap(config.build_canvas_keymap());
|
||||
router.navigate(Page::Register(page));
|
||||
}
|
||||
}
|
||||
AppView::Admin => {
|
||||
info!("Active view is Admin, refreshing profile tree...");
|
||||
match grpc_client.get_profile_tree().await {
|
||||
Ok(refreshed_tree) => {
|
||||
app_state.profile_tree = refreshed_tree;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to refresh profile tree for Admin panel: {}", e);
|
||||
event_handler.command_message =
|
||||
format!("Error refreshing admin data: {}", e);
|
||||
}
|
||||
if let Page::Admin(current) = &router.current {
|
||||
admin_state = current.clone();
|
||||
}
|
||||
let profile_names = app_state.profile_tree.profiles.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
admin_state.set_profiles(profile_names);
|
||||
info!("Auth role at render: {:?}", auth_state.role);
|
||||
|
||||
if admin_state.current_focus == AdminFocus::default()
|
||||
|| !matches!(admin_state.current_focus,
|
||||
AdminFocus::InsideProfilesList |
|
||||
AdminFocus::Tables | AdminFocus::InsideTablesList |
|
||||
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3)
|
||||
{
|
||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
||||
}
|
||||
if admin_state.profile_list_state.selected().is_none()
|
||||
&& !app_state.profile_tree.profiles.is_empty()
|
||||
{
|
||||
admin_state.profile_list_state.select(Some(0));
|
||||
// Use the admin loader instead of inline logic
|
||||
if let Err(e) = admin::loader::refresh_admin_state(&mut grpc_client, &mut app_state, &mut admin_state).await {
|
||||
error!("Failed to refresh admin state: {}", e);
|
||||
event_handler.command_message = format!("Error refreshing admin data: {}", e);
|
||||
}
|
||||
|
||||
router.navigate(Page::Admin(admin_state.clone()));
|
||||
}
|
||||
AppView::AddTable => router.navigate(Page::AddTable(admin_state.add_table_state.clone())),
|
||||
AppView::AddLogic => router.navigate(Page::AddLogic(admin_state.add_logic_state.clone())),
|
||||
AppView::Form => {
|
||||
if let Some(form_state) = app_state.form_state().cloned() {
|
||||
router.navigate(Page::Form(form_state));
|
||||
AppView::AddTable => {
|
||||
if let Page::AddTable(page) = &mut router.current {
|
||||
// Ensure keymap is set once (same as AddLogic)
|
||||
page.editor.set_keymap(config.build_canvas_keymap());
|
||||
} else {
|
||||
// Page is created by admin navigation (Button2). No-op here.
|
||||
}
|
||||
}
|
||||
AppView::AddLogic => {
|
||||
if let Page::AddLogic(page) = &mut router.current {
|
||||
// Ensure keymap is set once
|
||||
page.editor.set_keymap(config.build_canvas_keymap());
|
||||
}
|
||||
}
|
||||
AppView::Form(path) => {
|
||||
// Keep current_view_* consistent with the active buffer path
|
||||
if let Some((profile, table)) = path.split_once('/') {
|
||||
app_state.set_current_view_table(
|
||||
profile.to_string(),
|
||||
table.to_string(),
|
||||
);
|
||||
}
|
||||
router.navigate(Page::Form(path.clone()));
|
||||
}
|
||||
AppView::Scratch => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Page::Form(_) = &router.current {
|
||||
if let Page::Form(_current_path) = &router.current {
|
||||
let current_view_profile = app_state.current_view_profile_name.clone();
|
||||
let current_view_table = app_state.current_view_table_name.clone();
|
||||
|
||||
@@ -404,51 +480,16 @@ pub async fn run_ui() -> Result<()> {
|
||||
);
|
||||
needs_redraw = true;
|
||||
|
||||
match UiService::load_table_view(
|
||||
// DELEGATE to the forms loader
|
||||
match forms::loader::ensure_form_loaded_and_count(
|
||||
&mut grpc_client,
|
||||
&mut app_state,
|
||||
&config,
|
||||
prof_name,
|
||||
tbl_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
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,
|
||||
form_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
app_state.update_dialog_content(
|
||||
&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();
|
||||
}
|
||||
}
|
||||
|
||||
).await {
|
||||
Ok(()) => {
|
||||
app_state.hide_dialog();
|
||||
prev_view_profile_name = current_view_profile;
|
||||
prev_view_table_name = current_view_table;
|
||||
table_just_switched = true;
|
||||
@@ -459,10 +500,9 @@ pub async fn run_ui() -> Result<()> {
|
||||
vec!["OK".to_string()],
|
||||
DialogPurpose::LoginFailed,
|
||||
);
|
||||
app_state.current_view_profile_name =
|
||||
prev_view_profile_name.clone();
|
||||
app_state.current_view_table_name =
|
||||
prev_view_table_name.clone();
|
||||
// Reset to previous state on error
|
||||
app_state.current_view_profile_name = prev_view_profile_name.clone();
|
||||
app_state.current_view_table_name = prev_view_table_name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,83 +510,43 @@ 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.
|
||||
let needs_redraw_from_fetch = add_logic::loader::process_pending_table_structure_fetch(
|
||||
&mut app_state,
|
||||
&mut router,
|
||||
&mut grpc_client,
|
||||
&mut event_handler.command_message,
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
|
||||
if let Page::AddLogic(state) = &mut router.current {
|
||||
if state.profile_name == profile_name
|
||||
&& state.selected_table_name.as_deref() == Some(table_name.as_str())
|
||||
{
|
||||
info!("Fetching table structure for {}.{}", profile_name, table_name);
|
||||
let fetch_message = UiService::initialize_add_logic_table_data(
|
||||
&mut grpc_client,
|
||||
state,
|
||||
&app_state.profile_tree,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Error initializing add_logic_table_data: {}", e);
|
||||
format!("Error fetching table structure: {}", e)
|
||||
});
|
||||
|
||||
if !fetch_message.contains("Error") && !fetch_message.contains("Warning") {
|
||||
info!("{}", fetch_message);
|
||||
} else {
|
||||
event_handler.command_message = fetch_message;
|
||||
}
|
||||
needs_redraw = true;
|
||||
} else {
|
||||
error!(
|
||||
"Mismatch in pending_table_structure_fetch: app_state wants {}.{}, but AddLogic state is for {}.{:?}",
|
||||
profile_name,
|
||||
table_name,
|
||||
state.profile_name,
|
||||
state.selected_table_name
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Pending table structure fetch for {}.{} but AddLogic view is not active. Fetch ignored.",
|
||||
profile_name, table_name
|
||||
);
|
||||
}
|
||||
if needs_redraw_from_fetch {
|
||||
needs_redraw = true;
|
||||
}
|
||||
|
||||
if let Page::AddLogic(state) = &mut router.current {
|
||||
if let Some(table_name) = state.script_editor_awaiting_column_autocomplete.clone() {
|
||||
let profile_name = state.profile_name.clone();
|
||||
let needs_redraw_from_columns = add_logic::loader::maybe_fetch_columns_for_awaiting_table(
|
||||
&mut grpc_client,
|
||||
state,
|
||||
&mut event_handler.command_message,
|
||||
).await.unwrap_or(false);
|
||||
|
||||
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
|
||||
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
|
||||
Ok(columns) => {
|
||||
state.set_columns_for_table_autocomplete(columns.clone());
|
||||
info!("Loaded {} columns for table '{}'", columns.len(), table_name);
|
||||
event_handler.command_message = format!("Columns for '{}' loaded. Select a column.", table_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
|
||||
state.script_editor_awaiting_column_autocomplete = None;
|
||||
state.deactivate_script_editor_autocomplete();
|
||||
event_handler.command_message = format!("Error loading columns for '{}': {}", table_name, e);
|
||||
}
|
||||
}
|
||||
if needs_redraw_from_columns {
|
||||
needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
let current_position = app_state.form_state()
|
||||
.map(|fs| fs.current_position)
|
||||
.unwrap_or(1);
|
||||
let current_position = if let Page::Form(path) = &router.current {
|
||||
app_state.form_state_for_path(path).map(|fs| fs.current_position).unwrap_or(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let position_changed = current_position != position_before_event;
|
||||
let mut position_logic_needs_redraw = false;
|
||||
|
||||
if let Page::Form(form_state) = &mut router.current {
|
||||
if let Page::Form(path) = &router.current {
|
||||
if !table_just_switched {
|
||||
if position_changed && !event_handler.is_edit_mode {
|
||||
if position_changed && !app_state.is_canvas_edit_mode_at(path) {
|
||||
position_logic_needs_redraw = true;
|
||||
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
if let Some(form_state) = app_state.form_state_for_path(path) {
|
||||
if form_state.current_position > form_state.total_count {
|
||||
form_state.reset_to_empty();
|
||||
event_handler.command_message = format!(
|
||||
@@ -581,8 +581,8 @@ pub async fn run_ui() -> Result<()> {
|
||||
form_state.current_cursor_pos =
|
||||
event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
}
|
||||
} else if !position_changed && !event_handler.is_edit_mode {
|
||||
if let Some(form_state) = app_state.form_state_mut() {
|
||||
} else if !position_changed && !app_state.is_canvas_edit_mode_at(path) {
|
||||
if let Some(form_state) = app_state.form_state_for_path(path) {
|
||||
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 {
|
||||
@@ -596,20 +596,18 @@ pub async fn run_ui() -> Result<()> {
|
||||
}
|
||||
}
|
||||
} else if let Page::Register(state) = &mut router.current {
|
||||
if !event_handler.is_edit_mode {
|
||||
if !app_state.is_canvas_edit_mode() {
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos =
|
||||
if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
|
||||
state.current_cursor_pos =
|
||||
event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(event_handler.ideal_cursor_column.min(max_cursor_pos));
|
||||
}
|
||||
} else if let Page::Login(state) = &mut router.current {
|
||||
if !event_handler.is_edit_mode {
|
||||
if !app_state.is_canvas_edit_mode() {
|
||||
let current_input = state.get_current_input();
|
||||
let max_cursor_pos =
|
||||
if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
|
||||
state.current_cursor_pos =
|
||||
event_handler.ideal_cursor_column.min(max_cursor_pos);
|
||||
state.set_current_cursor_pos(event_handler.ideal_cursor_column.min(max_cursor_pos));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,53 +639,68 @@ pub async fn run_ui() -> Result<()> {
|
||||
|
||||
if event_processed || needs_redraw || position_changed {
|
||||
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &router);
|
||||
|
||||
match current_mode {
|
||||
AppMode::Edit => { terminal.show_cursor()?; }
|
||||
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
|
||||
AppMode::ReadOnly => {
|
||||
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
|
||||
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
|
||||
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
|
||||
}
|
||||
AppMode::General => {
|
||||
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
|
||||
else { terminal.hide_cursor()?; }
|
||||
let outside_canvas = match &router.current {
|
||||
Page::Login(state) => state.focus_outside_canvas,
|
||||
Page::Register(state) => state.focus_outside_canvas,
|
||||
Page::AddTable(state) => state.focus_outside_canvas,
|
||||
Page::AddLogic(state) => state.focus_outside_canvas,
|
||||
_ => false, // Form and Admin don’t use this flag
|
||||
};
|
||||
|
||||
if outside_canvas {
|
||||
// Outside canvas → app decides
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
|
||||
terminal.show_cursor()?;
|
||||
} else {
|
||||
// Inside canvas → let canvas handle it
|
||||
if let Page::Form(path) = &router.current {
|
||||
if let Some(editor) = app_state.editor_for_path(path) {
|
||||
let _ = CursorManager::update_for_mode(editor.mode());
|
||||
}
|
||||
}
|
||||
if let Page::Login(page) = &router.current {
|
||||
let _ = CursorManager::update_for_mode(page.editor.mode());
|
||||
}
|
||||
if let Page::Register(page) = &router.current {
|
||||
let _ = CursorManager::update_for_mode(page.editor.mode());
|
||||
}
|
||||
if let Page::AddTable(page) = &router.current {
|
||||
let _ = CursorManager::update_for_mode(page.editor.mode());
|
||||
}
|
||||
if let Page::AddLogic(page) = &router.current {
|
||||
let _ = CursorManager::update_for_mode(page.editor.mode());
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMode::Command => {
|
||||
// Command line overlay → app decides
|
||||
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
|
||||
terminal.show_cursor()?;
|
||||
}
|
||||
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 router,
|
||||
&buffer_state,
|
||||
&theme,
|
||||
event_handler.is_edit_mode,
|
||||
&event_handler.command_input,
|
||||
event_handler.command_mode,
|
||||
&event_handler.command_message,
|
||||
&event_handler.navigation_state,
|
||||
¤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")?;
|
||||
terminal
|
||||
.draw(|f| {
|
||||
render_ui(
|
||||
f,
|
||||
&mut router,
|
||||
&buffer_state,
|
||||
&theme,
|
||||
&event_handler.command_input,
|
||||
event_handler.command_mode,
|
||||
&event_handler.command_message,
|
||||
&event_handler.navigation_state,
|
||||
¤t_dir,
|
||||
current_fps,
|
||||
&app_state,
|
||||
&auth_state,
|
||||
);
|
||||
})
|
||||
.context("Terminal draw call failed")?;
|
||||
needs_redraw = false;
|
||||
}
|
||||
|
||||
|
||||
0
client/ui.rs
Normal file
0
client/ui.rs
Normal file
Reference in New Issue
Block a user