src/editor.rs doesnt exist anymore

This commit is contained in:
Priec
2025-08-15 00:06:19 +02:00
parent 3f5dedbd6e
commit 67512ac151
12 changed files with 2206 additions and 2192 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
// src/editor/computed_helpers.rs
use crate::computed::{ComputedContext, ComputedProvider, ComputedState};
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "computed")]
pub fn set_computed_provider<C>(&mut self, mut provider: C)
where
C: ComputedProvider,
{
self.ui_state.computed = Some(ComputedState::new());
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if provider.handles_field(field_index) {
let deps = provider.field_dependencies(field_index);
if let Some(computed_state) = &mut self.ui_state.computed {
computed_state.register_computed_field(field_index, deps);
}
}
}
self.recompute_all_fields(&mut provider);
}
#[cfg(feature = "computed")]
pub fn recompute_fields<C>(
&mut self,
provider: &mut C,
field_indices: &[usize],
) where
C: ComputedProvider,
{
if let Some(computed_state) = &mut self.ui_state.computed {
let field_values: Vec<String> = (0..self.data_provider.field_count())
.map(|i| {
if computed_state.is_computed_field(i) {
computed_state
.get_computed_value(i)
.cloned()
.unwrap_or_default()
} else {
self.data_provider.field_value(i).to_string()
}
})
.collect();
let field_refs: Vec<&str> =
field_values.iter().map(|s| s.as_str()).collect();
for &field_index in field_indices {
if provider.handles_field(field_index) {
let context = ComputedContext {
field_values: &field_refs,
target_field: field_index,
current_field: Some(self.ui_state.current_field),
};
let computed_value = provider.compute_field(context);
computed_state.set_computed_value(
field_index,
computed_value,
);
}
}
}
}
#[cfg(feature = "computed")]
pub fn recompute_all_fields<C>(&mut self, provider: &mut C)
where
C: ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let computed_fields: Vec<usize> =
computed_state.computed_fields().collect();
self.recompute_fields(provider, &computed_fields);
}
}
#[cfg(feature = "computed")]
pub fn on_field_changed<C>(
&mut self,
provider: &mut C,
changed_field: usize,
) where
C: ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let fields_to_update =
computed_state.fields_to_recompute(changed_field);
if !fields_to_update.is_empty() {
self.recompute_fields(provider, &fields_to_update);
}
}
}
#[cfg(feature = "computed")]
pub fn effective_field_value(&self, field_index: usize) -> String {
if let Some(computed_state) = &self.ui_state.computed {
if let Some(computed_value) =
computed_state.get_computed_value(field_index)
{
return computed_value.clone();
}
}
self.data_provider.field_value(field_index).to_string()
}
}

122
canvas/src/editor/core.rs Normal file
View File

@@ -0,0 +1,122 @@
// src/editor/core.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::EditorState;
use crate::DataProvider;
#[cfg(feature = "suggestions")]
use crate::SuggestionItem;
pub struct FormEditor<D: DataProvider> {
pub(crate) ui_state: EditorState,
pub(crate) data_provider: D,
#[cfg(feature = "suggestions")]
pub(crate) suggestions: Vec<SuggestionItem>,
#[cfg(feature = "validation")]
pub(crate) external_validation_callback: Option<
Box<
dyn FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync,
>,
>,
}
impl<D: DataProvider> FormEditor<D> {
// Make helpers visible to sibling modules in this crate
pub(crate) fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or_else(|| s.len())
}
#[allow(dead_code)]
pub(crate) fn byte_to_char_index(s: &str, byte_idx: usize) -> usize {
s[..byte_idx].chars().count()
}
pub fn new(data_provider: D) -> Self {
let editor = Self {
ui_state: EditorState::new(),
data_provider,
#[cfg(feature = "suggestions")]
suggestions: Vec::new(),
#[cfg(feature = "validation")]
external_validation_callback: None,
};
#[cfg(feature = "validation")]
{
let mut editor = editor;
editor.initialize_validation();
editor
}
#[cfg(not(feature = "validation"))]
{
editor
}
}
// Library-internal, used by multiple modules
pub(crate) fn current_text(&self) -> &str {
let field_index = self.ui_state.current_field;
if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
}
}
// Read-only getters
pub fn current_field(&self) -> usize {
self.ui_state.current_field()
}
pub fn cursor_position(&self) -> usize {
self.ui_state.cursor_position()
}
pub fn mode(&self) -> AppMode {
self.ui_state.mode()
}
#[cfg(feature = "suggestions")]
pub fn is_suggestions_active(&self) -> bool {
self.ui_state.is_suggestions_active()
}
pub fn ui_state(&self) -> &EditorState {
&self.ui_state
}
pub fn data_provider(&self) -> &D {
&self.data_provider
}
pub fn data_provider_mut(&mut self) -> &mut D {
&mut self.data_provider
}
#[cfg(feature = "suggestions")]
pub fn suggestions(&self) -> &[SuggestionItem] {
&self.suggestions
}
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
self.ui_state.validation_state()
}
// Cursor cleanup
#[cfg(feature = "cursor-style")]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
CursorManager::reset()
}
#[cfg(not(feature = "cursor-style"))]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
Ok(())
}
}
impl<D: DataProvider> Drop for FormEditor<D> {
fn drop(&mut self) {
let _ = self.cleanup_cursor();
}
}

