1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
//! GTK rasteriser for [`crate::RichTextPopup`].
//!
//! Cairo + Pango equivalent of `quadraui::tui::draw_rich_text_popup`.
//! Returns per-link hit regions in `(x, y, w, h, url)` form so the
//! caller's click handler can resolve a link click without re-running
//! the layout.
//!
//! Each visible line is rendered as a SINGLE Pango call with an
//! `AttrList` — per-span fg/bold/italic + per-character selection bg
//! become attribute ranges. This avoids proportional Pango widths
//! drifting from monospace `char_width * char_count` math.
use gtk4::cairo::Context;
use gtk4::pango;
use super::cairo_rgb;
use crate::primitives::rich_text_popup::{RichTextPopup, RichTextPopupLayout, TextSelection};
use crate::theme::Theme;
use crate::types::Color;
/// Visible width of the rich-text-popup scrollbar in pixels. Wider
/// than the layout's 1px border so the bar is paint+click-friendly.
/// Callers that hit-test the scrollbar should match this constant so
/// paint and hit-test geometry stay in sync.
pub const RICH_TEXT_POPUP_SB_WIDTH: f64 = 8.0;
/// Pixels of inset between the scrollbar's right edge and the popup's
/// right border.
pub const RICH_TEXT_POPUP_SB_INSET: f64 = 1.0;
/// Draw a [`RichTextPopup`] at its resolved layout. Returns per-link
/// hit regions in `(x, y, w, h, url)` form.
///
/// `pango_layout` is the editor's monospace Pango layout — the
/// rasteriser temporarily swaps in `body_font_desc` for popup body
/// rendering, then restores the layout's original font description
/// before returning so subsequent paints in the same frame keep
/// rendering in the editor font. `body_font_desc` is caller-
/// resolved: `Backend::draw_rich_text_popup` passes the chrome (UI)
/// description, `Backend::draw_rich_text_popup_with_font_role` passes
/// the editor one when asked for `FontRole::Editor` — this function
/// itself has no font-role opinion, it just paints with whichever
/// description it's handed.
///
/// The frame border uses [`Theme::link_fg`] when `popup.has_focus`,
/// otherwise [`Theme::hover_border`]. Per-popup `popup.bg` / `popup.fg`
/// overrides win over [`Theme::hover_bg`] / [`Theme::hover_fg`].
#[allow(clippy::too_many_arguments)]
pub fn draw_rich_text_popup(
cr: &Context,
pango_layout: &pango::Layout,
body_font_desc: &pango::FontDescription,
popup: &RichTextPopup,
layout: &RichTextPopupLayout,
theme: &Theme,
) -> Vec<(f64, f64, f64, f64, String)> {
let bx = layout.bounds.x as f64;
let by = layout.bounds.y as f64;
let bw = layout.bounds.width as f64;
let bh = layout.bounds.height as f64;
if bw <= 0.0 || bh <= 0.0 {
return Vec::new();
}
let (bg_r, bg_g, bg_b) = popup
.bg
.map(cairo_rgb)
.unwrap_or_else(|| cairo_rgb(theme.hover_bg));
let (fg_r, fg_g, fg_b) = popup
.fg
.map(cairo_rgb)
.unwrap_or_else(|| cairo_rgb(theme.hover_fg));
let (border_r, border_g, border_b) = if popup.has_focus {
cairo_rgb(theme.link_fg)
} else {
cairo_rgb(theme.hover_border)
};
// Background.
cr.set_source_rgb(bg_r, bg_g, bg_b);
cr.rectangle(bx, by, bw, bh);
cr.fill().ok();
// Border.
cr.set_source_rgb(border_r, border_g, border_b);
cr.set_line_width(1.0);
cr.rectangle(bx, by, bw, bh);
cr.stroke().ok();
// Save the layout's current font_description before per-line
// `set_font_description(body_font_desc)` calls below. Without this,
// the UI font leaks into subsequent draw calls in the same frame.
let saved_font = pango_layout.font_description();
// Clip text rendering to the content area so long lines (Pango is
// unbounded) and selection rectangles don't bleed past the popup
// boundary into the editor area behind. Restored at end of draw.
let content = layout.content_bounds;
cr.save().ok();
cr.rectangle(
content.x as f64,
content.y as f64,
content.width as f64,
content.height as f64,
);
cr.clip();
for vis in &layout.visible_lines {
let row_y = vis.bounds.y as f64;
let line_x = vis.bounds.x as f64;
let line_idx = vis.line_idx;
let raw_text = popup
.line_text
.get(line_idx)
.map(String::as_str)
.unwrap_or("");
// Single-Pango-call render with per-span AttrList.
if let Some(styled) = popup.lines.get(line_idx) {
pango_layout.set_text(raw_text);
pango_layout.set_font_description(Some(body_font_desc));
let attrs = pango::AttrList::new();
// Per-line font scale (markdown headings render larger).
let line_scale = popup.line_scales.get(line_idx).copied().unwrap_or(1.0);
if (line_scale - 1.0).abs() > 0.01 {
let mut a = pango::AttrFloat::new_scale(line_scale as f64);
a.set_start_index(0);
a.set_end_index(raw_text.len() as u32);
attrs.insert(a);
}
// Compute selection byte range once for this line.
let (sel_start_byte, sel_end_byte) = popup
.selection
.map(|sel| selection_byte_range(sel, line_idx, raw_text))
.unwrap_or((0, 0));
let in_selection = |byte_start: usize, byte_end: usize| -> bool {
sel_end_byte > sel_start_byte
&& byte_start >= sel_start_byte
&& byte_end <= sel_end_byte
};
let to_u16 = |c: u8| ((c as u16) << 8) | c as u16;
let bg_color = popup.bg.unwrap_or(Color::rgb(0, 0, 0));
// Selection bg is painted in Cairo as a single rect BEFORE the
// Pango render to avoid hairline antialiasing gaps. The Pango call
// below inverts fg per-character within the selected range.
// Per-span fg + bold + italic. Each span is split by the
// selection boundary so we can swap the fg colour to the
// inverted (popup bg) for the in-selection chunk without
// an attr-override conflict.
let push_fg_attr = |attrs: &pango::AttrList, start: usize, end: usize, fg: Color| {
let mut a =
pango::AttrColor::new_foreground(to_u16(fg.r), to_u16(fg.g), to_u16(fg.b));
a.set_start_index(start as u32);
a.set_end_index(end as u32);
attrs.insert(a);
};
let push_bold = |attrs: &pango::AttrList, start: usize, end: usize| {
let mut a = pango::AttrInt::new_weight(pango::Weight::Bold);
a.set_start_index(start as u32);
a.set_end_index(end as u32);
attrs.insert(a);
};
let push_italic = |attrs: &pango::AttrList, start: usize, end: usize| {
let mut a = pango::AttrInt::new_style(pango::Style::Italic);
a.set_start_index(start as u32);
a.set_end_index(end as u32);
attrs.insert(a);
};
let mut byte_pos: usize = 0;
for span in &styled.spans {
let len = span.text.len();
let start = byte_pos;
let end = byte_pos + len;
// Split the span into up-to-three chunks based on
// selection boundary: pre-selection / in-selection /
// post-selection. Each chunk gets its own fg attr
// (with inverted colour for the in-selection chunk).
let span_fg = span.fg.unwrap_or(bg_color);
let inv_fg = bg_color;
let chunk_start_pre = start;
let chunk_end_pre = end.min(sel_start_byte).max(start);
let chunk_start_in = start.max(sel_start_byte).min(end);
let chunk_end_in = end.min(sel_end_byte).max(chunk_start_in);
let chunk_start_post = end.min(sel_end_byte).max(start);
let chunk_end_post = end.max(chunk_start_post);
if span.fg.is_some() && chunk_end_pre > chunk_start_pre {
push_fg_attr(&attrs, chunk_start_pre, chunk_end_pre, span_fg);
}
if chunk_end_in > chunk_start_in && in_selection(chunk_start_in, chunk_end_in) {
push_fg_attr(&attrs, chunk_start_in, chunk_end_in, inv_fg);
}
if span.fg.is_some() && chunk_end_post > chunk_start_post {
push_fg_attr(&attrs, chunk_start_post, chunk_end_post, span_fg);
}
if span.bold {
push_bold(&attrs, start, end);
}
if span.italic {
push_italic(&attrs, start, end);
}
byte_pos += len;
}
// Focused-link underline.
if popup.has_focus {
if let Some(focused) = popup.focused_link {
if let Some(link) = popup.links.get(focused) {
if link.line == line_idx {
let mut ul = pango::AttrInt::new_underline(pango::Underline::Single);
ul.set_start_index(link.start_byte as u32);
ul.set_end_index(link.end_byte as u32);
attrs.insert(ul);
}
}
}
}
pango_layout.set_attributes(Some(&attrs));
// Selection bg fill (Cairo rect underneath the text). With
// attrs applied so `index_to_pos` honours the font scale on
// heading rows. Pango byte indices clamp to text length, so
// a sel_end_byte at end-of-line maps to the line's right
// edge correctly.
if sel_end_byte > sel_start_byte {
let fg_color = popup.fg.unwrap_or_else(|| Color::rgb(255, 255, 255));
let start_pos = pango_layout.index_to_pos(sel_start_byte as i32);
let end_pos = pango_layout.index_to_pos(sel_end_byte as i32);
let x0 = line_x + start_pos.x() as f64 / pango::SCALE as f64;
let x1 = line_x + end_pos.x() as f64 / pango::SCALE as f64;
let row_h = vis.bounds.height as f64;
cr.set_source_rgb(
fg_color.r as f64 / 255.0,
fg_color.g as f64 / 255.0,
fg_color.b as f64 / 255.0,
);
cr.rectangle(x0.min(x1), row_y, (x1 - x0).abs(), row_h);
cr.fill().ok();
}
cr.set_source_rgb(fg_r, fg_g, fg_b);
cr.move_to(line_x, row_y);
super::painted_text::show_layout(cr, pango_layout);
pango_layout.set_attributes(None);
}
}
cr.restore().ok(); // pop the content clip
// Scrollbar — wider than the 1px border so it's visually + clickably
// present. Draw at the right inside edge of the popup. Constants
// shared with the caller's hit-test so click hit-test matches what's
// painted.
if let Some(sb) = layout.scrollbar {
let sb_w = RICH_TEXT_POPUP_SB_WIDTH;
let sb_x = bx + bw - sb_w - RICH_TEXT_POPUP_SB_INSET;
let track_y = sb.track.y as f64;
let track_h = sb.track.height as f64;
// Track background.
let (sr, sg, sbb) = cairo_rgb(theme.muted_fg);
cr.set_source_rgba(sr, sg, sbb, 0.3);
cr.rectangle(sb_x, track_y, sb_w, track_h);
cr.fill().ok();
// Thumb.
let thumb_top_off = (sb.thumb.y - sb.track.y) as f64;
let thumb_h = sb.thumb.height as f64;
cr.set_source_rgb(border_r, border_g, border_b);
cr.rectangle(sb_x + 1.0, track_y + thumb_top_off, sb_w - 2.0, thumb_h);
cr.fill().ok();
}
// Link hit regions — computed from Pango's index_to_pos so link
// rects match the proportional-font glyphs the rasteriser actually
// painted. The primitive's layout.link_hit_regions (computed from
// the consumer's measure closure) are inaccurate for proportional
// fonts. We re-derive them here using the same pango_layout
// + attrs the line was rendered with.
let mut link_rects: Vec<(f64, f64, f64, f64, String)> = Vec::new();
for vis in &layout.visible_lines {
let line_idx = vis.line_idx;
let line_x = vis.bounds.x as f64;
let row_y = vis.bounds.y as f64;
let row_h = vis.bounds.height as f64;
let raw_text = popup
.line_text
.get(line_idx)
.map(String::as_str)
.unwrap_or("");
let links_on_line: Vec<(usize, &crate::primitives::rich_text_popup::RichTextLink)> = popup
.links
.iter()
.enumerate()
.filter(|(_, l)| l.line == line_idx)
.collect();
if links_on_line.is_empty() {
continue;
}
// Re-set the line text + attrs so index_to_pos is accurate.
pango_layout.set_text(raw_text);
pango_layout.set_font_description(Some(body_font_desc));
let attrs = pango::AttrList::new();
let line_scale = popup.line_scales.get(line_idx).copied().unwrap_or(1.0);
if (line_scale - 1.0).abs() > 0.01 {
let mut a = pango::AttrFloat::new_scale(line_scale as f64);
a.set_start_index(0);
a.set_end_index(raw_text.len() as u32);
attrs.insert(a);
}
if let Some(styled) = popup.lines.get(line_idx) {
let mut byte_pos: usize = 0;
for span in &styled.spans {
let len = span.text.len();
let start = byte_pos;
let end = byte_pos + len;
if span.bold {
let mut a = pango::AttrInt::new_weight(pango::Weight::Bold);
a.set_start_index(start as u32);
a.set_end_index(end as u32);
attrs.insert(a);
}
if span.italic {
let mut a = pango::AttrInt::new_style(pango::Style::Italic);
a.set_start_index(start as u32);
a.set_end_index(end as u32);
attrs.insert(a);
}
byte_pos += len;
}
}
pango_layout.set_attributes(Some(&attrs));
for (_idx, link) in &links_on_line {
let start_pos = pango_layout.index_to_pos(link.start_byte as i32);
let end_pos = pango_layout.index_to_pos(link.end_byte as i32);
let x0 = line_x + start_pos.x() as f64 / pango::SCALE as f64;
let x1 = line_x + end_pos.x() as f64 / pango::SCALE as f64;
link_rects.push((x0.min(x1), row_y, (x1 - x0).abs(), row_h, link.url.clone()));
}
pango_layout.set_attributes(None);
}
// Restore font for subsequent paints in the same frame.
pango_layout.set_font_description(saved_font.as_ref());
link_rects
}
/// Translate a `TextSelection` (in char columns) into the byte range
/// that this line contributes to the selection. Returns `(0, 0)` when
/// the line is outside the selection.
fn selection_byte_range(sel: TextSelection, line_idx: usize, line_text: &str) -> (usize, usize) {
if line_idx < sel.start_line || line_idx > sel.end_line {
return (0, 0);
}
let (start_col, end_col) = if sel.start_line == sel.end_line {
(sel.start_col, sel.end_col)
} else if line_idx == sel.start_line {
(sel.start_col, line_text.chars().count())
} else if line_idx == sel.end_line {
(0, sel.end_col)
} else {
(0, line_text.chars().count())
};
if end_col <= start_col {
return (0, 0);
}
(
crate::text_util::char_to_byte_idx(line_text, start_col),
crate::text_util::char_to_byte_idx(line_text, end_col),
)
}