use gtk4::glib;
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use rotulus_layout::{
Caret, ChatBuffer, ColorRef, FocusItem, LayoutParams, LineSource, Message, MessageId,
ParsedText, RowSelection, Selection, Span, Style, TextMeasure, MIN_INDENT,
};
use crate::measure::PangoMeasure;
pub const PALETTE_COLS: usize = 55;
pub const PAL_FG: usize = 34;
pub const PAL_BG: usize = 35;
pub const PAL_HISTORY_MUTED: usize = 37;
pub const PAL_TIMESTAMP: usize = 38;
pub const PAL_RULE: usize = 46;
pub const PAL_MARK_FG: usize = 32;
pub const PAL_MARK_BG: usize = 33;
pub const PAL_NICK_COLOR0: usize = 47;
pub const PAL_MARKER: usize = 36;
const MARKER_HEIGHT: f32 = 2.0;
const FOLLOW_SLOP: u32 = 8;
enum ScrollKey {
Page(i32),
Home,
End,
}
fn focus_is_text_entry(c: >k4::EventControllerKey) -> bool {
use gtk4::prelude::*;
let Some(root) = c.widget().and_then(|w| w.root()) else {
return false;
};
let Some(focus) = root.focus() else {
return false;
};
focus.is::<gtk4::TextView>() || focus.is::<gtk4::Text>() || focus.is::<gtk4::Entry>()
}
const MIN_FRAME_DELAY_MS: u32 = 10;
pub type AvatarFunc = Box<dyn Fn(&RotulusView, u64) -> Option<gtk4::gdk::Paintable>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, glib::Enum)]
#[enum_type(name = "RotulusLoadDirection")]
pub enum LoadDirection {
Older = 0,
Newer = 1,
}
impl From<rotulus_layout::LoadMoreDirection> for LoadDirection {
fn from(d: rotulus_layout::LoadMoreDirection) -> Self {
match d {
rotulus_layout::LoadMoreDirection::Older => LoadDirection::Older,
rotulus_layout::LoadMoreDirection::Newer => LoadDirection::Newer,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HoverTarget {
Link {
message: MessageId,
source: LineSource,
range: std::ops::Range<usize>,
href: String,
disguised: bool,
},
Nick { message: MessageId, key: u64 },
LoadMore {
message: MessageId,
direction: LoadDirection,
},
Media { message: MessageId, token: u32 },
}
impl HoverTarget {
fn underline(&self) -> Option<(MessageId, LineSource, std::ops::Range<usize>)> {
match self {
HoverTarget::Link {
message,
source,
range,
..
} => Some((*message, *source, range.clone())),
HoverTarget::Nick { .. } | HoverTarget::LoadMore { .. } | HoverTarget::Media { .. } => {
None
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mark {
None,
Match,
CurrentMatch,
Selection,
}
impl Mark {
fn rank(self) -> u8 {
match self {
Mark::None => 0,
Mark::Match => 1,
Mark::CurrentMatch => 2,
Mark::Selection => 3,
}
}
}
const SEARCH_MATCH_BG: gtk4::gdk::RGBA = gtk4::gdk::RGBA::new(0.9647, 0.8275, 0.1765, 1.0);
const SEARCH_MATCH_FG: gtk4::gdk::RGBA = gtk4::gdk::RGBA::new(0.0, 0.0, 0.0, 1.0);
const SEARCH_CURRENT_BG: gtk4::gdk::RGBA = gtk4::gdk::RGBA::new(1.0, 0.4706, 0.0, 1.0);
const SEARCH_CURRENT_FG: gtk4::gdk::RGBA = gtk4::gdk::RGBA::new(0.0, 0.0, 0.0, 1.0);
const ZOOM_BADGE_HOLD_US: i64 = 700_000;
const ZOOM_BADGE_FADE_US: i64 = 400_000;
const WHEEL_LINES: f64 = 3.0;
const CODE_BG_ALPHA: f32 = 0.10;
const CODE_BORDER_ALPHA: f32 = 0.22;
const CODE_BOX_PAD: f32 = 4.0;
const SEPARATOR_GRAB: f64 = 4.0;
pub(crate) const PAD_X: i32 = 4;
pub(crate) const PAD_Y: i32 = 2;
pub const DEFAULT_STAMP_FORMAT: &str = "[%H:%M:%S] ";
pub const DEFAULT_FONT: &str = "Monospace 10";
#[derive(Clone)]
pub(crate) struct MediaEntry {
pub(crate) frames: Vec<(gtk4::gdk::Texture, u32)>,
pub(crate) current: usize,
pub(crate) since_us: i64,
}
impl MediaEntry {
pub(crate) fn texture(&self) -> Option<>k4::gdk::Texture> {
self.frames.get(self.current).map(|(t, _)| t)
}
pub(crate) fn is_animated(&self) -> bool {
self.frames.len() > 1
}
pub(crate) fn size(&self) -> Option<rotulus_layout::ImageSize> {
self.frames.first().map(|(t, _)| rotulus_layout::ImageSize {
width: t.width().max(0) as u32,
height: t.height().max(0) as u32,
})
}
}
impl std::fmt::Debug for MediaEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MediaEntry")
.field("frames", &self.frames.len())
.field("current", &self.current)
.finish()
}
}
pub(crate) mod imp {
use super::*;
use std::cell::{Cell, RefCell};
pub struct RotulusView {
pub buffer: RefCell<ChatBuffer>,
pub measure: RefCell<PangoMeasure>,
pub palette: RefCell<[gtk4::gdk::RGBA; PALETTE_COLS]>,
pub hadjustment: RefCell<Option<gtk4::Adjustment>>,
pub vadjustment: RefCell<Option<gtk4::Adjustment>>,
pub hscroll_policy: Cell<gtk4::ScrollablePolicy>,
pub vscroll_policy: Cell<gtk4::ScrollablePolicy>,
pub vadj_handler: RefCell<Option<glib::SignalHandlerId>>,
pub updating_adj: Cell<bool>,
pub font_generation: Cell<u32>,
pub separator: Cell<bool>,
pub moving_separator: Cell<bool>,
pub search: RefCell<rotulus_layout::SearchState>,
pub zoom_badge_until: Cell<i64>,
pub zoom_badge_tick: RefCell<Option<gtk4::TickCallbackId>>,
pub(crate) hovered: RefCell<Option<HoverTarget>>,
pub time_stamp: Cell<bool>,
pub selection: RefCell<Option<Selection>>,
pub selecting: Cell<bool>,
pub root_key_handler:
RefCell<Option<(glib::object::WeakRef<gtk4::Widget>, gtk4::EventController)>>,
pub stamp_format: RefCell<String>,
pub(crate) media: RefCell<std::collections::HashMap<u32, MediaEntry>>,
pub anim_tick: RefCell<Option<gtk4::TickCallbackId>>,
pub(crate) drawn_media: RefCell<std::collections::HashSet<u32>>,
pub drag_pointer: Cell<(f64, f64)>,
pub drag_start: RefCell<Option<Caret>>,
pub drag_moved: Cell<bool>,
pub autoscroll_tick: RefCell<Option<gtk4::TickCallbackId>>,
pub avatar_func: RefCell<Option<AvatarFunc>>,
pub linkifier: RefCell<rotulus_layout::Linkifier>,
pub markdown: Cell<bool>,
pub autocopy: Cell<bool>,
pub copy_timestamps: Cell<bool>,
pub activate_links: Cell<bool>,
pub font: RefCell<String>,
pub marker: Cell<Option<MessageId>>,
#[cfg(feature = "v4_14")]
pub(crate) a11y: RefCell<Option<crate::a11y::TextModel>>,
#[cfg(feature = "v4_14")]
pub(crate) a11y_caret: Cell<Option<Selection>>,
pub(crate) focus_item: RefCell<Option<FocusItem>>,
pub settings_handlers: RefCell<Option<(gtk4::Settings, Vec<glib::SignalHandlerId>)>>,
}
impl Default for RotulusView {
fn default() -> Self {
RotulusView {
buffer: RefCell::new(ChatBuffer::new(LayoutParams::default())),
measure: RefCell::new(PangoMeasure::headless(DEFAULT_FONT)),
palette: RefCell::new(default_palette()),
hadjustment: RefCell::new(None),
vadjustment: RefCell::new(None),
hscroll_policy: Cell::new(gtk4::ScrollablePolicy::Minimum),
vscroll_policy: Cell::new(gtk4::ScrollablePolicy::Minimum),
vadj_handler: RefCell::new(None),
updating_adj: Cell::new(false),
font_generation: Cell::new(0),
separator: Cell::new(false),
moving_separator: Cell::new(false),
search: RefCell::new(rotulus_layout::SearchState::new()),
hovered: RefCell::new(None),
zoom_badge_until: Cell::new(0),
zoom_badge_tick: RefCell::new(None),
time_stamp: Cell::new(false),
selection: RefCell::new(None),
selecting: Cell::new(false),
root_key_handler: RefCell::new(None),
stamp_format: RefCell::new(DEFAULT_STAMP_FORMAT.to_string()),
media: RefCell::new(std::collections::HashMap::new()),
anim_tick: RefCell::new(None),
drawn_media: RefCell::new(std::collections::HashSet::new()),
drag_pointer: Cell::new((0.0, 0.0)),
drag_start: RefCell::new(None),
drag_moved: Cell::new(false),
autoscroll_tick: RefCell::new(None),
avatar_func: RefCell::new(None),
linkifier: RefCell::new(rotulus_layout::Linkifier::default()),
markdown: Cell::new(true),
autocopy: Cell::new(true),
copy_timestamps: Cell::new(false),
activate_links: Cell::new(true),
font: RefCell::new(DEFAULT_FONT.to_string()),
marker: Cell::new(None),
#[cfg(feature = "v4_14")]
a11y: RefCell::new(None),
#[cfg(feature = "v4_14")]
a11y_caret: Cell::new(None),
focus_item: RefCell::new(None),
settings_handlers: RefCell::new(None),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for RotulusView {
const NAME: &'static str = "RotulusView";
type Type = super::RotulusView;
type ParentType = gtk4::Widget;
#[cfg(not(feature = "v4_14"))]
type Interfaces = (gtk4::Scrollable,);
#[cfg(feature = "v4_14")]
type Interfaces = (gtk4::Scrollable, gtk4::AccessibleText);
fn class_init(klass: &mut Self::Class) {
klass.set_accessible_role(gtk4::AccessibleRole::Log);
}
}
impl ObjectImpl for RotulusView {
fn signals() -> &'static [glib::subclass::Signal] {
use std::ops::ControlFlow;
use std::sync::OnceLock;
static S: OnceLock<Vec<glib::subclass::Signal>> = OnceLock::new();
fn first_handled(
_: &glib::subclass::SignalInvocationHint,
_: glib::Value,
ret: &glib::Value,
) -> ControlFlow<glib::Value, glib::Value> {
if ret.get::<bool>().unwrap_or(false) {
ControlFlow::Break(ret.clone())
} else {
ControlFlow::Continue(ret.clone())
}
}
S.get_or_init(|| {
vec![
glib::subclass::Signal::builder("link-activated")
.param_types([String::static_type()])
.return_type::<bool>()
.run_last()
.accumulator(first_handled)
.build(),
glib::subclass::Signal::builder("link-menu")
.param_types([
String::static_type(),
f64::static_type(),
f64::static_type(),
])
.return_type::<bool>()
.run_last()
.accumulator(first_handled)
.build(),
glib::subclass::Signal::builder("speaker-activated")
.param_types([u64::static_type()])
.build(),
glib::subclass::Signal::builder("speaker-menu")
.param_types([u64::static_type(), f64::static_type(), f64::static_type()])
.build(),
glib::subclass::Signal::builder("load-more")
.param_types([LoadDirection::static_type()])
.build(),
glib::subclass::Signal::builder("media-activated")
.param_types([u32::static_type()])
.build(),
glib::subclass::Signal::builder("selection-changed").build(),
]
})
}
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.set_hexpand(true);
obj.set_vexpand(true);
obj.set_overflow(gtk4::Overflow::Hidden);
obj.add_css_class("view");
let ctx = obj.pango_context();
let font = pango::FontDescription::from_string(DEFAULT_FONT);
*self.measure.borrow_mut() = PangoMeasure::new(ctx, font);
obj.install_selection_gestures();
obj.install_zoom_bindings();
obj.install_link_handlers();
obj.install_keyboard();
obj.watch_settings();
}
fn dispose(&self) {
if let Some((settings, ids)) = self.settings_handlers.borrow_mut().take() {
for id in ids {
settings.disconnect(id);
}
}
}
fn properties() -> &'static [glib::ParamSpec] {
use std::sync::OnceLock;
static P: OnceLock<Vec<glib::ParamSpec>> = OnceLock::new();
P.get_or_init(|| {
vec![
glib::ParamSpecOverride::for_interface::<gtk4::Scrollable>("hadjustment"),
glib::ParamSpecOverride::for_interface::<gtk4::Scrollable>("vadjustment"),
glib::ParamSpecOverride::for_interface::<gtk4::Scrollable>("hscroll-policy"),
glib::ParamSpecOverride::for_interface::<gtk4::Scrollable>("vscroll-policy"),
glib::ParamSpecString::builder("font")
.nick("Font")
.blurb("Pango font description for the text")
.default_value(Some(DEFAULT_FONT))
.build(),
glib::ParamSpecBoolean::builder("word-wrap")
.nick("Word wrap")
.blurb("Break long lines between words rather than anywhere")
.default_value(true)
.build(),
glib::ParamSpecInt::builder("max-lines")
.nick("Maximum lines")
.blurb("Rows kept before the oldest are dropped; 0 keeps everything")
.minimum(0)
.default_value(0)
.build(),
glib::ParamSpecBoolean::builder("indent")
.nick("Two columns")
.blurb("Give timestamps and nicks a column of their own")
.default_value(true)
.build(),
glib::ParamSpecInt::builder("max-indent")
.nick("Maximum indent")
.blurb("How wide the nick column may grow, in pixels")
.minimum(0)
.default_value(256)
.build(),
glib::ParamSpecBoolean::builder("separator")
.nick("Separator")
.blurb("Draw a rule between the nick column and the text")
.default_value(false)
.build(),
glib::ParamSpecBoolean::builder("show-timestamps")
.nick("Show timestamps")
.default_value(false)
.build(),
glib::ParamSpecString::builder("timestamp-format")
.nick("Timestamp format")
.blurb("strftime(3) format for the timestamp column")
.default_value(Some(DEFAULT_STAMP_FORMAT))
.build(),
glib::ParamSpecInt::builder("avatar-size")
.nick("Avatar size")
.blurb("Edge of the avatar beside a speaker, in pixels; 0 hides avatars")
.minimum(0)
.default_value(0)
.build(),
glib::ParamSpecInt::builder("group-gap")
.nick("Group gap")
.blurb("Seconds between one speaker's messages that start a new group; 0 never groups")
.minimum(0)
.default_value(rotulus_layout::buffer::DEFAULT_GROUP_GAP_SECS as i32)
.build(),
glib::ParamSpecBoolean::builder("markdown")
.nick("Markdown")
.blurb("Render markdown in bodies appended from now on")
.default_value(true)
.build(),
glib::ParamSpecBoxed::builder::<Vec<String>>("link-schemes")
.nick("Link schemes")
.blurb("URL scheme prefixes that become links, such as \"https://\" and \"mailto:\"")
.build(),
glib::ParamSpecBoolean::builder("autocopy")
.nick("Copy on select")
.blurb("Copy the selection to the clipboards when a drag ends")
.default_value(true)
.build(),
glib::ParamSpecBoolean::builder("activate-links")
.nick("Activate links")
.blurb("Open a link on a primary click; the link menu works either way")
.default_value(true)
.build(),
glib::ParamSpecBoolean::builder("copy-timestamps")
.nick("Copy timestamps")
.blurb("Prefix each copied row with its timestamp")
.default_value(false)
.build(),
glib::ParamSpecDouble::builder("zoom")
.nick("Zoom")
.blurb("Text scale, where 1.0 is the font's own size")
.minimum(0.1)
.maximum(10.0)
.default_value(1.0)
.build(),
glib::ParamSpecBoolean::builder("has-selection")
.nick("Has selection")
.read_only()
.build(),
]
})
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
let obj = self.obj();
match pspec.name() {
"hadjustment" => {
*self.hadjustment.borrow_mut() = value.get().ok().flatten();
}
"vadjustment" => {
obj.set_vadjustment_internal(value.get().ok().flatten());
}
"hscroll-policy" => {
if let Ok(v) = value.get() {
self.hscroll_policy.set(v);
}
}
"vscroll-policy" => {
if let Ok(v) = value.get() {
self.vscroll_policy.set(v);
}
}
"font" => {
let f: Option<String> = value.get().ok().flatten();
obj.apply_font(
f.as_deref()
.filter(|f| !f.is_empty())
.unwrap_or(DEFAULT_FONT),
);
}
"word-wrap" => obj.apply_word_wrap(value.get().unwrap_or(true)),
"max-lines" => obj.apply_max_rows(value.get().unwrap_or(0)),
"indent" => obj.apply_indent(value.get().unwrap_or(true)),
"max-indent" => obj.apply_max_indent(value.get().unwrap_or(256)),
"separator" => {
self.separator.set(value.get().unwrap_or(false));
obj.queue_draw();
}
"show-timestamps" => obj.apply_time_stamp(value.get().unwrap_or(false)),
"timestamp-format" => {
let f: Option<String> = value.get().ok().flatten();
obj.apply_stamp_format(f.as_deref().unwrap_or(""));
}
"avatar-size" => {
obj.apply_avatar_size(value.get::<i32>().unwrap_or(0).max(0) as u32)
}
"group-gap" => {
obj.apply_group_gap_secs(value.get::<i32>().unwrap_or(0).max(0) as i64)
}
"markdown" => self.markdown.set(value.get().unwrap_or(true)),
"link-schemes" => {
let schemes: Vec<String> = value.get().unwrap_or_default();
*self.linkifier.borrow_mut() = if schemes.is_empty() {
rotulus_layout::Linkifier::default()
} else {
rotulus_layout::Linkifier::new(schemes)
};
obj.a11y_reset();
}
"autocopy" => self.autocopy.set(value.get().unwrap_or(true)),
"copy-timestamps" => self.copy_timestamps.set(value.get().unwrap_or(false)),
"activate-links" => self.activate_links.set(value.get().unwrap_or(true)),
"zoom" => {
let z: f64 = value.get().unwrap_or(1.0);
obj.apply_zoom_permille((z * 1000.0).round() as u32);
}
_ => {}
}
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
let buf = self.buffer.borrow();
let params = buf.params();
match pspec.name() {
"hadjustment" => self.hadjustment.borrow().to_value(),
"vadjustment" => self.vadjustment.borrow().to_value(),
"hscroll-policy" => self.hscroll_policy.get().to_value(),
"vscroll-policy" => self.vscroll_policy.get().to_value(),
"font" => self.font.borrow().to_value(),
"word-wrap" => params.word_wrap.to_value(),
"max-lines" => (buf.max_rows() as i32).to_value(),
"indent" => params.indent.to_value(),
"max-indent" => (params.max_indent as i32).to_value(),
"separator" => self.separator.get().to_value(),
"show-timestamps" => self.time_stamp.get().to_value(),
"timestamp-format" => self.stamp_format.borrow().to_value(),
"avatar-size" => (params.avatar_size as i32).to_value(),
"group-gap" => (buf.group_gap_secs() as i32).to_value(),
"markdown" => self.markdown.get().to_value(),
"link-schemes" => self.linkifier.borrow().schemes().to_vec().to_value(),
"autocopy" => self.autocopy.get().to_value(),
"copy-timestamps" => self.copy_timestamps.get().to_value(),
"activate-links" => self.activate_links.get().to_value(),
"zoom" => (f64::from(self.measure.borrow().zoom_permille()) / 1000.0).to_value(),
"has-selection" => self
.selection
.borrow()
.map(|s| !s.is_empty())
.unwrap_or(false)
.to_value(),
_ => glib::Value::from_type(glib::Type::UNIT),
}
}
}
impl WidgetImpl for RotulusView {
fn measure(&self, orientation: gtk4::Orientation, _for_size: i32) -> (i32, i32, i32, i32) {
let m = self.measure.borrow().metrics();
match orientation {
gtk4::Orientation::Horizontal => (0, 0, -1, -1),
_ => (m.line_height as i32, m.line_height as i32, -1, -1),
}
}
fn size_allocate(&self, width: i32, height: i32, _baseline: i32) {
let obj = self.obj();
{
let mut buf = self.buffer.borrow_mut();
buf.set_width(content_width(width));
}
obj.sync_adjustment(content_height(height));
}
fn snapshot(&self, snapshot: >k4::Snapshot) {
let obj = self.obj();
let before = std::mem::take(&mut *self.drawn_media.borrow_mut());
obj.snapshot_content(snapshot);
obj.snapshot_focus(snapshot);
obj.snapshot_zoom_badge(snapshot, obj.width(), obj.height());
{
let drawn = self.drawn_media.borrow();
let mut media = self.media.borrow_mut();
for token in drawn.difference(&before) {
if let Some(entry) = media.get_mut(token) {
entry.since_us = 0;
}
}
}
obj.sync_animation_tick();
}
fn focus(&self, direction: gtk4::DirectionType) -> bool {
match direction {
gtk4::DirectionType::TabForward => self.obj().focus_step(true),
gtk4::DirectionType::TabBackward => self.obj().focus_step(false),
_ => self.parent_focus(direction),
}
}
fn unmap(&self) {
self.parent_unmap();
self.drawn_media.borrow_mut().clear();
self.obj().sync_animation_tick();
}
}
impl ScrollableImpl for RotulusView {}
}
#[cfg(not(feature = "v4_14"))]
glib::wrapper! {
pub struct RotulusView(ObjectSubclass<imp::RotulusView>)
@extends gtk4::Widget,
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget,
gtk4::Scrollable;
}
#[cfg(feature = "v4_14")]
glib::wrapper! {
pub struct RotulusView(ObjectSubclass<imp::RotulusView>)
@extends gtk4::Widget,
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget,
gtk4::Scrollable, gtk4::AccessibleText;
}
impl Default for RotulusView {
fn default() -> Self {
Self::new()
}
}
impl RotulusView {
pub fn new() -> RotulusView {
glib::Object::new()
}
fn imp_(&self) -> &imp::RotulusView {
imp::RotulusView::from_obj(self)
}
pub(crate) fn imp_ref(&self) -> &imp::RotulusView {
self.imp_()
}
#[cfg_attr(not(feature = "v4_14"), allow(dead_code))]
pub(crate) fn a11y_format(&self) -> crate::a11y::RowFormat {
let imp = self.imp_();
let stamp: Box<dyn Fn(i64) -> Option<String>> = if imp.time_stamp.get() {
let fmt = imp.stamp_format.borrow().clone();
Box::new(move |t| format_stamp(t, &fmt))
} else {
Box::new(|_| None)
};
crate::a11y::RowFormat {
stamp,
linkifier: imp.linkifier.borrow().clone(),
}
}
fn apply_font(&self, font: &str) {
let imp = self.imp_();
*imp.font.borrow_mut() = font.to_string();
imp.measure
.borrow_mut()
.set_font(pango::FontDescription::from_string(font));
let g = imp.font_generation.get().wrapping_add(1);
imp.font_generation.set(g);
imp.buffer.borrow_mut().set_font_generation(g);
self.recompute_stamp_width();
self.queue_resize();
}
fn pal(&self, i: usize) -> gtk4::gdk::RGBA {
let c = self.imp_().palette.borrow()[i.min(PALETTE_COLS - 1)];
if c.alpha() == 0.0 && i != PAL_BG {
self.color()
} else {
c
}
}
pub fn set_palette(&self, palette: &[gtk4::gdk::RGBA; PALETTE_COLS]) {
*self.imp_().palette.borrow_mut() = *palette;
self.queue_draw();
}
fn apply_word_wrap(&self, on: bool) {
self.imp_().buffer.borrow_mut().set_word_wrap(on);
self.queue_draw();
}
fn apply_max_rows(&self, n: i32) {
let cap = if n > 2 { n as usize } else { 0 };
let before = self.len();
{
let m = self.imp_().measure.borrow();
self.imp_().buffer.borrow_mut().set_max_rows(cap, &*m);
}
if self.len() != before {
self.drop_stale_focus();
self.after_content_change();
self.a11y_reset();
}
}
fn apply_avatar_size(&self, px: u32) {
let m = self.imp_().measure.borrow();
let mut buf = self.imp_().buffer.borrow_mut();
buf.set_avatar_size(px);
drop(buf);
drop(m);
self.queue_resize();
self.queue_draw();
}
fn apply_group_gap_secs(&self, secs: i64) {
let m = self.imp_().measure.borrow();
self.imp_()
.buffer
.borrow_mut()
.set_group_gap_secs(secs, &*m);
drop(m);
self.queue_resize();
self.queue_draw();
}
fn apply_indent(&self, on: bool) {
self.imp_().buffer.borrow_mut().set_indent(on);
self.queue_resize();
self.queue_draw();
}
fn apply_max_indent(&self, px: i32) {
self.imp_()
.buffer
.borrow_mut()
.set_max_indent(px.max(0) as u32);
self.queue_draw();
}
fn apply_zoom_permille(&self, zoom: u32) {
let imp = self.imp_();
let was = imp.measure.borrow().zoom_permille();
imp.measure.borrow_mut().set_zoom_permille(zoom);
imp.buffer.borrow_mut().set_zoom_permille(zoom);
self.recompute_stamp_width();
self.queue_resize();
if zoom != was {
self.flash_zoom_badge();
self.a11y_announce(&zoom_label(self.zoom_permille()));
}
}
fn flash_zoom_badge(&self) {
let imp = self.imp_();
let shown_us = ZOOM_BADGE_HOLD_US + ZOOM_BADGE_FADE_US;
imp.zoom_badge_until.set(glib::monotonic_time() + shown_us);
self.queue_draw();
if !self.animations_enabled() {
let this = self.downgrade();
glib::timeout_add_local_once(
std::time::Duration::from_micros(shown_us as u64),
move || {
let Some(this) = this.upgrade() else {
return;
};
if glib::monotonic_time() >= this.imp_().zoom_badge_until.get() {
this.imp_().zoom_badge_until.set(0);
this.queue_draw();
}
},
);
return;
}
if imp.zoom_badge_tick.borrow().is_some() {
return; }
let id = self.add_tick_callback(|view, _clock| {
let imp = view.imp_();
if glib::monotonic_time() >= imp.zoom_badge_until.get() {
imp.zoom_badge_until.set(0);
*imp.zoom_badge_tick.borrow_mut() = None;
view.queue_draw();
return glib::ControlFlow::Break;
}
view.queue_draw();
glib::ControlFlow::Continue
});
*imp.zoom_badge_tick.borrow_mut() = Some(id);
}
fn snapshot_zoom_badge(&self, snapshot: >k4::Snapshot, alloc_w: i32, alloc_h: i32) {
let imp = self.imp_();
let until = imp.zoom_badge_until.get();
if until == 0 {
return;
}
let remaining = until - glib::monotonic_time();
if remaining <= 0 {
return;
}
let alpha = if self.animations_enabled() {
(remaining as f64 / ZOOM_BADGE_FADE_US as f64).clamp(0.0, 1.0) as f32
} else {
1.0
};
let layout = self.create_pango_layout(Some(&zoom_label(self.zoom_permille())));
let (tw, th) = layout.pixel_size();
let pad = 8.0;
let w = tw as f32 + pad * 2.0;
let h = th as f32 + pad;
let x = (alloc_w as f32 - w - 12.0).max(0.0);
let y = (alloc_h as f32 - h - 12.0).max(0.0);
let mut bg = self.pal(PAL_FG);
let mut fg = self.pal(PAL_BG);
if fg.alpha() == 0.0 {
let light = 0.299 * bg.red() + 0.587 * bg.green() + 0.114 * bg.blue() > 0.5;
fg = if light {
gtk4::gdk::RGBA::BLACK
} else {
gtk4::gdk::RGBA::WHITE
};
}
bg = gtk4::gdk::RGBA::new(bg.red(), bg.green(), bg.blue(), 0.85 * alpha);
fg = gtk4::gdk::RGBA::new(fg.red(), fg.green(), fg.blue(), alpha);
let rect = gtk4::graphene::Rect::new(x, y, w, h);
let rounded = gtk4::gsk::RoundedRect::from_rect(rect, h / 2.0);
snapshot.push_rounded_clip(&rounded);
snapshot.append_color(&bg, &rect);
snapshot.pop();
snapshot.save();
snapshot.translate(>k4::graphene::Point::new(x + pad, y + pad / 2.0));
snapshot.append_layout(&layout, &fg);
snapshot.restore();
}
pub fn zoom_permille(&self) -> u32 {
self.imp_().measure.borrow().zoom_permille()
}
fn watch_settings(&self) {
let Some(settings) = gtk4::Settings::default() else {
return;
};
let mut ids = Vec::new();
for name in [
"gtk-enable-animations",
"gtk-theme-name",
"gtk-interface-contrast",
] {
if settings.find_property(name).is_none() {
continue;
}
let this = self.downgrade();
ids.push(settings.connect_notify_local(Some(name), move |_, _| {
if let Some(this) = this.upgrade() {
this.sync_animation_tick();
this.queue_draw();
}
}));
}
let this = self.downgrade();
ids.push(
settings.connect_notify_local(Some("gtk-xft-dpi"), move |_, _| {
let this = this.clone();
glib::idle_add_local_once(move || {
if let Some(this) = this.upgrade() {
let font = this.imp_().font.borrow().clone();
this.apply_font(&font);
}
});
}),
);
*self.imp_().settings_handlers.borrow_mut() = Some((settings, ids));
}
fn animations_enabled(&self) -> bool {
gtk4::Settings::default().is_none_or(|s| s.is_gtk_enable_animations())
}
fn min_contrast(&self) -> f32 {
let Some(settings) = gtk4::Settings::default() else {
return crate::contrast::MIN_RATIO;
};
let asked = settings
.find_property("gtk-interface-contrast")
.is_some_and(|_| {
let v = settings.property_value("gtk-interface-contrast");
glib::EnumValue::from_value(&v).is_some_and(|(_, e)| e.nick() == "more")
});
let theme = settings
.gtk_theme_name()
.is_some_and(|n| n.to_ascii_lowercase().contains("highcontrast"));
if asked || theme {
crate::contrast::HIGH_RATIO
} else {
crate::contrast::MIN_RATIO
}
}
fn legibility(&self) -> Legibility {
let text = self.color();
let guess = if crate::contrast::luminance(rgb_of(&text)) > 0.5 {
ADWAITA_DARK_BG
} else {
ADWAITA_LIGHT_BG
};
Legibility {
bg: over(&self.pal(PAL_BG), guess),
min: self.min_contrast(),
}
}
fn apply_time_stamp(&self, on: bool) {
let imp = self.imp_();
if imp.time_stamp.get() == on {
return;
}
imp.time_stamp.set(on);
self.recompute_stamp_width();
self.queue_resize();
self.a11y_reset();
}
fn apply_stamp_format(&self, format: &str) {
let imp = self.imp_();
let f = if format.is_empty() {
DEFAULT_STAMP_FORMAT.to_string()
} else {
format.to_string()
};
if *imp.stamp_format.borrow() == f {
return;
}
*imp.stamp_format.borrow_mut() = f;
self.recompute_stamp_width();
self.queue_resize();
self.a11y_reset();
}
fn recompute_stamp_width(&self) {
let imp = self.imp_();
let px = if imp.time_stamp.get() {
let fmt = imp.stamp_format.borrow().clone();
let sample = format_stamp(1_000_000_000, &fmt).unwrap_or_default();
if sample.is_empty() {
0
} else {
imp.measure.borrow().run_width(&sample, Style::default())
}
} else {
0
};
imp.buffer.borrow_mut().set_stamp_width(px);
}
pub fn set_font_from_string(&self, font: &str) {
self.set_property("font", font);
}
pub fn set_word_wrap(&self, on: bool) {
self.set_property("word-wrap", on);
}
pub fn set_max_rows(&self, n: i32) {
self.set_property("max-lines", n.max(0));
}
pub fn set_avatar_size(&self, px: u32) {
self.set_property("avatar-size", px.min(i32::MAX as u32) as i32);
}
pub fn set_group_gap_secs(&self, secs: i64) {
self.set_property("group-gap", secs.clamp(0, i32::MAX as i64) as i32);
}
pub fn set_indent(&self, on: bool) {
self.set_property("indent", on);
}
pub fn set_max_indent(&self, px: i32) {
self.set_property("max-indent", px.max(0));
}
pub fn set_zoom_permille(&self, zoom: u32) {
self.set_property("zoom", (f64::from(zoom) / 1000.0).clamp(0.1, 10.0));
}
pub fn set_time_stamp(&self, on: bool) {
self.set_property("show-timestamps", on);
}
pub fn set_stamp_format(&self, format: &str) {
self.set_property("timestamp-format", format);
}
pub fn set_separator(&self, on: bool) {
self.set_property("separator", on);
}
pub fn set_markdown(&self, on: bool) {
self.set_property("markdown", on);
}
pub fn set_autocopy(&self, on: bool) {
self.set_property("autocopy", on);
}
pub fn set_activate_links(&self, on: bool) {
self.set_property("activate-links", on);
}
pub fn set_copy_timestamps(&self, on: bool) {
self.set_property("copy-timestamps", on);
}
pub fn set_link_schemes(&self, schemes: &[&str]) {
let v: Vec<String> = schemes.iter().map(|s| s.to_string()).collect();
self.set_property("link-schemes", v);
}
pub fn linkifier(&self) -> rotulus_layout::Linkifier {
self.imp_().linkifier.borrow().clone()
}
pub fn markdown(&self) -> bool {
self.imp_().markdown.get()
}
pub fn set_avatar_func(&self, f: Option<AvatarFunc>) {
*self.imp_().avatar_func.borrow_mut() = f;
self.queue_draw();
}
pub fn append(&self, msg: Message) -> MessageId {
let imp = self.imp_();
let before = imp.buffer.borrow().len();
let id = {
let m = imp.measure.borrow();
imp.buffer.borrow_mut().append(msg, &*m)
};
let trimmed = (before + 1).saturating_sub(imp.buffer.borrow().len());
if trimmed > 0 {
self.drop_stale_focus();
}
self.after_content_change();
self.a11y_appended(id, trimmed);
id
}
pub fn insert_before(&self, anchor: Option<MessageId>, msg: Message) -> MessageId {
let imp = self.imp_();
let id = {
let m = imp.measure.borrow();
imp.buffer.borrow_mut().insert_before(anchor, msg, &*m)
};
self.after_content_change();
self.a11y_inserted(id);
id
}
pub fn remove(&self, id: MessageId) -> bool {
let ok = {
let imp = self.imp_();
let m = imp.measure.borrow();
imp.buffer.borrow_mut().remove(id, &*m)
};
if ok {
if self.imp_().marker.get() == Some(id) {
self.imp_().marker.set(None);
}
self.imp_()
.focus_item
.borrow_mut()
.take_if(|i| i.message() == id);
self.after_content_change();
self.a11y_removed(id);
}
ok
}
pub fn replace(&self, id: MessageId, msg: Message) -> bool {
let ok = {
let imp = self.imp_();
let m = imp.measure.borrow();
imp.buffer.borrow_mut().replace(id, msg, &*m)
};
if ok {
self.imp_().search.borrow_mut().clear();
self.clear_selection();
self.imp_()
.focus_item
.borrow_mut()
.take_if(|i| i.message() == id);
self.after_content_change();
self.a11y_replaced(id);
}
ok
}
pub fn last(&self) -> Option<MessageId> {
let buf = self.imp_().buffer.borrow();
buf.len().checked_sub(1).and_then(|r| buf.id_at(r))
}
pub fn set_marker(&self, id: Option<MessageId>) {
self.imp_().marker.set(id);
self.queue_draw();
}
pub fn marker(&self) -> Option<MessageId> {
let id = self.imp_().marker.get()?;
self.imp_().buffer.borrow().row_of(id).map(|_| id)
}
pub fn max_rows(&self) -> usize {
self.imp_().buffer.borrow().max_rows()
}
pub fn len(&self) -> usize {
self.imp_().buffer.borrow().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn clear(&self) {
self.imp_().buffer.borrow_mut().clear();
self.imp_().marker.set(None);
self.imp_().focus_item.replace(None);
self.clear_selection();
self.clear_media();
self.after_content_change();
self.a11y_reset();
}
pub fn scroll_to_bottom(&self) {
self.imp_().buffer.borrow_mut().scroll_to_bottom();
self.after_content_change();
}
fn after_content_change(&self) {
self.sync_adjustment(content_height(self.height()));
self.queue_draw();
}
fn set_vadjustment_internal(&self, adj: Option<gtk4::Adjustment>) {
let imp = self.imp_();
if let (Some(old), Some(id)) = (
imp.vadjustment.borrow().as_ref(),
imp.vadj_handler.borrow_mut().take(),
) {
old.disconnect(id);
}
if let Some(a) = &adj {
let this = self.downgrade();
let id = a.connect_value_changed(move |adj| {
let Some(this) = this.upgrade() else {
return;
};
let imp = this.imp_();
if imp.updating_adj.get() {
return;
}
let h = content_height(this.height());
imp.buffer
.borrow_mut()
.scroll_to(adj.value().max(0.0) as u64, h, FOLLOW_SLOP);
this.queue_draw();
});
*imp.vadj_handler.borrow_mut() = Some(id);
}
*imp.vadjustment.borrow_mut() = adj;
self.sync_adjustment(content_height(self.height()));
}
fn sync_adjustment(&self, viewport_height: u32) {
let imp = self.imp_();
let Some(adj) = imp.vadjustment.borrow().clone() else {
return;
};
let (total, value) = {
let mut buf = imp.buffer.borrow_mut();
(buf.total_height(), buf.scroll_offset(viewport_height))
};
let page = f64::from(viewport_height);
imp.updating_adj.set(true);
adj.configure(
value as f64,
0.0,
(total as f64).max(page),
page / 10.0,
page * 0.9,
page,
);
imp.updating_adj.set(false);
}
fn resolve(&self, c: ColorRef, fallback: usize) -> gtk4::gdk::RGBA {
match c {
ColorRef::Default => self.pal(fallback),
ColorRef::Palette(i) => self.pal(i as usize),
ColorRef::Rgb(v) => rgb(v),
}
}
fn snapshot_content(&self, snapshot: >k4::Snapshot) {
let imp = self.imp_();
let alloc_w = self.width().max(0);
let alloc_h = self.height().max(0);
if alloc_h <= 0 {
return;
}
let height = content_height(alloc_h) as i32;
imp.buffer.borrow_mut().reindex();
let bg = self.pal(PAL_BG);
snapshot.append_color(
&bg,
>k4::graphene::Rect::new(0.0, 0.0, alloc_w as f32, alloc_h as f32),
);
snapshot.save();
snapshot.translate(>k4::graphene::Point::new(PAD_X as f32, PAD_Y as f32));
const MAX_SCROLL_SETTLE_PASSES: usize = 3;
let (placed, corrected) = {
let m = imp.measure.borrow();
let mut buf = imp.buffer.borrow_mut();
let first = buf.scroll_offset(height as u32);
let mut scroll = first;
let mut rows = buf.ensure_visible(scroll, height as u32, &*m);
for _ in 0..MAX_SCROLL_SETTLE_PASSES {
let settled = buf.scroll_offset(height as u32);
if settled == scroll {
break;
}
scroll = settled;
rows = buf.ensure_visible(scroll, height as u32, &*m);
}
let placed: Vec<(usize, i64)> = rows
.into_iter()
.map(|row| {
let top = buf.index_mut().offset_of(row) as i64 - scroll as i64;
(row, top)
})
.collect();
(placed, scroll != first)
};
if corrected {
self.queue_allocate();
}
if let Some(sx) = self.separator_x() {
let x = (sx - PAD_X as f64) as f32;
if x >= 1.0 {
let fg = self.pal(PAL_RULE);
let bgc = self.pal(PAL_BG);
snapshot.append_color(
&bgc,
>k4::graphene::Rect::new(x - 1.0, 0.0, 1.0, height as f32),
);
snapshot.append_color(&fg, >k4::graphene::Rect::new(x, 0.0, 1.0, height as f32));
}
}
let measure = imp.measure.borrow();
let buf = imp.buffer.borrow();
let font = measure.scaled_font();
let ctx = measure.context();
let draw_layout = pango::Layout::new(ctx);
draw_layout.set_font_description(Some(&font));
let selection = *imp.selection.borrow();
let show_stamp = imp.time_stamp.get();
let stamp_format = imp.stamp_format.borrow().clone();
let legibility = self.legibility();
let stamp_color = legibility.ink(self.pal(PAL_TIMESTAMP), None);
for (row, row_top) in placed {
let Some(layout) = buf.layout_at(row) else {
continue;
};
let Some(msg) = buf.message_at(row) else {
continue;
};
if show_stamp && row_top + i64::from(layout.height) >= 0 && row_top <= i64::from(height)
{
if let Some(stamp) = format_stamp(msg.timestamp, &stamp_format) {
draw_layout.set_attributes(None);
draw_layout.set_text(&stamp);
snapshot.save();
snapshot.translate(>k4::graphene::Point::new(0.0, row_top as f32));
snapshot.append_layout(&draw_layout, &stamp_color);
snapshot.restore();
}
}
if let Some(msg) = buf.message_at(row) {
for (bi, blk) in msg.blocks.iter().enumerate() {
if !matches!(blk, rotulus_layout::Block::Code { .. }) {
continue;
}
let src = LineSource::Block(bi);
let mut top = i64::MAX;
let mut bot = i64::MIN;
let mut left = i32::MAX;
let mut right = i32::MIN;
for l in layout.lines.iter().filter(|l| l.source == src) {
top = top.min(l.y as i64);
bot = bot.max(l.y as i64 + l.height as i64);
left = left.min(l.x as i32);
right = right.max(l.x as i32 + l.width as i32);
}
if top > bot {
continue; }
let x = left as f32 - CODE_BOX_PAD;
let y = (row_top + top) as f32 - CODE_BOX_PAD;
let w = ((right as f32 + CODE_BOX_PAD) - x)
.min(content_width(alloc_w) as f32 - x)
.max(1.0);
let h = (bot - top) as f32 + CODE_BOX_PAD * 2.0;
let fgc = self.pal(PAL_FG);
let fill =
gtk4::gdk::RGBA::new(fgc.red(), fgc.green(), fgc.blue(), CODE_BG_ALPHA);
let edge =
gtk4::gdk::RGBA::new(fgc.red(), fgc.green(), fgc.blue(), CODE_BORDER_ALPHA);
let rect = gtk4::graphene::Rect::new(x, y, w, h);
let rounded = gtk4::gsk::RoundedRect::from_rect(rect, 4.0);
snapshot.push_rounded_clip(&rounded);
snapshot.append_color(&fill, &rect);
snapshot.pop();
snapshot.append_border(&rounded, &[1.0; 4], &[edge, edge, edge, edge]);
}
}
if let Some(av) = layout.avatar {
let paintable = imp
.avatar_func
.borrow()
.as_ref()
.and_then(|f| f(self, av.key));
if let Some(p) = paintable {
let (iw, ih) = (p.intrinsic_width(), p.intrinsic_height());
let (iw, ih) = if iw > 0 && ih > 0 {
(iw, ih)
} else {
(av.size as i32, av.size as i32)
};
let scale = (av.size as f64 / iw as f64).min(av.size as f64 / ih as f64);
let (dw, dh) = ((iw as f64 * scale).max(1.0), (ih as f64 * scale).max(1.0));
snapshot.save();
snapshot.translate(>k4::graphene::Point::new(
av.x as f32,
(row_top + av.y as i64) as f32,
));
p.snapshot(snapshot, dw, dh);
snapshot.restore();
}
}
for line in &layout.lines {
let y = row_top + i64::from(line.y);
if y + i64::from(line.height) < 0 || y > i64::from(height) {
continue;
}
let (text, spans): (&str, &[Span]) = match line.source {
LineSource::Gutter => match &msg.gutter {
Some(g) => (g.text.as_str(), &g.spans),
None => continue,
},
LineSource::Block(bi) => match msg.blocks.get(bi) {
Some(rotulus_layout::Block::Text(p)) => (p.text.as_str(), &p.spans),
Some(rotulus_layout::Block::Quote { content, .. }) => {
(content.text.as_str(), &content.spans)
}
Some(rotulus_layout::Block::Code { text, .. }) => (text.as_str(), &[]),
Some(rotulus_layout::Block::Image { alt, token, size }) => {
let media = imp.media.borrow();
if let (Some(sz), Some(tex)) =
(size, media.get(token).and_then(|m| m.texture()))
{
let avail = (content_width(alloc_w)).saturating_sub(line.x);
let (dw, dh) = measure.image_size((sz.width, sz.height), avail);
snapshot.save();
snapshot.translate(>k4::graphene::Point::new(
line.x as f32,
y as f32,
));
tex.snapshot(snapshot, dw as f64, dh as f64);
snapshot.restore();
imp.drawn_media.borrow_mut().insert(*token);
continue;
}
(alt.as_str(), &[][..])
}
None => continue,
},
};
let slice = text.get(line.range.clone()).unwrap_or("");
if slice.is_empty() {
continue;
}
let x = line.x as f32;
let row_sel = selection
.as_ref()
.map(|s| buf.row_selection(row, s))
.unwrap_or(RowSelection::None);
let hl = buf.covered_range(row, line.source, &row_sel).and_then(|r| {
let s = r.start.max(line.range.start);
let e = r.end.min(line.range.end);
if s < e {
Some((s, e))
} else {
None
}
});
if trace_selection() && selection.is_some() {
eprintln!(
"[chatview] row={row} src={:?} line={:?} row_sel={:?} hl={:?}",
line.source, line.range, row_sel, hl
);
}
let hits: Vec<(usize, usize, bool)> = {
let st = imp.search.borrow();
if st.is_active() {
let id = buf.id_at(row);
st.matches()
.iter()
.filter(|m| Some(m.message) == id && m.source == line.source)
.filter_map(|m| {
let a = m.start.max(line.range.start);
let b = m.end.min(line.range.end);
(a < b).then(|| (a, b, st.is_current(m)))
})
.collect()
} else {
Vec::new()
}
};
let hover = buf
.id_at(row)
.and_then(|id| self.hover_range_for(id, line.source))
.and_then(|r| {
let a = r.start.max(line.range.start);
let b = r.end.min(line.range.end);
(a < b).then_some((a, b))
});
self.draw_runs(
snapshot,
&draw_layout,
slice,
line.range.start,
spans,
hl,
&hits,
hover,
legibility,
x,
y as f32,
);
}
if imp.marker.get().is_some() && imp.marker.get() == buf.id_at(row) {
let y = (row_top + i64::from(layout.height)) as f32 - MARKER_HEIGHT;
snapshot.append_color(
&self.pal(PAL_MARKER),
>k4::graphene::Rect::new(
0.0,
y,
content_width(alloc_w) as f32,
MARKER_HEIGHT,
),
);
}
}
snapshot.restore();
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn draw_runs(
&self,
snapshot: >k4::Snapshot,
layout: &pango::Layout,
slice: &str,
slice_start: usize,
spans: &[rotulus_layout::Span],
hl: Option<(usize, usize)>,
search: &[(usize, usize, bool)],
hover: Option<(usize, usize)>,
legibility: Legibility,
x0: f32,
y: f32,
) {
let mut x = x0;
let mut cursor = 0usize;
let end = slice.len();
let floor_boundary = |i: usize| {
let mut i = i.min(end);
while i > 0 && !slice.is_char_boundary(i) {
i -= 1;
}
i
};
let mut bands: Vec<(usize, usize, Mark)> = Vec::new();
if let Some((a, b)) = hl {
let (a, b) = (
floor_boundary(a.saturating_sub(slice_start)),
floor_boundary(b.saturating_sub(slice_start)),
);
if a < b {
bands.push((a, b, Mark::Selection));
}
}
for (a, b, is_current) in search {
let (a, b) = (
floor_boundary(a.saturating_sub(slice_start)),
floor_boundary(b.saturating_sub(slice_start)),
);
if a < b {
bands.push((
a,
b,
if *is_current {
Mark::CurrentMatch
} else {
Mark::Match
},
));
}
}
let hover_band = hover.map(|(a, b)| {
(
floor_boundary(a.saturating_sub(slice_start)),
floor_boundary(b.saturating_sub(slice_start)),
)
});
let is_hovered = |pos: usize| match hover_band {
Some((a, b)) => pos >= a && pos < b,
None => false,
};
let mark_at = |pos: usize| {
bands
.iter()
.filter(|(a, b, _)| pos >= *a && pos < *b)
.map(|(_, _, m)| *m)
.max_by_key(|m| m.rank())
.unwrap_or(Mark::None)
};
let mark_fg = self.pal(PAL_MARK_FG);
let mark_bg = self.pal(PAL_MARK_BG);
let emit = |text: &str, style: Style, mark: Mark, underline: bool, x: &mut f32| {
if text.is_empty() {
return;
}
let style = if underline {
Style {
attrs: style.attrs.union(rotulus_layout::Attrs::UNDERLINE),
..style
}
} else {
style
};
layout.set_attributes(Some(&PangoMeasure::attrs_for(style)));
layout.set_text(text);
let (w, h) = layout.pixel_size();
let band_bg = match mark {
Mark::Selection => Some(mark_bg),
Mark::CurrentMatch => Some(SEARCH_CURRENT_BG),
Mark::Match => Some(SEARCH_MATCH_BG),
Mark::None => None,
};
if style.attrs.contains(rotulus_layout::Attrs::CODE) {
let fgc = self.pal(PAL_FG);
let tint = gtk4::gdk::RGBA::new(fgc.red(), fgc.green(), fgc.blue(), CODE_BG_ALPHA);
snapshot.append_color(&tint, >k4::graphene::Rect::new(*x, y, w as f32, h as f32));
}
let (run_fg, run_bg) = {
let fg = self.resolve(style.fg, PAL_FG);
let bg = (style.bg != ColorRef::Default).then(|| self.resolve(style.bg, PAL_BG));
if style.attrs.contains(rotulus_layout::Attrs::REVERSE) {
let ink = match bg.filter(|b| b.alpha() > 0.0) {
Some(b) => b,
None => black_or_white(&fg),
};
(ink, Some(fg))
} else {
(fg, bg)
}
};
if let Some(bg) = band_bg {
snapshot.append_color(&bg, >k4::graphene::Rect::new(*x, y, w as f32, h as f32));
} else if let Some(bg) = run_bg {
snapshot.append_color(&bg, >k4::graphene::Rect::new(*x, y, w as f32, h as f32));
}
let fg = match mark {
Mark::Selection => mark_fg,
Mark::CurrentMatch => SEARCH_CURRENT_FG,
Mark::Match => SEARCH_MATCH_FG,
Mark::None => run_fg,
};
let fg = legibility.ink(fg, band_bg.as_ref().or(run_bg.as_ref()));
snapshot.save();
snapshot.translate(>k4::graphene::Point::new(*x, y));
snapshot.append_layout(layout, &fg);
snapshot.restore();
*x += w as f32;
};
let emit_split = |from: usize, to: usize, style: Style, x: &mut f32| {
if from >= to {
return;
}
let mut cuts: Vec<usize> = vec![from, to];
for (a, b, _) in &bands {
for p in [*a, *b] {
if p > from && p < to {
cuts.push(p);
}
}
}
if let Some((a, b)) = hover_band {
for p in [a, b] {
if p > from && p < to {
cuts.push(p);
}
}
}
cuts.sort_unstable();
cuts.dedup();
for w in cuts.windows(2) {
let (a, b) = (w[0], w[1]);
if a < b {
emit(&slice[a..b], style, mark_at(a), is_hovered(a), x);
}
}
};
for s in spans {
let ss = s.range.start.saturating_sub(slice_start);
let se = s.range.end.saturating_sub(slice_start);
if se <= cursor || ss >= end {
continue;
}
let ss = ss.max(cursor).min(end);
let se = se.min(end);
emit_split(cursor, ss, Style::default(), &mut x);
emit_split(ss, se, s.style, &mut x);
cursor = se;
}
emit_split(cursor, end, Style::default(), &mut x);
}
}
fn styled_width(
m: &PangoMeasure,
msg: &Message,
source: LineSource,
range: std::ops::Range<usize>,
) -> u32 {
let (text, spans): (&str, &[Span]) = match source {
LineSource::Gutter => match &msg.gutter {
Some(g) => (&g.text, &g.spans),
None => return 0,
},
LineSource::Block(bi) => match msg.blocks.get(bi) {
Some(rotulus_layout::Block::Text(p)) => (&p.text, &p.spans),
Some(rotulus_layout::Block::Quote { content, .. }) => (&content.text, &content.spans),
Some(rotulus_layout::Block::Code { text, .. }) => (text, &[]),
Some(rotulus_layout::Block::Image { alt, .. }) => (alt, &[]),
None => return 0,
},
};
let width = |r: std::ops::Range<usize>, style| text.get(r).map_or(0, |t| m.run_width(t, style));
let mut w = 0;
let mut at = range.start;
for s in spans {
let (a, b) = (s.range.start.max(at), s.range.end.min(range.end));
if a < b {
w += width(at..a, Style::default()) + width(a..b, s.style);
at = b;
}
}
w + width(at..range.end, Style::default())
}
pub fn plain_message(text: &str) -> Message {
Message::system(ParsedText::plain(text))
}
impl RotulusView {
pub fn find_image(&self, token: u32) -> Option<MessageId> {
self.imp_().buffer.borrow().find_image(token)
}
}
fn black_or_white(c: >k4::gdk::RGBA) -> gtk4::gdk::RGBA {
let light = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue() > 0.5;
if light {
gtk4::gdk::RGBA::BLACK
} else {
gtk4::gdk::RGBA::WHITE
}
}
const ADWAITA_LIGHT_BG: crate::contrast::Rgb = [1.0, 1.0, 1.0];
const ADWAITA_DARK_BG: crate::contrast::Rgb = [0.118, 0.118, 0.118];
fn rgb_of(c: >k4::gdk::RGBA) -> crate::contrast::Rgb {
[c.red(), c.green(), c.blue()]
}
fn over(top: >k4::gdk::RGBA, under: crate::contrast::Rgb) -> crate::contrast::Rgb {
let a = top.alpha();
let t = rgb_of(top);
[0, 1, 2].map(|i| t[i] * a + under[i] * (1.0 - a))
}
#[derive(Debug, Clone, Copy)]
struct Legibility {
bg: crate::contrast::Rgb,
min: f32,
}
impl Legibility {
fn ink(&self, fg: gtk4::gdk::RGBA, under: Option<>k4::gdk::RGBA>) -> gtk4::gdk::RGBA {
let bg = under.map_or(self.bg, |u| over(u, self.bg));
let seen = over(&fg, bg);
let out = crate::contrast::ensure(seen, bg, self.min);
if out == seen {
fg
} else {
gtk4::gdk::RGBA::new(out[0], out[1], out[2], 1.0)
}
}
}
fn rgb(v: u32) -> gtk4::gdk::RGBA {
gtk4::gdk::RGBA::new(
((v >> 16) & 0xff) as f32 / 255.0,
((v >> 8) & 0xff) as f32 / 255.0,
(v & 0xff) as f32 / 255.0,
1.0,
)
}
pub fn default_palette() -> [gtk4::gdk::RGBA; PALETTE_COLS] {
const MIRC: [u32; 32] = [
0xffffff, 0x000000, 0x00007f, 0x009300, 0xff0000, 0x7f0000, 0x9c009c, 0xfc7f00, 0xffff00,
0x00fc00, 0x009393, 0x00ffff, 0x0000fc, 0xff00ff, 0x7f7f7f, 0xd2d2d2, 0x470000, 0x472100,
0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047, 0x2e0047, 0x470047,
0x47002a, 0x740000, 0x743a00, 0x747400, 0x517400,
];
const NICKS: [u32; 8] = [
0x1c71d8, 0x2ec27e, 0xe66100, 0x9141ac, 0xc01c28, 0x0a8e8e, 0x986a44, 0xe5a50a,
];
let follow = gtk4::gdk::RGBA::new(0.0, 0.0, 0.0, 0.0);
let mut p = [follow; PALETTE_COLS];
for (dst, v) in p.iter_mut().zip(MIRC) {
*dst = rgb(v);
}
let gray = gtk4::gdk::RGBA::new(0.5, 0.5, 0.5, 1.0);
p[PAL_MARK_BG] = gtk4::gdk::RGBA::new(0.208, 0.518, 0.894, 0.35);
p[PAL_MARKER] = rgb(0xe01b24);
p[PAL_HISTORY_MUTED] = gray;
p[PAL_TIMESTAMP] = gray;
for (i, v) in NICKS.iter().enumerate() {
p[PAL_NICK_COLOR0 + i] = rgb(*v);
}
p
}
fn content_width(alloc_width: i32) -> u32 {
(alloc_width - 2 * PAD_X).max(1) as u32
}
fn content_height(alloc_height: i32) -> u32 {
(alloc_height - 2 * PAD_Y).max(1) as u32
}
fn format_stamp(unix: i64, format: &str) -> Option<String> {
if unix <= 0 {
return None;
}
let dt = glib::DateTime::from_unix_local(unix).ok()?;
dt.format(format).ok().map(|g| g.to_string())
}
impl RotulusView {
fn separator_x(&self) -> Option<f64> {
let imp = self.imp_();
if !imp.separator.get() {
return None;
}
let indent = imp.buffer.borrow().indent_width();
if indent == 0 {
return None;
}
let half = self.half_space();
Some(PAD_X as f64 + indent as f64 - half)
}
fn half_space(&self) -> f64 {
let space = self.imp_().measure.borrow().metrics().space_width;
(space as f64 + 1.0) / 2.0
}
fn on_separator(&self, x: f64) -> bool {
match self.separator_x() {
Some(sx) => (x - sx).abs() <= SEPARATOR_GRAB,
None => false,
}
}
fn drag_separator_to(&self, x: f64) {
let width = self.width();
if width <= 0 {
return;
}
let lo = PAD_X as f64 + MIN_INDENT as f64;
let hi = (3.0 * width as f64) / 5.0;
if hi <= lo {
return;
}
let x = x.clamp(lo, hi);
let indent = (x + self.half_space() - PAD_X as f64).max(0.0) as u32;
let moved = self.imp_().buffer.borrow_mut().set_indent_width(indent);
if moved {
self.queue_resize();
self.queue_draw();
}
}
fn install_selection_gestures(&self) {
let drag = gtk4::GestureDrag::new();
drag.set_button(gtk4::gdk::BUTTON_PRIMARY);
let this = self.downgrade();
drag.connect_drag_begin(move |g, x, y| {
let Some(this) = this.upgrade() else {
return;
};
let _ = g;
if this.on_separator(x) {
this.imp_().moving_separator.set(true);
this.imp_().selecting.set(false);
return;
}
*this.imp_().drag_start.borrow_mut() = this.caret_at(x, y);
this.imp_().drag_moved.set(false);
this.imp_().selecting.set(true);
trace_clicks(|| format!("drag_begin at ({x:.0},{y:.0})"));
});
let this = self.downgrade();
drag.connect_drag_update(move |g, dx, dy| {
let Some(this) = this.upgrade() else {
return;
};
if this.imp_().moving_separator.get() {
if let Some((sx, _)) = g.start_point() {
this.drag_separator_to(sx + dx);
}
return;
}
if !this.imp_().selecting.get() {
return;
}
let Some((sx, sy)) = g.start_point() else {
return;
};
if !this.imp_().drag_moved.get() {
this.imp_().drag_moved.set(true);
let start = *this.imp_().drag_start.borrow();
this.set_selection(start.map(|c| Selection::new(c, c)));
trace_clicks(|| "drag_update: first motion".to_string());
}
let (px, py) = (sx + dx, sy + dy);
this.imp_().drag_pointer.set((px, py));
this.sync_autoscroll();
this.extend_selection_to(px, py);
});
let this = self.downgrade();
drag.connect_drag_end(move |_, _, _| {
let Some(this) = this.upgrade() else {
return;
};
if this.imp_().moving_separator.get() {
this.imp_().moving_separator.set(false);
return;
}
this.imp_().selecting.set(false);
this.sync_autoscroll();
if this.imp_().drag_moved.get() && this.imp_().autocopy.get() {
this.copy_selection_to(ClipboardTarget::Primary);
this.copy_selection_to(ClipboardTarget::Clipboard);
}
});
self.add_controller(drag);
let click = gtk4::GestureClick::new();
click.set_button(gtk4::gdk::BUTTON_PRIMARY);
let this = self.downgrade();
click.connect_released(move |_, n_press, x, y| {
let Some(this) = this.upgrade() else {
return;
};
if n_press != 1 || this.imp_().drag_moved.get() || this.has_selection() {
return;
}
this.activate_at(x, y);
});
let this = self.downgrade();
click.connect_released(move |_, n_press, _, _| {
let Some(this) = this.upgrade() else {
return;
};
if n_press != 1 || this.imp_().drag_moved.get() {
return;
}
this.clear_selection();
});
let this = self.downgrade();
click.connect_pressed(move |_, n_press, x, y| {
let Some(this) = this.upgrade() else {
return;
};
trace_clicks(|| format!("pressed n_press={n_press} at ({x:.0},{y:.0})"));
if n_press < 2 {
return;
}
let Some(caret) = this.caret_at(x, y) else {
return;
};
let sel = {
let buf = this.imp_().buffer.borrow();
if n_press == 2 {
buf.select_word(&caret)
} else {
buf.row_of(caret.message).and_then(|r| buf.select_row(r))
}
};
trace_clicks(|| format!("multi-click n={n_press} -> sel={}", sel.is_some()));
if let Some(sel) = sel {
this.set_selection(Some(sel));
this.imp_().selecting.set(false);
this.imp_().drag_moved.set(false);
this.queue_draw();
if this.imp_().autocopy.get() {
this.copy_selection_to(ClipboardTarget::Primary);
}
}
});
self.add_controller(click);
self.connect_root_notify(move |v| v.rebind_root_copy_shortcut());
self.rebind_root_copy_shortcut();
}
fn rebind_root_copy_shortcut(&self) {
let imp = self.imp_();
if let Some((root, id)) = imp.root_key_handler.borrow_mut().take() {
if let Some(w) = root.upgrade() {
w.remove_controller(&id);
}
}
let Some(root) = self.root() else {
return;
};
let key = gtk4::EventControllerKey::new();
key.set_propagation_phase(gtk4::PropagationPhase::Capture);
let this = self.downgrade();
key.connect_key_pressed(move |c, keyval, _, state| {
let Some(this) = this.upgrade() else {
return glib::Propagation::Proceed;
};
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let is_c = keyval == gtk4::gdk::Key::c || keyval == gtk4::gdk::Key::C;
if ctrl && is_c && this.has_selection() {
this.copy_selection_to(ClipboardTarget::Clipboard);
return glib::Propagation::Stop;
}
if this.handle_scroll_key(c, keyval, state) {
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
let root_widget: gtk4::Widget = root.clone().upcast();
root_widget.add_controller(key.clone());
*imp.root_key_handler.borrow_mut() = Some((glib::object::WeakRef::new(), key.upcast()));
if let Some((weak, _)) = imp.root_key_handler.borrow().as_ref() {
weak.set(Some(&root_widget));
}
}
fn handle_scroll_key(
&self,
c: >k4::EventControllerKey,
keyval: gtk4::gdk::Key,
state: gtk4::gdk::ModifierType,
) -> bool {
use gtk4::gdk::Key;
if !self.is_mapped() {
return false;
}
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
let action = match keyval {
Key::Page_Up | Key::KP_Page_Up => ScrollKey::Page(-1),
Key::Page_Down | Key::KP_Page_Down => ScrollKey::Page(1),
Key::Home | Key::KP_Home if ctrl => ScrollKey::Home,
Key::End | Key::KP_End if ctrl => ScrollKey::End,
_ => return false,
};
if !(focus_is_text_entry(c)
|| self.has_focus()
|| shift && matches!(action, ScrollKey::Page(_)))
{
return false;
}
match action {
ScrollKey::Page(dir) => self.scroll_page(dir),
ScrollKey::Home => self.scroll_to_extreme(false),
ScrollKey::End => self.scroll_to_extreme(true),
}
true
}
pub fn scroll_page(&self, dir: i32) {
let height = content_height(self.height());
if height == 0 {
return;
}
let line = self.imp_().measure.borrow().metrics().line_height.max(1);
let step = height.saturating_sub(line).max(line) as i64;
let cur = {
let mut buf = self.imp_().buffer.borrow_mut();
buf.scroll_offset(height) as i64
};
let next = (cur + step * dir as i64).max(0) as u64;
self.scroll_absolute(next, height);
}
pub fn scroll_to_extreme(&self, bottom: bool) {
let height = content_height(self.height());
if bottom {
let total = self.imp_().buffer.borrow_mut().total_height();
self.scroll_absolute(total.saturating_sub(height as u64), height);
} else {
self.scroll_absolute(0, height);
}
}
fn scroll_absolute(&self, y: u64, height: u32) {
{
let mut buf = self.imp_().buffer.borrow_mut();
buf.scroll_to(y, height, FOLLOW_SLOP);
}
self.sync_adjustment(height);
self.queue_draw();
}
pub fn search_set(&self, needle: &str, case_sensitive: bool) -> (usize, usize) {
let imp = self.imp_();
if needle.is_empty() {
self.search_clear();
return (0, 0);
}
let hits = imp.buffer.borrow().search(needle, case_sensitive);
let height = content_height(self.height());
let from_row = {
let mut buf = imp.buffer.borrow_mut();
let y = buf.scroll_offset(height);
buf.index_mut().locate(y).map(|h| h.row).unwrap_or(0)
};
{
let mut st = imp.search.borrow_mut();
st.set_results(needle, case_sensitive, hits);
let buf = imp.buffer.borrow();
st.seek_from(|id| buf.row_of(id), from_row);
}
self.reveal_current_match();
self.search_readout()
}
pub fn search_step(&self, dir: i32) -> (usize, usize) {
{
let mut st = self.imp_().search.borrow_mut();
st.step(dir);
}
self.reveal_current_match();
self.search_readout()
}
pub fn search_clear(&self) {
self.imp_().search.borrow_mut().clear();
self.a11y_move_caret(None);
self.queue_draw();
}
pub fn search_readout(&self) -> (usize, usize) {
let st = self.imp_().search.borrow();
(st.len(), st.ordinal().unwrap_or(0))
}
fn reveal_current_match(&self) {
let imp = self.imp_();
let cur = imp.search.borrow().current();
if let Some(m) = cur {
let height = content_height(self.height());
let measure = imp.measure.borrow();
imp.buffer.borrow_mut().reveal(m.message, height, &*measure);
drop(measure);
self.sync_adjustment(height);
let at = |offset| Caret {
message: m.message,
source: m.source,
offset,
};
self.a11y_move_caret(Some(Selection::new(at(m.start), at(m.end))));
}
self.queue_draw();
}
pub(crate) fn caret_at(&self, x: f64, y: f64) -> Option<Caret> {
let imp = self.imp_();
let height = content_height(self.height());
let scroll = {
let mut buf = imp.buffer.borrow_mut();
buf.scroll_offset(height)
};
let cx = (x as i32) - PAD_X;
let cy = ((y as i32) - PAD_Y).max(0) as u64 + scroll;
let m = imp.measure.borrow();
let mut buf = imp.buffer.borrow_mut();
buf.hit_test(cx, cy, &*m)
}
#[cfg(feature = "v4_16")]
pub(crate) fn char_at(&self, x: f64, y: f64) -> Option<Caret> {
let mut c = self.caret_at(x, y)?;
if c.offset > 0 && self.caret_rect(&c).is_some_and(|r| f64::from(r.x()) > x) {
let buf = self.imp_().buffer.borrow();
let text = buf.source_text(buf.row_of(c.message)?, c.source)?;
c.offset = text[..c.offset]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
}
Some(c)
}
#[cfg(feature = "v4_16")]
pub(crate) fn caret_rect(&self, caret: &Caret) -> Option<gtk4::graphene::Rect> {
let imp = self.imp_();
let height = content_height(self.height());
let m = imp.measure.borrow();
let mut buf = imp.buffer.borrow_mut();
let row = buf.row_of(caret.message)?;
buf.ensure_layout(row, &*m);
let scroll = buf.scroll_offset(height);
let top = buf.index_mut().offset_of(row) as f32 - scroll as f32 + PAD_Y as f32;
let layout = buf.layout_at(row)?;
let mut lines = layout.lines.iter().filter(|l| l.source == caret.source);
let line = lines
.clone()
.find(|l| l.range.contains(&caret.offset))
.or_else(|| lines.find(|l| l.range.end == caret.offset));
let Some(line) = line else {
let w = content_width(self.width()) as f32;
return Some(gtk4::graphene::Rect::new(
PAD_X as f32,
top,
w,
layout.height as f32,
));
};
let msg = buf.message_at(row)?;
let text = buf.source_text(row, caret.source).unwrap_or("");
let before = styled_width(&m, msg, caret.source, line.range.start..caret.offset);
let x = PAD_X as f32 + line.x as f32 + before as f32;
let w = match text.get(caret.offset..).and_then(|t| t.chars().next()) {
Some(c) => {
let end = caret.offset + c.len_utf8();
styled_width(&m, msg, caret.source, caret.offset..end)
}
None => m.metrics().space_width,
};
Some(gtk4::graphene::Rect::new(
x,
top + line.y as f32,
w as f32,
line.height as f32,
))
}
pub fn selected_text(&self) -> String {
let imp = self.imp_();
let sel = *imp.selection.borrow();
let Some(s) = sel.filter(|s| !s.is_empty()) else {
return String::new();
};
let buf = imp.buffer.borrow();
if !imp.copy_timestamps.get() {
return buf.selected_text(&s);
}
let fmt = imp.stamp_format.borrow().clone();
buf.selected_rows(&s)
.into_iter()
.map(|(row, text)| {
match buf
.message_at(row)
.and_then(|m| format_stamp(m.timestamp, &fmt))
{
Some(ts) => format!("{ts}{text}"),
None => text,
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn has_selection(&self) -> bool {
self.imp_()
.selection
.borrow()
.map(|s| !s.is_empty())
.unwrap_or(false)
}
pub fn clear_selection(&self) {
if self.imp_().selection.borrow().is_some() {
self.set_selection(None);
}
}
fn set_selection(&self, sel: Option<Selection>) {
let had = self.has_selection();
*self.imp_().selection.borrow_mut() = sel;
self.selection_changed(had);
}
fn selection_changed(&self, had: bool) {
self.queue_draw();
self.a11y_selection_changed();
self.emit_by_name::<()>("selection-changed", &[]);
if had != self.has_selection() {
self.notify("has-selection");
}
}
fn copy_selection_to(&self, target: ClipboardTarget) {
let text = self.selected_text();
if text.is_empty() {
return;
}
let display = WidgetExt::display(self);
let cb = match target {
ClipboardTarget::Primary => display.primary_clipboard(),
ClipboardTarget::Clipboard => display.clipboard(),
};
cb.set_text(&text);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ClipboardTarget {
Primary,
Clipboard,
}
fn zoom_label(permille: u32) -> String {
let percent = ((permille + 5) / 10).to_string();
crate::trc("zoom level", "{percent}%").replace("{percent}", &percent)
}
const ZOOM_STEPS: [u32; 13] = [
500, 670, 800, 900, 1000, 1100, 1250, 1500, 1750, 2000, 2500, 3000, 4000,
];
impl RotulusView {
fn install_zoom_bindings(&self) {
let scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
let this = self.downgrade();
scroll.connect_scroll(move |c, _dx, dy| {
let Some(this) = this.upgrade() else {
return glib::Propagation::Proceed;
};
if !c
.current_event_state()
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
{
this.scroll_by_notches(dy);
return glib::Propagation::Stop;
}
if dy < 0.0 {
this.zoom_step(1);
} else if dy > 0.0 {
this.zoom_step(-1);
}
glib::Propagation::Stop
});
self.add_controller(scroll);
let controller = gtk4::ShortcutController::new();
controller.set_scope(gtk4::ShortcutScope::Global);
for (accel, delta) in [
("<Control>plus", 1i32),
("<Control>equal", 1),
("<Control>KP_Add", 1),
("<Control>minus", -1),
("<Control>KP_Subtract", -1),
] {
let this = self.downgrade();
let action = gtk4::CallbackAction::new(move |_, _| {
if let Some(this) = this.upgrade() {
this.zoom_step(delta);
}
glib::Propagation::Stop
});
if let Some(trigger) = gtk4::ShortcutTrigger::parse_string(accel) {
controller.add_shortcut(gtk4::Shortcut::new(Some(trigger), Some(action)));
}
}
let this = self.downgrade();
let reset = gtk4::CallbackAction::new(move |_, _| {
if let Some(this) = this.upgrade() {
this.set_zoom_permille(1000);
}
glib::Propagation::Stop
});
if let Some(trigger) = gtk4::ShortcutTrigger::parse_string("<Control>0") {
controller.add_shortcut(gtk4::Shortcut::new(Some(trigger), Some(reset)));
}
self.add_controller(controller);
}
fn scroll_by_notches(&self, notches: f64) {
let height = content_height(self.height());
if height == 0 {
return;
}
let line = self.imp_().measure.borrow().metrics().line_height.max(1);
let delta = notches * (line as f64) * WHEEL_LINES;
let cur = {
let mut buf = self.imp_().buffer.borrow_mut();
buf.scroll_offset(height) as f64
};
let next = (cur + delta).max(0.0) as u64;
{
let mut buf = self.imp_().buffer.borrow_mut();
buf.scroll_to(next, height, FOLLOW_SLOP);
}
self.sync_adjustment(height);
self.queue_draw();
}
pub fn zoom_step(&self, delta: i32) {
let cur = self.zoom_permille();
let idx = ZOOM_STEPS
.iter()
.position(|z| *z >= cur)
.unwrap_or(ZOOM_STEPS.len() - 1) as i32;
let next = (idx + delta).clamp(0, ZOOM_STEPS.len() as i32 - 1) as usize;
if ZOOM_STEPS[next] != cur {
self.set_zoom_permille(ZOOM_STEPS[next]);
}
}
}
impl RotulusView {
fn install_link_handlers(&self) {
self.set_has_tooltip(true);
self.connect_query_tooltip(|view, x, y, _keyboard, tooltip| {
match view.hover_target_at(f64::from(x), f64::from(y)) {
Some(HoverTarget::Link { href, .. }) => {
tooltip.set_text(Some(&href));
true
}
_ => false,
}
});
let motion = gtk4::EventControllerMotion::new();
let this = self.downgrade();
motion.connect_motion(move |_, x, y| {
let Some(this) = this.upgrade() else {
return;
};
let on_sep = this.on_separator(x) || this.imp_().moving_separator.get();
let target = if on_sep {
None
} else {
this.hover_target_at(x, y)
};
let changed = *this.imp_().hovered.borrow() != target;
if changed {
*this.imp_().hovered.borrow_mut() = target.clone();
this.queue_draw();
}
let clickable = match &target {
Some(HoverTarget::Link { .. }) => this.imp_().activate_links.get(),
other => other.is_some(),
};
let want = if on_sep {
"col-resize"
} else if clickable {
"pointer"
} else {
"text"
};
if this.cursor().and_then(|c| c.name()).as_deref() != Some(want) {
this.set_cursor_from_name(Some(want));
}
});
let this = self.downgrade();
motion.connect_leave(move |_| {
let Some(this) = this.upgrade() else {
return;
};
if this.imp_().hovered.borrow().is_some() {
*this.imp_().hovered.borrow_mut() = None;
this.queue_draw();
}
this.set_cursor_from_name(Some("text"));
});
self.add_controller(motion);
for button in [gtk4::gdk::BUTTON_SECONDARY, gtk4::gdk::BUTTON_MIDDLE] {
let click = gtk4::GestureClick::new();
click.set_button(button);
let this = self.downgrade();
click.connect_pressed(move |_, _, x, y| {
let Some(this) = this.upgrade() else {
return;
};
let target = this.hover_target_at(x, y);
if button == gtk4::gdk::BUTTON_SECONDARY
|| matches!(target, Some(HoverTarget::Link { .. }))
{
this.open_menu(target, x, y);
}
});
self.add_controller(click);
}
}
pub(crate) fn hover_target_at(&self, x: f64, y: f64) -> Option<HoverTarget> {
{
let imp = self.imp_();
let height = content_height(self.height());
let scroll = imp.buffer.borrow_mut().scroll_offset(height);
let cx = (x as i32) - PAD_X;
let cy = ((y as i32) - PAD_Y).max(0) as u64 + scroll;
let hit = imp.buffer.borrow_mut().avatar_at(cx, cy);
if let Some((message, key)) = hit {
return Some(HoverTarget::Nick { message, key });
}
}
let caret = self.caret_at(x, y)?;
let buf = self.imp_().buffer.borrow();
let msg = buf.message(caret.message)?;
if let rotulus_layout::MessageKind::LoadMore(d) = msg.kind {
return Some(HoverTarget::LoadMore {
message: caret.message,
direction: d.into(),
});
}
if caret.source == LineSource::Gutter {
return match buf.speaker_of(caret.message) {
Some(sp) if sp.key != 0 => Some(HoverTarget::Nick {
message: caret.message,
key: sp.key,
}),
_ => None,
};
}
if let LineSource::Block(bi) = caret.source {
if let Some(rotulus_layout::Block::Image { token, .. }) = msg.blocks.get(bi) {
return Some(HoverTarget::Media {
message: caret.message,
token: *token,
});
}
}
let range = buf.link_range_at(&caret)?;
let (href, _) = buf.link_at(&caret)?;
let shown = buf
.source_text(buf.row_of(caret.message)?, caret.source)
.and_then(|t| t.get(range.clone()))
.unwrap_or("");
let disguised = self.imp_().linkifier.borrow().normalize(shown) != href;
Some(HoverTarget::Link {
message: caret.message,
source: caret.source,
range,
href,
disguised,
})
}
pub(crate) fn activate_at(&self, x: f64, y: f64) {
self.activate(self.hover_target_at(x, y), x, y);
}
fn activate(&self, target: Option<HoverTarget>, x: f64, y: f64) {
match target {
Some(HoverTarget::Link { .. }) if !self.imp_().activate_links.get() => {}
Some(HoverTarget::Link {
href,
disguised: true,
..
}) => {
let handled: bool = self.emit_by_name("link-menu", &[&href, &x, &y]);
if !handled {
self.show_link_menu(&href, x, y);
}
}
Some(HoverTarget::Link { href, .. }) => {
let handled: bool = self.emit_by_name("link-activated", &[&href]);
if !handled {
self.open_link(&href);
}
}
Some(HoverTarget::Nick { key, .. }) => {
self.emit_by_name::<()>("speaker-activated", &[&key]);
}
Some(HoverTarget::LoadMore { direction, .. }) => {
self.emit_by_name::<()>("load-more", &[&direction]);
}
Some(HoverTarget::Media { token, .. }) => {
self.emit_by_name::<()>("media-activated", &[&token]);
}
None => {}
}
}
fn open_menu(&self, target: Option<HoverTarget>, x: f64, y: f64) {
match target {
Some(HoverTarget::Nick { key, .. }) => {
self.emit_by_name::<()>("speaker-menu", &[&key, &x, &y]);
}
Some(HoverTarget::Link { href, .. }) => {
let handled: bool = self.emit_by_name("link-menu", &[&href, &x, &y]);
if !handled {
self.show_link_menu(&href, x, y);
}
}
_ => self.show_context_menu(x, y),
}
}
fn open_link(&self, href: &str) {
let parent = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
gtk4::UriLauncher::new(href).launch(parent.as_ref(), gtk4::gio::Cancellable::NONE, |_| {});
}
fn hover_range_for(
&self,
row_id: MessageId,
source: LineSource,
) -> Option<std::ops::Range<usize>> {
let hovered = self.imp_().hovered.borrow();
match hovered.as_ref()? {
HoverTarget::Nick { message, .. } => {
if *message != row_id || source != LineSource::Gutter {
return None;
}
self.imp_().buffer.borrow().gutter_range(row_id)
}
HoverTarget::LoadMore { message, .. } => {
if *message != row_id || source == LineSource::Gutter {
return None;
}
Some(0..usize::MAX)
}
t => {
let (m, s, r) = t.underline()?;
(m == row_id && s == source).then_some(r)
}
}
}
fn point_in_root(&self, x: f64, y: f64) -> (gtk4::Widget, f64, f64) {
let Some(root) = self.root() else {
return (self.clone().upcast(), x, y);
};
let root: gtk4::Widget = root.upcast();
match self.compute_point(&root, >k4::graphene::Point::new(x as f32, y as f32)) {
Some(p) => (root, f64::from(p.x()), f64::from(p.y())),
None => (root, x, y),
}
}
fn show_context_menu(&self, x: f64, y: f64) {
let copy_enabled = self.has_selection();
let this = self.downgrade();
let copy = move || {
if let Some(this) = this.upgrade() {
this.copy_selection_to(ClipboardTarget::Clipboard);
}
};
let this = self.downgrade();
let select_all = move || {
if let Some(this) = this.upgrade() {
this.select_all();
}
};
self.popup_menu(
x,
y,
vec![
(crate::tr("Copy"), copy_enabled, Box::new(copy)),
(crate::tr("Select All"), true, Box::new(select_all)),
],
);
}
fn show_link_menu(&self, href: &str, x: f64, y: f64) {
let this = self.downgrade();
let url = href.to_string();
let open = move || {
if let Some(this) = this.upgrade() {
this.open_link(&url);
}
};
let this = self.downgrade();
let url = href.to_string();
let copy = move || {
if let Some(this) = this.upgrade() {
WidgetExt::display(&this).clipboard().set_text(&url);
}
};
self.popup_menu_titled(
Some(href),
x,
y,
vec![
(crate::tr("Open Link in Browser"), true, Box::new(open)),
(crate::tr("Copy Link"), true, Box::new(copy)),
],
);
}
fn popup_menu(&self, x: f64, y: f64, rows: Vec<MenuRow>) {
self.popup_menu_titled(None, x, y, rows);
}
fn popup_menu_titled(&self, title: Option<&str>, x: f64, y: f64, rows: Vec<MenuRow>) {
let (parent, px, py) = self.point_in_root(x, y);
let popover = gtk4::Popover::new();
popover.set_has_arrow(false);
popover.set_halign(gtk4::Align::Start);
popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(px as i32, py as i32, 1, 1)));
let vbox = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
for set in [
gtk4::Box::set_margin_start,
gtk4::Box::set_margin_end,
gtk4::Box::set_margin_top,
gtk4::Box::set_margin_bottom,
] {
set(&vbox, 4);
}
popover.set_child(Some(&vbox));
if let Some(t) = title {
let header = gtk4::Label::new(Some(t));
header.set_ellipsize(pango::EllipsizeMode::Middle);
header.set_max_width_chars(48);
header.set_xalign(0.0);
header.add_css_class("dim-label");
header.set_margin_start(10);
header.set_margin_end(10);
header.set_margin_bottom(4);
vbox.append(&header);
}
for (label, enabled, action) in rows {
let row = menu_row(&label, enabled);
let weak = popover.downgrade();
row.connect_clicked(move |_| {
action();
if let Some(p) = weak.upgrade() {
p.popdown();
}
});
vbox.append(&row);
}
popover.set_parent(&parent);
let refocus = self.is_focus().then(|| self.downgrade());
popover.connect_closed(move |p| {
p.unparent();
if let Some(view) = refocus.as_ref().and_then(|v| v.upgrade()) {
view.grab_focus();
}
});
popover.popup();
}
pub fn select_all(&self) {
let imp = self.imp_();
let sel = imp.buffer.borrow().select_all();
if sel.is_none() {
return;
}
self.set_selection(sel);
}
}
type MenuRow = (String, bool, Box<dyn Fn()>);
fn install_menu_css() {
use std::cell::Cell;
thread_local! {
static INSTALLED: Cell<bool> = const { Cell::new(false) };
}
if INSTALLED.with(|i| i.replace(true)) {
return;
}
let Some(display) = gtk4::gdk::Display::default() else {
INSTALLED.with(|i| i.set(false));
return;
};
const CSS: &str = ".rotulus-menu-item { padding: 4px 10px; } \
.rotulus-menu-item:hover { background-color: alpha(currentColor, 0.10); }";
let provider = gtk4::CssProvider::new();
#[cfg(feature = "v4_14")]
provider.load_from_string(CSS);
#[cfg(not(feature = "v4_14"))]
provider.load_from_data(CSS);
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
fn menu_row(label: &str, enabled: bool) -> gtk4::Button {
install_menu_css();
let b = gtk4::Button::with_label(label);
b.add_css_class("flat");
b.add_css_class("rotulus-menu-item");
b.set_has_frame(false);
b.set_halign(gtk4::Align::Fill);
b.set_sensitive(enabled);
if let Some(l) = b.child().and_then(|c| c.downcast::<gtk4::Label>().ok()) {
l.set_xalign(0.0);
l.set_hexpand(true);
}
b
}
impl RotulusView {
fn install_keyboard(&self) {
self.set_focusable(true);
let key = gtk4::EventControllerKey::new();
let this = self.downgrade();
key.connect_key_pressed(move |_, keyval, _, state| {
use gtk4::gdk::Key;
let Some(this) = this.upgrade() else {
return glib::Propagation::Proceed;
};
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
let handled = match keyval {
Key::Return | Key::KP_Enter | Key::ISO_Enter | Key::space | Key::KP_Space
if !ctrl =>
{
this.activate_focused()
}
Key::Menu => {
this.open_focused_menu();
true
}
Key::F10 if shift => {
this.open_focused_menu();
true
}
Key::a | Key::A if ctrl => {
this.select_all();
true
}
Key::Escape => {
let had = this.imp_().focus_item.borrow().is_some() || this.has_selection();
this.set_focus_item(None);
this.clear_selection();
had
}
_ => false,
};
if handled {
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
self.add_controller(key);
let focus = gtk4::EventControllerFocus::new();
focus.connect_enter(|c| {
if let Some(w) = c.widget() {
w.queue_draw();
}
});
focus.connect_leave(|c| {
if let Some(w) = c.widget() {
w.queue_draw();
}
});
self.add_controller(focus);
}
pub(crate) fn focus_step(&self, forward: bool) -> bool {
let entering = !self.is_focus();
let item = if entering {
None
} else {
self.imp_().focus_item.borrow().clone()
};
if entering && forward {
self.set_focus_item(None);
return self.grab_focus();
}
if !entering && item.is_none() && !forward {
return false;
}
let next = self.visible_rows().and_then(|rows| {
self.imp_()
.buffer
.borrow()
.step_focus(item.as_ref(), forward, rows)
});
if entering && !self.grab_focus() {
return false;
}
let moved = next.is_some();
self.set_focus_item(next);
moved || !forward
}
fn drop_stale_focus(&self) {
let imp = self.imp_();
let buf = imp.buffer.borrow();
imp.focus_item
.borrow_mut()
.take_if(|i| buf.row_of(i.message()).is_none());
}
fn visible_rows(&self) -> Option<(usize, usize)> {
let height = content_height(self.height());
let mut buf = self.imp_().buffer.borrow_mut();
let top = buf.scroll_offset(height);
let first = buf.index_mut().locate(top)?.row;
let last = buf
.index_mut()
.locate(top + u64::from(height).saturating_sub(1))?
.row;
Some((first, last))
}
fn set_focus_item(&self, item: Option<FocusItem>) {
let imp = self.imp_();
imp.focus_item.replace(item.clone());
if let Some(item) = item {
let height = content_height(self.height());
let m = imp.measure.borrow();
imp.buffer.borrow_mut().reveal(item.message(), height, &*m);
drop(m);
self.sync_adjustment(height);
self.announce_item(&item);
}
self.queue_draw();
}
fn announce_item(&self, item: &FocusItem) {
let (caret, text) = {
let buf = self.imp_().buffer.borrow();
let Some(msg) = buf.message(item.message()) else {
return;
};
let at = |source, offset| Caret {
message: item.message(),
source,
offset,
};
match item {
FocusItem::Speaker { .. } => (
at(LineSource::Gutter, 0),
msg.speaker
.as_ref()
.map(|s| s.nick.clone())
.unwrap_or_default(),
),
FocusItem::Link { source, range, .. } => {
let Some((href, label)) = buf.link_at(&at(*source, range.start)) else {
return;
};
let text = if self.imp_().linkifier.borrow().normalize(&label) == href {
label
} else {
format!("{label} ({href})")
};
(at(*source, range.start), text)
}
FocusItem::LoadMore { .. } => (at(LineSource::Block(0), 0), msg.to_plain_text()),
FocusItem::Image { block, .. } => {
let alt = match msg.blocks.get(*block) {
Some(rotulus_layout::Block::Image { alt, .. }) => alt.clone(),
_ => String::new(),
};
(at(LineSource::Block(*block), 0), alt)
}
}
};
let kind = match item {
FocusItem::Speaker { .. } => {
crate::trc("accessible role", "user")
}
FocusItem::Link { .. } => crate::trc("accessible role", "link"),
FocusItem::LoadMore { .. } => crate::trc("accessible role", "button"),
FocusItem::Image { .. } => crate::trc("accessible role", "image"),
};
self.a11y_move_caret(Some(Selection::new(caret, caret)));
let said = crate::trc("item announcement", "{text}, {kind}");
self.a11y_announce(&crate::a11y::announcement(&said, &text, &kind));
}
pub(crate) fn activate_focused(&self) -> bool {
let item = self.imp_().focus_item.borrow().clone();
let Some((item, target)) = item.and_then(|i| Some((i.clone(), self.target_of(&i)?))) else {
return false;
};
let (x, y) = self.item_point(&item);
self.activate(Some(target), x, y);
true
}
fn target_of(&self, item: &FocusItem) -> Option<HoverTarget> {
let buf = self.imp_().buffer.borrow();
let msg = buf.message(item.message())?;
Some(match item {
FocusItem::Speaker { message } => HoverTarget::Nick {
message: *message,
key: msg.speaker.as_ref()?.key,
},
FocusItem::Link {
message,
source,
range,
} => {
let caret = Caret {
message: *message,
source: *source,
offset: range.start,
};
let (href, label) = buf.link_at(&caret)?;
let disguised = self.imp_().linkifier.borrow().normalize(&label) != href;
HoverTarget::Link {
message: *message,
source: *source,
range: range.clone(),
href,
disguised,
}
}
FocusItem::LoadMore { message } => match msg.kind {
rotulus_layout::MessageKind::LoadMore(d) => HoverTarget::LoadMore {
message: *message,
direction: d.into(),
},
_ => return None,
},
FocusItem::Image { message, block } => match msg.blocks.get(*block)? {
rotulus_layout::Block::Image { token, .. } => HoverTarget::Media {
message: *message,
token: *token,
},
_ => return None,
},
})
}
pub(crate) fn open_focused_menu(&self) {
let item = self.imp_().focus_item.borrow().clone();
let (target, (x, y)) = match item {
Some(item) => (self.target_of(&item), self.item_point(&item)),
None => (
None,
(
f64::from(self.width()) / 2.0,
f64::from(self.height()) / 2.0,
),
),
};
self.open_menu(target, x, y);
}
fn item_point(&self, item: &FocusItem) -> (f64, f64) {
match self.item_boxes(item).first() {
Some(r) => (f64::from(r.x()), f64::from(r.y() + r.height())),
None => (f64::from(PAD_X), f64::from(PAD_Y)),
}
}
pub(crate) fn item_boxes(&self, item: &FocusItem) -> Vec<gtk4::graphene::Rect> {
let imp = self.imp_();
let height = content_height(self.height());
let m = imp.measure.borrow();
let mut buf = imp.buffer.borrow_mut();
let Some(row) = buf.row_of(item.message()) else {
return Vec::new();
};
buf.ensure_layout(row, &*m);
let scroll = buf.scroll_offset(height);
let top = buf.index_mut().offset_of(row) as f32 - scroll as f32 + PAD_Y as f32;
let (Some(layout), Some(msg)) = (buf.layout_at(row), buf.message_at(row)) else {
return Vec::new();
};
let rect = |x: u32, y: u32, w: u32, h: u32| {
gtk4::graphene::Rect::new(PAD_X as f32 + x as f32, top + y as f32, w as f32, h as f32)
};
let width = |source, range| styled_width(&m, msg, source, range);
let mut out = Vec::new();
match item {
FocusItem::Speaker { .. } => {
if let Some(av) = layout.avatar {
out.push(rect(av.x, av.y, av.size, av.size));
}
for l in layout
.lines
.iter()
.filter(|l| l.source == LineSource::Gutter)
{
out.push(rect(l.x, l.y, width(l.source, l.range.clone()), l.height));
}
}
FocusItem::Link { source, range, .. } => {
for l in layout.lines.iter().filter(|l| l.source == *source) {
let (a, b) = (range.start.max(l.range.start), range.end.min(l.range.end));
if a < b {
let x = l.x + width(l.source, l.range.start..a);
out.push(rect(x, l.y, width(l.source, a..b), l.height));
}
}
}
FocusItem::LoadMore { .. } => {
out.push(rect(0, 0, content_width(self.width()), layout.height));
}
FocusItem::Image { block, .. } => {
let source = LineSource::Block(*block);
for l in layout.lines.iter().filter(|l| l.source == source) {
let w = match msg.blocks.get(*block) {
Some(rotulus_layout::Block::Image { size: Some(sz), .. }) => {
let avail = content_width(self.width()).saturating_sub(l.x);
m.image_size((sz.width, sz.height), avail).0
}
_ => width(source, l.range.clone()),
};
out.push(rect(l.x, l.y, w, l.height));
}
}
}
out
}
fn snapshot_focus(&self, snapshot: >k4::Snapshot) {
if !self.has_visible_focus() {
return;
}
let item = self.imp_().focus_item.borrow().clone();
let boxes = match item {
Some(item) => self.item_boxes(&item),
None => vec![gtk4::graphene::Rect::new(
2.0,
2.0,
(self.width() - 4).max(0) as f32,
(self.height() - 4).max(0) as f32,
)],
};
let accent = self.pal(PAL_MARK_BG);
let accent = gtk4::gdk::RGBA::new(accent.red(), accent.green(), accent.blue(), 1.0);
let ring = self.legibility().ink(accent, None);
for r in boxes {
let r = gtk4::graphene::Rect::new(
r.x() - 2.0,
r.y() - 1.0,
r.width() + 4.0,
r.height() + 2.0,
);
let rounded = gtk4::gsk::RoundedRect::from_rect(r, 3.0);
snapshot.append_border(&rounded, &[2.0; 4], &[ring; 4]);
}
}
}
impl RotulusView {
pub fn set_media_frames(&self, token: u32, frames: Vec<(gtk4::gdk::Texture, u32)>) {
let imp = self.imp_();
if frames.is_empty() {
imp.media.borrow_mut().remove(&token);
let m = imp.measure.borrow();
let mut buf = imp.buffer.borrow_mut();
if let Some(id) = buf.find_image(token) {
buf.set_image_size(id, token, None, &*m);
}
} else {
let entry = MediaEntry {
frames,
current: 0,
since_us: 0,
};
let size = entry.size();
imp.media.borrow_mut().insert(token, entry);
if let Some(size) = size {
let m = imp.measure.borrow();
let mut buf = imp.buffer.borrow_mut();
if let Some(id) = buf.find_image(token) {
buf.set_image_size(id, token, Some(size), &*m);
}
}
}
self.sync_animation_tick();
self.queue_resize();
}
fn animating_on_screen(&self) -> bool {
let imp = self.imp_();
let media = imp.media.borrow();
imp.drawn_media
.borrow()
.iter()
.any(|t| media.get(t).is_some_and(MediaEntry::is_animated))
}
fn sync_animation_tick(&self) {
let imp = self.imp_();
if !self.animations_enabled() {
let mut rewound = false;
for entry in imp.media.borrow_mut().values_mut() {
rewound |= entry.current != 0;
entry.current = 0;
entry.since_us = 0;
}
if rewound {
self.queue_draw();
}
}
let animated = self.animations_enabled() && self.animating_on_screen();
let running = imp.anim_tick.borrow().is_some();
if animated == running {
return;
}
if !animated {
if let Some(id) = imp.anim_tick.borrow_mut().take() {
id.remove();
}
return;
}
let id = self.add_tick_callback(move |view, clock| {
let imp = view.imp_();
if !view.animating_on_screen() {
imp.anim_tick.borrow_mut().take();
return glib::ControlFlow::Break;
}
let now = clock.frame_time();
let mut advanced = false;
{
let drawn = imp.drawn_media.borrow();
let mut media = imp.media.borrow_mut();
for token in drawn.iter() {
let Some(entry) = media.get_mut(token) else {
continue;
};
if !entry.is_animated() {
continue;
}
let delay = entry
.frames
.get(entry.current)
.map(|(_, d)| *d)
.unwrap_or(100)
.max(MIN_FRAME_DELAY_MS) as i64
* 1000;
if entry.since_us == 0 {
entry.since_us = now;
continue;
}
if now - entry.since_us >= delay {
entry.current = (entry.current + 1) % entry.frames.len();
entry.since_us = now;
advanced = true;
}
}
}
if advanced {
view.queue_draw();
}
glib::ControlFlow::Continue
});
*imp.anim_tick.borrow_mut() = Some(id);
}
fn clear_media(&self) {
self.imp_().media.borrow_mut().clear();
self.imp_().drawn_media.borrow_mut().clear();
self.sync_animation_tick();
}
}
impl RotulusView {
pub fn image_token_of(&self, id: MessageId) -> Option<u32> {
let buf = self.imp_().buffer.borrow();
let msg = buf.message(id)?;
msg.blocks.iter().find_map(|b| match b {
rotulus_layout::Block::Image { token, .. } => Some(*token),
_ => None,
})
}
}
fn trace_clicks(msg: impl FnOnce() -> String) {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
let on = *ON.get_or_init(|| {
std::env::var("ROTULUS_TRACE")
.map(|v| v.split(',').any(|p| p.trim() == "clicks"))
.unwrap_or(false)
});
if on {
eprintln!("[chatview] {}", msg());
}
}
fn trace_selection() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| {
std::env::var("ROTULUS_TRACE")
.map(|v| v.split(',').any(|p| p.trim() == "selection"))
.unwrap_or(false)
})
}
const AUTOSCROLL_MAX_PPS: f64 = 1200.0;
const AUTOSCROLL_FULL_AT: f64 = 120.0;
impl RotulusView {
fn extend_selection_to(&self, x: f64, y: f64) {
let Some(focus) = self.caret_at(x, y) else {
return;
};
let had = self.has_selection();
let mut sel = self.imp_().selection.borrow_mut();
let Some(s) = sel.as_mut() else {
return;
};
if s.focus == focus {
return;
}
s.focus = focus;
drop(sel);
self.selection_changed(had);
}
fn drag_overshoot(&self) -> f64 {
let (_, y) = self.imp_().drag_pointer.get();
let top = f64::from(PAD_Y);
let bottom = f64::from(self.height().max(0) - PAD_Y);
if y < top {
y - top
} else if y > bottom {
y - bottom
} else {
0.0
}
}
fn sync_autoscroll(&self) {
let imp = self.imp_();
let want = imp.selecting.get() && self.drag_overshoot() != 0.0;
let running = imp.autoscroll_tick.borrow().is_some();
if want == running {
return;
}
if !want {
if let Some(id) = imp.autoscroll_tick.borrow_mut().take() {
id.remove();
}
return;
}
let last_us: std::cell::Cell<Option<i64>> = std::cell::Cell::new(None);
let id = self.add_tick_callback(move |view, clock| {
let imp = view.imp_();
let stop = |view: &RotulusView| {
*view.imp_().autoscroll_tick.borrow_mut() = None;
glib::ControlFlow::Break
};
if !imp.selecting.get() {
return stop(view);
}
let overshoot = view.drag_overshoot();
if overshoot == 0.0 {
return stop(view);
}
let now = clock.frame_time();
let dt = match last_us.get() {
Some(prev) => ((now - prev) as f64 / 1_000_000.0).clamp(0.0, 0.1),
None => 0.0,
};
last_us.set(Some(now));
let ramp = (overshoot.abs() / AUTOSCROLL_FULL_AT).clamp(0.0, 1.0);
let delta = overshoot.signum() * AUTOSCROLL_MAX_PPS * ramp * dt;
let height = content_height(view.height());
let cur = {
let mut buf = imp.buffer.borrow_mut();
buf.scroll_offset(height) as f64
};
let next = (cur + delta).max(0.0) as u64;
{
let mut buf = imp.buffer.borrow_mut();
buf.scroll_to(next, height, FOLLOW_SLOP);
}
view.sync_adjustment(height);
let (px, py) = imp.drag_pointer.get();
let clamped_y = py.clamp(f64::from(PAD_Y), f64::from(view.height().max(0) - PAD_Y));
view.extend_selection_to(px, clamped_y);
glib::ControlFlow::Continue
});
*imp.autoscroll_tick.borrow_mut() = Some(id);
}
}