View File

@@ -0,0 +1,123 @@
// src/editor/display.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Get current field text for display.
/// Policies documented in original file.
#[cfg(feature = "validation")]
pub fn current_display_text(&self) -> String {
let field_index = self.ui_state.current_field;
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if matches!(self.ui_state.current_mode, AppMode::Edit) {
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Get effective display text for any field index (Feature 4 + masks).
#[cfg(feature = "validation")]
pub fn display_text_for_field(&self, field_index: usize) -> String {
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if field_index == self.ui_state.current_field
&& matches!(self.ui_state.current_mode, AppMode::Edit)
{
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Map raw cursor to display position (formatter/mask aware).
pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text();
let char_count = current_text.chars().count();
let raw_pos = match self.ui_state.current_mode {
AppMode::Edit => self.ui_state.cursor_pos.min(char_count),
_ => {
if char_count == 0 {
0
} else {
self.ui_state
.cursor_pos
.min(char_count.saturating_sub(1))
}
}
};
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
if let Some((formatted, mapper, _)) =
cfg.run_custom_formatter(current_text)
{
return mapper.raw_to_formatted(
current_text,
&formatted,
raw_pos,
);
}
}
if let Some(mask) = &cfg.display_mask {
return mask.raw_pos_to_display_pos(raw_pos);
}
}
}
raw_pos
}
}

View File

