use crate::content::{ContentLoader, ContentProvider};
use crate::fltk_draw_context::FltkDrawContext;
use crate::fltk_structured_rich_display::FltkStructuredRichDisplay;
use crate::live_share::HighlightTarget;
use crate::markdown_converter::document_to_markdown;
use crate::note_ui::NoteUI;
use fltk::{app, enums::Color, prelude::*, window};
use rutle::editor::Editor;
use rutle::renderer::SearchMatch;
use rutle::structured_document::BlockType;
use rutle::tree_path::{DocumentPosition, PathSegment, TreePath};
use rutle::tree_walk::LeafInfo;
use std::any::Any;
use std::collections::{HashMap, HashSet};
use tdoc::Document;
const ANCHOR_TOP_MARGIN: i32 = 12;
pub struct StructuredRichUI(pub FltkStructuredRichDisplay);
impl StructuredRichUI {
pub fn new(x: i32, y: i32, w: i32, h: i32, edit_mode: bool) -> Self {
Self(FltkStructuredRichDisplay::new(x, y, w, h, edit_mode))
}
pub fn has_selection(&self) -> bool {
self.0.display.borrow().editor().selection().is_some()
}
pub fn highlight_targets(&self) -> Vec<HighlightTarget> {
let disp = self.0.display.borrow();
let editor = disp.editor();
let Some((a, b)) = editor.selection() else {
return Vec::new();
};
let (start, end) = if a <= b { (a, b) } else { (b, a) };
selection_targets(editor.document(), &start, &end)
}
pub fn cut_selection(&mut self) -> bool {
let doc = self.0.display.borrow().editor().get_selection_document();
let Some(doc) = doc else {
return false;
};
crate::clipboard::copy_structured_to_system(&doc);
{
let mut disp = self.0.display.borrow_mut();
let _ = disp.editor_mut().delete_selection();
}
self.0.notify_change();
true
}
pub fn copy_selection(&self) -> bool {
let doc = self.0.display.borrow().editor().get_selection_document();
match doc {
Some(doc) => {
crate::clipboard::copy_structured_to_system(&doc);
true
}
None => false,
}
}
pub fn paste_from_clipboard(&mut self) {
let group = self.0.group.clone();
app::paste(&group);
}
pub fn undo(&mut self) -> bool {
let changed = {
let mut disp = self.0.display.borrow_mut();
disp.editor_mut().undo()
};
if changed {
self.0.notify_change();
self.0.emit_paragraph_state();
}
changed
}
pub fn redo(&mut self) -> bool {
let changed = {
let mut disp = self.0.display.borrow_mut();
disp.editor_mut().redo()
};
if changed {
self.0.notify_change();
self.0.emit_paragraph_state();
}
changed
}
pub fn clear_formatting(&mut self) -> bool {
self.apply_edit(|editor| editor.clear_formatting())
}
pub fn set_block_type(&mut self, block_type: BlockType) -> bool {
self.apply_edit(move |editor| editor.set_block_type(block_type))
}
pub fn toggle_quote(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_quote())
}
pub fn toggle_code_block(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_code_block())
}
pub fn toggle_list(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_list())
}
pub fn toggle_checklist(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_checklist())
}
pub fn toggle_ordered_list(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_ordered_list())
}
pub fn toggle_bold(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_bold())
}
pub fn toggle_italic(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_italic())
}
pub fn toggle_code(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_code())
}
pub fn toggle_strikethrough(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_strikethrough())
}
pub fn toggle_underline(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_underline())
}
pub fn toggle_highlight(&mut self) -> bool {
self.apply_edit(|editor| editor.toggle_highlight())
}
pub fn current_block_type(&self) -> Option<BlockType> {
let disp = self.0.display.borrow();
Some(disp.editor().current_block_type())
}
pub fn current_heading_anchor(&self) -> Option<String> {
let disp = self.0.display.borrow();
let cursor = disp.editor().cursor();
let para = match cursor.path.segments().first() {
Some(PathSegment::Paragraph(i)) => *i,
_ => return None,
};
heading_anchor_map(disp.editor().document())
.into_iter()
.find(|(idx, _)| *idx == para)
.map(|(_, anchor)| anchor)
}
pub fn scroll_to_anchor(&mut self, anchor: &str) -> bool {
let target = {
let disp = self.0.display.borrow();
heading_anchor_map(disp.editor().document())
.into_iter()
.find(|(_, a)| a == anchor)
.map(|(idx, _)| idx)
};
match target {
Some(idx) => self.scroll_to_block(idx),
None => false,
}
}
pub fn scroll_to_block(&mut self, block_index: usize) -> bool {
let mut ctx = FltkDrawContext::new(true, true);
let mut disp = self.0.display.borrow_mut();
if block_index >= disp.editor().document().paragraphs.len() {
return false;
}
disp.editor_mut()
.set_cursor(DocumentPosition::new(block_index, 0));
disp.ensure_cursor_visible(&mut ctx);
if let Some((content_y, _line_h)) = disp.cursor_content_y(&mut ctx) {
let max_scroll = (disp.content_height() - disp.h()).max(0);
let target = (content_y - ANCHOR_TOP_MARGIN).max(0).min(max_scroll);
disp.set_scroll(target);
}
drop(disp);
self.0.group.redraw();
true
}
pub fn set_horizontal_padding(&mut self, padding: i32) {
self.0.display.borrow_mut().set_horizontal_padding(padding);
self.0.group.redraw();
}
pub fn horizontal_padding(&self) -> i32 {
self.0.display.borrow().horizontal_padding()
}
pub fn reveal_codes(&self) -> bool {
self.0.display.borrow().reveal_codes()
}
pub fn toggle_reveal_codes(&mut self) -> bool {
let new_state = !self.0.display.borrow().reveal_codes();
self.0.display.borrow_mut().set_reveal_codes(new_state);
self.0.group.redraw();
new_state
}
pub fn resize(&mut self, x: i32, y: i32, w: i32, h: i32) {
self.0.group.resize(x, y, w, h);
self.0.group.redraw();
}
pub fn height(&self) -> i32 {
self.0.group.height()
}
pub fn width(&self) -> i32 {
self.0.group.width()
}
pub fn x(&self) -> i32 {
self.0.group.x()
}
pub fn y(&self) -> i32 {
self.0.group.y()
}
pub fn search(&mut self, term: &str) -> usize {
self.0.display.borrow_mut().search(term)
}
pub fn clear_search(&mut self) {
self.0.display.borrow_mut().clear_search();
}
pub fn search_matches(&self) -> Vec<SearchMatch> {
self.0.display.borrow().search_matches().to_vec()
}
pub fn search_current_index(&self) -> Option<usize> {
self.0.display.borrow().search_current_index()
}
pub fn next_match(&mut self) -> bool {
self.0.display.borrow_mut().next_match()
}
pub fn prev_match(&mut self) -> bool {
self.0.display.borrow_mut().prev_match()
}
pub fn scroll_to_current_match(&mut self) {
let mut ctx = FltkDrawContext::new(true, true);
self.0
.display
.borrow_mut()
.scroll_to_current_match(&mut ctx);
self.0.group.redraw();
}
pub fn take_focus(&mut self) {
let _ = self.0.group.take_focus();
}
fn apply_edit<F>(&mut self, edit: F) -> bool
where
F: FnOnce(&mut Editor) -> rutle::editor::EditResult,
{
let result = {
let mut disp = self.0.display.borrow_mut();
let editor = disp.editor_mut();
edit(editor)
};
if result.is_ok() {
self.0.notify_change();
self.0.emit_paragraph_state();
true
} else {
false
}
}
}
fn selection_targets(
doc: &Document,
start: &DocumentPosition,
end: &DocumentPosition,
) -> Vec<HighlightTarget> {
let leaves = rutle::tree_walk::enumerate_leaves(doc);
let li_of = build_li_index(&leaves);
let mut out = Vec::new();
let mut seen = HashSet::new();
for leaf in &leaves {
if leaf.path < start.path || leaf.path > end.path {
continue;
}
if leaf.path == end.path && end.offset == 0 && start.path != end.path {
continue;
}
let target = leaf_element(&li_of, &leaf.path);
if seen.insert(target.clone()) {
out.push(target);
}
}
out
}
fn build_li_index(leaves: &[LeafInfo]) -> HashMap<TreePath, usize> {
let mut map = HashMap::new();
let mut cur_block = None;
let mut counter = 0;
for leaf in leaves {
let block = match leaf.path.segments().first() {
Some(PathSegment::Paragraph(i)) => *i,
_ => continue,
};
if cur_block != Some(block) {
cur_block = Some(block);
counter = 0;
}
if leaf.marker.is_some() {
map.insert(leaf.path.clone(), counter);
counter += 1;
}
}
map
}
fn leaf_element(li_of: &HashMap<TreePath, usize>, path: &TreePath) -> HighlightTarget {
let block = match path.segments().first() {
Some(PathSegment::Paragraph(i)) => *i,
_ => 0,
};
let li = enclosing_li_path(path).and_then(|p| li_of.get(&p).copied());
HighlightTarget { block, li }
}
fn enclosing_li_path(path: &TreePath) -> Option<TreePath> {
let segs = path.segments();
let last_item = segs.iter().rposition(|s| {
matches!(
s,
PathSegment::ListEntry { .. } | PathSegment::ChecklistItem(_)
)
})?;
let mut li_segs = segs[..=last_item].to_vec();
if let Some(PathSegment::ListEntry { entry, .. }) = li_segs.last() {
let entry = *entry;
*li_segs.last_mut().unwrap() = PathSegment::ListEntry { entry, para: 0 };
}
Some(TreePath(li_segs))
}
fn heading_anchor_map(doc: &tdoc::Document) -> Vec<(usize, String)> {
let mut indices = Vec::new();
let mut texts = Vec::new();
for i in 0..doc.paragraphs.len() {
let path = TreePath::root(i);
if matches!(
rutle::tree_walk::effective_block_type(doc, &path),
BlockType::Heading { .. }
) {
texts.push(rutle::tree_walk::leaf_plain_text(doc, &path));
indices.push(i);
}
}
indices
.into_iter()
.zip(crate::section_link::heading_anchors(&texts))
.collect()
}
impl ContentProvider for StructuredRichUI {
fn get_content(&self) -> String {
let disp = self.0.display.borrow();
document_to_markdown(disp.editor().document())
}
}
impl ContentLoader for StructuredRichUI {
fn set_content_from_markdown(&mut self, markdown: &str) {
let mut disp = self.0.display.borrow_mut();
let mut doc = crate::markdown_converter::markdown_to_document(markdown);
if doc.paragraphs.is_empty() {
doc.add_paragraph(tdoc::Paragraph::new_text());
}
disp.editor_mut().set_document(doc);
disp.set_scroll(0);
disp.set_hovered_link(None);
drop(disp);
self.0.emit_paragraph_state();
}
}
impl NoteUI for StructuredRichUI {
fn on_change(&mut self, f: Box<dyn FnMut() + 'static>) {
self.0.set_change_callback(Some(f));
}
fn set_readonly(&mut self, readonly: bool) {
self.0.display.borrow_mut().set_cursor_visible(!readonly);
}
fn is_readonly(&self) -> bool {
!self.0.display.borrow().cursor_visible()
}
fn scroll_pos(&self) -> i32 {
self.0.display.borrow().scroll_offset()
}
fn set_scroll_pos(&mut self, pos: i32) {
self.0.display.borrow_mut().set_scroll(pos.max(0));
}
fn cursor_pos(&self) -> Option<DocumentPosition> {
Some(self.0.display.borrow().editor().cursor())
}
fn set_cursor_pos(&mut self, pos: DocumentPosition) {
self.0.display.borrow_mut().editor_mut().set_cursor(pos);
}
fn set_bg_color(&mut self, color: Color) {
self.0.group.set_color(color);
}
fn set_resizable(&self, wind: &mut window::Window) {
wind.resizable(&self.0.group);
}
fn on_link_click(&mut self, f: Box<dyn Fn(String) + 'static>) {
self.0.set_link_callback(Some(f));
}
fn tick(&mut self, ms_since_start: u64) {
self.0.tick(ms_since_start);
}
fn on_link_hover(&mut self, f: Box<dyn Fn(Option<String>) + 'static>) {
self.0.set_link_hover_callback(Some(f));
}
fn on_paragraph_style_change(&mut self, f: Box<dyn FnMut(BlockType) + 'static>) {
self.0.set_paragraph_callback(Some(f));
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn hide(&mut self) {
self.0.group.hide();
}
fn take_focus(&mut self) {
let _ = self.0.group.take_focus();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target_at(doc: &Document, path: &TreePath) -> HighlightTarget {
let leaves = rutle::tree_walk::enumerate_leaves(doc);
leaf_element(&build_li_index(&leaves), path)
}
fn item_targets(doc: &Document, block: usize) -> Vec<HighlightTarget> {
rutle::tree_walk::enumerate_leaves(doc)
.iter()
.filter(|l| {
l.marker.is_some()
&& l.path.segments().first() == Some(&PathSegment::Paragraph(block))
})
.map(|l| target_at(doc, &l.path))
.collect()
}
#[test]
fn highlight_target_uses_block_for_non_list_leaves() {
let md = "# Title\n\nA paragraph\n";
let doc = crate::markdown_converter::markdown_to_document(md);
let leaves = rutle::tree_walk::enumerate_leaves(&doc);
assert_eq!(
target_at(&doc, &leaves[0].path),
HighlightTarget { block: 0, li: None }
);
assert_eq!(
target_at(&doc, &leaves[1].path),
HighlightTarget { block: 1, li: None }
);
}
#[test]
fn highlight_target_indexes_flat_list_items() {
let md = "intro\n\n- one\n- two\n- three\n";
let doc = crate::markdown_converter::markdown_to_document(md);
assert_eq!(
item_targets(&doc, 1),
vec![
HighlightTarget {
block: 1,
li: Some(0)
},
HighlightTarget {
block: 1,
li: Some(1)
},
HighlightTarget {
block: 1,
li: Some(2)
},
]
);
}
#[test]
fn highlight_target_indexes_nested_list_items_in_document_order() {
let md = "- a\n - b\n - c\n- d\n";
let doc = crate::markdown_converter::markdown_to_document(md);
assert_eq!(
item_targets(&doc, 0),
vec![
HighlightTarget {
block: 0,
li: Some(0)
},
HighlightTarget {
block: 0,
li: Some(1)
},
HighlightTarget {
block: 0,
li: Some(2)
},
HighlightTarget {
block: 0,
li: Some(3)
},
]
);
}
#[test]
fn highlight_target_indexes_checklist_items() {
let md = "- [ ] x\n- [x] y\n";
let doc = crate::markdown_converter::markdown_to_document(md);
assert_eq!(
item_targets(&doc, 0),
vec![
HighlightTarget {
block: 0,
li: Some(0)
},
HighlightTarget {
block: 0,
li: Some(1)
},
]
);
}
#[test]
fn selection_spanning_paragraphs_yields_one_target_each() {
let md = "# Title\n\nfirst\n\nsecond\n\nthird\n";
let doc = crate::markdown_converter::markdown_to_document(md);
let leaves = rutle::tree_walk::enumerate_leaves(&doc);
let start = DocumentPosition::at(leaves[1].path.clone(), 0);
let end = DocumentPosition::at(leaves[3].path.clone(), 1);
assert_eq!(
selection_targets(&doc, &start, &end),
vec![
HighlightTarget { block: 1, li: None },
HighlightTarget { block: 2, li: None },
HighlightTarget { block: 3, li: None },
]
);
}
#[test]
fn selection_spanning_list_items_yields_each_item() {
let md = "- one\n- two\n- three\n";
let doc = crate::markdown_converter::markdown_to_document(md);
let leaves = rutle::tree_walk::enumerate_leaves(&doc);
let start = DocumentPosition::at(leaves[0].path.clone(), 0);
let end = DocumentPosition::at(leaves[1].path.clone(), 1);
assert_eq!(
selection_targets(&doc, &start, &end),
vec![
HighlightTarget {
block: 0,
li: Some(0)
},
HighlightTarget {
block: 0,
li: Some(1)
},
]
);
}
#[test]
fn selection_from_paragraph_into_a_list() {
let md = "intro\n\n- one\n- two\n";
let doc = crate::markdown_converter::markdown_to_document(md);
let leaves = rutle::tree_walk::enumerate_leaves(&doc);
let start = DocumentPosition::at(leaves[0].path.clone(), 0);
let end = DocumentPosition::at(leaves[1].path.clone(), 1);
assert_eq!(
selection_targets(&doc, &start, &end),
vec![
HighlightTarget { block: 0, li: None },
HighlightTarget {
block: 1,
li: Some(0)
},
]
);
}
#[test]
fn selection_ending_at_a_block_start_excludes_it() {
let md = "one\n\ntwo\n\nthree\n";
let doc = crate::markdown_converter::markdown_to_document(md);
let leaves = rutle::tree_walk::enumerate_leaves(&doc);
let start = DocumentPosition::at(leaves[0].path.clone(), 0);
let end = DocumentPosition::at(leaves[2].path.clone(), 0);
assert_eq!(
selection_targets(&doc, &start, &end),
vec![
HighlightTarget { block: 0, li: None },
HighlightTarget { block: 1, li: None },
]
);
}
#[test]
fn heading_anchor_map_slugs_and_dedup() {
let md = "# Overview\n\nsome text\n\n## Details\n\n## Details\n\n# Overview\n";
let doc = crate::markdown_converter::markdown_to_document(md);
let map = heading_anchor_map(&doc);
let slugs: Vec<&str> = map.iter().map(|(_, s)| s.as_str()).collect();
assert_eq!(slugs, ["overview", "details", "details-1", "overview-1"]);
for (idx, _) in &map {
assert!(matches!(
rutle::tree_walk::effective_block_type(&doc, &TreePath::root(*idx)),
BlockType::Heading { .. }
));
}
}
#[test]
fn block_type_is_a_no_op_on_an_empty_document() {
let mut editor = Editor::with_tdoc(tdoc::Document::new());
let _ = editor.set_block_type(BlockType::Heading { level: 1 });
assert_eq!(editor.current_block_type(), BlockType::Paragraph);
}
#[test]
fn seeded_empty_paragraph_accepts_a_heading() {
let mut doc = tdoc::Document::new();
assert!(doc.paragraphs.is_empty());
doc.add_paragraph(tdoc::Paragraph::new_text());
let mut editor = Editor::with_tdoc(doc);
assert!(
editor
.set_block_type(BlockType::Heading { level: 1 })
.is_ok()
);
assert_eq!(editor.current_block_type(), BlockType::Heading { level: 1 });
}
}