use crate::content::{ContentLoader, ContentProvider};
use crate::fltk_draw_context::FltkDrawContext;
use crate::fltk_structured_rich_display::FltkStructuredRichDisplay;
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 std::any::Any;
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 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 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);
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::*;
#[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 });
}
}