@@ -0,0 +1,348 @@
// src/editor/editing.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Open new line below (vim o)
pub fn open_line_below(&mut self) -> anyhow::Result<()> {
// paste the method body unchanged from editor.rs
// (exact code from your VIM COMMANDS: o and O section)
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let next_field = (self.ui_state.current_field + 1)
.min(field_count.saturating_sub(1));
self.transition_to_field(next_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Open new line above (vim O)
pub fn open_line_above(&mut self) -> anyhow::Result<()> {
let prev_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(prev_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Handle character insertion (mask/limit-aware)
pub fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
// paste entire insert_char body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
#[cfg(feature = "validation")]
let field_index = self.ui_state.current_field;
#[cfg(feature = "validation")]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(not(feature = "validation"))]
let field_index = self.ui_state.current_field;
#[cfg(not(feature = "validation"))]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_cursor_pos =
mask.raw_pos_to_display_pos(raw_cursor_pos);
let pattern_char_len = mask.pattern().chars().count();
if display_cursor_pos >= pattern_char_len {
return Ok(());
}
if !mask.is_input_position(display_cursor_pos) {
return Ok(());
}
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if current_raw_text.chars().count() >= input_slots {
return Ok(());
}
}
}
}
#[cfg(feature = "validation")]
{
let vr = self.ui_state.validation.validate_char_insertion(
field_index,
current_raw_text,
raw_cursor_pos,
ch,
);
if !vr.is_acceptable() {
return Ok(());
}
}
let new_raw_text = {
let mut temp = current_raw_text.to_string();
let byte_pos = Self::char_to_byte_index(
current_raw_text,
raw_cursor_pos,
);
temp.insert(byte_pos, ch);
temp
};
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(limits) = &cfg.character_limits {
if let Some(result) = limits.validate_content(&new_raw_text)
{
if !result.is_acceptable() {
return Ok(());
}
}
}
if let Some(mask) = &cfg.display_mask {
let pattern_char_len = mask.pattern().chars().count();
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if new_raw_text.chars().count() > input_slots {
return Ok(());
}
}
}
}
self.data_provider
.set_field_value(field_index, new_raw_text.clone());
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let new_raw_pos = raw_cursor_pos + 1;
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
let next_input_display =
mask.next_input_position(display_pos);
let next_raw_pos =
mask.display_pos_to_raw_pos(next_input_display);
let max_raw = new_raw_text.chars().count();
self.ui_state.cursor_pos = next_raw_pos.min(max_raw);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
return Ok(());
}
}
}
self.ui_state.cursor_pos = raw_cursor_pos + 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
Ok(())
}
/// Delete backward (backspace)
pub fn delete_backward(&mut self) -> anyhow::Result<()> {
// paste entire delete_backward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
if self.ui_state.cursor_pos == 0 {
return Ok(());
}
let field_index = self.ui_state.current_field;
let mut current_text =
self.data_provider.field_value(field_index).to_string();
let new_cursor = self.ui_state.cursor_pos.saturating_sub(1);
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos - 1,
);
let end =
Self::char_to_byte_index(&current_text, self.ui_state.cursor_pos);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = new_cursor;
#[cfg(not(feature = "validation"))]
let target_cursor = new_cursor;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(new_cursor);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
target_cursor =
mask.display_pos_to_raw_pos(prev_input);
}
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
Ok(())
}
/// Delete forward (Delete key)
pub fn delete_forward(&mut self) -> anyhow::Result<()> {
// paste entire delete_forward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
let field_index = self.ui_state.current_field;
let mut current_text =
self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos < current_text.chars().count() {
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos,
);
let end = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos + 1,
);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let target_cursor = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(
self.ui_state.cursor_pos,
);
let next_input =
mask.next_input_position(display_pos);
target_cursor = mask
.display_pos_to_raw_pos(next_input)
.min(current_text.chars().count());
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
/// Enter edit mode with cursor positioned for append (vim 'a')
pub fn enter_append_mode(&mut self) {
// paste body unchanged
let current_text = self.current_text();
let char_len = current_text.chars().count();
let append_pos = if current_text.is_empty() {
0
} else {
(self.ui_state.cursor_pos + 1).min(char_len)
};
self.ui_state.cursor_pos = append_pos;
self.ui_state.ideal_cursor_column = append_pos;
self.set_mode(crate::canvas::modes::AppMode::Edit);
}
/// Set current field value (validates under feature flag)
pub fn set_current_field_value(&mut self, value: String) {
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(field_index, value.clone());
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
/// Set specific field value by index (validates under feature flag)
pub fn set_field_value(&mut self, field_index: usize, value: String) {
if field_index < self.data_provider.field_count() {
self.data_provider
.set_field_value(field_index, value.clone());
if field_index == self.ui_state.current_field {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
}
/// Clear the current field
pub fn clear_current_field(&mut self) {
self.set_current_field_value(String::new());
}
}

21
canvas/src/editor/mod.rs Normal file
View File

@@ -0,0 +1,21 @@
// src/editor/mod.rs
// Only module declarations and re-exports.
pub mod core;
pub mod display;
pub mod editing;
pub mod movement;
pub mod navigation;
pub mod mode;
#[cfg(feature = "suggestions")]
pub mod suggestions;
#[cfg(feature = "validation")]
pub mod validation_helpers;
#[cfg(feature = "computed")]
pub mod computed_helpers;
// Re-export the main type
pub use core::FormEditor;

222
canvas/src/editor/mode.rs Normal file
View File

@@ -0,0 +1,222 @@
// src/editor/mode.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::SelectionState;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Change mode (for vim compatibility)
pub fn set_mode(&mut self, mode: AppMode) {
match (self.ui_state.current_mode, mode) {
(AppMode::ReadOnly, AppMode::Highlight) => {
self.enter_highlight_mode();
}
(AppMode::Highlight, AppMode::ReadOnly) => {
self.exit_highlight_mode();
}
(_, new_mode) => {
self.ui_state.current_mode = new_mode;
if new_mode != AppMode::Highlight {
self.ui_state.selection = SelectionState::None;
}
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
}
}
/// Exit edit mode to read-only mode (vim Escape)
pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self.ui_state.validation.allows_field_switch(
self.ui_state.current_field,
current_text,
) {
if let Some(reason) = self.ui_state.validation
.field_switch_block_reason(
self.ui_state.current_field,
current_text,
)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
return Err(anyhow::anyhow!(
"Cannot exit edit mode: {}",
reason
));
}
}
}
let current_text = self.current_text();
if !current_text.is_empty() {
let max_normal_pos =
current_text.chars().count().saturating_sub(1);
if self.ui_state.cursor_pos > max_normal_pos {
self.ui_state.cursor_pos = max_normal_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if cfg.external_validation_enabled {
let text = self.current_text().to_string();
if !text.is_empty() {
self.set_external_validation(
field_index,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(field_index, &text);
self.set_external_validation(field_index, final_state);
}
}
}
}
}
self.set_mode(AppMode::ReadOnly);
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
/// Enter edit mode from read-only mode (vim i/a/o)
pub fn enter_edit_mode(&mut self) {
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(self.ui_state.current_field)
{
return;
}
}
}
self.set_mode(AppMode::Edit);
}
// -------------------- Highlight/Visual mode -------------------------
pub fn enter_highlight_mode(&mut self) {
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
self.ui_state.selection = SelectionState::Characterwise {
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos),
};
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
}
}
pub fn enter_highlight_line_mode(&mut self) {
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
self.ui_state.selection =
SelectionState::Linewise { anchor_field: self.ui_state.current_field };
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
}
}
pub fn exit_highlight_mode(&mut self) {
if self.ui_state.current_mode == AppMode::Highlight {
self.ui_state.current_mode = AppMode::ReadOnly;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::ReadOnly);
}
}
}
pub fn is_highlight_mode(&self) -> bool {
self.ui_state.current_mode == AppMode::Highlight
}
pub fn selection_state(&self) -> &SelectionState {
&self.ui_state.selection
}
// Visual-mode movements reuse existing movement methods
pub fn move_left_with_selection(&mut self) {
let _ = self.move_left();
}
pub fn move_right_with_selection(&mut self) {
let _ = self.move_right();
}
pub fn move_up_with_selection(&mut self) {
let _ = self.move_up();
}
pub fn move_down_with_selection(&mut self) {
let _ = self.move_down();
}
pub fn move_word_next_with_selection(&mut self) {
self.move_word_next();
}
pub fn move_word_end_with_selection(&mut self) {
self.move_word_end();
}
pub fn move_word_prev_with_selection(&mut self) {
self.move_word_prev();
}
pub fn move_word_end_prev_with_selection(&mut self) {
self.move_word_end_prev();
}
pub fn move_WORD_next_with_selection(&mut self) {
self.move_WORD_next();
}
pub fn move_WORD_end_with_selection(&mut self) {
self.move_WORD_end();
}
pub fn move_WORD_prev_with_selection(&mut self) {
self.move_WORD_prev();
}
pub fn move_WORD_end_prev_with_selection(&mut self) {
self.move_WORD_end_prev();
}
pub fn move_line_start_with_selection(&mut self) {
self.move_line_start();
}
pub fn move_line_end_with_selection(&mut self) {
self.move_line_end();
}
}

