use super::history::History;
use crate::{
shell::{Key, Mods},
ui::{self, draw_icon8, Icon8, Mouse, ICON_PENCIL},
};
use pixel8_runtime::{
assets::{Assets, SpriteSheet, SHEET_H, SHEET_W, SPRITES_PER_ROW},
clipboard::{self, ClipboardPayload, Pasted},
fb::Framebuffer,
palette::col,
};
const CANVAS: (i32, i32) = (3, 20); const PAL: (i32, i32) = (76, 20); const PAL_SW: i32 = 8; const SIZE_BTNS: (i32, i32) = (76, 55); const FLAGS: (i32, i32) = (74, 70); const SHEET_Y: i32 = 88; const PAGE_BTNS: (i32, i32) = (104, 81);
const FS_CANVAS: (i32, i32) = (8, 8);
const FS_MAX: i32 = 112;
const SIZES: [i32; 4] = [1, 2, 4, 8];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tool {
Pencil,
Eraser,
Fill,
Picker,
Pan,
}
const TOOLS: [(Tool, Icon8); 5] = [
(Tool::Pencil, ICON_PENCIL),
(Tool::Eraser, ICON_ERASER),
(Tool::Fill, ICON_FILL),
(Tool::Picker, ICON_PICKER),
(Tool::Pan, ICON_HAND),
];
fn tool_x(i: usize) -> i32 {
3 + i as i32 * 10
}
#[derive(Clone, Copy)]
struct PanDrag {
amx: i32,
amy: i32,
avx: i32,
avy: i32,
}
pub struct SpriteEditor {
view_x: i32,
view_y: i32,
color: u8,
tool: Tool,
page: u32,
size: i32,
fullscreen: bool,
mx: i32,
my: i32,
status: ui::StatusMsg,
pan: Option<PanDrag>,
history: History<SpriteSheet>,
}
impl SpriteEditor {
pub fn new() -> Self {
Self {
view_x: 8, view_y: 0,
color: 7,
tool: Tool::Pencil,
page: 0,
size: 1,
fullscreen: false,
mx: -16,
my: -16,
status: ui::StatusMsg::default(),
pan: None,
history: History::new(),
}
}
pub fn is_fullscreen(&self) -> bool {
self.fullscreen
}
fn block_px(&self) -> i32 {
self.size * 8
}
fn origin_cell(&self) -> (i32, i32) {
(self.view_x / 8, self.view_y / 8)
}
fn top_left_sprite(&self) -> u32 {
let (col, row) = self.origin_cell();
(row * SPRITES_PER_ROW as i32 + col) as u32
}
fn clamp_view(&mut self) {
let bpx = self.block_px();
self.view_x = self.view_x.clamp(0, SHEET_W as i32 - bpx);
self.view_y = self.view_y.clamp(0, SHEET_H as i32 - bpx);
}
fn nudge_cell(&mut self, dcol: i32, drow: i32) {
self.view_x = (self.view_x / 8 + dcol) * 8;
self.view_y = (self.view_y / 8 + drow) * 8;
self.clamp_view();
self.page = (self.view_y / 32).clamp(0, 3) as u32;
}
fn canvas(&self) -> (i32, i32, i32) {
let bpx = self.block_px();
if self.fullscreen {
(FS_CANVAS.0, FS_CANVAS.1, (FS_MAX / bpx).max(1))
} else {
(CANVAS.0, CANVAS.1, 64 / bpx)
}
}
fn sheet_origin(&self) -> (i32, i32) {
(self.view_x, self.view_y)
}
pub fn key(&mut self, key: Key, mods: Mods, assets: &mut Assets) {
if mods.ctrl {
if let Key::Char(c) = key {
match c.to_ascii_lowercase() {
'z' if mods.shift => {
self.history.redo(&mut assets.sprites);
return;
}
'z' => {
self.history.undo(&mut assets.sprites);
return;
}
'y' => {
self.history.redo(&mut assets.sprites);
return;
}
_ => {}
}
}
}
match key {
Key::Left => self.nudge_cell(-1, 0),
Key::Right => self.nudge_cell(1, 0),
Key::Up => self.nudge_cell(0, -1),
Key::Down => self.nudge_cell(0, 1),
Key::PageUp => self.page = (self.page + 3) % 4,
Key::PageDown => self.page = (self.page + 1) % 4,
Key::Char('p') => self.tool = Tool::Pencil,
Key::Char('e') => self.tool = Tool::Eraser,
Key::Char('f') => self.tool = Tool::Fill,
Key::Char('i') => self.tool = Tool::Picker,
Key::Char('h') => self.tool = Tool::Pan,
Key::Tab => self.fullscreen = !self.fullscreen,
_ => {}
}
}
fn apply_tool(&mut self, assets: &mut Assets, px: i32, py: i32, right: bool) {
let (ox, oy) = self.sheet_origin();
let (sx, sy) = (ox + px, oy + py);
if right {
self.color = assets.sprites.get(sx, sy);
return;
}
match self.tool {
Tool::Pencil => assets.sprites.set(sx, sy, self.color),
Tool::Eraser => assets.sprites.set(sx, sy, 0),
Tool::Picker => {
self.color = assets.sprites.get(sx, sy);
self.tool = Tool::Pencil;
}
Tool::Fill => {
let target = assets.sprites.get(sx, sy);
if target == self.color {
return;
}
let bpx = self.block_px();
let mut stack = vec![(px, py)];
while let Some((x, y)) = stack.pop() {
if !(0..bpx).contains(&x) || !(0..bpx).contains(&y) {
continue;
}
if assets.sprites.get(ox + x, oy + y) != target {
continue;
}
assets.sprites.set(ox + x, oy + y, self.color);
stack.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]);
}
}
Tool::Pan => {}
}
}
fn handle_pan(&mut self, m: &Mouse, z: i32, over: bool) {
if m.left_pressed && over {
self.pan = Some(PanDrag {
amx: m.x,
amy: m.y,
avx: self.view_x,
avy: self.view_y,
});
}
let Some(p) = self.pan else { return };
if !m.left {
self.pan = None;
return;
}
self.view_x = p.avx + (p.amx - m.x) / z;
self.view_y = p.avy + (p.amy - m.y) / z;
self.clamp_view();
self.page = (self.view_y / 32).clamp(0, 3) as u32;
}
pub fn tick(&mut self, mouse: &Mouse, assets: &mut Assets) {
self.status.tick();
self.mx = mouse.x;
self.my = mouse.y;
let m = *mouse;
if m.left || m.right {
self.history.begin(&assets.sprites);
} else {
self.history.commit(&assets.sprites);
}
if m.left_pressed && m.y < 8 {
if m.over(4, 0, 12, 7) {
self.fullscreen = false;
return;
} else if m.over(13, 0, 22, 7) {
self.fullscreen = true;
return;
}
}
let (cx, cy, z) = self.canvas();
let size = z * self.block_px();
let over_canvas = m.over(cx, cy, cx + size - 1, cy + size - 1);
if matches!(self.tool, Tool::Pan) {
self.handle_pan(&m, z, over_canvas);
} else if (m.left || m.right) && over_canvas {
let px = (m.x - cx) / z;
let py = (m.y - cy) / z;
let one_shot = matches!(self.tool, Tool::Fill | Tool::Picker);
if !one_shot || m.left_pressed || m.right_pressed {
self.apply_tool(assets, px, py, m.right && !m.left);
}
}
if !m.left_pressed || self.fullscreen {
return;
}
if m.over(PAL.0, PAL.1, PAL.0 + 4 * PAL_SW - 1, PAL.1 + 4 * PAL_SW - 1) {
let c = ((m.y - PAL.1) / PAL_SW) * 4 + (m.x - PAL.0) / PAL_SW;
self.color = c as u8;
}
for (i, (tool, _)) in TOOLS.iter().enumerate() {
let x = tool_x(i);
if m.over(x, 9, x + 7, 17) {
self.tool = *tool;
}
}
for (i, &n) in SIZES.iter().enumerate() {
let x = SIZE_BTNS.0 + i as i32 * 8;
if m.over(x, SIZE_BTNS.1, x + 6, SIZE_BTNS.1 + 7) {
self.size = n;
self.clamp_view(); }
}
let flag_sprite = self.top_left_sprite();
for f in 0..8 {
let x = FLAGS.0 + f * 6;
if m.over(x, FLAGS.1, x + 4, FLAGS.1 + 4) {
let cur = assets.sprites.flags(flag_sprite);
assets
.sprites
.set_flag(flag_sprite, f as u8, cur & (1 << f) == 0);
}
}
for p in 0..4 {
let x = PAGE_BTNS.0 + p * 6;
if m.over(x, PAGE_BTNS.1, x + 4, PAGE_BTNS.1 + 5) {
self.page = p as u32;
}
}
if m.over(0, SHEET_Y, 127, SHEET_Y + 31) {
let col = m.x / 8;
let row = self.page as i32 * 4 + (m.y - SHEET_Y) / 8;
self.view_x = col * 8;
self.view_y = row * 8;
self.clamp_view();
}
}
pub fn draw(&self, fb: &mut Framebuffer, assets: &Assets) {
if self.fullscreen {
self.draw_fullscreen(fb, assets);
return;
}
for (i, (tool, icon)) in TOOLS.iter().enumerate() {
let x = tool_x(i);
let color = if *tool == self.tool {
fb.rectfill(x - 1, 9, x + 8, 17, col::BLACK);
col::WHITE
} else {
col::LAVENDER
};
draw_icon8(fb, icon, x, 9, color);
}
fb.print(
&format!("#{:03}", self.top_left_sprite()),
106,
11,
col::WHITE,
);
for (i, &n) in SIZES.iter().enumerate() {
let x = SIZE_BTNS.0 + i as i32 * 8;
let sel = n == self.size;
let color = if sel {
fb.rectfill(x - 1, SIZE_BTNS.1, x + 7, SIZE_BTNS.1 + 8, col::BLACK);
col::WHITE
} else {
col::LAVENDER
};
fb.print(&n.to_string(), x + 2, SIZE_BTNS.1 + 2, color);
}
fb.rect(
CANVAS.0 - 1,
CANVAS.1 - 1,
CANVAS.0 + 64,
CANVAS.1 + 64,
col::BLACK,
);
let (ox, oy) = self.sheet_origin();
let bpx = self.block_px();
fb.set_transparent_color(0, false);
fb.sspr(
&assets.sprites,
ox,
oy,
bpx,
bpx,
CANVAS.0,
CANVAS.1,
64,
64,
false,
false,
);
fb.reset_transparency();
self.draw_canvas_hover(fb);
for c in 0u8..16 {
let x = PAL.0 + (c as i32 % 4) * PAL_SW;
let y = PAL.1 + (c as i32 / 4) * PAL_SW;
fb.rectfill(x, y, x + PAL_SW - 1, y + PAL_SW - 1, c);
}
let sx = PAL.0 + (self.color as i32 % 4) * PAL_SW;
let sy = PAL.1 + (self.color as i32 / 4) * PAL_SW;
fb.rect(sx, sy, sx + PAL_SW - 1, sy + PAL_SW - 1, col::WHITE);
let flags = assets.sprites.flags(self.top_left_sprite());
for f in 0..8 {
let x = FLAGS.0 + f * 6;
let on = flags & (1 << f) != 0;
fb.circfill(
x + 2,
FLAGS.1 + 2,
2,
if on { col::RED } else { col::LAVENDER },
);
}
for p in 0..4u32 {
let x = PAGE_BTNS.0 + p as i32 * 6;
let c = if p == self.page {
col::WHITE
} else {
col::LAVENDER
};
fb.rectfill(x, PAGE_BTNS.1, x + 4, PAGE_BTNS.1 + 4, c);
}
for cy in 0..4i32 {
for cx in 0..16i32 {
let n = self.page * 64 + (cy * 16 + cx) as u32;
let (sx, sy) = (
(n as i32 % SPRITES_PER_ROW as i32) * 8,
(n as i32 / SPRITES_PER_ROW as i32) * 8,
);
for py in 0..8 {
for px in 0..8 {
fb.pset(
cx * 8 + px,
SHEET_Y + cy * 8 + py,
assets.sprites.get(sx + px, sy + py),
);
}
}
}
}
let (col, row) = self.origin_cell();
let page_row0 = self.page as i32 * 4;
let y0 = (row - page_row0).max(0);
let y1 = (row + self.size - page_row0).min(4);
if y1 > y0 {
let x = col * 8;
let y = SHEET_Y + y0 * 8;
fb.rect(
x,
y,
x + self.size * 8 - 1,
y + (y1 - y0) * 8 - 1,
col::WHITE,
);
}
self.draw_status(fb, assets);
}
fn draw_fullscreen(&self, fb: &mut Framebuffer, assets: &Assets) {
let (ox, oy) = self.sheet_origin();
let (cx, cy, z) = self.canvas();
let bpx = self.block_px();
fb.set_transparent_color(0, false);
fb.sspr(
&assets.sprites,
ox,
oy,
bpx,
bpx,
cx,
cy,
z * bpx,
z * bpx,
false,
false,
);
fb.reset_transparency();
self.draw_canvas_hover(fb);
self.draw_status(fb, assets);
}
fn draw_canvas_hover(&self, fb: &mut Framebuffer) {
let Some((px, py)) = self.canvas_pixel_under_cursor() else {
return;
};
let (cx, cy, z) = self.canvas();
let (bx, by) = (cx + px * z, cy + py * z);
fb.rect(bx, by, bx + z - 1, by + z - 1, col::WHITE);
}
fn tool_under_cursor(&self) -> Option<Tool> {
if self.fullscreen {
return None;
}
TOOLS.iter().enumerate().find_map(|(i, &(tool, _))| {
let x = tool_x(i);
(self.mx >= x && self.mx <= x + 7 && self.my >= 9 && self.my <= 17).then_some(tool)
})
}
fn canvas_pixel_under_cursor(&self) -> Option<(i32, i32)> {
let (cx, cy, z) = self.canvas();
let size = z * self.block_px();
if self.mx >= cx && self.mx < cx + size && self.my >= cy && self.my < cy + size {
Some(((self.mx - cx) / z, (self.my - cy) / z))
} else {
None
}
}
fn draw_status(&self, fb: &mut Framebuffer, assets: &Assets) {
let text = if let Some(tool) = self.tool_under_cursor() {
tool_label(tool).to_string()
} else if let Some((px, py)) = self.canvas_pixel_under_cursor() {
let (ox, oy) = self.sheet_origin();
let c = assets.sprites.get(ox + px, oy + py);
format!("#{:03} x{} y{} c{:02}", self.top_left_sprite(), px, py, c)
} else {
let flags = assets.sprites.flags(self.top_left_sprite());
format!("Spr {:03} flags {:08b}", self.top_left_sprite(), flags)
};
self.status.show(fb, &text);
}
pub fn set_status(&mut self, msg: String) {
self.status.set(msg);
}
pub fn paste(&mut self, pasted: &Pasted, assets: &mut Assets) {
self.history.begin(&assets.sprites);
match pasted {
Pasted::Sprites { rect, flags } => {
let (x0, y0) = self.sheet_origin();
let report =
clipboard::paste_sprites(&mut assets.sprites, rect, x0, y0, flags.as_deref());
self.status.set(report.summary);
}
Pasted::Sfx(_) => self.status.set("sfx - use sfx editor".into()),
Pasted::Map { .. } => self.status.set("map - use map editor".into()),
}
self.history.commit(&assets.sprites);
}
pub fn copy(&mut self, assets: &Assets) -> String {
let (x0, y0) = self.sheet_origin();
let (col, row) = self.origin_cell();
let bpx = self.block_px();
let mut pixels = Vec::with_capacity((bpx * bpx) as usize);
for dy in 0..bpx {
for dx in 0..bpx {
pixels.push(assets.sprites.get(x0 + dx, y0 + dy));
}
}
let mut flags = Vec::with_capacity((self.size * self.size) as usize);
for ry in 0..self.size {
for rx in 0..self.size {
let n = (row + ry) * SPRITES_PER_ROW as i32 + (col + rx);
flags.push(assets.sprites.flags(n as u32));
}
}
let top = self.top_left_sprite();
self.status.set(if self.size == 1 {
format!("copied sprite {top}")
} else {
format!("copied {bpx}x{bpx} spr {top}")
});
clipboard::encode(&ClipboardPayload::Sprite {
w: bpx as u8,
h: bpx as u8,
pixels,
flags,
})
}
}
fn tool_label(tool: Tool) -> &'static str {
match tool {
Tool::Pencil => "Pencil (p)",
Tool::Eraser => "Eraser (e)",
Tool::Fill => "Fill (f)",
Tool::Picker => "Picker (i)",
Tool::Pan => "Pan (h)",
}
}
const ICON_ERASER: Icon8 = [
0b00111100, 0b01111110, 0b11111111, 0b11111111, 0b11111111, 0b01111110, 0b00111100, 0b00000000,
];
const ICON_FILL: Icon8 = [
0b00011000, 0b00111100, 0b01111110, 0b11111111, 0b11111111, 0b01111110, 0b00011000, 0b00010000,
];
const ICON_PICKER: Icon8 = [
0b00000111, 0b00000111, 0b00001110, 0b00011100, 0b00111000, 0b01110000, 0b01100000, 0b00000000,
];
const ICON_HAND: Icon8 = [0x28, 0x2A, 0x2A, 0x3E, 0xBE, 0x7E, 0x1C, 0x00];
#[cfg(test)]
mod paste_tests {
use super::*;
use pixel8_runtime::clipboard::{parse, Pasted, PixelRect};
#[test]
fn pastes_pixels_at_selected_sprite() {
let mut ed = SpriteEditor::new(); let mut assets = Assets::default();
let rect = PixelRect {
w: 2,
h: 1,
pixels: vec![9, 10],
};
ed.paste(&Pasted::Sprites { rect, flags: None }, &mut assets);
assert_eq!(assets.sprites.get(8, 0), 9);
assert_eq!(assets.sprites.get(9, 0), 10);
assert!(ed.status.current().unwrap().contains("pasted"));
}
#[test]
fn rejects_sfx_with_a_hint() {
use pixel8_runtime::clipboard::SfxClip;
let mut ed = SpriteEditor::new();
let mut assets = Assets::default();
ed.paste(
&Pasted::Sfx(SfxClip {
records: vec![],
patterns: vec![],
}),
&mut assets,
);
assert!(ed.status.current().unwrap().contains("sfx"));
assert_eq!(assets.sprites.get(8, 0), 0); }
#[test]
fn copies_selected_sprite() {
let mut ed = SpriteEditor::new(); let mut assets = Assets::default();
assets.sprites.set(8, 0, 5);
assets.sprites.set(15, 7, 9);
let blob = ed.copy(&assets);
assert!(ed.status.current().unwrap().contains("copied sprite 1"));
let Pasted::Sprites { rect: r, .. } = parse(&blob).unwrap() else {
panic!("not sprites")
};
assert_eq!((r.w, r.h), (8, 8));
assert_eq!(r.pixels[0], 5);
assert_eq!(r.pixels[63], 9);
}
#[test]
fn undo_and_redo_a_paste() {
let mut ed = SpriteEditor::new(); let mut assets = Assets::default();
let rect = PixelRect {
w: 2,
h: 1,
pixels: vec![9, 10],
};
ed.paste(&Pasted::Sprites { rect, flags: None }, &mut assets);
assert_eq!(assets.sprites.get(8, 0), 9);
let ctrl = Mods {
ctrl: true,
shift: false,
..Default::default()
};
let ctrl_shift = Mods {
ctrl: true,
shift: true,
..Default::default()
};
ed.key(Key::Char('z'), ctrl, &mut assets);
assert_eq!(assets.sprites.get(8, 0), 0, "undo clears the paste");
assert_eq!(assets.sprites.get(9, 0), 0);
ed.key(Key::Char('z'), ctrl_shift, &mut assets);
assert_eq!(assets.sprites.get(8, 0), 9, "redo re-applies the paste");
assert_eq!(assets.sprites.get(9, 0), 10);
}
#[test]
fn an_incompatible_paste_records_no_undo() {
use pixel8_runtime::clipboard::SfxClip;
let mut ed = SpriteEditor::new();
let mut assets = Assets::default();
assets.sprites.set(8, 0, 5); ed.paste(
&Pasted::Sfx(SfxClip {
records: vec![],
patterns: vec![],
}),
&mut assets,
);
let ctrl = Mods {
ctrl: true,
shift: false,
..Default::default()
};
ed.key(Key::Char('z'), ctrl, &mut assets);
assert_eq!(
assets.sprites.get(8, 0),
5,
"the hint paste recorded no undo step"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use pixel8_runtime::assets::Assets;
fn press(x: i32, y: i32) -> Mouse {
Mouse {
x,
y,
left: true,
left_pressed: true,
..Default::default()
}
}
#[test]
fn tab_toggles_fullscreen() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
assert!(!ed.is_fullscreen());
ed.key(Key::Tab, Mods::default(), &mut a);
assert!(ed.is_fullscreen());
ed.key(Key::Tab, Mods::default(), &mut a);
assert!(!ed.is_fullscreen());
}
#[test]
fn view_buttons_toggle_fullscreen() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.tick(&press(15, 2), &mut a); assert!(ed.is_fullscreen());
ed.tick(&press(6, 2), &mut a); assert!(!ed.is_fullscreen());
}
#[test]
fn inactive_page_dot_is_lavender() {
let ed = SpriteEditor::new();
let a = Assets::default();
let mut fb = Framebuffer::new();
ed.draw(&mut fb, &a);
assert_eq!(fb.pget(PAGE_BTNS.0 + 6, PAGE_BTNS.1), col::LAVENDER);
}
#[test]
fn fullscreen_drag_draws_through_the_zoomed_canvas() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.key(Key::Tab, Mods::default(), &mut a);
ed.tick(&press(9, 9), &mut a);
assert_eq!(a.sprites.get(8, 0), 7);
}
#[test]
fn hovering_a_tool_reports_its_label() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
let hover = Mouse {
x: 13,
y: 13,
..Default::default()
};
ed.tick(&hover, &mut a);
assert_eq!(ed.tool, Tool::Pencil);
assert_eq!(ed.tool_under_cursor(), Some(Tool::Eraser));
assert_eq!(tool_label(Tool::Eraser), "Eraser (e)");
}
#[test]
fn tool_under_cursor_is_none_in_fullscreen() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.key(Key::Tab, Mods::default(), &mut a);
let hover = Mouse {
x: 13,
y: 13,
..Default::default()
};
ed.tick(&hover, &mut a);
assert_eq!(ed.tool_under_cursor(), None);
}
#[test]
fn canvas_pixel_maps_screen_to_sprite_pixel() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
let hover = Mouse {
x: 3 + 8 * 2 + 1,
y: 20 + 8 * 3 + 1,
..Default::default()
};
ed.tick(&hover, &mut a);
assert_eq!(ed.canvas_pixel_under_cursor(), Some((2, 3)));
}
#[test]
fn canvas_hover_outlines_the_snapped_pixel_block() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
let hover = Mouse {
x: 3 + 8 * 2 + 1,
y: 20 + 8 * 3 + 1,
..Default::default()
};
ed.tick(&hover, &mut a);
let mut fb = Framebuffer::new();
ed.draw(&mut fb, &a);
assert_eq!(fb.pget(19, 44), col::WHITE);
assert_ne!(fb.pget(18, 44), col::WHITE);
}
fn ctrl(shift: bool) -> Mods {
Mods {
ctrl: true,
shift,
..Default::default()
}
}
fn release() -> Mouse {
Mouse::default()
}
#[test]
fn undo_and_redo_a_pencil_stroke() {
let mut ed = SpriteEditor::new(); let mut a = Assets::default();
ed.tick(&press(CANVAS.0, CANVAS.1), &mut a);
ed.tick(&release(), &mut a);
assert_eq!(a.sprites.get(8, 0), 7);
ed.key(Key::Char('z'), ctrl(false), &mut a);
assert_eq!(a.sprites.get(8, 0), 0);
ed.key(Key::Char('z'), ctrl(true), &mut a); assert_eq!(a.sprites.get(8, 0), 7);
}
#[test]
fn undo_only_keeps_the_last_ten_strokes() {
use crate::editor::history::MAX_HISTORY;
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
let strokes = MAX_HISTORY as i32 + 2;
for i in 0..strokes {
let sx = CANVAS.0 + (i % 8) * 8;
let sy = CANVAS.1 + (i / 8) * 8;
ed.tick(&press(sx, sy), &mut a);
ed.tick(&release(), &mut a);
}
for _ in 0..MAX_HISTORY {
ed.key(Key::Char('z'), ctrl(false), &mut a);
}
assert_eq!(a.sprites.get(8, 0), 7, "oldest stroke is past the cap");
assert_eq!(a.sprites.get(9, 0), 7, "second-oldest stroke too");
assert_eq!(a.sprites.get(10, 0), 0, "third stroke was undone");
ed.key(Key::Char('z'), ctrl(false), &mut a);
assert_eq!(a.sprites.get(8, 0), 7);
}
#[test]
fn no_canvas_hover_when_over_the_palette() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
let on_palette = Mouse {
x: PAL.0 + 1,
y: PAL.1 + 1,
..Default::default()
};
ed.tick(&on_palette, &mut a);
assert_eq!(ed.canvas_pixel_under_cursor(), None);
}
#[test]
fn clicking_a_size_button_sets_the_block_size() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
assert_eq!(ed.size, 1);
ed.tick(&press(SIZE_BTNS.0 + 8 + 1, SIZE_BTNS.1 + 1), &mut a);
assert_eq!(ed.size, 2);
ed.tick(&press(SIZE_BTNS.0 + 3 * 8 + 1, SIZE_BTNS.1 + 1), &mut a);
assert_eq!(ed.size, 8);
}
#[test]
fn painting_a_2x2_block_reaches_the_adjacent_sprite() {
let mut ed = SpriteEditor::new(); let mut a = Assets::default();
ed.size = 2; ed.tick(&press(CANVAS.0 + 8 * 4, CANVAS.1), &mut a);
assert_eq!(a.sprites.get(16, 0), 7, "painted into the adjacent sprite");
}
#[test]
fn a_block_near_the_edge_clamps_onto_the_sheet() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.size = 2;
ed.view_x = 15 * 8; ed.view_y = 0;
ed.clamp_view(); assert_eq!(
ed.origin_cell(),
(14, 0),
"clamped so the 2-wide block fits"
);
assert_eq!(ed.top_left_sprite(), 14);
ed.tick(&press(CANVAS.0, CANVAS.1), &mut a);
assert_eq!(a.sprites.get(112, 0), 7);
}
#[test]
fn copy_covers_the_whole_block() {
use pixel8_runtime::clipboard::{parse, Pasted};
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.size = 2; a.sprites.set(8, 0, 5); a.sprites.set(23, 15, 9); let blob = ed.copy(&a);
assert_eq!(ed.status.current(), Some("copied 16x16 spr 1"));
let Pasted::Sprites { rect, flags } = parse(&blob).unwrap() else {
panic!("not sprites")
};
assert_eq!((rect.w, rect.h), (16, 16));
assert_eq!(rect.pixels[0], 5);
assert_eq!(rect.pixels[16 * 16 - 1], 9);
assert_eq!(flags.map(|f| f.len()), Some(4));
}
fn held(x: i32, y: i32) -> Mouse {
Mouse {
x,
y,
left: true,
..Default::default()
}
}
#[test]
fn arrow_keys_step_the_window_by_one_cell() {
let mut ed = SpriteEditor::new(); let mut a = Assets::default();
ed.key(Key::Right, Mods::default(), &mut a);
assert_eq!(ed.top_left_sprite(), 2);
ed.key(Key::Down, Mods::default(), &mut a);
assert_eq!(ed.top_left_sprite(), 18);
ed.key(Key::Left, Mods::default(), &mut a);
assert_eq!(ed.top_left_sprite(), 17);
}
#[test]
fn clicking_the_sheet_snaps_the_window_to_that_cell() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.page = 1; ed.tick(&press(2 * 8 + 1, SHEET_Y + 8 + 1), &mut a);
assert_eq!((ed.view_x, ed.view_y), (16, 40));
assert_eq!(ed.top_left_sprite(), 82);
}
#[test]
fn hand_tool_is_selectable_by_key_and_icon() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.key(Key::Char('h'), Mods::default(), &mut a);
assert_eq!(ed.tool, Tool::Pan);
ed.tool = Tool::Pencil;
ed.tick(&press(tool_x(4) + 1, 12), &mut a);
assert_eq!(ed.tool, Tool::Pan);
}
#[test]
fn pan_slides_the_window_by_pixels_following_the_cursor() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.size = 2;
ed.view_x = 16; ed.view_y = 16;
ed.tool = Tool::Pan;
ed.tick(&press(CANVAS.0, CANVAS.1), &mut a); ed.tick(&held(CANVAS.0 + 4, CANVAS.1), &mut a);
assert_eq!((ed.view_x, ed.view_y), (15, 16));
ed.tick(&held(CANVAS.0 + 8, CANVAS.1 + 8), &mut a);
assert_eq!((ed.view_x, ed.view_y), (14, 14));
ed.tick(&held(CANVAS.0 - 4, CANVAS.1), &mut a);
assert_eq!(ed.view_x, 17);
}
#[test]
fn pan_clamps_at_the_sheet_edge_and_touches_no_pixels() {
let mut ed = SpriteEditor::new();
let mut a = Assets::default();
ed.size = 2;
ed.view_x = 0; ed.view_y = 0;
a.sprites.set(8, 8, 9); ed.tool = Tool::Pan;
ed.tick(&press(CANVAS.0, CANVAS.1), &mut a);
ed.tick(&held(CANVAS.0 + 96, CANVAS.1 + 96), &mut a);
assert_eq!((ed.view_x, ed.view_y), (0, 0), "clamped at the corner");
ed.tick(&release(), &mut a);
assert_eq!(a.sprites.get(8, 8), 9, "pan left pixels untouched");
ed.key(Key::Char('z'), ctrl(false), &mut a);
assert_eq!(a.sprites.get(8, 8), 9);
}
#[test]
fn copy_emits_native_blob_with_flags() {
use pixel8_runtime::clipboard::{parse, Pasted};
let mut a = Assets::default();
a.sprites.set_flag(1, 3, true); let mut ed = SpriteEditor::new(); let blob = ed.copy(&a);
assert!(blob.starts_with(r#"{"app":"pixel8""#));
let Pasted::Sprites { flags, .. } = parse(&blob).unwrap() else {
panic!("not sprites")
};
assert_eq!(flags, Some(vec![0b0000_1000]));
assert_eq!(ed.status.current(), Some("copied sprite 1"));
}
}