···168168 - Editor layout tightened: small-font baseline, 7 px line pitch, gutter=18 px with 1 px gap; first row starts immediately below the bar; removed extra paddings.
169169 - Fixed new-text baseline misalignment (was rendered 1 px too low); unified small-font rendering across normal/selected/caret glyphs.
170170 - Tests and docs updated; clippy/tests green.
171171+ - Keyboard shortcuts (Phase 1): implemented PageUp/PageDown (+Shift), Ctrl/Cmd+Home/End, and block indent/outdent (Tab/Shift+Tab with selection; Shift+Tab outdents current line). Added tests and a planning doc.
172172+ - Plan doc: `docs/roadmap/editor_shortcuts.md` with checkboxes (updated).
171173 - 2025-09-01:
172174 - Fixed editor selection drop shadow to avoid interior horizontal seams for multi-line selections.
173175 - Implemented per-row bottom shadow segmentation by subtracting next-row overlap; right-edge rule preserved.
+4-4
docs/roadmap/editor_shortcuts.md
···6677## Scope (Phase 1)
8899-- [ ] Page Up / Page Down (plain)
99+- [x] Page Up / Page Down (plain)
1010 - Move caret up/down by visible line count (viewport height / 7), clamped.
1111 - Maintain a virtual column (desired x) across ragged lines.
1212 - Ensure caret remains visible by adjusting `scroll_line`.
1313 - Tests: basic motion, clamping, viewport re-centering/visibility.
14141515-- [ ] Page Up / Page Down with Shift
1515+- [x] Page Up / Page Down with Shift
1616 - Same movement but extend selection from anchor.
1717 - If no active selection, set anchor at caret before moving.
1818 - Tests: selection spans exactly N rows; correct range regardless of direction.
19192020-- [ ] Ctrl+Home / Ctrl+End
2020+- [x] Ctrl+Home / Ctrl+End
2121 - Ctrl+Home → caret to (0,0); Ctrl+End → caret to (last line, `line_len(last)`).
2222 - Adjust viewport so caret is visible.
2323 - Tests: motion to start/end; clamping; viewport visibility.
24242525-- [ ] Block Indent / Outdent (Tab / Shift+Tab)
2525+- [x] Block Indent / Outdent (Tab / Shift+Tab)
2626 - When selection spans lines:
2727 - Tab → insert one leading space on each selected line.
2828 - Shift+Tab → remove one leading space when present.
+2
docs/specs/implementation_status.md
···8282- CODE view: rope‑backed buffer; editable; gutter; selection highlight; TIC‑style caret (cursor color index 2 + shadow); 6 px small font on a 7 px pitch.
8383- Layout: gutter 18 px wide (3 digits), 1 px gap before code; first row starts directly under the toolbar; no extra top/bottom padding.
8484- Selection: per‑cell 7×7 shadow+fill; outer-perimeter shadow only; no inter‑line seams.
8585+- Navigation shortcuts: PageUp/PageDown (with Shift), Ctrl/Cmd+Home/End.
8686+- Editing shortcuts: Block indent/outdent with Tab/Shift+Tab on multi-line selections; Shift+Tab outdents current line when no selection.
8587- Navigation: arrows, Home/End; auto‑scroll keeps caret visible.
8688- Editing: insert chars/newline/tab (1 space), backspace/delete with line joins at SOL/EOL.
8789- Selection/Clipboard: Shift+arrows; Select All (Cmd/Ctrl+A); copy/cut/paste via OS clipboard (Cmd/Ctrl+C/V/X).
+3
docs/testing/test_catalog.md
···7777- `tic80_rust/tests/editor_selection_undo_tests.rs`: selection replace/cut/paste and undo/redo cycles; select-all.
7878- `tic80_rust/tests/editor_selection_shadow_tests.rs`: multi-line selection renders without interior bottom seams; right-edge shadow is exactly 7 px tall.
7979- `tic80_rust/tests/editor_selection_align_tests.rs`: selection top aligns with caret box (small font baseline at 6 px, pitch 7 px).
8080+- `tic80_rust/tests/editor_page_nav_tests.rs`: PageUp/Down movement by visible lines; Shift variants extend selection.
8181+- `tic80_rust/tests/editor_ctrl_home_end_tests.rs`: Ctrl/Cmd+Home/End jump to document bounds.
8282+- `tic80_rust/tests/editor_indent_outdent_tests.rs`: Block indent/outdent adds/removes a leading space per selected line.
80838184## Screenshot Tests
8285- `tic80_rust/tests/screenshot_smoke.rs`
+98-7
tic80_rust/src/editor/code.rs
···1414 pub caret_col: usize,
1515 pub scroll_line: usize,
1616 pub scroll_col: usize,
1717+ // Desired horizontal column preserved across vertical/page moves
1818+ desired_col: Option<usize>,
1719 // Selection anchor as (line, col) if active
1820 sel_anchor: Option<(usize, usize)>,
1921 // Undo/redo stacks (each EditOp can be a batch of atomic edits)
···4446 caret_col: 0,
4547 scroll_line: 0,
4648 scroll_col: 0,
4949+ desired_col: None,
4750 sel_anchor: None,
4851 undo: Vec::new(),
4952 redo: Vec::new(),
···212215 }
213216 }
214217218218+ // Read-only access to rope for tests
219219+ #[must_use]
220220+ #[allow(clippy::missing_const_for_fn)]
221221+ pub fn rope(&self) -> &Rope { &self.rope }
222222+215223 pub fn move_left(&mut self) {
216224 self.reset_caret_blink();
217225 if self.caret_col > 0 {
···220228 self.caret_line -= 1;
221229 self.caret_col = self.line_len(self.caret_line);
222230 }
231231+ self.desired_col = Some(self.caret_col);
223232 }
224233225234 pub fn move_right(&mut self) {
···231240 self.caret_line += 1;
232241 self.caret_col = 0;
233242 }
243243+ self.desired_col = Some(self.caret_col);
234244 }
235245236246 pub fn move_up(&mut self) {
237247 self.reset_caret_blink();
248248+ let want = self.desired_col.unwrap_or(self.caret_col);
238249 if self.caret_line > 0 {
239250 self.caret_line -= 1;
240240- let len = self.line_len(self.caret_line);
241241- if self.caret_col > len {
242242- self.caret_col = len;
243243- }
251251+ self.caret_col = self.line_len(self.caret_line).min(want);
244252 }
253253+ self.desired_col = Some(want);
245254 }
246255247256 pub fn move_down(&mut self) {
248257 self.reset_caret_blink();
258258+ let want = self.desired_col.unwrap_or(self.caret_col);
249259 if self.caret_line + 1 < self.line_count() {
250260 self.caret_line += 1;
251251- let len = self.line_len(self.caret_line);
252252- if self.caret_col > len {
253253- self.caret_col = len;
261261+ self.caret_col = self.line_len(self.caret_line).min(want);
262262+ }
263263+ self.desired_col = Some(want);
264264+ }
265265+266266+ // Page motion by visible line count
267267+ pub fn page_down(&mut self, vis: usize) {
268268+ self.reset_caret_blink();
269269+ let want = self.desired_col.unwrap_or(self.caret_col);
270270+ let lc = self.line_count();
271271+ if lc == 0 { return; }
272272+ let delta = vis.min(lc.saturating_sub(1) - self.caret_line);
273273+ self.caret_line += delta;
274274+ self.caret_col = self.line_len(self.caret_line).min(want);
275275+ self.desired_col = Some(want);
276276+ }
277277+ pub fn page_up(&mut self, vis: usize) {
278278+ self.reset_caret_blink();
279279+ let want = self.desired_col.unwrap_or(self.caret_col);
280280+ let delta = vis.min(self.caret_line);
281281+ self.caret_line -= delta;
282282+ self.caret_col = self.line_len(self.caret_line).min(want);
283283+ self.desired_col = Some(want);
284284+ }
285285+286286+ pub fn doc_home(&mut self) {
287287+ self.reset_caret_blink();
288288+ self.caret_line = 0;
289289+ self.caret_col = 0;
290290+ self.desired_col = Some(0);
291291+ }
292292+ pub fn doc_end(&mut self) {
293293+ self.reset_caret_blink();
294294+ if self.line_count() == 0 { self.caret_line = 0; self.caret_col = 0; return; }
295295+ self.caret_line = self.line_count().saturating_sub(1);
296296+ self.caret_col = self.line_len(self.caret_line);
297297+ self.desired_col = Some(self.caret_col);
298298+ }
299299+300300+ // Block indentation: selection required for indent; outdent also supports single line when no selection
301301+ pub fn block_indent(&mut self) {
302302+ if let Some((s, e)) = self.selection_range_idx() {
303303+ let mut op = EditOp { ops: Vec::new() };
304304+ let start_line = self.rope.char_to_line(s);
305305+ let end_line = if e > 0 { self.rope.char_to_line(e - 1) } else { self.rope.char_to_line(e) };
306306+ for l in start_line..=end_line {
307307+ let idx = self.rope.line_to_char(l);
308308+ self.rope.insert(idx, " ");
309309+ op.ops.push(EditKind::Insert { index: idx, text: " ".to_string() });
310310+ if self.caret_line == l { self.caret_col = self.caret_col.saturating_add(1); }
311311+ if let Some((al, ac)) = self.sel_anchor { if al == l { self.sel_anchor = Some((al, ac.saturating_add(1))); } }
312312+ }
313313+ self.undo.push(op);
314314+ self.clear_redo();
315315+ }
316316+ }
317317+ pub fn block_outdent(&mut self) {
318318+ if let Some((s, e)) = self.selection_range_idx() {
319319+ let mut op = EditOp { ops: Vec::new() };
320320+ let start_line = self.rope.char_to_line(s);
321321+ let end_line = if e > 0 { self.rope.char_to_line(e - 1) } else { self.rope.char_to_line(e) };
322322+ for l in start_line..=end_line {
323323+ let idx = self.rope.line_to_char(l);
324324+ // Remove leading space if any
325325+ if self.rope.line(l).chars().next() == Some(' ') {
326326+ self.rope.remove(idx..=idx);
327327+ op.ops.push(EditKind::Delete { index: idx, text: " ".to_string() });
328328+ if self.caret_line == l { self.caret_col = self.caret_col.saturating_sub(1); }
329329+ if let Some((al, ac)) = self.sel_anchor { if al == l { self.sel_anchor = Some((al, ac.saturating_sub(1))); } }
330330+ }
331331+ }
332332+ self.undo.push(op);
333333+ self.clear_redo();
334334+ } else {
335335+ // No selection: outdent current line if possible
336336+ let l = self.caret_line;
337337+ let idx = self.rope.line_to_char(l);
338338+ if self.rope.line(l).chars().next() == Some(' ') {
339339+ let mut op = EditOp { ops: Vec::new() };
340340+ self.rope.remove(idx..=idx);
341341+ op.ops.push(EditKind::Delete { index: idx, text: " ".to_string() });
342342+ self.undo.push(op);
343343+ self.caret_col = self.caret_col.saturating_sub(1);
344344+ self.clear_redo();
254345 }
255346 }
256347 }