View File

@@ -0,0 +1,715 @@
// src/editor/movement.rs
use crate::canvas::actions::movement::line::{
line_end_position, line_start_position,
};
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
use crate::canvas::actions::movement::word::{
find_last_WORD_end_in_field, find_last_WORD_start_in_field,
find_last_word_end_in_field, find_last_word_start_in_field,
find_next_WORD_start, find_next_word_start, find_prev_WORD_end,
find_prev_WORD_start, find_prev_word_end, find_prev_word_start,
find_WORD_end, find_word_end,
};
impl<D: DataProvider> FormEditor<D> {
/// Move cursor left within current field (mask-aware)
pub fn move_left(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
let raw_pos =
mask.display_pos_to_raw_pos(prev_input);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = raw_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
} else {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
moved = true;
}
}
}
}
if !moved {
if self.ui_state.cursor_pos > 0 {
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
Ok(())
}
/// Move cursor right within current field (mask-aware)
pub fn move_right(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
let next_display_pos = mask.next_input_position(display_pos);
let next_pos =
mask.display_pos_to_raw_pos(next_display_pos);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = next_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
}
}
}
if !moved {
let max_pos = self.current_text().chars().count();
if self.ui_state.cursor_pos < max_pos {
self.ui_state.cursor_pos += 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
Ok(())
}
/// Move to start of current field (vim 0)
pub fn move_line_start(&mut self) {
let new_pos = line_start_position();
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Move to end of current field (vim $)
pub fn move_line_end(&mut self) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_pos = line_end_position(current_text, is_edit_mode);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Set cursor to exact position (for f/F/t/T etc.)
pub fn set_cursor_position(&mut self, position: usize) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let max_pos = if is_edit_mode {
char_len
} else {
char_len.saturating_sub(1)
};
let clamped_pos = position.min(max_pos);
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
}
impl<D: DataProvider> FormEditor<D> {
/// Move to start of next word (vim w) - can cross field boundaries
pub fn move_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first word
let new_text = self.current_text();
if !new_text.is_empty() {
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_word_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first word in new field
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous word (vim b) - can cross field boundaries
pub fn move_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field and find last word
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
// Try to find previous word in current field
let new_pos = find_prev_word_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal word movement within current field - we found a previous word
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first word), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
}
}
/// Move to end of current/next word (vim e) - can cross field boundaries
pub fn move_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Recursively call move_word_end in the new field
self.move_word_end();
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_word_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find word end from there
let next_pos = find_word_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Position at start and find first word end
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.move_word_end();
}
} else {
// Normal word end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous word (vim ge) - can cross field boundaries
pub fn move_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{find_prev_word_end, find_last_word_end_in_field};
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field (but don't recurse)
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find end of last word in the field
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field (but don't recurse)
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
// CHANGE THIS LINE: replace find_prev_word_end_corrected with find_prev_word_end
let new_pos = find_prev_word_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
// We didn't move within the current field, try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of next WORD (vim W) - can cross field boundaries
pub fn move_WORD_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_WORD_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first WORD
let new_text = self.current_text();
if !new_text.is_empty() {
let first_WORD_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first WORD
find_next_WORD_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_WORD_pos.min(char_len)
} else {
first_WORD_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_WORD_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first WORD in new field
let first_WORD_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first WORD
find_next_WORD_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_WORD_pos.min(char_len)
} else {
first_WORD_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal WORD movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous WORD (vim B) - can cross field boundaries
pub fn move_WORD_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_WORD_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field and find last WORD
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_WORD_start = find_last_WORD_start_in_field(new_text);
self.ui_state.cursor_pos = last_WORD_start;
self.ui_state.ideal_cursor_column = last_WORD_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_WORD_start = find_last_WORD_start_in_field(new_text);
self.ui_state.cursor_pos = last_WORD_start;
self.ui_state.ideal_cursor_column = last_WORD_start;
}
}
}
return;
}
// Try to find previous WORD in current field
let new_pos = find_prev_WORD_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal WORD movement within current field - we found a previous WORD
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first WORD), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_WORD_start = find_last_WORD_start_in_field(new_text);
self.ui_state.cursor_pos = last_WORD_start;
self.ui_state.ideal_cursor_column = last_WORD_start;
}
}
}
}
}
/// Move to end of current/next WORD (vim E) - can cross field boundaries
pub fn move_WORD_end(&mut self) {
use crate::canvas::actions::movement::word::find_WORD_end;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field (but don't recurse)
if self.move_down().is_ok() {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find first WORD end in new field
let first_WORD_end = find_WORD_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_WORD_end.min(char_len)
} else {
first_WORD_end.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_WORD_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find WORD end from there
let next_pos = find_WORD_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field (but don't recurse)
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Find first WORD end in new field
let new_text = self.current_text();
if !new_text.is_empty() {
let first_WORD_end = find_WORD_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_WORD_end.min(new_char_len)
} else {
first_WORD_end.min(new_char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
} else {
// Normal WORD end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous WORD (vim gE) - can cross field boundaries
pub fn move_WORD_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{find_prev_WORD_end, find_WORD_end};
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field (but don't recurse)
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find end of last WORD in the field
let last_WORD_end = find_last_WORD_end_in_field(new_text);
self.ui_state.cursor_pos = last_WORD_end;
self.ui_state.ideal_cursor_column = last_WORD_end;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field (but don't recurse)
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_WORD_end = find_last_WORD_end_in_field(new_text);
self.ui_state.cursor_pos = last_WORD_end;
self.ui_state.ideal_cursor_column = last_WORD_end;
}
}
}
return;
}
let new_pos = find_prev_WORD_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
// We didn't move within the current field, try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_WORD_end = find_last_WORD_end_in_field(new_text);
self.ui_state.cursor_pos = last_WORD_end;
self.ui_state.ideal_cursor_column = last_WORD_end;
}
}
}
} else {
// Normal WORD movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
}

