use fresh_core::api::{TreeNode, WidgetSpec};
use fresh_core::text_property::TextPropertyEntry;
pub fn find_widget_by_key<'a>(spec: &'a WidgetSpec, target: &str) -> Option<&'a WidgetSpec> {
if target.is_empty() {
return None;
}
if leaf_key_matches(spec, target) {
return Some(spec);
}
spec.children().find_map(|c| find_widget_by_key(c, target))
}
fn leaf_key_matches(spec: &WidgetSpec, target: &str) -> bool {
match spec {
WidgetSpec::Toggle { key: Some(k), .. }
| WidgetSpec::Button { key: Some(k), .. }
| WidgetSpec::Text { key: Some(k), .. }
| WidgetSpec::List { key: Some(k), .. }
| WidgetSpec::Tree { key: Some(k), .. } => k == target,
_ => false,
}
}
pub fn apply_text_key(value: &str, cursor: usize, key: &str, multiline: bool) -> (String, usize) {
if multiline {
apply_text_area_key(value, cursor, key)
} else {
apply_text_input_key(value, cursor, key)
}
}
pub fn apply_text_char(value: &str, cursor: usize, text: &str) -> (String, usize) {
let mut cursor = cursor.min(value.len());
while cursor > 0 && !value.is_char_boundary(cursor) {
cursor -= 1;
}
let mut new_value = String::with_capacity(value.len() + text.len());
new_value.push_str(&value[..cursor]);
new_value.push_str(text);
new_value.push_str(&value[cursor..]);
(new_value, cursor + text.len())
}
pub fn apply_text_input_key(value: &str, cursor: usize, key: &str) -> (String, usize) {
let cursor = cursor.min(value.len());
match key {
"Backspace" => {
if cursor == 0 {
return (value.to_string(), 0);
}
let mut prev = cursor - 1;
while prev > 0 && !value.is_char_boundary(prev) {
prev -= 1;
}
let mut new_value = String::with_capacity(value.len() - (cursor - prev));
new_value.push_str(&value[..prev]);
new_value.push_str(&value[cursor..]);
(new_value, prev)
}
"Delete" => {
if cursor >= value.len() {
return (value.to_string(), cursor);
}
let mut next = cursor + 1;
while next < value.len() && !value.is_char_boundary(next) {
next += 1;
}
let mut new_value = String::with_capacity(value.len() - (next - cursor));
new_value.push_str(&value[..cursor]);
new_value.push_str(&value[next..]);
(new_value, cursor)
}
"Left" => {
if cursor == 0 {
return (value.to_string(), 0);
}
let mut prev = cursor - 1;
while prev > 0 && !value.is_char_boundary(prev) {
prev -= 1;
}
(value.to_string(), prev)
}
"Right" => {
if cursor >= value.len() {
return (value.to_string(), value.len());
}
let mut next = cursor + 1;
while next < value.len() && !value.is_char_boundary(next) {
next += 1;
}
(value.to_string(), next)
}
"Home" => (value.to_string(), 0),
"End" => (value.to_string(), value.len()),
_ => (value.to_string(), cursor),
}
}
pub fn apply_text_area_key(value: &str, cursor: usize, key: &str) -> (String, usize) {
let cursor = cursor.min(value.len());
match key {
"Backspace" | "Delete" | "Left" | "Right" => apply_text_input_key(value, cursor, key),
"Home" => (value.to_string(), line_start(value, cursor)),
"End" => (value.to_string(), line_end(value, cursor)),
"Up" => {
let (line_start, col) = line_start_and_col(value, cursor);
if line_start == 0 {
return (value.to_string(), 0);
}
let prev_end = line_start - 1;
let prev_start = line_start_at(value, prev_end);
let prev_len = prev_end - prev_start;
let new_col = col.min(prev_len);
let new_cursor = clamp_to_char_boundary(value, prev_start + new_col);
(value.to_string(), new_cursor)
}
"Down" => {
let (line_start, col) = line_start_and_col(value, cursor);
let cur_line_end = line_end_at(value, line_start);
if cur_line_end >= value.len() {
return (value.to_string(), value.len());
}
let next_start = cur_line_end + 1;
let next_end = line_end_at(value, next_start);
let next_len = next_end - next_start;
let new_col = col.min(next_len);
let new_cursor = clamp_to_char_boundary(value, next_start + new_col);
(value.to_string(), new_cursor)
}
"Enter" => {
let mut new_value = String::with_capacity(value.len() + 1);
new_value.push_str(&value[..cursor]);
new_value.push('\n');
new_value.push_str(&value[cursor..]);
(new_value, cursor + 1)
}
_ => (value.to_string(), cursor),
}
}
fn line_start(value: &str, cursor: usize) -> usize {
line_start_at(value, cursor)
}
fn line_end(value: &str, cursor: usize) -> usize {
line_end_at(value, cursor)
}
fn line_start_at(value: &str, byte: usize) -> usize {
let bytes = value.as_bytes();
let mut i = byte;
while i > 0 && bytes[i - 1] != b'\n' {
i -= 1;
}
i
}
fn line_end_at(value: &str, byte: usize) -> usize {
let bytes = value.as_bytes();
let mut i = byte;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
i
}
fn line_start_and_col(value: &str, cursor: usize) -> (usize, usize) {
let s = line_start_at(value, cursor);
(s, cursor - s)
}
fn clamp_to_char_boundary(value: &str, byte: usize) -> usize {
let mut i = byte.min(value.len());
while i > 0 && !value.is_char_boundary(i) {
i -= 1;
}
i
}
pub fn set_toggle_checked_in_spec(
spec: &mut WidgetSpec,
widget_key: &str,
new_checked: bool,
) -> bool {
if widget_key.is_empty() {
return false;
}
if let WidgetSpec::Toggle { checked, key, .. } = spec {
if key.as_deref() == Some(widget_key) {
*checked = new_checked;
return true;
}
}
spec.children_mut()
.any(|c| set_toggle_checked_in_spec(c, widget_key, new_checked))
}
pub fn set_list_items_in_spec(
spec: &mut WidgetSpec,
widget_key: &str,
new_items: Vec<TextPropertyEntry>,
new_item_keys: Vec<String>,
) -> bool {
if widget_key.is_empty() {
return false;
}
if let WidgetSpec::List {
items,
item_keys,
key,
..
} = spec
{
if key.as_deref() == Some(widget_key) {
*items = new_items;
*item_keys = new_item_keys;
return true;
}
}
for c in spec.children_mut() {
if c.contains_key(widget_key) {
return set_list_items_in_spec(c, widget_key, new_items, new_item_keys);
}
}
false
}
pub fn set_tree_nodes_in_spec(
spec: &mut WidgetSpec,
widget_key: &str,
new_nodes: Vec<TreeNode>,
new_item_keys: Vec<String>,
) -> bool {
if widget_key.is_empty() {
return false;
}
if let WidgetSpec::Tree {
nodes,
item_keys,
key,
..
} = spec
{
if key.as_deref() == Some(widget_key) {
*nodes = new_nodes;
*item_keys = new_item_keys;
return true;
}
}
for c in spec.children_mut() {
if c.contains_key(widget_key) {
return set_tree_nodes_in_spec(c, widget_key, new_nodes, new_item_keys);
}
}
false
}
pub fn set_tree_checked_keys_in_spec(
spec: &mut WidgetSpec,
widget_key: &str,
checked: bool,
keys: &[String],
) -> bool {
if widget_key.is_empty() {
return false;
}
if let WidgetSpec::Tree {
nodes,
item_keys,
key,
..
} = spec
{
if key.as_deref() == Some(widget_key) {
let target: std::collections::HashSet<&str> = keys.iter().map(String::as_str).collect();
for (i, node) in nodes.iter_mut().enumerate() {
if node.checked.is_none() {
continue;
}
let item_key = item_keys.get(i).map(String::as_str).unwrap_or("");
if !item_key.is_empty() && target.contains(item_key) {
node.checked = Some(checked);
}
}
return true;
}
}
spec.children_mut()
.any(|c| set_tree_checked_keys_in_spec(c, widget_key, checked, keys))
}
pub fn tree_parent_index(nodes: &[TreeNode], child_idx: usize) -> Option<usize> {
let child = nodes.get(child_idx)?;
if child.depth == 0 {
return None;
}
let target_depth = child.depth - 1;
nodes[..child_idx]
.iter()
.enumerate()
.rev()
.find(|(_, n)| n.depth == target_depth)
.map(|(i, _)| i)
}
trait ContainsKey {
fn contains_key(&self, widget_key: &str) -> bool;
}
impl ContainsKey for WidgetSpec {
fn contains_key(&self, widget_key: &str) -> bool {
let direct = match self {
WidgetSpec::Toggle { key, .. }
| WidgetSpec::Button { key, .. }
| WidgetSpec::Text { key, .. }
| WidgetSpec::List { key, .. }
| WidgetSpec::Tree { key, .. } => key.as_deref() == Some(widget_key),
_ => false,
};
direct || self.children().any(|c| c.contains_key(widget_key))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn toggle_with_key(k: &str) -> WidgetSpec {
WidgetSpec::Toggle {
checked: false,
label: "T".into(),
focused: false,
key: Some(k.into()),
}
}
#[test]
fn find_widget_by_key_finds_top_level_match() {
let spec = toggle_with_key("a");
assert!(find_widget_by_key(&spec, "a").is_some());
assert!(find_widget_by_key(&spec, "b").is_none());
}
#[test]
fn find_widget_by_key_recurses_into_row() {
let spec = WidgetSpec::Row {
children: vec![toggle_with_key("a"), toggle_with_key("b")],
key: None,
};
assert!(find_widget_by_key(&spec, "b").is_some());
}
#[test]
fn find_widget_by_key_returns_none_for_empty_target() {
let spec = toggle_with_key("a");
assert!(find_widget_by_key(&spec, "").is_none());
}
#[test]
fn backspace_at_start_is_noop() {
assert_eq!(
apply_text_input_key("hello", 0, "Backspace"),
("hello".into(), 0)
);
}
#[test]
fn backspace_in_middle_removes_previous_char() {
assert_eq!(
apply_text_input_key("hello", 3, "Backspace"),
("helo".into(), 2)
);
}
#[test]
fn backspace_at_end_removes_last_char() {
assert_eq!(
apply_text_input_key("hello", 5, "Backspace"),
("hell".into(), 4)
);
}
#[test]
fn delete_at_end_is_noop() {
assert_eq!(
apply_text_input_key("hello", 5, "Delete"),
("hello".into(), 5)
);
}
#[test]
fn delete_in_middle_removes_next_char() {
assert_eq!(
apply_text_input_key("hello", 2, "Delete"),
("helo".into(), 2)
);
}
#[test]
fn left_decrements_cursor() {
assert_eq!(apply_text_input_key("abc", 2, "Left"), ("abc".into(), 1));
}
#[test]
fn right_increments_cursor_until_end() {
assert_eq!(apply_text_input_key("abc", 1, "Right"), ("abc".into(), 2));
assert_eq!(apply_text_input_key("abc", 3, "Right"), ("abc".into(), 3));
}
#[test]
fn home_jumps_to_zero() {
assert_eq!(apply_text_input_key("abc", 2, "Home"), ("abc".into(), 0));
}
#[test]
fn end_jumps_to_value_len() {
assert_eq!(apply_text_input_key("abc", 1, "End"), ("abc".into(), 3));
}
#[test]
fn unknown_key_is_noop() {
assert_eq!(apply_text_input_key("abc", 1, "Wat"), ("abc".into(), 1));
}
#[test]
fn apply_text_char_inserts_ascii_at_cursor() {
assert_eq!(apply_text_char("Hello", 5, "!"), ("Hello!".into(), 6));
assert_eq!(apply_text_char("Hxllo", 1, "e"), ("Hexllo".into(), 2));
}
#[test]
fn apply_text_char_inserts_multibyte_codepoint() {
let (v, c) = apply_text_char("", 0, "你");
assert_eq!(v, "你");
assert_eq!(c, 3);
}
#[test]
fn apply_text_char_ime_commit_step_by_step() {
let (v, c) = apply_text_char("", 0, "你");
let (v, c) = apply_text_char(&v, c, "好");
assert_eq!(v, "你好");
assert_eq!(c, 6);
}
#[test]
fn apply_text_char_ime_commit_multi_codepoint_in_one_event() {
let (v, c) = apply_text_char("", 0, "你好");
assert_eq!(v, "你好");
assert_eq!(c, 6);
}
#[test]
fn apply_text_char_inserts_into_middle_of_existing_text() {
let (v, c) = apply_text_char("Hello", 2, "你");
assert_eq!(v, "He你llo");
assert_eq!(c, 5);
}
#[test]
fn apply_text_char_snaps_mid_multibyte_cursor_down_to_boundary() {
let (v, c) = apply_text_char("你", 1, "X");
assert_eq!(v, "X你");
assert_eq!(c, 1);
}
#[test]
fn apply_text_char_clamps_cursor_past_end() {
let (v, c) = apply_text_char("ab", 99, "c");
assert_eq!(v, "abc");
assert_eq!(c, 3);
}
#[test]
fn backspace_handles_multibyte_chars() {
let s = "héllo";
let (new_value, new_cursor) = apply_text_input_key(s, 3, "Backspace");
assert_eq!(new_value, "hllo");
assert_eq!(new_cursor, 1);
}
#[test]
fn left_handles_multibyte_chars() {
let s = "héllo";
let (_, cursor) = apply_text_input_key(s, 3, "Left");
assert_eq!(cursor, 1);
}
#[test]
fn right_handles_multibyte_chars() {
let s = "héllo";
let (_, cursor) = apply_text_input_key(s, 1, "Right");
assert_eq!(cursor, 3);
}
fn node(text: &str, depth: u32, has_children: bool) -> TreeNode {
TreeNode {
text: TextPropertyEntry::text(text),
depth,
has_children,
checked: None,
}
}
#[test]
fn tree_parent_index_top_level_returns_none() {
let nodes = vec![node("root", 0, true)];
assert!(tree_parent_index(&nodes, 0).is_none());
}
#[test]
fn tree_parent_index_finds_immediate_parent() {
let nodes = vec![
node("root", 0, true),
node("child", 1, false),
node("child2", 1, false),
];
assert_eq!(tree_parent_index(&nodes, 1), Some(0));
assert_eq!(tree_parent_index(&nodes, 2), Some(0));
}
#[test]
fn tree_parent_index_skips_intermediate_siblings() {
let nodes = vec![
node("root", 0, true),
node("child", 1, true),
node("grand", 2, false),
];
assert_eq!(tree_parent_index(&nodes, 2), Some(1));
}
#[test]
fn tree_parent_index_finds_parent_across_unrelated_subtree() {
let nodes = vec![
node("a", 0, true),
node("a.0", 1, false),
node("b", 0, true),
node("b.0", 1, false),
];
assert_eq!(tree_parent_index(&nodes, 3), Some(2));
}
#[test]
fn set_tree_nodes_in_spec_replaces_nodes() {
let mut spec = WidgetSpec::Tree {
nodes: vec![node("old", 0, false)],
item_keys: vec!["k0".into()],
selected_index: -1,
visible_rows: 5,
expanded_keys: vec![],
checkable: false,
key: Some("t".into()),
};
let new_nodes = vec![node("new1", 0, false), node("new2", 0, false)];
let new_keys = vec!["a".to_string(), "b".to_string()];
let ok = set_tree_nodes_in_spec(&mut spec, "t", new_nodes.clone(), new_keys.clone());
assert!(ok);
match &spec {
WidgetSpec::Tree {
nodes, item_keys, ..
} => {
assert_eq!(nodes.len(), 2);
assert_eq!(item_keys, &new_keys);
}
_ => unreachable!(),
}
}
#[test]
fn text_area_enter_inserts_newline_at_cursor() {
let (v, c) = apply_text_area_key("hello", 2, "Enter");
assert_eq!(v, "he\nllo");
assert_eq!(c, 3);
}
#[test]
fn text_area_enter_at_end_appends_newline() {
let (v, c) = apply_text_area_key("ab", 2, "Enter");
assert_eq!(v, "ab\n");
assert_eq!(c, 3);
}
#[test]
fn text_area_left_right_share_text_input_semantics() {
assert_eq!(apply_text_area_key("abc", 2, "Left"), ("abc".into(), 1));
assert_eq!(apply_text_area_key("abc", 1, "Right"), ("abc".into(), 2));
}
#[test]
fn text_area_backspace_can_delete_a_newline() {
let (v, c) = apply_text_area_key("ab\ncd", 3, "Backspace");
assert_eq!(v, "abcd");
assert_eq!(c, 2);
}
#[test]
fn text_area_home_jumps_to_line_start_not_buffer_start() {
let (v, c) = apply_text_area_key("ab\ncd", 4, "Home");
assert_eq!(v, "ab\ncd");
assert_eq!(c, 3);
}
#[test]
fn text_area_end_jumps_to_line_end_not_buffer_end() {
let (v, c) = apply_text_area_key("ab\ncd", 0, "End");
assert_eq!(v, "ab\ncd");
assert_eq!(c, 2);
}
#[test]
fn text_area_up_moves_to_previous_line_preserving_column() {
let (v, c) = apply_text_area_key("abcd\nef", 7, "Up");
assert_eq!(v, "abcd\nef");
assert_eq!(c, 2);
}
#[test]
fn text_area_up_clamps_column_to_short_target_line() {
let (v, c) = apply_text_area_key("ab\nxyz", 6, "Up");
assert_eq!(c, 2);
assert_eq!(v, "ab\nxyz");
}
#[test]
fn text_area_up_at_top_line_clamps_to_buffer_start() {
let (_, c) = apply_text_area_key("abc\ndef", 2, "Up");
assert_eq!(c, 0);
}
#[test]
fn text_area_down_moves_to_next_line_preserving_column() {
let (_, c) = apply_text_area_key("abcd\nefgh", 2, "Down");
assert_eq!(c, 7);
}
#[test]
fn text_area_down_at_last_line_clamps_to_buffer_end() {
let (v, c) = apply_text_area_key("abc\ndef", 5, "Down");
assert_eq!(v, "abc\ndef");
assert_eq!(c, 7);
}
#[test]
fn text_area_unknown_key_is_noop() {
assert_eq!(apply_text_area_key("abc", 1, "Wat"), ("abc".into(), 1));
}
#[test]
fn text_area_up_clamps_to_char_boundary() {
let (_, c) = apply_text_area_key("aé\nbé", 5, "Up");
assert_eq!(c, 1);
}
#[test]
fn set_tree_checked_keys_in_spec_flips_only_named_keys() {
let mut a = node("a", 0, false);
a.checked = Some(true);
let mut b = node("b", 0, false);
b.checked = Some(true);
let mut c = node("c", 0, false);
c.checked = Some(true);
let mut spec = WidgetSpec::Tree {
nodes: vec![a, b, c],
item_keys: vec!["k_a".into(), "k_b".into(), "k_c".into()],
selected_index: -1,
visible_rows: 5,
expanded_keys: vec![],
checkable: true,
key: Some("t".into()),
};
let ok = set_tree_checked_keys_in_spec(
&mut spec,
"t",
false,
&["k_a".to_string(), "k_c".to_string()],
);
assert!(ok);
match &spec {
WidgetSpec::Tree { nodes, .. } => {
assert_eq!(nodes[0].checked, Some(false));
assert_eq!(nodes[1].checked, Some(true), "untouched");
assert_eq!(nodes[2].checked, Some(false));
}
_ => unreachable!(),
}
}
#[test]
fn set_tree_checked_keys_in_spec_skips_nodes_without_checkbox() {
let n_with = {
let mut n = node("checked", 0, false);
n.checked = Some(true);
n
};
let n_without = node("no-checkbox", 0, false); let mut spec = WidgetSpec::Tree {
nodes: vec![n_with, n_without],
item_keys: vec!["k0".into(), "k1".into()],
selected_index: -1,
visible_rows: 5,
expanded_keys: vec![],
checkable: true,
key: Some("t".into()),
};
let _ok = set_tree_checked_keys_in_spec(
&mut spec,
"t",
false,
&["k0".to_string(), "k1".to_string()],
);
match &spec {
WidgetSpec::Tree { nodes, .. } => {
assert_eq!(nodes[0].checked, Some(false));
assert_eq!(nodes[1].checked, None);
}
_ => unreachable!(),
}
}
#[test]
fn set_tree_nodes_in_spec_returns_false_for_unknown_key() {
let mut spec = WidgetSpec::Tree {
nodes: vec![node("a", 0, false)],
item_keys: vec!["k".into()],
selected_index: -1,
visible_rows: 5,
expanded_keys: vec![],
checkable: false,
key: Some("real".into()),
};
assert!(!set_tree_nodes_in_spec(&mut spec, "wrong", vec![], vec![]));
}
}