Skip to main content

ui/
code_assist.rs

1// code_assist -- what a language gives ui.code_editor beyond highlighting: completion, hover
2// texts and signature help (CodeAssist, through ui.code_editor_with), the requests of the
3// editor that the app answers (CodeEv, ui.code_events), and the popups that show them
4//
5//   trait CodeAssist        complete(doc, at, explicit) -> Option<CompList>   the items for
6//                           the word at the caret (explicit: Ctrl+Space; else typing a word or
7//                           a trigger character of triggers(), "." by default)
8//                           hover(doc, at) -> Option<String>   the text of a tooltip over the
9//                           word at a position (lines by '\n'; long ones are wrapped)
10//                           signature(doc, at) -> Option<SigHelp>   inside a call's parentheses
11//                           Every method has a default that offers nothing.
12//   CompItem { label, detail, doc, kind: CI_*, insert }   an item: what the list shows, a short
13//                           line beside it (a signature), a description (shown under the list
14//                           for the selected item), its kind (a letter in a badge), the text
15//                           it inserts ("": the label)
16//   CompList { start, items }   the items replace the word from start to the end of the word
17//                           at the caret
18//   SigHelp { label, params, active, doc }   "split(sep, n)", the byte ranges of the
19//                           parameters in it, the one at the caret (drawn bold), a description
20//   CodeEv                  Definition(pos) (Ctrl+click, F12), Rename(pos) (F2), Format
21//                           (Shift+Alt+F), Breakpoint(line) (a click in the breakpoints' column)
22//
23// The completion popup opens while a word is typed (once per word: a word without items keeps
24// it closed until it ends), after a trigger character and on Ctrl+Space; it filters as one
25// types (the items whose label starts with the typed part, ignoring case, those that match
26// the case first, otherwise in the order of the assist).  Up / Down / PgUp / PgDn choose, Enter
27// or Tab insert (the word from the list's start to the end of the word at the caret becomes
28// the item's text, one undo step, the caret after it; Enter after a word typed out in full
29// closes the list and breaks the line), Esc closes, a click on an item inserts
30// it; leaving the word, a character that is no part of a word, a click elsewhere or the focus
31// leaving close it.  At most POP_ROWS rows show, the wheel scrolls them.  Signature help opens
32// when ( or , is typed and on Ctrl+Shift+Space, follows the caret and closes outside a call or
33// on Esc.  Both are drawn on the popup layer below / above the caret line within the window,
34// in the code theme's colours (high contrast too), and recorded for screen readers: the list
35// as a K_LIST (id + 14) with a K_LISTITEM per visible row (the chosen one SS_SELECTED, label
36// and detail), signature help as a K_TEXT.
37
38use alloc::format;
39use alloc::string::String;
40use alloc::vec::Vec;
41
42use crate::code_doc::{is_word, CodeDoc};
43use crate::consts::*;
44use crate::ed_core::TextPos;
45use crate::highlight::*;
46use crate::i18n::tr;
47use crate::Ui;
48
49/// completion item kinds: plain text
50pub const CI_TEXT: u8 = 0;
51pub const CI_KEYWORD: u8 = 1;
52/// a variable (let, loop, catch)
53pub const CI_VARIABLE: u8 = 2;
54pub const CI_PARAM: u8 = 3;
55pub const CI_FUNCTION: u8 = 4;
56pub const CI_METHOD: u8 = 5;
57/// a module or namespace
58pub const CI_MODULE: u8 = 6;
59pub const CI_CONSTANT: u8 = 7;
60pub const CI_FIELD: u8 = 8;
61/// a value of the host (a configuration)
62pub const CI_VALUE: u8 = 9;
63
64/// the rows the completion popup shows at most
65pub const POP_ROWS: usize = 10;
66
67/// a completion item (see the module header)
68#[derive(Clone, Debug, Default, PartialEq)]
69pub struct CompItem {
70    pub label: String,
71    pub detail: String,
72    pub doc: String,
73    pub kind: u8,
74    /// the text it inserts ("": the label)
75    pub insert: String,
76}
77
78impl CompItem {
79    pub fn new(label: &str, kind: u8, detail: &str) -> CompItem {
80        CompItem { label: String::from(label), detail: String::from(detail), doc: String::new(), kind, insert: String::new() }
81    }
82}
83
84/// what complete() offers: the items replace the word from start on
85#[derive(Clone, Debug, Default, PartialEq)]
86pub struct CompList {
87    pub start: TextPos,
88    pub items: Vec<CompItem>,
89}
90
91/// signature help inside a call
92#[derive(Clone, Debug, Default, PartialEq)]
93pub struct SigHelp {
94    /// "split(sep, n)"
95    pub label: String,
96    /// the bytes of each parameter in label
97    pub params: Vec<(usize, usize)>,
98    /// the parameter at the caret (params.len(): none)
99    pub active: usize,
100    pub doc: String,
101}
102
103/// a language's services for ui.code_editor_with (see the module header)
104pub trait CodeAssist {
105    /// the items for the word at the caret `at` (explicit: Ctrl+Space)
106    fn complete(&mut self, doc: &CodeDoc, at: TextPos, explicit: bool) -> Option<CompList> {
107        let _ = (doc, at, explicit);
108        None
109    }
110    /// the tooltip text for the word at `at`
111    fn hover(&mut self, doc: &CodeDoc, at: TextPos) -> Option<String> {
112        let _ = (doc, at);
113        None
114    }
115    /// signature help at the caret
116    fn signature(&mut self, doc: &CodeDoc, at: TextPos) -> Option<SigHelp> {
117        let _ = (doc, at);
118        None
119    }
120    /// characters after which completion opens
121    fn triggers(&self) -> &str {
122        "."
123    }
124}
125
126/// what the user asked of a code editor in its last pass (ui.code_events)
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128#[non_exhaustive]
129pub enum CodeEv {
130    /// go to the definition of the word at the position (Ctrl+click, F12)
131    Definition(TextPos),
132    /// rename the word at the position (F2)
133    Rename(TextPos),
134    /// format the document (Shift+Alt+F)
135    Format,
136    /// a click in the breakpoints' column of line (from 0)
137    Breakpoint(usize),
138}
139
140// ---------------------------------------------------------------- the popups' state
141
142/// the completion popup of an editor
143#[derive(Default)]
144pub(crate) struct Pop {
145    pub(crate) open: bool,
146    pub(crate) start: TextPos,
147    pub(crate) items: Vec<CompItem>,
148    /// the items that match the typed part, in the order shown
149    pub(crate) shown: Vec<usize>,
150    pub(crate) sel: usize,
151    pub(crate) top: usize,
152    /// the typed part `shown` belongs to
153    pub(crate) typed: Option<String>,
154    /// a word that had no items: (line, its start), auto-opening stays off there
155    pub(crate) quiet: Option<(usize, usize)>,
156}
157
158impl Pop {
159    pub(crate) fn close(&mut self) {
160        self.open = false;
161        self.items.clear();
162        self.shown.clear();
163        self.typed = None;
164    }
165
166    /// the list of an assist at the caret (None or no items: closed; a word typed without
167    /// items stays quiet)
168    pub(crate) fn request(&mut self, doc: &CodeDoc, a: &mut (dyn CodeAssist + '_), explicit: bool) {
169        let caret = doc.selection().caret;
170        match a.complete(doc, caret, explicit) {
171            Some(l) if !l.items.is_empty() && l.start.line == caret.line && l.start.byte <= caret.byte => {
172                self.open = true;
173                self.start = l.start;
174                self.items = l.items;
175                self.typed = None;
176                self.quiet = None;
177                if !self.filter(doc) {
178                    self.quiet = Some((caret.line, l.start.byte));
179                }
180            }
181            _ => {
182                self.close();
183                let (a, _) = word_around(doc.line(caret.line), caret.byte);
184                self.quiet = Some((caret.line, a));
185            }
186        }
187    }
188
189    /// the items for the typed part again -> still open (the caret left the word, or no item
190    /// matches: closed)
191    pub(crate) fn filter(&mut self, doc: &CodeDoc) -> bool {
192        if !self.open {
193            return false;
194        }
195        let c = doc.selection().caret;
196        let t = doc.line(c.line);
197        if c.line != self.start.line || c.byte < self.start.byte || !doc.selection().is_empty() || self.start.byte > t.len() {
198            self.close();
199            return false;
200        }
201        let typed = &t[self.start.byte..c.byte];
202        if !typed.chars().all(is_word) {
203            self.close();
204            return false;
205        }
206        if self.typed.as_deref() == Some(typed) {
207            return true;
208        }
209        let low = typed.to_lowercase();
210        let mut exact: Vec<usize> = Vec::new();
211        let mut other: Vec<usize> = Vec::new();
212        for (i, it) in self.items.iter().enumerate() {
213            if it.label.starts_with(typed) {
214                exact.push(i);
215            } else if it.label.to_lowercase().starts_with(&low) {
216                other.push(i);
217            }
218        }
219        exact.extend(other);
220        self.shown = exact;
221        if self.shown.is_empty() {
222            self.close();
223            return false;
224        }
225        // another typed part: the best item first
226        self.typed = Some(String::from(typed));
227        self.sel = 0;
228        self.top = 0;
229        true
230    }
231
232    pub(crate) fn scroll_to_sel(&mut self) {
233        if self.sel < self.top {
234            self.top = self.sel;
235        }
236        if self.sel >= self.top + POP_ROWS {
237            self.top = self.sel + 1 - POP_ROWS;
238        }
239        self.top = self.top.min(self.shown.len().saturating_sub(POP_ROWS));
240    }
241
242    /// the typed part is the chosen item's text already
243    pub(crate) fn typed_whole(&self) -> bool {
244        let it = self.shown.get(self.sel).and_then(|&i| self.items.get(i));
245        match (it, self.typed.as_deref()) {
246            (Some(it), Some(t)) => t == if it.insert.is_empty() { it.label.as_str() } else { it.insert.as_str() },
247            _ => false,
248        }
249    }
250
251    /// move the choice by delta rows (clamped)
252    pub(crate) fn step(&mut self, delta: isize) {
253        let n = self.shown.len() as isize;
254        if n == 0 {
255            return;
256        }
257        self.sel = (self.sel as isize + delta).clamp(0, n - 1) as usize;
258        self.scroll_to_sel();
259    }
260
261    /// insert the chosen item (one undo step) and close
262    pub(crate) fn accept(&mut self, doc: &mut CodeDoc) {
263        let Some(it) = self.shown.get(self.sel).and_then(|&i| self.items.get(i)) else { return self.close() };
264        let text = if it.insert.is_empty() { it.label.clone() } else { it.insert.clone() };
265        let c = doc.selection().caret;
266        let t = doc.line(c.line);
267        let end = c.byte + t[c.byte.min(t.len())..].chars().take_while(|&ch| is_word(ch)).map(char::len_utf8).sum::<usize>();
268        let (a, b) = (self.start, TextPos::new(c.line, end));
269        let _ = doc.replace_kind(a, b, &text, crate::code_doc::CK_NONE, tr("Vervollständigen"));
270        self.close();
271    }
272}
273
274/// signature help of an editor: open, what it shows, the revision and caret it belongs to
275#[derive(Default)]
276pub(crate) struct Sig {
277    pub(crate) open: bool,
278    pub(crate) help: Option<SigHelp>,
279    pub(crate) key: (u64, TextPos),
280}
281
282/// the hover text of an editor: (document, revision, line, word start) -> the text
283#[derive(Default)]
284pub(crate) struct Hov {
285    pub(crate) key: (u32, u64, usize, usize),
286    pub(crate) text: Option<String>,
287}
288
289/// the word around byte b of t: (start, end) (b itself when there is none)
290pub(crate) fn word_around(t: &str, b: usize) -> (usize, usize) {
291    let b = b.min(t.len());
292    let a = t[..b].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map_or(b, |(i, _)| i);
293    let e = t[b..].char_indices().find(|(_, c)| !is_word(*c)).map_or(t.len(), |(i, _)| b + i);
294    (a, e)
295}
296
297/// lines of at most max characters (at blanks; a longer word stays whole)
298pub(crate) fn wrap(s: &str, max: usize) -> String {
299    let mut out = String::with_capacity(s.len() + 8);
300    for (k, line) in s.split('\n').enumerate() {
301        if k > 0 {
302            out.push('\n');
303        }
304        let mut n = 0;
305        for (j, w) in line.split(' ').enumerate() {
306            let wn = w.chars().count();
307            if j > 0 && n + 1 + wn > max {
308                out.push('\n');
309                n = 0;
310            } else if j > 0 {
311                out.push(' ');
312                n += 1;
313            }
314            out.push_str(w);
315            n += wn;
316        }
317    }
318    out
319}
320
321/// the font and cells of the editor (code_edit.rs Metrics)
322pub(crate) struct PopFont {
323    pub(crate) font: u32,
324    pub(crate) size: i32,
325    pub(crate) cw: i32,
326    pub(crate) lh: i32,
327    pub(crate) base: i32,
328}
329
330// the letter and the token colour of an item kind's badge
331fn badge(kind: u8, t: &CodeTheme) -> (&'static str, u32) {
332    match kind {
333        CI_KEYWORD => ("k", t.tokens[SY_KEYWORD as usize]),
334        CI_VARIABLE => ("v", t.tokens[SY_NUMBER as usize]),
335        CI_PARAM => ("p", t.tokens[SY_NUMBER as usize]),
336        CI_FUNCTION => ("f", t.tokens[SY_FUNC as usize]),
337        CI_METHOD => ("m", t.tokens[SY_FUNC as usize]),
338        CI_MODULE => ("M", t.tokens[SY_TYPE as usize]),
339        CI_CONSTANT => ("c", t.tokens[SY_CONST as usize]),
340        CI_FIELD => ("F", t.tokens[SY_KEY as usize]),
341        CI_VALUE => ("w", t.tokens[SY_KEY as usize]),
342        _ => ("t", t.fg),
343    }
344}
345
346impl Ui {
347    // the window's rectangle (px) for popups, the hit clip set to it; -> the old clip
348    fn ca_layer_begin(&mut self) -> ([i32; 4], u32, [i32; 4], u32) {
349        let (w, h) = (self.wp.rt.fb.w, self.wp.rt.fb.h);
350        let prev = self.layer_set(L_POPUP);
351        let clip = self.wp.ctx.uiclip;
352        self.wp.ctx.uiclip = [0, 0, w, h];
353        ([0, 0, w, h], prev, clip, self.wp.ctx.last_state)
354    }
355
356    fn ca_layer_end(&mut self, prev: u32, clip: [i32; 4], lr: [i32; 4], ls: u32) {
357        self.wp.ctx.uiclip = clip;
358        self.layer_set(prev);
359        self.wp.ctx.last_rect = lr;
360        self.wp.ctx.last_state = ls;
361    }
362
363    /// (code_edit.rs) the completion popup of editor id: below the caret line (x: the word's
364    /// start, below: the line's foot, above: its top), above it when there is no room ->
365    /// accepted (a click on an item)
366    pub(crate) fn ca_complete_popup(&mut self, id: u32, pop: &mut Pop, doc: &mut CodeDoc, x: i32, below: i32, above: i32, f: &PopFont, t: &CodeTheme) -> bool {
367        if !pop.open || pop.shown.is_empty() {
368            return false;
369        }
370        let lr = self.wp.ctx.last_rect;
371        let (win, prev, clip, ls) = self.ca_layer_begin();
372        let pad = self.dp(4);
373        let rh = f.lh.max(self.dp(22));
374        let rows = pop.shown.len().min(POP_ROWS);
375        let first = pop.top.min(pop.shown.len().saturating_sub(rows));
376        // the columns: a badge, the labels (code font), the details (caption font)
377        let lchars = pop.shown.iter().map(|&i| pop.items[i].label.chars().count()).max().unwrap_or(1).clamp(6, 40) as i32;
378        self.ts_font(TS_CAPTION);
379        let (cf, cs) = self.text.get_font();
380        let cap_line = self.ts_line(TS_CAPTION);
381        let cap_base = self.ts_base(TS_CAPTION);
382        let max_d = self.dp(300);
383        let mut dw = 0;
384        for &i in &pop.shown[first..first + rows] {
385            dw = dw.max(self.text.width(&pop.items[i].detail).min(max_d));
386        }
387        let badge_w = rh;
388        let w = (badge_w + pad + lchars * f.cw + if dw > 0 { self.dp(16) + dw } else { 0 } + pad * 2).max(self.dp(180)).min(win[2] - self.dp(8));
389        // the selected item's description under the rows
390        let docs = pop.shown.get(pop.sel).map(|&i| wrap(&pop.items[i].doc, ((w - pad * 4) / (cap_line / 2).max(1)).max(20) as usize)).unwrap_or_default();
391        let doc_lines = if docs.is_empty() { 0 } else { docs.split('\n').count().min(4) as i32 };
392        let foot = if doc_lines > 0 { doc_lines * cap_line + pad * 2 + self.hair() } else { 0 };
393        let h = rows as i32 * rh + pad * 2 + foot;
394        let mut y = below;
395        if y + h > win[3] && above - h >= 0 {
396            y = above - h;
397        }
398        // (the labels start below the word)
399        let x = (x - pad * 2 - badge_w).min(win[2] - w - self.dp(4)).max(self.dp(4));
400        // the surface
401        let hair = self.hair();
402        let bg = self.color(T_FLYOUT) | 0xFF00_0000;
403        self.cmd_rrect(x, y, w, h, self.dp(4), hair, bg, self.color(T_FLYOUT_STROKE), self.color(T_FLYOUT_STROKE));
404        let ry0 = y + pad;
405        for (k, &i) in pop.shown[first..first + rows].iter().enumerate() {
406            let it = &pop.items[i];
407            let ry = ry0 + k as i32 * rh;
408            let chosen = first + k == pop.sel;
409            let fg = if chosen && t.sel_text != 0 { t.sel_text } else { t.fg };
410            if chosen {
411                self.draw_color(t.sel);
412                self.draw_rrect(x + pad, ry, w - pad * 2, rh, self.dp(3));
413            }
414            let (letter, bc) = badge(it.kind, t);
415            let bc = if chosen && t.sel_text != 0 { t.sel_text } else { bc };
416            let bs = (rh * 2 / 3).max(8);
417            let (bx, by) = (x + pad + (badge_w - bs) / 2, ry + (rh - bs) / 2);
418            self.draw_color(bc);
419            self.draw_frame(bx, by, bs, bs, self.dp(2), hair);
420            let lw = self.text.width(letter);
421            self.cmd_text(bx + (bs - lw) / 2, by + (bs - cap_line) / 2 + cap_base, letter.as_bytes(), cf, cs, bc, 0);
422            let lx = x + pad + badge_w + pad;
423            let ly = ry + (rh - f.lh) / 2 + f.base;
424            self.cmd_text(lx, ly, it.label.as_bytes(), f.font, f.size, fg, lchars * f.cw);
425            if !it.detail.is_empty() {
426                let dx = x + w - pad * 2 - dw;
427                let dc = if chosen && t.sel_text != 0 { t.sel_text } else { t.gutter_fg };
428                self.cmd_text(dx.max(lx + lchars * f.cw + self.dp(8)), ry + (rh - cap_line) / 2 + cap_base, it.detail.as_bytes(), cf, cs, dc, dw);
429            }
430        }
431        if doc_lines > 0 {
432            let fy = ry0 + rows as i32 * rh + pad;
433            self.draw_color(self.color(T_DIVIDER));
434            self.draw_rect(x + pad, fy, w - pad * 2, hair);
435            for (k, l) in docs.split('\n').take(4).enumerate() {
436                self.cmd_text(x + pad * 2, fy + pad + k as i32 * cap_line + cap_base, l.as_bytes(), cf, cs, t.fg, w - pad * 4);
437            }
438        }
439        // the pointer: a click inserts, the wheel scrolls
440        self.wp.ctx.last_rect = [x, y, w, h];
441        let ev = self.behave(id, 0);
442        let mut accepted = false;
443        if ev & EV_HOVER != 0 {
444            let wy = self.wp.ctx.wheel_y;
445            if wy != 0 && pop.shown.len() > POP_ROWS {
446                let d = if wy > 0 { -3 } else { 3 };
447                pop.top = (pop.top as isize + d).clamp(0, (pop.shown.len() - POP_ROWS) as isize) as usize;
448                self.wp.ctx.wheel_y = 0;
449                self.repass();
450            }
451        }
452        if ev & EV_ACT != 0 {
453            let my = self.wp.ctx.my;
454            let k = ((my - ry0) / rh.max(1)).clamp(0, rows as i32 - 1) as usize;
455            if my >= ry0 && my < ry0 + rows as i32 * rh {
456                pop.sel = first + k;
457                pop.accept(doc);
458                accepted = true;
459            }
460        }
461        // the records: the list and its visible rows
462        self.wp.ctx.last_rect = [x, y, w, h];
463        self.wp.ctx.last_state = 0;
464        self.sem(K_LIST, id, Some(tr("Vorschläge")), pop.sel as i32);
465        if !accepted {
466            for k in 0..rows {
467                let it = &pop.items[pop.shown[first + k]];
468                let label = if it.detail.is_empty() { it.label.clone() } else { format!("{} – {}", it.label, it.detail) };
469                self.wp.ctx.last_rect = [x + pad, ry0 + k as i32 * rh, w - pad * 2, rh];
470                self.wp.ctx.last_state = if first + k == pop.sel { SS_SELECTED as u32 } else { 0 };
471                self.sem(K_LISTITEM, 0, Some(&label), (first + k) as i32);
472            }
473            if doc_lines > 0 {
474                self.wp.ctx.last_rect = [x, ry0 + rows as i32 * rh, w, foot];
475                self.wp.ctx.last_state = 0;
476                self.sem(K_TEXT, 0, Some(&docs.replace('\n', " ")), 0);
477            }
478        }
479        self.ca_layer_end(prev, clip, lr, ls);
480        accepted
481    }
482
483    /// (code_edit.rs) signature help above the caret line (top: the line's top, foot: its
484    /// foot for below), x near the caret
485    pub(crate) fn ca_signature_popup(&mut self, sig: &SigHelp, x: i32, top: i32, foot: i32, f: &PopFont, t: &CodeTheme) {
486        let lr = self.wp.ctx.last_rect;
487        let (win, prev, clip, ls) = self.ca_layer_begin();
488        let pad = self.dp(6);
489        self.ts_font(TS_CAPTION);
490        let (cf, cs) = self.text.get_font();
491        let cap_line = self.ts_line(TS_CAPTION);
492        let cap_base = self.ts_base(TS_CAPTION);
493        let chars = sig.label.chars().count() as i32;
494        let doc = wrap(&sig.doc, 70);
495        let doc_lines = if doc.is_empty() { 0 } else { doc.split('\n').count().min(3) as i32 };
496        let mut dw = 0;
497        for l in doc.split('\n').take(3) {
498            dw = dw.max(self.text.width(l));
499        }
500        let w = (chars * f.cw).max(dw) + pad * 2;
501        let w = w.min(win[2] - self.dp(8));
502        let h = f.lh + pad * 2 + doc_lines * cap_line;
503        let mut y = top - h - self.dp(2);
504        if y < 0 {
505            y = foot + self.dp(2);
506        }
507        let x = x.min(win[2] - w - self.dp(4)).max(self.dp(4));
508        let hair = self.hair();
509        let bg = self.color(T_FLYOUT) | 0xFF00_0000;
510        self.cmd_rrect(x, y, w, h, self.dp(4), hair, bg, self.color(T_FLYOUT_STROKE), self.color(T_FLYOUT_STROKE));
511        // the label, the active parameter bold and underlined
512        let lx = x + pad;
513        let ly = y + pad + f.base;
514        self.cmd_text(lx, ly, sig.label.as_bytes(), f.font, f.size, t.fg, w - pad * 2);
515        let mut said = String::new();
516        if let Some(&(a, b)) = sig.params.get(sig.active) {
517            if b <= sig.label.len() && a < b {
518                let c0 = sig.label[..a].chars().count() as i32;
519                let c1 = sig.label[..b].chars().count() as i32;
520                let px = lx + c0 * f.cw;
521                let part = &sig.label[a..b];
522                let c = t.tokens[SY_FUNC as usize];
523                self.cmd_text(px, ly, part.as_bytes(), f.font, f.size, c, 0);
524                self.cmd_text(px + (f.size >> 8) / 16 + 1, ly, part.as_bytes(), f.font, f.size, c, 0);
525                self.draw_color(c);
526                self.draw_rect(px, ly + self.dp(2), (c1 - c0) * f.cw, hair.max(self.dp(1)));
527                said = String::from(part);
528            }
529        }
530        for (k, l) in doc.split('\n').take(3).enumerate() {
531            self.cmd_text(lx, y + pad + f.lh + k as i32 * cap_line + cap_base, l.as_bytes(), cf, cs, t.gutter_fg, w - pad * 2);
532        }
533        self.wp.ctx.last_rect = [x, y, w, h];
534        self.wp.ctx.last_state = 0;
535        let mut text = sig.label.clone();
536        if !said.is_empty() {
537            text.push_str(" – ");
538            text.push_str(&said);
539        }
540        if !doc.is_empty() {
541            text.push_str(" – ");
542            text.push_str(&doc.replace('\n', " "));
543        }
544        self.sem(K_TEXT, 0, Some(&text), 0);
545        self.ca_layer_end(prev, clip, lr, ls);
546    }
547}