View File

@@ -0,0 +1,177 @@
// src/editor/navigation.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Centralized field transition logic (unchanged).
pub fn transition_to_field(&mut self, new_field: usize) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let prev_field = self.ui_state.current_field;
#[cfg(feature = "computed")]
let mut target_field = new_field.min(field_count - 1);
#[cfg(not(feature = "computed"))]
let target_field = new_field.min(field_count - 1);
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(target_field) {
if target_field >= prev_field {
for i in (target_field + 1)..field_count {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
}
} else {
let mut i = target_field;
loop {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
if i == 0 {
break;
}
i -= 1;
}
}
}
}
}
if target_field == prev_field {
return Ok(());
}
#[cfg(feature = "validation")]
self.ui_state.validation.clear_last_switch_block();
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self
.ui_state
.validation
.allows_field_switch(prev_field, current_text)
{
if let Some(reason) = self
.ui_state
.validation
.field_switch_block_reason(prev_field, current_text)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!(
"Cannot switch fields: {}",
reason
));
}
}
}
#[cfg(feature = "validation")]
{
let text =
self.data_provider.field_value(prev_field).to_string();
let _ = self
.ui_state
.validation
.validate_field_content(prev_field, &text);
if let Some(cfg) =
self.ui_state.validation.get_field_config(prev_field)
{
if cfg.external_validation_enabled && !text.is_empty() {
self.set_external_validation(
prev_field,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(prev_field, &text);
self.set_external_validation(prev_field, final_state);
}
}
}
}
#[cfg(feature = "computed")]
{
// Placeholder for recompute hook if needed later
}
self.ui_state.move_to_field(target_field, field_count);
let current_text = self.current_text();
let max_pos = current_text.chars().count();
self.ui_state.set_cursor(
self.ui_state.ideal_cursor_column,
max_pos,
self.ui_state.current_mode == AppMode::Edit,
);
// Automatically close suggestions on field switch
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
/// Move to first line (vim gg)
pub fn move_first_line(&mut self) -> anyhow::Result<()> {
self.transition_to_field(0)
}
/// Move to last line (vim G)
pub fn move_last_line(&mut self) -> anyhow::Result<()> {
let last_field =
self.data_provider.field_count().saturating_sub(1);
self.transition_to_field(last_field)
}
/// Move to previous field (vim k / up)
pub fn move_up(&mut self) -> anyhow::Result<()> {
let new_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(new_field)
}
/// Move to next field (vim j / down)
pub fn move_down(&mut self) -> anyhow::Result<()> {
let new_field = (self.ui_state.current_field + 1)
.min(self.data_provider.field_count().saturating_sub(1));
self.transition_to_field(new_field)
}
/// Move to next field cyclic
pub fn move_to_next_field(&mut self) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let new_field = (self.ui_state.current_field + 1) % field_count;
self.transition_to_field(new_field)
}
/// Aliases
pub fn prev_field(&mut self) -> anyhow::Result<()> {
self.move_up()
}
pub fn next_field(&mut self) -> anyhow::Result<()> {
self.move_down()
}
}

