use crate::primitives::text_edit::TextEdit;
use fresh_core::api::WidgetSpec;
use fresh_core::BufferId;
use std::collections::{HashMap, HashSet};
pub type PanelId = u64;
#[derive(Debug, Clone)]
pub struct HitArea {
pub widget_key: String,
pub widget_kind: &'static str,
pub buffer_row: u32,
pub byte_start: usize,
pub byte_end: usize,
pub payload: serde_json::Value,
pub event_type: &'static str,
}
#[derive(Debug, Clone, Default)]
pub enum WidgetInstanceState {
#[default]
None,
List {
scroll_offset: u32,
selected_index: i32,
},
Text {
editor: TextEdit,
scroll: u32,
completions: Vec<fresh_core::api::CompletionItem>,
completion_selected_index: usize,
completion_scroll_offset: u32,
},
Tree {
scroll_offset: u32,
selected_index: i32,
expanded_keys: HashSet<String>,
},
}
#[derive(Debug, Clone)]
pub struct WidgetPanelState {
pub buffer_id: BufferId,
pub spec: WidgetSpec,
pub hits: Vec<HitArea>,
pub instance_states: HashMap<String, WidgetInstanceState>,
pub focus_key: String,
pub tabbable: Vec<String>,
}
#[derive(Debug, Default)]
pub struct WidgetRegistry {
panels: HashMap<PanelId, WidgetPanelState>,
}
impl WidgetRegistry {
pub fn new() -> Self {
Self::default()
}
#[allow(clippy::too_many_arguments)]
pub fn mount(
&mut self,
panel_id: PanelId,
buffer_id: BufferId,
spec: WidgetSpec,
hits: Vec<HitArea>,
instance_states: HashMap<String, WidgetInstanceState>,
focus_key: String,
tabbable: Vec<String>,
) -> Option<WidgetPanelState> {
self.panels.insert(
panel_id,
WidgetPanelState {
buffer_id,
spec,
hits,
instance_states,
focus_key,
tabbable,
},
)
}
#[allow(clippy::result_unit_err)]
#[allow(clippy::too_many_arguments)]
pub fn update(
&mut self,
panel_id: PanelId,
spec: WidgetSpec,
hits: Vec<HitArea>,
instance_states: HashMap<String, WidgetInstanceState>,
focus_key: String,
tabbable: Vec<String>,
) -> Result<BufferId, ()> {
match self.panels.get_mut(&panel_id) {
Some(state) => {
state.spec = spec;
state.hits = hits;
state.instance_states = instance_states;
state.focus_key = focus_key;
state.tabbable = tabbable;
Ok(state.buffer_id)
}
None => Err(()),
}
}
pub fn instance_states(
&self,
panel_id: PanelId,
) -> Option<&HashMap<String, WidgetInstanceState>> {
self.panels.get(&panel_id).map(|s| &s.instance_states)
}
pub fn focus_key(&self, panel_id: PanelId) -> Option<&str> {
self.panels.get(&panel_id).map(|s| s.focus_key.as_str())
}
pub fn set_focus_key(&mut self, panel_id: PanelId, key: String) {
if let Some(state) = self.panels.get_mut(&panel_id) {
state.focus_key = key;
}
}
pub fn set_list_scroll(
&mut self,
panel_id: PanelId,
list_key: &str,
scroll_offset: u32,
visible: u32,
) -> Option<i32> {
let state = self.panels.get_mut(&panel_id)?;
let WidgetInstanceState::List {
scroll_offset: so,
selected_index,
} = state.instance_states.get_mut(list_key)?
else {
return None;
};
*so = scroll_offset;
if *selected_index < 0 || visible == 0 {
return None;
}
let prev = *selected_index;
let lo = scroll_offset as i32;
let hi = (scroll_offset + visible).saturating_sub(1) as i32;
if *selected_index < lo {
*selected_index = lo;
} else if *selected_index > hi {
*selected_index = hi;
}
if *selected_index != prev {
Some(*selected_index)
} else {
None
}
}
pub fn update_side_effects(
&mut self,
panel_id: PanelId,
hits: Vec<HitArea>,
instance_states: HashMap<String, WidgetInstanceState>,
focus_key: String,
tabbable: Vec<String>,
) -> Result<BufferId, ()> {
match self.panels.get_mut(&panel_id) {
Some(state) => {
state.hits = hits;
state.instance_states = instance_states;
state.focus_key = focus_key;
state.tabbable = tabbable;
Ok(state.buffer_id)
}
None => Err(()),
}
}
pub fn buffer_and_spec_ref(&self, panel_id: PanelId) -> Option<(BufferId, &WidgetSpec)> {
self.panels.get(&panel_id).map(|s| (s.buffer_id, &s.spec))
}
pub fn buffer_and_spec(&self, panel_id: PanelId) -> Option<(BufferId, WidgetSpec)> {
self.panels
.get(&panel_id)
.map(|s| (s.buffer_id, s.spec.clone()))
}
pub fn unmount(&mut self, panel_id: PanelId) -> Option<BufferId> {
self.panels.remove(&panel_id).map(|s| s.buffer_id)
}
pub fn get(&self, panel_id: PanelId) -> Option<&WidgetPanelState> {
self.panels.get(&panel_id)
}
pub fn get_mut(&mut self, panel_id: PanelId) -> Option<&mut WidgetPanelState> {
self.panels.get_mut(&panel_id)
}
pub fn panel_ids(&self) -> Vec<PanelId> {
self.panels.keys().copied().collect()
}
pub fn panels_for_buffer(&self, buffer_id: BufferId) -> Vec<PanelId> {
self.panels
.iter()
.filter(|(_, s)| s.buffer_id == buffer_id)
.map(|(pid, _)| *pid)
.collect()
}
pub fn hit_test(
&self,
buffer_id: BufferId,
row: u32,
col_byte: u32,
) -> Option<(PanelId, HitArea)> {
for (pid, state) in &self.panels {
if state.buffer_id != buffer_id {
continue;
}
for hit in &state.hits {
if hit.buffer_row == row
&& (col_byte as usize) >= hit.byte_start
&& (col_byte as usize) < hit.byte_end
{
return Some((*pid, hit.clone()));
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn empty_spec() -> WidgetSpec {
WidgetSpec::Col {
children: vec![],
key: None,
}
}
fn make_hit(row: u32, byte_start: usize, byte_end: usize, key: &str) -> HitArea {
HitArea {
widget_key: key.into(),
widget_kind: "button",
buffer_row: row,
byte_start,
byte_end,
payload: json!({}),
event_type: "activate",
}
}
#[test]
fn hit_test_finds_widget_inside_range() {
let mut reg = WidgetRegistry::new();
reg.mount(
42,
BufferId(7),
empty_spec(),
vec![make_hit(0, 0, 5, "a"), make_hit(0, 7, 12, "b")],
HashMap::new(),
String::new(),
Vec::new(),
);
let hit = reg.hit_test(BufferId(7), 0, 8).expect("inside b");
assert_eq!(hit.0, 42);
assert_eq!(hit.1.widget_key, "b");
}
#[test]
fn hit_test_returns_none_when_outside_range() {
let mut reg = WidgetRegistry::new();
reg.mount(
1,
BufferId(0),
empty_spec(),
vec![make_hit(0, 0, 5, "a")],
HashMap::new(),
String::new(),
Vec::new(),
);
assert!(
reg.hit_test(BufferId(0), 0, 5).is_none(),
"byte_end is exclusive"
);
assert!(reg.hit_test(BufferId(0), 0, 100).is_none());
assert!(reg.hit_test(BufferId(0), 1, 0).is_none(), "wrong row");
assert!(reg.hit_test(BufferId(99), 0, 0).is_none(), "wrong buffer");
}
fn mount_with_list(reg: &mut WidgetRegistry, scroll: u32, sel: i32) {
let mut states = HashMap::new();
states.insert(
"lst".to_string(),
WidgetInstanceState::List {
scroll_offset: scroll,
selected_index: sel,
},
);
reg.mount(
7,
BufferId(0),
empty_spec(),
Vec::new(),
states,
String::new(),
Vec::new(),
);
}
fn list_state(reg: &WidgetRegistry) -> (u32, i32) {
match reg.instance_states(7).unwrap().get("lst").unwrap() {
WidgetInstanceState::List {
scroll_offset,
selected_index,
} => (*scroll_offset, *selected_index),
_ => panic!("not a list"),
}
}
#[test]
fn set_list_scroll_sets_offset_and_clamps_selection_into_view() {
let mut reg = WidgetRegistry::new();
mount_with_list(&mut reg, 0, 2);
let moved = reg.set_list_scroll(7, "lst", 10, 8);
assert_eq!(moved, Some(10));
assert_eq!(list_state(®), (10, 10));
}
#[test]
fn set_list_scroll_leaves_in_view_selection_untouched() {
let mut reg = WidgetRegistry::new();
mount_with_list(&mut reg, 0, 12);
let moved = reg.set_list_scroll(7, "lst", 10, 8); assert_eq!(moved, None);
assert_eq!(list_state(®), (10, 12));
}
#[test]
fn set_list_scroll_ignores_selectionless_list() {
let mut reg = WidgetRegistry::new();
mount_with_list(&mut reg, 0, -1);
let moved = reg.set_list_scroll(7, "lst", 5, 8);
assert_eq!(moved, None);
assert_eq!(list_state(®), (5, -1));
}
#[test]
fn unmount_clears_hits() {
let mut reg = WidgetRegistry::new();
reg.mount(
5,
BufferId(2),
empty_spec(),
vec![make_hit(0, 0, 3, "x")],
HashMap::new(),
String::new(),
Vec::new(),
);
assert!(reg.hit_test(BufferId(2), 0, 1).is_some());
reg.unmount(5);
assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
}
#[test]
fn update_replaces_hits() {
let mut reg = WidgetRegistry::new();
reg.mount(
5,
BufferId(2),
empty_spec(),
vec![make_hit(0, 0, 3, "old")],
HashMap::new(),
String::new(),
Vec::new(),
);
reg.update(
5,
empty_spec(),
vec![make_hit(1, 4, 9, "new")],
HashMap::new(),
String::new(),
Vec::new(),
)
.expect("mounted");
assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
let hit = reg.hit_test(BufferId(2), 1, 5).unwrap();
assert_eq!(hit.1.widget_key, "new");
}
}