aboutsummaryrefslogtreecommitdiff
path: root/src/cue_view.rs
diff options
context:
space:
mode:
authorMalte Voos <git@mal.tc>2025-10-01 00:20:10 +0200
committerMalte Voos <git@mal.tc>2025-10-01 00:20:10 +0200
commit338babaad2189f7ff1ee088994c8c20a0646ff4d (patch)
tree29fb2620f748d32a42c1d1eb3346771600a8d75b /src/cue_view.rs
downloadlleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.tar.gz
lleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.zip
init
Diffstat (limited to 'src/cue_view.rs')
-rw-r--r--src/cue_view.rs179
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: &gtk::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,
+ )
+ }
+}