src/editor.rs doesnt exist anymore
This commit is contained in:
2192
canvas/src/editor.rs
2192
canvas/src/editor.rs
File diff suppressed because it is too large
Load Diff
111
canvas/src/editor/computed_helpers.rs
Normal file
111
canvas/src/editor/computed_helpers.rs
Normal 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
122
canvas/src/editor/core.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
123
canvas/src/editor/display.rs
Normal file
123
canvas/src/editor/display.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
348
canvas/src/editor/editing.rs
Normal file
348
canvas/src/editor/editing.rs
Normal 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(
|
||||||
|
¤t_text,
|
||||||
|
self.ui_state.cursor_pos - 1,
|
||||||
|
);
|
||||||
|
let end =
|
||||||
|
Self::char_to_byte_index(¤t_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,
|
||||||
|
¤t_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(
|
||||||
|
¤t_text,
|
||||||
|
self.ui_state.cursor_pos,
|
||||||
|
);
|
||||||
|
let end = Self::char_to_byte_index(
|
||||||
|
¤t_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,
|
||||||
|
¤t_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
21
canvas/src/editor/mod.rs
Normal 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
222
canvas/src/editor/mode.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
715
canvas/src/editor/movement.rs
Normal file
715
canvas/src/editor/movement.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
canvas/src/editor/navigation.rs
Normal file
177
canvas/src/editor/navigation.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
166
canvas/src/editor/suggestions.rs
Normal file
166
canvas/src/editor/suggestions.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
canvas/src/editor/suggestions_stub.rs
Normal file
23
canvas/src/editor/suggestions_stub.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
178
canvas/src/editor/validation_helpers.rs
Normal file
178
canvas/src/editor/validation_helpers.rs
Normal 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, ¤t_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user