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, // byte ranges for the words in `text` word_ranges: Vec>, } #[derive(Debug)] pub enum CueViewMsg { // messages from the app SetText(Option), // 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, ) -> relm4::ComponentParts { 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) { 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!( "{}", 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) { widgets.popover.unparent(); } } impl CueView { fn get_rect_of_byte_range(label: >k::Label, range: &Range) -> 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, ) } }