authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 05:43:13-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 05:43:14-07:00
log11bf37c8a7d470d535338524dbe2c81bbcff29bb
tree627101d55e619a14342bf7f7ea74cd1a2d4190c0
parent71e4e308b60bf1a52ad839646150a0f9e324ecee
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat(ios): iterative select all

Select All widens from the paragraph to its outline as OneNote does, driven from the same editor step as the desktop. Assisted-by: claude-opus-5.5

6 files changed, 82 insertions(+), 10 deletions(-)

apps/ios/Snowbound/CanvasView.swift+2-1
......@@ -606,7 +606,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
606606 }
607607
608608 override func selectAll(_ sender: Any?) {
609 edit(external: true) { choose(Range(0, endOfDocument.offset)) }
609 guard let handle else { return }
610 edit(external: true) { sb_select_more(handle) }
610611 }
611612
612613 override func toggleBoldface(_ sender: Any?) { apply(0) }
apps/ios/Snowbound/Script.swift+3-1
......@@ -9,7 +9,8 @@ extension CanvasView {
99 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.
1010 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
1111 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture; `done`
12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall`
13 /// is the Select All command; `done`
1314 /// ends editing, `tree` saves the view hierarchy to
1415 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
1516 /// screenshot command.
......@@ -54,6 +55,7 @@ extension CanvasView {
5455 let values = argument.split(separator: ",").compactMap { Double($0) }
5556 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
5657 case "done": _ = resignFirstResponder()
58 case "selectall": selectAll(nil)
5759 case "format": apply(UInt8(argument) ?? 0)
5860 case "find": _ = find(argument)
5961 case "title": _ = focusTitle()
crates/mobile/include/snowbound.h+1
......@@ -72,6 +72,7 @@ uint32_t sb_text_length(const View *view);
7272char *sb_text(const View *view, uint32_t start, uint32_t end);
7373void sb_selection(const View *view, uint32_t range[2]);
7474bool sb_select(View *view, uint32_t start, uint32_t end);
75bool sb_select_more(View *view);
7576bool sb_marked(const View *view, uint32_t range[2]);
7677bool sb_set_marked(View *view, const char *text, uint32_t selected_start, uint32_t selected_end);
7778void sb_unmark(View *view);
crates/mobile/src/lib.rs+19-3
......@@ -13,10 +13,10 @@ use library::Shared;
1313pub use library::{Library, Section, Share};
1414
1515use canvas::{
16 date::DateField,
1617 document::TextPosition,
1718 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},
1819 gpu::{Paper, Viewport, page::PageScene},
19 date::DateField,
2020 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors},
2121 layout::TextEngine,
2222};
......@@ -416,7 +416,8 @@ impl Canvas {
416416 /// Selects the first match of `query` on the page, as the search that found it matches.
417417 fn find(&mut self, query: &str) -> Result<bool> {
418418 let query = canvas::search::Query::new(query);
419 let Some(&(id, selection)) = canvas::search::page_matches(&self.page.editor, &query).first()
419 let Some(&(id, selection)) =
420 canvas::search::page_matches(&self.page.editor, &query).first()
420421 else {
421422 return Ok(false);
422423 };
......@@ -943,7 +944,9 @@ pub unsafe extern "C" fn sb_view_change_date(
943944 date: *const c_char,
944945 time: *const c_char,
945946) -> bool {
946 let result = view.canvas.change_date(seconds, [string(date), string(time)]);
947 let result = view
948 .canvas
949 .change_date(seconds, [string(date), string(time)]);
947950 view.stored(result)
948951}
949952
......@@ -977,6 +980,19 @@ pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool {
977980 report(view.canvas.select([start, end])).is_some()
978981}
979982
983/// Widens the selection as OneNote's Ctrl+A does, one enclosing unit per call.
984#[unsafe(no_mangle)]
985pub extern "C" fn sb_select_more(view: &mut View) -> bool {
986 report(
987 view.canvas
988 .page
989 .editor
990 .widen_selection()
991 .map_err(Into::into),
992 )
993 .is_some()
994}
995
980996#[unsafe(no_mangle)]
981997pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool {
982998 report(view.canvas.marked())
crates/mobile/src/library.rs+12-1
......@@ -226,6 +226,15 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
226226 readable,
227227 });
228228 }
229 for entry in folder.unavailable.iter().filter(|entry| !entry.group) {
230 tabs.push(Tab {
231 name: stem(&entry.path),
232 path: entry.path.clone(),
233 group: folder.path.clone(),
234 color: rgb(SECTION_COLOR),
235 readable: false,
236 });
237 }
229238 for group in &folder.groups {
230239 if !group.path.ends_with("OneNote_RecycleBin") {
231240 self::tabs(group, tabs);
......@@ -891,7 +900,9 @@ pub unsafe extern "C" fn sb_library_new_section(
891900 let (folder, name) = (string(folder), string(name));
892901 let page = dated(&string(author), &string(date), &string(time));
893902 report(page.and_then(|page| {
894 library.with_notebook(true, |notebook| Ok(notebook.create_section(&folder, &name, &page)?))
903 library.with_notebook(true, |notebook| {
904 Ok(notebook.create_section(&folder, &name, &page)?)
905 })
895906 }))
896907 .map_or(std::ptr::null_mut(), owned)
897908}
crates/mobile/src/tests.rs+45-4
......@@ -197,6 +197,33 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {
197197 }
198198}
199199
200#[test]
201fn select_more_widens_from_the_caret_paragraph_to_the_outline() {
202 let (_directory, section) = features();
203 let mut canvas = canvas(&section, "Paragraph controls");
204 focus(&mut canvas, "Collapsed parent");
205 let shown = canvas.active().shown_text();
206 let units = |text: &str| text.encode_utf16().count() as u32;
207 let start = units(&shown[..shown.find("Collapsed parent").unwrap()]);
208 let line = units(
209 shown
210 .split('\n')
211 .find(|line| line.contains("Collapsed parent"))
212 .unwrap(),
213 );
214 canvas.select([start + 2; 2]).unwrap();
215 canvas.page.editor.widen_selection().unwrap();
216 let [lo, hi] = canvas.selection().unwrap();
217 assert!(
218 lo == start && [start + line, start + line + 1].contains(&hi),
219 "{lo}..{hi}"
220 );
221 for _ in 0..8 {
222 canvas.page.editor.widen_selection().unwrap();
223 }
224 assert_eq!(canvas.selection().unwrap(), [0, canvas.length()]);
225}
226
200227#[test]
201228fn undo_restores_typing() {
202229 let (_directory, section) = features();
......@@ -542,13 +569,24 @@ fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {
542569 let mut canvas = canvas(&section, "Paragraph controls");
543570 focus(&mut canvas, "Collapsed parent");
544571 canvas.insert("Coordinated ".into()).unwrap();
545 section.shared.apply(canvas.edit().unwrap().unwrap()).unwrap();
572 section
573 .shared
574 .apply(canvas.edit().unwrap().unwrap())
575 .unwrap();
546576 assert!(section.flush(Duration::from_secs(20)));
547577 let file = root.canonicalize().unwrap().join("Features.one");
548578 let file = file.to_string_lossy();
549579 let coordinated = COORDINATED.lock().unwrap();
550 assert!(coordinated.iter().any(|(path, write)| path == &*file && !write));
551 assert!(coordinated.iter().any(|(path, write)| path == &*file && *write));
580 assert!(
581 coordinated
582 .iter()
583 .any(|(path, write)| path == &*file && !write)
584 );
585 assert!(
586 coordinated
587 .iter()
588 .any(|(path, write)| path == &*file && *write)
589 );
552590}
553591
554592#[test]
......@@ -572,7 +610,10 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
572610 // 2 January 2026, 10:30 UTC.
573611 assert!(
574612 canvas
575 .change_date(1_767_349_800, ["Friday, January 2, 2026".into(), "10:30 AM".into()])
613 .change_date(
614 1_767_349_800,
615 ["Friday, January 2, 2026".into(), "10:30 AM".into()]
616 )
576617 .unwrap()
577618 );
578619 let edit = canvas.edit().unwrap().unwrap();