···257257 }
258258 }
259259260260- #[allow(clippy::cast_possible_truncation)]
260260+ #[allow(clippy::cast_possible_truncation, clippy::too_many_lines)]
261261 pub fn draw(&mut self, fb: &mut crate::gfx::framebuffer::Framebuffer, area: Area) {
262262 let gutter_w = 24i32;
263263- let lines_vis = (area.h / 8).max(1) as usize;
263263+ // Match TIC-80 editor line pitch: 7px (TIC_FONT_HEIGHT + 1)
264264+ let line_pitch = 7i32;
265265+ let lines_vis = (area.h / line_pitch).max(1) as usize;
264266 let cols_vis = ((area.w - gutter_w) / 6).max(1) as usize;
265267 self.ensure_visible(lines_vis, cols_vis);
266268···274276 break;
275277 }
276278 // Gutter (1-based line numbers)
277277- let gutter_y = area.y + i32::try_from(i).unwrap_or(0) * 8;
279279+ let gutter_y = area.y + i32::try_from(i).unwrap_or(0) * line_pitch;
278280 let ln = line_idx + 1;
279281 let label = format!("{ln:>3}");
280282 let _ = fb.print_text(&label, area.x + 2, gutter_y, 6, true, 1, false);
···287289 let start = self.scroll_col.min(line.chars().count());
288290 let mut iter = line.chars().skip(start);
289291 let vis: String = iter.by_ref().take(cols_vis).collect();
290290- // Selection highlight for this line (with TIC-80-style drop shadow)
291291- if let Some((sel_start, sel_end)) = self.selection_range_idx() {
292292- // Compute selection coverage in columns for this visible segment
293293- let line_char_start = self.rope.line_to_char(line_idx);
294294- let line_char_end = line_char_start + self.line_len(line_idx);
295295- let s = sel_start.max(line_char_start);
296296- let e = sel_end.min(line_char_end);
297297- if e > s {
298298- let a = (s - line_char_start) as i32;
299299- let b = (e - line_char_start) as i32;
300300- let a_vis = (a - self.scroll_col as i32).max(0);
301301- let b_vis = (b - self.scroll_col as i32).max(0);
302302- let from = a_vis.min(cols_vis as i32).max(0);
303303- let to = b_vis.min(cols_vis as i32).max(from);
304304- if to > from {
305305- let sel_x = area.x + gutter_w + from * 6;
306306- let y_top = (gutter_y - 1).max(area.y);
307307- let sel_w = (to - from) * 6;
308308- // Fill (7px tall), like caret box
309309- fb.rect(sel_x, y_top, sel_w, 7, 14);
310310- // Decide whether to draw the right shadow for this row segment.
311311- // Only draw if the next line's selection doesn't extend as far right (outer perimeter).
312312- let mut draw_right_shadow = true;
313313- if line_idx + 1 < self.line_count() {
314314- let next_line_char_start = self.rope.line_to_char(line_idx + 1);
315315- let next_line_char_end = next_line_char_start + self.line_len(line_idx + 1);
316316- // Next line selection coverage
317317- let ns = sel_start.max(next_line_char_start);
318318- let ne = sel_end.min(next_line_char_end);
319319- if ne > ns {
320320- let na = (ns.saturating_sub(next_line_char_start)) as i32;
321321- let nb = (ne.saturating_sub(next_line_char_start)) as i32;
322322- let na_vis = (na - self.scroll_col as i32).max(0);
323323- let nb_vis = (nb - self.scroll_col as i32).max(0);
324324- let nfrom = na_vis.min(cols_vis as i32).max(0);
325325- let nto = nb_vis.min(cols_vis as i32).max(nfrom);
326326- // If next line's right edge is strictly greater than this line's,
327327- // skip right shadow here (it's interior to the overall blob).
328328- // Equal width should draw to produce a continuous vertical edge.
329329- if nto > to {
330330- draw_right_shadow = false;
331331- }
332332- }
333333- }
334334- if draw_right_shadow {
335335- // Right edge: start at the same top as fill and span 8px so adjacent rows abut exactly
336336- fb.rect(sel_x + sel_w, y_top, 1, 8, 0);
337337- }
338338- // Only draw bottom shadow if selection does not continue to next line
339339- let continues_down = sel_end >= line_char_end;
340340- if !continues_down {
341341- fb.rect(sel_x, y_top + 7, sel_w, 1, 0);
342342- }
343343- }
292292+ // Draw characters cell-by-cell, applying selection overlays where needed
293293+ let line_char_start = self.rope.line_to_char(line_idx);
294294+ let sel = self.selection_range_idx();
295295+ for (i_vis, ch) in vis.chars().enumerate() {
296296+ let cell_x = area.x + gutter_w + i32::try_from(i_vis).unwrap_or(0) * 6;
297297+ let cell_y = gutter_y;
298298+ let global_idx = line_char_start + start + i_vis;
299299+ let selected = sel.is_some_and(|(s, e)| global_idx >= s && global_idx < e);
300300+ if selected {
301301+ // Shadow and fill per TIC-80
302302+ fb.rect(cell_x, cell_y, 7, 7, 0);
303303+ fb.rect(cell_x - 1, cell_y - 1, 7, 7, 14);
304304+ // Dark glyph on top
305305+ let s = ch.to_string();
306306+ let _ = fb.print_text(&s, cell_x, cell_y, 5, true, 1, false);
307307+ } else {
308308+ // Normal glyph (no selection overlay)
309309+ let s = ch.to_string();
310310+ let _ = fb.print_text(&s, cell_x, cell_y, 12, true, 1, false);
344311 }
345312 }
346346- // Monospace rendering for alignment (fixed=true)
347347- let _ = fb.print_text(&vis, area.x + gutter_w, gutter_y, 12, true, 1, false);
348313 }
349314350315 // Caret (red box aligned to 6x8 cell, with 1px drop shadow; underlying glyph drawn dark)
···352317 let row = i32::try_from(self.caret_line - self.scroll_line).unwrap_or(0);
353318 let col = i32::try_from(self.caret_col.saturating_sub(self.scroll_col)).unwrap_or(0);
354319 let cell_x = area.x + gutter_w + col * 6;
355355- let cell_y = area.y + row * 8;
356356- // Box aligned to cell: width 6, height 7 (reserve 1px bottom for shadow)
357357- let fill_x = cell_x;
358358- let fill_y = (cell_y - 1).max(area.y);
359359- let fill_w = 6;
360360- let fill_h = 7;
361361- // Fill: palette 8 (red)
362362- fb.rect(fill_x, fill_y, fill_w, fill_h, 8);
363363- // Shadow (palette 0) to the right and along bottom
364364- fb.rect(fill_x + fill_w, fill_y, 1, fill_h, 0);
365365- fb.rect(fill_x, fill_y + fill_h, fill_w, 1, 0);
320320+ let cell_y = area.y + row * line_pitch;
321321+ // TIC-80 caret style: drop shadow rect (black) then caret rect (red), both 7x7, offset by 1px
322322+ fb.rect(cell_x, cell_y, 7, 7, 0);
323323+ fb.rect(cell_x - 1, cell_y - 1, 7, 7, 8);
366324367325 // Draw the underlying glyph in dark color to simulate inversion
368326 let line_idx = self.caret_line;
···377335 if idx < total {
378336 let ch = full.chars().nth(idx).unwrap_or(' ');
379337 let s = ch.to_string();
380380- // Render in color 0 (dark) monospaced aligned to cell; this simulates inversion
381381- let _ = fb.print_text(&s, cell_x, cell_y, 0, true, 1, false);
338338+ // Render in dark grey monospaced aligned to cell; this simulates inversion
339339+ let _ = fb.print_text(&s, cell_x, cell_y, 5, true, 1, false);
382340 }
383341 }
384342 }
+26-2
tic80_rust/src/main.rs
···33333434use pixels::{Pixels, SurfaceTexture};
3535use winit::dpi::LogicalSize;
3636-use winit::event::{ElementState, Event, KeyboardInput, ModifiersState, VirtualKeyCode, WindowEvent};
3636+use winit::event::{
3737+ ElementState, Event, KeyboardInput, ModifiersState, VirtualKeyCode, WindowEvent,
3838+};
3739use winit::event_loop::{ControlFlow, EventLoop};
3840use winit::window::{Window, WindowBuilder};
3941···8587 quiet: bool,
8688 help: bool,
8789 editor: bool,
9090+ // Editor helpers (headless diagnostics)
9191+ editor_demo_select: bool,
8892 // Screenshot/headless
8993 headless: bool,
9094 screenshot_path: Option<PathBuf>,
···105109 quiet: false,
106110 help: false,
107111 editor: false,
112112+ editor_demo_select: false,
108113 headless: false,
109114 screenshot_path: None,
110115 screenshot_scale: 1,
···120125 "--debug-fx" => out.debug_fx = true,
121126 "--quiet" => out.quiet = true,
122127 "--editor" => out.editor = true,
128128+ "--editor-demo-select" => out.editor_demo_select = true,
123129 "--headless" => out.headless = true,
124130 "--screenshot" => {
125131 if let Some(val) = args_iter.next() {
···284290 },
285291 );
286292 println!(
287287- "Usage: {prog} [OPTIONS] [CART.lua]\n\nOptions:\n -h, --help Show this help message and exit\n --quiet Suppress once-only warnings and Lua BOOT()/TIC() error prints\n --editor Launch the editor UI (CODE/CONSOLE)\n --headless Run offscreen without opening a window (for screenshots/CI)\n --screenshot <PATH> Save a screenshot and exit (first frame by default)\n --screenshot-scale <N> Integer scale for screenshot (default 1)\n --screenshot-frame <N> Capture after N frames (windowed/headless)\n --list-audio List input audio devices and exit\n --audio-device <SUBSTR> Select input device by substring match (case-insensitive)\n --audio-disable Disable audio capture and analysis\n --audio-vu Print VU peak dBFS once per second\n --debug-fft Print first 16 FFT bins (smoothed, normalized) ~every 500 ms\n --debug-fx Print per-second FX timings plus ring stats (dp/ovf/underrun/consumed, EMA samples/tick, occupancy)\n\nArguments:\n CART.lua Optional path to a Lua cart; defaults to bundled demo when omitted\n\nNotes:\n- Window: fixed 240x136 internal resolution with integer scaling in a desktop window.\n- Audio: selects nearest supported sample rate to 44100 Hz and logs the choice.\n- Ring stats (with --debug-fx): dp=pushed, ovf=overflows, underrun=no-data, consumed=samples, EMA samples/tick, occupancy.\n\nExamples:\n {prog} --screenshot out.png --screenshot-scale 3\n {prog} --headless --screenshot out.png --screenshot-frame 60\n {prog} --editor\n ");
293293+ "Usage: {prog} [OPTIONS] [CART.lua]\n\nOptions:\n -h, --help Show this help message and exit\n --quiet Suppress once-only warnings and Lua BOOT()/TIC() error prints\n --editor Launch the editor UI (CODE/CONSOLE)\n --editor-demo-select In headless editor mode, create a 3-line demo selection before drawing\n --headless Run offscreen without opening a window (for screenshots/CI)\n --screenshot <PATH> Save a screenshot and exit (first frame by default)\n --screenshot-scale <N> Integer scale for screenshot (default 1)\n --screenshot-frame <N> Capture after N frames (windowed/headless)\n --list-audio List input audio devices and exit\n --audio-device <SUBSTR> Select input device by substring match (case-insensitive)\n --audio-disable Disable audio capture and analysis\n --audio-vu Print VU peak dBFS once per second\n --debug-fft Print first 16 FFT bins (smoothed, normalized) ~every 500 ms\n --debug-fx Print per-second FX timings plus ring stats (dp/ovf/underrun/consumed, EMA samples/tick, occupancy)\n\nArguments:\n CART.lua Optional path to a Lua cart; defaults to bundled demo when omitted\n\nNotes:\n- Window: fixed 240x136 internal resolution with integer scaling in a desktop window.\n- Audio: selects nearest supported sample rate to 44100 Hz and logs the choice.\n- Ring stats (with --debug-fx): dp=pushed, ovf=overflows, underrun=no-data, consumed=samples, EMA samples/tick, occupancy.\n\nExamples:\n {prog} --screenshot out.png --screenshot-scale 3\n {prog} --headless --screenshot out.png --screenshot-frame 60\n {prog} --editor\n ");
288294}
289295290296fn load_script(script_path: Option<&PathBuf>) -> String {
···701707 let initial = load_script(args.script_path.as_ref());
702708 let ui = EditorUi::new(1.0);
703709 let mut code = CodeBuffer::from_text(&initial);
710710+ if args.editor_demo_select {
711711+ apply_demo_selection(&mut code);
712712+ }
704713 {
705714 let mut fbb = fb.borrow_mut();
706715 ui.draw(&mut fbb);
···727736 fb.borrow().blit_to_rgba(&mut rgba);
728737 save_screenshot_from_rgba(args, &rgba)?;
729738 Ok(())
739739+}
740740+741741+// Create a 3-line demo selection with ragged edges to inspect drop shadows.
742742+fn apply_demo_selection(cb: &mut CodeBuffer) {
743743+ // Choose lines 6..=8 (0-based) which are likely to exist in default carts.
744744+ let l0 = 6usize;
745745+ let l2 = 8usize;
746746+ // Start mid-line, end a few chars into line 8 to create overhangs.
747747+ let c0 = (cb.line_len(l0) / 2).max(2);
748748+ let c2 = cb.line_len(l2).min(8);
749749+ cb.caret_line = l0;
750750+ cb.caret_col = c0;
751751+ cb.start_selection();
752752+ cb.caret_line = l2;
753753+ cb.caret_col = c2;
730754}
731755732756// -- Clipboard helpers -------------------------------------------------------------------------
+8-3
tic80_rust/tests/editor_selection_align_tests.rs
···1414 cb.caret_col = 1;
1515 cb.start_selection();
1616 cb.caret_col = 3; // selection [1,3)
1717- // Also place caret for box at col 3
1717+ // Also place caret for box at col 3
1818 let fb = Rc::new(RefCell::new(Framebuffer::new()));
1919 let mut fbb = fb.borrow_mut();
2020- let area = Area { x: 0, y: 12, w: 240, h: 124 };
2020+ let area = Area {
2121+ x: 0,
2222+ y: 12,
2323+ w: 240,
2424+ h: 124,
2525+ };
2126 cb.draw(&mut fbb, area);
22272328 // Expected coordinates
···2530 let row = 0i32; // first line
2631 let gutter_y = area.y + row * 8;
2732 let caret_fill_top = (gutter_y - 1).max(area.y); // caret fills 7px starting 1px above baseline, clipped to area
2828- // Selection starts at col 1 (from) over a space (no glyph ink)
3333+ // Selection starts at col 1 (from) over a space (no glyph ink)
2934 let sel_x = area.x + gutter_w + 6;
3035 let sel_y = caret_fill_top;
3136
+89
tic80_rust/tests/editor_selection_shadow_tests.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::editor::code::{Area, CodeBuffer};
55+use tic80_rust::gfx::framebuffer::Framebuffer;
66+77+// Helper to sample a pixel color from the framebuffer (palette index)
88+fn px(fb: &mut Framebuffer, x: i32, y: i32) -> u8 {
99+ fb.pix(x, y, None).unwrap_or(0)
1010+}
1111+1212+#[test]
1313+fn selection_no_seam_between_lines() {
1414+ // Three short lines; select across all so middle seam is exercised.
1515+ let text = "abc\nabc\nabc\n";
1616+ let mut cb = CodeBuffer::from_text(text);
1717+1818+ // Create selection from start of first line to middle of last line
1919+ cb.caret_line = 0;
2020+ cb.caret_col = 0;
2121+ cb.start_selection();
2222+ cb.caret_line = 2;
2323+ cb.caret_col = 2; // up to 'b'
2424+2525+ let fb_rc = Rc::new(RefCell::new(Framebuffer::new()));
2626+ let mut fb = fb_rc.borrow_mut();
2727+ let area = Area {
2828+ x: 0,
2929+ y: 12,
3030+ w: 240,
3131+ h: 40,
3232+ };
3333+ cb.draw(&mut fb, area);
3434+3535+ // Pick column 1 (the 'b') well inside selection run
3636+ let gutter_w = 24i32;
3737+ let col_x = gutter_w + 6 + 2; // inside col #1
3838+3939+ // With TIC-80 logic there should be NO black seam between consecutive lines.
4040+ // Our line pitch should be 7; seam of row0 is at y0 + 6 and is covered by row1 fill.
4141+ let row0_y = area.y;
4242+ let seam_y = row0_y + 6; // seam between row0 and row1
4343+ let c = px(&mut fb, col_x, seam_y);
4444+ assert_ne!(
4545+ c, 0,
4646+ "seam must not be black (should be covered by selection fill)"
4747+ );
4848+}
4949+5050+#[test]
5151+fn selection_right_edge_shadow_height_is_7() {
5252+ let text = "abc def\n"; // ensure next char is space so it doesn't overwrite the right-edge shadow
5353+ let mut cb = CodeBuffer::from_text(text);
5454+ // Select first 3 columns on a single line, keep caret away from right edge
5555+ cb.caret_line = 0;
5656+ cb.caret_col = 3;
5757+ cb.start_selection(); // anchor at (0,3)
5858+ cb.caret_line = 0;
5959+ cb.caret_col = 0; // caret at (0,0): selection is (0,3)
6060+6161+ let fb_rc = Rc::new(RefCell::new(Framebuffer::new()));
6262+ let mut fb = fb_rc.borrow_mut();
6363+ let area = Area {
6464+ x: 0,
6565+ y: 12,
6666+ w: 240,
6767+ h: 20,
6868+ };
6969+ cb.draw(&mut fb, area);
7070+7171+ let gutter_w = 24i32;
7272+ let right_edge_x = gutter_w + 3 * 6 - 1; // vertical shadow at right edge of col2
7373+ let base_y = area.y - 1; // selection fill starts at y-1; shadow spans 7 px down from y
7474+7575+ let mut black_count = 0;
7676+ let mut vals = [0u8; 7];
7777+ for dy in 0..7 {
7878+ let c = px(&mut fb, right_edge_x + 1, base_y + 1 + dy);
7979+ vals[dy as usize] = c;
8080+ if c == 0 {
8181+ black_count += 1;
8282+ }
8383+ }
8484+ eprintln!("right edge column vals={:?}", vals);
8585+ assert_eq!(
8686+ black_count, 7,
8787+ "right-edge shadow must be exactly 7 px tall"
8888+ );
8989+}