View File

@@ -0,0 +1,166 @@
// src/editor/suggestions.rs
use crate::editor::FormEditor;
use crate::{DataProvider, SuggestionItem};
impl<D: DataProvider> FormEditor<D> {
/// Compute inline completion for current selection and text
fn compute_current_completion(&self) -> Option<String> {
let typed = self.current_text();
let idx = self.ui_state.suggestions.selected_index?;
let sugg = self.suggestions.get(idx)?;
if let Some(rest) = sugg.value_to_store.strip_prefix(typed) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
None
}
/// Update UI state's completion text from current selection
pub fn update_inline_completion(&mut self) {
self.ui_state.suggestions.completion_text =
self.compute_current_completion();
}
/// Open the suggestions UI for `field_index`
pub fn open_suggestions(&mut self, field_index: usize) {
self.ui_state.open_suggestions(field_index);
}
/// Close suggestions UI and clear current suggestion results
pub fn close_suggestions(&mut self) {
self.ui_state.close_suggestions();
self.suggestions.clear();
}
/// Handle Escape key in ReadOnly mode (closes suggestions if active)
pub fn handle_escape_readonly(&mut self) {
if self.ui_state.suggestions.is_active {
self.close_suggestions();
}
}
// ----------------- Non-blocking suggestions API --------------------
#[cfg(feature = "suggestions")]
pub fn start_suggestions(&mut self, field_index: usize) -> Option<String> {
if !self.data_provider.supports_suggestions(field_index) {
return None;
}
let query = self.current_text().to_string();
self.ui_state.open_suggestions(field_index);
self.ui_state.suggestions.is_loading = true;
self.ui_state.suggestions.active_query = Some(query.clone());
self.suggestions.clear();
Some(query)
}
#[cfg(not(feature = "suggestions"))]
pub fn start_suggestions(&mut self, _field_index: usize) -> Option<String> {
None
}
#[cfg(feature = "suggestions")]
pub fn apply_suggestions_result(
&mut self,
field_index: usize,
query: &str,
results: Vec<SuggestionItem>,
) -> bool {
if self.ui_state.suggestions.active_field != Some(field_index) {
return false;
}
if self.ui_state.suggestions.active_query.as_deref() != Some(query) {
return false;
}
self.ui_state.suggestions.is_loading = false;
self.suggestions = results;
if !self.suggestions.is_empty() {
self.ui_state.suggestions.selected_index = Some(0);
self.update_inline_completion();
} else {
self.ui_state.suggestions.selected_index = None;
self.ui_state.suggestions.completion_text = None;
}
true
}
#[cfg(not(feature = "suggestions"))]
pub fn apply_suggestions_result(
&mut self,
_field_index: usize,
_query: &str,
_results: Vec<SuggestionItem>,
) -> bool {
false
}
#[cfg(feature = "suggestions")]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
if self.ui_state.suggestions.is_loading {
if let (Some(field), Some(query)) = (
self.ui_state.suggestions.active_field,
&self.ui_state.suggestions.active_query,
) {
return Some((field, query.clone()));
}
}
None
}
#[cfg(not(feature = "suggestions"))]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
None
}
pub fn cancel_suggestions(&mut self) {
self.close_suggestions();
}
pub fn suggestions_next(&mut self) {
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty()
{
return;
}
let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
let next = (current + 1) % self.suggestions.len();
self.ui_state.suggestions.selected_index = Some(next);
self.update_inline_completion();
}
pub fn apply_suggestion(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
if let Some(suggestion) = self.suggestions.get(selected_index).cloned()
{
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(
field_index,
suggestion.value_to_store.clone(),
);
self.ui_state.cursor_pos = suggestion.value_to_store.len();
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
self.close_suggestions();
self.suggestions.clear();
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&suggestion.value_to_store,
);
}
return Some(suggestion.display_text);
}
}
None
}
}

