diff options
Diffstat (limited to 'src/cue_view.rs')
-rw-r--r-- | src/cue_view.rs | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/src/cue_view.rs b/src/cue_view.rs new file mode 100644 index 0000000..c031720 --- /dev/null +++ b/src/cue_view.rs @@ -0,0 +1,179 @@ +use std::ops::Range; +use std::str::FromStr; + +use gtk::gdk; +use gtk::glib; +use gtk::{pango, prelude::*}; +use relm4::prelude::*; +use relm4::{ComponentParts, SimpleComponent}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::util::OptionTracker; + +pub struct CueView { + text: OptionTracker<String>, + // byte ranges for the words in `text` + word_ranges: Vec<Range<usize>>, +} + +#[derive(Debug)] +pub enum CueViewMsg { + // messages from the app + SetText(Option<String>), + // messages from UI + MouseMotion, +} + +#[derive(Debug)] +pub enum CueViewOutput { + MouseEnter, + MouseLeave, +} + +#[relm4::component(pub)] +impl SimpleComponent for CueView { + type Init = (); + type Input = CueViewMsg; + type Output = CueViewOutput; + + view! { + #[root] + #[name(label)] + gtk::Label { + add_controller: event_controller.clone(), + set_use_markup: true, + set_visible: false, + set_justify: gtk::Justification::Center, + add_css_class: "cue-view", + }, + + #[name(event_controller)] + gtk::EventControllerMotion { + connect_enter[sender] => move |_, _, _| { sender.output(CueViewOutput::MouseEnter).unwrap() }, + connect_motion[sender] => move |_, _, _| { sender.input(CueViewMsg::MouseMotion) }, + connect_leave[sender] => move |_| { sender.output(CueViewOutput::MouseLeave).unwrap() }, + }, + + #[name(popover)] + gtk::Popover { + set_parent: &root, + set_position: gtk::PositionType::Top, + set_autohide: false, + + #[name(popover_label)] + gtk::Label { } + } + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: relm4::ComponentSender<Self>, + ) -> relm4::ComponentParts<Self> { + let model = Self { + text: OptionTracker::new(None), + word_ranges: Vec::new(), + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender<Self>) { + match message { + CueViewMsg::SetText(text) => { + self.text.set(text); + + if let Some(text) = self.text.get() { + self.word_ranges = UnicodeSegmentation::unicode_word_indices(text.as_str()) + .map(|(offset, slice)| Range { + start: offset, + end: offset + slice.len(), + }) + .collect(); + } else { + self.word_ranges = Vec::new(); + } + } + CueViewMsg::MouseMotion => { + // only used to update popover in view + } + } + } + + fn post_view() { + if self.text.is_dirty() { + if let Some(text) = self.text.get() { + let mut markup = String::new(); + + let mut it = self.word_ranges.iter().enumerate().peekable(); + if let Some((_, first_word_range)) = it.peek() { + markup.push_str( + glib::markup_escape_text(&text[..first_word_range.start]).as_str(), + ); + } + while let Some((word_ix, word_range)) = it.next() { + markup.push_str(&format!( + "<a href=\"{}\">{}</a>", + word_ix, + glib::markup_escape_text(&text[word_range.clone()]) + )); + let next_gap_range = if let Some((_, next_word_range)) = it.peek() { + word_range.end..next_word_range.start + } else { + word_range.end..text.len() + }; + markup.push_str(glib::markup_escape_text(&text[next_gap_range]).as_str()); + } + + widgets.label.set_markup(markup.as_str()); + widgets.label.set_visible(true); + } else { + widgets.label.set_visible(false); + } + } + + if let Some(word_ix_str) = widgets.label.current_uri() { + let range = self + .word_ranges + .get(usize::from_str(word_ix_str.as_str()).unwrap()) + .unwrap(); + widgets + .popover_label + .set_text(&self.text.get().as_ref().unwrap()[range.clone()]); + widgets + .popover + .set_pointing_to(Some(&Self::get_rect_of_byte_range(&widgets.label, &range))); + widgets.popover.popup(); + } else { + widgets.popover.popdown(); + } + } + + fn shutdown(&mut self, widgets: &mut Self::Widgets, _output: relm4::Sender<Self::Output>) { + widgets.popover.unparent(); + } +} + +impl CueView { + fn get_rect_of_byte_range(label: >k::Label, range: &Range<usize>) -> gdk::Rectangle { + let layout = label.layout(); + let (offset_x, offset_y) = label.layout_offsets(); + + let start_pos = layout.index_to_pos(range.start as i32); + let end_pos = layout.index_to_pos(range.end as i32); + let (x, width) = if start_pos.x() <= end_pos.x() { + (start_pos.x(), end_pos.x() - start_pos.x()) + } else { + (end_pos.x(), start_pos.x() - end_pos.x()) + }; + + gdk::Rectangle::new( + x / pango::SCALE + offset_x, + start_pos.y() / pango::SCALE + offset_y, + width / pango::SCALE, + start_pos.height() / pango::SCALE, + ) + } +} |