Remove inlined client crate (moved to separate repository)
This commit is contained in:
2
client/.gitignore
vendored
2
client/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
canvas_config.toml.txt
|
|
||||||
ui_debug.log
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "client"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
async-trait = "0.1.88"
|
|
||||||
common = { path = "../common" }
|
|
||||||
canvas = { path = "../canvas", features = ["gui", "suggestions", "cursor-style", "keymap", "validation"] }
|
|
||||||
|
|
||||||
ratatui = { workspace = true }
|
|
||||||
crossterm = { workspace = true }
|
|
||||||
prost-types = { workspace = true }
|
|
||||||
dirs = "6.0.0"
|
|
||||||
dotenvy = "0.15.7"
|
|
||||||
lazy_static = "1.5.0"
|
|
||||||
prost = "0.13.5"
|
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
|
||||||
serde_json = "1.0.140"
|
|
||||||
time = "0.3.41"
|
|
||||||
tokio = { version = "1.44.2", features = ["full", "macros"] }
|
|
||||||
toml = { workspace = true }
|
|
||||||
tonic = "0.13.0"
|
|
||||||
tracing = "0.1.41"
|
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
|
||||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
|
||||||
unicode-segmentation = "1.12.0"
|
|
||||||
unicode-width.workspace = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["validation"]
|
|
||||||
ui-debug = []
|
|
||||||
validation = []
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
rstest = "0.25.0"
|
|
||||||
tokio-test = "0.4.4"
|
|
||||||
uuid = { version = "1.17.0", features = ["v4"] }
|
|
||||||
futures = "0.3.31"
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# config.toml
|
|
||||||
[keybindings]
|
|
||||||
|
|
||||||
enter_command_mode = [":", "ctrl+;"]
|
|
||||||
next_buffer = ["space+b+n"]
|
|
||||||
previous_buffer = ["space+b+p"]
|
|
||||||
close_buffer = ["space+b+d"]
|
|
||||||
revert = ["space+b+r"]
|
|
||||||
|
|
||||||
[keybindings.general]
|
|
||||||
up = ["k", "Up"]
|
|
||||||
down = ["j", "Down"]
|
|
||||||
left = ["h", "Left"]
|
|
||||||
right = ["l", "Right"]
|
|
||||||
next = ["Tab"]
|
|
||||||
previous = ["Shift+Tab"]
|
|
||||||
select = ["Enter"]
|
|
||||||
esc = ["esc"]
|
|
||||||
open_search = ["ctrl+f"]
|
|
||||||
|
|
||||||
[keybindings.common]
|
|
||||||
save = ["ctrl+s"]
|
|
||||||
quit = ["ctrl+q"]
|
|
||||||
|
|
||||||
force_quit = ["ctrl+shift+q"]
|
|
||||||
save_and_quit = ["ctrl+shift+s"]
|
|
||||||
move_up = ["Up"]
|
|
||||||
move_down = ["Down"]
|
|
||||||
toggle_sidebar = ["ctrl+t"]
|
|
||||||
toggle_buffer_list = ["ctrl+b"]
|
|
||||||
|
|
||||||
# MODE SPECIFIC
|
|
||||||
# READ ONLY MODE
|
|
||||||
[keybindings.read_only]
|
|
||||||
enter_edit_mode_before = ["i"]
|
|
||||||
enter_edit_mode_after = ["a"]
|
|
||||||
previous_entry = ["left","q"]
|
|
||||||
next_entry = ["right","1"]
|
|
||||||
|
|
||||||
enter_highlight_mode = ["v"]
|
|
||||||
enter_highlight_mode_linewise = ["shift+v"]
|
|
||||||
|
|
||||||
### AUTOGENERATED CANVAS CONFIG
|
|
||||||
# Required
|
|
||||||
move_up = ["k", "Up"]
|
|
||||||
move_left = ["h", "Left"]
|
|
||||||
move_right = ["l", "Right"]
|
|
||||||
move_down = ["j", "Down"]
|
|
||||||
# Optional
|
|
||||||
move_line_end = ["$"]
|
|
||||||
move_word_next = ["w"]
|
|
||||||
next_field = ["Tab"]
|
|
||||||
move_word_prev = ["b"]
|
|
||||||
move_word_end = ["e"]
|
|
||||||
move_last_line = ["shift+g"]
|
|
||||||
move_word_end_prev = ["ge"]
|
|
||||||
move_line_start = ["0"]
|
|
||||||
move_first_line = ["g+g"]
|
|
||||||
prev_field = ["Shift+Tab"]
|
|
||||||
|
|
||||||
[keybindings.highlight]
|
|
||||||
exit_highlight_mode = ["esc"]
|
|
||||||
enter_highlight_mode_linewise = ["shift+v"]
|
|
||||||
|
|
||||||
### AUTOGENERATED CANVAS CONFIG
|
|
||||||
# Required
|
|
||||||
move_left = ["h", "Left"]
|
|
||||||
move_right = ["l", "Right"]
|
|
||||||
move_up = ["k", "Up"]
|
|
||||||
move_down = ["j", "Down"]
|
|
||||||
# Optional
|
|
||||||
move_word_next = ["w"]
|
|
||||||
move_line_start = ["0"]
|
|
||||||
move_line_end = ["$"]
|
|
||||||
move_word_prev = ["b"]
|
|
||||||
move_word_end = ["e"]
|
|
||||||
|
|
||||||
|
|
||||||
[keybindings.edit]
|
|
||||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
|
||||||
# exit_edit_mode = ["esc","ctrl+e"]
|
|
||||||
# exit_suggestion_mode = ["esc"]
|
|
||||||
# select_suggestion = ["enter"]
|
|
||||||
# next_field = ["enter"]
|
|
||||||
enter_decider = ["enter"]
|
|
||||||
exit = ["esc", "ctrl+e"]
|
|
||||||
suggestion_down = ["ctrl+n", "tab"]
|
|
||||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
|
||||||
|
|
||||||
### AUTOGENERATED CANVAS CONFIG
|
|
||||||
# Required
|
|
||||||
move_right = ["Right"]
|
|
||||||
delete_char_backward = ["Backspace"]
|
|
||||||
next_field = ["Tab", "Enter"]
|
|
||||||
move_up = ["Up"]
|
|
||||||
move_down = ["Down"]
|
|
||||||
prev_field = ["Shift+Tab"]
|
|
||||||
move_left = ["Left"]
|
|
||||||
# Optional
|
|
||||||
move_last_line = ["Ctrl+End"]
|
|
||||||
delete_char_forward = ["Delete"]
|
|
||||||
move_word_prev = ["Ctrl+Left"]
|
|
||||||
# move_word_end = ["e"]
|
|
||||||
# move_word_end_prev = ["ge"]
|
|
||||||
move_first_line = ["Ctrl+Home"]
|
|
||||||
move_word_next = ["Ctrl+Right"]
|
|
||||||
move_line_start = ["Home"]
|
|
||||||
move_line_end = ["End"]
|
|
||||||
|
|
||||||
[keybindings.command]
|
|
||||||
exit_command_mode = ["ctrl+g", "esc"]
|
|
||||||
command_execute = ["enter"]
|
|
||||||
command_backspace = ["backspace"]
|
|
||||||
save = ["w"]
|
|
||||||
quit = ["q"]
|
|
||||||
force_quit = ["q!"]
|
|
||||||
save_and_quit = ["wq"]
|
|
||||||
revert = ["r"]
|
|
||||||
find_file_palette_toggle = ["ff"]
|
|
||||||
|
|
||||||
[editor]
|
|
||||||
keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
|
||||||
|
|
||||||
[colors]
|
|
||||||
theme = "dark"
|
|
||||||
# Options: "light", "dark", "high_contrast"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
## How Canvas Library Custom Functionality Works
|
|
||||||
|
|
||||||
### 1. **The Canvas Library Calls YOUR Custom Code First**
|
|
||||||
|
|
||||||
When you call `ActionDispatcher::dispatch()`, here's what happens:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Inside canvas library (canvas/src/actions/edit.rs):
|
|
||||||
pub async fn execute_canvas_action<S: CanvasState>(
|
|
||||||
action: CanvasAction,
|
|
||||||
state: &mut S,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
) -> Result<ActionResult> {
|
|
||||||
// 1. FIRST: Canvas library calls YOUR custom handler
|
|
||||||
if let Some(result) = state.handle_feature_action(&action, &context) {
|
|
||||||
return Ok(ActionResult::HandledByFeature(result)); // YOUR code handled it
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ONLY IF your code returns None: Canvas handles generic actions
|
|
||||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Your Extension Point: `handle_feature_action`**
|
|
||||||
|
|
||||||
You add custom functionality by implementing `handle_feature_action` in your states:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// In src/state/pages/auth.rs
|
|
||||||
impl CanvasState for LoginState {
|
|
||||||
// ... other methods ...
|
|
||||||
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
// Custom login-specific actions
|
|
||||||
CanvasAction::Custom(action_str) if action_str == "submit_login" => {
|
|
||||||
if self.username.is_empty() || self.password.is_empty() {
|
|
||||||
Some("Please fill in all required fields".to_string())
|
|
||||||
} else {
|
|
||||||
// Trigger login process
|
|
||||||
Some(format!("Logging in user: {}", self.username))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
|
|
||||||
self.username.clear();
|
|
||||||
self.password.clear();
|
|
||||||
self.set_has_unsaved_changes(false);
|
|
||||||
Some("Login form cleared".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom behavior for standard actions
|
|
||||||
CanvasAction::NextField => {
|
|
||||||
// Custom validation when moving between fields
|
|
||||||
if self.current_field == 0 && self.username.is_empty() {
|
|
||||||
Some("Username cannot be empty".to_string())
|
|
||||||
} else {
|
|
||||||
None // Let canvas library handle the normal field movement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let canvas library handle everything else
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Multiple Ways to Add Custom Functionality**
|
|
||||||
|
|
||||||
#### A) **Custom Actions via Config**
|
|
||||||
```toml
|
|
||||||
# In config.toml
|
|
||||||
[keybindings.edit]
|
|
||||||
submit_login = ["ctrl+enter"]
|
|
||||||
clear_form = ["ctrl+r"]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### B) **Override Standard Actions**
|
|
||||||
```rust
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::InsertChar('p') if self.current_field == 1 => {
|
|
||||||
// Custom behavior when typing 'p' in password field
|
|
||||||
Some("Password field - use secure input".to_string())
|
|
||||||
}
|
|
||||||
_ => None, // Let canvas handle normally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### C) **Context-Aware Logic**
|
|
||||||
```rust
|
|
||||||
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
|
||||||
match action {
|
|
||||||
CanvasAction::MoveDown => {
|
|
||||||
// Custom logic based on current state
|
|
||||||
if context.current_field == 1 && context.current_input.len() < 8 {
|
|
||||||
Some("Password should be at least 8 characters".to_string())
|
|
||||||
} else {
|
|
||||||
None // Normal field movement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## The Canvas Library Philosophy
|
|
||||||
|
|
||||||
**Canvas Library = Generic behavior + Your extension points**
|
|
||||||
|
|
||||||
- ✅ **Canvas handles**: Character insertion, cursor movement, field navigation, etc.
|
|
||||||
- ✅ **You handle**: Validation, submission, clearing, app-specific logic
|
|
||||||
- ✅ **You decide**: Return `Some(message)` to override, `None` to use canvas default
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
You **don't communicate with the library elsewhere**. Instead:
|
|
||||||
|
|
||||||
1. **Canvas library calls your code first** via `handle_feature_action`
|
|
||||||
2. **Your code decides** whether to handle the action or let canvas handle it
|
|
||||||
3. **Canvas library handles** generic form behavior when you return `None`
|
|
||||||
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
client/
|
|
||||||
├── Cargo.toml
|
|
||||||
├── config.toml
|
|
||||||
└── src/
|
|
||||||
├── main.rs # Entry point with minimal code
|
|
||||||
├── lib.rs # Core exports
|
|
||||||
├── app.rs # Application lifecycle and main loop
|
|
||||||
│
|
|
||||||
├── ui/ # UI components and rendering
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── theme.rs # Theme definitions (from colors.rs)
|
|
||||||
│ ├── layout.rs # Layout definitions
|
|
||||||
│ ├── render.rs # Main render coordinator
|
|
||||||
│ └── components/ # UI components
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── command_line.rs
|
|
||||||
│ ├── form.rs
|
|
||||||
│ ├── preview_card.rs
|
|
||||||
│ └── status_line.rs
|
|
||||||
│
|
|
||||||
├── input/ # Input handling
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── handler.rs # Main input handler (lightweight coordinator)
|
|
||||||
│ ├── commands.rs # Command processing
|
|
||||||
│ ├── navigation.rs # Navigation between entries and fields
|
|
||||||
│ └── edit.rs # Edit mode logic
|
|
||||||
│
|
|
||||||
├── editor/ # Text editing functionality
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── cursor.rs # Cursor movement
|
|
||||||
│ └── text.rs # Text manipulation (word movements, etc.)
|
|
||||||
│
|
|
||||||
├── state/ # Application state
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── app_state.rs # Main application state
|
|
||||||
│ └── form_state.rs # Form state
|
|
||||||
│
|
|
||||||
├── model/ # Data models
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ └── entry.rs # Entry model with business logic
|
|
||||||
│
|
|
||||||
├── service/ # External services
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── terminal.rs # Terminal setup and management
|
|
||||||
│ └── grpc.rs # gRPC client (extracted from terminal.rs)
|
|
||||||
│
|
|
||||||
└── config/ # Configuration
|
|
||||||
├── mod.rs
|
|
||||||
└── keybindings.rs # Keybinding definitions and matching
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// src/components/common/command_line.rs
|
|
||||||
|
|
||||||
use ratatui::{
|
|
||||||
widgets::{Block, Paragraph},
|
|
||||||
style::Style,
|
|
||||||
layout::Rect,
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use unicode_width::UnicodeWidthStr; // Import for width calculation
|
|
||||||
|
|
||||||
pub fn render_command_line(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
input: &str, // This is event_handler.command_input
|
|
||||||
active: bool, // This is event_handler.command_mode
|
|
||||||
theme: &Theme,
|
|
||||||
message: &str, // This is event_handler.command_message
|
|
||||||
) {
|
|
||||||
// Original logic for determining display_text
|
|
||||||
let display_text = if !active {
|
|
||||||
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
|
|
||||||
// Or if command mode is off and message is empty (render minimally)
|
|
||||||
if message.is_empty() {
|
|
||||||
"".to_string() // Render an empty string, background will cover
|
|
||||||
} else {
|
|
||||||
message.to_string()
|
|
||||||
}
|
|
||||||
} else { // active is true (normal command mode)
|
|
||||||
let prompt = ":";
|
|
||||||
if message.is_empty() || message == ":" {
|
|
||||||
format!("{}{}", prompt, input)
|
|
||||||
} else {
|
|
||||||
if input.is_empty() { // If command was just executed, input is cleared, show message
|
|
||||||
message.to_string()
|
|
||||||
} else { // Show input and message
|
|
||||||
format!("{}{} | {}", prompt, input, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let content_width = UnicodeWidthStr::width(display_text.as_str());
|
|
||||||
let available_width = area.width as usize;
|
|
||||||
let padding_needed = available_width.saturating_sub(content_width);
|
|
||||||
|
|
||||||
let display_text_padded = if padding_needed > 0 {
|
|
||||||
format!("{}{}", display_text, " ".repeat(padding_needed))
|
|
||||||
} else {
|
|
||||||
// If text is too long, ratatui's Paragraph will handle truncation.
|
|
||||||
// We could also truncate here if specific behavior is needed:
|
|
||||||
// display_text.chars().take(available_width).collect::<String>()
|
|
||||||
display_text
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine style based on active state, but apply to the whole paragraph
|
|
||||||
let text_style = if active {
|
|
||||||
Style::default().fg(theme.accent)
|
|
||||||
} else {
|
|
||||||
// If not active, but there's a message, use default foreground.
|
|
||||||
// If message is also empty, this style won't matter much for empty text.
|
|
||||||
Style::default().fg(theme.fg)
|
|
||||||
};
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(display_text_padded)
|
|
||||||
.block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
|
|
||||||
.style(text_style); // Style for the text itself
|
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// src/bottom_panel/find_file_palette.rs
|
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::modes::general::command_navigation::NavigationState; // Corrected path
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
|
||||||
style::Style,
|
|
||||||
widgets::{Block, List, ListItem, Paragraph},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
|
|
||||||
const PADDING_CHAR: &str = " ";
|
|
||||||
|
|
||||||
pub fn render_find_file_palette(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
navigation_state: &NavigationState,
|
|
||||||
) {
|
|
||||||
let palette_display_input = navigation_state.get_display_input(); // Use the new method
|
|
||||||
|
|
||||||
let num_total_filtered = navigation_state.filtered_options.len();
|
|
||||||
let current_selected_list_idx = navigation_state.selected_index;
|
|
||||||
|
|
||||||
let mut display_start_offset = 0;
|
|
||||||
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
|
|
||||||
if let Some(sel_idx) = current_selected_list_idx {
|
|
||||||
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
|
|
||||||
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
|
|
||||||
} else if sel_idx < display_start_offset {
|
|
||||||
display_start_offset = sel_idx;
|
|
||||||
}
|
|
||||||
display_start_offset = display_start_offset
|
|
||||||
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
display_start_offset = display_start_offset.max(0);
|
|
||||||
|
|
||||||
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
|
|
||||||
.min(num_total_filtered);
|
|
||||||
|
|
||||||
// navigation_state.filtered_options is Vec<(usize, String)>
|
|
||||||
// We only need the String part for display.
|
|
||||||
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
|
|
||||||
navigation_state.filtered_options
|
|
||||||
[display_start_offset..display_end_offset]
|
|
||||||
.iter()
|
|
||||||
.map(|(_, opt_str)| opt_str)
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(1), // For palette input line
|
|
||||||
Constraint::Min(0), // For options list, take remaining space
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
|
|
||||||
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
|
|
||||||
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
|
|
||||||
|
|
||||||
|
|
||||||
let input_area = chunks[0];
|
|
||||||
// let list_area = chunks[1]; // Use final_list_area
|
|
||||||
|
|
||||||
let prompt_prefix = match navigation_state.navigation_type {
|
|
||||||
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
|
|
||||||
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
|
|
||||||
};
|
|
||||||
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
|
|
||||||
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
|
|
||||||
let input_area_width = input_area.width as usize;
|
|
||||||
let input_padding_needed =
|
|
||||||
input_area_width.saturating_sub(prompt_text_width);
|
|
||||||
|
|
||||||
let padded_prompt_text = if input_padding_needed > 0 {
|
|
||||||
format!(
|
|
||||||
"{}{}",
|
|
||||||
base_prompt_text,
|
|
||||||
PADDING_CHAR.repeat(input_padding_needed)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
base_prompt_text
|
|
||||||
};
|
|
||||||
|
|
||||||
let input_paragraph = Paragraph::new(padded_prompt_text)
|
|
||||||
.style(Style::default().fg(theme.accent).bg(theme.bg));
|
|
||||||
f.render_widget(input_paragraph, input_area);
|
|
||||||
|
|
||||||
let mut display_list_items: Vec<ListItem> =
|
|
||||||
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
|
|
||||||
|
|
||||||
for (idx_in_visible_slice, opt_str) in
|
|
||||||
visible_options_slice.iter().enumerate()
|
|
||||||
{
|
|
||||||
// The selected_index in navigation_state is relative to the full filtered_options list.
|
|
||||||
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
|
|
||||||
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
|
|
||||||
let is_selected =
|
|
||||||
current_selected_list_idx == Some(original_filtered_idx);
|
|
||||||
|
|
||||||
let style = if is_selected {
|
|
||||||
Style::default().fg(theme.bg).bg(theme.accent)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(theme.fg).bg(theme.bg)
|
|
||||||
};
|
|
||||||
|
|
||||||
let opt_width = opt_str.width() as u16;
|
|
||||||
let list_item_width = final_list_area.width;
|
|
||||||
let padding_amount = list_item_width.saturating_sub(opt_width);
|
|
||||||
let padded_opt_str = format!(
|
|
||||||
"{}{}",
|
|
||||||
opt_str,
|
|
||||||
PADDING_CHAR.repeat(padding_amount as usize)
|
|
||||||
);
|
|
||||||
display_list_items.push(ListItem::new(padded_opt_str).style(style));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill remaining lines in the list area to maintain fixed height appearance
|
|
||||||
let num_rendered_options = display_list_items.len();
|
|
||||||
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
|
|
||||||
for _ in num_rendered_options..(final_list_area.height as usize) {
|
|
||||||
let empty_padded_str =
|
|
||||||
PADDING_CHAR.repeat(final_list_area.width as usize);
|
|
||||||
display_list_items.push(
|
|
||||||
ListItem::new(empty_padded_str)
|
|
||||||
.style(Style::default().fg(theme.bg).bg(theme.bg)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let options_list_widget = List::new(display_list_items)
|
|
||||||
.block(Block::default().style(Style::default().bg(theme.bg)));
|
|
||||||
f.render_widget(options_list_widget, final_list_area);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// src/bottom_panel/layout.rs
|
|
||||||
|
|
||||||
use ratatui::{layout::Constraint, layout::Rect, Frame};
|
|
||||||
use crate::bottom_panel::{status_line::render_status_line, command_line::render_command_line};
|
|
||||||
use crate::bottom_panel::find_file_palette;
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::modes::general::command_navigation::NavigationState;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::pages::routing::Router;
|
|
||||||
|
|
||||||
/// Calculate the layout constraints for the bottom panel (status line + command line/palette).
|
|
||||||
pub fn bottom_panel_constraints(
|
|
||||||
app_state: &AppState,
|
|
||||||
navigation_state: &NavigationState,
|
|
||||||
event_handler_command_mode_active: bool,
|
|
||||||
) -> Vec<Constraint> {
|
|
||||||
let mut status_line_height = 1;
|
|
||||||
#[cfg(feature = "ui-debug")]
|
|
||||||
{
|
|
||||||
if let Some(debug_state) = &app_state.debug_state {
|
|
||||||
if debug_state.is_error {
|
|
||||||
status_line_height = 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
|
|
||||||
let command_palette_area_height = if navigation_state.active {
|
|
||||||
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
|
|
||||||
} else if event_handler_command_mode_active {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut constraints = vec![Constraint::Length(status_line_height)];
|
|
||||||
if command_palette_area_height > 0 {
|
|
||||||
constraints.push(Constraint::Length(command_palette_area_height));
|
|
||||||
}
|
|
||||||
constraints
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the bottom panel (status line + command line/palette).
|
|
||||||
pub fn render_bottom_panel(
|
|
||||||
f: &mut Frame,
|
|
||||||
root_chunks: &[Rect],
|
|
||||||
chunk_idx: &mut usize,
|
|
||||||
current_dir: &str,
|
|
||||||
theme: &Theme,
|
|
||||||
current_fps: f64,
|
|
||||||
app_state: &AppState,
|
|
||||||
router: &Router,
|
|
||||||
navigation_state: &NavigationState,
|
|
||||||
event_handler_command_input: &str,
|
|
||||||
event_handler_command_mode_active: bool,
|
|
||||||
event_handler_command_message: &str,
|
|
||||||
) {
|
|
||||||
// --- Status line area ---
|
|
||||||
let status_line_area = root_chunks[*chunk_idx];
|
|
||||||
*chunk_idx += 1;
|
|
||||||
|
|
||||||
// --- Command line / palette area ---
|
|
||||||
let command_render_area = if root_chunks.len() > *chunk_idx {
|
|
||||||
Some(root_chunks[*chunk_idx])
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if command_render_area.is_some() {
|
|
||||||
*chunk_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Render status line ---
|
|
||||||
render_status_line(
|
|
||||||
f,
|
|
||||||
status_line_area,
|
|
||||||
current_dir,
|
|
||||||
theme,
|
|
||||||
current_fps,
|
|
||||||
app_state,
|
|
||||||
router,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Render command line or palette ---
|
|
||||||
if let Some(area) = command_render_area {
|
|
||||||
if navigation_state.active {
|
|
||||||
find_file_palette::render_find_file_palette(f, area, theme, navigation_state);
|
|
||||||
} else if event_handler_command_mode_active {
|
|
||||||
render_command_line(
|
|
||||||
f,
|
|
||||||
area,
|
|
||||||
event_handler_command_input,
|
|
||||||
true,
|
|
||||||
theme,
|
|
||||||
event_handler_command_message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// src/bottom_panel/mod.rs
|
|
||||||
|
|
||||||
pub mod status_line;
|
|
||||||
pub mod command_line;
|
|
||||||
pub mod layout;
|
|
||||||
pub mod find_file_palette;
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
// client/src/components/common/status_line.rs
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use ratatui::{
|
|
||||||
layout::Rect,
|
|
||||||
style::Style,
|
|
||||||
text::{Line, Span, Text},
|
|
||||||
widgets::{Paragraph, Wrap},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use crate::pages::routing::Page;
|
|
||||||
use crate::pages::routing::Router;
|
|
||||||
use std::path::Path;
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
pub fn render_status_line(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
current_dir: &str,
|
|
||||||
theme: &Theme,
|
|
||||||
current_fps: f64,
|
|
||||||
app_state: &AppState,
|
|
||||||
router: &Router,
|
|
||||||
) {
|
|
||||||
#[cfg(feature = "ui-debug")]
|
|
||||||
{
|
|
||||||
if let Some(debug_state) = &app_state.debug_state {
|
|
||||||
let paragraph = if debug_state.is_error {
|
|
||||||
// --- THIS IS THE CRITICAL LOGIC FOR ERRORS ---
|
|
||||||
// 1. Create a `Text` object, which can contain multiple lines.
|
|
||||||
let error_text = Text::from(debug_state.displayed_message.clone());
|
|
||||||
|
|
||||||
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
|
|
||||||
Paragraph::new(error_text)
|
|
||||||
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
|
|
||||||
.style(Style::default().bg(theme.highlight).fg(theme.bg))
|
|
||||||
} else {
|
|
||||||
// --- This is for normal, single-line info messages ---
|
|
||||||
Paragraph::new(debug_state.displayed_message.as_str())
|
|
||||||
.style(Style::default().fg(theme.accent).bg(theme.bg))
|
|
||||||
};
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
} else {
|
|
||||||
// Fallback for when debug state is None
|
|
||||||
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
return; // Stop here and don't render the normal status line.
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- The normal status line rendering logic (unchanged) ---
|
|
||||||
let program_info = format!("komp_ac v{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
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())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let display_dir = if current_dir.starts_with(&home_dir) {
|
|
||||||
current_dir.replacen(&home_dir, "~", 1)
|
|
||||||
} else {
|
|
||||||
current_dir.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let available_width = area.width as usize;
|
|
||||||
let mode_width = UnicodeWidthStr::width(mode_text);
|
|
||||||
let program_info_width = UnicodeWidthStr::width(program_info.as_str());
|
|
||||||
let fps_text = format!("{:.0} FPS", current_fps);
|
|
||||||
let fps_width = UnicodeWidthStr::width(fps_text.as_str());
|
|
||||||
let separator = " | ";
|
|
||||||
let separator_width = UnicodeWidthStr::width(separator);
|
|
||||||
|
|
||||||
let fixed_width_with_fps = mode_width
|
|
||||||
+ separator_width
|
|
||||||
+ separator_width
|
|
||||||
+ program_info_width
|
|
||||||
+ separator_width
|
|
||||||
+ fps_width;
|
|
||||||
|
|
||||||
let show_fps = fixed_width_with_fps <= available_width;
|
|
||||||
|
|
||||||
let remaining_width_for_dir = available_width.saturating_sub(
|
|
||||||
mode_width
|
|
||||||
+ separator_width
|
|
||||||
+ separator_width
|
|
||||||
+ program_info_width
|
|
||||||
+ (if show_fps {
|
|
||||||
separator_width + fps_width
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
|
|
||||||
<= remaining_width_for_dir
|
|
||||||
{
|
|
||||||
display_dir
|
|
||||||
} else {
|
|
||||||
let dir_name = Path::new(current_dir)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or(current_dir);
|
|
||||||
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
|
|
||||||
dir_name.to_string()
|
|
||||||
} else {
|
|
||||||
dir_name
|
|
||||||
.chars()
|
|
||||||
.take(remaining_width_for_dir)
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut current_content_width = mode_width
|
|
||||||
+ separator_width
|
|
||||||
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
|
|
||||||
+ separator_width
|
|
||||||
+ program_info_width;
|
|
||||||
if show_fps {
|
|
||||||
current_content_width += separator_width + fps_width;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut line_spans = vec![
|
|
||||||
Span::styled(mode_text, Style::default().fg(theme.accent)),
|
|
||||||
Span::styled(separator, Style::default().fg(theme.border)),
|
|
||||||
Span::styled(
|
|
||||||
dir_display_text_str.as_str(),
|
|
||||||
Style::default().fg(theme.fg),
|
|
||||||
),
|
|
||||||
Span::styled(separator, Style::default().fg(theme.border)),
|
|
||||||
Span::styled(
|
|
||||||
program_info.as_str(),
|
|
||||||
Style::default().fg(theme.secondary),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
if show_fps {
|
|
||||||
line_spans
|
|
||||||
.push(Span::styled(separator, Style::default().fg(theme.border)));
|
|
||||||
line_spans.push(Span::styled(
|
|
||||||
fps_text.as_str(),
|
|
||||||
Style::default().fg(theme.secondary),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let padding_needed = available_width.saturating_sub(current_content_width);
|
|
||||||
if padding_needed > 0 {
|
|
||||||
line_spans.push(Span::styled(
|
|
||||||
" ".repeat(padding_needed),
|
|
||||||
Style::default().bg(theme.bg),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let paragraph =
|
|
||||||
Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg));
|
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// src/buffer/functions/buffer.rs
|
|
||||||
|
|
||||||
use crate::buffer::state::BufferState;
|
|
||||||
use crate::buffer::state::AppView;
|
|
||||||
|
|
||||||
pub fn get_view_layer(view: &AppView) -> u8 {
|
|
||||||
match view {
|
|
||||||
AppView::Intro => 1,
|
|
||||||
AppView::Login | AppView::Register | AppView::Admin | AppView::AddTable | AppView::AddLogic => 2,
|
|
||||||
AppView::Form(_) | AppView::Scratch => 3,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switches the active buffer index.
|
|
||||||
pub fn switch_buffer(buffer_state: &mut BufferState, next: bool) -> bool {
|
|
||||||
if buffer_state.history.len() <= 1 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let len = buffer_state.history.len();
|
|
||||||
let current_index = buffer_state.active_index;
|
|
||||||
let new_index = if next {
|
|
||||||
(current_index + 1) % len
|
|
||||||
} else {
|
|
||||||
(current_index + len - 1) % len
|
|
||||||
};
|
|
||||||
|
|
||||||
if new_index != current_index {
|
|
||||||
buffer_state.active_index = new_index;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// src/buffer/logic.rs
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
|
||||||
use crate::config::binds::config::Config;
|
|
||||||
use crate::state::app::state::UiState;
|
|
||||||
|
|
||||||
/// Toggle the buffer list visibility based on keybindings.
|
|
||||||
pub fn toggle_buffer_list(
|
|
||||||
ui_state: &mut UiState,
|
|
||||||
config: &Config,
|
|
||||||
key: KeyCode,
|
|
||||||
modifiers: KeyModifiers,
|
|
||||||
) -> bool {
|
|
||||||
if let Some(action) = config.get_common_action(key, modifiers) {
|
|
||||||
if action == "toggle_buffer_list" {
|
|
||||||
ui_state.show_buffer_list = !ui_state.show_buffer_list;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// src/buffer/mod.rs
|
|
||||||
|
|
||||||
pub mod state;
|
|
||||||
pub mod functions;
|
|
||||||
pub mod ui;
|
|
||||||
pub mod logic;
|
|
||||||
|
|
||||||
pub use state::{AppView, BufferState};
|
|
||||||
pub use functions::*;
|
|
||||||
pub use ui::render_buffer_list;
|
|
||||||
pub use logic::toggle_buffer_list;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
// src/buffer/state/buffer.rs
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum AppView {
|
|
||||||
Intro,
|
|
||||||
Login,
|
|
||||||
Register,
|
|
||||||
Admin,
|
|
||||||
AddTable,
|
|
||||||
AddLogic,
|
|
||||||
Form(String),
|
|
||||||
Scratch,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppView {
|
|
||||||
/// Returns the display name for the view.
|
|
||||||
/// For Form, pass the current table name to get dynamic naming.
|
|
||||||
pub fn display_name(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
AppView::Intro => "Intro",
|
|
||||||
AppView::Login => "Login",
|
|
||||||
AppView::Register => "Register",
|
|
||||||
AppView::Admin => "Admin_Panel",
|
|
||||||
AppView::AddTable => "Add_Table",
|
|
||||||
AppView::AddLogic => "Add_Logic",
|
|
||||||
AppView::Form(_) => "Form",
|
|
||||||
AppView::Scratch => "*scratch*",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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(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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BufferState {
|
|
||||||
pub history: Vec<AppView>,
|
|
||||||
pub active_index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BufferState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
history: vec![AppView::Intro],
|
|
||||||
active_index: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BufferState {
|
|
||||||
pub fn update_history(&mut self, view: AppView) {
|
|
||||||
let existing_pos = self.history.iter().position(|v| v == &view);
|
|
||||||
match existing_pos {
|
|
||||||
Some(pos) => self.active_index = pos,
|
|
||||||
None => {
|
|
||||||
self.history.push(view.clone());
|
|
||||||
self.active_index = self.history.len() - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_active_view(&self) -> Option<&AppView> {
|
|
||||||
self.history.get(self.active_index)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close_active_buffer(&mut self) -> bool {
|
|
||||||
if self.history.is_empty() {
|
|
||||||
self.history.push(AppView::Intro);
|
|
||||||
self.active_index = 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_index = self.active_index;
|
|
||||||
self.history.remove(current_index);
|
|
||||||
|
|
||||||
if self.history.is_empty() {
|
|
||||||
self.history.push(AppView::Intro);
|
|
||||||
self.active_index = 0;
|
|
||||||
} else if self.active_index >= self.history.len() {
|
|
||||||
self.active_index = self.history.len() - 1;
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close_buffer_with_intro_fallback(&mut self, current_table_name: Option<&str>) -> String {
|
|
||||||
let current_view_cloned = self.get_active_view().cloned();
|
|
||||||
|
|
||||||
if let Some(AppView::Intro) = current_view_cloned {
|
|
||||||
if self.history.len() == 1 {
|
|
||||||
self.close_active_buffer();
|
|
||||||
return "Intro buffer reset".to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let closed_name = current_view_cloned
|
|
||||||
.as_ref()
|
|
||||||
.map(|v| v.display_name_with_context(current_table_name))
|
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
|
||||||
|
|
||||||
if self.close_active_buffer() {
|
|
||||||
if self.history.len() == 1 && matches!(self.history.get(0), Some(AppView::Intro)) {
|
|
||||||
format!("Closed '{}' - returned to Intro", closed_name)
|
|
||||||
} else {
|
|
||||||
format!("Closed '{}'", closed_name)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("Buffer '{}' could not be closed", closed_name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// src/buffer/ui.rs
|
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::buffer::state::BufferState;
|
|
||||||
use crate::state::app::state::AppState; // Add this import
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Alignment, Rect},
|
|
||||||
style::Style,
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::Paragraph,
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
use crate::buffer::functions::get_view_layer;
|
|
||||||
|
|
||||||
pub fn render_buffer_list(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
buffer_state: &BufferState,
|
|
||||||
app_state: &AppState,
|
|
||||||
) {
|
|
||||||
// --- Style Definitions ---
|
|
||||||
let active_style = Style::default()
|
|
||||||
.fg(theme.bg)
|
|
||||||
.bg(theme.highlight);
|
|
||||||
|
|
||||||
let inactive_style = Style::default()
|
|
||||||
.fg(theme.fg)
|
|
||||||
.bg(theme.bg);
|
|
||||||
|
|
||||||
// --- Determine Active Layer ---
|
|
||||||
let active_layer = match buffer_state.history.get(buffer_state.active_index) {
|
|
||||||
Some(view) => get_view_layer(view),
|
|
||||||
None => 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Create Spans ---
|
|
||||||
let mut spans = Vec::new();
|
|
||||||
let mut current_width = 0;
|
|
||||||
|
|
||||||
let current_table_name = app_state.current_view_table_name.as_deref();
|
|
||||||
|
|
||||||
for (original_index, view) in buffer_state.history.iter().enumerate() {
|
|
||||||
// Filter: Only process views matching the active layer
|
|
||||||
if get_view_layer(view) != active_layer {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_active = original_index == buffer_state.active_index;
|
|
||||||
let buffer_name = view.display_name_with_context(current_table_name);
|
|
||||||
let buffer_text = format!(" {} ", buffer_name);
|
|
||||||
let text_width = UnicodeWidthStr::width(buffer_text.as_str());
|
|
||||||
|
|
||||||
// Calculate width needed for this buffer (separator + text)
|
|
||||||
let needed_width = text_width;
|
|
||||||
if current_width + needed_width > area.width as usize {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the buffer text itself
|
|
||||||
let text_style = if is_active { active_style } else { inactive_style };
|
|
||||||
spans.push(Span::styled(buffer_text, text_style));
|
|
||||||
current_width += text_width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Filler Span ---
|
|
||||||
let remaining_width = area.width.saturating_sub(current_width as u16);
|
|
||||||
if !spans.is_empty() || remaining_width > 0 {
|
|
||||||
spans.push(Span::styled(
|
|
||||||
" ".repeat(remaining_width as usize),
|
|
||||||
inactive_style,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Render ---
|
|
||||||
let buffer_line = Line::from(spans);
|
|
||||||
let paragraph = Paragraph::new(buffer_line).alignment(Alignment::Left);
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// src/components/common.rs
|
|
||||||
|
|
||||||
pub mod text_editor;
|
|
||||||
pub mod background;
|
|
||||||
pub mod autocomplete;
|
|
||||||
|
|
||||||
pub use text_editor::*;
|
|
||||||
pub use background::*;
|
|
||||||
pub use autocomplete::*;
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
// src/components/common/autocomplete.rs
|
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use common::proto::komp_ac::search::search_response::Hit;
|
|
||||||
use crate::pages::forms::FormState;
|
|
||||||
use ratatui::{
|
|
||||||
layout::Rect,
|
|
||||||
style::{Color, Modifier, Style},
|
|
||||||
widgets::{Block, List, ListItem, ListState},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
/// Renders an opaque dropdown list for simple string-based suggestions.
|
|
||||||
/// THIS IS THE RESTORED FUNCTION.
|
|
||||||
pub fn render_autocomplete_dropdown(
|
|
||||||
f: &mut Frame,
|
|
||||||
input_rect: Rect,
|
|
||||||
frame_area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
suggestions: &[String],
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
) {
|
|
||||||
if suggestions.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let max_suggestion_width =
|
|
||||||
suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
|
||||||
let horizontal_padding: u16 = 2;
|
|
||||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
|
||||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
|
||||||
|
|
||||||
let mut dropdown_area = Rect {
|
|
||||||
x: input_rect.x,
|
|
||||||
y: input_rect.y + 1,
|
|
||||||
width: dropdown_width,
|
|
||||||
height: dropdown_height,
|
|
||||||
};
|
|
||||||
|
|
||||||
if dropdown_area.bottom() > frame_area.height {
|
|
||||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
|
||||||
}
|
|
||||||
if dropdown_area.right() > frame_area.width {
|
|
||||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
|
||||||
}
|
|
||||||
dropdown_area.x = dropdown_area.x.max(0);
|
|
||||||
dropdown_area.y = dropdown_area.y.max(0);
|
|
||||||
|
|
||||||
let background_block =
|
|
||||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
|
||||||
f.render_widget(background_block, dropdown_area);
|
|
||||||
|
|
||||||
let items: Vec<ListItem> = suggestions
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, s)| {
|
|
||||||
let is_selected = selected_index == Some(i);
|
|
||||||
let s_width = s.width() as u16;
|
|
||||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
|
||||||
let padded_s =
|
|
||||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
|
||||||
|
|
||||||
ListItem::new(padded_s).style(if is_selected {
|
|
||||||
Style::default()
|
|
||||||
.fg(theme.bg)
|
|
||||||
.bg(theme.highlight)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let list = List::new(items);
|
|
||||||
let mut list_state = ListState::default();
|
|
||||||
list_state.select(selected_index);
|
|
||||||
|
|
||||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders an opaque dropdown list for rich `Hit`-based suggestions.
|
|
||||||
/// RENAMED from render_rich_autocomplete_dropdown
|
|
||||||
pub fn render_hit_autocomplete_dropdown(
|
|
||||||
f: &mut Frame,
|
|
||||||
input_rect: Rect,
|
|
||||||
frame_area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
suggestions: &[Hit],
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
form_state: &FormState,
|
|
||||||
) {
|
|
||||||
if suggestions.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let display_names: Vec<String> = suggestions
|
|
||||||
.iter()
|
|
||||||
.map(|hit| form_state.get_display_name_for_hit(hit))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let max_suggestion_width =
|
|
||||||
display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
|
|
||||||
let horizontal_padding: u16 = 2;
|
|
||||||
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
|
|
||||||
let dropdown_height = (suggestions.len() as u16).min(5);
|
|
||||||
|
|
||||||
let mut dropdown_area = Rect {
|
|
||||||
x: input_rect.x,
|
|
||||||
y: input_rect.y + 1,
|
|
||||||
width: dropdown_width,
|
|
||||||
height: dropdown_height,
|
|
||||||
};
|
|
||||||
|
|
||||||
if dropdown_area.bottom() > frame_area.height {
|
|
||||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
|
||||||
}
|
|
||||||
if dropdown_area.right() > frame_area.width {
|
|
||||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
|
||||||
}
|
|
||||||
dropdown_area.x = dropdown_area.x.max(0);
|
|
||||||
dropdown_area.y = dropdown_area.y.max(0);
|
|
||||||
|
|
||||||
let background_block =
|
|
||||||
Block::default().style(Style::default().bg(Color::DarkGray));
|
|
||||||
f.render_widget(background_block, dropdown_area);
|
|
||||||
|
|
||||||
let items: Vec<ListItem> = display_names
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, s)| {
|
|
||||||
let is_selected = selected_index == Some(i);
|
|
||||||
let s_width = s.width() as u16;
|
|
||||||
let padding_needed = dropdown_width.saturating_sub(s_width);
|
|
||||||
let padded_s =
|
|
||||||
format!("{}{}", s, " ".repeat(padding_needed as usize));
|
|
||||||
|
|
||||||
ListItem::new(padded_s).style(if is_selected {
|
|
||||||
Style::default()
|
|
||||||
.fg(theme.bg)
|
|
||||||
.bg(theme.highlight)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(theme.fg).bg(Color::DarkGray)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let list = List::new(items);
|
|
||||||
let mut list_state = ListState::default();
|
|
||||||
list_state.select(selected_index);
|
|
||||||
|
|
||||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// src/components/handlers/background.rs
|
|
||||||
use ratatui::{
|
|
||||||
widgets::{Block},
|
|
||||||
layout::Rect,
|
|
||||||
style::Style,
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
|
|
||||||
pub fn render_background(f: &mut Frame, area: Rect, theme: &Theme) {
|
|
||||||
let background = Block::default()
|
|
||||||
.style(Style::default().bg(theme.bg));
|
|
||||||
|
|
||||||
f.render_widget(background, area);
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
// src/components/common/text_editor.rs
|
|
||||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
|
||||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
|
||||||
use ratatui::style::{Color, Style, Modifier};
|
|
||||||
use tui_textarea::{Input, Key, TextArea, CursorMove};
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum VimMode {
|
|
||||||
Normal,
|
|
||||||
Insert,
|
|
||||||
Visual,
|
|
||||||
Operator(char),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VimMode {
|
|
||||||
pub fn cursor_style(&self) -> Style {
|
|
||||||
let color = match self {
|
|
||||||
Self::Normal => Color::Reset,
|
|
||||||
Self::Insert => Color::LightBlue,
|
|
||||||
Self::Visual => Color::LightYellow,
|
|
||||||
Self::Operator(_) => Color::LightGreen,
|
|
||||||
};
|
|
||||||
Style::default().fg(color).add_modifier(Modifier::REVERSED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for VimMode {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
|
|
||||||
match self {
|
|
||||||
Self::Normal => write!(f, "NORMAL"),
|
|
||||||
Self::Insert => write!(f, "INSERT"),
|
|
||||||
Self::Visual => write!(f, "VISUAL"),
|
|
||||||
Self::Operator(c) => write!(f, "OPERATOR({})", c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
enum Transition {
|
|
||||||
Nop,
|
|
||||||
Mode(VimMode),
|
|
||||||
Pending(Input),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct VimState {
|
|
||||||
pub mode: VimMode,
|
|
||||||
pub pending: Input,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for VimState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
mode: VimMode::Normal,
|
|
||||||
pending: Input::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VimState {
|
|
||||||
pub fn new(mode: VimMode) -> Self {
|
|
||||||
Self {
|
|
||||||
mode,
|
|
||||||
pending: Input::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_pending(self, pending: Input) -> Self {
|
|
||||||
Self {
|
|
||||||
mode: self.mode,
|
|
||||||
pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
|
|
||||||
if input.key == Key::Null {
|
|
||||||
return Transition::Nop;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.mode {
|
|
||||||
VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => {
|
|
||||||
match input {
|
|
||||||
Input { key: Key::Char('h'), .. } => textarea.move_cursor(CursorMove::Back),
|
|
||||||
Input { key: Key::Char('j'), .. } => textarea.move_cursor(CursorMove::Down),
|
|
||||||
Input { key: Key::Char('k'), .. } => textarea.move_cursor(CursorMove::Up),
|
|
||||||
Input { key: Key::Char('l'), .. } => textarea.move_cursor(CursorMove::Forward),
|
|
||||||
Input { key: Key::Char('w'), .. } => textarea.move_cursor(CursorMove::WordForward),
|
|
||||||
Input { key: Key::Char('e'), ctrl: false, .. } => {
|
|
||||||
textarea.move_cursor(CursorMove::WordEnd);
|
|
||||||
if matches!(self.mode, VimMode::Operator(_)) {
|
|
||||||
textarea.move_cursor(CursorMove::Forward);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('b'), ctrl: false, .. } => textarea.move_cursor(CursorMove::WordBack),
|
|
||||||
Input { key: Key::Char('^'), .. } => textarea.move_cursor(CursorMove::Head),
|
|
||||||
Input { key: Key::Char('$'), .. } => textarea.move_cursor(CursorMove::End),
|
|
||||||
Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head),
|
|
||||||
Input { key: Key::Char('D'), .. } => {
|
|
||||||
textarea.delete_line_by_end();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('C'), .. } => {
|
|
||||||
textarea.delete_line_by_end();
|
|
||||||
textarea.cancel_selection();
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('p'), .. } => {
|
|
||||||
textarea.paste();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('u'), ctrl: false, .. } => {
|
|
||||||
textarea.undo();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('r'), ctrl: true, .. } => {
|
|
||||||
textarea.redo();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('x'), .. } => {
|
|
||||||
textarea.delete_next_char();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('i'), .. } => {
|
|
||||||
textarea.cancel_selection();
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('a'), .. } => {
|
|
||||||
textarea.cancel_selection();
|
|
||||||
textarea.move_cursor(CursorMove::Forward);
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('A'), .. } => {
|
|
||||||
textarea.cancel_selection();
|
|
||||||
textarea.move_cursor(CursorMove::End);
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('o'), .. } => {
|
|
||||||
textarea.move_cursor(CursorMove::End);
|
|
||||||
textarea.insert_newline();
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('O'), .. } => {
|
|
||||||
textarea.move_cursor(CursorMove::Head);
|
|
||||||
textarea.insert_newline();
|
|
||||||
textarea.move_cursor(CursorMove::Up);
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('I'), .. } => {
|
|
||||||
textarea.cancel_selection();
|
|
||||||
textarea.move_cursor(CursorMove::Head);
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
|
||||||
textarea.start_selection();
|
|
||||||
return Transition::Mode(VimMode::Visual);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('V'), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
|
||||||
textarea.move_cursor(CursorMove::Head);
|
|
||||||
textarea.start_selection();
|
|
||||||
textarea.move_cursor(CursorMove::End);
|
|
||||||
return Transition::Mode(VimMode::Visual);
|
|
||||||
}
|
|
||||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
|
||||||
textarea.cancel_selection();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('g'), ctrl: false, .. } if matches!(
|
|
||||||
self.pending,
|
|
||||||
Input { key: Key::Char('g'), ctrl: false, .. }
|
|
||||||
) => {
|
|
||||||
textarea.move_cursor(CursorMove::Top)
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('G'), ctrl: false, .. } => textarea.move_cursor(CursorMove::Bottom),
|
|
||||||
Input { key: Key::Char(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => {
|
|
||||||
textarea.move_cursor(CursorMove::Head);
|
|
||||||
textarea.start_selection();
|
|
||||||
let cursor = textarea.cursor();
|
|
||||||
textarea.move_cursor(CursorMove::Down);
|
|
||||||
if cursor == textarea.cursor() {
|
|
||||||
textarea.move_cursor(CursorMove::End);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Input { key: Key::Char(op @ ('y' | 'd' | 'c')), ctrl: false, .. } if self.mode == VimMode::Normal => {
|
|
||||||
textarea.start_selection();
|
|
||||||
return Transition::Mode(VimMode::Operator(op));
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
|
||||||
textarea.move_cursor(CursorMove::Forward);
|
|
||||||
textarea.copy();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
|
||||||
textarea.move_cursor(CursorMove::Forward);
|
|
||||||
textarea.cut();
|
|
||||||
return Transition::Mode(VimMode::Normal);
|
|
||||||
}
|
|
||||||
Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => {
|
|
||||||
textarea.move_cursor(CursorMove::Forward);
|
|
||||||
textarea.cut();
|
|
||||||
return Transition::Mode(VimMode::Insert);
|
|
||||||
}
|
|
||||||
// Arrow keys work in normal mode
|
|
||||||
Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up),
|
|
||||||
Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down),
|
|
||||||
Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back),
|
|
||||||
Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward),
|
|
||||||
input => return Transition::Pending(input),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the pending operator
|
|
||||||
match self.mode {
|
|
||||||
VimMode::Operator('y') => {
|
|
||||||
textarea.copy();
|
|
||||||
Transition::Mode(VimMode::Normal)
|
|
||||||
}
|
|
||||||
VimMode::Operator('d') => {
|
|
||||||
textarea.cut();
|
|
||||||
Transition::Mode(VimMode::Normal)
|
|
||||||
}
|
|
||||||
VimMode::Operator('c') => {
|
|
||||||
textarea.cut();
|
|
||||||
Transition::Mode(VimMode::Insert)
|
|
||||||
}
|
|
||||||
_ => Transition::Nop,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VimMode::Insert => match input {
|
|
||||||
Input { key: Key::Esc, .. } | Input { key: Key::Char('c'), ctrl: true, .. } => {
|
|
||||||
Transition::Mode(VimMode::Normal)
|
|
||||||
}
|
|
||||||
input => {
|
|
||||||
textarea.input(input);
|
|
||||||
Transition::Mode(VimMode::Insert)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TextEditor;
|
|
||||||
|
|
||||||
impl TextEditor {
|
|
||||||
pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> {
|
|
||||||
let mut textarea = TextArea::default();
|
|
||||||
|
|
||||||
if editor_config.show_line_numbers {
|
|
||||||
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.set_tab_length(editor_config.tab_width);
|
|
||||||
|
|
||||||
textarea
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_input(
|
|
||||||
textarea: &mut TextArea<'static>,
|
|
||||||
key_event: KeyEvent,
|
|
||||||
keybinding_mode: &EditorKeybindingMode,
|
|
||||||
vim_state: &mut VimState,
|
|
||||||
) -> bool {
|
|
||||||
match keybinding_mode {
|
|
||||||
EditorKeybindingMode::Vim => {
|
|
||||||
Self::handle_vim_input(textarea, key_event, vim_state)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let tui_input: Input = key_event.into();
|
|
||||||
textarea.input(tui_input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_vim_input(
|
|
||||||
textarea: &mut TextArea<'static>,
|
|
||||||
key_event: KeyEvent,
|
|
||||||
vim_state: &mut VimState,
|
|
||||||
) -> bool {
|
|
||||||
let input = Self::convert_key_event_to_input(key_event);
|
|
||||||
|
|
||||||
*vim_state = match vim_state.transition(input, textarea) {
|
|
||||||
Transition::Mode(mode) if vim_state.mode != mode => {
|
|
||||||
// Update cursor style based on mode
|
|
||||||
textarea.set_cursor_style(mode.cursor_style());
|
|
||||||
VimState::new(mode)
|
|
||||||
}
|
|
||||||
Transition::Nop | Transition::Mode(_) => vim_state.clone(),
|
|
||||||
Transition::Pending(input) => vim_state.clone().with_pending(input),
|
|
||||||
};
|
|
||||||
|
|
||||||
true // Always consider input as handled in vim mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_key_event_to_input(key_event: KeyEvent) -> Input {
|
|
||||||
let key = match key_event.code {
|
|
||||||
KeyCode::Char(c) => Key::Char(c),
|
|
||||||
KeyCode::Enter => Key::Enter,
|
|
||||||
KeyCode::Left => Key::Left,
|
|
||||||
KeyCode::Right => Key::Right,
|
|
||||||
KeyCode::Up => Key::Up,
|
|
||||||
KeyCode::Down => Key::Down,
|
|
||||||
KeyCode::Backspace => Key::Backspace,
|
|
||||||
KeyCode::Delete => Key::Delete,
|
|
||||||
KeyCode::Home => Key::Home,
|
|
||||||
KeyCode::End => Key::End,
|
|
||||||
KeyCode::PageUp => Key::PageUp,
|
|
||||||
KeyCode::PageDown => Key::PageDown,
|
|
||||||
KeyCode::Tab => Key::Tab,
|
|
||||||
KeyCode::Esc => Key::Esc,
|
|
||||||
_ => Key::Null,
|
|
||||||
};
|
|
||||||
|
|
||||||
Input {
|
|
||||||
key,
|
|
||||||
ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL),
|
|
||||||
alt: key_event.modifiers.contains(KeyModifiers::ALT),
|
|
||||||
shift: key_event.modifiers.contains(KeyModifiers::SHIFT),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_vim_mode_status(vim_state: &VimState) -> String {
|
|
||||||
vim_state.mode.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_vim_insert_mode(vim_state: &VimState) -> bool {
|
|
||||||
matches!(vim_state.mode, VimMode::Insert)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_vim_normal_mode(vim_state: &VimState) -> bool {
|
|
||||||
matches!(vim_state.mode, VimMode::Normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// src/components/mod.rs
|
|
||||||
|
|
||||||
pub mod common;
|
|
||||||
pub mod utils;
|
|
||||||
|
|
||||||
pub use common::*;
|
|
||||||
pub use utils::*;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// src/components/utils.rs
|
|
||||||
pub mod text;
|
|
||||||
|
|
||||||
pub use text::*;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// src/components/utils/text.rs
|
|
||||||
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
|
||||||
|
|
||||||
/// Truncates a string to a maximum width, adding an ellipsis if truncated.
|
|
||||||
/// Considers unicode character widths.
|
|
||||||
pub fn truncate_string(s: &str, max_width: usize) -> String {
|
|
||||||
if UnicodeWidthStr::width(s) <= max_width {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
let ellipsis = "…";
|
|
||||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
|
||||||
let mut truncated_width = 0;
|
|
||||||
let mut end_byte_index = 0;
|
|
||||||
|
|
||||||
// Iterate over graphemes to handle multi-byte characters correctly
|
|
||||||
for (i, g) in s.grapheme_indices(true) {
|
|
||||||
let char_width = UnicodeWidthStr::width(g);
|
|
||||||
if truncated_width + char_width + ellipsis_width > max_width {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
truncated_width += char_width;
|
|
||||||
end_byte_index = i + g.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
format!("{}{}", &s[..end_byte_index], ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// src/config/binds.rs
|
|
||||||
|
|
||||||
pub mod config;
|
|
||||||
pub mod key_sequences;
|
|
||||||
|
|
||||||
pub use config::*;
|
|
||||||
pub use key_sequences::*;
|
|
||||||
@@ -1,868 +0,0 @@
|
|||||||
// src/config/binds/config.rs
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
|
||||||
use canvas::CanvasKeyMap;
|
|
||||||
|
|
||||||
// NEW: Editor Keybinding Mode Enum
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum EditorKeybindingMode {
|
|
||||||
#[serde(rename = "default")]
|
|
||||||
Default,
|
|
||||||
#[serde(rename = "vim")]
|
|
||||||
Vim,
|
|
||||||
#[serde(rename = "emacs")]
|
|
||||||
Emacs,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EditorKeybindingMode {
|
|
||||||
fn default() -> Self {
|
|
||||||
EditorKeybindingMode::Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Editor Configuration Struct
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct EditorConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub keybinding_mode: EditorKeybindingMode,
|
|
||||||
#[serde(default = "default_show_line_numbers")]
|
|
||||||
pub show_line_numbers: bool,
|
|
||||||
#[serde(default = "default_tab_width")]
|
|
||||||
pub tab_width: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_show_line_numbers() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_tab_width() -> u8 {
|
|
||||||
4
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EditorConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
EditorConfig {
|
|
||||||
keybinding_mode: EditorKeybindingMode::default(),
|
|
||||||
show_line_numbers: default_show_line_numbers(),
|
|
||||||
tab_width: default_tab_width(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
|
||||||
pub struct ColorsConfig {
|
|
||||||
#[serde(default = "default_theme")]
|
|
||||||
pub theme: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_theme() -> String {
|
|
||||||
"light".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(rename = "keybindings")]
|
|
||||||
pub keybindings: ModeKeybindings,
|
|
||||||
#[serde(default)]
|
|
||||||
pub colors: ColorsConfig,
|
|
||||||
// NEW: Add editor configuration
|
|
||||||
#[serde(default)]
|
|
||||||
pub editor: EditorConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... (rest of your Config struct and impl Config remains the same)
|
|
||||||
// Make sure ModeKeybindings is also deserializable if it's not already
|
|
||||||
#[derive(Debug, Deserialize, Default)] // Added Default here if not present
|
|
||||||
pub struct ModeKeybindings {
|
|
||||||
#[serde(default)]
|
|
||||||
pub general: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub read_only: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub edit: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub highlight: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub command: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub common: HashMap<String, Vec<String>>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub global: HashMap<String, Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Loads the configuration from "config.toml" in the client crate directory.
|
|
||||||
pub fn load() -> Result<Self> {
|
|
||||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
|
||||||
let config_path = Path::new(manifest_dir).join("config.toml");
|
|
||||||
let config_str = std::fs::read_to_string(&config_path)
|
|
||||||
.with_context(|| format!("Failed to read config file at {:?}", config_path))?;
|
|
||||||
let config: Config = toml::from_str(&config_str)
|
|
||||||
.with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Common actions for Edit/Read-only modes
|
|
||||||
pub fn get_common_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets an action for a key in Read-Only mode, also checking common keybindings.
|
|
||||||
pub fn get_read_only_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_for_key_in_mode(&self.keybindings.read_only, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers))
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets an action for a key in Edit mode, also checking common keybindings.
|
|
||||||
pub fn get_edit_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_for_key_in_mode(&self.keybindings.edit, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers))
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets an action for a key in Highlight mode, also checking common/global keybindings.
|
|
||||||
pub fn get_highlight_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_for_key_in_mode(&self.keybindings.highlight, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers))
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.read_only, key, modifiers))
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets an action for a key in Command mode, also checking common keybindings.
|
|
||||||
pub fn get_command_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
|
||||||
self.get_action_for_key_in_mode(&self.keybindings.command, key, modifiers)
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.common, key, modifiers))
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Context-aware keybinding resolution
|
|
||||||
pub fn get_action_for_current_context(
|
|
||||||
&self,
|
|
||||||
command_mode: bool,
|
|
||||||
key: KeyCode,
|
|
||||||
modifiers: KeyModifiers
|
|
||||||
) -> Option<&str> {
|
|
||||||
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))
|
|
||||||
.or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper function to get an action for a key in a specific mode.
|
|
||||||
pub fn get_action_for_key_in_mode<'a>(
|
|
||||||
&self,
|
|
||||||
mode_bindings: &'a HashMap<String, Vec<String>>,
|
|
||||||
key: KeyCode,
|
|
||||||
modifiers: KeyModifiers,
|
|
||||||
) -> Option<&'a str> {
|
|
||||||
for (action, bindings) in mode_bindings {
|
|
||||||
for binding in bindings {
|
|
||||||
if Self::matches_keybinding(binding, key, modifiers) {
|
|
||||||
return Some(action.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a sequence of keys matches any keybinding.
|
|
||||||
pub fn matches_key_sequence(&self, sequence: &[KeyCode]) -> Option<&str> {
|
|
||||||
if sequence.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert key sequence to a string (for simple character sequences).
|
|
||||||
let sequence_str: String = sequence.iter().filter_map(|key| {
|
|
||||||
if let KeyCode::Char(c) = key {
|
|
||||||
Some(*c)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
if sequence_str.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this sequence matches any binding in the mode-specific sections.
|
|
||||||
for (action, bindings) in &self.keybindings.read_only {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == &sequence_str {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (action, bindings) in &self.keybindings.edit {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == &sequence_str {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (action, bindings) in &self.keybindings.command {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == &sequence_str {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check common keybindings
|
|
||||||
for (action, bindings) in &self.keybindings.common {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == &sequence_str {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally check global bindings
|
|
||||||
for (action, bindings) in &self.keybindings.global {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == &sequence_str {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a keybinding matches a key and modifiers.
|
|
||||||
fn matches_keybinding(
|
|
||||||
binding: &str,
|
|
||||||
key: KeyCode,
|
|
||||||
modifiers: KeyModifiers,
|
|
||||||
) -> bool {
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If binding contains '+', distinguish between:
|
|
||||||
// - modifier combos (e.g., ctrl+shift+s) => single key + modifiers
|
|
||||||
// - multi-key sequences (e.g., space+b+r, g+g) => NOT a single key
|
|
||||||
if binding_lc.contains('+') {
|
|
||||||
let parts: Vec<&str> = binding_lc.split('+').collect();
|
|
||||||
let is_modifier = |t: &str| {
|
|
||||||
matches!(
|
|
||||||
t,
|
|
||||||
"ctrl" | "control" | "shift" | "alt" | "super" | "windows" | "cmd" | "hyper" | "meta"
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let non_modifier_count = parts.iter().filter(|p| !is_modifier(p)).count();
|
|
||||||
if non_modifier_count > 1 {
|
|
||||||
// This is a multi-key sequence (e.g., space+b+r, g+g), not a single keybind.
|
|
||||||
// It must be handled by the sequence engine, not here.
|
|
||||||
return 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].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 multi-character bindings (all standard keys without modifiers)
|
|
||||||
if binding.len() > 1 && !binding.contains('+') {
|
|
||||||
return match binding_lc.as_str() {
|
|
||||||
// Navigation keys
|
|
||||||
"left" => key == KeyCode::Left,
|
|
||||||
"right" => key == KeyCode::Right,
|
|
||||||
"up" => key == KeyCode::Up,
|
|
||||||
"down" => key == KeyCode::Down,
|
|
||||||
"home" => key == KeyCode::Home,
|
|
||||||
"end" => key == KeyCode::End,
|
|
||||||
"pageup" | "pgup" => key == KeyCode::PageUp,
|
|
||||||
"pagedown" | "pgdn" => key == KeyCode::PageDown,
|
|
||||||
|
|
||||||
// Editing keys
|
|
||||||
"insert" | "ins" => key == KeyCode::Insert,
|
|
||||||
"delete" | "del" => key == KeyCode::Delete,
|
|
||||||
"backspace" => key == KeyCode::Backspace,
|
|
||||||
|
|
||||||
// Tab keys
|
|
||||||
"tab" => key == KeyCode::Tab,
|
|
||||||
"backtab" => key == KeyCode::BackTab,
|
|
||||||
|
|
||||||
// Special keys
|
|
||||||
"enter" | "return" => key == KeyCode::Enter,
|
|
||||||
"escape" | "esc" => key == KeyCode::Esc,
|
|
||||||
"space" => key == KeyCode::Char(' '),
|
|
||||||
|
|
||||||
// Function keys F1-F24
|
|
||||||
"f1" => key == KeyCode::F(1),
|
|
||||||
"f2" => key == KeyCode::F(2),
|
|
||||||
"f3" => key == KeyCode::F(3),
|
|
||||||
"f4" => key == KeyCode::F(4),
|
|
||||||
"f5" => key == KeyCode::F(5),
|
|
||||||
"f6" => key == KeyCode::F(6),
|
|
||||||
"f7" => key == KeyCode::F(7),
|
|
||||||
"f8" => key == KeyCode::F(8),
|
|
||||||
"f9" => key == KeyCode::F(9),
|
|
||||||
"f10" => key == KeyCode::F(10),
|
|
||||||
"f11" => key == KeyCode::F(11),
|
|
||||||
"f12" => key == KeyCode::F(12),
|
|
||||||
"f13" => key == KeyCode::F(13),
|
|
||||||
"f14" => key == KeyCode::F(14),
|
|
||||||
"f15" => key == KeyCode::F(15),
|
|
||||||
"f16" => key == KeyCode::F(16),
|
|
||||||
"f17" => key == KeyCode::F(17),
|
|
||||||
"f18" => key == KeyCode::F(18),
|
|
||||||
"f19" => key == KeyCode::F(19),
|
|
||||||
"f20" => key == KeyCode::F(20),
|
|
||||||
"f21" => key == KeyCode::F(21),
|
|
||||||
"f22" => key == KeyCode::F(22),
|
|
||||||
"f23" => key == KeyCode::F(23),
|
|
||||||
"f24" => key == KeyCode::F(24),
|
|
||||||
|
|
||||||
// Lock keys
|
|
||||||
"capslock" => key == KeyCode::CapsLock,
|
|
||||||
"scrolllock" => key == KeyCode::ScrollLock,
|
|
||||||
"numlock" => key == KeyCode::NumLock,
|
|
||||||
|
|
||||||
// System keys
|
|
||||||
"printscreen" => key == KeyCode::PrintScreen,
|
|
||||||
"pause" => key == KeyCode::Pause,
|
|
||||||
"menu" => key == KeyCode::Menu,
|
|
||||||
"keypadbegin" => key == KeyCode::KeypadBegin,
|
|
||||||
|
|
||||||
// Media keys
|
|
||||||
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
|
|
||||||
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
|
|
||||||
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
|
|
||||||
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
|
|
||||||
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
|
|
||||||
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
|
|
||||||
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
|
|
||||||
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
|
|
||||||
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
|
|
||||||
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
|
|
||||||
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
|
|
||||||
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
|
|
||||||
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
|
|
||||||
|
|
||||||
// Multi-key sequences need special handling
|
|
||||||
"gg" => false, // This needs sequence handling
|
|
||||||
_ => {
|
|
||||||
// Handle single characters and punctuation
|
|
||||||
if binding.len() == 1 {
|
|
||||||
if let Some(c) = binding.chars().next() {
|
|
||||||
key == KeyCode::Char(c)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
|
|
||||||
let parts: Vec<&str> = binding.split('+').collect();
|
|
||||||
let mut expected_modifiers = KeyModifiers::empty();
|
|
||||||
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,
|
|
||||||
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
|
||||||
"alt" => expected_modifiers |= KeyModifiers::ALT,
|
|
||||||
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
|
|
||||||
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
|
|
||||||
"meta" => expected_modifiers |= KeyModifiers::META,
|
|
||||||
|
|
||||||
// Navigation keys
|
|
||||||
"left" => expected_key = Some(KeyCode::Left),
|
|
||||||
"right" => expected_key = Some(KeyCode::Right),
|
|
||||||
"up" => expected_key = Some(KeyCode::Up),
|
|
||||||
"down" => expected_key = Some(KeyCode::Down),
|
|
||||||
"home" => expected_key = Some(KeyCode::Home),
|
|
||||||
"end" => expected_key = Some(KeyCode::End),
|
|
||||||
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
|
|
||||||
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
|
|
||||||
|
|
||||||
// Editing keys
|
|
||||||
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
|
|
||||||
"delete" | "del" => expected_key = Some(KeyCode::Delete),
|
|
||||||
"backspace" => expected_key = Some(KeyCode::Backspace),
|
|
||||||
|
|
||||||
// Tab keys
|
|
||||||
"tab" => expected_key = Some(KeyCode::Tab),
|
|
||||||
"backtab" => expected_key = Some(KeyCode::BackTab),
|
|
||||||
|
|
||||||
// Special keys
|
|
||||||
"enter" | "return" => expected_key = Some(KeyCode::Enter),
|
|
||||||
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
|
|
||||||
"space" => expected_key = Some(KeyCode::Char(' ')),
|
|
||||||
|
|
||||||
// Function keys
|
|
||||||
"f1" => expected_key = Some(KeyCode::F(1)),
|
|
||||||
"f2" => expected_key = Some(KeyCode::F(2)),
|
|
||||||
"f3" => expected_key = Some(KeyCode::F(3)),
|
|
||||||
"f4" => expected_key = Some(KeyCode::F(4)),
|
|
||||||
"f5" => expected_key = Some(KeyCode::F(5)),
|
|
||||||
"f6" => expected_key = Some(KeyCode::F(6)),
|
|
||||||
"f7" => expected_key = Some(KeyCode::F(7)),
|
|
||||||
"f8" => expected_key = Some(KeyCode::F(8)),
|
|
||||||
"f9" => expected_key = Some(KeyCode::F(9)),
|
|
||||||
"f10" => expected_key = Some(KeyCode::F(10)),
|
|
||||||
"f11" => expected_key = Some(KeyCode::F(11)),
|
|
||||||
"f12" => expected_key = Some(KeyCode::F(12)),
|
|
||||||
"f13" => expected_key = Some(KeyCode::F(13)),
|
|
||||||
"f14" => expected_key = Some(KeyCode::F(14)),
|
|
||||||
"f15" => expected_key = Some(KeyCode::F(15)),
|
|
||||||
"f16" => expected_key = Some(KeyCode::F(16)),
|
|
||||||
"f17" => expected_key = Some(KeyCode::F(17)),
|
|
||||||
"f18" => expected_key = Some(KeyCode::F(18)),
|
|
||||||
"f19" => expected_key = Some(KeyCode::F(19)),
|
|
||||||
"f20" => expected_key = Some(KeyCode::F(20)),
|
|
||||||
"f21" => expected_key = Some(KeyCode::F(21)),
|
|
||||||
"f22" => expected_key = Some(KeyCode::F(22)),
|
|
||||||
"f23" => expected_key = Some(KeyCode::F(23)),
|
|
||||||
"f24" => expected_key = Some(KeyCode::F(24)),
|
|
||||||
|
|
||||||
// Lock keys
|
|
||||||
"capslock" => expected_key = Some(KeyCode::CapsLock),
|
|
||||||
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
|
|
||||||
"numlock" => expected_key = Some(KeyCode::NumLock),
|
|
||||||
|
|
||||||
// System keys
|
|
||||||
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
|
|
||||||
"pause" => expected_key = Some(KeyCode::Pause),
|
|
||||||
"menu" => expected_key = Some(KeyCode::Menu),
|
|
||||||
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
|
|
||||||
|
|
||||||
// Special characters and colon (legacy support)
|
|
||||||
":" => expected_key = Some(KeyCode::Char(':')),
|
|
||||||
|
|
||||||
// Single character (letters, numbers, punctuation)
|
|
||||||
part => {
|
|
||||||
if part.len() == 1 {
|
|
||||||
if let Some(c) = part.chars().next() {
|
|
||||||
expected_key = Some(KeyCode::Char(c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modifiers == expected_modifiers && Some(key) == expected_key
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets an action for a command string.
|
|
||||||
pub fn get_action_for_command(&self, command: &str) -> Option<&str> {
|
|
||||||
// First check command mode bindings
|
|
||||||
for (action, bindings) in &self.keybindings.command {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == command {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check common bindings
|
|
||||||
for (action, bindings) in &self.keybindings.common {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == command {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally check global bindings
|
|
||||||
for (action, bindings) in &self.keybindings.global {
|
|
||||||
for binding in bindings {
|
|
||||||
if binding == command {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to entering Edit mode (before cursor).
|
|
||||||
pub fn is_enter_edit_mode_before(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
if let Some(bindings) = self.keybindings.read_only.get("enter_edit_mode_before") {
|
|
||||||
bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers))
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to entering Edit mode (after cursor).
|
|
||||||
pub fn is_enter_edit_mode_after(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
if let Some(bindings) = self.keybindings.read_only.get("enter_edit_mode_after") {
|
|
||||||
bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers))
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to entering Edit mode.
|
|
||||||
pub fn is_enter_edit_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
self.is_enter_edit_mode_before(key, modifiers) || self.is_enter_edit_mode_after(key, modifiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to exiting Edit mode.
|
|
||||||
pub fn is_exit_edit_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
if let Some(bindings) = self.keybindings.edit.get("exit_edit_mode") {
|
|
||||||
bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers))
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to entering Command mode.
|
|
||||||
/// This method is no longer used in event.rs since we now handle command mode entry only in read-only mode directly.
|
|
||||||
pub fn is_enter_command_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
if let Some(bindings) = self.keybindings.command.get("enter_command_mode") {
|
|
||||||
bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers))
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Checks if a key is bound to exiting Command mode.
|
|
||||||
pub fn is_exit_command_mode(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
if let Some(bindings) = self.keybindings.command.get("exit_command_mode") {
|
|
||||||
bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers))
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to executing a command.
|
|
||||||
pub fn is_command_execute(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
if let Some(bindings) = self.keybindings.command.get("command_execute") {
|
|
||||||
bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers))
|
|
||||||
} else {
|
|
||||||
// Fall back to Enter key if no command_execute is defined.
|
|
||||||
key == KeyCode::Enter && modifiers.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to backspacing in Command mode.
|
|
||||||
pub fn is_command_backspace(&self, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
|
||||||
if let Some(bindings) = self.keybindings.command.get("command_backspace") {
|
|
||||||
bindings.iter().any(|b| Self::matches_keybinding(b, key, modifiers))
|
|
||||||
} else {
|
|
||||||
// Fall back to Backspace key if no command_backspace is defined.
|
|
||||||
key == KeyCode::Backspace && modifiers.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key is bound to a specific action.
|
|
||||||
pub fn has_key_for_action(&self, action: &str, key_char: char) -> bool {
|
|
||||||
// Check all mode-specific keybindings for the action
|
|
||||||
if let Some(bindings) = self.keybindings.read_only.get(action) {
|
|
||||||
if bindings.iter().any(|binding| binding == &key_char.to_string()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(bindings) = self.keybindings.edit.get(action) {
|
|
||||||
if bindings.iter().any(|binding| binding == &key_char.to_string()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(bindings) = self.keybindings.command.get(action) {
|
|
||||||
if bindings.iter().any(|binding| binding == &key_char.to_string()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(bindings) = self.keybindings.common.get(action) {
|
|
||||||
if bindings.iter().any(|binding| binding == &key_char.to_string()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(bindings) = self.keybindings.global.get(action) {
|
|
||||||
if bindings.iter().any(|binding| binding == &key_char.to_string()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This method handles all keybinding formats, both with and without +
|
|
||||||
pub fn matches_key_sequence_generalized(&self, sequence: &[KeyCode]) -> Option<&str> {
|
|
||||||
if sequence.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get string representations of the sequence
|
|
||||||
let sequence_str = sequence.iter()
|
|
||||||
.map(|k| crate::config::binds::key_sequences::key_to_string(k))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
// Add the missing sequence_plus definition
|
|
||||||
let sequence_plus = sequence.iter()
|
|
||||||
.map(|k| crate::config::binds::key_sequences::key_to_string(k))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("+");
|
|
||||||
|
|
||||||
// Check for matches in all binding formats across all modes
|
|
||||||
// First check read_only mode
|
|
||||||
if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.read_only, &sequence_str, &sequence_plus, sequence) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check edit mode
|
|
||||||
if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.edit, &sequence_str, &sequence_plus, sequence) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check command mode
|
|
||||||
if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.command, &sequence_str, &sequence_plus, sequence) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check common keybindings
|
|
||||||
if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.common, &sequence_str, &sequence_plus, sequence) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally check global bindings
|
|
||||||
if let Some(action) = self.check_bindings_for_sequence(&self.keybindings.global, &sequence_str, &sequence_plus, sequence) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper method to check a specific mode's bindings against a key sequence
|
|
||||||
fn check_bindings_for_sequence<'a>(
|
|
||||||
&self,
|
|
||||||
mode_bindings: &'a HashMap<String, Vec<String>>,
|
|
||||||
sequence_str: &str,
|
|
||||||
sequence_plus: &str,
|
|
||||||
sequence: &[KeyCode]
|
|
||||||
) -> Option<&'a str> {
|
|
||||||
for (action, bindings) in mode_bindings {
|
|
||||||
for binding in bindings {
|
|
||||||
let normalized_binding = binding.to_lowercase();
|
|
||||||
|
|
||||||
// Check if binding matches any of our formats
|
|
||||||
if normalized_binding == sequence_str || normalized_binding == sequence_plus {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case for + format in bindings
|
|
||||||
if binding.contains('+') {
|
|
||||||
let normalized_sequence = sequence.iter()
|
|
||||||
.map(|k| crate::config::binds::key_sequences::key_to_string(k))
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
|
|
||||||
let binding_parts: Vec<&str> = binding.split('+').collect();
|
|
||||||
|
|
||||||
if binding_parts.len() == sequence.len() {
|
|
||||||
let matches = binding_parts.iter().enumerate().all(|(i, part)| {
|
|
||||||
part.to_lowercase() == normalized_sequence[i].to_lowercase()
|
|
||||||
});
|
|
||||||
|
|
||||||
if matches {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the current key sequence is a prefix of a longer binding
|
|
||||||
pub fn is_key_sequence_prefix(&self, sequence: &[KeyCode]) -> bool {
|
|
||||||
if sequence.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get string representation of the sequence
|
|
||||||
let sequence_str = sequence.iter()
|
|
||||||
.map(|k| crate::config::binds::key_sequences::key_to_string(k))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
// Check in each mode if our sequence is a prefix
|
|
||||||
if self.is_prefix_in_mode(&self.keybindings.read_only, &sequence_str, sequence) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.is_prefix_in_mode(&self.keybindings.edit, &sequence_str, sequence) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.is_prefix_in_mode(&self.keybindings.command, &sequence_str, sequence) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.is_prefix_in_mode(&self.keybindings.common, &sequence_str, sequence) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.is_prefix_in_mode(&self.keybindings.global, &sequence_str, sequence) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper method to check if a sequence is a prefix in a specific mode
|
|
||||||
fn is_prefix_in_mode(
|
|
||||||
&self,
|
|
||||||
mode_bindings: &HashMap<String, Vec<String>>,
|
|
||||||
sequence_str: &str,
|
|
||||||
sequence: &[KeyCode]
|
|
||||||
) -> bool {
|
|
||||||
for (_, bindings) in mode_bindings {
|
|
||||||
for binding in bindings {
|
|
||||||
let normalized_binding = binding.to_lowercase();
|
|
||||||
|
|
||||||
// Check standard format
|
|
||||||
if normalized_binding.starts_with(sequence_str) &&
|
|
||||||
normalized_binding.len() > sequence_str.len() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check + format
|
|
||||||
if binding.contains('+') {
|
|
||||||
let binding_parts: Vec<&str> = binding.split('+').collect();
|
|
||||||
let sequence_parts = sequence.iter()
|
|
||||||
.map(|k| crate::config::binds::key_sequences::key_to_string(k))
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
|
|
||||||
if binding_parts.len() > sequence_parts.len() {
|
|
||||||
let prefix_matches = sequence_parts.iter().enumerate().all(|(i, part)| {
|
|
||||||
binding_parts.get(i).map_or(false, |b| b.to_lowercase() == part.to_lowercase())
|
|
||||||
});
|
|
||||||
|
|
||||||
if prefix_matches {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unified action resolver for app-level actions
|
|
||||||
pub fn get_app_action(
|
|
||||||
&self,
|
|
||||||
key_code: crossterm::event::KeyCode,
|
|
||||||
modifiers: crossterm::event::KeyModifiers,
|
|
||||||
) -> Option<&str> {
|
|
||||||
// First check common actions
|
|
||||||
if let Some(action) = self.get_common_action(key_code, modifiers) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check read-only mode actions
|
|
||||||
if let Some(action) = self.get_read_only_action_for_key(key_code, modifiers) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check highlight mode actions
|
|
||||||
if let Some(action) = self.get_highlight_action_for_key(key_code, modifiers) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check edit mode actions
|
|
||||||
if let Some(action) = self.get_edit_action_for_key(key_code, modifiers) {
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
// client/src/config/key_sequences.rs
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct ParsedKey {
|
|
||||||
pub code: KeyCode,
|
|
||||||
pub modifiers: KeyModifiers,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct KeySequenceTracker {
|
|
||||||
pub current_sequence: Vec<KeyCode>,
|
|
||||||
pub last_key_time: Instant,
|
|
||||||
pub timeout: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeySequenceTracker {
|
|
||||||
pub fn new(timeout_ms: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
current_sequence: Vec::new(),
|
|
||||||
last_key_time: Instant::now(),
|
|
||||||
timeout: Duration::from_millis(timeout_ms),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
info!("KeySequenceTracker.reset() from {:?}", self.current_sequence);
|
|
||||||
self.current_sequence.clear();
|
|
||||||
self.last_key_time = Instant::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_key(&mut self, key: KeyCode) -> bool {
|
|
||||||
let now = Instant::now();
|
|
||||||
if now.duration_since(self.last_key_time) > self.timeout {
|
|
||||||
info!("KeySequenceTracker timeout — reset before adding {:?}", key);
|
|
||||||
self.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.current_sequence.push(key);
|
|
||||||
self.last_key_time = now;
|
|
||||||
info!("KeySequenceTracker state after add: {:?}", self.current_sequence);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_sequence(&self) -> Vec<KeyCode> {
|
|
||||||
self.current_sequence.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a sequence of keys to a string representation
|
|
||||||
pub fn sequence_to_string(&self) -> String {
|
|
||||||
self.current_sequence.iter().map(|k| key_to_string(k)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a sequence to a format with + between keys
|
|
||||||
pub fn sequence_to_plus_format(&self) -> String {
|
|
||||||
if self.current_sequence.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let parts: Vec<String> = self.current_sequence.iter()
|
|
||||||
.map(|k| key_to_string(k))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
parts.join("+")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(),
|
|
||||||
KeyCode::Up => "up".to_string(),
|
|
||||||
KeyCode::Down => "down".to_string(),
|
|
||||||
KeyCode::Esc => "esc".to_string(),
|
|
||||||
KeyCode::Enter => "enter".to_string(),
|
|
||||||
KeyCode::Backspace => "backspace".to_string(),
|
|
||||||
KeyCode::Delete => "delete".to_string(),
|
|
||||||
KeyCode::Tab => "tab".to_string(),
|
|
||||||
KeyCode::BackTab => "backtab".to_string(),
|
|
||||||
KeyCode::Home => "home".to_string(),
|
|
||||||
KeyCode::End => "end".to_string(),
|
|
||||||
KeyCode::PageUp => "pageup".to_string(),
|
|
||||||
KeyCode::PageDown => "pagedown".to_string(),
|
|
||||||
KeyCode::Insert => "insert".to_string(),
|
|
||||||
_ => format!("{:?}", key).to_lowercase(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
"down" => Some(KeyCode::Down),
|
|
||||||
"esc" => Some(KeyCode::Esc),
|
|
||||||
"enter" => Some(KeyCode::Enter),
|
|
||||||
"backspace" => Some(KeyCode::Backspace),
|
|
||||||
"delete" => Some(KeyCode::Delete),
|
|
||||||
"tab" => Some(KeyCode::Tab),
|
|
||||||
"backtab" => Some(KeyCode::BackTab),
|
|
||||||
"home" => Some(KeyCode::Home),
|
|
||||||
"end" => Some(KeyCode::End),
|
|
||||||
"pageup" => Some(KeyCode::PageUp),
|
|
||||||
"pagedown" => Some(KeyCode::PageDown),
|
|
||||||
"insert" => Some(KeyCode::Insert),
|
|
||||||
s if s.len() == 1 => s.chars().next().map(KeyCode::Char),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_binding(binding: &str) -> Vec<ParsedKey> {
|
|
||||||
let mut sequence = Vec::new();
|
|
||||||
|
|
||||||
// Split into multi-key sequence:
|
|
||||||
// - If contains space → sequence split by space
|
|
||||||
// - Else split by '+'
|
|
||||||
let parts: Vec<&str> = if binding.contains(' ') {
|
|
||||||
binding.split(' ').collect()
|
|
||||||
} else {
|
|
||||||
binding.split('+').collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
for part in parts {
|
|
||||||
if let Some(parsed) = parse_key_part(part) {
|
|
||||||
sequence.push(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sequence
|
|
||||||
}
|
|
||||||
|
|
||||||
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" | "space"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_key_part(part: &str) -> Option<ParsedKey> {
|
|
||||||
let mut modifiers = KeyModifiers::empty();
|
|
||||||
let mut code = None;
|
|
||||||
|
|
||||||
if part.contains('+') {
|
|
||||||
// This handles modifiers like "ctrl+s", "super+shift+f5"
|
|
||||||
let components: Vec<&str> = part.split('+').collect();
|
|
||||||
|
|
||||||
for component in components {
|
|
||||||
match component.to_lowercase().as_str() {
|
|
||||||
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
|
||||||
"shift" => modifiers |= KeyModifiers::SHIFT,
|
|
||||||
"alt" => modifiers |= KeyModifiers::ALT,
|
|
||||||
"super" | "windows" | "cmd" => modifiers |= KeyModifiers::SUPER,
|
|
||||||
"hyper" => modifiers |= KeyModifiers::HYPER,
|
|
||||||
"meta" => modifiers |= KeyModifiers::META,
|
|
||||||
_ => {
|
|
||||||
// Last component is the key
|
|
||||||
code = string_to_keycode(component);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Simple key without modifiers
|
|
||||||
code = string_to_keycode(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
code.map(|code| ParsedKey { code, modifiers })
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// src/config/colors.rs
|
|
||||||
pub mod themes;
|
|
||||||
|
|
||||||
pub use themes::*;
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// src/config/colors/themes.rs
|
|
||||||
use ratatui::style::Color;
|
|
||||||
use canvas::CanvasTheme;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Theme {
|
|
||||||
pub bg: Color,
|
|
||||||
pub fg: Color,
|
|
||||||
pub accent: Color,
|
|
||||||
pub secondary: Color,
|
|
||||||
pub highlight: Color,
|
|
||||||
pub warning: Color,
|
|
||||||
pub border: Color,
|
|
||||||
pub highlight_bg: Color,
|
|
||||||
pub inactive_highlight_bg: Color, // admin panel no idea what it really is
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Theme {
|
|
||||||
pub fn from_str(theme_name: &str) -> Self {
|
|
||||||
match theme_name.to_lowercase().as_str() {
|
|
||||||
"dark" => Self::dark(),
|
|
||||||
"high_contrast" => Self::high_contrast(),
|
|
||||||
_ => Self::light(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default light theme
|
|
||||||
pub fn light() -> Self {
|
|
||||||
Self {
|
|
||||||
bg: Color::Rgb(245, 245, 245), // Light gray
|
|
||||||
fg: Color::Rgb(64, 64, 64), // Dark gray
|
|
||||||
accent: Color::Rgb(173, 216, 230), // Pastel blue
|
|
||||||
secondary: Color::Rgb(255, 165, 0), // Orange for secondary
|
|
||||||
highlight: Color::Rgb(152, 251, 152), // Pastel green
|
|
||||||
warning: Color::Rgb(255, 182, 193), // Pastel pink
|
|
||||||
border: Color::Rgb(220, 220, 220), // Light gray border
|
|
||||||
highlight_bg: Color::Rgb(70, 70, 70), // Darker grey for highlight background
|
|
||||||
inactive_highlight_bg: Color::Rgb(50, 50, 50),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// High-contrast dark theme
|
|
||||||
pub fn dark() -> Self {
|
|
||||||
Self {
|
|
||||||
bg: Color::Rgb(30, 30, 30), // Dark background
|
|
||||||
fg: Color::Rgb(255, 255, 255), // White text
|
|
||||||
accent: Color::Rgb(0, 191, 255), // Bright blue
|
|
||||||
secondary: Color::Rgb(255, 215, 0), // Gold for secondary
|
|
||||||
highlight: Color::Rgb(50, 205, 50), // Bright green
|
|
||||||
warning: Color::Rgb(255, 99, 71), // Bright red
|
|
||||||
border: Color::Rgb(100, 100, 100), // Medium gray border
|
|
||||||
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
|
|
||||||
inactive_highlight_bg: Color::Rgb(50, 50, 50),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// High-contrast light theme
|
|
||||||
pub fn high_contrast() -> Self {
|
|
||||||
Self {
|
|
||||||
bg: Color::Rgb(255, 255, 255), // White background
|
|
||||||
fg: Color::Rgb(0, 0, 0), // Black text
|
|
||||||
accent: Color::Rgb(0, 0, 255), // Blue
|
|
||||||
secondary: Color::Rgb(255, 140, 0), // Dark orange for secondary
|
|
||||||
highlight: Color::Rgb(0, 128, 0), // Green
|
|
||||||
warning: Color::Rgb(255, 0, 0), // Red
|
|
||||||
border: Color::Rgb(0, 0, 0), // Black border
|
|
||||||
highlight_bg: Color::Rgb(180, 180, 180), // Lighter grey for highlight background
|
|
||||||
inactive_highlight_bg: Color::Rgb(50, 50, 50),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Theme {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::light() // Default to light theme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CanvasTheme for Theme {
|
|
||||||
fn bg(&self) -> Color {
|
|
||||||
self.bg
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fg(&self) -> Color {
|
|
||||||
self.fg
|
|
||||||
}
|
|
||||||
|
|
||||||
fn border(&self) -> Color {
|
|
||||||
self.border
|
|
||||||
}
|
|
||||||
|
|
||||||
fn accent(&self) -> Color {
|
|
||||||
self.accent
|
|
||||||
}
|
|
||||||
|
|
||||||
fn secondary(&self) -> Color {
|
|
||||||
self.secondary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn highlight(&self) -> Color {
|
|
||||||
self.highlight
|
|
||||||
}
|
|
||||||
|
|
||||||
fn highlight_bg(&self) -> Color {
|
|
||||||
self.highlight_bg
|
|
||||||
}
|
|
||||||
|
|
||||||
fn warning(&self) -> Color {
|
|
||||||
self.warning
|
|
||||||
}
|
|
||||||
|
|
||||||
fn suggestion_gray(&self) -> Color {
|
|
||||||
// Neutral gray for suggestions
|
|
||||||
Color::Rgb(128, 128, 128)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// src/config/mod.rs
|
|
||||||
|
|
||||||
pub mod binds;
|
|
||||||
pub mod colors;
|
|
||||||
pub mod storage;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// src/config/storage.rs
|
|
||||||
pub mod storage;
|
|
||||||
|
|
||||||
pub use storage::*;
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// src/config/storage/storage.rs
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs::{self, File};
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use tracing::{error, info};
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
pub const APP_NAME: &str = "komp_ac_client";
|
|
||||||
pub const TOKEN_FILE_NAME: &str = "auth.token";
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct StoredAuthData {
|
|
||||||
pub access_token: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub role: String,
|
|
||||||
pub username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_token_storage_path() -> Result<PathBuf> {
|
|
||||||
let state_dir = dirs::state_dir()
|
|
||||||
.or_else(|| dirs::home_dir().map(|home| home.join(".local").join("state")))
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not determine state directory"))?;
|
|
||||||
|
|
||||||
let app_state_dir = state_dir.join(APP_NAME);
|
|
||||||
fs::create_dir_all(&app_state_dir)
|
|
||||||
.with_context(|| format!("Failed to create app state directory at {:?}", app_state_dir))?;
|
|
||||||
|
|
||||||
Ok(app_state_dir.join(TOKEN_FILE_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_auth_data(data: &StoredAuthData) -> Result<()> {
|
|
||||||
let path = get_token_storage_path()?;
|
|
||||||
|
|
||||||
let json_data = serde_json::to_string(data)
|
|
||||||
.context("Failed to serialize auth data")?;
|
|
||||||
|
|
||||||
let mut file = File::create(&path)
|
|
||||||
.with_context(|| format!("Failed to create token file at {:?}", path))?;
|
|
||||||
|
|
||||||
file.write_all(json_data.as_bytes())
|
|
||||||
.context("Failed to write token data to file")?;
|
|
||||||
|
|
||||||
// Set file permissions to 600 (owner read/write only) on Unix
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
|
||||||
.context("Failed to set token file permissions")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Auth data saved to {:?}", path);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_auth_data() -> Result<Option<StoredAuthData>> {
|
|
||||||
let path = get_token_storage_path()?;
|
|
||||||
|
|
||||||
if !path.exists() {
|
|
||||||
info!("Token file not found at {:?}", path);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let json_data = fs::read_to_string(&path)
|
|
||||||
.with_context(|| format!("Failed to read token file at {:?}", path))?;
|
|
||||||
|
|
||||||
if json_data.trim().is_empty() {
|
|
||||||
info!("Token file is empty at {:?}", path);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
match serde_json::from_str::<StoredAuthData>(&json_data) {
|
|
||||||
Ok(data) => {
|
|
||||||
info!("Auth data loaded from {:?}", path);
|
|
||||||
Ok(Some(data))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to deserialize token data from {:?}: {}. Deleting corrupt file.", path, e);
|
|
||||||
if let Err(del_e) = fs::remove_file(&path) {
|
|
||||||
error!("Failed to delete corrupt token file: {}", del_e);
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_auth_data() -> Result<()> {
|
|
||||||
let path = get_token_storage_path()?;
|
|
||||||
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(&path)
|
|
||||||
.with_context(|| format!("Failed to delete token file at {:?}", path))?;
|
|
||||||
info!("Token file deleted from {:?}", path);
|
|
||||||
} else {
|
|
||||||
info!("Token file not found for deletion at {:?}", path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// src/dialog/functions.rs
|
|
||||||
|
|
||||||
use crate::dialog::DialogState;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::ui::handlers::context::DialogPurpose;
|
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
pub fn show_dialog(
|
|
||||||
&mut self,
|
|
||||||
title: &str,
|
|
||||||
message: &str,
|
|
||||||
buttons: Vec<String>,
|
|
||||||
purpose: DialogPurpose,
|
|
||||||
) {
|
|
||||||
self.ui.dialog.dialog_title = title.to_string();
|
|
||||||
self.ui.dialog.dialog_message = message.to_string();
|
|
||||||
self.ui.dialog.dialog_buttons = buttons;
|
|
||||||
self.ui.dialog.dialog_active_button_index = 0;
|
|
||||||
self.ui.dialog.purpose = Some(purpose);
|
|
||||||
self.ui.dialog.is_loading = false;
|
|
||||||
self.ui.dialog.dialog_show = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
|
|
||||||
self.ui.dialog.dialog_title = title.to_string();
|
|
||||||
self.ui.dialog.dialog_message = message.to_string();
|
|
||||||
self.ui.dialog.dialog_buttons.clear();
|
|
||||||
self.ui.dialog.dialog_active_button_index = 0;
|
|
||||||
self.ui.dialog.purpose = None;
|
|
||||||
self.ui.dialog.is_loading = true;
|
|
||||||
self.ui.dialog.dialog_show = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_dialog_content(
|
|
||||||
&mut self,
|
|
||||||
message: &str,
|
|
||||||
buttons: Vec<String>,
|
|
||||||
purpose: DialogPurpose,
|
|
||||||
) {
|
|
||||||
if self.ui.dialog.dialog_show {
|
|
||||||
self.ui.dialog.dialog_message = message.to_string();
|
|
||||||
self.ui.dialog.dialog_buttons = buttons;
|
|
||||||
self.ui.dialog.dialog_active_button_index = 0;
|
|
||||||
self.ui.dialog.purpose = Some(purpose);
|
|
||||||
self.ui.dialog.is_loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn hide_dialog(&mut self) {
|
|
||||||
self.ui.dialog.dialog_show = false;
|
|
||||||
self.ui.dialog.dialog_title.clear();
|
|
||||||
self.ui.dialog.dialog_message.clear();
|
|
||||||
self.ui.dialog.dialog_buttons.clear();
|
|
||||||
self.ui.dialog.dialog_active_button_index = 0;
|
|
||||||
self.ui.dialog.purpose = None;
|
|
||||||
self.ui.dialog.is_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_dialog_button(&mut self) {
|
|
||||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
|
||||||
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
|
|
||||||
% self.ui.dialog.dialog_buttons.len();
|
|
||||||
self.ui.dialog.dialog_active_button_index = next_index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn previous_dialog_button(&mut self) {
|
|
||||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
|
||||||
let len = self.ui.dialog.dialog_buttons.len();
|
|
||||||
let prev_index =
|
|
||||||
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
|
|
||||||
self.ui.dialog.dialog_active_button_index = prev_index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
|
|
||||||
self.ui.dialog
|
|
||||||
.dialog_buttons
|
|
||||||
.get(self.ui.dialog.dialog_active_button_index)
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
// src/dialog/logic.rs
|
|
||||||
|
|
||||||
// TODO(dialog-refactor):
|
|
||||||
// Currently this module (`handle_dialog_event`) contains page-specific logic
|
|
||||||
// (e.g. Login, Register, Admin, SaveTable). This couples the dialog crate
|
|
||||||
// to application pages and business logic.
|
|
||||||
//
|
|
||||||
// Refactor plan:
|
|
||||||
// 1. Keep dialog generic: only handle navigation (next/prev/select) and return
|
|
||||||
// a `DialogResult` (Dismissed | Selected { purpose, index }).
|
|
||||||
// 2. Move all page-specific actions (e.g. login::back_to_main, register::back_to_login,
|
|
||||||
// handle_delete_selected_columns, buffer_state.update_history) into the
|
|
||||||
// respective page or event handler (e.g. modes/handlers/event.rs).
|
|
||||||
// 3. Dialog crate should only provide state, rendering, and generic navigation.
|
|
||||||
// Pages decide what to do when a dialog button is pressed.
|
|
||||||
|
|
||||||
use crossterm::event::{Event, KeyCode};
|
|
||||||
use crate::config::binds::config::Config;
|
|
||||||
use crate::ui::handlers::context::DialogPurpose;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::buffer::AppView;
|
|
||||||
use crate::buffer::state::BufferState;
|
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
|
||||||
use crate::pages::register;
|
|
||||||
use crate::pages::login;
|
|
||||||
use crate::pages::admin_panel::add_table::logic::handle_delete_selected_columns;
|
|
||||||
use crate::pages::routing::{Router, Page};
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
/// Handles key events specifically when a dialog is active.
|
|
||||||
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
|
|
||||||
/// otherwise returns None.
|
|
||||||
pub async fn handle_dialog_event(
|
|
||||||
event: &Event,
|
|
||||||
config: &Config,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
buffer_state: &mut BufferState,
|
|
||||||
router: &mut Router,
|
|
||||||
) -> Option<Result<EventOutcome>> {
|
|
||||||
if let Event::Key(key) = event {
|
|
||||||
// Always allow Esc to dismiss
|
|
||||||
if key.code == KeyCode::Esc {
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check general bindings for dialog actions
|
|
||||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
|
||||||
match action {
|
|
||||||
"move_down" | "next_option" => {
|
|
||||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
|
||||||
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
|
|
||||||
if num_buttons > 0 && current_index < num_buttons - 1 {
|
|
||||||
app_state.ui.dialog.dialog_active_button_index += 1;
|
|
||||||
}
|
|
||||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
|
||||||
}
|
|
||||||
"move_up" | "previous_option" => {
|
|
||||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
|
||||||
if current_index > 0 {
|
|
||||||
app_state.ui.dialog.dialog_active_button_index -= 1;
|
|
||||||
}
|
|
||||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
|
||||||
}
|
|
||||||
"select" => {
|
|
||||||
let selected_index = app_state.ui.dialog.dialog_active_button_index;
|
|
||||||
let purpose = match app_state.ui.dialog.purpose {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Internal Error: Dialog context lost".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle Dialog Actions Directly Here
|
|
||||||
match purpose {
|
|
||||||
DialogPurpose::LoginSuccess => match selected_index {
|
|
||||||
0 => {
|
|
||||||
// "Menu" button selected
|
|
||||||
app_state.hide_dialog();
|
|
||||||
if let Page::Login(state) = &mut router.current {
|
|
||||||
let message =
|
|
||||||
login::back_to_main(state, app_state, buffer_state).await;
|
|
||||||
return Some(Ok(EventOutcome::Ok(message)));
|
|
||||||
}
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Login state not active".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Unknown dialog button selected".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DialogPurpose::LoginFailed => match selected_index {
|
|
||||||
0 => {
|
|
||||||
// "OK" button selected
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Login failed dialog dismissed".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Unknown dialog button selected".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DialogPurpose::RegisterSuccess => match selected_index {
|
|
||||||
0 => {
|
|
||||||
// "OK" button for RegisterSuccess
|
|
||||||
app_state.hide_dialog();
|
|
||||||
if let Page::Register(state) = &mut router.current {
|
|
||||||
let message =
|
|
||||||
register::back_to_login(state, app_state, buffer_state)
|
|
||||||
.await;
|
|
||||||
return Some(Ok(EventOutcome::Ok(message)));
|
|
||||||
}
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Register state not active".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Unknown dialog button selected".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DialogPurpose::RegisterFailed => match selected_index {
|
|
||||||
0 => {
|
|
||||||
// "OK" button for RegisterFailed
|
|
||||||
app_state.hide_dialog(); // Just dismiss
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Register failed dialog dismissed".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Unknown dialog button selected".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DialogPurpose::ConfirmDeleteColumns => match selected_index {
|
|
||||||
0 => {
|
|
||||||
// "Confirm" button selected
|
|
||||||
if let Page::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(
|
|
||||||
"AddTable page not active".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
// "Cancel" button selected
|
|
||||||
app_state.hide_dialog();
|
|
||||||
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
|
|
||||||
}
|
|
||||||
_ => { /* Handle unexpected index */ }
|
|
||||||
},
|
|
||||||
DialogPurpose::SaveTableSuccess => match selected_index {
|
|
||||||
0 => {
|
|
||||||
// "OK" button selected
|
|
||||||
app_state.hide_dialog();
|
|
||||||
buffer_state.update_history(AppView::Admin); // Navigate back
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Save success dialog dismissed.".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
_ => { /* Handle unexpected index */ }
|
|
||||||
},
|
|
||||||
DialogPurpose::SaveLogicSuccess => match selected_index {
|
|
||||||
0 => {
|
|
||||||
// "OK" button selected
|
|
||||||
app_state.hide_dialog();
|
|
||||||
buffer_state.update_history(AppView::Admin);
|
|
||||||
return Some(Ok(EventOutcome::Ok(
|
|
||||||
"Save success dialog dismissed.".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
_ => { /* Handle unexpected index */ }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // Ignore other general actions when dialog is shown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If it was a key event but not handled above, consume it
|
|
||||||
Some(Ok(EventOutcome::Ok(String::new())))
|
|
||||||
} else {
|
|
||||||
// If it wasn't a key event, consume it too while dialog is active
|
|
||||||
Some(Ok(EventOutcome::Ok(String::new())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// src/dialog/mod.rs
|
|
||||||
|
|
||||||
pub mod ui;
|
|
||||||
pub mod logic;
|
|
||||||
pub mod state;
|
|
||||||
pub mod functions;
|
|
||||||
|
|
||||||
pub use ui::render_dialog;
|
|
||||||
pub use logic::handle_dialog_event;
|
|
||||||
pub use state::DialogState;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// src/dialog/state.rs
|
|
||||||
use crate::ui::handlers::context::DialogPurpose;
|
|
||||||
|
|
||||||
pub struct DialogState {
|
|
||||||
pub dialog_show: bool,
|
|
||||||
pub dialog_title: String,
|
|
||||||
pub dialog_message: String,
|
|
||||||
pub dialog_buttons: Vec<String>,
|
|
||||||
pub dialog_active_button_index: usize,
|
|
||||||
pub purpose: Option<DialogPurpose>,
|
|
||||||
pub is_loading: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DialogState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
dialog_show: false,
|
|
||||||
dialog_title: String::new(),
|
|
||||||
dialog_message: String::new(),
|
|
||||||
dialog_buttons: Vec::new(),
|
|
||||||
dialog_active_button_index: 0,
|
|
||||||
purpose: None,
|
|
||||||
is_loading: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
// src/dialog/ui.rs
|
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
|
||||||
prelude::Alignment,
|
|
||||||
style::{Modifier, Style},
|
|
||||||
text::{Line, Span, Text},
|
|
||||||
widgets::{Block, BorderType, Borders, Paragraph, Clear},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use unicode_segmentation::UnicodeSegmentation; // For grapheme clusters
|
|
||||||
use unicode_width::UnicodeWidthStr; // For accurate width calculation
|
|
||||||
|
|
||||||
pub fn render_dialog(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
dialog_title: &str,
|
|
||||||
dialog_message: &str,
|
|
||||||
dialog_buttons: &[String],
|
|
||||||
dialog_active_button_index: usize,
|
|
||||||
is_loading: bool,
|
|
||||||
) {
|
|
||||||
// Calculate required height based on the actual number of lines in the message
|
|
||||||
let message_lines: Vec<_> = dialog_message.lines().collect();
|
|
||||||
let message_height = message_lines.len() as u16;
|
|
||||||
let button_row_height = if dialog_buttons.is_empty() { 0 } else { 3 };
|
|
||||||
let vertical_padding = 2; // Block borders (top/bottom)
|
|
||||||
let inner_vertical_margin = 2; // Margin inside block (top/bottom)
|
|
||||||
|
|
||||||
// Calculate required height based on actual message lines
|
|
||||||
let required_inner_height =
|
|
||||||
message_height + button_row_height + inner_vertical_margin;
|
|
||||||
let required_total_height = required_inner_height + vertical_padding;
|
|
||||||
|
|
||||||
// Use a fixed percentage width, clamped to min/max
|
|
||||||
let width_percentage: u16 = 60;
|
|
||||||
let dialog_width = (area.width * width_percentage / 100)
|
|
||||||
.max(20) // Minimum width
|
|
||||||
.min(area.width); // Maximum width
|
|
||||||
|
|
||||||
// Ensure height doesn't exceed available area
|
|
||||||
let dialog_height = required_total_height.min(area.height);
|
|
||||||
|
|
||||||
// Calculate centered area manually
|
|
||||||
let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
|
|
||||||
let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
|
|
||||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
|
||||||
|
|
||||||
// Clear the area first before drawing the dialog
|
|
||||||
f.render_widget(Clear, dialog_area);
|
|
||||||
|
|
||||||
let block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(Style::default().fg(theme.accent))
|
|
||||||
.title(format!(" {} ", dialog_title)) // Add padding to title
|
|
||||||
.style(Style::default().bg(theme.bg));
|
|
||||||
|
|
||||||
f.render_widget(block, dialog_area);
|
|
||||||
|
|
||||||
// Calculate inner area *after* defining the block
|
|
||||||
let inner_area = dialog_area.inner(Margin {
|
|
||||||
horizontal: 2, // Left/Right padding inside border
|
|
||||||
vertical: 1, // Top/Bottom padding inside border
|
|
||||||
});
|
|
||||||
|
|
||||||
if is_loading {
|
|
||||||
// --- Loading State ---
|
|
||||||
let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading
|
|
||||||
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC))
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
// Render loading message centered in the inner area
|
|
||||||
f.render_widget(loading_text, inner_area);
|
|
||||||
} else {
|
|
||||||
// --- Normal State (Message + Buttons) ---
|
|
||||||
|
|
||||||
// Layout for Message and Buttons based on actual message height
|
|
||||||
let mut constraints = vec![
|
|
||||||
// Allocate space for message, ensuring at least 1 line height
|
|
||||||
Constraint::Length(message_height.max(1)), // Use actual calculated height
|
|
||||||
];
|
|
||||||
if button_row_height > 0 {
|
|
||||||
constraints.push(Constraint::Length(button_row_height));
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints(constraints)
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
// Render Message
|
|
||||||
let available_width = inner_area.width as usize;
|
|
||||||
let ellipsis = "...";
|
|
||||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
|
||||||
|
|
||||||
let processed_lines: Vec<Line> = message_lines
|
|
||||||
.into_iter()
|
|
||||||
.map(|line| {
|
|
||||||
let line_width = UnicodeWidthStr::width(line);
|
|
||||||
if line_width > available_width {
|
|
||||||
// Truncate with ellipsis
|
|
||||||
let mut truncated_len = 0;
|
|
||||||
let mut current_width = 0;
|
|
||||||
for (idx, grapheme) in line.grapheme_indices(true) {
|
|
||||||
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
|
||||||
if current_width + grapheme_width
|
|
||||||
> available_width.saturating_sub(ellipsis_width)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current_width += grapheme_width;
|
|
||||||
truncated_len = idx + grapheme.len();
|
|
||||||
}
|
|
||||||
let truncated_line =
|
|
||||||
format!("{}{}", &line[..truncated_len], ellipsis);
|
|
||||||
Line::from(Span::styled(
|
|
||||||
truncated_line,
|
|
||||||
Style::default().fg(theme.fg),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Line::from(Span::styled(line, Style::default().fg(theme.fg)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let message_paragraph =
|
|
||||||
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
|
|
||||||
f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
|
|
||||||
|
|
||||||
// Render Buttons if they exist and there's a chunk for them
|
|
||||||
if !dialog_buttons.is_empty() && chunks.len() > 1 {
|
|
||||||
let button_area = chunks[1];
|
|
||||||
let button_count = dialog_buttons.len();
|
|
||||||
|
|
||||||
let button_constraints = std::iter::repeat(Constraint::Ratio(
|
|
||||||
1,
|
|
||||||
button_count as u32,
|
|
||||||
))
|
|
||||||
.take(button_count)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let button_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints(button_constraints)
|
|
||||||
.horizontal_margin(1) // Add space between buttons
|
|
||||||
.split(button_area);
|
|
||||||
|
|
||||||
for (i, button_label) in dialog_buttons.iter().enumerate() {
|
|
||||||
if i >= button_chunks.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_active = i == dialog_active_button_index;
|
|
||||||
let (button_style, border_style) = if is_active {
|
|
||||||
(
|
|
||||||
Style::default()
|
|
||||||
.fg(theme.highlight)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
Style::default().fg(theme.accent),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
Style::default().fg(theme.fg),
|
|
||||||
Style::default().fg(theme.border),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let button_block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Plain)
|
|
||||||
.border_style(border_style);
|
|
||||||
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(button_label.as_str())
|
|
||||||
.block(button_block)
|
|
||||||
.style(button_style)
|
|
||||||
.alignment(Alignment::Center),
|
|
||||||
button_chunks[i],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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),
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
// 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};
|
|
||||||
use crate::input::leader::{leader_has_any_start, leader_is_prefix, leader_match_action};
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
leader_seq: KeySequenceTracker,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InputEngine {
|
|
||||||
pub fn new(normal_timeout_ms: u64, leader_timeout_ms: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
seq: KeySequenceTracker::new(normal_timeout_ms),
|
|
||||||
leader_seq: KeySequenceTracker::new(leader_timeout_ms),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset_sequence(&mut self) {
|
|
||||||
info!("InputEngine.reset_sequence() leader_seq_before={:?}", self.leader_seq.current_sequence);
|
|
||||||
self.seq.reset();
|
|
||||||
self.leader_seq.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_active_sequence(&self) -> bool {
|
|
||||||
!self.seq.current_sequence.is_empty()
|
|
||||||
|| !self.leader_seq.current_sequence.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
// Also reset leader sequence to avoid leaving a stale "space" active
|
|
||||||
info!("Overlay active → reset leader_seq (was {:?})", self.leader_seq.current_sequence);
|
|
||||||
self.leader_seq.reset();
|
|
||||||
return InputOutcome::PassThrough;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Space-led multi-key sequences (leader = space)
|
|
||||||
let space = KeyCode::Char(' ');
|
|
||||||
let leader_active = !self.leader_seq.current_sequence.is_empty()
|
|
||||||
&& self.leader_seq.current_sequence[0] == space;
|
|
||||||
|
|
||||||
// Keep collecting leader sequence even if allow_navigation_capture is false.
|
|
||||||
if leader_active {
|
|
||||||
self.leader_seq.add_key(key_event.code);
|
|
||||||
let sequence = self.leader_seq.get_sequence();
|
|
||||||
info!(
|
|
||||||
"Leader active updated: {:?} (added {:?})",
|
|
||||||
sequence, key_event.code
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(action_str) = leader_match_action(config, &sequence) {
|
|
||||||
info!("Leader matched '{}' with sequence {:?}", action_str, sequence);
|
|
||||||
if let Some(app_action) = map_action_string(action_str, ctx) {
|
|
||||||
self.leader_seq.reset();
|
|
||||||
return InputOutcome::Action(app_action);
|
|
||||||
}
|
|
||||||
self.leader_seq.reset();
|
|
||||||
return InputOutcome::PassThrough;
|
|
||||||
}
|
|
||||||
|
|
||||||
if leader_is_prefix(config, &sequence) {
|
|
||||||
info!("Leader prefix continuing...");
|
|
||||||
return InputOutcome::Pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Leader sequence reset (no match/prefix).");
|
|
||||||
self.leader_seq.reset();
|
|
||||||
// fall through to regular handling of this key
|
|
||||||
} else if ctx.allow_navigation_capture
|
|
||||||
&& key_event.code == space
|
|
||||||
&& leader_has_any_start(config)
|
|
||||||
{
|
|
||||||
// Start a leader sequence only if capturing is allowed
|
|
||||||
self.leader_seq.reset();
|
|
||||||
self.leader_seq.add_key(space);
|
|
||||||
info!("Leader started: {:?}", self.leader_seq.get_sequence());
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// src/input/leader.rs
|
|
||||||
use crate::config::binds::config::Config;
|
|
||||||
use crate::config::binds::key_sequences::parse_binding;
|
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
|
|
||||||
/// Collect leader (= space-prefixed) bindings from *all* binding maps
|
|
||||||
fn leader_bindings<'a>(config: &'a Config) -> Vec<(&'a str, Vec<KeyCode>)> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
|
|
||||||
// Include all keybinding maps, not just global
|
|
||||||
let all_modes: Vec<&std::collections::HashMap<String, Vec<String>>> = vec![
|
|
||||||
&config.keybindings.general,
|
|
||||||
&config.keybindings.read_only,
|
|
||||||
&config.keybindings.edit,
|
|
||||||
&config.keybindings.highlight,
|
|
||||||
&config.keybindings.command,
|
|
||||||
&config.keybindings.common,
|
|
||||||
&config.keybindings.global,
|
|
||||||
];
|
|
||||||
|
|
||||||
for mode in all_modes {
|
|
||||||
for (action, bindings) in mode {
|
|
||||||
for b in bindings {
|
|
||||||
let parsed = parse_binding(b);
|
|
||||||
if parsed.first().map(|pk| pk.code) == Some(KeyCode::Char(' ')) {
|
|
||||||
let codes =
|
|
||||||
parsed.into_iter().map(|pk| pk.code).collect::<Vec<_>>();
|
|
||||||
out.push((action.as_str(), codes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is there any leader binding configured at all?
|
|
||||||
pub fn leader_has_any_start(config: &Config) -> bool {
|
|
||||||
leader_bindings(config)
|
|
||||||
.iter()
|
|
||||||
.any(|(_, seq)| seq.first() == Some(&KeyCode::Char(' ')))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is `sequence` a prefix of any configured leader sequence?
|
|
||||||
pub fn leader_is_prefix(config: &Config, sequence: &[KeyCode]) -> bool {
|
|
||||||
if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (_, full) in leader_bindings(config) {
|
|
||||||
if full.len() > sequence.len()
|
|
||||||
&& full.iter().zip(sequence.iter()).all(|(a, b)| a == b)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is `sequence` an exact leader match? If yes, return the action string.
|
|
||||||
pub fn leader_match_action<'a>(
|
|
||||||
config: &'a Config,
|
|
||||||
sequence: &[KeyCode],
|
|
||||||
) -> Option<&'a str> {
|
|
||||||
if sequence.is_empty() || sequence[0] != KeyCode::Char(' ') {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
for (action, full) in leader_bindings(config) {
|
|
||||||
if full.len() == sequence.len()
|
|
||||||
&& full.iter().zip(sequence.iter()).all(|(a, b)| a == b)
|
|
||||||
{
|
|
||||||
return Some(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// src/input/mod.rs
|
|
||||||
pub mod action;
|
|
||||||
pub mod engine;
|
|
||||||
pub mod leader;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// client/src/lib.rs
|
|
||||||
pub mod ui;
|
|
||||||
pub mod tui;
|
|
||||||
pub mod config;
|
|
||||||
pub mod state;
|
|
||||||
pub mod components;
|
|
||||||
pub mod modes;
|
|
||||||
pub mod services;
|
|
||||||
pub mod utils;
|
|
||||||
pub mod buffer;
|
|
||||||
pub mod sidebar;
|
|
||||||
pub mod dialog;
|
|
||||||
pub mod search;
|
|
||||||
pub mod bottom_panel;
|
|
||||||
pub mod pages;
|
|
||||||
pub mod movement;
|
|
||||||
pub mod input;
|
|
||||||
|
|
||||||
pub use ui::run_ui;
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// client/src/main.rs
|
|
||||||
use client::run_ui;
|
|
||||||
#[cfg(feature = "ui-debug")]
|
|
||||||
use client::utils::debug_logger::UiDebugWriter;
|
|
||||||
use dotenvy::dotenv;
|
|
||||||
use anyhow::Result;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
#[cfg(feature = "ui-debug")]
|
|
||||||
{
|
|
||||||
use std::sync::Once;
|
|
||||||
static INIT_LOGGER: Once = Once::new();
|
|
||||||
|
|
||||||
INIT_LOGGER.call_once(|| {
|
|
||||||
let writer = UiDebugWriter::new();
|
|
||||||
let _ = tracing_subscriber::fmt()
|
|
||||||
.with_max_level(tracing::Level::DEBUG)
|
|
||||||
.with_target(false)
|
|
||||||
.without_time()
|
|
||||||
.with_writer(move || writer.clone())
|
|
||||||
// Filter out noisy grpc/h2 internals
|
|
||||||
.with_env_filter("client=debug,tonic=info,h2=info,tower=info")
|
|
||||||
.try_init();
|
|
||||||
|
|
||||||
client::utils::debug_logger::spawn_file_logger();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "ui-debug"))]
|
|
||||||
{
|
|
||||||
if env::var("ENABLE_TRACING").is_ok() {
|
|
||||||
let _ = tracing_subscriber::fmt::try_init();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dotenv().ok();
|
|
||||||
run_ui().await
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// src/modes/canvas/common_mode.rs
|
|
||||||
|
|
||||||
use crate::tui::terminal::core::TerminalCore;
|
|
||||||
use crate::state::pages::auth::AuthState;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::services::grpc_client::GrpcClient;
|
|
||||||
use crate::services::auth::AuthClient;
|
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
|
||||||
crate::pages::forms::logic::SaveOutcome;
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use crate::tui::functions::common::{
|
|
||||||
form::{save as form_save, revert as form_revert},
|
|
||||||
login::{save as login_save, revert as login_revert},
|
|
||||||
register::{revert as register_revert},
|
|
||||||
};
|
|
||||||
use crate::pages::routing::{Router, Page};
|
|
||||||
|
|
||||||
pub async fn handle_core_action(
|
|
||||||
action: &str,
|
|
||||||
auth_state: &mut AuthState,
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
auth_client: &mut AuthClient,
|
|
||||||
terminal: &mut TerminalCore,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
router: &mut Router,
|
|
||||||
) -> Result<EventOutcome> {
|
|
||||||
match action {
|
|
||||||
"save" => {
|
|
||||||
match &mut router.current {
|
|
||||||
Page::Login(state) => {
|
|
||||||
let message = login_save(auth_state, state, auth_client, app_state)
|
|
||||||
.await
|
|
||||||
.context("Login save action failed")?;
|
|
||||||
Ok(EventOutcome::Ok(message))
|
|
||||||
}
|
|
||||||
Page::Form(form_state) => {
|
|
||||||
let save_outcome = form_save(app_state, form_state, grpc_client)
|
|
||||||
.await
|
|
||||||
.context("Form save action failed")?;
|
|
||||||
let message = match save_outcome {
|
|
||||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
|
||||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
|
||||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
|
||||||
};
|
|
||||||
Ok(EventOutcome::DataSaved(save_outcome, message))
|
|
||||||
}
|
|
||||||
_ => Ok(EventOutcome::Ok("Save not applicable".into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"force_quit" => {
|
|
||||||
terminal.cleanup()?;
|
|
||||||
Ok(EventOutcome::Exit("Force exiting without saving.".to_string()))
|
|
||||||
}
|
|
||||||
"save_and_quit" => {
|
|
||||||
let message = match &mut router.current {
|
|
||||||
Page::Login(state) => {
|
|
||||||
login_save(auth_state, state, auth_client, app_state)
|
|
||||||
.await
|
|
||||||
.context("Login save and quit action failed")?
|
|
||||||
}
|
|
||||||
Page::Form(form_state) => {
|
|
||||||
let save_outcome = form_save(app_state, form_state, grpc_client).await?;
|
|
||||||
match save_outcome {
|
|
||||||
SaveOutcome::NoChange => "No changes to save.".to_string(),
|
|
||||||
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
|
|
||||||
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => "Save not applicable".to_string(),
|
|
||||||
};
|
|
||||||
terminal.cleanup()?;
|
|
||||||
Ok(EventOutcome::Exit(format!("{}. Exiting application.", message)))
|
|
||||||
}
|
|
||||||
"revert" => {
|
|
||||||
match &mut router.current {
|
|
||||||
Page::Login(state) => {
|
|
||||||
let message = login_revert(state, app_state).await;
|
|
||||||
Ok(EventOutcome::Ok(message))
|
|
||||||
}
|
|
||||||
Page::Register(state) => {
|
|
||||||
let message = register_revert(state, app_state).await;
|
|
||||||
Ok(EventOutcome::Ok(message))
|
|
||||||
}
|
|
||||||
Page::Form(form_state) => {
|
|
||||||
let message = form_revert(form_state, grpc_client)
|
|
||||||
.await
|
|
||||||
.context("Form revert action failed")?;
|
|
||||||
Ok(EventOutcome::Ok(message))
|
|
||||||
}
|
|
||||||
_ => Ok(EventOutcome::Ok("Revert not applicable".into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Ok(EventOutcome::Ok(format!("Core action not handled: {}", action))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// src/client/modes/common.rs
|
|
||||||
pub mod command_mode;
|
|
||||||
pub mod highlight;
|
|
||||||
pub mod commands;
|
|
||||||
|
|
||||||
pub use commands::*;
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
// src/modes/common/command_mode.rs
|
|
||||||
|
|
||||||
use crossterm::event::{KeyEvent, KeyCode, KeyModifiers};
|
|
||||||
use crate::config::binds::config::Config;
|
|
||||||
use crate::services::grpc_client::GrpcClient;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::modes::common::commands::CommandHandler;
|
|
||||||
use crate::tui::terminal::core::TerminalCore;
|
|
||||||
use crate::pages::forms::logic::{save, revert ,SaveOutcome};
|
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
|
||||||
use crate::pages::routing::{Router, Page};
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
pub async fn handle_command_event(
|
|
||||||
key: KeyEvent,
|
|
||||||
config: &Config,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
router: &mut Router,
|
|
||||||
command_input: &mut String,
|
|
||||||
command_message: &mut String,
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
command_handler: &mut CommandHandler,
|
|
||||||
terminal: &mut TerminalCore,
|
|
||||||
current_position: &mut u64,
|
|
||||||
total_count: u64,
|
|
||||||
) -> Result<EventOutcome> {
|
|
||||||
// Exit command mode
|
|
||||||
if config.is_exit_command_mode(key.code, key.modifiers) {
|
|
||||||
command_input.clear();
|
|
||||||
*command_message = "".to_string();
|
|
||||||
return Ok(EventOutcome::Ok("Exited command mode".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute command
|
|
||||||
if config.is_command_execute(key.code, key.modifiers) {
|
|
||||||
return process_command(
|
|
||||||
config,
|
|
||||||
app_state,
|
|
||||||
router,
|
|
||||||
command_input,
|
|
||||||
command_message,
|
|
||||||
grpc_client,
|
|
||||||
command_handler,
|
|
||||||
terminal,
|
|
||||||
current_position,
|
|
||||||
total_count,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backspace
|
|
||||||
if config.is_command_backspace(key.code, key.modifiers) {
|
|
||||||
command_input.pop();
|
|
||||||
return Ok(EventOutcome::Ok("".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular character input
|
|
||||||
if let KeyCode::Char(c) = key.code {
|
|
||||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
|
||||||
command_input.push(c);
|
|
||||||
return Ok(EventOutcome::Ok("".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(EventOutcome::Ok("".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_command(
|
|
||||||
config: &Config,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
router: &mut Router,
|
|
||||||
command_input: &mut String,
|
|
||||||
command_message: &mut String,
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
command_handler: &mut CommandHandler,
|
|
||||||
terminal: &mut TerminalCore,
|
|
||||||
current_position: &mut u64,
|
|
||||||
total_count: u64,
|
|
||||||
) -> Result<EventOutcome> {
|
|
||||||
let command = command_input.trim().to_string();
|
|
||||||
if command.is_empty() {
|
|
||||||
*command_message = "Empty command".to_string();
|
|
||||||
return Ok(EventOutcome::Ok(command_message.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = config.get_action_for_command(&command).unwrap_or("unknown");
|
|
||||||
|
|
||||||
match action {
|
|
||||||
"force_quit" | "save_and_quit" | "quit" => {
|
|
||||||
let (should_exit, message) = command_handler
|
|
||||||
.handle_command(action, terminal, app_state, router)
|
|
||||||
.await?;
|
|
||||||
command_input.clear();
|
|
||||||
if should_exit {
|
|
||||||
Ok(EventOutcome::Exit(message))
|
|
||||||
} else {
|
|
||||||
Ok(EventOutcome::Ok(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"save" => {
|
|
||||||
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" => {
|
|
||||||
if let Page::Form(path) = &router.current {
|
|
||||||
let message = revert(app_state, path, grpc_client).await?;
|
|
||||||
command_input.clear();
|
|
||||||
Ok(EventOutcome::Ok(message))
|
|
||||||
} else {
|
|
||||||
Ok(EventOutcome::Ok("Not in a form page".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let message = format!("Unhandled action: {}", action);
|
|
||||||
command_input.clear();
|
|
||||||
Ok(EventOutcome::Ok(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// src/modes/common/commands.rs
|
|
||||||
use crate::tui::terminal::core::TerminalCore;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::pages::routing::{Router, Page};
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
pub struct CommandHandler;
|
|
||||||
|
|
||||||
impl CommandHandler {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_command(
|
|
||||||
&mut self,
|
|
||||||
action: &str,
|
|
||||||
terminal: &mut TerminalCore,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
router: &Router,
|
|
||||||
) -> Result<(bool, String)> {
|
|
||||||
match action {
|
|
||||||
"quit" => self.handle_quit(terminal, app_state, router).await,
|
|
||||||
"force_quit" => self.handle_force_quit(terminal).await,
|
|
||||||
"save_and_quit" => self.handle_save_quit(terminal).await,
|
|
||||||
_ => Ok((false, format!("Unknown command: {}", action))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_quit(
|
|
||||||
&self,
|
|
||||||
terminal: &mut TerminalCore,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
router: &Router,
|
|
||||||
) -> Result<(bool, String)> {
|
|
||||||
// Use router to check unsaved changes
|
|
||||||
let has_unsaved = match &router.current {
|
|
||||||
Page::Login(page) => page.state.has_unsaved_changes(),
|
|
||||||
Page::Register(state) => state.has_unsaved_changes(),
|
|
||||||
Page::Form(path) => app_state
|
|
||||||
.form_state_for_path_ref(path)
|
|
||||||
.map(|fs| fs.has_unsaved_changes())
|
|
||||||
.unwrap_or(false),
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !has_unsaved {
|
|
||||||
terminal.cleanup()?;
|
|
||||||
Ok((true, "Exiting.".into()))
|
|
||||||
} else {
|
|
||||||
Ok((false, "No changes saved. Use :q! to force quit.".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_force_quit(
|
|
||||||
&self,
|
|
||||||
terminal: &mut TerminalCore,
|
|
||||||
) -> Result<(bool, String)> {
|
|
||||||
terminal.cleanup()?;
|
|
||||||
Ok((true, "Force exiting without saving.".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_save_quit(
|
|
||||||
&mut self,
|
|
||||||
terminal: &mut TerminalCore,
|
|
||||||
) -> Result<(bool, String)> {
|
|
||||||
terminal.cleanup()?;
|
|
||||||
Ok((true, "State saved. Exiting.".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// src/client/modes/general.rs
|
|
||||||
pub mod navigation;
|
|
||||||
pub mod command_navigation;
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
// src/modes/general/command_navigation.rs
|
|
||||||
use crate::config::binds::config::Config;
|
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
|
||||||
use anyhow::Result;
|
|
||||||
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum NavigationType {
|
|
||||||
FindFile,
|
|
||||||
TableTree,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TableDependencyGraph {
|
|
||||||
all_tables: HashSet<String>,
|
|
||||||
dependents_map: HashMap<String, Vec<String>>,
|
|
||||||
root_tables: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TableDependencyGraph {
|
|
||||||
pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self {
|
|
||||||
let mut dependents_map: HashMap<String, Vec<String>> = HashMap::new();
|
|
||||||
let mut all_tables_set: HashSet<String> = HashSet::new();
|
|
||||||
let mut table_dependencies: HashMap<String, Vec<String>> = HashMap::new();
|
|
||||||
|
|
||||||
for profile in &profile_tree.profiles {
|
|
||||||
for table in &profile.tables {
|
|
||||||
all_tables_set.insert(table.name.clone());
|
|
||||||
table_dependencies.insert(table.name.clone(), table.depends_on.clone());
|
|
||||||
|
|
||||||
for dependency_name in &table.depends_on {
|
|
||||||
dependents_map
|
|
||||||
.entry(dependency_name.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(table.name.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let root_tables: Vec<String> = all_tables_set
|
|
||||||
.iter()
|
|
||||||
.filter(|name| {
|
|
||||||
table_dependencies
|
|
||||||
.get(*name)
|
|
||||||
.map_or(true, |deps| deps.is_empty())
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut sorted_root_tables = root_tables;
|
|
||||||
sorted_root_tables.sort();
|
|
||||||
|
|
||||||
for dependents_list in dependents_map.values_mut() {
|
|
||||||
dependents_list.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
all_tables: all_tables_set,
|
|
||||||
dependents_map,
|
|
||||||
root_tables: sorted_root_tables,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_dependent_children(&self, path: &str) -> Vec<String> {
|
|
||||||
if path.is_empty() {
|
|
||||||
return self.root_tables.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
|
||||||
if let Some(last_segment_name) = path_segments.last() {
|
|
||||||
if self.all_tables.contains(*last_segment_name) {
|
|
||||||
return self
|
|
||||||
.dependents_map
|
|
||||||
.get(*last_segment_name)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct NavigationState {
|
|
||||||
pub active: bool,
|
|
||||||
pub input: String,
|
|
||||||
pub selected_index: Option<usize>,
|
|
||||||
pub filtered_options: Vec<(usize, String)>,
|
|
||||||
pub navigation_type: NavigationType,
|
|
||||||
pub current_path: String,
|
|
||||||
pub graph: Option<TableDependencyGraph>,
|
|
||||||
pub all_options: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NavigationState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
active: false,
|
|
||||||
input: String::new(),
|
|
||||||
selected_index: None,
|
|
||||||
filtered_options: Vec::new(),
|
|
||||||
navigation_type: NavigationType::FindFile,
|
|
||||||
current_path: String::new(),
|
|
||||||
graph: None,
|
|
||||||
all_options: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn activate_find_file(&mut self, options: Vec<String>) {
|
|
||||||
self.active = true;
|
|
||||||
self.navigation_type = NavigationType::FindFile;
|
|
||||||
self.all_options = options;
|
|
||||||
self.input.clear();
|
|
||||||
self.current_path.clear();
|
|
||||||
self.graph = None;
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
|
|
||||||
self.active = true;
|
|
||||||
self.navigation_type = NavigationType::TableTree;
|
|
||||||
self.graph = Some(graph);
|
|
||||||
self.input.clear();
|
|
||||||
self.current_path.clear();
|
|
||||||
self.update_options_for_path();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deactivate(&mut self) {
|
|
||||||
self.active = false;
|
|
||||||
self.input.clear();
|
|
||||||
self.all_options.clear();
|
|
||||||
self.filtered_options.clear();
|
|
||||||
self.selected_index = None;
|
|
||||||
self.current_path.clear();
|
|
||||||
self.graph = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_char(&mut self, c: char) {
|
|
||||||
match self.navigation_type {
|
|
||||||
NavigationType::FindFile => {
|
|
||||||
self.input.push(c);
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
NavigationType::TableTree => {
|
|
||||||
if c == '/' {
|
|
||||||
if !self.input.is_empty() {
|
|
||||||
if self.current_path.is_empty() {
|
|
||||||
self.current_path = self.input.clone();
|
|
||||||
} else {
|
|
||||||
self.current_path.push('/');
|
|
||||||
self.current_path.push_str(&self.input);
|
|
||||||
}
|
|
||||||
self.input.clear();
|
|
||||||
self.update_options_for_path();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.input.push(c);
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_char(&mut self) {
|
|
||||||
match self.navigation_type {
|
|
||||||
NavigationType::FindFile => {
|
|
||||||
self.input.pop();
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
NavigationType::TableTree => {
|
|
||||||
if self.input.is_empty() {
|
|
||||||
if !self.current_path.is_empty() {
|
|
||||||
if let Some(last_slash_idx) = self.current_path.rfind('/') {
|
|
||||||
self.input = self.current_path[last_slash_idx + 1..].to_string();
|
|
||||||
self.current_path = self.current_path[..last_slash_idx].to_string();
|
|
||||||
} else {
|
|
||||||
self.input = self.current_path.clone();
|
|
||||||
self.current_path.clear();
|
|
||||||
}
|
|
||||||
self.update_options_for_path();
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.input.pop();
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_up(&mut self) {
|
|
||||||
if self.filtered_options.is_empty() {
|
|
||||||
self.selected_index = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.selected_index = match self.selected_index {
|
|
||||||
Some(0) => Some(self.filtered_options.len() - 1),
|
|
||||||
Some(current) => Some(current - 1),
|
|
||||||
None => Some(self.filtered_options.len() - 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_down(&mut self) {
|
|
||||||
if self.filtered_options.is_empty() {
|
|
||||||
self.selected_index = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.selected_index = match self.selected_index {
|
|
||||||
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
|
|
||||||
Some(current) => Some(current + 1),
|
|
||||||
None => Some(0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_selected_option_str(&self) -> Option<&str> {
|
|
||||||
self.selected_index
|
|
||||||
.and_then(|idx| self.filtered_options.get(idx))
|
|
||||||
.map(|(_, option_str)| option_str.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn autocomplete_selected(&mut self) {
|
|
||||||
if let Some(selected_option_str) = self.get_selected_option_str() {
|
|
||||||
self.input = selected_option_str.to_string();
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_display_input(&self) -> String {
|
|
||||||
match self.navigation_type {
|
|
||||||
NavigationType::FindFile => self.input.clone(),
|
|
||||||
NavigationType::TableTree => {
|
|
||||||
if self.current_path.is_empty() {
|
|
||||||
self.input.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}/{}", self.current_path, self.input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- START FIX ---
|
|
||||||
pub fn get_selected_value(&self) -> Option<String> {
|
|
||||||
match self.navigation_type {
|
|
||||||
NavigationType::FindFile => {
|
|
||||||
// Return the highlighted option, not the raw input buffer.
|
|
||||||
self.get_selected_option_str().map(|s| s.to_string())
|
|
||||||
}
|
|
||||||
NavigationType::TableTree => {
|
|
||||||
self.get_selected_option_str().map(|selected_name| {
|
|
||||||
if self.current_path.is_empty() {
|
|
||||||
selected_name.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{}/{}", self.current_path, selected_name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- END FIX ---
|
|
||||||
|
|
||||||
fn update_options_for_path(&mut self) {
|
|
||||||
if let NavigationType::TableTree = self.navigation_type {
|
|
||||||
if let Some(graph) = &self.graph {
|
|
||||||
self.all_options = graph.get_dependent_children(&self.current_path);
|
|
||||||
} else {
|
|
||||||
self.all_options.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.update_filtered_options();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_filtered_options(&mut self) {
|
|
||||||
let filter_text = match self.navigation_type {
|
|
||||||
NavigationType::FindFile => &self.input,
|
|
||||||
NavigationType::TableTree => &self.input,
|
|
||||||
}
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
if filter_text.is_empty() {
|
|
||||||
self.filtered_options = self
|
|
||||||
.all_options
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, opt)| (i, opt.clone()))
|
|
||||||
.collect();
|
|
||||||
} else {
|
|
||||||
self.filtered_options = self
|
|
||||||
.all_options
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
|
|
||||||
.map(|(i, opt)| (i, opt.clone()))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.filtered_options.is_empty() {
|
|
||||||
self.selected_index = None;
|
|
||||||
} else {
|
|
||||||
self.selected_index = Some(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub async fn handle_command_navigation_event(
|
|
||||||
navigation_state: &mut NavigationState,
|
|
||||||
key: KeyEvent,
|
|
||||||
config: &Config,
|
|
||||||
) -> Result<EventOutcome> {
|
|
||||||
if !navigation_state.active {
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Esc => {
|
|
||||||
navigation_state.deactivate();
|
|
||||||
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
|
|
||||||
}
|
|
||||||
KeyCode::Tab => {
|
|
||||||
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
|
|
||||||
if navigation_state.input == selected_opt_str {
|
|
||||||
if navigation_state.navigation_type == NavigationType::TableTree {
|
|
||||||
let path_before_nav = navigation_state.current_path.clone();
|
|
||||||
let input_before_nav = navigation_state.input.clone();
|
|
||||||
navigation_state.add_char('/');
|
|
||||||
if !(navigation_state.input.is_empty() &&
|
|
||||||
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
|
|
||||||
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
|
|
||||||
navigation_state.input = input_before_nav;
|
|
||||||
if navigation_state.current_path != path_before_nav {
|
|
||||||
navigation_state.current_path = path_before_nav;
|
|
||||||
}
|
|
||||||
navigation_state.update_options_for_path();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
navigation_state.autocomplete_selected();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(EventOutcome::Ok(String::new()))
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
navigation_state.remove_char();
|
|
||||||
Ok(EventOutcome::Ok(String::new()))
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
navigation_state.add_char(c);
|
|
||||||
Ok(EventOutcome::Ok(String::new()))
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
|
||||||
match action {
|
|
||||||
"move_up" => {
|
|
||||||
navigation_state.move_up();
|
|
||||||
Ok(EventOutcome::Ok(String::new()))
|
|
||||||
}
|
|
||||||
"move_down" => {
|
|
||||||
navigation_state.move_down();
|
|
||||||
Ok(EventOutcome::Ok(String::new()))
|
|
||||||
}
|
|
||||||
"select" => {
|
|
||||||
if let Some(selected_value) = navigation_state.get_selected_value() {
|
|
||||||
let outcome = match navigation_state.navigation_type {
|
|
||||||
// --- START FIX ---
|
|
||||||
NavigationType::FindFile => {
|
|
||||||
// The purpose of this palette is to select a table.
|
|
||||||
// Emit a TableSelected event instead of a generic Ok message.
|
|
||||||
EventOutcome::TableSelected {
|
|
||||||
path: selected_value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- END FIX ---
|
|
||||||
NavigationType::TableTree => {
|
|
||||||
EventOutcome::TableSelected {
|
|
||||||
path: selected_value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
navigation_state.deactivate();
|
|
||||||
Ok(outcome)
|
|
||||||
} else {
|
|
||||||
Ok(EventOutcome::Ok("No selection".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Ok(EventOutcome::Ok(String::new())),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(EventOutcome::Ok(String::new()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
// src/modes/general/navigation.rs
|
|
||||||
|
|
||||||
use crossterm::event::KeyEvent;
|
|
||||||
use crate::config::binds::config::Config;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::pages::routing::{Router, Page};
|
|
||||||
use crate::pages::forms::FormState;
|
|
||||||
use crate::ui::handlers::context::UiContext;
|
|
||||||
use crate::modes::handlers::event::EventOutcome;
|
|
||||||
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
|
|
||||||
use canvas::DataProvider;
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
pub async fn handle_navigation_event(
|
|
||||||
key: KeyEvent,
|
|
||||||
config: &Config,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
router: &mut Router,
|
|
||||||
command_mode: &mut bool,
|
|
||||||
command_input: &mut String,
|
|
||||||
command_message: &mut String,
|
|
||||||
navigation_state: &mut NavigationState,
|
|
||||||
) -> Result<EventOutcome> {
|
|
||||||
// Handle command navigation first if active
|
|
||||||
if navigation_state.active {
|
|
||||||
return handle_command_navigation_event(navigation_state, key, config).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
|
||||||
match action {
|
|
||||||
"up" => {
|
|
||||||
up(app_state, router);
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
"down" => {
|
|
||||||
down(app_state, router);
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
"next_option" => {
|
|
||||||
next_option(app_state, router);
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
"previous_option" => {
|
|
||||||
previous_option(app_state, router);
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
"next_field" => {
|
|
||||||
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 Page::Form(path) = &router.current {
|
|
||||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
|
||||||
prev_field(fs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
"enter_command_mode" => {
|
|
||||||
handle_enter_command_mode(command_mode, command_input, command_message);
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
"select" => {
|
|
||||||
let (context, index) = match &router.current {
|
|
||||||
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(state) if state.focus_outside_canvas => {
|
|
||||||
(UiContext::Register, state.focused_button_index)
|
|
||||||
}
|
|
||||||
Page::Admin(state) => {
|
|
||||||
(UiContext::Admin, state.get_selected_index().unwrap_or(0))
|
|
||||||
}
|
|
||||||
_ if app_state.ui.dialog.dialog_show => {
|
|
||||||
(UiContext::Dialog, app_state.ui.dialog.dialog_active_button_index)
|
|
||||||
}
|
|
||||||
_ => return Ok(EventOutcome::Ok("Select (No Action)".to_string())),
|
|
||||||
};
|
|
||||||
return Ok(EventOutcome::ButtonSelected { context, index });
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(EventOutcome::Ok(String::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn up(app_state: &mut AppState, router: &mut Router) {
|
|
||||||
match &mut router.current {
|
|
||||||
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 {
|
|
||||||
page.focused_button_index =
|
|
||||||
page.focused_button_index.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 {
|
|
||||||
state.focused_button_index =
|
|
||||||
state.focused_button_index.saturating_sub(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Page::Intro(state) => state.previous_option(),
|
|
||||||
Page::Admin(state) => state.previous(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn down(app_state: &mut AppState, router: &mut Router) {
|
|
||||||
match &mut router.current {
|
|
||||||
Page::Login(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::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(),
|
|
||||||
Page::Admin(state) => state.next(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_option(app_state: &mut AppState, router: &mut Router) {
|
|
||||||
match &mut router.current {
|
|
||||||
Page::Intro(state) => state.next_option(),
|
|
||||||
Page::Admin(state) => {
|
|
||||||
let option_count = app_state.profile_tree.profiles.len();
|
|
||||||
if option_count > 0 {
|
|
||||||
state.focused_button_index =
|
|
||||||
(state.focused_button_index + 1) % option_count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
|
|
||||||
match &mut router.current {
|
|
||||||
Page::Intro(state) => state.previous_option(),
|
|
||||||
Page::Admin(state) => {
|
|
||||||
let option_count = app_state.profile_tree.profiles.len();
|
|
||||||
if option_count > 0 {
|
|
||||||
state.focused_button_index = if state.focused_button_index == 0 {
|
|
||||||
option_count.saturating_sub(1)
|
|
||||||
} else {
|
|
||||||
state.focused_button_index - 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_field(form_state: &mut FormState) {
|
|
||||||
if !form_state.fields.is_empty() {
|
|
||||||
form_state.current_field = (form_state.current_field + 1) % form_state.fields.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_field(form_state: &mut FormState) {
|
|
||||||
if !form_state.fields.is_empty() {
|
|
||||||
if form_state.current_field == 0 {
|
|
||||||
form_state.current_field = form_state.fields.len() - 1;
|
|
||||||
} else {
|
|
||||||
form_state.current_field -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_enter_command_mode(
|
|
||||||
command_mode: &mut bool,
|
|
||||||
command_input: &mut String,
|
|
||||||
command_message: &mut String,
|
|
||||||
) {
|
|
||||||
*command_mode = true;
|
|
||||||
command_input.clear();
|
|
||||||
command_message.clear();
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// src/modes/handlers.rs
|
|
||||||
pub mod event;
|
|
||||||
pub mod mode_manager;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
|||||||
// src/modes/handlers/mode_manager.rs
|
|
||||||
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::modes::handlers::event::EventHandler;
|
|
||||||
use crate::pages::routing::{Router, Page};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AppMode {
|
|
||||||
/// General mode = when focus is outside any canvas
|
|
||||||
/// (Intro, Admin, Login/Register buttons, AddTable/AddLogic menus, dialogs, etc.)
|
|
||||||
General,
|
|
||||||
|
|
||||||
/// Command overlay (":" or "ctrl+;"), available globally
|
|
||||||
Command,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ModeManager;
|
|
||||||
|
|
||||||
impl ModeManager {
|
|
||||||
/// Determine current mode:
|
|
||||||
/// - 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,
|
|
||||||
router: &Router,
|
|
||||||
) -> AppMode {
|
|
||||||
// Navigation palette always forces General
|
|
||||||
if event_handler.navigation_state.active {
|
|
||||||
return AppMode::General;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit command overlay flag
|
|
||||||
if event_handler.command_mode {
|
|
||||||
return AppMode::Command;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If focus is inside a canvas, we don't duplicate canvas modes here.
|
|
||||||
// Canvas crate owns ReadOnly/Edit/Highlight internally.
|
|
||||||
match &router.current {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Command overlay can be entered from anywhere (General or Canvas).
|
|
||||||
pub fn can_enter_command_mode(_current_mode: AppMode) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// src/client/modes/mod.rs
|
|
||||||
pub mod handlers;
|
|
||||||
pub mod general;
|
|
||||||
pub mod common;
|
|
||||||
pub mod canvas;
|
|
||||||
|
|
||||||
pub use handlers::*;
|
|
||||||
pub use general::*;
|
|
||||||
pub use common::*;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// src/movement/actions.rs
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum MovementAction {
|
|
||||||
Next,
|
|
||||||
Previous,
|
|
||||||
Up,
|
|
||||||
Down,
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
Select,
|
|
||||||
Esc,
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// src/movement/mod.rs
|
|
||||||
pub mod actions;
|
|
||||||
pub mod lib;
|
|
||||||
|
|
||||||
pub use actions::MovementAction;
|
|
||||||
pub use lib::move_focus;
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// 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(())
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// 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};
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
// 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,12 +0,0 @@
|
|||||||
// src/pages/admin/admin/tui.rs
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::pages::admin::AdminState;
|
|
||||||
|
|
||||||
pub fn handle_admin_selection(app_state: &mut AppState, admin_state: &AdminState) {
|
|
||||||
let profiles = &app_state.profile_tree.profiles;
|
|
||||||
if let Some(selected_index) = admin_state.get_selected_index() {
|
|
||||||
if let Some(profile) = profiles.get(selected_index) {
|
|
||||||
app_state.selected_profile = Some(profile.name.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
// src/pages/admin/admin/ui.rs
|
|
||||||
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::pages::admin::{AdminFocus, AdminState};
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
||||||
style::Style,
|
|
||||||
text::{Line, Span, Text},
|
|
||||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn render_admin_panel_admin(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
app_state: &AppState,
|
|
||||||
admin_state: &mut AdminState,
|
|
||||||
theme: &Theme,
|
|
||||||
) {
|
|
||||||
let main_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
|
|
||||||
.split(area);
|
|
||||||
let panes_area = main_chunks[0];
|
|
||||||
let buttons_area = main_chunks[1];
|
|
||||||
|
|
||||||
let pane_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(25), // Profiles
|
|
||||||
Constraint::Percentage(40), // Tables
|
|
||||||
Constraint::Percentage(35), // Dependencies
|
|
||||||
].as_ref())
|
|
||||||
.split(panes_area);
|
|
||||||
|
|
||||||
let profiles_pane = pane_chunks[0];
|
|
||||||
let tables_pane = pane_chunks[1];
|
|
||||||
let deps_pane = pane_chunks[2];
|
|
||||||
|
|
||||||
// --- Profiles Pane (Left) ---
|
|
||||||
let profile_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::ProfilesPane | AdminFocus::InsideProfilesList);
|
|
||||||
let profile_border_style = if profile_pane_has_focus {
|
|
||||||
Style::default().fg(theme.highlight)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(theme.border)
|
|
||||||
};
|
|
||||||
let profiles_block = Block::default().title(" Profiles ").borders(Borders::ALL).border_type(BorderType::Rounded).border_style(profile_border_style);
|
|
||||||
let profiles_inner_area = profiles_block.inner(profiles_pane);
|
|
||||||
f.render_widget(profiles_block, profiles_pane);
|
|
||||||
let profile_list_items: Vec<ListItem> = app_state.profile_tree.profiles.iter().enumerate().map(|(idx, profile)| {
|
|
||||||
let is_persistently_selected = admin_state.selected_profile_index == Some(idx);
|
|
||||||
let is_nav_highlighted = admin_state.profile_list_state.selected() == Some(idx) && admin_state.current_focus == AdminFocus::InsideProfilesList;
|
|
||||||
let prefix = if is_persistently_selected { "[*] " } else { "[ ] " };
|
|
||||||
let item_style = if is_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
|
||||||
else if is_persistently_selected { Style::default().fg(theme.accent) }
|
|
||||||
else { Style::default().fg(theme.fg) };
|
|
||||||
ListItem::new(Line::from(vec![Span::styled(prefix, item_style), Span::styled(&profile.name, item_style)]))
|
|
||||||
}).collect();
|
|
||||||
let profile_list = List::new(profile_list_items)
|
|
||||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideProfilesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
|
||||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideProfilesList { "> " } else { " " });
|
|
||||||
f.render_stateful_widget(profile_list, profiles_inner_area, &mut admin_state.profile_list_state);
|
|
||||||
|
|
||||||
|
|
||||||
// --- Tables Pane (Middle) ---
|
|
||||||
let table_pane_has_focus = matches!(admin_state.current_focus, AdminFocus::Tables | AdminFocus::InsideTablesList);
|
|
||||||
let table_border_style = if table_pane_has_focus { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.border) };
|
|
||||||
|
|
||||||
let profile_to_display_tables_for_idx: Option<usize>;
|
|
||||||
if admin_state.current_focus == AdminFocus::InsideProfilesList {
|
|
||||||
profile_to_display_tables_for_idx = admin_state.profile_list_state.selected();
|
|
||||||
} else {
|
|
||||||
profile_to_display_tables_for_idx = admin_state.selected_profile_index
|
|
||||||
.or_else(|| admin_state.profile_list_state.selected());
|
|
||||||
}
|
|
||||||
let tables_pane_title_profile_name = profile_to_display_tables_for_idx
|
|
||||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx))
|
|
||||||
.map_or("None Selected", |p| p.name.as_str());
|
|
||||||
let tables_block = Block::default().title(format!(" Tables (Profile: {}) ", tables_pane_title_profile_name)).borders(Borders::ALL).border_type(BorderType::Rounded).border_style(table_border_style);
|
|
||||||
let tables_inner_area = tables_block.inner(tables_pane);
|
|
||||||
f.render_widget(tables_block, tables_pane);
|
|
||||||
|
|
||||||
let table_list_items_for_display: Vec<ListItem> =
|
|
||||||
if let Some(profile_data_for_tables) = profile_to_display_tables_for_idx
|
|
||||||
.and_then(|idx| app_state.profile_tree.profiles.get(idx)) {
|
|
||||||
profile_data_for_tables.tables.iter().enumerate().map(|(idx, table)| {
|
|
||||||
let is_table_persistently_selected = admin_state.selected_table_index == Some(idx) &&
|
|
||||||
profile_to_display_tables_for_idx == admin_state.selected_profile_index;
|
|
||||||
let is_table_nav_highlighted = admin_state.table_list_state.selected() == Some(idx) &&
|
|
||||||
admin_state.current_focus == AdminFocus::InsideTablesList;
|
|
||||||
let prefix = if is_table_persistently_selected { "[*] " } else { "[ ] " };
|
|
||||||
let style = if is_table_nav_highlighted { Style::default().fg(theme.highlight).add_modifier(ratatui::style::Modifier::BOLD) }
|
|
||||||
else if is_table_persistently_selected { Style::default().fg(theme.accent) }
|
|
||||||
else { Style::default().fg(theme.fg) };
|
|
||||||
ListItem::new(Line::from(vec![Span::styled(prefix, style), Span::styled(&table.name, style)]))
|
|
||||||
}).collect()
|
|
||||||
} else {
|
|
||||||
vec![ListItem::new("Select a profile to see tables")]
|
|
||||||
};
|
|
||||||
let table_list = List::new(table_list_items_for_display)
|
|
||||||
.highlight_style(if admin_state.current_focus == AdminFocus::InsideTablesList { Style::default().add_modifier(ratatui::style::Modifier::REVERSED) } else { Style::default() })
|
|
||||||
.highlight_symbol(if admin_state.current_focus == AdminFocus::InsideTablesList { "> " } else { " " });
|
|
||||||
f.render_stateful_widget(table_list, tables_inner_area, &mut admin_state.table_list_state);
|
|
||||||
|
|
||||||
|
|
||||||
// --- Dependencies Pane (Right) ---
|
|
||||||
let mut deps_pane_title_table_name = "N/A".to_string();
|
|
||||||
let dependencies_to_display: Vec<String>;
|
|
||||||
|
|
||||||
if admin_state.current_focus == AdminFocus::InsideTablesList {
|
|
||||||
// If navigating tables, show dependencies for the '>' highlighted table.
|
|
||||||
// The profile context is `profile_to_display_tables_for_idx` (from Tables pane logic).
|
|
||||||
if let Some(p_idx_for_current_tables) = profile_to_display_tables_for_idx {
|
|
||||||
if let Some(current_profile_showing_tables) = app_state.profile_tree.profiles.get(p_idx_for_current_tables) {
|
|
||||||
if let Some(table_nav_idx) = admin_state.table_list_state.selected() { // The '>' highlighted table
|
|
||||||
if let Some(navigated_table) = current_profile_showing_tables.tables.get(table_nav_idx) {
|
|
||||||
deps_pane_title_table_name = navigated_table.name.clone();
|
|
||||||
dependencies_to_display = navigated_table.depends_on.clone();
|
|
||||||
} else {
|
|
||||||
dependencies_to_display = Vec::new(); // Navigated table index out of bounds
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dependencies_to_display = Vec::new(); // No table navigated with '>'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dependencies_to_display = Vec::new(); // Profile for tables out of bounds
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dependencies_to_display = Vec::new(); // No profile active for table display
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Otherwise, show dependencies for the '[*]' persistently selected table & profile.
|
|
||||||
if let Some(p_idx) = admin_state.selected_profile_index { // Must be a persistently selected profile
|
|
||||||
if let Some(selected_profile) = app_state.profile_tree.profiles.get(p_idx) {
|
|
||||||
if let Some(t_idx) = admin_state.selected_table_index { // Must be a persistently selected table
|
|
||||||
if let Some(selected_table) = selected_profile.tables.get(t_idx) {
|
|
||||||
deps_pane_title_table_name = selected_table.name.clone();
|
|
||||||
dependencies_to_display = selected_table.depends_on.clone();
|
|
||||||
} else { dependencies_to_display = Vec::new(); }
|
|
||||||
} else { dependencies_to_display = Vec::new(); }
|
|
||||||
} else { dependencies_to_display = Vec::new(); }
|
|
||||||
} else { dependencies_to_display = Vec::new(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
let deps_block = Block::default()
|
|
||||||
.title(format!(" Dependencies (Table: {}) ", deps_pane_title_table_name))
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(Style::default().fg(theme.border));
|
|
||||||
let deps_inner_area = deps_block.inner(deps_pane);
|
|
||||||
f.render_widget(deps_block, deps_pane);
|
|
||||||
|
|
||||||
let mut deps_content = Text::default();
|
|
||||||
deps_content.lines.push(Line::from(Span::styled(
|
|
||||||
"Depends On:",
|
|
||||||
Style::default().fg(theme.accent),
|
|
||||||
)));
|
|
||||||
|
|
||||||
if !dependencies_to_display.is_empty() {
|
|
||||||
for dep in dependencies_to_display {
|
|
||||||
deps_content.lines.push(Line::from(Span::styled(format!("- {}", dep), theme.fg)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
deps_content.lines.push(Line::from(Span::styled(" None", theme.secondary)));
|
|
||||||
}
|
|
||||||
let deps_paragraph = Paragraph::new(deps_content);
|
|
||||||
f.render_widget(deps_paragraph, deps_inner_area);
|
|
||||||
|
|
||||||
// --- Buttons Row ---
|
|
||||||
let button_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33)].as_ref()).split(buttons_area);
|
|
||||||
let btn_base_style = Style::default().fg(theme.secondary);
|
|
||||||
let get_btn_style = |button_focus: AdminFocus| { if admin_state.current_focus == button_focus { btn_base_style.add_modifier(ratatui::style::Modifier::REVERSED) } else { btn_base_style } };
|
|
||||||
let btn1 = Paragraph::new("Add Logic").style(get_btn_style(AdminFocus::Button1)).alignment(Alignment::Center);
|
|
||||||
let btn2 = Paragraph::new("Add Table").style(get_btn_style(AdminFocus::Button2)).alignment(Alignment::Center);
|
|
||||||
let btn3 = Paragraph::new("Change Table").style(get_btn_style(AdminFocus::Button3)).alignment(Alignment::Center);
|
|
||||||
f.render_widget(btn1, button_chunks[0]);
|
|
||||||
f.render_widget(btn2, button_chunks[1]);
|
|
||||||
f.render_widget(btn3, button_chunks[2]);
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
// 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 ratatui::widgets::ListState;
|
|
||||||
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) {
|
|
||||||
if item_count == 0 {
|
|
||||||
list_state.select(None);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = match list_state.selected() {
|
|
||||||
Some(i) => if i >= item_count - 1 { 0 } else { i + 1 },
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
list_state.select(Some(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_select_previous(list_state: &mut ListState, item_count: usize) {
|
|
||||||
if item_count == 0 {
|
|
||||||
list_state.select(None);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = match list_state.selected() {
|
|
||||||
Some(i) => if i == 0 { item_count - 1 } else { i - 1 },
|
|
||||||
None => if item_count > 0 { item_count - 1 } else { 0 },
|
|
||||||
};
|
|
||||||
list_state.select(Some(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_admin_navigation(
|
|
||||||
key: crossterm::event::KeyEvent,
|
|
||||||
config: &Config,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
if !app_state.profile_tree.profiles.is_empty() {
|
|
||||||
if admin_state.profile_list_state.selected().is_none() {
|
|
||||||
admin_state.profile_list_state.select(Some(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*command_message = "Navigating profiles. Use Up/Down. Esc to exit.".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
Some("next_option") | Some("move_down") => {
|
|
||||||
admin_state.current_focus = AdminFocus::Tables;
|
|
||||||
*command_message = "Focus: Tables Pane".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
Some("previous_option") | Some("move_up") => {
|
|
||||||
*command_message = "At first focusable pane.".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdminFocus::InsideProfilesList => {
|
|
||||||
let Page::Admin(admin_state) = &mut router.current else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
match action.as_deref() {
|
|
||||||
Some("move_up") => {
|
|
||||||
if profile_count > 0 {
|
|
||||||
list_select_previous(&mut admin_state.profile_list_state, profile_count);
|
|
||||||
*command_message = "".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("move_down") => {
|
|
||||||
if profile_count > 0 {
|
|
||||||
list_select_next(&mut admin_state.profile_list_state, profile_count);
|
|
||||||
*command_message = "".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("select") => {
|
|
||||||
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
|
|
||||||
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));
|
|
||||||
} else {
|
|
||||||
admin_state.table_list_state.select(None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
admin_state.table_list_state.select(None);
|
|
||||||
}
|
|
||||||
*command_message = format!(
|
|
||||||
"Profile '{}' set as active.",
|
|
||||||
admin_state.get_selected_profile_name().unwrap_or(&"N/A".to_string())
|
|
||||||
);
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
Some("exit_table_scroll") => {
|
|
||||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
|
||||||
*command_message = "Focus: Profiles Pane".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
let current_profile_idx = admin_state.selected_profile_index
|
|
||||||
.or_else(|| admin_state.profile_list_state.selected());
|
|
||||||
if let Some(profile_idx) = current_profile_idx {
|
|
||||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
|
||||||
if !profile.tables.is_empty() {
|
|
||||||
if admin_state.table_list_state.selected().is_none() {
|
|
||||||
admin_state.table_list_state.select(Some(0));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
admin_state.table_list_state.select(None);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
admin_state.table_list_state.select(None);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
admin_state.table_list_state.select(None);
|
|
||||||
*command_message = "Select a profile first to view its tables.".to_string();
|
|
||||||
}
|
|
||||||
if admin_state.current_focus == AdminFocus::InsideTablesList && !admin_state.table_list_state.selected().is_none() {
|
|
||||||
*command_message = "Navigating tables. Use Up/Down. Esc to exit.".to_string();
|
|
||||||
} else if admin_state.table_list_state.selected().is_none() {
|
|
||||||
if current_profile_idx.is_none() {
|
|
||||||
*command_message = "No profile selected to view tables.".to_string();
|
|
||||||
} else {
|
|
||||||
*command_message = "No tables in selected profile.".to_string();
|
|
||||||
}
|
|
||||||
admin_state.current_focus = AdminFocus::Tables;
|
|
||||||
}
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
Some("previous_option") | Some("move_up") => {
|
|
||||||
admin_state.current_focus = AdminFocus::ProfilesPane;
|
|
||||||
*command_message = "Focus: Profiles Pane".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
Some("next_option") | Some("move_down") => {
|
|
||||||
admin_state.current_focus = AdminFocus::Button1;
|
|
||||||
*command_message = "Focus: Add Logic Button".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
.or_else(|| admin_state.profile_list_state.selected());
|
|
||||||
if let Some(p_idx) = current_profile_idx {
|
|
||||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
|
||||||
if !profile.tables.is_empty() {
|
|
||||||
list_select_previous(&mut admin_state.table_list_state, profile.tables.len());
|
|
||||||
*command_message = "".to_string();
|
|
||||||
handled = true;
|
|
||||||
} else {
|
|
||||||
*command_message = "No tables to navigate.".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
*command_message = "No active profile for tables.".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("move_down") => {
|
|
||||||
let current_profile_idx = admin_state.selected_profile_index
|
|
||||||
.or_else(|| admin_state.profile_list_state.selected());
|
|
||||||
if let Some(p_idx) = current_profile_idx {
|
|
||||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
|
||||||
if !profile.tables.is_empty() {
|
|
||||||
list_select_next(&mut admin_state.table_list_state, profile.tables.len());
|
|
||||||
*command_message = "".to_string();
|
|
||||||
handled = true;
|
|
||||||
} else {
|
|
||||||
*command_message = "No tables to navigate.".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
*command_message = "No active profile for tables.".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
.and_then(|p| admin_state.selected_table_index.and_then(|t_idx| p.tables.get(t_idx)))
|
|
||||||
.map_or("N/A", |t| t.name.as_str());
|
|
||||||
*command_message = format!("Table '{}' set as active.", table_name);
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
Some("exit_table_scroll") => {
|
|
||||||
admin_state.current_focus = AdminFocus::Tables;
|
|
||||||
*command_message = "Focus: Tables Pane".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdminFocus::Button1 => { // Add Logic Button
|
|
||||||
match action.as_deref() {
|
|
||||||
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) = selected_table_idx {
|
|
||||||
if let Some(table) = profile.tables.get(t_idx) {
|
|
||||||
// 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(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Now it's safe to reassign router.current
|
|
||||||
router.current = Page::AddLogic(add_logic_form);
|
|
||||||
buffer_state.update_history(AppView::AddLogic);
|
|
||||||
|
|
||||||
*command_message = format!(
|
|
||||||
"Opening Add Logic for table '{}' in profile '{}'...",
|
|
||||||
table.name, profile.name
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
*command_message = "Error: Selected table data not found.".to_string();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
*command_message = "Select a table first!".to_string();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
*command_message = "Error: Selected profile data not found.".to_string();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
*command_message = "Select a profile first!".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::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;
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdminFocus::Button2 => { // Add Table Button
|
|
||||||
match action.as_deref() {
|
|
||||||
Some("select") => {
|
|
||||||
// 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,
|
|
||||||
selected: false,
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
*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();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
*command_message = "Please select a profile ([*]) first to add a table.".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::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;
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdminFocus::Button3 => { // Change Table Button
|
|
||||||
match action.as_deref() {
|
|
||||||
Some("select") => {
|
|
||||||
*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") => {
|
|
||||||
*command_message = "At last focusable button.".to_string();
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
_ => handled = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handled
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// src/pages/admin/main/mod.rs
|
|
||||||
|
|
||||||
pub mod state;
|
|
||||||
pub mod ui;
|
|
||||||
pub mod logic;
|
|
||||||
|
|
||||||
pub use state::NonAdminState;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// 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,128 +0,0 @@
|
|||||||
// 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::pages::admin::AdminState;
|
|
||||||
use common::proto::komp_ac::table_definition::ProfileTreeResponse;
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
|
||||||
style::Style,
|
|
||||||
text::{Line, Span, Text},
|
|
||||||
widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use crate::state::pages::auth::UserRole;
|
|
||||||
use crate::pages::admin::admin::ui::render_admin_panel_admin;
|
|
||||||
|
|
||||||
pub fn render_admin_panel(
|
|
||||||
f: &mut Frame,
|
|
||||||
app_state: &AppState,
|
|
||||||
auth_state: &AuthState,
|
|
||||||
admin_state: &mut AdminState,
|
|
||||||
area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
profile_tree: &ProfileTreeResponse,
|
|
||||||
selected_profile: &Option<String>,
|
|
||||||
) {
|
|
||||||
let block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(Style::default().fg(theme.accent))
|
|
||||||
.style(Style::default().bg(theme.bg));
|
|
||||||
|
|
||||||
let inner_area = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Length(3), Constraint::Min(1)])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
// Content
|
|
||||||
let content_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
|
||||||
.split(chunks[1]);
|
|
||||||
|
|
||||||
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: &mut AdminState,
|
|
||||||
content_chunks: &[Rect],
|
|
||||||
theme: &Theme,
|
|
||||||
profile_tree: &ProfileTreeResponse,
|
|
||||||
selected_profile: &Option<String>,
|
|
||||||
) {
|
|
||||||
// Profile list - Use data from admin_state
|
|
||||||
let items: Vec<ListItem> = admin_state
|
|
||||||
.profiles
|
|
||||||
.iter()
|
|
||||||
.map(|p| {
|
|
||||||
ListItem::new(Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
if Some(p) == selected_profile.as_ref() { "✓ " } else { " " },
|
|
||||||
Style::default().fg(theme.accent),
|
|
||||||
),
|
|
||||||
Span::styled(p, Style::default().fg(theme.fg)),
|
|
||||||
]))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let list = List::new(items)
|
|
||||||
.block(Block::default().title("Profiles"))
|
|
||||||
.highlight_style(Style::default().bg(theme.highlight).fg(theme.bg));
|
|
||||||
|
|
||||||
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
|
|
||||||
.get_selected_index()
|
|
||||||
.and_then(|i| profile_tree.profiles.get(i))
|
|
||||||
{
|
|
||||||
let mut text = Text::default();
|
|
||||||
text.lines.push(Line::from(vec![
|
|
||||||
Span::styled("Profile: ", Style::default().fg(theme.accent)),
|
|
||||||
Span::styled(&profile.name, Style::default().fg(theme.highlight)),
|
|
||||||
]));
|
|
||||||
|
|
||||||
text.lines.push(Line::from(""));
|
|
||||||
text.lines.push(Line::from(Span::styled(
|
|
||||||
"Tables:",
|
|
||||||
Style::default().fg(theme.accent),
|
|
||||||
)));
|
|
||||||
|
|
||||||
for table in &profile.tables {
|
|
||||||
let mut line = vec![Span::styled(format!("├─ {}", table.name), theme.fg)];
|
|
||||||
if !table.depends_on.is_empty() {
|
|
||||||
line.push(Span::styled(
|
|
||||||
format!(" → {}", table.depends_on.join(", ")),
|
|
||||||
Style::default().fg(theme.secondary),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
text.lines.push(Line::from(line));
|
|
||||||
}
|
|
||||||
|
|
||||||
let details_widget = Paragraph::new(text)
|
|
||||||
.block(Block::default().title("Details"))
|
|
||||||
.wrap(Wrap { trim: true });
|
|
||||||
f.render_widget(details_widget, content_chunks[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// 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};
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
// 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()))
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// src/pages/admin_panel/add_logic/mod.rs
|
|
||||||
|
|
||||||
pub mod ui;
|
|
||||||
pub mod nav;
|
|
||||||
pub mod state;
|
|
||||||
pub mod loader;
|
|
||||||
pub mod event;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// src/pages/admin_panel/add_logic/nav.rs
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
pub type SaveLogicResultSender = mpsc::Sender<Result<String>>;
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
// 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, FormEditor, SuggestionItem};
|
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use tui_textarea::TextArea;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum AddLogicFocus {
|
|
||||||
#[default]
|
|
||||||
InputLogicName,
|
|
||||||
InputTargetColumn,
|
|
||||||
InputDescription,
|
|
||||||
ScriptContentPreview,
|
|
||||||
InsideScriptContent,
|
|
||||||
SaveButton,
|
|
||||||
CancelButton,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct AddLogicState {
|
|
||||||
pub profile_name: String,
|
|
||||||
pub selected_table_id: Option<i64>,
|
|
||||||
pub selected_table_name: Option<String>,
|
|
||||||
pub logic_name_input: String,
|
|
||||||
pub target_column_input: String,
|
|
||||||
pub script_content_editor: Rc<RefCell<TextArea<'static>>>,
|
|
||||||
pub description_input: String,
|
|
||||||
pub current_focus: AddLogicFocus,
|
|
||||||
pub last_canvas_field: usize,
|
|
||||||
pub logic_name_cursor_pos: usize,
|
|
||||||
pub target_column_cursor_pos: usize,
|
|
||||||
pub description_cursor_pos: usize,
|
|
||||||
pub has_unsaved_changes: bool,
|
|
||||||
pub editor_keybinding_mode: EditorKeybindingMode,
|
|
||||||
pub vim_state: VimState,
|
|
||||||
|
|
||||||
// New fields for Target Column Autocomplete
|
|
||||||
pub table_columns_for_suggestions: Vec<String>, // All columns for the table
|
|
||||||
pub target_column_suggestions: Vec<String>, // Filtered suggestions
|
|
||||||
pub show_target_column_suggestions: bool,
|
|
||||||
pub selected_target_column_suggestion_index: Option<usize>,
|
|
||||||
pub in_target_column_suggestion_mode: bool,
|
|
||||||
|
|
||||||
// Script Editor Autocomplete
|
|
||||||
pub script_editor_autocomplete_active: bool,
|
|
||||||
pub script_editor_suggestions: Vec<String>,
|
|
||||||
pub script_editor_selected_suggestion_index: Option<usize>,
|
|
||||||
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
|
|
||||||
pub all_table_names: Vec<String>,
|
|
||||||
pub script_editor_filter_text: String,
|
|
||||||
|
|
||||||
// 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: canvas::AppMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AddLogicState {
|
|
||||||
pub fn new(editor_config: &EditorConfig) -> Self {
|
|
||||||
let editor = TextEditor::new_textarea(editor_config);
|
|
||||||
AddLogicState {
|
|
||||||
profile_name: "default".to_string(),
|
|
||||||
selected_table_id: None,
|
|
||||||
selected_table_name: None,
|
|
||||||
logic_name_input: String::new(),
|
|
||||||
target_column_input: String::new(),
|
|
||||||
script_content_editor: Rc::new(RefCell::new(editor)),
|
|
||||||
description_input: String::new(),
|
|
||||||
current_focus: AddLogicFocus::InputLogicName,
|
|
||||||
last_canvas_field: 2,
|
|
||||||
logic_name_cursor_pos: 0,
|
|
||||||
target_column_cursor_pos: 0,
|
|
||||||
description_cursor_pos: 0,
|
|
||||||
has_unsaved_changes: false,
|
|
||||||
editor_keybinding_mode: editor_config.keybinding_mode.clone(),
|
|
||||||
vim_state: VimState::default(),
|
|
||||||
|
|
||||||
table_columns_for_suggestions: Vec::new(),
|
|
||||||
target_column_suggestions: Vec::new(),
|
|
||||||
show_target_column_suggestions: false,
|
|
||||||
selected_target_column_suggestion_index: None,
|
|
||||||
in_target_column_suggestion_mode: false,
|
|
||||||
|
|
||||||
script_editor_autocomplete_active: false,
|
|
||||||
script_editor_suggestions: Vec::new(),
|
|
||||||
script_editor_selected_suggestion_index: None,
|
|
||||||
script_editor_trigger_position: None,
|
|
||||||
all_table_names: Vec::new(),
|
|
||||||
script_editor_filter_text: String::new(),
|
|
||||||
|
|
||||||
same_profile_table_names: Vec::new(),
|
|
||||||
script_editor_awaiting_column_autocomplete: None,
|
|
||||||
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();
|
|
||||||
if self.table_columns_for_suggestions.is_empty() {
|
|
||||||
self.target_column_suggestions.clear();
|
|
||||||
self.show_target_column_suggestions = false;
|
|
||||||
self.selected_target_column_suggestion_index = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if current_input.is_empty() {
|
|
||||||
self.target_column_suggestions = self.table_columns_for_suggestions.clone();
|
|
||||||
} else {
|
|
||||||
self.target_column_suggestions = self
|
|
||||||
.table_columns_for_suggestions
|
|
||||||
.iter()
|
|
||||||
.filter(|name| name.to_lowercase().contains(¤t_input))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.show_target_column_suggestions = !self.target_column_suggestions.is_empty();
|
|
||||||
if self.show_target_column_suggestions {
|
|
||||||
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
|
|
||||||
if selected_idx >= self.target_column_suggestions.len() {
|
|
||||||
self.selected_target_column_suggestion_index = Some(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.selected_target_column_suggestion_index = Some(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.selected_target_column_suggestion_index = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates script editor suggestions based on current filter text
|
|
||||||
pub fn update_script_editor_suggestions(&mut self) {
|
|
||||||
let mut suggestions = vec!["sql".to_string()];
|
|
||||||
|
|
||||||
if self.selected_table_name.is_some() {
|
|
||||||
suggestions.extend(self.table_columns_for_suggestions.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_selected_table_name = self.selected_table_name.as_deref();
|
|
||||||
suggestions.extend(
|
|
||||||
self.same_profile_table_names
|
|
||||||
.iter()
|
|
||||||
.filter(|tn| Some(tn.as_str()) != current_selected_table_name)
|
|
||||||
.cloned()
|
|
||||||
);
|
|
||||||
|
|
||||||
if self.script_editor_filter_text.is_empty() {
|
|
||||||
self.script_editor_suggestions = suggestions;
|
|
||||||
} else {
|
|
||||||
let filter_lower = self.script_editor_filter_text.to_lowercase();
|
|
||||||
self.script_editor_suggestions = suggestions
|
|
||||||
.into_iter()
|
|
||||||
.filter(|suggestion| suggestion.to_lowercase().contains(&filter_lower))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update selection index
|
|
||||||
if self.script_editor_suggestions.is_empty() {
|
|
||||||
self.script_editor_selected_suggestion_index = None;
|
|
||||||
self.script_editor_autocomplete_active = false;
|
|
||||||
} else if let Some(selected_idx) = self.script_editor_selected_suggestion_index {
|
|
||||||
if selected_idx >= self.script_editor_suggestions.len() {
|
|
||||||
self.script_editor_selected_suggestion_index = Some(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.script_editor_selected_suggestion_index = Some(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a suggestion is a table name (for triggering column autocomplete)
|
|
||||||
pub fn is_table_name_suggestion(&self, suggestion: &str) -> bool {
|
|
||||||
// Not "sql"
|
|
||||||
if suggestion == "sql" {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if self.table_columns_for_suggestions.contains(&suggestion.to_string()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.same_profile_table_names.contains(&suggestion.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets table columns for autocomplete suggestions
|
|
||||||
pub fn set_table_columns(&mut self, columns: Vec<String>) {
|
|
||||||
self.table_columns_for_suggestions = columns.clone();
|
|
||||||
if !columns.is_empty() {
|
|
||||||
self.update_target_column_suggestions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets all available table names for autocomplete suggestions
|
|
||||||
pub fn set_all_table_names(&mut self, table_names: Vec<String>) {
|
|
||||||
self.all_table_names = table_names;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets table names from the same profile for autocomplete suggestions
|
|
||||||
pub fn set_same_profile_table_names(&mut self, table_names: Vec<String>) {
|
|
||||||
self.same_profile_table_names = table_names;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triggers waiting for column autocomplete for a specific table
|
|
||||||
pub fn trigger_column_autocomplete_for_table(&mut self, table_name: String) {
|
|
||||||
self.script_editor_awaiting_column_autocomplete = Some(table_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates autocomplete with columns for a specific table
|
|
||||||
pub fn set_columns_for_table_autocomplete(&mut self, columns: Vec<String>) {
|
|
||||||
self.script_editor_suggestions = columns;
|
|
||||||
self.script_editor_selected_suggestion_index = if self.script_editor_suggestions.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(0)
|
|
||||||
};
|
|
||||||
self.script_editor_autocomplete_active = !self.script_editor_suggestions.is_empty();
|
|
||||||
self.script_editor_awaiting_column_autocomplete = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deactivates script editor autocomplete and clears related state
|
|
||||||
pub fn deactivate_script_editor_autocomplete(&mut self) {
|
|
||||||
self.script_editor_autocomplete_active = false;
|
|
||||||
self.script_editor_suggestions.clear();
|
|
||||||
self.script_editor_selected_suggestion_index = None;
|
|
||||||
self.script_editor_trigger_position = None;
|
|
||||||
self.script_editor_filter_text.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper method to validate and save logic
|
|
||||||
pub fn save_logic(&mut self) -> Option<String> {
|
|
||||||
if self.logic_name_input.trim().is_empty() {
|
|
||||||
return Some("Logic name is required".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.target_column_input.trim().is_empty() {
|
|
||||||
return Some("Target column is required".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let script_content = {
|
|
||||||
let editor_borrow = self.script_content_editor.borrow();
|
|
||||||
editor_borrow.lines().join("\n")
|
|
||||||
};
|
|
||||||
|
|
||||||
if script_content.trim().is_empty() {
|
|
||||||
return Some("Script content is required".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Here you would typically save to database/storage
|
|
||||||
// For now, just clear the form and mark as saved
|
|
||||||
self.has_unsaved_changes = false;
|
|
||||||
Some(format!("Logic '{}' saved successfully", self.logic_name_input.trim()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper method to clear the form
|
|
||||||
pub fn clear_form(&mut self) -> Option<String> {
|
|
||||||
let profile = self.profile_name.clone();
|
|
||||||
let table_id = self.selected_table_id;
|
|
||||||
let table_name = self.selected_table_name.clone();
|
|
||||||
let editor_config = EditorConfig::default(); // You might want to preserve the actual config
|
|
||||||
|
|
||||||
*self = Self::new(&editor_config);
|
|
||||||
self.profile_name = profile;
|
|
||||||
self.selected_table_id = table_id;
|
|
||||||
self.selected_table_name = table_name;
|
|
||||||
|
|
||||||
Some("Form cleared".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AddLogicState {
|
|
||||||
fn default() -> Self {
|
|
||||||
let mut state = Self::new(&EditorConfig::default());
|
|
||||||
state.app_mode = canvas::AppMode::Edit;
|
|
||||||
state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DataProvider for AddLogicState {
|
|
||||||
fn field_count(&self) -> usize {
|
|
||||||
3 // Logic Name, Target Column, Description
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_name(&self, index: usize) -> &str {
|
|
||||||
match index {
|
|
||||||
0 => "Logic Name",
|
|
||||||
1 => "Target Column",
|
|
||||||
2 => "Description",
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_value(&self, index: usize) -> &str {
|
|
||||||
match index {
|
|
||||||
0 => &self.logic_name_input,
|
|
||||||
1 => &self.target_column_input,
|
|
||||||
2 => &self.description_input,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_field_value(&mut self, index: usize, value: String) {
|
|
||||||
match index {
|
|
||||||
0 => self.logic_name_input = value,
|
|
||||||
1 => self.target_column_input = value,
|
|
||||||
2 => self.description_input = value,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
|
||||||
// Only Target Column supports suggestions
|
|
||||||
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,302 +0,0 @@
|
|||||||
// src/pages/admin_panel/add_logic/ui.rs
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
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},
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, BorderType, Borders, Paragraph},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use crate::components::common::autocomplete;
|
|
||||||
use crate::dialog;
|
|
||||||
use crate::config::binds::config::EditorKeybindingMode;
|
|
||||||
|
|
||||||
pub fn render_add_logic(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
app_state: &AppState,
|
|
||||||
add_logic_state: &mut AddLogicFormState,
|
|
||||||
) {
|
|
||||||
let main_block = Block::default()
|
|
||||||
.title(" Add New Logic Script ")
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(Style::default().fg(theme.border))
|
|
||||||
.style(Style::default().bg(theme.bg));
|
|
||||||
let inner_area = main_block.inner(area);
|
|
||||||
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
|
|
||||||
.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() {
|
|
||||||
EditorKeybindingMode::Vim => {
|
|
||||||
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 crate::components::common::text_editor::TextEditor::is_vim_insert_mode(add_logic_state.vim_state()) {
|
|
||||||
"Script (Editing)".to_string()
|
|
||||||
} else {
|
|
||||||
"Script".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
editor_ref.set_block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(script_title_hint, Style::default().fg(theme.fg)))
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(border_style),
|
|
||||||
);
|
|
||||||
f.render_widget(&*editor_ref, inner_area);
|
|
||||||
|
|
||||||
// Drop the editor borrow before accessing autocomplete state
|
|
||||||
drop(editor_ref);
|
|
||||||
|
|
||||||
// === SCRIPT EDITOR AUTOCOMPLETE RENDERING ===
|
|
||||||
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();
|
|
||||||
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (cursor_line, cursor_col) = current_cursor;
|
|
||||||
|
|
||||||
// Account for TextArea's block borders (1 for each side)
|
|
||||||
let block_offset_x = 1;
|
|
||||||
let block_offset_y = 1;
|
|
||||||
|
|
||||||
// Position autocomplete at current cursor position
|
|
||||||
// Add 1 to column to position dropdown right after the cursor
|
|
||||||
let autocomplete_x = cursor_col + 1;
|
|
||||||
let autocomplete_y = cursor_line;
|
|
||||||
|
|
||||||
let input_rect = Rect {
|
|
||||||
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
|
|
||||||
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
|
|
||||||
width: 1, // Minimum width for positioning
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render autocomplete dropdown
|
|
||||||
autocomplete::render_autocomplete_dropdown(
|
|
||||||
f,
|
|
||||||
input_rect,
|
|
||||||
f.area(), // Full frame area for clamping
|
|
||||||
theme,
|
|
||||||
add_logic_state.script_editor_suggestions(),
|
|
||||||
add_logic_state.script_editor_selected_suggestion_index(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // Exit early for fullscreen mode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular layout with preview
|
|
||||||
let main_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3), // Top info
|
|
||||||
Constraint::Length(9), // Canvas for 3 inputs (each 1 line + 1 padding = 2 lines * 3 + 2 border = 8, +1 for good measure)
|
|
||||||
Constraint::Min(5), // Script preview
|
|
||||||
Constraint::Length(3), // Buttons
|
|
||||||
])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
let top_info_area = main_chunks[0];
|
|
||||||
let canvas_area = main_chunks[1];
|
|
||||||
let script_content_area = main_chunks[2];
|
|
||||||
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),
|
|
||||||
)),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!("Table: {}", table_label),
|
|
||||||
Style::default().fg(theme.fg),
|
|
||||||
)),
|
|
||||||
])
|
|
||||||
.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(),
|
|
||||||
AddLogicFocus::InputLogicName
|
|
||||||
| AddLogicFocus::InputTargetColumn
|
|
||||||
| AddLogicFocus::InputDescription
|
|
||||||
);
|
|
||||||
|
|
||||||
let editor = &add_logic_state.editor;
|
|
||||||
let active_field_rect = render_canvas(f, canvas_area, editor, theme);
|
|
||||||
|
|
||||||
// --- 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();
|
|
||||||
editor_ref.set_cursor_line_style(Style::default());
|
|
||||||
|
|
||||||
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));
|
|
||||||
} else {
|
|
||||||
let underscore_cursor_style = Style::default()
|
|
||||||
.add_modifier(Modifier::UNDERLINED)
|
|
||||||
.fg(theme.secondary);
|
|
||||||
editor_ref.set_cursor_style(underscore_cursor_style);
|
|
||||||
}
|
|
||||||
|
|
||||||
let border_style_color = if is_script_preview_focused {
|
|
||||||
theme.highlight
|
|
||||||
} else {
|
|
||||||
theme.secondary
|
|
||||||
};
|
|
||||||
|
|
||||||
let title_text = "Script Preview"; // Title doesn't need to change based on focus here
|
|
||||||
|
|
||||||
let title_style = if is_script_preview_focused {
|
|
||||||
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(theme.fg)
|
|
||||||
};
|
|
||||||
|
|
||||||
editor_ref.set_block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(title_text, title_style))
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(Style::default().fg(border_style_color)),
|
|
||||||
);
|
|
||||||
f.render_widget(&*editor_ref, script_content_area);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
let get_button_style = |button_focus: AddLogicFocus, current_focus_state: AddLogicFocus| {
|
|
||||||
let is_focused = current_focus_state == button_focus;
|
|
||||||
let base_style = Style::default().fg(if is_focused {
|
|
||||||
theme.highlight
|
|
||||||
} else {
|
|
||||||
theme.secondary
|
|
||||||
});
|
|
||||||
if is_focused {
|
|
||||||
base_style.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
base_style
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let get_button_border_style = |is_focused: bool, current_theme: &Theme| {
|
|
||||||
if is_focused {
|
|
||||||
Style::default().fg(current_theme.highlight)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(current_theme.secondary)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let button_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(50),
|
|
||||||
Constraint::Percentage(50),
|
|
||||||
])
|
|
||||||
.split(buttons_area);
|
|
||||||
|
|
||||||
let save_button = Paragraph::new(" Save Logic ")
|
|
||||||
.style(get_button_style(
|
|
||||||
AddLogicFocus::SaveButton,
|
|
||||||
add_logic_state.current_focus(),
|
|
||||||
))
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(get_button_border_style(
|
|
||||||
add_logic_state.current_focus() == AddLogicFocus::SaveButton,
|
|
||||||
theme,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
f.render_widget(save_button, button_chunks[0]);
|
|
||||||
|
|
||||||
let cancel_button = Paragraph::new(" Cancel ")
|
|
||||||
.style(get_button_style(
|
|
||||||
AddLogicFocus::CancelButton,
|
|
||||||
add_logic_state.current_focus(),
|
|
||||||
))
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(get_button_border_style(
|
|
||||||
add_logic_state.current_focus() == AddLogicFocus::CancelButton,
|
|
||||||
theme,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
f.render_widget(cancel_button, button_chunks[1]);
|
|
||||||
|
|
||||||
// Dialog
|
|
||||||
if app_state.ui.dialog.dialog_show {
|
|
||||||
dialog::render_dialog(
|
|
||||||
f,
|
|
||||||
f.area(),
|
|
||||||
theme,
|
|
||||||
&app_state.ui.dialog.dialog_title,
|
|
||||||
&app_state.ui.dialog.dialog_message,
|
|
||||||
&app_state.ui.dialog.dialog_buttons,
|
|
||||||
app_state.ui.dialog.dialog_active_button_index,
|
|
||||||
app_state.ui.dialog.is_loading,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
// 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()))
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// 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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// 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())
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// src/pages/admin_panel/add_table/nav.rs
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
pub type SaveTableResultSender = mpsc::Sender<Result<String>>;
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
// src/pages/admin_panel/add_table/state.rs
|
|
||||||
|
|
||||||
use canvas::{DataProvider, AppMode};
|
|
||||||
use canvas::FormEditor;
|
|
||||||
use ratatui::widgets::TableState;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ColumnDefinition {
|
|
||||||
pub name: String,
|
|
||||||
pub data_type: String,
|
|
||||||
pub selected: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct IndexDefinition {
|
|
||||||
pub name: String,
|
|
||||||
pub selected: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LinkDefinition {
|
|
||||||
pub linked_table_name: String,
|
|
||||||
pub is_required: bool,
|
|
||||||
pub selected: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum AddTableFocus {
|
|
||||||
#[default]
|
|
||||||
InputTableName, // Field 0 for CanvasState
|
|
||||||
InputColumnName, // Field 1 for CanvasState
|
|
||||||
InputColumnType, // Field 2 for CanvasState
|
|
||||||
AddColumnButton,
|
|
||||||
// Result Tables
|
|
||||||
ColumnsTable,
|
|
||||||
IndexesTable,
|
|
||||||
LinksTable,
|
|
||||||
// Inside Tables (Scrolling Focus)
|
|
||||||
InsideColumnsTable,
|
|
||||||
InsideIndexesTable,
|
|
||||||
InsideLinksTable,
|
|
||||||
// Buttons
|
|
||||||
SaveButton,
|
|
||||||
DeleteSelectedButton,
|
|
||||||
CancelButton,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AddTableState {
|
|
||||||
pub profile_name: String,
|
|
||||||
pub table_name: String,
|
|
||||||
pub table_name_input: String,
|
|
||||||
pub column_name_input: String,
|
|
||||||
pub column_type_input: String,
|
|
||||||
pub columns: Vec<ColumnDefinition>,
|
|
||||||
pub indexes: Vec<IndexDefinition>,
|
|
||||||
pub links: Vec<LinkDefinition>,
|
|
||||||
pub current_focus: AddTableFocus,
|
|
||||||
pub last_canvas_field: usize,
|
|
||||||
pub column_table_state: TableState,
|
|
||||||
pub index_table_state: TableState,
|
|
||||||
pub link_table_state: TableState,
|
|
||||||
pub table_name_cursor_pos: usize,
|
|
||||||
pub column_name_cursor_pos: usize,
|
|
||||||
pub column_type_cursor_pos: usize,
|
|
||||||
pub has_unsaved_changes: bool,
|
|
||||||
pub app_mode: canvas::AppMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AddTableState {
|
|
||||||
fn default() -> Self {
|
|
||||||
AddTableState {
|
|
||||||
profile_name: "default".to_string(),
|
|
||||||
table_name: String::new(),
|
|
||||||
table_name_input: String::new(),
|
|
||||||
column_name_input: String::new(),
|
|
||||||
column_type_input: String::new(),
|
|
||||||
columns: Vec::new(),
|
|
||||||
indexes: Vec::new(),
|
|
||||||
links: Vec::new(),
|
|
||||||
current_focus: AddTableFocus::InputTableName,
|
|
||||||
last_canvas_field: 2,
|
|
||||||
column_table_state: TableState::default(),
|
|
||||||
index_table_state: TableState::default(),
|
|
||||||
link_table_state: TableState::default(),
|
|
||||||
table_name_cursor_pos: 0,
|
|
||||||
column_name_cursor_pos: 0,
|
|
||||||
column_type_cursor_pos: 0,
|
|
||||||
has_unsaved_changes: false,
|
|
||||||
app_mode: canvas::AppMode::Edit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AddTableState {
|
|
||||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
|
||||||
|
|
||||||
/// Helper method to add a column from current inputs
|
|
||||||
pub fn add_column_from_inputs(&mut self) -> Option<String> {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: 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 column inputs and set focus for next entry
|
|
||||||
self.column_name_input.clear();
|
|
||||||
self.column_type_input.clear();
|
|
||||||
self.column_name_cursor_pos = 0;
|
|
||||||
self.column_type_cursor_pos = 0;
|
|
||||||
self.current_focus = AddTableFocus::InputColumnName;
|
|
||||||
self.last_canvas_field = 1;
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
|
|
||||||
Some(format!("Column '{}' added successfully", column_name_in))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper method to delete selected items
|
|
||||||
pub fn delete_selected_items(&mut self) -> Option<String> {
|
|
||||||
let mut deleted_items: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
// Remove selected columns
|
|
||||||
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();
|
|
||||||
self.indexes.retain(|idx| {
|
|
||||||
if idx.selected {
|
|
||||||
deleted_items.push(format!("index '{}'", idx.name));
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove selected links
|
|
||||||
let initial_link_count = self.links.len();
|
|
||||||
self.links.retain(|link| {
|
|
||||||
if link.selected {
|
|
||||||
deleted_items.push(format!("link to '{}'", link.linked_table_name));
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if deleted_items.is_empty() {
|
|
||||||
Some("No items selected for deletion".to_string())
|
|
||||||
} else {
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
self.column_table_state.select(None);
|
|
||||||
self.index_table_state.select(None);
|
|
||||||
Some(format!("Deleted: {}", deleted_items.join(", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DataProvider for AddTableState {
|
|
||||||
fn field_count(&self) -> usize {
|
|
||||||
3 // Table name, Column name, Column type
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_name(&self, index: usize) -> &str {
|
|
||||||
match index {
|
|
||||||
0 => "Table name",
|
|
||||||
1 => "Name",
|
|
||||||
2 => "Type",
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_value(&self, index: usize) -> &str {
|
|
||||||
match index {
|
|
||||||
0 => &self.table_name_input,
|
|
||||||
1 => &self.column_name_input,
|
|
||||||
2 => &self.column_type_input,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_field_value(&mut self, index: usize, value: String) {
|
|
||||||
match index {
|
|
||||||
0 => self.table_name_input = value,
|
|
||||||
1 => self.column_name_input = value,
|
|
||||||
2 => self.column_type_input = value,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
|
||||||
false // AddTableState 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,560 +0,0 @@
|
|||||||
// src/pages/admin_panel/add_table/ui.rs
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
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},
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use crate::dialog;
|
|
||||||
|
|
||||||
/// Renders the Add New Table page layout, structuring the display of table information,
|
|
||||||
/// input fields, and action buttons. Adapts layout based on terminal width.
|
|
||||||
pub fn render_add_table(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
app_state: &AppState,
|
|
||||||
add_table_state: &mut AddTableFormState,
|
|
||||||
) {
|
|
||||||
// --- Configuration ---
|
|
||||||
// Threshold width to switch between wide and narrow layouts
|
|
||||||
const NARROW_LAYOUT_THRESHOLD: u16 = 120; // Adjust this value as needed
|
|
||||||
|
|
||||||
// --- State Checks ---
|
|
||||||
let focus_on_canvas_inputs = matches!(
|
|
||||||
add_table_state.current_focus(),
|
|
||||||
AddTableFocus::InputTableName
|
|
||||||
| AddTableFocus::InputColumnName
|
|
||||||
| AddTableFocus::InputColumnType
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Main Page Block ---
|
|
||||||
let main_block = Block::default()
|
|
||||||
.title(" Add New Table ")
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(Style::default().fg(theme.border))
|
|
||||||
.style(Style::default().bg(theme.bg));
|
|
||||||
let inner_area = main_block.inner(area);
|
|
||||||
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 {
|
|
||||||
// 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()
|
|
||||||
.iter()
|
|
||||||
.map(|col_def| {
|
|
||||||
Row::new(vec![
|
|
||||||
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
|
|
||||||
Cell::from(col_def.name.clone()),
|
|
||||||
Cell::from(col_def.data_type.clone()),
|
|
||||||
])
|
|
||||||
.style(Style::default().fg(theme.fg))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let header_cells = ["Sel", "Name", "Type"]
|
|
||||||
.iter()
|
|
||||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
|
||||||
let header = Row::new(header_cells).height(1).bottom_margin(1);
|
|
||||||
let columns_table = Table::new(column_rows, [Constraint::Length(5), Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
||||||
.header(header)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(" Columns (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(columns_border_style),
|
|
||||||
)
|
|
||||||
.row_highlight_style(
|
|
||||||
Style::default()
|
|
||||||
.add_modifier(Modifier::REVERSED)
|
|
||||||
.fg(theme.highlight),
|
|
||||||
)
|
|
||||||
.highlight_symbol(" > "); // Use the inside symbol
|
|
||||||
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
|
|
||||||
// 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()
|
|
||||||
.iter()
|
|
||||||
.map(|index_def| {
|
|
||||||
Row::new(vec![
|
|
||||||
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }),
|
|
||||||
Cell::from(index_def.name.clone()),
|
|
||||||
])
|
|
||||||
.style(Style::default().fg(theme.fg))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let index_header_cells = ["Sel", "Column Name"]
|
|
||||||
.iter()
|
|
||||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
|
||||||
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
|
|
||||||
let indexes_table = Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
|
||||||
.header(index_header)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(" Indexes (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(indexes_border_style),
|
|
||||||
)
|
|
||||||
.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());
|
|
||||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Fullscreen Links Table Check ---
|
|
||||||
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()
|
|
||||||
.iter()
|
|
||||||
.map(|link_def| {
|
|
||||||
Row::new(vec![
|
|
||||||
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }), // Selection first
|
|
||||||
Cell::from(link_def.linked_table_name.clone()), // Table name second
|
|
||||||
])
|
|
||||||
.style(Style::default().fg(theme.fg))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let link_header_cells = ["Sel", "Available Table"]
|
|
||||||
|
|
||||||
.iter()
|
|
||||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
|
||||||
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
|
|
||||||
let links_table = Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
|
||||||
.header(link_header)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(" Links (Fullscreen) ", theme.fg)) // Indicate fullscreen
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(links_border_style),
|
|
||||||
)
|
|
||||||
.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());
|
|
||||||
return; // IMPORTANT: Stop rendering here for fullscreen mode
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Area Variable Declarations ---
|
|
||||||
let top_info_area: Rect;
|
|
||||||
let columns_area: Rect;
|
|
||||||
let canvas_area: Rect;
|
|
||||||
let add_button_area: Rect;
|
|
||||||
let indexes_area: Rect;
|
|
||||||
let links_area: Rect;
|
|
||||||
let bottom_buttons_area: Rect;
|
|
||||||
|
|
||||||
// --- Layout Decision ---
|
|
||||||
if area.width >= NARROW_LAYOUT_THRESHOLD {
|
|
||||||
// --- WIDE Layout (Based on first screenshot) ---
|
|
||||||
let main_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3), // Top Info (Profile/Table Name) - Increased to 3 lines
|
|
||||||
Constraint::Min(10), // Middle Area (Columns | Right Pane)
|
|
||||||
Constraint::Length(3), // Bottom Buttons
|
|
||||||
])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
top_info_area = main_chunks[0];
|
|
||||||
let middle_area = main_chunks[1];
|
|
||||||
bottom_buttons_area = main_chunks[2];
|
|
||||||
|
|
||||||
// Split Middle Horizontally
|
|
||||||
let middle_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(50), // Left: Columns Table
|
|
||||||
Constraint::Percentage(50), // Right: Inputs etc.
|
|
||||||
])
|
|
||||||
.split(middle_area);
|
|
||||||
|
|
||||||
columns_area = middle_chunks[0];
|
|
||||||
let right_pane_area = middle_chunks[1];
|
|
||||||
|
|
||||||
// Split Right Pane Vertically
|
|
||||||
let right_pane_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(5), // Input Canvas Area
|
|
||||||
Constraint::Length(3), // Add Button Area
|
|
||||||
Constraint::Min(5), // Indexes & Links Area
|
|
||||||
])
|
|
||||||
.split(right_pane_area);
|
|
||||||
|
|
||||||
canvas_area = right_pane_chunks[0];
|
|
||||||
add_button_area = right_pane_chunks[1];
|
|
||||||
let indexes_links_area = right_pane_chunks[2];
|
|
||||||
|
|
||||||
// Split Indexes/Links Horizontally
|
|
||||||
let indexes_links_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(50), // Indexes Table
|
|
||||||
Constraint::Percentage(50), // Links Table
|
|
||||||
])
|
|
||||||
.split(indexes_links_area);
|
|
||||||
indexes_area = indexes_links_chunks[0];
|
|
||||||
links_area = indexes_links_chunks[1];
|
|
||||||
|
|
||||||
// --- Top Info Rendering (Wide - 2 lines) ---
|
|
||||||
let profile_text = Paragraph::new(vec![
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!("Profile: {}", add_table_state.profile_name()),
|
|
||||||
theme.fg,
|
|
||||||
)),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!("Table name: {}", add_table_state.table_name()),
|
|
||||||
theme.fg,
|
|
||||||
)),
|
|
||||||
])
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::BOTTOM)
|
|
||||||
.border_style(Style::default().fg(theme.secondary)),
|
|
||||||
);
|
|
||||||
f.render_widget(profile_text, top_info_area);
|
|
||||||
} else {
|
|
||||||
// --- NARROW Layout (Based on second screenshot) ---
|
|
||||||
let main_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(1), // Top: Profile & Table Name (Single Row)
|
|
||||||
Constraint::Length(5), // Column Definition Input Canvas Area
|
|
||||||
Constraint::Length(3), // Add Button Area
|
|
||||||
Constraint::Min(5), // Columns Table Area
|
|
||||||
Constraint::Min(5), // Indexes & Links Area
|
|
||||||
Constraint::Length(3), // Bottom: Save/Cancel Buttons
|
|
||||||
])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
top_info_area = main_chunks[0];
|
|
||||||
canvas_area = main_chunks[1];
|
|
||||||
add_button_area = main_chunks[2];
|
|
||||||
columns_area = main_chunks[3];
|
|
||||||
let indexes_links_area = main_chunks[4];
|
|
||||||
bottom_buttons_area = main_chunks[5];
|
|
||||||
|
|
||||||
// Split Indexes/Links Horizontally
|
|
||||||
let indexes_links_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(50), // Indexes Table
|
|
||||||
Constraint::Percentage(50), // Links Table
|
|
||||||
])
|
|
||||||
.split(indexes_links_area);
|
|
||||||
indexes_area = indexes_links_chunks[0];
|
|
||||||
links_area = indexes_links_chunks[1];
|
|
||||||
|
|
||||||
// --- Top Info Rendering (Narrow - 1 line) ---
|
|
||||||
let top_info_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(50),
|
|
||||||
Constraint::Percentage(50),
|
|
||||||
])
|
|
||||||
.split(top_info_area);
|
|
||||||
|
|
||||||
let profile_text = Paragraph::new(Span::styled(
|
|
||||||
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()),
|
|
||||||
theme.fg,
|
|
||||||
))
|
|
||||||
.alignment(Alignment::Left);
|
|
||||||
f.render_widget(table_name_text, top_info_chunks[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Common Widget Rendering (Uses calculated areas) ---
|
|
||||||
|
|
||||||
// --- Columns Table Rendering ---
|
|
||||||
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()
|
|
||||||
.iter()
|
|
||||||
.map(|col_def| {
|
|
||||||
Row::new(vec![
|
|
||||||
Cell::from(if col_def.selected { "[*]" } else { "[ ]" }),
|
|
||||||
Cell::from(col_def.name.clone()),
|
|
||||||
Cell::from(col_def.data_type.clone()),
|
|
||||||
])
|
|
||||||
.style(Style::default().fg(theme.fg))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let header_cells = ["Sel", "Name", "Type"]
|
|
||||||
.iter()
|
|
||||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
|
||||||
let header = Row::new(header_cells).height(1).bottom_margin(1);
|
|
||||||
let columns_table = Table::new(
|
|
||||||
column_rows,
|
|
||||||
[ // Define constraints for 3 columns: Sel, Name, Type
|
|
||||||
Constraint::Length(5),
|
|
||||||
Constraint::Percentage(60),
|
|
||||||
Constraint::Percentage(35),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.header(header)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(" Columns ", theme.fg))
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(columns_border_style),
|
|
||||||
)
|
|
||||||
.row_highlight_style(
|
|
||||||
Style::default()
|
|
||||||
.add_modifier(Modifier::REVERSED)
|
|
||||||
.fg(theme.highlight),
|
|
||||||
)
|
|
||||||
.highlight_symbol(" > ");
|
|
||||||
f.render_stateful_widget(
|
|
||||||
columns_table,
|
|
||||||
columns_area,
|
|
||||||
&mut add_table_state.column_table_state(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
|
|
||||||
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| {
|
|
||||||
// Only handles text style (FG + Bold) now, no BG
|
|
||||||
let is_focused = current_focus == button_focus;
|
|
||||||
let base_style = Style::default().fg(if is_focused {
|
|
||||||
theme.highlight // Highlighted text color
|
|
||||||
} else {
|
|
||||||
theme.secondary // Normal text color
|
|
||||||
});
|
|
||||||
if is_focused {
|
|
||||||
base_style.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
base_style
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Updated signature to accept bool and theme
|
|
||||||
let get_button_border_style = |is_focused: bool, theme: &Theme| {
|
|
||||||
if is_focused {
|
|
||||||
Style::default().fg(theme.highlight)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(theme.secondary)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Add Button Rendering ---
|
|
||||||
// Determine if the add button is focused
|
|
||||||
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
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(get_button_border_style(is_add_button_focused, theme)), // Pass bool and theme
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render the button in its designated area
|
|
||||||
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_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()
|
|
||||||
.iter()
|
|
||||||
.map(|index_def| { // Use index_def now
|
|
||||||
Row::new(vec![
|
|
||||||
Cell::from(if index_def.selected { "[*]" } else { "[ ]" }), // Display selection
|
|
||||||
Cell::from(index_def.name.clone()),
|
|
||||||
])
|
|
||||||
.style(Style::default().fg(theme.fg))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let index_header_cells = ["Sel", "Column Name"]
|
|
||||||
.iter()
|
|
||||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
|
||||||
let index_header = Row::new(index_header_cells).height(1).bottom_margin(1);
|
|
||||||
let indexes_table =
|
|
||||||
Table::new(index_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
|
||||||
.header(index_header)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(" Indexes ", theme.fg))
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(indexes_border_style),
|
|
||||||
)
|
|
||||||
.row_highlight_style(
|
|
||||||
Style::default()
|
|
||||||
.add_modifier(Modifier::REVERSED)
|
|
||||||
.fg(theme.highlight),
|
|
||||||
)
|
|
||||||
.highlight_symbol(" > ");
|
|
||||||
f.render_stateful_widget(
|
|
||||||
indexes_table,
|
|
||||||
indexes_area,
|
|
||||||
&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_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()
|
|
||||||
.iter()
|
|
||||||
.map(|link_def| {
|
|
||||||
Row::new(vec![
|
|
||||||
Cell::from(if link_def.selected { "[*]" } else { "[ ]" }),
|
|
||||||
Cell::from(link_def.linked_table_name.clone()),
|
|
||||||
])
|
|
||||||
.style(Style::default().fg(theme.fg))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let link_header_cells = ["Sel", "Available Table"]
|
|
||||||
.iter()
|
|
||||||
.map(|h| Cell::from(*h).style(Style::default().fg(theme.accent)));
|
|
||||||
let link_header = Row::new(link_header_cells).height(1).bottom_margin(1);
|
|
||||||
let links_table =
|
|
||||||
Table::new(link_rows, [Constraint::Length(5), Constraint::Percentage(95)])
|
|
||||||
.header(link_header)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title(Span::styled(" Links ", theme.fg))
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(links_border_style),
|
|
||||||
)
|
|
||||||
.row_highlight_style(
|
|
||||||
Style::default()
|
|
||||||
.add_modifier(Modifier::REVERSED)
|
|
||||||
.fg(theme.highlight),
|
|
||||||
)
|
|
||||||
.highlight_symbol(" > ");
|
|
||||||
f.render_stateful_widget(
|
|
||||||
links_table,
|
|
||||||
links_area,
|
|
||||||
&mut add_table_state.link_table_state(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Save/Cancel Buttons Rendering ---
|
|
||||||
let bottom_button_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(33), // Save Button
|
|
||||||
Constraint::Percentage(34), // Delete Button
|
|
||||||
Constraint::Percentage(33), // Cancel Button
|
|
||||||
])
|
|
||||||
.split(bottom_buttons_area);
|
|
||||||
|
|
||||||
let save_button = Paragraph::new(" Save table ")
|
|
||||||
.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
|
|
||||||
theme,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
f.render_widget(save_button, bottom_button_chunks[0]);
|
|
||||||
|
|
||||||
let delete_button = Paragraph::new(" Delete Selected ")
|
|
||||||
.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,
|
|
||||||
theme,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
f.render_widget(delete_button, bottom_button_chunks[1]);
|
|
||||||
|
|
||||||
let cancel_button = Paragraph::new(" Cancel ")
|
|
||||||
.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,
|
|
||||||
theme,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
f.render_widget(cancel_button, bottom_button_chunks[2]);
|
|
||||||
|
|
||||||
// --- DIALOG ---
|
|
||||||
// Render the dialog overlay if it's active
|
|
||||||
if app_state.ui.dialog.dialog_show {
|
|
||||||
dialog::render_dialog(
|
|
||||||
f,
|
|
||||||
f.area(), // Render over the whole frame area
|
|
||||||
theme,
|
|
||||||
&app_state.ui.dialog.dialog_title,
|
|
||||||
&app_state.ui.dialog.dialog_message,
|
|
||||||
&app_state.ui.dialog.dialog_buttons,
|
|
||||||
app_state.ui.dialog.dialog_active_button_index,
|
|
||||||
app_state.ui.dialog.is_loading,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// src/pages/admin_panel/mod.rs
|
|
||||||
|
|
||||||
pub mod add_table;
|
|
||||||
pub mod add_logic;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// 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))
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// 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(())
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
// src/pages/forms/logic.rs
|
|
||||||
use crate::services::grpc_client::GrpcClient;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::pages::forms::FormState;
|
|
||||||
use crate::utils::data_converter;
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SaveOutcome {
|
|
||||||
NoChange,
|
|
||||||
UpdatedExisting,
|
|
||||||
CreatedNew(i64),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save(
|
|
||||||
app_state: &mut AppState,
|
|
||||||
path: &str,
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
) -> Result<SaveOutcome> {
|
|
||||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
|
||||||
if !fs.has_unsaved_changes {
|
|
||||||
return Ok(SaveOutcome::NoChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
let profile_name = fs.profile_name.clone();
|
|
||||||
let table_name = fs.table_name.clone();
|
|
||||||
let fields = fs.fields.clone();
|
|
||||||
let values = fs.values.clone();
|
|
||||||
let id = fs.id;
|
|
||||||
let total_count = fs.total_count;
|
|
||||||
let current_position = fs.current_position;
|
|
||||||
|
|
||||||
let cache_key = format!("{}.{}", profile_name, table_name);
|
|
||||||
let schema = app_state
|
|
||||||
.schema_cache
|
|
||||||
.get(&cache_key)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
anyhow!(
|
|
||||||
"Schema for table '{}' not found in cache. Cannot save.",
|
|
||||||
table_name
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let data_map: HashMap<String, String> = fields
|
|
||||||
.iter()
|
|
||||||
.zip(values.iter())
|
|
||||||
.map(|(field_def, value)| (field_def.data_key.clone(), value.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let converted_data =
|
|
||||||
data_converter::convert_and_validate_data(&data_map, schema)
|
|
||||||
.map_err(|user_error| anyhow!(user_error))?;
|
|
||||||
|
|
||||||
let is_new_entry = id == 0
|
|
||||||
|| (total_count > 0 && current_position > total_count)
|
|
||||||
|| (total_count == 0 && current_position == 1);
|
|
||||||
|
|
||||||
let outcome = if is_new_entry {
|
|
||||||
let response = grpc_client
|
|
||||||
.post_table_data(profile_name.clone(), table_name.clone(), converted_data)
|
|
||||||
.await
|
|
||||||
.context("Failed to post new table data")?;
|
|
||||||
|
|
||||||
if response.success {
|
|
||||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
|
||||||
fs.id = response.inserted_id;
|
|
||||||
fs.total_count += 1;
|
|
||||||
fs.current_position = fs.total_count;
|
|
||||||
fs.has_unsaved_changes = false;
|
|
||||||
}
|
|
||||||
SaveOutcome::CreatedNew(response.inserted_id)
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("Server failed to insert data: {}", response.message));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if id == 0 {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Cannot update record: ID is 0, but not classified as new entry."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let response = grpc_client
|
|
||||||
.put_table_data(profile_name.clone(), table_name.clone(), id, converted_data)
|
|
||||||
.await
|
|
||||||
.context("Failed to put (update) table data")?;
|
|
||||||
|
|
||||||
if response.success {
|
|
||||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
|
||||||
fs.has_unsaved_changes = false;
|
|
||||||
}
|
|
||||||
SaveOutcome::UpdatedExisting
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("Server failed to update data: {}", response.message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(outcome)
|
|
||||||
} else {
|
|
||||||
Ok(SaveOutcome::NoChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn revert(
|
|
||||||
app_state: &mut AppState,
|
|
||||||
path: &str,
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
) -> Result<String> {
|
|
||||||
if let Some(fs) = app_state.form_state_for_path(path) {
|
|
||||||
if fs.id == 0
|
|
||||||
|| (fs.total_count > 0 && fs.current_position > fs.total_count)
|
|
||||||
|| (fs.total_count == 0 && fs.current_position == 1)
|
|
||||||
{
|
|
||||||
let old_total_count = fs.total_count;
|
|
||||||
fs.reset_to_empty();
|
|
||||||
fs.total_count = old_total_count;
|
|
||||||
if fs.total_count > 0 {
|
|
||||||
fs.current_position = fs.total_count + 1;
|
|
||||||
} else {
|
|
||||||
fs.current_position = 1;
|
|
||||||
}
|
|
||||||
return Ok("New entry cleared".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if fs.current_position == 0 || fs.current_position > fs.total_count {
|
|
||||||
if fs.total_count > 0 {
|
|
||||||
fs.current_position = 1;
|
|
||||||
} else {
|
|
||||||
fs.reset_to_empty();
|
|
||||||
return Ok("No saved data to revert to; form cleared.".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = grpc_client
|
|
||||||
.get_table_data_by_position(
|
|
||||||
fs.profile_name.clone(),
|
|
||||||
fs.table_name.clone(),
|
|
||||||
fs.current_position as i32,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context(format!(
|
|
||||||
"Failed to get table data by position {} for table {}.{}",
|
|
||||||
fs.current_position, fs.profile_name, fs.table_name
|
|
||||||
))?;
|
|
||||||
|
|
||||||
fs.update_from_response(&response.data, fs.current_position);
|
|
||||||
Ok("Changes discarded, reloaded last saved version".to_string())
|
|
||||||
} else {
|
|
||||||
Ok("Nothing to revert".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_action(
|
|
||||||
action: &str,
|
|
||||||
form_state: &mut FormState,
|
|
||||||
_grpc_client: &mut GrpcClient,
|
|
||||||
ideal_cursor_column: &mut usize,
|
|
||||||
) -> Result<String> {
|
|
||||||
if form_state.has_unsaved_changes() {
|
|
||||||
return Ok(
|
|
||||||
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_count = form_state.total_count;
|
|
||||||
|
|
||||||
match action {
|
|
||||||
"previous_entry" => {
|
|
||||||
if form_state.current_position > 1 {
|
|
||||||
form_state.current_position -= 1;
|
|
||||||
*ideal_cursor_column = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"next_entry" => {
|
|
||||||
if form_state.current_position <= total_count {
|
|
||||||
form_state.current_position += 1;
|
|
||||||
*ideal_cursor_column = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return Err(anyhow!("Unknown form action: {}", action)),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(String::new())
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// src/pages/forms/mod.rs
|
|
||||||
|
|
||||||
pub mod ui;
|
|
||||||
pub mod state;
|
|
||||||
pub mod logic;
|
|
||||||
pub mod event;
|
|
||||||
pub mod loader;
|
|
||||||
|
|
||||||
pub use ui::*;
|
|
||||||
pub use state::*;
|
|
||||||
pub use logic::*;
|
|
||||||
pub use event::*;
|
|
||||||
pub use loader::*;
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
// src/pages/forms/state.rs
|
|
||||||
|
|
||||||
use canvas::{DataProvider, AppMode};
|
|
||||||
#[cfg(feature = "validation")]
|
|
||||||
use canvas::{CharacterLimits, ValidationConfig, ValidationConfigBuilder};
|
|
||||||
#[cfg(feature = "validation")]
|
|
||||||
use canvas::validation::limits::CountMode;
|
|
||||||
use common::proto::komp_ac::search::search_response::Hit;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
fn json_value_to_string(value: &serde_json::Value) -> String {
|
|
||||||
match value {
|
|
||||||
serde_json::Value::String(s) => s.clone(),
|
|
||||||
serde_json::Value::Number(n) => n.to_string(),
|
|
||||||
serde_json::Value::Bool(b) => b.to_string(),
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct FieldDefinition {
|
|
||||||
pub display_name: String,
|
|
||||||
pub data_key: String,
|
|
||||||
pub is_link: bool,
|
|
||||||
pub link_target_table: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct FormState {
|
|
||||||
pub id: i64,
|
|
||||||
pub profile_name: String,
|
|
||||||
pub table_name: String,
|
|
||||||
pub total_count: u64,
|
|
||||||
pub current_position: u64,
|
|
||||||
pub fields: Vec<FieldDefinition>,
|
|
||||||
pub values: Vec<String>,
|
|
||||||
pub current_field: usize,
|
|
||||||
pub has_unsaved_changes: bool,
|
|
||||||
pub current_cursor_pos: usize,
|
|
||||||
pub autocomplete_active: bool,
|
|
||||||
pub autocomplete_suggestions: Vec<Hit>,
|
|
||||||
pub selected_suggestion_index: Option<usize>,
|
|
||||||
pub autocomplete_loading: bool,
|
|
||||||
pub link_display_map: HashMap<usize, String>,
|
|
||||||
pub app_mode: AppMode,
|
|
||||||
// Validation 1 (character limits) per field. None = no validation for that field.
|
|
||||||
// Leave room for future rules (patterns, masks, etc.).
|
|
||||||
pub char_limits: Vec<Option<CharLimitsRule>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "validation")]
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CharLimitsRule {
|
|
||||||
pub min: Option<usize>,
|
|
||||||
pub max: Option<usize>,
|
|
||||||
pub warn_at: Option<usize>,
|
|
||||||
pub count_mode: CountMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FormState {
|
|
||||||
// Add this method
|
|
||||||
pub fn deactivate_autocomplete(&mut self) {
|
|
||||||
self.autocomplete_active = false;
|
|
||||||
self.autocomplete_suggestions.clear();
|
|
||||||
self.selected_suggestion_index = None;
|
|
||||||
self.autocomplete_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new(
|
|
||||||
profile_name: String,
|
|
||||||
table_name: String,
|
|
||||||
fields: Vec<FieldDefinition>,
|
|
||||||
) -> Self {
|
|
||||||
let values = vec![String::new(); fields.len()];
|
|
||||||
let len = values.len();
|
|
||||||
FormState {
|
|
||||||
id: 0,
|
|
||||||
profile_name,
|
|
||||||
table_name,
|
|
||||||
total_count: 0,
|
|
||||||
current_position: 1,
|
|
||||||
fields,
|
|
||||||
values,
|
|
||||||
current_field: 0,
|
|
||||||
has_unsaved_changes: false,
|
|
||||||
current_cursor_pos: 0,
|
|
||||||
autocomplete_active: false,
|
|
||||||
autocomplete_suggestions: Vec::new(),
|
|
||||||
selected_suggestion_index: None,
|
|
||||||
autocomplete_loading: false,
|
|
||||||
link_display_map: HashMap::new(),
|
|
||||||
app_mode: canvas::AppMode::Edit,
|
|
||||||
char_limits: vec![None; len],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_display_name_for_hit(&self, hit: &Hit) -> String {
|
|
||||||
if let Ok(content_map) =
|
|
||||||
serde_json::from_str::<HashMap<String, serde_json::Value>>(
|
|
||||||
&hit.content_json,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
const IGNORED_KEYS: &[&str] = &["id", "deleted", "created_at"];
|
|
||||||
let mut keys: Vec<_> = content_map
|
|
||||||
.keys()
|
|
||||||
.filter(|k| !IGNORED_KEYS.contains(&k.as_str()))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
keys.sort();
|
|
||||||
|
|
||||||
let values: Vec<_> = keys
|
|
||||||
.iter()
|
|
||||||
.map(|key| {
|
|
||||||
content_map
|
|
||||||
.get(key)
|
|
||||||
.map(json_value_to_string)
|
|
||||||
.unwrap_or_default()
|
|
||||||
})
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.take(1)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let display_part = values.first().cloned().unwrap_or_default();
|
|
||||||
if display_part.is_empty() {
|
|
||||||
format!("ID: {}", hit.id)
|
|
||||||
} else {
|
|
||||||
format!("{} | ID: {}", display_part, hit.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("ID: {} (parse error)", hit.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset_to_empty(&mut self) {
|
|
||||||
self.id = 0;
|
|
||||||
self.values.iter_mut().for_each(|v| v.clear());
|
|
||||||
self.current_field = 0;
|
|
||||||
self.current_cursor_pos = 0;
|
|
||||||
self.has_unsaved_changes = false;
|
|
||||||
if self.total_count > 0 {
|
|
||||||
self.current_position = self.total_count + 1;
|
|
||||||
} else {
|
|
||||||
self.current_position = 1;
|
|
||||||
}
|
|
||||||
self.deactivate_autocomplete();
|
|
||||||
self.link_display_map.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_input(&self) -> &str {
|
|
||||||
self.values
|
|
||||||
.get(self.current_field)
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
self.link_display_map.remove(&self.current_field);
|
|
||||||
self.values
|
|
||||||
.get_mut(self.current_field)
|
|
||||||
.expect("Invalid current_field index")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_from_response(
|
|
||||||
&mut self,
|
|
||||||
response_data: &HashMap<String, String>,
|
|
||||||
new_position: u64,
|
|
||||||
) {
|
|
||||||
self.values = self
|
|
||||||
.fields
|
|
||||||
.iter()
|
|
||||||
.map(|field_def| {
|
|
||||||
response_data
|
|
||||||
.get(&field_def.data_key)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let id_str_opt = response_data
|
|
||||||
.iter()
|
|
||||||
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
|
|
||||||
.map(|(_, v)| v);
|
|
||||||
|
|
||||||
if let Some(id_str) = id_str_opt {
|
|
||||||
if let Ok(parsed_id) = id_str.parse::<i64>() {
|
|
||||||
self.id = parsed_id;
|
|
||||||
} else {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to parse 'id' field '{}' for table {}.{}",
|
|
||||||
id_str,
|
|
||||||
self.profile_name,
|
|
||||||
self.table_name
|
|
||||||
);
|
|
||||||
self.id = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.id = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.current_position = new_position;
|
|
||||||
self.has_unsaved_changes = false;
|
|
||||||
self.current_field = 0;
|
|
||||||
self.current_cursor_pos = 0;
|
|
||||||
self.deactivate_autocomplete();
|
|
||||||
self.link_display_map.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Keep the rich suggestions methods for compatibility
|
|
||||||
pub fn get_rich_suggestions(&self) -> Option<&[Hit]> {
|
|
||||||
if self.autocomplete_active {
|
|
||||||
Some(&self.autocomplete_suggestions)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn activate_rich_suggestions(&mut self, suggestions: Vec<Hit>) {
|
|
||||||
self.autocomplete_suggestions = suggestions;
|
|
||||||
self.autocomplete_active = !self.autocomplete_suggestions.is_empty();
|
|
||||||
self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
|
||||||
self.autocomplete_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy method compatibility
|
|
||||||
pub fn fields(&self) -> Vec<&str> {
|
|
||||||
self.fields
|
|
||||||
.iter()
|
|
||||||
.map(|f| f.display_name.as_str())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_display_value_for_field(&self, index: usize) -> &str {
|
|
||||||
if let Some(display_text) = self.link_display_map.get(&index) {
|
|
||||||
return display_text.as_str();
|
|
||||||
}
|
|
||||||
self.values
|
|
||||||
.get(index)
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_display_override(&self, index: usize) -> bool {
|
|
||||||
self.link_display_map.contains_key(&index)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_mode(&self) -> AppMode {
|
|
||||||
self.app_mode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add missing methods that used to come from CanvasState trait
|
|
||||||
pub fn has_unsaved_changes(&self) -> bool {
|
|
||||||
self.has_unsaved_changes
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
|
|
||||||
self.has_unsaved_changes = changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_field(&self) -> usize {
|
|
||||||
self.current_field
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_current_field(&mut self, index: usize) {
|
|
||||||
if index < self.fields.len() {
|
|
||||||
self.current_field = index;
|
|
||||||
}
|
|
||||||
self.deactivate_autocomplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_cursor_pos(&self) -> usize {
|
|
||||||
self.current_cursor_pos
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_current_cursor_pos(&mut self, pos: usize) {
|
|
||||||
self.current_cursor_pos = pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "validation")]
|
|
||||||
pub fn set_character_limits_rules(
|
|
||||||
&mut self,
|
|
||||||
rules: Vec<Option<CharLimitsRule>>,
|
|
||||||
) {
|
|
||||||
if rules.len() == self.fields.len() {
|
|
||||||
self.char_limits = rules;
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"Character limits count {} != field count {} for {}.{}",
|
|
||||||
rules.len(),
|
|
||||||
self.fields.len(),
|
|
||||||
self.profile_name,
|
|
||||||
self.table_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Implement DataProvider for FormState
|
|
||||||
impl DataProvider for FormState {
|
|
||||||
fn field_count(&self) -> usize {
|
|
||||||
self.fields.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_name(&self, index: usize) -> &str {
|
|
||||||
&self.fields[index].display_name
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_value(&self, index: usize) -> &str {
|
|
||||||
&self.values[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_field_value(&mut self, index: usize, value: String) {
|
|
||||||
if let Some(v) = self.values.get_mut(index) {
|
|
||||||
*v = value;
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_suggestions(&self, field_index: usize) -> bool {
|
|
||||||
self.fields.get(field_index).map(|f| f.is_link).unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation 1: Provide character-limit-based validation to canvas
|
|
||||||
// Only compiled when the "validation" feature is enabled on canvas.
|
|
||||||
#[cfg(feature = "validation")]
|
|
||||||
fn validation_config(&self, index: usize) -> Option<ValidationConfig> {
|
|
||||||
let rule = self.char_limits.get(index)?.as_ref()?;
|
|
||||||
let mut limits = match (rule.min, rule.max) {
|
|
||||||
(Some(min), Some(max)) => CharacterLimits::new_range(min, max),
|
|
||||||
(None, Some(max)) => CharacterLimits::new(max),
|
|
||||||
(Some(min), None) => CharacterLimits::new_range(min, usize::MAX),
|
|
||||||
(None, None) => CharacterLimits::new(usize::MAX),
|
|
||||||
};
|
|
||||||
limits = limits.with_count_mode(rule.count_mode);
|
|
||||||
if let Some(warn) = rule.warn_at {
|
|
||||||
limits = limits.with_warning_threshold(warn);
|
|
||||||
}
|
|
||||||
Some(
|
|
||||||
ValidationConfigBuilder::new()
|
|
||||||
.with_character_limits(limits)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// src/pages/forms/ui.rs
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
|
||||||
style::Style,
|
|
||||||
widgets::{Block, Borders, Paragraph},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use canvas::{
|
|
||||||
render_canvas, render_suggestions_dropdown, DefaultCanvasTheme, FormEditor,
|
|
||||||
};
|
|
||||||
use crate::pages::forms::FormState;
|
|
||||||
|
|
||||||
pub fn render_form_page(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
editor: &FormEditor<FormState>,
|
|
||||||
table_name: &str,
|
|
||||||
theme: &Theme,
|
|
||||||
total_count: u64,
|
|
||||||
current_position: u64,
|
|
||||||
) {
|
|
||||||
let card_title = format!(" {} ", table_name);
|
|
||||||
|
|
||||||
let adresar_card = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(theme.border))
|
|
||||||
.title(card_title)
|
|
||||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
|
||||||
|
|
||||||
f.render_widget(adresar_card, area);
|
|
||||||
|
|
||||||
let inner_area = area.inner(Margin {
|
|
||||||
horizontal: 1,
|
|
||||||
vertical: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
let main_layout = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
let count_position_text = if total_count == 0 && current_position == 1 {
|
|
||||||
"Total: 0 | New Entry".to_string()
|
|
||||||
} else if current_position > total_count && total_count > 0 {
|
|
||||||
format!("Total: {} | New Entry ({})", total_count, current_position)
|
|
||||||
} else if total_count == 0 && current_position > 1 {
|
|
||||||
format!("Total: 0 | New Entry ({})", current_position)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"Total: {} | Position: {}/{}",
|
|
||||||
total_count, current_position, total_count
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let count_para = Paragraph::new(count_position_text)
|
|
||||||
.style(Style::default().fg(theme.fg))
|
|
||||||
.alignment(Alignment::Left);
|
|
||||||
f.render_widget(count_para, main_layout[0]);
|
|
||||||
|
|
||||||
// --- FORM RENDERING (Using persistent FormEditor) ---
|
|
||||||
let active_field_rect = render_canvas(f, main_layout[1], editor, theme);
|
|
||||||
if let Some(active_rect) = active_field_rect {
|
|
||||||
render_suggestions_dropdown(
|
|
||||||
f,
|
|
||||||
main_layout[1],
|
|
||||||
active_rect,
|
|
||||||
&DefaultCanvasTheme,
|
|
||||||
editor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// src/pages/intro/logic.rs
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
use crate::buffer::state::{AppView, BufferState};
|
|
||||||
|
|
||||||
/// Handles intro screen selection by updating view history and managing focus state.
|
|
||||||
/// 0: Continue (restores last form or default)
|
|
||||||
/// 1: Admin view
|
|
||||||
/// 2: Login view
|
|
||||||
/// 3: Register view (with focus reset)
|
|
||||||
pub fn handle_intro_selection(
|
|
||||||
app_state: &mut AppState,
|
|
||||||
buffer_state: &mut BufferState,
|
|
||||||
index: usize,
|
|
||||||
) {
|
|
||||||
match index {
|
|
||||||
// Continue: go to the most recent existing Form tab, or open a sensible default
|
|
||||||
0 => {
|
|
||||||
// 1) Try to switch to an already open Form buffer (most recent)
|
|
||||||
if let Some(existing_path) = buffer_state
|
|
||||||
.history
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find_map(|view| {
|
|
||||||
if let AppView::Form(p) = view {
|
|
||||||
Some(p.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{
|
|
||||||
buffer_state.update_history(AppView::Form(existing_path));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Otherwise pick a fallback path
|
|
||||||
let fallback_path = if let (Some(profile), Some(table)) = (
|
|
||||||
app_state.current_view_profile_name.clone(),
|
|
||||||
app_state.current_view_table_name.clone(),
|
|
||||||
) {
|
|
||||||
Some(format!("{}/{}", profile, table))
|
|
||||||
} else if let Some(any_key) = app_state.form_editor.keys().next().cloned() {
|
|
||||||
// Use any existing editor key if available
|
|
||||||
Some(any_key)
|
|
||||||
} else {
|
|
||||||
// Otherwise pick the first available table from the profile tree
|
|
||||||
let mut found: Option<String> = None;
|
|
||||||
for prof in &app_state.profile_tree.profiles {
|
|
||||||
if let Some(tbl) = prof.tables.first() {
|
|
||||||
found = Some(format!("{}/{}", prof.name, tbl.name));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
found
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(path) = fallback_path {
|
|
||||||
buffer_state.update_history(AppView::Form(path));
|
|
||||||
} else {
|
|
||||||
// No sensible default; stay on Intro
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
buffer_state.update_history(AppView::Admin);
|
|
||||||
}
|
|
||||||
2 => {
|
|
||||||
buffer_state.update_history(AppView::Login);
|
|
||||||
}
|
|
||||||
3 => {
|
|
||||||
buffer_state.update_history(AppView::Register);
|
|
||||||
}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// src/pages/intro/mod.rs
|
|
||||||
|
|
||||||
pub mod state;
|
|
||||||
pub mod ui;
|
|
||||||
pub mod logic;
|
|
||||||
|
|
||||||
pub use state::*;
|
|
||||||
pub use ui::render_intro;
|
|
||||||
pub use logic::*;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// src/state/pages/intro.rs
|
|
||||||
use crate::movement::MovementAction;
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug)]
|
|
||||||
pub struct IntroState {
|
|
||||||
pub focus_outside_canvas: bool,
|
|
||||||
pub focused_button_index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntroState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
focus_outside_canvas: true,
|
|
||||||
focused_button_index: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_option(&mut self) {
|
|
||||||
if self.focused_button_index < 3 {
|
|
||||||
self.focused_button_index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn previous_option(&mut self) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// src/pages/intro/ui.rs
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
||||||
style::Style,
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, BorderType, Borders, Paragraph},
|
|
||||||
prelude::Margin,
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use crate::config::colors::themes::Theme;
|
|
||||||
use crate::pages::intro::IntroState;
|
|
||||||
|
|
||||||
pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme: &Theme) {
|
|
||||||
let block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.border_style(Style::default().fg(theme.accent))
|
|
||||||
.style(Style::default().bg(theme.bg));
|
|
||||||
|
|
||||||
let inner_area = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
// Center layout
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(40),
|
|
||||||
Constraint::Length(5),
|
|
||||||
Constraint::Percentage(40),
|
|
||||||
])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
// Title
|
|
||||||
let title = Line::from(vec![
|
|
||||||
Span::styled("komp_ac", Style::default().fg(theme.highlight)),
|
|
||||||
Span::styled(" v", Style::default().fg(theme.fg)),
|
|
||||||
Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(theme.secondary)),
|
|
||||||
]);
|
|
||||||
let title_para = Paragraph::new(title)
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
f.render_widget(title_para, chunks[1]);
|
|
||||||
|
|
||||||
// Buttons - now with 4 options
|
|
||||||
let button_area = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage(25),
|
|
||||||
Constraint::Percentage(25),
|
|
||||||
Constraint::Percentage(25),
|
|
||||||
Constraint::Percentage(25),
|
|
||||||
])
|
|
||||||
.split(chunks[1].inner(Margin {
|
|
||||||
horizontal: 1,
|
|
||||||
vertical: 1
|
|
||||||
}));
|
|
||||||
|
|
||||||
let buttons = ["Continue", "Admin", "Login", "Register"];
|
|
||||||
for (i, &text) in buttons.iter().enumerate() {
|
|
||||||
let active = intro_state.focused_button_index == i;
|
|
||||||
render_button(f, button_area[i], text, active, theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_button(f: &mut Frame, area: Rect, text: &str, selected: bool, theme: &Theme) {
|
|
||||||
let button_style = Style::default()
|
|
||||||
.fg(if selected { theme.highlight } else { theme.fg })
|
|
||||||
.bg(theme.bg)
|
|
||||||
.add_modifier(if selected {
|
|
||||||
ratatui::style::Modifier::BOLD
|
|
||||||
} else {
|
|
||||||
ratatui::style::Modifier::empty()
|
|
||||||
});
|
|
||||||
|
|
||||||
let border_style = Style::default()
|
|
||||||
.fg(if selected { theme.accent } else { theme.border });
|
|
||||||
|
|
||||||
let button = Paragraph::new(text)
|
|
||||||
.style(button_style)
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Double)
|
|
||||||
.border_style(border_style),
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(button, area);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// 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,243 +0,0 @@
|
|||||||
// src/pages/login/logic.rs
|
|
||||||
|
|
||||||
use crate::services::auth::AuthClient;
|
|
||||||
use crate::state::pages::auth::AuthState;
|
|
||||||
use crate::state::app::state::AppState;
|
|
||||||
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::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};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum LoginResult {
|
|
||||||
Success(LoginResponse),
|
|
||||||
Failure(String),
|
|
||||||
ConnectionError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to log the user in using the provided credentials via gRPC.
|
|
||||||
/// Updates AuthState and AppState on success or failure.
|
|
||||||
pub async fn save(
|
|
||||||
auth_state: &mut AuthState,
|
|
||||||
login_state: &mut LoginFormState,
|
|
||||||
auth_client: &mut AuthClient,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
) -> Result<String> {
|
|
||||||
let identifier = login_state.username().to_string();
|
|
||||||
let password = login_state.password().to_string();
|
|
||||||
|
|
||||||
// --- Client-side validation ---
|
|
||||||
if identifier.trim().is_empty() {
|
|
||||||
let error_message = "Username/Email cannot be empty.".to_string();
|
|
||||||
app_state.show_dialog(
|
|
||||||
"Login Failed",
|
|
||||||
&error_message,
|
|
||||||
vec!["OK".to_string()],
|
|
||||||
DialogPurpose::LoginFailed,
|
|
||||||
);
|
|
||||||
login_state.set_error_message(Some(error_message.clone()));
|
|
||||||
return Err(anyhow!(error_message));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous error/dialog state before attempting
|
|
||||||
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
|
|
||||||
auth_state.auth_token = Some(response.access_token.clone());
|
|
||||||
auth_state.user_id = Some(response.user_id.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.set_error_message(None);
|
|
||||||
|
|
||||||
let success_message = format!(
|
|
||||||
"Login Successful!\n\n\
|
|
||||||
Username: {}\n\
|
|
||||||
User ID: {}\n\
|
|
||||||
Role: {}",
|
|
||||||
response.username,
|
|
||||||
response.user_id,
|
|
||||||
response.role
|
|
||||||
);
|
|
||||||
|
|
||||||
app_state.show_dialog(
|
|
||||||
"Login Success",
|
|
||||||
&success_message,
|
|
||||||
vec!["Menu".to_string(), "Exit".to_string()],
|
|
||||||
DialogPurpose::LoginSuccess,
|
|
||||||
);
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
let error_message = format!("{}", e);
|
|
||||||
app_state.show_dialog(
|
|
||||||
"Login Failed",
|
|
||||||
&error_message,
|
|
||||||
vec!["OK".to_string()],
|
|
||||||
DialogPurpose::LoginFailed,
|
|
||||||
);
|
|
||||||
login_state.set_error_message(Some(error_message.clone()));
|
|
||||||
login_state.set_has_unsaved_changes(true);
|
|
||||||
login_state.username_mut().clear();
|
|
||||||
login_state.password_mut().clear();
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reverts the login form fields to empty and returns to the previous screen (Intro).
|
|
||||||
pub async fn revert(
|
|
||||||
login_state: &mut LoginFormState,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
) -> String {
|
|
||||||
// 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 LoginFormState,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
buffer_state: &mut BufferState,
|
|
||||||
) -> String {
|
|
||||||
login_state.clear();
|
|
||||||
app_state.hide_dialog();
|
|
||||||
|
|
||||||
buffer_state.close_active_buffer();
|
|
||||||
buffer_state.update_history(AppView::Intro);
|
|
||||||
|
|
||||||
"Returned to main menu".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates input, shows loading, and spawns the login task.
|
|
||||||
pub fn initiate_login(
|
|
||||||
login_state: &mut LoginFormState,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
mut auth_client: AuthClient,
|
|
||||||
sender: mpsc::Sender<LoginResult>,
|
|
||||||
) -> String {
|
|
||||||
login_state.sync_from_editor();
|
|
||||||
let username = login_state.username().to_string();
|
|
||||||
let password = login_state.password().to_string();
|
|
||||||
|
|
||||||
if username.trim().is_empty() {
|
|
||||||
app_state.show_dialog(
|
|
||||||
"Login Failed",
|
|
||||||
"Username/Email cannot be empty.",
|
|
||||||
vec!["OK".to_string()],
|
|
||||||
DialogPurpose::LoginFailed,
|
|
||||||
);
|
|
||||||
"Username cannot be empty.".to_string()
|
|
||||||
} else {
|
|
||||||
app_state.show_loading_dialog("Logging In", "Please wait...");
|
|
||||||
|
|
||||||
spawn(async move {
|
|
||||||
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)),
|
|
||||||
};
|
|
||||||
if let Err(e) = sender.send(login_outcome).await {
|
|
||||||
error!("Failed to send login result: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
"Login initiated.".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles the result received from the login task.
|
|
||||||
/// Returns true if a redraw is needed.
|
|
||||||
pub fn handle_login_result(
|
|
||||||
result: LoginResult,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
auth_state: &mut AuthState,
|
|
||||||
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(UserRole::from_str(&response.role));
|
|
||||||
auth_state.decoded_username = Some(response.username.clone());
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
let success_message = format!(
|
|
||||||
"Login Successful!\n\nUsername: {}\nUser ID: {}\nRole: {}",
|
|
||||||
response.username, response.user_id, response.role
|
|
||||||
);
|
|
||||||
app_state.update_dialog_content(
|
|
||||||
&success_message,
|
|
||||||
vec!["Menu".to_string(), "Exit".to_string()],
|
|
||||||
DialogPurpose::LoginSuccess,
|
|
||||||
);
|
|
||||||
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.set_error_message(Some(err_msg.clone()));
|
|
||||||
error!(error = %err_msg, "Login failed/connection error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
login_state.username_mut().clear();
|
|
||||||
login_state.password_mut().clear();
|
|
||||||
login_state.set_has_unsaved_changes(false);
|
|
||||||
login_state.set_current_cursor_pos(0);
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_action(action: &str) -> Result<String> {
|
|
||||||
match action {
|
|
||||||
"previous_entry" => Ok("Previous entry not implemented".into()),
|
|
||||||
"next_entry" => Ok("Next entry not implemented".into()),
|
|
||||||
_ => Err(anyhow!("Unknown login action: {}", action)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// src/pages/login/mod.rs
|
|
||||||
|
|
||||||
pub mod state;
|
|
||||||
pub mod ui;
|
|
||||||
pub mod logic;
|
|
||||||
pub mod event;
|
|
||||||
|
|
||||||
pub use state::*;
|
|
||||||
pub use ui::render_login;
|
|
||||||
pub use logic::*;
|
|
||||||
pub use event::*;
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
// src/pages/login/state.rs
|
|
||||||
|
|
||||||
use canvas::{AppMode, DataProvider};
|
|
||||||
use canvas::FormEditor;
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LoginState {
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
pub error_message: Option<String>,
|
|
||||||
pub current_field: usize,
|
|
||||||
pub current_cursor_pos: usize,
|
|
||||||
pub has_unsaved_changes: bool,
|
|
||||||
pub login_request_pending: bool,
|
|
||||||
pub app_mode: AppMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LoginState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
username: String::new(),
|
|
||||||
password: String::new(),
|
|
||||||
error_message: None,
|
|
||||||
current_field: 0,
|
|
||||||
current_cursor_pos: 0,
|
|
||||||
has_unsaved_changes: false,
|
|
||||||
login_request_pending: false,
|
|
||||||
app_mode: canvas::AppMode::Edit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoginState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
app_mode: canvas::AppMode::Edit,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_field(&self) -> usize {
|
|
||||||
self.current_field
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_cursor_pos(&self) -> usize {
|
|
||||||
self.current_cursor_pos
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_current_field(&mut self, index: usize) {
|
|
||||||
if index < 2 {
|
|
||||||
self.current_field = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_current_cursor_pos(&mut self, pos: usize) {
|
|
||||||
self.current_cursor_pos = pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_input(&self) -> &str {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &self.username,
|
|
||||||
1 => &self.password,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
match self.current_field {
|
|
||||||
0 => &mut self.username,
|
|
||||||
1 => &mut self.password,
|
|
||||||
_ => panic!("Invalid current_field index in LoginState"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_mode(&self) -> AppMode {
|
|
||||||
self.app_mode
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_unsaved_changes(&self) -> bool {
|
|
||||||
self.has_unsaved_changes
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
|
|
||||||
self.has_unsaved_changes = changed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement DataProvider for LoginState
|
|
||||||
impl DataProvider for LoginState {
|
|
||||||
fn field_count(&self) -> usize {
|
|
||||||
2
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_name(&self, index: usize) -> &str {
|
|
||||||
match index {
|
|
||||||
0 => "Username/Email",
|
|
||||||
1 => "Password",
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_value(&self, index: usize) -> &str {
|
|
||||||
match index {
|
|
||||||
0 => &self.username,
|
|
||||||
1 => &self.password,
|
|
||||||
_ => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_field_value(&mut self, index: usize, value: String) {
|
|
||||||
match index {
|
|
||||||
0 => self.username = value,
|
|
||||||
1 => self.password = value,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_suggestions(&self, _field_index: usize) -> bool {
|
|
||||||
false // Login form doesn't support suggestions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrapper that owns both the raw login state and its editor
|
|
||||||
|
|
||||||
pub struct LoginFormState {
|
|
||||||
pub state: LoginState,
|
|
||||||
pub editor: FormEditor<LoginState>,
|
|
||||||
pub focus_outside_canvas: bool,
|
|
||||||
pub focused_button_index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
// manual debug because FormEditor doesnt implement debug
|
|
||||||
impl fmt::Debug for LoginFormState {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("LoginFormState")
|
|
||||||
.field("state", &self.state) // ✅ only print the data
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoginFormState {
|
|
||||||
/// Sync the editor's data provider back into our state
|
|
||||||
pub fn sync_from_editor(&mut self) {
|
|
||||||
// FormEditor holds the authoritative data
|
|
||||||
let dp = self.editor.data_provider();
|
|
||||||
self.state = dp.clone(); // LoginState implements Clone
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new LoginFormState with default LoginState and FormEditor
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let state = LoginState::default();
|
|
||||||
let editor = FormEditor::new(state.clone());
|
|
||||||
Self {
|
|
||||||
state,
|
|
||||||
editor,
|
|
||||||
focus_outside_canvas: false,
|
|
||||||
focused_button_index: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Delegates to LoginState fields ===
|
|
||||||
|
|
||||||
pub fn username(&self) -> &str {
|
|
||||||
&self.state.username
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn username_mut(&mut self) -> &mut String {
|
|
||||||
&mut self.state.username
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn password(&self) -> &str {
|
|
||||||
&self.state.password
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn password_mut(&mut self) -> &mut String {
|
|
||||||
&mut self.state.password
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error_message(&self) -> Option<&String> {
|
|
||||||
self.state.error_message.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_error_message(&mut self, msg: Option<String>) {
|
|
||||||
self.state.error_message = msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_unsaved_changes(&self) -> bool {
|
|
||||||
self.state.has_unsaved_changes
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_has_unsaved_changes(&mut self, changed: bool) {
|
|
||||||
self.state.has_unsaved_changes = changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.state.username.clear();
|
|
||||||
self.state.password.clear();
|
|
||||||
self.state.error_message = None;
|
|
||||||
self.state.has_unsaved_changes = false;
|
|
||||||
self.state.login_request_pending = false;
|
|
||||||
self.state.current_cursor_pos = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Delegates to LoginState cursor/input ===
|
|
||||||
|
|
||||||
pub fn current_field(&self) -> usize {
|
|
||||||
self.state.current_field()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_current_field(&mut self, index: usize) {
|
|
||||||
self.state.set_current_field(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_cursor_pos(&self) -> usize {
|
|
||||||
self.state.current_cursor_pos()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_current_cursor_pos(&mut self, pos: usize) {
|
|
||||||
self.state.set_current_cursor_pos(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_input(&self) -> &str {
|
|
||||||
self.state.get_current_input()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
self.state.get_current_input_mut()
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Delegates to FormEditor ===
|
|
||||||
|
|
||||||
pub fn mode(&self) -> AppMode {
|
|
||||||
self.editor.mode()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cursor_position(&self) -> usize {
|
|
||||||
self.editor.cursor_position()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_key_event(
|
|
||||||
&mut self,
|
|
||||||
key_event: crossterm::event::KeyEvent,
|
|
||||||
) -> canvas::keymap::KeyEventOutcome {
|
|
||||||
self.editor.handle_key_event(key_event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
// src/pages/login/ui.rs
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
config::colors::themes::Theme,
|
|
||||||
state::app::state::AppState,
|
|
||||||
};
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect, Margin},
|
|
||||||
style::{Style, Modifier, Color},
|
|
||||||
widgets::{Block, BorderType, Borders, Paragraph},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use canvas::{
|
|
||||||
FormEditor,
|
|
||||||
render_canvas,
|
|
||||||
render_suggestions_dropdown,
|
|
||||||
DefaultCanvasTheme,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::pages::login::LoginFormState;
|
|
||||||
use crate::dialog;
|
|
||||||
|
|
||||||
pub fn render_login(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
theme: &Theme,
|
|
||||||
login_page: &LoginFormState,
|
|
||||||
app_state: &AppState,
|
|
||||||
) {
|
|
||||||
let login_state = &login_page.state;
|
|
||||||
let editor = &login_page.editor;
|
|
||||||
|
|
||||||
// Main container
|
|
||||||
let block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Plain)
|
|
||||||
.border_style(Style::default().fg(theme.border))
|
|
||||||
.title(" Login ")
|
|
||||||
.style(Style::default().bg(theme.bg));
|
|
||||||
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
let inner_area = area.inner(Margin {
|
|
||||||
horizontal: 1,
|
|
||||||
vertical: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Layout chunks
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(4), // Form (2 fields + padding)
|
|
||||||
Constraint::Length(1), // Error message
|
|
||||||
Constraint::Length(3), // Buttons
|
|
||||||
])
|
|
||||||
.split(inner_area);
|
|
||||||
|
|
||||||
let input_rect = render_canvas(
|
|
||||||
f,
|
|
||||||
chunks[0],
|
|
||||||
editor,
|
|
||||||
&DefaultCanvasTheme,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- ERROR MESSAGE ---
|
|
||||||
if let Some(err) = &login_state.error_message {
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(err.as_str())
|
|
||||||
.style(Style::default().fg(Color::Red))
|
|
||||||
.alignment(Alignment::Center),
|
|
||||||
chunks[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- BUTTONS (unchanged) ---
|
|
||||||
let button_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
||||||
.split(chunks[2]);
|
|
||||||
|
|
||||||
// Login Button
|
|
||||||
let login_button_index = 0;
|
|
||||||
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 {
|
|
||||||
login_style = login_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
|
|
||||||
login_border = login_border.fg(theme.accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new("Login")
|
|
||||||
.style(login_style)
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Plain)
|
|
||||||
.border_style(login_border),
|
|
||||||
),
|
|
||||||
button_chunks[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return Button
|
|
||||||
let return_button_index = 1;
|
|
||||||
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 {
|
|
||||||
return_style = return_style.fg(theme.highlight).add_modifier(Modifier::BOLD);
|
|
||||||
return_border = return_border.fg(theme.accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new("Return")
|
|
||||||
.style(return_style)
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Plain)
|
|
||||||
.border_style(return_border),
|
|
||||||
),
|
|
||||||
button_chunks[1],
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- SUGGESTIONS DROPDOWN (if active) ---
|
|
||||||
if editor.mode() == canvas::AppMode::Edit {
|
|
||||||
if let Some(input_rect) = input_rect {
|
|
||||||
render_suggestions_dropdown(
|
|
||||||
f,
|
|
||||||
chunks[0],
|
|
||||||
input_rect,
|
|
||||||
&DefaultCanvasTheme,
|
|
||||||
editor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DIALOG ---
|
|
||||||
if app_state.ui.dialog.dialog_show {
|
|
||||||
dialog::render_dialog(
|
|
||||||
f,
|
|
||||||
f.area(),
|
|
||||||
theme,
|
|
||||||
&app_state.ui.dialog.dialog_title,
|
|
||||||
&app_state.ui.dialog.dialog_message,
|
|
||||||
&app_state.ui.dialog.dialog_buttons,
|
|
||||||
app_state.ui.dialog.dialog_active_button_index,
|
|
||||||
app_state.ui.dialog.is_loading,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// src/pages/mod.rs
|
|
||||||
|
|
||||||
pub mod routing;
|
|
||||||
pub mod intro;
|
|
||||||
pub mod login;
|
|
||||||
pub mod register;
|
|
||||||
pub mod forms;
|
|
||||||
pub mod admin;
|
|
||||||
pub mod admin_panel;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// 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,157 +0,0 @@
|
|||||||
// src/pages/register/logic.rs
|
|
||||||
|
|
||||||
use crate::services::auth::AuthClient;
|
|
||||||
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::RegisterFormState;
|
|
||||||
use anyhow::Context;
|
|
||||||
use tokio::spawn;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{info, error};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum RegisterResult {
|
|
||||||
Success(AuthResponse),
|
|
||||||
Failure(String),
|
|
||||||
ConnectionError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears the registration form fields.
|
|
||||||
pub async fn revert(
|
|
||||||
register_state: &mut RegisterFormState,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
) -> String {
|
|
||||||
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.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 RegisterFormState,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
buffer_state: &mut BufferState,
|
|
||||||
) -> String {
|
|
||||||
// Clear fields first
|
|
||||||
let _ = revert(register_state, app_state).await;
|
|
||||||
|
|
||||||
// Ensure dialog is hidden
|
|
||||||
app_state.hide_dialog();
|
|
||||||
|
|
||||||
// Navigation logic
|
|
||||||
buffer_state.close_active_buffer();
|
|
||||||
buffer_state.update_history(AppView::Login);
|
|
||||||
|
|
||||||
// Reset focus state
|
|
||||||
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: &mut RegisterFormState,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
mut auth_client: AuthClient,
|
|
||||||
sender: mpsc::Sender<RegisterResult>,
|
|
||||||
) -> String {
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
"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()
|
|
||||||
} else {
|
|
||||||
// 2. Show Loading Dialog
|
|
||||||
app_state.show_loading_dialog("Registering", "Please wait...");
|
|
||||||
|
|
||||||
// 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 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
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Return immediately
|
|
||||||
"Registration initiated.".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles the result received from the registration task.
|
|
||||||
/// Returns true if a redraw is needed.
|
|
||||||
pub fn handle_registration_result(
|
|
||||||
result: RegisterResult,
|
|
||||||
app_state: &mut AppState,
|
|
||||||
register_state: &mut RegisterFormState,
|
|
||||||
) -> bool {
|
|
||||||
match result {
|
|
||||||
RegisterResult::Success(response) => {
|
|
||||||
let success_message = format!(
|
|
||||||
"Registration Successful!\n\nUser ID: {}\nUsername: {}\nEmail: {}\nRole: {}",
|
|
||||||
response.id, response.username, response.email, response.role
|
|
||||||
);
|
|
||||||
app_state.update_dialog_content(
|
|
||||||
&success_message,
|
|
||||||
vec!["OK".to_string()],
|
|
||||||
DialogPurpose::RegisterSuccess,
|
|
||||||
);
|
|
||||||
info!(message = %success_message, "Registration successful");
|
|
||||||
}
|
|
||||||
RegisterResult::Failure(err_msg) | RegisterResult::ConnectionError(err_msg) => {
|
|
||||||
app_state.update_dialog_content(
|
|
||||||
&err_msg,
|
|
||||||
vec!["OK".to_string()],
|
|
||||||
DialogPurpose::RegisterFailed,
|
|
||||||
);
|
|
||||||
register_state.set_error_message(Some(err_msg.clone()));
|
|
||||||
error!(error = %err_msg, "Registration failed/connection error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
register_state.set_has_unsaved_changes(false); // Clear flag after processing
|
|
||||||
true // Request redraw as dialog content changed
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user