View File

@@ -0,0 +1,23 @@
// src/editor/suggestions_stub.rs
// Crate-private no-op methods so internal calls compile when feature is off.
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
pub(crate) fn open_suggestions(&mut self, _field_index: usize) {
// no-op
}
pub(crate) fn close_suggestions(&mut self) {
// no-op
}
pub(crate) fn handle_escape_readonly(&mut self) {
// no-op
}
pub(crate) fn cancel_suggestions(&mut self) {
// no-op
}
}

View File

@@ -0,0 +1,178 @@
// src/editor/validation_helpers.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "validation")]
pub fn set_validation_enabled(&mut self, enabled: bool) {
self.ui_state.validation.set_enabled(enabled);
}
#[cfg(feature = "validation")]
pub fn is_validation_enabled(&self) -> bool {
self.ui_state.validation.is_enabled()
}
#[cfg(feature = "validation")]
pub fn set_field_validation(
&mut self,
field_index: usize,
config: crate::validation::ValidationConfig,
) {
self.ui_state
.validation
.set_field_config(field_index, config);
}
#[cfg(feature = "validation")]
pub fn remove_field_validation(&mut self, field_index: usize) {
self.ui_state.validation.remove_field_config(field_index);
}
#[cfg(feature = "validation")]
pub fn validate_current_field(
&mut self,
) -> crate::validation::ValidationResult {
let field_index = self.ui_state.current_field;
let current_text = self.current_text().to_string();
self.ui_state
.validation
.validate_field_content(field_index, &current_text)
}
#[cfg(feature = "validation")]
pub fn validate_field(
&mut self,
field_index: usize,
) -> Option<crate::validation::ValidationResult> {
if field_index < self.data_provider.field_count() {
let text =
self.data_provider.field_value(field_index).to_string();
Some(
self.ui_state
.validation
.validate_field_content(field_index, &text),
)
} else {
None
}
}
#[cfg(feature = "validation")]
pub fn clear_validation_results(&mut self) {
self.ui_state.validation.clear_all_results();
}
#[cfg(feature = "validation")]
pub fn validation_summary(
&self,
) -> crate::validation::ValidationSummary {
self.ui_state.validation.summary()
}
#[cfg(feature = "validation")]
pub fn can_switch_fields(&self) -> bool {
let current_text = self.current_text();
self.ui_state.validation.allows_field_switch(
self.ui_state.current_field,
current_text,
)
}
#[cfg(feature = "validation")]
pub fn field_switch_block_reason(&self) -> Option<String> {
let current_text = self.current_text();
self.ui_state.validation.field_switch_block_reason(
self.ui_state.current_field,
current_text,
)
}
#[cfg(feature = "validation")]
pub fn last_switch_block(&self) -> Option<&str> {
self.ui_state.validation.last_switch_block()
}
#[cfg(feature = "validation")]
pub fn current_limits_status_text(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some(limits) = &cfg.character_limits {
return limits.status_text(self.current_text());
}
}
None
}
#[cfg(feature = "validation")]
pub fn current_formatter_warning(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some((_fmt, _mapper, warn)) =
cfg.run_custom_formatter(self.current_text())
{
return warn;
}
}
None
}
#[cfg(feature = "validation")]
pub fn external_validation_of(
&self,
field_index: usize,
) -> crate::validation::ExternalValidationState {
self.ui_state
.validation
.get_external_validation(field_index)
}
#[cfg(feature = "validation")]
pub fn clear_all_external_validation(&mut self) {
self.ui_state.validation.clear_all_external_validation();
}
#[cfg(feature = "validation")]
pub fn clear_external_validation(&mut self, field_index: usize) {
self.ui_state
.validation
.clear_external_validation(field_index);
}
#[cfg(feature = "validation")]
pub fn set_external_validation(
&mut self,
field_index: usize,
state: crate::validation::ExternalValidationState,
) {
self.ui_state
.validation
.set_external_validation(field_index, state);
}
#[cfg(feature = "validation")]
pub fn set_external_validation_callback<F>(&mut self, callback: F)
where
F: FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync
+ 'static,
{
self.external_validation_callback = Some(Box::new(callback));
}
#[cfg(feature = "validation")]
pub(crate) fn initialize_validation(&mut self) {
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if let Some(config) =
self.data_provider.validation_config(field_index)
{
self.ui_state
.validation
.set_field_config(field_index, config);
}
}
}
}