Compare commits
7 Commits
8157dc7a60
...
c2a6272413
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a6272413 | ||
|
|
c51af13fb1 | ||
|
|
d9d8562539 | ||
|
|
6891631b8d | ||
|
|
738d58b5f1 | ||
|
|
3081125716 | ||
|
|
6073c7ab43 |
@@ -6,6 +6,49 @@ use crate::validation::{CharacterLimits, PatternFilters, DisplayMask};
|
|||||||
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
|
use crate::validation::{CustomFormatter, FormattingResult, PositionMapper};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Whitelist of allowed exact values for a field.
|
||||||
|
/// If configured, the field is valid when it is empty (by default) or when the
|
||||||
|
/// content exactly matches one of the allowed values. This does not block field
|
||||||
|
/// switching (unlike minimum length in CharacterLimits).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AllowedValues {
|
||||||
|
allowed: Vec<String>,
|
||||||
|
allow_empty: bool,
|
||||||
|
case_insensitive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AllowedValues {
|
||||||
|
pub fn new(allowed: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
allowed,
|
||||||
|
allow_empty: true,
|
||||||
|
case_insensitive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow or disallow empty value to be considered valid (default: true).
|
||||||
|
pub fn allow_empty(mut self, allow: bool) -> Self {
|
||||||
|
self.allow_empty = allow;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable ASCII case-insensitive matching (default: false).
|
||||||
|
pub fn case_insensitive(mut self, ci: bool) -> Self {
|
||||||
|
self.case_insensitive = ci;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, text: &str) -> bool {
|
||||||
|
if self.case_insensitive {
|
||||||
|
self.allowed
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.eq_ignore_ascii_case(text))
|
||||||
|
} else {
|
||||||
|
self.allowed.iter().any(|s| s == text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Main validation configuration for a field
|
/// Main validation configuration for a field
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct ValidationConfig {
|
pub struct ValidationConfig {
|
||||||
@@ -22,6 +65,9 @@ pub struct ValidationConfig {
|
|||||||
#[cfg(feature = "validation")]
|
#[cfg(feature = "validation")]
|
||||||
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
|
pub custom_formatter: Option<Arc<dyn CustomFormatter + Send + Sync>>,
|
||||||
|
|
||||||
|
/// Optional: restrict the field to one of exact allowed values (or empty)
|
||||||
|
pub allowed_values: Option<AllowedValues>,
|
||||||
|
|
||||||
/// Enable external validation indicator UI (feature 5)
|
/// Enable external validation indicator UI (feature 5)
|
||||||
pub external_validation_enabled: bool,
|
pub external_validation_enabled: bool,
|
||||||
|
|
||||||
@@ -50,6 +96,7 @@ impl std::fmt::Debug for ValidationConfig {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.field("allowed_values", &self.allowed_values)
|
||||||
.field("external_validation_enabled", &self.external_validation_enabled)
|
.field("external_validation_enabled", &self.external_validation_enabled)
|
||||||
.field("external_validation", &self.external_validation)
|
.field("external_validation", &self.external_validation)
|
||||||
.finish()
|
.finish()
|
||||||
@@ -167,6 +214,18 @@ impl ValidationConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allowed values (whitelist) validation
|
||||||
|
if let Some(ref allowed) = self.allowed_values {
|
||||||
|
// Empty value is allowed (default) or required (if allow_empty is false)
|
||||||
|
if text.is_empty() {
|
||||||
|
if !allowed.allow_empty {
|
||||||
|
return ValidationResult::warning("Value required");
|
||||||
|
}
|
||||||
|
} else if !allowed.matches(text) {
|
||||||
|
return ValidationResult::error("Value must be one of the allowed options");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Future: Add other validation types here
|
// Future: Add other validation types here
|
||||||
|
|
||||||
ValidationResult::Valid
|
ValidationResult::Valid
|
||||||
@@ -183,6 +242,12 @@ impl ValidationConfig {
|
|||||||
#[cfg(not(feature = "validation"))]
|
#[cfg(not(feature = "validation"))]
|
||||||
{ false }
|
{ false }
|
||||||
}
|
}
|
||||||
|
|| self.allowed_values.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if whitelist is configured
|
||||||
|
pub fn has_allowed_values(&self) -> bool {
|
||||||
|
self.allowed_values.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn allows_field_switch(&self, text: &str) -> bool {
|
pub fn allows_field_switch(&self, text: &str) -> bool {
|
||||||
@@ -289,6 +354,41 @@ impl ValidationConfigBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restrict content to one of the provided exact values (or empty).
|
||||||
|
/// - Empty is considered valid by default.
|
||||||
|
/// - Matching is case-sensitive by default.
|
||||||
|
pub fn with_allowed_values<S>(mut self, values: Vec<S>) -> Self
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
|
||||||
|
self.config.allowed_values = Some(AllowedValues::new(vals));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as with_allowed_values, but case-insensitive (ASCII).
|
||||||
|
pub fn with_allowed_values_ci<S>(mut self, values: Vec<S>) -> Self
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
let vals: Vec<String> = values.into_iter().map(Into::into).collect();
|
||||||
|
self.config.allowed_values = Some(AllowedValues::new(vals).case_insensitive(true));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure whether empty value should be allowed when using AllowedValues.
|
||||||
|
pub fn with_allowed_values_allow_empty(mut self, allow_empty: bool) -> Self {
|
||||||
|
if let Some(av) = self.config.allowed_values.take() {
|
||||||
|
self.config.allowed_values = Some(AllowedValues {
|
||||||
|
allow_empty,
|
||||||
|
..av
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.config.allowed_values = Some(AllowedValues::new(vec![]).allow_empty(allow_empty));
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Enable or disable external validation indicator UI (feature 5)
|
/// Enable or disable external validation indicator UI (feature 5)
|
||||||
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
|
pub fn with_external_validation_enabled(mut self, enabled: bool) -> Self {
|
||||||
self.config.external_validation_enabled = enabled;
|
self.config.external_validation_enabled = enabled;
|
||||||
@@ -391,6 +491,47 @@ mod tests {
|
|||||||
assert!(config.display_mask.is_some());
|
assert!(config.display_mask.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_allowed_values() {
|
||||||
|
let config = ValidationConfigBuilder::new()
|
||||||
|
.with_allowed_values(vec!["alpha", "beta", "gamma", "delta", "epsilon"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Empty should be valid by default
|
||||||
|
let result = config.validate_content("");
|
||||||
|
assert!(result.is_acceptable());
|
||||||
|
|
||||||
|
// Exact allowed values are valid
|
||||||
|
assert!(config.validate_content("alpha").is_acceptable());
|
||||||
|
assert!(config.validate_content("beta").is_acceptable());
|
||||||
|
|
||||||
|
// Anything else is an error
|
||||||
|
let res = config.validate_content("alph");
|
||||||
|
assert!(res.is_error());
|
||||||
|
let res = config.validate_content("ALPHA");
|
||||||
|
assert!(res.is_error()); // case-sensitive by default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_allowed_values_case_insensitive_and_required() {
|
||||||
|
let config = ValidationConfigBuilder::new()
|
||||||
|
.with_allowed_values_ci(vec!["Yes", "No"])
|
||||||
|
.with_allowed_values_allow_empty(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Empty is not allowed now (warning so it's still acceptable for typing)
|
||||||
|
let res = config.validate_content("");
|
||||||
|
assert!(res.is_acceptable());
|
||||||
|
|
||||||
|
// Case-insensitive matches
|
||||||
|
assert!(config.validate_content("yes").is_acceptable());
|
||||||
|
assert!(config.validate_content("NO").is_acceptable());
|
||||||
|
|
||||||
|
// Random text is an error
|
||||||
|
let res = config.validate_content("maybe");
|
||||||
|
assert!(res.is_error());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validation_result() {
|
fn test_validation_result() {
|
||||||
let valid = ValidationResult::Valid;
|
let valid = ValidationResult::Valid;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ impl AppState {
|
|||||||
self.ui.dialog.purpose = Some(purpose);
|
self.ui.dialog.purpose = Some(purpose);
|
||||||
self.ui.dialog.is_loading = false;
|
self.ui.dialog.is_loading = false;
|
||||||
self.ui.dialog.dialog_show = true;
|
self.ui.dialog.dialog_show = true;
|
||||||
self.ui.focus_outside_canvas = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
|
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
|
||||||
@@ -30,7 +29,6 @@ impl AppState {
|
|||||||
self.ui.dialog.purpose = None;
|
self.ui.dialog.purpose = None;
|
||||||
self.ui.dialog.is_loading = true;
|
self.ui.dialog.is_loading = true;
|
||||||
self.ui.dialog.dialog_show = true;
|
self.ui.dialog.dialog_show = true;
|
||||||
self.ui.focus_outside_canvas = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_dialog_content(
|
pub fn update_dialog_content(
|
||||||
@@ -55,7 +53,6 @@ impl AppState {
|
|||||||
self.ui.dialog.dialog_buttons.clear();
|
self.ui.dialog.dialog_buttons.clear();
|
||||||
self.ui.dialog.dialog_active_button_index = 0;
|
self.ui.dialog.dialog_active_button_index = 0;
|
||||||
self.ui.dialog.purpose = None;
|
self.ui.dialog.purpose = None;
|
||||||
self.ui.focus_outside_canvas = false;
|
|
||||||
self.ui.dialog.is_loading = false;
|
self.ui.dialog.is_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,14 +154,13 @@ pub async fn handle_dialog_event(
|
|||||||
DialogPurpose::ConfirmDeleteColumns => match selected_index {
|
DialogPurpose::ConfirmDeleteColumns => match selected_index {
|
||||||
0 => {
|
0 => {
|
||||||
// "Confirm" button selected
|
// "Confirm" button selected
|
||||||
if let Page::Admin(state) = &mut router.current {
|
if let Page::AddTable(page) = &mut router.current {
|
||||||
let outcome_message =
|
let outcome_message = handle_delete_selected_columns(&mut page.state);
|
||||||
handle_delete_selected_columns(&mut state.add_table_state);
|
|
||||||
app_state.hide_dialog();
|
app_state.hide_dialog();
|
||||||
return Some(Ok(EventOutcome::Ok(outcome_message)));
|
return Some(Ok(EventOutcome::Ok(outcome_message)));
|
||||||
}
|
}
|
||||||
return Some(Ok(EventOutcome::Ok(
|
return Some(Ok(EventOutcome::Ok(
|
||||||
"Admin state not active".to_string(),
|
"AddTable page not active".to_string(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
1 => {
|
1 => {
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ pub async fn handle_navigation_event(
|
|||||||
}
|
}
|
||||||
"select" => {
|
"select" => {
|
||||||
let (context, index) = match &router.current {
|
let (context, index) = match &router.current {
|
||||||
Page::Intro(state) => (UiContext::Intro, state.selected_option),
|
Page::Intro(state) => (UiContext::Intro, state.focused_button_index),
|
||||||
Page::Login(_) if app_state.ui.focus_outside_canvas => {
|
Page::Login(state) if state.focus_outside_canvas => {
|
||||||
(UiContext::Login, app_state.focused_button_index)
|
(UiContext::Login, state.focused_button_index)
|
||||||
}
|
}
|
||||||
Page::Register(_) if app_state.ui.focus_outside_canvas => {
|
Page::Register(state) if state.focus_outside_canvas => {
|
||||||
(UiContext::Register, app_state.focused_button_index)
|
(UiContext::Register, state.focused_button_index)
|
||||||
}
|
}
|
||||||
Page::Admin(state) => {
|
Page::Admin(state) => {
|
||||||
(UiContext::Admin, state.get_selected_index().unwrap_or(0))
|
(UiContext::Admin, state.get_selected_index().unwrap_or(0))
|
||||||
@@ -91,24 +91,24 @@ pub async fn handle_navigation_event(
|
|||||||
|
|
||||||
pub fn up(app_state: &mut AppState, router: &mut Router) {
|
pub fn up(app_state: &mut AppState, router: &mut Router) {
|
||||||
match &mut router.current {
|
match &mut router.current {
|
||||||
Page::Login(page) if app_state.ui.focus_outside_canvas => {
|
Page::Login(page) if page.focus_outside_canvas => {
|
||||||
if app_state.focused_button_index == 0 {
|
if page.focused_button_index == 0 {
|
||||||
app_state.ui.focus_outside_canvas = false;
|
page.focus_outside_canvas = false;
|
||||||
let last_field_index = page.state.field_count().saturating_sub(1);
|
let last_field_index = page.state.field_count().saturating_sub(1);
|
||||||
page.state.set_current_field(last_field_index);
|
page.state.set_current_field(last_field_index);
|
||||||
} else {
|
} else {
|
||||||
app_state.focused_button_index =
|
page.focused_button_index =
|
||||||
app_state.focused_button_index.saturating_sub(1);
|
page.focused_button_index.saturating_sub(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Page::Register(state) if app_state.ui.focus_outside_canvas => {
|
Page::Register(state) if state.focus_outside_canvas => {
|
||||||
if app_state.focused_button_index == 0 {
|
if state.focused_button_index == 0 {
|
||||||
app_state.ui.focus_outside_canvas = false;
|
state.focus_outside_canvas = false;
|
||||||
let last_field_index = state.state.field_count().saturating_sub(1);
|
let last_field_index = state.state.field_count().saturating_sub(1);
|
||||||
state.set_current_field(last_field_index);
|
state.set_current_field(last_field_index);
|
||||||
} else {
|
} else {
|
||||||
app_state.focused_button_index =
|
state.focused_button_index =
|
||||||
app_state.focused_button_index.saturating_sub(1);
|
state.focused_button_index.saturating_sub(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Page::Intro(state) => state.previous_option(),
|
Page::Intro(state) => state.previous_option(),
|
||||||
@@ -119,10 +119,16 @@ pub fn up(app_state: &mut AppState, router: &mut Router) {
|
|||||||
|
|
||||||
pub fn down(app_state: &mut AppState, router: &mut Router) {
|
pub fn down(app_state: &mut AppState, router: &mut Router) {
|
||||||
match &mut router.current {
|
match &mut router.current {
|
||||||
Page::Login(_) | Page::Register(_) if app_state.ui.focus_outside_canvas => {
|
Page::Login(state) if state.focus_outside_canvas => {
|
||||||
let num_general_elements = 2;
|
let num_general_elements = 2;
|
||||||
if app_state.focused_button_index < num_general_elements - 1 {
|
if state.focused_button_index < num_general_elements - 1 {
|
||||||
app_state.focused_button_index += 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::Intro(state) => state.next_option(),
|
||||||
@@ -134,11 +140,11 @@ pub fn down(app_state: &mut AppState, router: &mut Router) {
|
|||||||
pub fn next_option(app_state: &mut AppState, router: &mut Router) {
|
pub fn next_option(app_state: &mut AppState, router: &mut Router) {
|
||||||
match &mut router.current {
|
match &mut router.current {
|
||||||
Page::Intro(state) => state.next_option(),
|
Page::Intro(state) => state.next_option(),
|
||||||
Page::Admin(_) => {
|
Page::Admin(state) => {
|
||||||
let option_count = app_state.profile_tree.profiles.len();
|
let option_count = app_state.profile_tree.profiles.len();
|
||||||
if option_count > 0 {
|
if option_count > 0 {
|
||||||
app_state.focused_button_index =
|
state.focused_button_index =
|
||||||
(app_state.focused_button_index + 1) % option_count;
|
(state.focused_button_index + 1) % option_count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -148,13 +154,13 @@ pub fn next_option(app_state: &mut AppState, router: &mut Router) {
|
|||||||
pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
|
pub fn previous_option(app_state: &mut AppState, router: &mut Router) {
|
||||||
match &mut router.current {
|
match &mut router.current {
|
||||||
Page::Intro(state) => state.previous_option(),
|
Page::Intro(state) => state.previous_option(),
|
||||||
Page::Admin(_) => {
|
Page::Admin(state) => {
|
||||||
let option_count = app_state.profile_tree.profiles.len();
|
let option_count = app_state.profile_tree.profiles.len();
|
||||||
if option_count > 0 {
|
if option_count > 0 {
|
||||||
app_state.focused_button_index = if app_state.focused_button_index == 0 {
|
state.focused_button_index = if state.focused_button_index == 0 {
|
||||||
option_count.saturating_sub(1)
|
option_count.saturating_sub(1)
|
||||||
} else {
|
} else {
|
||||||
app_state.focused_button_index - 1
|
state.focused_button_index - 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ impl EventHandler {
|
|||||||
self.command_input.clear();
|
self.command_input.clear();
|
||||||
self.command_message.clear();
|
self.command_message.clear();
|
||||||
self.key_sequence_tracker.reset();
|
self.key_sequence_tracker.reset();
|
||||||
app_state.ui.focus_outside_canvas = true;
|
self.set_focus_outside(router, true);
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,6 +379,24 @@ impl EventHandler {
|
|||||||
return Ok(outcome);
|
return Ok(outcome);
|
||||||
}
|
}
|
||||||
} else if let Page::AddTable(add_table_page) = &mut router.current {
|
} else if let Page::AddTable(add_table_page) = &mut router.current {
|
||||||
|
// Allow ":" (enter_command_mode) even when inside AddTable canvas
|
||||||
|
if let Some(action) =
|
||||||
|
config.get_general_action(key_event.code, key_event.modifiers)
|
||||||
|
{
|
||||||
|
if action == "enter_command_mode"
|
||||||
|
&& !self.command_mode
|
||||||
|
&& !app_state.ui.show_search_palette
|
||||||
|
&& !self.navigation_state.active
|
||||||
|
{
|
||||||
|
self.command_mode = true;
|
||||||
|
self.command_input.clear();
|
||||||
|
self.command_message.clear();
|
||||||
|
self.key_sequence_tracker.reset();
|
||||||
|
self.set_focus_outside(router, true);
|
||||||
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle AddTable before global actions so canvas gets first shot at keys.
|
// Handle AddTable before global actions so canvas gets first shot at keys.
|
||||||
// Map keys to MovementAction (same as AddLogic early handler)
|
// Map keys to MovementAction (same as AddLogic early handler)
|
||||||
let movement_action_early = if let Some(act) =
|
let movement_action_early = if let Some(act) =
|
||||||
@@ -505,7 +523,7 @@ impl EventHandler {
|
|||||||
app_state.ui.show_search_palette = true;
|
app_state.ui.show_search_palette = true;
|
||||||
app_state.search_state =
|
app_state.search_state =
|
||||||
Some(SearchState::new(table_name));
|
Some(SearchState::new(table_name));
|
||||||
app_state.ui.focus_outside_canvas = true;
|
self.set_focus_outside(router, true);
|
||||||
return Ok(EventOutcome::Ok(
|
return Ok(EventOutcome::Ok(
|
||||||
"Search palette opened".to_string(),
|
"Search palette opened".to_string(),
|
||||||
));
|
));
|
||||||
@@ -514,7 +532,7 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
// Allow ":" / ctrl+; to enter command mode only when outside canvas.
|
// Allow ":" / ctrl+; to enter command mode only when outside canvas.
|
||||||
if action == "enter_command_mode" {
|
if action == "enter_command_mode" {
|
||||||
if app_state.ui.focus_outside_canvas
|
if self.is_focus_outside(router)
|
||||||
&& !self.command_mode
|
&& !self.command_mode
|
||||||
&& !app_state.ui.show_search_palette
|
&& !app_state.ui.show_search_palette
|
||||||
&& !self.navigation_state.active
|
&& !self.navigation_state.active
|
||||||
@@ -524,7 +542,7 @@ impl EventHandler {
|
|||||||
self.command_message.clear();
|
self.command_message.clear();
|
||||||
self.key_sequence_tracker.reset();
|
self.key_sequence_tracker.reset();
|
||||||
// Keep focus outside so canvas won't receive keys
|
// Keep focus outside so canvas won't receive keys
|
||||||
app_state.ui.focus_outside_canvas = true;
|
self.set_focus_outside(router, true);
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,4 +955,52 @@ impl EventHandler {
|
|||||||
"find_file_palette_toggle"
|
"find_file_palette_toggle"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_focus_outside(&mut self, router: &mut Router, outside: bool) {
|
||||||
|
match &mut router.current {
|
||||||
|
Page::Login(state) => state.focus_outside_canvas = outside,
|
||||||
|
Page::Register(state) => state.focus_outside_canvas = outside,
|
||||||
|
Page::Intro(state) => state.focus_outside_canvas = outside,
|
||||||
|
Page::Admin(state) => state.focus_outside_canvas = outside,
|
||||||
|
Page::AddLogic(state) => state.focus_outside_canvas = outside,
|
||||||
|
Page::AddTable(state) => state.focus_outside_canvas = outside,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_focused_button(&mut self, router: &mut Router, index: usize) {
|
||||||
|
match &mut router.current {
|
||||||
|
Page::Login(state) => state.focused_button_index = index,
|
||||||
|
Page::Register(state) => state.focused_button_index = index,
|
||||||
|
Page::Intro(state) => state.focused_button_index = index,
|
||||||
|
Page::Admin(state) => state.focused_button_index = index,
|
||||||
|
Page::AddLogic(state) => state.focused_button_index = index,
|
||||||
|
Page::AddTable(state) => state.focused_button_index = index,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_focus_outside(&self, router: &Router) -> bool {
|
||||||
|
match &router.current {
|
||||||
|
Page::Login(state) => state.focus_outside_canvas,
|
||||||
|
Page::Register(state) => state.focus_outside_canvas,
|
||||||
|
Page::Intro(state) => state.focus_outside_canvas,
|
||||||
|
Page::Admin(state) => state.focus_outside_canvas,
|
||||||
|
Page::AddLogic(state) => state.focus_outside_canvas,
|
||||||
|
Page::AddTable(state) => state.focus_outside_canvas,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focused_button(&self, router: &Router) -> usize {
|
||||||
|
match &router.current {
|
||||||
|
Page::Login(state) => state.focused_button_index,
|
||||||
|
Page::Register(state) => state.focused_button_index,
|
||||||
|
Page::Intro(state) => state.focused_button_index,
|
||||||
|
Page::Admin(state) => state.focused_button_index,
|
||||||
|
Page::AddLogic(state) => state.focused_button_index,
|
||||||
|
Page::AddTable(state) => state.focused_button_index,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,14 +40,11 @@ impl ModeManager {
|
|||||||
// If focus is inside a canvas, we don't duplicate canvas modes here.
|
// If focus is inside a canvas, we don't duplicate canvas modes here.
|
||||||
// Canvas crate owns ReadOnly/Edit/Highlight internally.
|
// Canvas crate owns ReadOnly/Edit/Highlight internally.
|
||||||
match &router.current {
|
match &router.current {
|
||||||
Page::Form(_)
|
Page::Form(_) => AppMode::General, // Form always has its own canvas
|
||||||
| Page::Login(_)
|
Page::Login(state) if !state.focus_outside_canvas => AppMode::General,
|
||||||
| Page::Register(_)
|
Page::Register(state) if !state.focus_outside_canvas => AppMode::General,
|
||||||
| Page::AddTable(_)
|
Page::AddTable(state) if !state.focus_outside_canvas => AppMode::General,
|
||||||
| Page::AddLogic(_) if !app_state.ui.focus_outside_canvas => {
|
Page::AddLogic(state) if !state.focus_outside_canvas => AppMode::General,
|
||||||
// Canvas active → let canvas handle its own AppMode
|
|
||||||
AppMode::General
|
|
||||||
}
|
|
||||||
_ => AppMode::General,
|
_ => AppMode::General,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// src/pages/admin/admin/state.rs
|
// src/pages/admin/admin/state.rs
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
use crate::pages::admin_panel::add_table::state::AddTableState;
|
|
||||||
use crate::movement::{move_focus, MovementAction};
|
use crate::movement::{move_focus, MovementAction};
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
|
|
||||||
@@ -26,7 +25,8 @@ pub struct AdminState {
|
|||||||
pub selected_profile_index: Option<usize>,
|
pub selected_profile_index: Option<usize>,
|
||||||
pub selected_table_index: Option<usize>,
|
pub selected_table_index: Option<usize>,
|
||||||
pub current_focus: AdminFocus,
|
pub current_focus: AdminFocus,
|
||||||
pub add_table_state: AddTableState,
|
pub focus_outside_canvas: bool,
|
||||||
|
pub focused_button_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminState {
|
impl AdminState {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use crate::pages::admin::{AdminFocus, AdminState};
|
|||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::config::binds::config::Config;
|
use crate::config::binds::config::Config;
|
||||||
use crate::buffer::state::{BufferState, AppView};
|
use crate::buffer::state::{BufferState, AppView};
|
||||||
use crate::pages::admin_panel::add_table::state::{AddTableState, LinkDefinition};
|
|
||||||
use ratatui::widgets::ListState;
|
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::admin_panel::add_logic::state::{AddLogicState, AddLogicFocus, AddLogicFormState};
|
||||||
use crate::pages::routing::{Page, Router};
|
use crate::pages::routing::{Page, Router};
|
||||||
|
|
||||||
@@ -43,15 +43,29 @@ pub fn handle_admin_navigation(
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
|
let action = config.get_general_action(key.code, key.modifiers).map(String::from);
|
||||||
|
|
||||||
let Page::Admin(admin_state) = &mut router.current else {
|
// 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;
|
return false;
|
||||||
};
|
};
|
||||||
let current_focus = admin_state.current_focus;
|
|
||||||
let profile_count = app_state.profile_tree.profiles.len();
|
let profile_count = app_state.profile_tree.profiles.len();
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
|
|
||||||
match current_focus {
|
match current_focus {
|
||||||
AdminFocus::ProfilesPane => {
|
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() {
|
match action.as_deref() {
|
||||||
Some("select") => {
|
Some("select") => {
|
||||||
admin_state.current_focus = AdminFocus::InsideProfilesList;
|
admin_state.current_focus = AdminFocus::InsideProfilesList;
|
||||||
@@ -69,7 +83,6 @@ pub fn handle_admin_navigation(
|
|||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
Some("previous_option") | Some("move_up") => {
|
Some("previous_option") | Some("move_up") => {
|
||||||
// No wrap-around: Stay on ProfilesPane if trying to go "before" it
|
|
||||||
*command_message = "At first focusable pane.".to_string();
|
*command_message = "At first focusable pane.".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
@@ -78,6 +91,10 @@ pub fn handle_admin_navigation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AdminFocus::InsideProfilesList => {
|
AdminFocus::InsideProfilesList => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
match action.as_deref() {
|
match action.as_deref() {
|
||||||
Some("move_up") => {
|
Some("move_up") => {
|
||||||
if profile_count > 0 {
|
if profile_count > 0 {
|
||||||
@@ -95,11 +112,11 @@ pub fn handle_admin_navigation(
|
|||||||
}
|
}
|
||||||
Some("select") => {
|
Some("select") => {
|
||||||
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
|
admin_state.selected_profile_index = admin_state.profile_list_state.selected();
|
||||||
admin_state.selected_table_index = None; // Deselect table when profile changes
|
admin_state.selected_table_index = None;
|
||||||
if let Some(profile_idx) = admin_state.selected_profile_index {
|
if let Some(profile_idx) = admin_state.selected_profile_index {
|
||||||
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
if let Some(profile) = app_state.profile_tree.profiles.get(profile_idx) {
|
||||||
if !profile.tables.is_empty() {
|
if !profile.tables.is_empty() {
|
||||||
admin_state.table_list_state.select(Some(0)); // Auto-select first table for nav
|
admin_state.table_list_state.select(Some(0));
|
||||||
} else {
|
} else {
|
||||||
admin_state.table_list_state.select(None);
|
admin_state.table_list_state.select(None);
|
||||||
}
|
}
|
||||||
@@ -123,6 +140,10 @@ pub fn handle_admin_navigation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AdminFocus::Tables => {
|
AdminFocus::Tables => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
match action.as_deref() {
|
match action.as_deref() {
|
||||||
Some("select") => {
|
Some("select") => {
|
||||||
admin_state.current_focus = AdminFocus::InsideTablesList;
|
admin_state.current_focus = AdminFocus::InsideTablesList;
|
||||||
@@ -152,7 +173,7 @@ pub fn handle_admin_navigation(
|
|||||||
} else {
|
} else {
|
||||||
*command_message = "No tables in selected profile.".to_string();
|
*command_message = "No tables in selected profile.".to_string();
|
||||||
}
|
}
|
||||||
admin_state.current_focus = AdminFocus::Tables; // Stay in Tables pane if no tables to enter
|
admin_state.current_focus = AdminFocus::Tables;
|
||||||
}
|
}
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
@@ -171,6 +192,10 @@ pub fn handle_admin_navigation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AdminFocus::InsideTablesList => {
|
AdminFocus::InsideTablesList => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
match action.as_deref() {
|
match action.as_deref() {
|
||||||
Some("move_up") => {
|
Some("move_up") => {
|
||||||
let current_profile_idx = admin_state.selected_profile_index
|
let current_profile_idx = admin_state.selected_profile_index
|
||||||
@@ -210,7 +235,7 @@ pub fn handle_admin_navigation(
|
|||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("select") => { // This is for persistently selecting a table with [*]
|
Some("select") => {
|
||||||
admin_state.selected_table_index = admin_state.table_list_state.selected();
|
admin_state.selected_table_index = admin_state.table_list_state.selected();
|
||||||
let table_name = admin_state.selected_profile_index
|
let table_name = admin_state.selected_profile_index
|
||||||
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
.and_then(|p_idx| app_state.profile_tree.profiles.get(p_idx))
|
||||||
@@ -230,10 +255,17 @@ pub fn handle_admin_navigation(
|
|||||||
|
|
||||||
AdminFocus::Button1 => { // Add Logic Button
|
AdminFocus::Button1 => { // Add Logic Button
|
||||||
match action.as_deref() {
|
match action.as_deref() {
|
||||||
Some("select") => { // Typically "Enter" key
|
Some("select") => {
|
||||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
// 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(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||||
if let Some(t_idx) = admin_state.selected_table_index {
|
if let Some(t_idx) = selected_table_idx {
|
||||||
if let Some(table) = profile.tables.get(t_idx) {
|
if let Some(table) = profile.tables.get(t_idx) {
|
||||||
// Create AddLogic page with selected profile & table
|
// Create AddLogic page with selected profile & table
|
||||||
let add_logic_form = AddLogicFormState::new_with_table(
|
let add_logic_form = AddLogicFormState::new_with_table(
|
||||||
@@ -243,16 +275,16 @@ pub fn handle_admin_navigation(
|
|||||||
table.name.clone(),
|
table.name.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Route to AddLogic
|
|
||||||
router.current = Page::AddLogic(add_logic_form);
|
|
||||||
// Store table info for later fetching
|
// Store table info for later fetching
|
||||||
app_state.pending_table_structure_fetch = Some((
|
app_state.pending_table_structure_fetch = Some((
|
||||||
profile.name.clone(),
|
profile.name.clone(),
|
||||||
table.name.clone(),
|
table.name.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Now it's safe to reassign router.current
|
||||||
|
router.current = Page::AddLogic(add_logic_form);
|
||||||
buffer_state.update_history(AppView::AddLogic);
|
buffer_state.update_history(AppView::AddLogic);
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
*command_message = format!(
|
*command_message = format!(
|
||||||
"Opening Add Logic for table '{}' in profile '{}'...",
|
"Opening Add Logic for table '{}' in profile '{}'...",
|
||||||
table.name, profile.name
|
table.name, profile.name
|
||||||
@@ -272,11 +304,17 @@ pub fn handle_admin_navigation(
|
|||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
Some("previous_option") | Some("move_up") => {
|
Some("previous_option") | Some("move_up") => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
admin_state.current_focus = AdminFocus::Tables;
|
admin_state.current_focus = AdminFocus::Tables;
|
||||||
*command_message = "Focus: Tables Pane".to_string();
|
*command_message = "Focus: Tables Pane".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
Some("next_option") | Some("move_down") => {
|
Some("next_option") | Some("move_down") => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
admin_state.current_focus = AdminFocus::Button2;
|
admin_state.current_focus = AdminFocus::Button2;
|
||||||
*command_message = "Focus: Add Table Button".to_string();
|
*command_message = "Focus: Add Table Button".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
@@ -288,25 +326,36 @@ pub fn handle_admin_navigation(
|
|||||||
AdminFocus::Button2 => { // Add Table Button
|
AdminFocus::Button2 => { // Add Table Button
|
||||||
match action.as_deref() {
|
match action.as_deref() {
|
||||||
Some("select") => {
|
Some("select") => {
|
||||||
if let Some(p_idx) = admin_state.selected_profile_index {
|
// Extract needed data first
|
||||||
|
let selected_profile_idx = if let Page::Admin(admin_state) = &router.current {
|
||||||
|
admin_state.selected_profile_index
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(p_idx) = selected_profile_idx {
|
||||||
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
if let Some(profile) = app_state.profile_tree.profiles.get(p_idx) {
|
||||||
let selected_profile_name = profile.name.clone();
|
let selected_profile_name = profile.name.clone();
|
||||||
// Prepare links from the selected profile's existing tables
|
// Prepare links from the selected profile's existing tables
|
||||||
let available_links: Vec<LinkDefinition> = profile.tables.iter()
|
let available_links: Vec<LinkDefinition> = profile.tables.iter()
|
||||||
.map(|table| LinkDefinition {
|
.map(|table| LinkDefinition {
|
||||||
linked_table_name: table.name.clone(),
|
linked_table_name: table.name.clone(),
|
||||||
is_required: false, // Default, can be changed in AddTable screen
|
is_required: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
admin_state.add_table_state = AddTableState {
|
// Build decoupled AddTable page and route into it
|
||||||
profile_name: selected_profile_name,
|
let mut page = AddTableFormState::new(selected_profile_name.clone());
|
||||||
links: available_links,
|
page.state.links = available_links;
|
||||||
..AddTableState::default() // Reset other fields
|
|
||||||
};
|
// Now safe to reassign router.current
|
||||||
|
router.current = Page::AddTable(page);
|
||||||
buffer_state.update_history(AppView::AddTable);
|
buffer_state.update_history(AppView::AddTable);
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
*command_message = format!("Opening Add Table for profile '{}'...", admin_state.add_table_state.profile_name);
|
*command_message = format!(
|
||||||
|
"Opening Add Table for profile '{}'...",
|
||||||
|
selected_profile_name
|
||||||
|
);
|
||||||
handled = true;
|
handled = true;
|
||||||
} else {
|
} else {
|
||||||
*command_message = "Error: Selected profile index out of bounds.".to_string();
|
*command_message = "Error: Selected profile index out of bounds.".to_string();
|
||||||
@@ -318,11 +367,17 @@ pub fn handle_admin_navigation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("previous_option") | Some("move_up") => {
|
Some("previous_option") | Some("move_up") => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
admin_state.current_focus = AdminFocus::Button1;
|
admin_state.current_focus = AdminFocus::Button1;
|
||||||
*command_message = "Focus: Add Logic Button".to_string();
|
*command_message = "Focus: Add Logic Button".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
Some("next_option") | Some("move_down") => {
|
Some("next_option") | Some("move_down") => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
admin_state.current_focus = AdminFocus::Button3;
|
admin_state.current_focus = AdminFocus::Button3;
|
||||||
*command_message = "Focus: Change Table Button".to_string();
|
*command_message = "Focus: Change Table Button".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
@@ -334,17 +389,18 @@ pub fn handle_admin_navigation(
|
|||||||
AdminFocus::Button3 => { // Change Table Button
|
AdminFocus::Button3 => { // Change Table Button
|
||||||
match action.as_deref() {
|
match action.as_deref() {
|
||||||
Some("select") => {
|
Some("select") => {
|
||||||
// Future: Logic to load selected table into AddTableState for editing
|
|
||||||
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
*command_message = "Action: Change Table (Not Implemented)".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
Some("previous_option") | Some("move_up") => {
|
Some("previous_option") | Some("move_up") => {
|
||||||
|
let Page::Admin(admin_state) = &mut router.current else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
admin_state.current_focus = AdminFocus::Button2;
|
admin_state.current_focus = AdminFocus::Button2;
|
||||||
*command_message = "Focus: Add Table Button".to_string();
|
*command_message = "Focus: Add Table Button".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
Some("next_option") | Some("move_down") => {
|
Some("next_option") | Some("move_down") => {
|
||||||
// No wrap-around: Stay on Button3 if trying to go "after" it
|
|
||||||
*command_message = "At last focusable button.".to_string();
|
*command_message = "At last focusable button.".to_string();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pub fn handle_add_logic_event(
|
|||||||
match key_event.code {
|
match key_event.code {
|
||||||
crossterm::event::KeyCode::Esc => {
|
crossterm::event::KeyCode::Esc => {
|
||||||
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
|
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||||
app_state.ui.focus_outside_canvas = true;
|
add_logic_page.focus_outside_canvas = true;
|
||||||
return Ok(EventOutcome::Ok("Exited script editing.".to_string()));
|
return Ok(EventOutcome::Ok("Exited script editing.".to_string()));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -85,7 +85,7 @@ pub fn handle_add_logic_event(
|
|||||||
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
|
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
|
||||||
add_logic_page.state.last_canvas_field = last_idx;
|
add_logic_page.state.last_canvas_field = last_idx;
|
||||||
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
|
add_logic_page.state.current_focus = AddLogicFocus::ScriptContentPreview;
|
||||||
app_state.ui.focus_outside_canvas = true;
|
add_logic_page.focus_outside_canvas = true;
|
||||||
return Ok(EventOutcome::Ok("Moved to Script Preview".to_string()));
|
return Ok(EventOutcome::Ok("Moved to Script Preview".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ pub fn handle_add_logic_event(
|
|||||||
let mut current = add_logic_page.state.current_focus;
|
let mut current = add_logic_page.state.current_focus;
|
||||||
if move_focus(&ADD_LOGIC_FOCUS_ORDER, &mut current, ma) {
|
if move_focus(&ADD_LOGIC_FOCUS_ORDER, &mut current, ma) {
|
||||||
add_logic_page.state.current_focus = current;
|
add_logic_page.state.current_focus = current;
|
||||||
app_state.ui.focus_outside_canvas = !matches!(
|
add_logic_page.focus_outside_canvas = !matches!(
|
||||||
add_logic_page.state.current_focus,
|
add_logic_page.state.current_focus,
|
||||||
AddLogicFocus::InputLogicName
|
AddLogicFocus::InputLogicName
|
||||||
| AddLogicFocus::InputTargetColumn
|
| AddLogicFocus::InputTargetColumn
|
||||||
@@ -127,7 +127,7 @@ pub fn handle_add_logic_event(
|
|||||||
MovementAction::Select => match add_logic_page.state.current_focus {
|
MovementAction::Select => match add_logic_page.state.current_focus {
|
||||||
AddLogicFocus::ScriptContentPreview => {
|
AddLogicFocus::ScriptContentPreview => {
|
||||||
add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent;
|
add_logic_page.state.current_focus = AddLogicFocus::InsideScriptContent;
|
||||||
app_state.ui.focus_outside_canvas = false;
|
add_logic_page.focus_outside_canvas = false;
|
||||||
return Ok(EventOutcome::Ok(
|
return Ok(EventOutcome::Ok(
|
||||||
"Fullscreen script editing. Esc to exit.".to_string(),
|
"Fullscreen script editing. Esc to exit.".to_string(),
|
||||||
));
|
));
|
||||||
@@ -147,7 +147,7 @@ pub fn handle_add_logic_event(
|
|||||||
MovementAction::Esc => {
|
MovementAction::Esc => {
|
||||||
if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview {
|
if add_logic_page.state.current_focus == AddLogicFocus::ScriptContentPreview {
|
||||||
add_logic_page.state.current_focus = AddLogicFocus::InputDescription;
|
add_logic_page.state.current_focus = AddLogicFocus::InputDescription;
|
||||||
app_state.ui.focus_outside_canvas = false;
|
add_logic_page.focus_outside_canvas = false;
|
||||||
return Ok(EventOutcome::Ok("Back to Description".to_string()));
|
return Ok(EventOutcome::Ok("Back to Description".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ pub struct AddLogicFormState {
|
|||||||
pub state: AddLogicState,
|
pub state: AddLogicState,
|
||||||
pub editor: FormEditor<AddLogicState>,
|
pub editor: FormEditor<AddLogicState>,
|
||||||
pub focus_outside_canvas: bool,
|
pub focus_outside_canvas: bool,
|
||||||
|
pub focused_button_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// manual Debug because FormEditor may not implement Debug
|
// manual Debug because FormEditor may not implement Debug
|
||||||
@@ -343,6 +344,7 @@ impl std::fmt::Debug for AddLogicFormState {
|
|||||||
f.debug_struct("AddLogicFormState")
|
f.debug_struct("AddLogicFormState")
|
||||||
.field("state", &self.state)
|
.field("state", &self.state)
|
||||||
.field("focus_outside_canvas", &self.focus_outside_canvas)
|
.field("focus_outside_canvas", &self.focus_outside_canvas)
|
||||||
|
.field("focused_button_index", &self.focused_button_index)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,6 +357,7 @@ impl AddLogicFormState {
|
|||||||
state,
|
state,
|
||||||
editor,
|
editor,
|
||||||
focus_outside_canvas: false,
|
focus_outside_canvas: false,
|
||||||
|
focused_button_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +376,7 @@ impl AddLogicFormState {
|
|||||||
state,
|
state,
|
||||||
editor,
|
editor,
|
||||||
focus_outside_canvas: false,
|
focus_outside_canvas: false,
|
||||||
|
focused_button_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +386,7 @@ impl AddLogicFormState {
|
|||||||
state,
|
state,
|
||||||
editor,
|
editor,
|
||||||
focus_outside_canvas: false,
|
focus_outside_canvas: false,
|
||||||
|
focused_button_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use anyhow::Result;
|
|||||||
use crate::config::binds::config::Config;
|
use crate::config::binds::config::Config;
|
||||||
use crate::movement::{move_focus, MovementAction};
|
use crate::movement::{move_focus, MovementAction};
|
||||||
use crate::pages::admin_panel::add_table::logic::{
|
use crate::pages::admin_panel::add_table::logic::{
|
||||||
handle_add_column_action, handle_delete_selected_columns, handle_save_table_action,
|
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::nav::SaveTableResultSender;
|
||||||
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
|
use crate::pages::admin_panel::add_table::state::{AddTableFocus, AddTableFormState};
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
@@ -15,11 +16,14 @@ use canvas::{AppMode as CanvasMode, DataProvider};
|
|||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
|
|
||||||
/// Focus traversal order for AddTable (outside canvas)
|
/// Focus traversal order for AddTable (outside canvas)
|
||||||
const ADD_TABLE_FOCUS_ORDER: [AddTableFocus; 7] = [
|
const ADD_TABLE_FOCUS_ORDER: [AddTableFocus; 10] = [
|
||||||
AddTableFocus::InputTableName,
|
AddTableFocus::InputTableName,
|
||||||
AddTableFocus::InputColumnName,
|
AddTableFocus::InputColumnName,
|
||||||
AddTableFocus::InputColumnType,
|
AddTableFocus::InputColumnType,
|
||||||
AddTableFocus::AddColumnButton,
|
AddTableFocus::AddColumnButton,
|
||||||
|
AddTableFocus::ColumnsTable,
|
||||||
|
AddTableFocus::IndexesTable,
|
||||||
|
AddTableFocus::LinksTable,
|
||||||
AddTableFocus::SaveButton,
|
AddTableFocus::SaveButton,
|
||||||
AddTableFocus::DeleteSelectedButton,
|
AddTableFocus::DeleteSelectedButton,
|
||||||
AddTableFocus::CancelButton,
|
AddTableFocus::CancelButton,
|
||||||
@@ -47,18 +51,7 @@ pub fn handle_add_table_event(
|
|||||||
|
|
||||||
if inside_canvas_inputs {
|
if inside_canvas_inputs {
|
||||||
// Disable global shortcuts while typing
|
// Disable global shortcuts while typing
|
||||||
app_state.ui.focus_outside_canvas = false;
|
page.focus_outside_canvas = false;
|
||||||
|
|
||||||
// Special case: allow ":" to enter command mode even inside canvas
|
|
||||||
if let Some(action) = config.get_general_action(key_event.code, key_event.modifiers) {
|
|
||||||
if action == "enter_command_mode"
|
|
||||||
&& !app_state.ui.show_search_palette
|
|
||||||
&& !app_state.ui.dialog.dialog_show
|
|
||||||
{
|
|
||||||
app_state.ui.focus_outside_canvas = true;
|
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow leaving the canvas with Down/Next when in ReadOnly mode
|
// Only allow leaving the canvas with Down/Next when in ReadOnly mode
|
||||||
let in_edit_mode = page.editor.mode() == CanvasMode::Edit;
|
let in_edit_mode = page.editor.mode() == CanvasMode::Edit;
|
||||||
@@ -69,7 +62,7 @@ pub fn handle_add_table_event(
|
|||||||
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
|
if at_last && matches!(ma, MovementAction::Down | MovementAction::Next) {
|
||||||
page.state.last_canvas_field = last_idx;
|
page.state.last_canvas_field = last_idx;
|
||||||
page.set_current_focus(AddTableFocus::AddColumnButton);
|
page.set_current_focus(AddTableFocus::AddColumnButton);
|
||||||
app_state.ui.focus_outside_canvas = true;
|
page.focus_outside_canvas = true;
|
||||||
return Ok(EventOutcome::Ok("Moved to Add button".to_string()));
|
return Ok(EventOutcome::Ok("Moved to Add button".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,21 +89,151 @@ pub fn handle_add_table_event(
|
|||||||
|
|
||||||
// 2) Outside canvas
|
// 2) Outside canvas
|
||||||
if let Some(ma) = movement {
|
if let Some(ma) = movement {
|
||||||
// First let the AddTable state's own movement handler process
|
// Block outer moves when "inside" any table and handle locally
|
||||||
if page.state.handle_movement(ma) {
|
match page.current_focus() {
|
||||||
app_state.ui.focus_outside_canvas = !matches!(
|
AddTableFocus::InsideColumnsTable => {
|
||||||
page.current_focus(),
|
match ma {
|
||||||
AddTableFocus::InputTableName
|
MovementAction::Up => {
|
||||||
| AddTableFocus::InputColumnName
|
if let Some(i) = page.state.column_table_state.selected() {
|
||||||
| AddTableFocus::InputColumnType
|
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()));
|
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();
|
let mut current = page.current_focus();
|
||||||
if move_focus(&ADD_TABLE_FOCUS_ORDER, &mut current, ma) {
|
if move_focus(&ADD_TABLE_FOCUS_ORDER, &mut current, ma) {
|
||||||
page.set_current_focus(current);
|
page.set_current_focus(current);
|
||||||
app_state.ui.focus_outside_canvas = !matches!(
|
page.focus_outside_canvas = !matches!(
|
||||||
page.current_focus(),
|
page.current_focus(),
|
||||||
AddTableFocus::InputTableName
|
AddTableFocus::InputTableName
|
||||||
| AddTableFocus::InputColumnName
|
| AddTableFocus::InputColumnName
|
||||||
@@ -123,11 +246,9 @@ pub fn handle_add_table_event(
|
|||||||
match ma {
|
match ma {
|
||||||
MovementAction::Select => match page.current_focus() {
|
MovementAction::Select => match page.current_focus() {
|
||||||
AddTableFocus::AddColumnButton => {
|
AddTableFocus::AddColumnButton => {
|
||||||
if let Some(focus_after_add) =
|
if let Some(msg) = page.state.add_column_from_inputs() {
|
||||||
handle_add_column_action(&mut page.state, &mut String::new())
|
// Focus is set by the state method; just bubble message
|
||||||
{
|
return Ok(EventOutcome::Ok(msg));
|
||||||
page.set_current_focus(focus_after_add);
|
|
||||||
return Ok(EventOutcome::Ok("Column added".into()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AddTableFocus::SaveButton => {
|
AddTableFocus::SaveButton => {
|
||||||
@@ -147,7 +268,10 @@ pub fn handle_add_table_event(
|
|||||||
return Ok(EventOutcome::Ok("Saving table...".into()));
|
return Ok(EventOutcome::Ok("Saving table...".into()));
|
||||||
}
|
}
|
||||||
AddTableFocus::DeleteSelectedButton => {
|
AddTableFocus::DeleteSelectedButton => {
|
||||||
let msg = handle_delete_selected_columns(&mut page.state);
|
let msg = page
|
||||||
|
.state
|
||||||
|
.delete_selected_items()
|
||||||
|
.unwrap_or_else(|| "No items selected for deletion".to_string());
|
||||||
return Ok(EventOutcome::Ok(msg));
|
return Ok(EventOutcome::Ok(msg));
|
||||||
}
|
}
|
||||||
AddTableFocus::CancelButton => {
|
AddTableFocus::CancelButton => {
|
||||||
|
|||||||
78
client/src/pages/admin_panel/add_table/loader.rs
Normal file
78
client/src/pages/admin_panel/add_table/loader.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// src/pages/admin_panel/add_table/loader.rs
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::pages::admin_panel::add_table::state::AddTableState;
|
||||||
|
use crate::services::grpc_client::GrpcClient;
|
||||||
|
use common::proto::komp_ac::table_definition::{
|
||||||
|
ColumnDefinition as ProtoColumnDefinition, PostTableDefinitionRequest, TableLink as ProtoTableLink,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Prepares and sends the request to save the new table definition via gRPC.
|
||||||
|
pub async fn handle_save_table_action(
|
||||||
|
grpc_client: &mut GrpcClient,
|
||||||
|
add_table_state: &AddTableState,
|
||||||
|
) -> Result<String> {
|
||||||
|
if add_table_state.table_name.is_empty() {
|
||||||
|
return Err(anyhow!("Table name cannot be empty."));
|
||||||
|
}
|
||||||
|
if add_table_state.columns.is_empty() {
|
||||||
|
return Err(anyhow!("Table must have at least one column."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.map(|col| ProtoColumnDefinition {
|
||||||
|
name: col.name.clone(),
|
||||||
|
field_type: col.data_type.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let proto_indexes: Vec<String> = add_table_state
|
||||||
|
.indexes
|
||||||
|
.iter()
|
||||||
|
.filter(|idx| idx.selected)
|
||||||
|
.map(|idx| idx.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let proto_links: Vec<ProtoTableLink> = add_table_state
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.filter(|link| link.selected)
|
||||||
|
.map(|link| ProtoTableLink {
|
||||||
|
linked_table_name: link.linked_table_name.clone(),
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let request = PostTableDefinitionRequest {
|
||||||
|
table_name: add_table_state.table_name.clone(),
|
||||||
|
columns: proto_columns,
|
||||||
|
indexes: proto_indexes,
|
||||||
|
links: proto_links,
|
||||||
|
profile_name: add_table_state.profile_name.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Sending PostTableDefinitionRequest: {:?}", request);
|
||||||
|
|
||||||
|
match grpc_client.post_table_definition(request).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.success {
|
||||||
|
Ok(format!(
|
||||||
|
"Table '{}' saved successfully.",
|
||||||
|
add_table_state.table_name
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
let error_message = if !response.sql.is_empty() {
|
||||||
|
format!("Server failed to save table: {}", response.sql)
|
||||||
|
} else {
|
||||||
|
"Server failed to save table (unknown reason).".to_string()
|
||||||
|
};
|
||||||
|
Err(anyhow!(error_message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,197 +1,24 @@
|
|||||||
// src/pages/admin_panel/add_table/logic.rs
|
// src/pages/admin_panel/add_table/logic.rs
|
||||||
use crate::pages::admin_panel::add_table::state;
|
|
||||||
use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus, IndexDefinition, ColumnDefinition};
|
|
||||||
use crate::services::GrpcClient;
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use common::proto::komp_ac::table_definition::{
|
|
||||||
PostTableDefinitionRequest,
|
|
||||||
ColumnDefinition as ProtoColumnDefinition,
|
|
||||||
TableLink as ProtoTableLink,
|
|
||||||
};
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
/// Handles the logic for adding a column when the "Add" button is activated.
|
use crate::pages::admin_panel::add_table::state::{AddTableState, AddTableFocus};
|
||||||
///
|
|
||||||
/// Takes the mutable state and command message string.
|
/// Thin wrapper around AddTableState::add_column_from_inputs
|
||||||
/// Returns `Some(AddTableFocus)` indicating the desired focus state after a successful add,
|
/// Returns Some(AddTableFocus) for compatibility with old call sites.
|
||||||
/// or `None` if the action failed (e.g., validation error).
|
|
||||||
pub fn handle_add_column_action(
|
pub fn handle_add_column_action(
|
||||||
add_table_state: &mut AddTableState,
|
add_table_state: &mut AddTableState,
|
||||||
command_message: &mut String,
|
command_message: &mut String,
|
||||||
) -> Option<AddTableFocus> {
|
) -> Option<AddTableFocus> {
|
||||||
|
if let Some(msg) = add_table_state.add_column_from_inputs() {
|
||||||
// Trim and create owned Strings from inputs
|
|
||||||
let table_name_in = add_table_state.table_name_input.trim();
|
|
||||||
let column_name_in = add_table_state.column_name_input.trim();
|
|
||||||
let column_type_in = add_table_state.column_type_input.trim();
|
|
||||||
|
|
||||||
// Validate all inputs needed for this combined action
|
|
||||||
let has_table_name = !table_name_in.is_empty();
|
|
||||||
let has_column_name = !column_name_in.is_empty();
|
|
||||||
let has_column_type = !column_type_in.is_empty();
|
|
||||||
|
|
||||||
match (has_table_name, has_column_name, has_column_type) {
|
|
||||||
// Case 1: Both column fields have input (Table name is optional here)
|
|
||||||
(_, true, true) => {
|
|
||||||
let mut msg = String::new();
|
|
||||||
// Optionally update table name if provided
|
|
||||||
if has_table_name {
|
|
||||||
add_table_state.table_name = table_name_in.to_string();
|
|
||||||
msg.push_str(&format!("Table name set to '{}'. ", add_table_state.table_name));
|
|
||||||
}
|
|
||||||
// Add the column
|
|
||||||
let new_column = ColumnDefinition {
|
|
||||||
name: column_name_in.to_string(),
|
|
||||||
data_type: column_type_in.to_string(),
|
|
||||||
selected: false,
|
|
||||||
};
|
|
||||||
add_table_state.columns.push(new_column.clone()); // Clone for msg
|
|
||||||
msg.push_str(&format!("Column '{}' added.", new_column.name));
|
|
||||||
|
|
||||||
// Add corresponding index definition (initially unselected)
|
|
||||||
let new_index = IndexDefinition {
|
|
||||||
name: column_name_in.to_string(),
|
|
||||||
selected: false,
|
|
||||||
};
|
|
||||||
add_table_state.indexes.push(new_index);
|
|
||||||
*command_message = msg;
|
*command_message = msg;
|
||||||
|
// State sets focus internally; return it explicitly for old call sites
|
||||||
// Clear all inputs and reset cursors
|
return Some(add_table_state.current_focus);
|
||||||
add_table_state.table_name_input.clear();
|
|
||||||
add_table_state.column_name_input.clear();
|
|
||||||
add_table_state.column_type_input.clear();
|
|
||||||
add_table_state.table_name_cursor_pos = 0;
|
|
||||||
add_table_state.column_name_cursor_pos = 0;
|
|
||||||
add_table_state.column_type_cursor_pos = 0;
|
|
||||||
add_table_state.has_unsaved_changes = true;
|
|
||||||
Some(AddTableFocus::InputColumnName) // Focus for next column
|
|
||||||
}
|
}
|
||||||
// Case 2: Only one column field has input (Error)
|
|
||||||
(_, true, false) | (_, false, true) => {
|
|
||||||
*command_message = "Both Column Name and Type are required to add a column.".to_string();
|
|
||||||
None // Indicate validation failure
|
|
||||||
}
|
|
||||||
// Case 3: Only Table name has input (No column input)
|
|
||||||
(true, false, false) => {
|
|
||||||
add_table_state.table_name = table_name_in.to_string();
|
|
||||||
*command_message = format!("Table name set to '{}'.", add_table_state.table_name);
|
|
||||||
// Clear only table name input
|
|
||||||
add_table_state.table_name_input.clear();
|
|
||||||
add_table_state.table_name_cursor_pos = 0;
|
|
||||||
add_table_state.has_unsaved_changes = true;
|
|
||||||
Some(AddTableFocus::InputTableName) // Keep focus here
|
|
||||||
}
|
|
||||||
// Case 4: All fields are empty
|
|
||||||
(false, false, false) => {
|
|
||||||
*command_message = "No input provided.".to_string();
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles deleting columns marked as selected in the AddTableState.
|
/// Thin wrapper around AddTableState::delete_selected_items
|
||||||
pub fn handle_delete_selected_columns(
|
pub fn handle_delete_selected_columns(add_table_state: &mut AddTableState) -> String {
|
||||||
add_table_state: &mut AddTableState,
|
add_table_state
|
||||||
) -> String {
|
.delete_selected_items()
|
||||||
let initial_count = add_table_state.columns.len();
|
.unwrap_or_else(|| "No items selected for deletion".to_string())
|
||||||
// Keep only the columns that are NOT selected
|
|
||||||
let initial_selected_indices: std::collections::HashSet<String> = add_table_state
|
|
||||||
.columns
|
|
||||||
.iter()
|
|
||||||
.filter(|col| col.selected)
|
|
||||||
.map(|col| col.name.clone())
|
|
||||||
.collect();
|
|
||||||
add_table_state.columns.retain(|col| !col.selected);
|
|
||||||
let deleted_count = initial_count - add_table_state.columns.len();
|
|
||||||
|
|
||||||
if deleted_count > 0 {
|
|
||||||
add_table_state.indexes.retain(|index| !initial_selected_indices.contains(&index.name));
|
|
||||||
add_table_state.has_unsaved_changes = true;
|
|
||||||
// Reset selection highlight as indices have changed
|
|
||||||
add_table_state.column_table_state.select(None);
|
|
||||||
// Optionally, select the first item if the list is not empty
|
|
||||||
// if !add_table_state.columns.is_empty() {
|
|
||||||
// add_table_state.column_table_state.select(Some(0));
|
|
||||||
// }
|
|
||||||
add_table_state.index_table_state.select(None);
|
|
||||||
format!("Deleted {} selected column(s).", deleted_count)
|
|
||||||
} else {
|
|
||||||
"No columns marked for deletion.".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prepares and sends the request to save the new table definition via gRPC.
|
|
||||||
pub async fn handle_save_table_action(
|
|
||||||
grpc_client: &mut GrpcClient,
|
|
||||||
add_table_state: &AddTableState,
|
|
||||||
) -> Result<String> {
|
|
||||||
// --- Basic Validation ---
|
|
||||||
if add_table_state.table_name.is_empty() {
|
|
||||||
return Err(anyhow!("Table name cannot be empty."));
|
|
||||||
}
|
|
||||||
if add_table_state.columns.is_empty() {
|
|
||||||
return Err(anyhow!("Table must have at least one column."));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Prepare Proto Data ---
|
|
||||||
let proto_columns: Vec<ProtoColumnDefinition> = add_table_state
|
|
||||||
.columns
|
|
||||||
.iter()
|
|
||||||
.map(|col| ProtoColumnDefinition {
|
|
||||||
name: col.name.clone(),
|
|
||||||
field_type: col.data_type.clone(), // Assuming data_type maps directly
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let proto_indexes: Vec<String> = add_table_state
|
|
||||||
.indexes
|
|
||||||
.iter()
|
|
||||||
.filter(|idx| idx.selected) // Only include selected indexes
|
|
||||||
.map(|idx| idx.name.clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let proto_links: Vec<ProtoTableLink> = add_table_state
|
|
||||||
.links
|
|
||||||
.iter()
|
|
||||||
.filter(|link| link.selected) // Only include selected links
|
|
||||||
.map(|link| ProtoTableLink {
|
|
||||||
linked_table_name: link.linked_table_name.clone(),
|
|
||||||
// Assuming 'required' maps directly, adjust if needed
|
|
||||||
// For now, the proto only seems to use linked_table_name based on example
|
|
||||||
// If your proto evolves, map link.is_required here.
|
|
||||||
required: false, // Set based on your proto definition/needs
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// --- Create Request ---
|
|
||||||
let request = PostTableDefinitionRequest {
|
|
||||||
table_name: add_table_state.table_name.clone(),
|
|
||||||
columns: proto_columns,
|
|
||||||
indexes: proto_indexes,
|
|
||||||
links: proto_links,
|
|
||||||
profile_name: add_table_state.profile_name.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!("Sending PostTableDefinitionRequest: {:?}", request);
|
|
||||||
|
|
||||||
// --- Call gRPC Service ---
|
|
||||||
match grpc_client.post_table_definition(request).await {
|
|
||||||
Ok(response) => {
|
|
||||||
if response.success {
|
|
||||||
Ok(format!(
|
|
||||||
"Table '{}' saved successfully.",
|
|
||||||
add_table_state.table_name
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
// Use the SQL message from the response if available, otherwise generic error
|
|
||||||
let error_message = if !response.sql.is_empty() {
|
|
||||||
format!("Server failed to save table: {}", response.sql)
|
|
||||||
} else {
|
|
||||||
"Server failed to save table (unknown reason).".to_string()
|
|
||||||
};
|
|
||||||
Err(anyhow!(error_message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(anyhow!("gRPC call failed: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ pub mod nav;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod logic;
|
pub mod logic;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod loader;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use canvas::{DataProvider, AppMode};
|
|||||||
use canvas::FormEditor;
|
use canvas::FormEditor;
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::movement::{move_focus, MovementAction};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ColumnDefinition {
|
pub struct ColumnDefinition {
|
||||||
@@ -99,23 +98,48 @@ impl AddTableState {
|
|||||||
|
|
||||||
/// Helper method to add a column from current inputs
|
/// Helper method to add a column from current inputs
|
||||||
pub fn add_column_from_inputs(&mut self) -> Option<String> {
|
pub fn add_column_from_inputs(&mut self) -> Option<String> {
|
||||||
if self.column_name_input.trim().is_empty() || self.column_type_input.trim().is_empty() {
|
let table_name_in = self.table_name_input.trim().to_string();
|
||||||
return Some("Both column name and type are required".to_string());
|
let column_name_in = self.column_name_input.trim().to_string();
|
||||||
|
let column_type_in = self.column_type_input.trim().to_string();
|
||||||
|
|
||||||
|
// Case: "only table name" provided → set it and stay on TableName
|
||||||
|
if !table_name_in.is_empty() && column_name_in.is_empty() && column_type_in.is_empty() {
|
||||||
|
self.table_name = table_name_in;
|
||||||
|
self.table_name_input.clear();
|
||||||
|
self.table_name_cursor_pos = 0;
|
||||||
|
self.current_focus = AddTableFocus::InputTableName;
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
return Some(format!("Table name set to '{}'.", self.table_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate column names
|
// Column validation
|
||||||
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) {
|
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());
|
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
|
// Add the column
|
||||||
self.columns.push(ColumnDefinition {
|
self.columns.push(ColumnDefinition {
|
||||||
name: self.column_name_input.trim().to_string(),
|
name: column_name_in.clone(),
|
||||||
data_type: self.column_type_input.trim().to_string(),
|
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,
|
selected: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear inputs and reset focus to column name for next entry
|
// Clear column inputs and set focus for next entry
|
||||||
self.column_name_input.clear();
|
self.column_name_input.clear();
|
||||||
self.column_type_input.clear();
|
self.column_type_input.clear();
|
||||||
self.column_name_cursor_pos = 0;
|
self.column_name_cursor_pos = 0;
|
||||||
@@ -124,23 +148,33 @@ impl AddTableState {
|
|||||||
self.last_canvas_field = 1;
|
self.last_canvas_field = 1;
|
||||||
self.has_unsaved_changes = true;
|
self.has_unsaved_changes = true;
|
||||||
|
|
||||||
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name))
|
Some(format!("Column '{}' added successfully", column_name_in))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to delete selected items
|
/// Helper method to delete selected items
|
||||||
pub fn delete_selected_items(&mut self) -> Option<String> {
|
pub fn delete_selected_items(&mut self) -> Option<String> {
|
||||||
let mut deleted_items = Vec::new();
|
let mut deleted_items: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// Remove selected columns
|
// Remove selected columns
|
||||||
let initial_column_count = self.columns.len();
|
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| {
|
self.columns.retain(|col| {
|
||||||
if col.selected {
|
if selected_col_names.contains(&col.name) {
|
||||||
deleted_items.push(format!("column '{}'", col.name));
|
deleted_items.push(format!("column '{}'", col.name));
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Also purge indexes for deleted columns
|
||||||
|
self.indexes
|
||||||
|
.retain(|idx| !selected_col_names.contains(&idx.name));
|
||||||
|
}
|
||||||
|
|
||||||
// Remove selected indexes
|
// Remove selected indexes
|
||||||
let initial_index_count = self.indexes.len();
|
let initial_index_count = self.indexes.len();
|
||||||
@@ -168,6 +202,8 @@ impl AddTableState {
|
|||||||
Some("No items selected for deletion".to_string())
|
Some("No items selected for deletion".to_string())
|
||||||
} else {
|
} else {
|
||||||
self.has_unsaved_changes = true;
|
self.has_unsaved_changes = true;
|
||||||
|
self.column_table_state.select(None);
|
||||||
|
self.index_table_state.select(None);
|
||||||
Some(format!("Deleted: {}", deleted_items.join(", ")))
|
Some(format!("Deleted: {}", deleted_items.join(", ")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,182 +247,11 @@ impl DataProvider for AddTableState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl AddTableState {
|
|
||||||
pub fn handle_movement(&mut self, action: MovementAction) -> bool {
|
|
||||||
use AddTableFocus::*;
|
|
||||||
|
|
||||||
// Linear outer focus order
|
|
||||||
const ORDER: [AddTableFocus; 10] = [
|
|
||||||
InputTableName,
|
|
||||||
InputColumnName,
|
|
||||||
InputColumnType,
|
|
||||||
AddColumnButton,
|
|
||||||
ColumnsTable,
|
|
||||||
IndexesTable,
|
|
||||||
LinksTable,
|
|
||||||
SaveButton,
|
|
||||||
DeleteSelectedButton,
|
|
||||||
CancelButton,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Enter "inside" on Select from outer panes
|
|
||||||
match (self.current_focus, action) {
|
|
||||||
(ColumnsTable, MovementAction::Select) => {
|
|
||||||
if !self.columns.is_empty() && self.column_table_state.selected().is_none() {
|
|
||||||
self.column_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
self.current_focus = InsideColumnsTable;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
(IndexesTable, MovementAction::Select) => {
|
|
||||||
if !self.indexes.is_empty() && self.index_table_state.selected().is_none() {
|
|
||||||
self.index_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
self.current_focus = InsideIndexesTable;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
(LinksTable, MovementAction::Select) => {
|
|
||||||
if !self.links.is_empty() && self.link_table_state.selected().is_none() {
|
|
||||||
self.link_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
self.current_focus = InsideLinksTable;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle "inside" states: Up/Down/Select/Esc; block outer movement keys
|
|
||||||
match self.current_focus {
|
|
||||||
InsideColumnsTable => {
|
|
||||||
match action {
|
|
||||||
MovementAction::Up => {
|
|
||||||
if let Some(i) = self.column_table_state.selected() {
|
|
||||||
let next = i.saturating_sub(1);
|
|
||||||
self.column_table_state.select(Some(next));
|
|
||||||
} else if !self.columns.is_empty() {
|
|
||||||
self.column_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Down => {
|
|
||||||
if let Some(i) = self.column_table_state.selected() {
|
|
||||||
let last = self.columns.len().saturating_sub(1);
|
|
||||||
let next = if i < last { i + 1 } else { i };
|
|
||||||
self.column_table_state.select(Some(next));
|
|
||||||
} else if !self.columns.is_empty() {
|
|
||||||
self.column_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Select => {
|
|
||||||
if let Some(i) = self.column_table_state.selected() {
|
|
||||||
if let Some(col) = self.columns.get_mut(i) {
|
|
||||||
col.selected = !col.selected;
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Esc => {
|
|
||||||
self.column_table_state.select(None);
|
|
||||||
self.current_focus = ColumnsTable;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InsideIndexesTable => {
|
|
||||||
match action {
|
|
||||||
MovementAction::Up => {
|
|
||||||
if let Some(i) = self.index_table_state.selected() {
|
|
||||||
let next = i.saturating_sub(1);
|
|
||||||
self.index_table_state.select(Some(next));
|
|
||||||
} else if !self.indexes.is_empty() {
|
|
||||||
self.index_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Down => {
|
|
||||||
if let Some(i) = self.index_table_state.selected() {
|
|
||||||
let last = self.indexes.len().saturating_sub(1);
|
|
||||||
let next = if i < last { i + 1 } else { i };
|
|
||||||
self.index_table_state.select(Some(next));
|
|
||||||
} else if !self.indexes.is_empty() {
|
|
||||||
self.index_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Select => {
|
|
||||||
if let Some(i) = self.index_table_state.selected() {
|
|
||||||
if let Some(ix) = self.indexes.get_mut(i) {
|
|
||||||
ix.selected = !ix.selected;
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Esc => {
|
|
||||||
self.index_table_state.select(None);
|
|
||||||
self.current_focus = IndexesTable;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InsideLinksTable => {
|
|
||||||
match action {
|
|
||||||
MovementAction::Up => {
|
|
||||||
if let Some(i) = self.link_table_state.selected() {
|
|
||||||
let next = i.saturating_sub(1);
|
|
||||||
self.link_table_state.select(Some(next));
|
|
||||||
} else if !self.links.is_empty() {
|
|
||||||
self.link_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Down => {
|
|
||||||
if let Some(i) = self.link_table_state.selected() {
|
|
||||||
let last = self.links.len().saturating_sub(1);
|
|
||||||
let next = if i < last { i + 1 } else { i };
|
|
||||||
self.link_table_state.select(Some(next));
|
|
||||||
} else if !self.links.is_empty() {
|
|
||||||
self.link_table_state.select(Some(0));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Select => {
|
|
||||||
if let Some(i) = self.link_table_state.selected() {
|
|
||||||
if let Some(link) = self.links.get_mut(i) {
|
|
||||||
link.selected = !link.selected;
|
|
||||||
self.has_unsaved_changes = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Esc => {
|
|
||||||
self.link_table_state.select(None);
|
|
||||||
self.current_focus = LinksTable;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
MovementAction::Next | MovementAction::Previous => return true, // block outer moves
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: outer navigation via helper
|
|
||||||
move_focus(&ORDER, &mut self.current_focus, action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AddTableFormState {
|
pub struct AddTableFormState {
|
||||||
pub state: AddTableState,
|
pub state: AddTableState,
|
||||||
pub editor: FormEditor<AddTableState>,
|
pub editor: FormEditor<AddTableState>,
|
||||||
pub focus_outside_canvas: bool,
|
pub focus_outside_canvas: bool,
|
||||||
|
pub focused_button_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for AddTableFormState {
|
impl std::fmt::Debug for AddTableFormState {
|
||||||
@@ -394,6 +259,7 @@ impl std::fmt::Debug for AddTableFormState {
|
|||||||
f.debug_struct("AddTableFormState")
|
f.debug_struct("AddTableFormState")
|
||||||
.field("state", &self.state)
|
.field("state", &self.state)
|
||||||
.field("focus_outside_canvas", &self.focus_outside_canvas)
|
.field("focus_outside_canvas", &self.focus_outside_canvas)
|
||||||
|
.field("focused_button_index", &self.focused_button_index)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,6 +273,7 @@ impl AddTableFormState {
|
|||||||
state,
|
state,
|
||||||
editor,
|
editor,
|
||||||
focus_outside_canvas: false,
|
focus_outside_canvas: false,
|
||||||
|
focused_button_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +283,7 @@ impl AddTableFormState {
|
|||||||
state,
|
state,
|
||||||
editor,
|
editor,
|
||||||
focus_outside_canvas: false,
|
focus_outside_canvas: false,
|
||||||
|
focused_button_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,4 +323,10 @@ impl AddTableFormState {
|
|||||||
pub fn link_table_state(&mut self) -> &mut TableState {
|
pub fn link_table_state(&mut self) -> &mut TableState {
|
||||||
&mut self.state.link_table_state
|
&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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -490,10 +490,11 @@ pub fn render_add_table(
|
|||||||
.split(bottom_buttons_area);
|
.split(bottom_buttons_area);
|
||||||
|
|
||||||
let save_button = Paragraph::new(" Save table ")
|
let save_button = Paragraph::new(" Save table ")
|
||||||
.style(get_button_style(
|
.style(if add_table_state.current_focus() == AddTableFocus::SaveButton {
|
||||||
AddTableFocus::SaveButton,
|
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||||
add_table_state.current_focus(),
|
} else {
|
||||||
))
|
Style::default().fg(theme.secondary)
|
||||||
|
})
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
@@ -507,34 +508,36 @@ pub fn render_add_table(
|
|||||||
f.render_widget(save_button, bottom_button_chunks[0]);
|
f.render_widget(save_button, bottom_button_chunks[0]);
|
||||||
|
|
||||||
let delete_button = Paragraph::new(" Delete Selected ")
|
let delete_button = Paragraph::new(" Delete Selected ")
|
||||||
.style(get_button_style(
|
.style(if add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton {
|
||||||
AddTableFocus::DeleteSelectedButton,
|
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||||
add_table_state.current_focus(),
|
} else {
|
||||||
))
|
Style::default().fg(theme.secondary)
|
||||||
|
})
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(get_button_border_style(
|
.border_style(get_button_border_style(
|
||||||
add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton, // Pass bool
|
add_table_state.current_focus() == AddTableFocus::DeleteSelectedButton,
|
||||||
theme,
|
theme,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
f.render_widget(delete_button, bottom_button_chunks[1]);
|
f.render_widget(delete_button, bottom_button_chunks[1]);
|
||||||
|
|
||||||
let cancel_button = Paragraph::new(" Cancel ")
|
let cancel_button = Paragraph::new(" Cancel ")
|
||||||
.style(get_button_style(
|
.style(if add_table_state.current_focus() == AddTableFocus::CancelButton {
|
||||||
AddTableFocus::CancelButton,
|
Style::default().fg(theme.highlight).add_modifier(Modifier::BOLD)
|
||||||
add_table_state.current_focus(),
|
} else {
|
||||||
))
|
Style::default().fg(theme.secondary)
|
||||||
|
})
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.border_style(get_button_border_style(
|
.border_style(get_button_border_style(
|
||||||
add_table_state.current_focus() == AddTableFocus::CancelButton, // Pass bool
|
add_table_state.current_focus() == AddTableFocus::CancelButton,
|
||||||
theme,
|
theme,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ pub fn handle_intro_selection(
|
|||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
buffer_state.update_history(AppView::Register);
|
buffer_state.update_history(AppView::Register);
|
||||||
// Register view requires focus reset
|
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
app_state.focused_button_index = 0;
|
|
||||||
}
|
}
|
||||||
_ => return,
|
_ => return,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,27 @@ use crate::movement::MovementAction;
|
|||||||
|
|
||||||
#[derive(Default, Clone, Debug)]
|
#[derive(Default, Clone, Debug)]
|
||||||
pub struct IntroState {
|
pub struct IntroState {
|
||||||
pub selected_option: usize,
|
pub focus_outside_canvas: bool,
|
||||||
|
pub focused_button_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntroState {
|
impl IntroState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
focus_outside_canvas: true,
|
||||||
|
focused_button_index: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_option(&mut self) {
|
pub fn next_option(&mut self) {
|
||||||
if self.selected_option < 3 {
|
if self.focused_button_index < 3 {
|
||||||
self.selected_option += 1;
|
self.focused_button_index += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn previous_option(&mut self) {
|
pub fn previous_option(&mut self) {
|
||||||
if self.selected_option > 0 {
|
if self.focused_button_index > 0 {
|
||||||
self.selected_option -= 1
|
self.focused_button_index -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ pub fn render_intro(f: &mut Frame, intro_state: &IntroState, area: Rect, theme:
|
|||||||
|
|
||||||
let buttons = ["Continue", "Admin", "Login", "Register"];
|
let buttons = ["Continue", "Admin", "Login", "Register"];
|
||||||
for (i, &text) in buttons.iter().enumerate() {
|
for (i, &text) in buttons.iter().enumerate() {
|
||||||
render_button(f, button_area[i], text, intro_state.selected_option == i, theme);
|
let active = intro_state.focused_button_index == i;
|
||||||
|
render_button(f, button_area[i], text, active, theme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ pub fn handle_login_event(
|
|||||||
&& modifiers.is_empty()
|
&& modifiers.is_empty()
|
||||||
{
|
{
|
||||||
login_page.focus_outside_canvas = false;
|
login_page.focus_outside_canvas = false;
|
||||||
app_state.ui.focus_outside_canvas = false; // 🔑 keep global in sync
|
|
||||||
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
}
|
}
|
||||||
@@ -43,9 +42,7 @@ pub fn handle_login_event(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
login_page.focus_outside_canvas = true;
|
login_page.focus_outside_canvas = true;
|
||||||
login_page.focused_button_index = 0; // focus "Login" button
|
login_page.focused_button_index = 0;
|
||||||
app_state.ui.focus_outside_canvas = true;
|
|
||||||
app_state.focused_button_index = 0;
|
|
||||||
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
login_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||||
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
|
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,9 +125,6 @@ pub async fn back_to_main(
|
|||||||
buffer_state.close_active_buffer();
|
buffer_state.close_active_buffer();
|
||||||
buffer_state.update_history(AppView::Intro);
|
buffer_state.update_history(AppView::Intro);
|
||||||
|
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
app_state.focused_button_index = 0;
|
|
||||||
|
|
||||||
"Returned to main menu".to_string()
|
"Returned to main menu".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,8 @@ pub fn render_login(
|
|||||||
|
|
||||||
// Login Button
|
// Login Button
|
||||||
let login_button_index = 0;
|
let login_button_index = 0;
|
||||||
let login_active = if login_page.focus_outside_canvas {
|
let login_active = login_page.focus_outside_canvas
|
||||||
app_state.focused_button_index == login_button_index
|
&& login_page.focused_button_index == login_button_index;
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
let mut login_style = Style::default().fg(theme.fg);
|
let mut login_style = Style::default().fg(theme.fg);
|
||||||
let mut login_border = Style::default().fg(theme.border);
|
let mut login_border = Style::default().fg(theme.border);
|
||||||
if login_active {
|
if login_active {
|
||||||
@@ -107,11 +104,8 @@ pub fn render_login(
|
|||||||
|
|
||||||
// Return Button
|
// Return Button
|
||||||
let return_button_index = 1;
|
let return_button_index = 1;
|
||||||
let return_active = if app_state.ui.focus_outside_canvas {
|
let return_active = login_page.focus_outside_canvas
|
||||||
app_state.focused_button_index == return_button_index
|
&& login_page.focused_button_index == return_button_index;
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
let mut return_style = Style::default().fg(theme.fg);
|
let mut return_style = Style::default().fg(theme.fg);
|
||||||
let mut return_border = Style::default().fg(theme.border);
|
let mut return_border = Style::default().fg(theme.border);
|
||||||
if return_active {
|
if return_active {
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ pub fn handle_register_event(
|
|||||||
&& modifiers.is_empty()
|
&& modifiers.is_empty()
|
||||||
{
|
{
|
||||||
register_page.focus_outside_canvas = false;
|
register_page.focus_outside_canvas = false;
|
||||||
// Keep global in sync for now (cursor styling elsewhere still reads it)
|
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
register_page.editor.set_mode(CanvasMode::ReadOnly);
|
register_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||||
return Ok(EventOutcome::Ok(String::new()));
|
return Ok(EventOutcome::Ok(String::new()));
|
||||||
}
|
}
|
||||||
@@ -47,9 +45,6 @@ pub fn handle_register_event(
|
|||||||
{
|
{
|
||||||
register_page.focus_outside_canvas = true;
|
register_page.focus_outside_canvas = true;
|
||||||
register_page.focused_button_index = 0; // focus "Register" button
|
register_page.focused_button_index = 0; // focus "Register" button
|
||||||
// Keep global in sync for now
|
|
||||||
app_state.ui.focus_outside_canvas = true;
|
|
||||||
app_state.focused_button_index = 0;
|
|
||||||
register_page.editor.set_mode(CanvasMode::ReadOnly);
|
register_page.editor.set_mode(CanvasMode::ReadOnly);
|
||||||
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
|
return Ok(EventOutcome::Ok("Focus moved to buttons".into()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ pub async fn back_to_login(
|
|||||||
// Reset focus state
|
// Reset focus state
|
||||||
register_state.focus_outside_canvas = false;
|
register_state.focus_outside_canvas = false;
|
||||||
register_state.focused_button_index = 0;
|
register_state.focused_button_index = 0;
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
app_state.focused_button_index = 0;
|
|
||||||
|
|
||||||
"Returned to main menu".to_string()
|
"Returned to main menu".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ pub async fn handle_search_palette_event(
|
|||||||
if should_close {
|
if should_close {
|
||||||
app_state.search_state = None;
|
app_state.search_state = None;
|
||||||
app_state.ui.show_search_palette = false;
|
app_state.ui.show_search_palette = false;
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(outcome_message)
|
Ok(outcome_message)
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub struct UiState {
|
|||||||
pub show_login: bool,
|
pub show_login: bool,
|
||||||
pub show_register: bool,
|
pub show_register: bool,
|
||||||
pub show_search_palette: bool,
|
pub show_search_palette: bool,
|
||||||
pub focus_outside_canvas: bool,
|
|
||||||
pub dialog: DialogState,
|
pub dialog: DialogState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ pub struct AppState {
|
|||||||
// NEW: The "Rulebook" cache. We use Arc for efficient sharing.
|
// NEW: The "Rulebook" cache. We use Arc for efficient sharing.
|
||||||
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
|
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
|
||||||
|
|
||||||
pub focused_button_index: usize,
|
|
||||||
pub pending_table_structure_fetch: Option<(String, String)>,
|
pub pending_table_structure_fetch: Option<(String, String)>,
|
||||||
|
|
||||||
pub search_state: Option<SearchState>,
|
pub search_state: Option<SearchState>,
|
||||||
@@ -77,7 +75,6 @@ impl AppState {
|
|||||||
current_view_table_name: None,
|
current_view_table_name: None,
|
||||||
current_mode: AppMode::General,
|
current_mode: AppMode::General,
|
||||||
schema_cache: HashMap::new(), // NEW: Initialize the cache
|
schema_cache: HashMap::new(), // NEW: Initialize the cache
|
||||||
focused_button_index: 0,
|
|
||||||
pending_table_structure_fetch: None,
|
pending_table_structure_fetch: None,
|
||||||
search_state: None,
|
search_state: None,
|
||||||
ui: UiState::default(),
|
ui: UiState::default(),
|
||||||
@@ -166,8 +163,7 @@ impl Default for UiState {
|
|||||||
show_login: false,
|
show_login: false,
|
||||||
show_register: false,
|
show_register: false,
|
||||||
show_buffer_list: true,
|
show_buffer_list: true,
|
||||||
show_search_palette: false, // ADDED
|
show_search_palette: false,
|
||||||
focus_outside_canvas: false,
|
|
||||||
dialog: DialogState::default(),
|
dialog: DialogState::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,12 @@ pub fn logout(
|
|||||||
// Delete stored auth data
|
// Delete stored auth data
|
||||||
if let Err(e) = delete_auth_data() {
|
if let Err(e) = delete_auth_data() {
|
||||||
error!("Failed to delete stored auth data: {}", e);
|
error!("Failed to delete stored auth data: {}", e);
|
||||||
// Continue anyway - user is logged out in memory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to intro screen
|
// Navigate to intro screen
|
||||||
buffer_state.history = vec![AppView::Intro];
|
buffer_state.history = vec![AppView::Intro];
|
||||||
buffer_state.active_index = 0;
|
buffer_state.active_index = 0;
|
||||||
|
|
||||||
// Reset UI state
|
|
||||||
app_state.ui.focus_outside_canvas = false;
|
|
||||||
app_state.focused_button_index = 0;
|
|
||||||
|
|
||||||
// Hide any open dialogs
|
// Hide any open dialogs
|
||||||
app_state.hide_dialog();
|
app_state.hide_dialog();
|
||||||
|
|
||||||
@@ -39,7 +34,7 @@ pub fn logout(
|
|||||||
"Logged Out",
|
"Logged Out",
|
||||||
"You have been successfully logged out.",
|
"You have been successfully logged out.",
|
||||||
vec!["OK".to_string()],
|
vec!["OK".to_string()],
|
||||||
DialogPurpose::LoginSuccess, // Reuse or create a new purpose
|
DialogPurpose::LoginSuccess,
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("User logged out successfully.");
|
info!("User logged out successfully.");
|
||||||
|
|||||||
@@ -227,8 +227,17 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
|| app_state.ui.show_search_palette
|
|| app_state.ui.show_search_palette
|
||||||
|| event_handler.navigation_state.active;
|
|| event_handler.navigation_state.active;
|
||||||
if !overlay_active {
|
if !overlay_active {
|
||||||
|
let inside_canvas = match &router.current {
|
||||||
|
Page::Form(_) => true,
|
||||||
|
Page::Login(state) => !state.focus_outside_canvas,
|
||||||
|
Page::Register(state) => !state.focus_outside_canvas,
|
||||||
|
Page::AddTable(state) => !state.focus_outside_canvas,
|
||||||
|
Page::AddLogic(state) => !state.focus_outside_canvas,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if inside_canvas {
|
||||||
if let Page::Form(path) = &router.current {
|
if let Page::Form(path) = &router.current {
|
||||||
if !app_state.ui.focus_outside_canvas {
|
|
||||||
if let Some(editor) = app_state.editor_for_path(path) {
|
if let Some(editor) = app_state.editor_for_path(path) {
|
||||||
match editor.handle_key_event(*key_event) {
|
match editor.handle_key_event(*key_event) {
|
||||||
KeyEventOutcome::Consumed(Some(msg)) => {
|
KeyEventOutcome::Consumed(Some(msg)) => {
|
||||||
@@ -366,7 +375,9 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
vec!["OK".to_string()],
|
vec!["OK".to_string()],
|
||||||
DialogPurpose::SaveTableSuccess,
|
DialogPurpose::SaveTableSuccess,
|
||||||
);
|
);
|
||||||
admin_state.add_table_state.has_unsaved_changes = false;
|
if let Page::AddTable(page) = &mut router.current {
|
||||||
|
page.state.has_unsaved_changes = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
event_handler.command_message = format!("Save failed: {}", e);
|
event_handler.command_message = format!("Save failed: {}", e);
|
||||||
@@ -426,14 +437,11 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
router.navigate(Page::Admin(admin_state.clone()));
|
router.navigate(Page::Admin(admin_state.clone()));
|
||||||
}
|
}
|
||||||
AppView::AddTable => {
|
AppView::AddTable => {
|
||||||
if let Page::AddTable(_) = &router.current {
|
if let Page::AddTable(page) = &mut router.current {
|
||||||
} else {
|
// Ensure keymap is set once (same as AddLogic)
|
||||||
let mut page =
|
|
||||||
add_table::state::AddTableFormState::from_state(
|
|
||||||
admin_state.add_table_state.clone(),
|
|
||||||
);
|
|
||||||
page.editor.set_keymap(config.build_canvas_keymap());
|
page.editor.set_keymap(config.build_canvas_keymap());
|
||||||
router.navigate(Page::AddTable(page));
|
} else {
|
||||||
|
// Page is created by admin navigation (Button2). No-op here.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppView::AddLogic => {
|
AppView::AddLogic => {
|
||||||
@@ -634,7 +642,15 @@ pub async fn run_ui() -> Result<()> {
|
|||||||
|
|
||||||
match current_mode {
|
match current_mode {
|
||||||
AppMode::General => {
|
AppMode::General => {
|
||||||
if app_state.ui.focus_outside_canvas {
|
let outside_canvas = match &router.current {
|
||||||
|
Page::Login(state) => state.focus_outside_canvas,
|
||||||
|
Page::Register(state) => state.focus_outside_canvas,
|
||||||
|
Page::AddTable(state) => state.focus_outside_canvas,
|
||||||
|
Page::AddLogic(state) => state.focus_outside_canvas,
|
||||||
|
_ => false, // Form and Admin don’t use this flag
|
||||||
|
};
|
||||||
|
|
||||||
|
if outside_canvas {
|
||||||
// Outside canvas → app decides
|
// Outside canvas → app decides
|
||||||
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
|
terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|||||||
Reference in New Issue
Block a user