mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 04:56:58 -06:00
egui037-20261022: migrate DeckCraft to egui 0.37
Preserve current-main document and PDF behavior alongside the reviewed input, capture, font, ribbon and notice-packaging migration. Validation: cargo xtask ci passed all eight stages, with 1,877 workspace tests and three doctests passed, six tests ignored, strict native lint and 23 WASM crates checked. Native GUI, platform and performance qualification remains separate.
This commit is contained in:
@@ -1761,6 +1761,39 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
|
||||
if let Some(t) = subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
|
||||
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
|
||||
@@ -1772,33 +1805,8 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
if let Some(t) = &subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
c.raw = None;
|
||||
}
|
||||
|
||||
@@ -539,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.sections.is_empty() {
|
||||
// The slides before `at` go into a "Default Section".
|
||||
if at > 0 {
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
}
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
} else {
|
||||
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
|
||||
let si = doc.section_of(start).unwrap_or(0);
|
||||
|
||||
@@ -10,7 +10,7 @@ use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{DocState, Result, Selection, Session, Target, TextSel};
|
||||
use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -66,6 +66,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
// An input method asks for text around the caret to be removed (macOS press-and-hold,
|
||||
// dictation, Wayland text-input). Internal: the keyboard and control protocol keep the
|
||||
// existing text.delete spelling. `bytes` says the counts are UTF-8 bytes (winit's
|
||||
// `Ime::DeleteSurrounding`), otherwise characters, both in the logical text
|
||||
// `ime_surrounding` publishes: a paragraph mark or soft line break is one `\n` byte, so a
|
||||
// range may cross paragraphs. The selection and pre-edit are kept; byte ranges round
|
||||
// inward to whole scalars. A deletion while composing stays in the composition's single
|
||||
// undo step.
|
||||
//
|
||||
// `undoable: false` is deliberate even though a non-composing deletion is undoable: the
|
||||
// handler pushes its own history entry from a snapshot taken before the composition is
|
||||
// replaced, so the generic post-run undo hook must not add a duplicate. `journal` stays
|
||||
// off for the same reason an input-method edit is not a replayable document command.
|
||||
CommandSpec {
|
||||
id: "text.deleteSurrounding",
|
||||
label: "Delete Surrounding Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{before, after, bytes?: bool}",
|
||||
enabled: has_text,
|
||||
run: delete_surrounding,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
@@ -111,6 +135,235 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
|
||||
Some(sh.text.get_or_insert_with(TextBody::default))
|
||||
}
|
||||
|
||||
/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query.
|
||||
///
|
||||
/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the
|
||||
/// engine answer without copying a whole body or document.
|
||||
pub const IME_SURROUNDING_MAX_BYTES: usize = 4000;
|
||||
|
||||
/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8
|
||||
/// byte offsets. The current input-method composition (pre-edit) is removed from both the text
|
||||
/// and the offsets.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeSurrounding {
|
||||
pub text: String,
|
||||
pub cursor: usize,
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// How a soft line break reads in the IME view: a newline, like a paragraph break.
|
||||
///
|
||||
/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would
|
||||
/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines
|
||||
/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change.
|
||||
fn ime_char(ch: char) -> char {
|
||||
if ch == '\u{b}' { '\n' } else { ch }
|
||||
}
|
||||
|
||||
/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`.
|
||||
fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
p.runs
|
||||
.iter()
|
||||
.flat_map(|run| run.content().chars())
|
||||
.enumerate()
|
||||
.filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))))
|
||||
}
|
||||
|
||||
/// The same characters, newest first, without walking the paragraph from its start.
|
||||
fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
let mut starts = Vec::with_capacity(p.runs.len());
|
||||
let mut at = 0usize;
|
||||
for r in &p.runs {
|
||||
let n = r.char_len();
|
||||
starts.push((at, n));
|
||||
at += n;
|
||||
}
|
||||
p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| {
|
||||
let content = r.content();
|
||||
content.chars().rev().enumerate().filter_map(move |(k, ch)| {
|
||||
let i = start + n - 1 - k;
|
||||
(!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The composition's `(paragraph, start, end)` character range, when it is inside the body.
|
||||
fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> {
|
||||
let c = c?;
|
||||
let para = body.paragraphs.get(c.start.0)?;
|
||||
let start = c.start.1.min(para.char_len());
|
||||
let end = start.saturating_add(c.len).min(para.char_len());
|
||||
(start < end).then_some((c.start.0, start, end))
|
||||
}
|
||||
|
||||
fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> {
|
||||
comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e))
|
||||
}
|
||||
|
||||
/// Clip a model position to the body. An empty body only has position `(0, 0)`.
|
||||
fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> {
|
||||
if body.paragraphs.is_empty() {
|
||||
return (pos == (0, 0)).then_some((0, 0));
|
||||
}
|
||||
body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len())))
|
||||
}
|
||||
|
||||
/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
|
||||
/// characters), stopping at the end of the body. Returns the position reached and the characters
|
||||
/// and bytes consumed.
|
||||
///
|
||||
/// The walk is what bounds the surrounding query: it never looks further than `limit`, so a
|
||||
/// megabyte body costs no more than a short one.
|
||||
fn walk_forward(
|
||||
body: &TextBody,
|
||||
comp: Option<(usize, usize, usize)>,
|
||||
from: (usize, usize),
|
||||
limit: usize,
|
||||
bytes: bool,
|
||||
) -> ((usize, usize), usize, usize) {
|
||||
let (mut pi, mut at) = from;
|
||||
let (mut chars, mut used) = (0usize, 0usize);
|
||||
// `limit` comes from a (possibly hostile) request: every count is checked, never added
|
||||
// blindly, so an enormous limit cannot overflow.
|
||||
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
|
||||
while let Some(para) = body.paragraphs.get(pi) {
|
||||
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
|
||||
if i < at {
|
||||
continue;
|
||||
}
|
||||
let n = ch.len_utf8();
|
||||
if !fits(used, chars, n) {
|
||||
return ((pi, i), chars, used);
|
||||
}
|
||||
used += n;
|
||||
chars += 1;
|
||||
at = i + 1;
|
||||
}
|
||||
if pi + 1 >= body.paragraphs.len() {
|
||||
break;
|
||||
}
|
||||
// The paragraph separator between this paragraph and the next.
|
||||
if !fits(used, chars, 1) {
|
||||
return ((pi, at), chars, used);
|
||||
}
|
||||
used += 1;
|
||||
chars += 1;
|
||||
pi += 1;
|
||||
at = 0;
|
||||
}
|
||||
((pi, at), chars, used)
|
||||
}
|
||||
|
||||
/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
|
||||
/// characters). Returns the position reached and the characters and bytes consumed.
|
||||
fn walk_backward(
|
||||
body: &TextBody,
|
||||
comp: Option<(usize, usize, usize)>,
|
||||
from: (usize, usize),
|
||||
limit: usize,
|
||||
bytes: bool,
|
||||
) -> ((usize, usize), usize, usize) {
|
||||
let (mut pi, mut at) = from;
|
||||
let (mut chars, mut used) = (0usize, 0usize);
|
||||
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
|
||||
loop {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) };
|
||||
let mut boundary = at.min(para.char_len());
|
||||
for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) {
|
||||
if i >= boundary {
|
||||
continue;
|
||||
}
|
||||
let n = ch.len_utf8();
|
||||
if !fits(used, chars, n) {
|
||||
return ((pi, boundary), chars, used);
|
||||
}
|
||||
used += n;
|
||||
chars += 1;
|
||||
boundary = i;
|
||||
}
|
||||
// This paragraph is consumed down to its start.
|
||||
if pi == 0 {
|
||||
return ((0, 0), chars, used);
|
||||
}
|
||||
if !fits(used, chars, 1) {
|
||||
return ((pi, 0), chars, used);
|
||||
}
|
||||
used += 1;
|
||||
chars += 1;
|
||||
pi -= 1;
|
||||
at = usize::MAX; // all of the previous paragraph is before us
|
||||
}
|
||||
}
|
||||
|
||||
/// The committed logical text between two model positions (excluding `end`). `None` when it is
|
||||
/// longer than `cap` bytes: every caller only needs a bounded answer.
|
||||
fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option<String> {
|
||||
let (a, b) = if start <= end { (start, end) } else { (end, start) };
|
||||
let mut out = String::new();
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return Some(out) };
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
let to = if pi == b.0 { b.1 } else { usize::MAX };
|
||||
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
|
||||
if i < from || i >= to {
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
if out.len() > cap {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if pi < b.0 {
|
||||
out.push('\n');
|
||||
if out.len() > cap {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Committed surrounding text for the text being edited.
|
||||
///
|
||||
/// Returns `None` when the body or selection is invalid, or when the selection alone is wider
|
||||
/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct
|
||||
/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection.
|
||||
///
|
||||
/// Only a bounded window is measured and copied: the query walks at most the budget out from the
|
||||
/// selection on each side and never visits the rest of the body.
|
||||
pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option<ImeSurrounding> {
|
||||
let body = body_of(st, t)?;
|
||||
let comp = composition_range(body, st.composition.as_ref());
|
||||
let caret = model_pos(body, t.caret)?;
|
||||
let anchor = model_pos(body, t.anchor)?;
|
||||
let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) };
|
||||
let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?;
|
||||
let selection_len = selected.len();
|
||||
let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len);
|
||||
let (_, _, before_available) = walk_backward(body, comp, start, budget, true);
|
||||
let (_, _, after_available) = walk_forward(body, comp, end, budget, true);
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let (window_start, _, _) = walk_backward(body, comp, start, before, true);
|
||||
let (window_end, _, _) = walk_forward(body, comp, end, after, true);
|
||||
let before_text = logical_between(body, comp, window_start, start, budget)?;
|
||||
let after_text = logical_between(body, comp, end, window_end, budget)?;
|
||||
let before_len = before_text.len();
|
||||
let mut text = before_text;
|
||||
text.push_str(&selected);
|
||||
text.push_str(&after_text);
|
||||
// The budget arithmetic above already bounds this; refuse rather than publish more.
|
||||
if text.len() > IME_SURROUNDING_MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
// The caret and anchor are the selection's own endpoints, so their offsets inside the
|
||||
// window are the window's start plus either end of the selected text.
|
||||
let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) };
|
||||
Some(ImeSurrounding { text, cursor, anchor })
|
||||
}
|
||||
|
||||
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
|
||||
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
|
||||
@@ -841,7 +1094,9 @@ fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
s.end_composition();
|
||||
// The text changed under the pre-edit: the app settled it, so the platform's copy
|
||||
// must be cancelled too.
|
||||
s.settle_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
@@ -1060,6 +1315,89 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A `before`/`after` count from a control or input-method request.
|
||||
///
|
||||
/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is
|
||||
/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that
|
||||
/// would delete different text than asked.
|
||||
fn count_param(p: &Value, name: &str) -> usize {
|
||||
p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
}
|
||||
|
||||
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = count_param(p, "before");
|
||||
let after = count_param(p, "after");
|
||||
let bytes = bool_or(p, "bytes", false);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?;
|
||||
let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?;
|
||||
let comp = composition_range(body, st.composition.as_ref());
|
||||
// The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a
|
||||
// paragraph separator and for a soft line break. A range may therefore cross paragraphs.
|
||||
let ordered = {
|
||||
let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
if a <= b { (a, b) } else { (b, a) }
|
||||
};
|
||||
// The selection (or the input method's pre-edit) stays exactly where it is. Only whole
|
||||
// scalars fully inside the requested before/after ranges around it are removed.
|
||||
let (start, end) = match comp {
|
||||
Some((pi, cs, ce)) => ((pi, cs), (pi, ce)),
|
||||
None => ordered,
|
||||
};
|
||||
let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes);
|
||||
let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes);
|
||||
let deleted = before_chars + after_chars;
|
||||
if deleted == 0 {
|
||||
return Ok(json!({"deleted": 0}));
|
||||
}
|
||||
let before_doc = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let composing = st.composition.is_some();
|
||||
// Where the deletion before `start` begins decides how the selection moves: within the start
|
||||
// paragraph its characters shift; from an earlier paragraph those paragraphs merge, so later
|
||||
// paragraph numbers shift too.
|
||||
let (from_para, from_char) = from;
|
||||
let remap = move |pos: (usize, usize)| -> (usize, usize) {
|
||||
if before_chars == 0 || pos < start {
|
||||
return pos;
|
||||
}
|
||||
if from_para == start.0 {
|
||||
// Only endpoints in the start paragraph move; later paragraphs keep their text.
|
||||
if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos }
|
||||
} else {
|
||||
(from_para + (pos.0 - start.0), if pos.0 == start.0 { from_char + (pos.1 - start.1) } else { pos.1 })
|
||||
}
|
||||
};
|
||||
edit_text(s, |body, t| {
|
||||
// Delete the after-range first, so the positions of the earlier range stay valid; then
|
||||
// the text before the selection.
|
||||
if after_chars > 0 {
|
||||
ed::delete(body, end, to);
|
||||
}
|
||||
if before_chars > 0 {
|
||||
ed::delete(body, from, start);
|
||||
}
|
||||
t.anchor = remap(t.anchor);
|
||||
t.caret = remap(t.caret);
|
||||
t.line_end = None;
|
||||
})?;
|
||||
if composing {
|
||||
if let Some(c) = s.doc_mut()?.composition.as_mut() {
|
||||
c.start = if before_chars == 0 {
|
||||
start
|
||||
} else if from_para == start.0 {
|
||||
(start.0, start.1 - before_chars)
|
||||
} else {
|
||||
from
|
||||
};
|
||||
}
|
||||
} else {
|
||||
push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel });
|
||||
}
|
||||
Ok(json!({"deleted": deleted}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
@@ -1475,6 +1813,207 @@ mod rtl_promote_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_surrounding_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn edit(s: &mut Session) -> u64 {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: u64) -> String {
|
||||
let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap());
|
||||
s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, anchor: usize, caret: usize) {
|
||||
s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap();
|
||||
}
|
||||
|
||||
/// DeleteSurrounding removes only the requested text around the selection. The selection
|
||||
/// itself and its direction stay, so the following commit replaces it.
|
||||
#[test]
|
||||
fn selected_text_survives_the_requested_characters_or_bytes_around_it() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀
|
||||
s.delete_surrounding(1, 1, false).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
|
||||
select(&mut s, 1, 3);
|
||||
s.delete_surrounding(1, 1, true).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
}
|
||||
|
||||
/// A byte range removes only whole scalars fully contained in it. A count that ends inside
|
||||
/// a character leaves that character whole (and undeleted), never splitting it.
|
||||
#[test]
|
||||
fn byte_counts_round_inward_to_whole_characters() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 2, 2); // after é: bytes 0..3
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar");
|
||||
s.delete_surrounding(2, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
|
||||
select(&mut s, 2, 2);
|
||||
s.delete_surrounding(0, 1, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar");
|
||||
s.delete_surrounding(0, 4, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji");
|
||||
}
|
||||
|
||||
/// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed
|
||||
/// selection, removes the model-stored pre-edit, and treats an empty body as available.
|
||||
#[test]
|
||||
fn surrounding_text_is_committed_logical_and_bounded() {
|
||||
use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding};
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8));
|
||||
|
||||
s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor");
|
||||
assert_eq!(q.text, "aé😀b");
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
s.compose_text("日本", None).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text");
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": ""})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available");
|
||||
|
||||
let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500);
|
||||
s.execute("text.set", &json!({"id": id, "text": long})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len());
|
||||
assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window");
|
||||
}
|
||||
|
||||
/// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may
|
||||
/// remove one and merge the paragraphs, exactly as the published offsets say.
|
||||
#[test]
|
||||
fn paragraph_marks_are_deletable_logical_text() {
|
||||
use super::ime_surrounding;
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap();
|
||||
// The caret at the start of the second paragraph: after "ab" and its paragraph mark.
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3));
|
||||
|
||||
// One byte before the caret is that paragraph mark.
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "ab\ncd");
|
||||
|
||||
// Characters count the same mark.
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
|
||||
s.delete_surrounding(1, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// And from the other side: one character after the end of the first paragraph.
|
||||
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap();
|
||||
s.delete_surrounding(0, 1, false).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd");
|
||||
}
|
||||
|
||||
/// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the
|
||||
/// platform must never see the model's `\u{b}`, which its own text filter would reject.
|
||||
#[test]
|
||||
fn soft_line_breaks_read_as_newlines() {
|
||||
use super::ime_surrounding;
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap();
|
||||
assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph");
|
||||
let st = s.doc().unwrap();
|
||||
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2));
|
||||
|
||||
// The break is one logical byte, so removing it removes one character.
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "ab");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "a\u{b}b");
|
||||
}
|
||||
|
||||
/// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a
|
||||
/// different number that would delete other text than the platform asked for.
|
||||
#[test]
|
||||
fn out_of_range_counts_clamp_instead_of_truncating() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 1, 1);
|
||||
s.delete_surrounding(usize::MAX, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic");
|
||||
let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX);
|
||||
assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected);
|
||||
assert!(expected > 0, "the count is clamped, not wrapped to nothing");
|
||||
assert_eq!(super::count_param(&json!({}), "before"), 0);
|
||||
}
|
||||
|
||||
/// A non-composing input-method deletion is exactly one undo step; the manual history entry
|
||||
/// must not be duplicated by the generic command wrapper.
|
||||
#[test]
|
||||
fn a_non_composing_ime_deletion_is_one_undo_step() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
select(&mut s, 3, 3);
|
||||
s.delete_surrounding(1, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "aéb");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replace_font_tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".into())
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 {
|
||||
}
|
||||
|
||||
fn parsed() -> &'static HashMap<&'static str, BezPath> {
|
||||
static CACHE: OnceLock<HashMap<&'static str, BezPath>> = OnceLock::new();
|
||||
static CACHE: OnceLock<HashMap<&str, BezPath>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for icon in ICONS {
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Session {
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
/// Returns `{handled: bool, …}`.
|
||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key_token(key);
|
||||
@@ -247,6 +247,16 @@ impl Session {
|
||||
self.execute("text.commit", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Remove text around the composition caret or selection, as an input method asks
|
||||
/// (`ImeEvent::DeleteSurrounding`/`DeleteSurroundingBytes`). Counts are characters or UTF-8
|
||||
/// bytes of the logical text the surrounding query publishes, so a range may cross a
|
||||
/// paragraph mark (read as one `\n` byte) and whole scalars only are removed; out-of-range
|
||||
/// counts are clamped. A deletion while composing stays inside the composition's single undo
|
||||
/// step.
|
||||
pub fn delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result<Value> {
|
||||
self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))
|
||||
}
|
||||
|
||||
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||
/// and nothing being edited).
|
||||
|
||||
@@ -482,6 +482,8 @@ pub struct Session {
|
||||
/// Presentations a host read off the UI thread, waiting for `file.adopt` (by token).
|
||||
loaded: Vec<(u64, cmd::file::LoadedFile)>,
|
||||
next_loaded: u64,
|
||||
/// Compositions the application settled itself (see [`Session::composition_settles`]).
|
||||
composition_settles: u64,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -513,6 +515,7 @@ impl Session {
|
||||
panics_caught: 0,
|
||||
loaded: vec![],
|
||||
next_loaded: 0,
|
||||
composition_settles: 0,
|
||||
}
|
||||
}
|
||||
/// Hand over a presentation read on another thread ([`cmd::file::read_presentation_file`]);
|
||||
@@ -590,7 +593,7 @@ impl Session {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||
@@ -660,6 +663,9 @@ impl Session {
|
||||
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
///
|
||||
/// This is also how the input method's own commit ends a composition: the platform has
|
||||
/// already finished that one, so it must not ask for an interrupt.
|
||||
pub fn end_composition(&mut self) {
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
@@ -668,6 +674,27 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle a composition because the application did something else (a click, a key, a
|
||||
/// command, a target change) rather than because the input method committed it.
|
||||
///
|
||||
/// The platform still holds its own copy of that composition, so a host must publish one
|
||||
/// `should_interrupt_composition` for each increment of [`Self::composition_settles`]: the
|
||||
/// backend then cancels it instead of committing its text into whatever is selected next.
|
||||
pub fn settle_composition(&mut self) {
|
||||
if self.active().is_some_and(|d| d.composition.is_some()) {
|
||||
self.composition_settles = self.composition_settles.wrapping_add(1);
|
||||
}
|
||||
self.end_composition();
|
||||
}
|
||||
|
||||
/// How many compositions the application settled itself (clicks, keys, other commands).
|
||||
///
|
||||
/// A host compares this with the value it last published; every increment is one platform
|
||||
/// composition that must be interrupted. The input method's own commits do not count.
|
||||
pub fn composition_settles(&self) -> u64 {
|
||||
self.composition_settles
|
||||
}
|
||||
|
||||
/// Change only the selection (no undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
@@ -683,7 +710,7 @@ impl Session {
|
||||
st.revision += 1;
|
||||
// A composition belongs to the text body it began in: leaving that body settles it.
|
||||
if st.composition.is_some() && owner(&st.selection) != before {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -152,13 +152,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
|
||||
@@ -3233,6 +3233,32 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
|
||||
use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef};
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
|
||||
// A file-defined weight needs a real regular/bold pair, independent of missing Inter
|
||||
// or whichever system face happened to load first. Leave bold unset for the table style.
|
||||
let fonts = deckcraft_fonts::FontDb::global();
|
||||
let regular = fonts.face("BIZ UDPGothic", "Regular");
|
||||
let bold = fonts.face("BIZ UDPGothic", "Bold");
|
||||
let metric_pair = regular.family == "BIZ UDPGothic"
|
||||
&& bold.family == "BIZ UDPGothic"
|
||||
&& regular.style == "Regular"
|
||||
&& bold.style == "Bold"
|
||||
&& regular.weight < bold.weight;
|
||||
assert!(metric_pair || option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required BIZ Regular/Bold pair is absent");
|
||||
if metric_pair {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
let deckcraft_model::ShapeKind::Table(table) = &mut slide.shape_mut(id).unwrap().kind else { panic!("not a table") };
|
||||
for cell in &mut table.rows[0].cells {
|
||||
for paragraph in &mut cell.text.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("BIZ UDPGothic".into());
|
||||
run.props.bold = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("optional BIZ pair absent: skipping weight-metric assertion; edit/resize checks still run");
|
||||
}
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let built_in = caret_point(&s, 0, 8);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
@@ -3257,7 +3283,9 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let p = caret_point(&s, 0, 8);
|
||||
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
|
||||
if metric_pair {
|
||||
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
|
||||
}
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
// A double-click selects the word drawn there.
|
||||
|
||||
@@ -451,7 +451,7 @@ impl Session {
|
||||
}
|
||||
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||
if !matches!(ev.kind, PointerKind::Move) {
|
||||
self.end_composition();
|
||||
self.settle_composition();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
|
||||
Reference in New Issue
Block a user