git.lucas.co / cce-list
things-to-remember checklist
git clone https://git.lucas.co/cce-list.git

commitf290b6d9b2cc2e3cf24c2af54b1ea266be0c3ce2
authorLucas Galante <[email protected]>
date2026-08-30 20:33
cce-list: a things-to-remember checklist kept on the desktop

A self-sizing utility window on the cce-ui Phase 6 shape (display list,
routed events): one TextBox adds items on Enter, a click on a row toggles
it done (filled disc + strikethrough), the hover ✕ deletes it, and the
window height tracks the item count via desired_size until MAX_H, where
the list scrolls. Storage is a markdown checklist at
~/.local/share/cce-list/list.md — written temp-then-rename, and foreign
lines in a hand-edited file are adopted as items rather than dropped
(the next save rewrites the file, so a skipped line would be a deleted
one).

Two cce-ui findings encoded here: Enter must be read from the box's own
'editing' flag (a click focuses through the thread-local focus registry,
so focused(&ctx) — which checks UiContext::focused_widget — never learns
of it), and the live value during editing is edit_buffer, not text,
which only holds the last committed value.

Co-Authored-By: Claude Fable 5 <[email protected]>

 .gitignore       |   2 +
 Cargo.toml       |  11 +
 Makefile         |  17 ++
 cce-list.desktop |   9 +
 src/main.rs      | 612 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 651 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca98cd9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target/
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..e0e03ee
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "cce-list"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+log = "0.4"
+env_logger = "0.11"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..44abf39
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,17 @@
+.PHONY: build install run clean
+
+build:
+	cargo build --release
+
+# Binaries, helper scripts and user units are enumerated by ccebuild from
+# cargo metadata, so extra [[bin]] targets are picked up without being named
+# here — hand-listing them is what left crates shipping incomplete for weeks.
+install: build
+	@command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+	ccebuild install --no-build cce-list
+
+run:
+	cargo run
+
+clean:
+	cargo clean
diff --git a/cce-list.desktop b/cce-list.desktop
new file mode 100644
index 0000000..1488ab6
--- /dev/null
+++ b/cce-list.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=CCE List
+Comment=Things to remember, kept on the desktop
+Exec=cce-list
+Icon=cce-list
+Categories=Utility;
+Terminal=false
+StartupWMClass=cce-list
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..7eb1f8f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,612 @@
+//! `cce-list` — a small list of things to remember, kept on the desktop.
+//!
+//! A self-sizing UTILITY window (the compositor never dictates its size and
+//! persists it across sessions like any other window, so it stays parked
+//! wherever it is put). One `TextBox` adds items; a click on a row toggles it
+//! done; the ✕ that appears on hover deletes it. The list is a plain markdown
+//! checklist on disk (`~/.local/share/cce-list/list.md`), so it can be read
+//! and edited with anything.
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx, PlateSpec};
+use cce_ui::widget::{
+    Adapted, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, TextBox,
+    WidgetHost,
+};
+use std::path::PathBuf;
+use wayland_client::QueueHandle;
+
+const WIDTH: u32 = 300;
+const ROW_H: f32 = 26.0;
+const INPUT_H: f32 = 30.0;
+const MIN_H: u32 = 140;
+/// Past this the window stops growing and the list scrolls instead.
+const MAX_H: u32 = 640;
+const TITLE_FONT_SIZE: f32 = 14.0;
+/// Checkbox disc radius; its hit target is the whole row, this is only drawn.
+const CHECK_R: f32 = 7.0;
+/// Side of the ✕ delete target at a row's right edge.
+const DELETE_S: f32 = 18.0;
+
+#[derive(Debug, Clone)]
+enum ListMessage {
+    Exit,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct Item {
+    text: String,
+    done: bool,
+}
+
+// ── Persistence: a markdown checklist ─────────────────────────────────────
+
+fn data_path() -> PathBuf {
+    std::env::var_os("XDG_DATA_HOME")
+        .map(PathBuf::from)
+        .filter(|p| p.is_absolute())
+        .unwrap_or_else(|| {
+            PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
+        })
+        .join("cce-list/list.md")
+}
+
+/// Checklist lines become items; any other non-empty line is adopted as a
+/// not-done item rather than parsed around — the next save rewrites the file,
+/// so a line this reader skipped would be a line silently deleted.
+fn parse_items(text: &str) -> Vec<Item> {
+    text.lines()
+        .filter_map(|line| {
+            let trimmed = line.trim();
+            if trimmed.is_empty() {
+                return None;
+            }
+            let (done, rest) = if let Some(r) = trimmed.strip_prefix("- [ ] ") {
+                (false, r)
+            } else if let Some(r) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
+                (true, r)
+            } else {
+                (false, trimmed)
+            };
+            Some(Item { text: rest.to_string(), done })
+        })
+        .collect()
+}
+
+fn serialize_items(items: &[Item]) -> String {
+    items
+        .iter()
+        .map(|i| format!("- [{}] {}\n", if i.done { 'x' } else { ' ' }, i.text))
+        .collect()
+}
+
+fn load_items() -> Vec<Item> {
+    match std::fs::read_to_string(data_path()) {
+        Ok(text) => parse_items(&text),
+        Err(_) => Vec::new(),
+    }
+}
+
+/// Write-temp-then-rename in the same directory, so a crash mid-write never
+/// leaves a truncated list behind.
+fn save_items(items: &[Item]) {
+    let path = data_path();
+    let write = || -> std::io::Result<()> {
+        if let Some(dir) = path.parent() {
+            std::fs::create_dir_all(dir)?;
+        }
+        let tmp = path.with_extension("md.tmp");
+        std::fs::write(&tmp, serialize_items(items))?;
+        std::fs::rename(&tmp, &path)
+    };
+    if let Err(e) = write() {
+        log::error!("cce-list: failed to save {}: {e}", path.display());
+    }
+}
+
+// ── Layout: hand math over a fixed-width column ───────────────────────────
+
+/// The frame's fixed vertical anatomy, derived once per use from the shared
+/// config paddings so paint, hit-testing, and `desired_size` cannot drift.
+struct Metrics {
+    pad: f32,
+    band_h: f32,
+    input: Rect,
+    list_top: f32,
+}
+
+fn text_leaf_height(font_size: f32) -> f32 {
+    (font_size * 1.2).ceil()
+}
+
+fn metrics(width: f32) -> Metrics {
+    let pad = cce_ui::layout::root_plate_padding();
+    let band_h = pad + text_leaf_height(TITLE_FONT_SIZE) + 10.0;
+    let gap = cce_ui::layout::bevel_width().max(4.0);
+    let input_y = band_h + gap;
+    Metrics {
+        pad,
+        band_h,
+        input: Rect { x: pad, y: input_y, width: width - 2.0 * pad, height: INPUT_H },
+        list_top: input_y + INPUT_H + gap,
+    }
+}
+
+fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+    let srgb = cce_ui::colors::to_srgb(linear);
+    [
+        (srgb[0] * 255.0) as u8,
+        (srgb[1] * 255.0) as u8,
+        (srgb[2] * 255.0) as u8,
+    ]
+}
+
+// ── Application ───────────────────────────────────────────────────────────
+
+struct ListApp {
+    /// The source of truth; every mutation saves before the frame that shows it.
+    items: Vec<Item>,
+    input_box: Adapted<TextBox>,
+    ui_context: cce_ui::context::UiContext,
+    width: u32,
+    height: u32,
+    scale_factor: f64,
+    needs_rebuild: bool,
+    widgets_registered: bool,
+    /// How far the list is scrolled down, in logical px; non-zero only once
+    /// the window has hit [`MAX_H`] and the rows overflow it.
+    scroll: f32,
+    pointer: Option<(f32, f32)>,
+    hovered_row: Option<usize>,
+}
+
+impl ListApp {
+    fn list_viewport(&self, m: &Metrics) -> Rect {
+        Rect {
+            x: 0.0,
+            y: m.list_top,
+            width: self.width as f32,
+            height: (self.height as f32 - m.list_top - m.pad).max(0.0),
+        }
+    }
+
+    /// Row `i`'s rect in window coordinates, scroll applied.
+    fn row_rect(&self, m: &Metrics, i: usize) -> Rect {
+        Rect {
+            x: m.pad,
+            y: m.list_top + i as f32 * ROW_H - self.scroll,
+            width: self.width as f32 - 2.0 * m.pad,
+            height: ROW_H,
+        }
+    }
+
+    fn delete_rect(row: Rect) -> Rect {
+        Rect {
+            x: row.x + row.width - DELETE_S,
+            y: row.y + (row.height - DELETE_S) / 2.0,
+            width: DELETE_S,
+            height: DELETE_S,
+        }
+    }
+
+    fn max_scroll(&self, m: &Metrics) -> f32 {
+        (self.items.len() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
+    }
+
+    fn clamp_scroll(&mut self) {
+        let m = metrics(self.width as f32);
+        self.scroll = self.scroll.clamp(0.0, self.max_scroll(&m));
+    }
+
+    fn row_at(&self, x: f32, y: f32) -> Option<usize> {
+        let m = metrics(self.width as f32);
+        let vp = self.list_viewport(&m);
+        if y < vp.y || y > vp.y + vp.height {
+            return None;
+        }
+        let i = ((y - m.list_top + self.scroll) / ROW_H).floor();
+        let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.items.len())?;
+        let r = self.row_rect(&m, row);
+        (x >= r.x && x <= r.x + r.width).then_some(row)
+    }
+
+    fn submit_input(&mut self) {
+        // The live value: while the box is in edit mode the typed text sits in
+        // `edit_buffer`; `text` is only the last committed value.
+        let raw = if self.input_box.editing {
+            &self.input_box.edit_buffer
+        } else {
+            &self.input_box.text
+        };
+        let text = raw.trim().to_string();
+        if text.is_empty() {
+            return;
+        }
+        self.items.push(Item { text, done: false });
+        self.input_box.text.clear();
+        self.input_box.edit_buffer.clear();
+        self.input_box.cursor_idx = 0;
+        save_items(&self.items);
+        // Keep the fresh item in view once the window is at its height cap.
+        let m = metrics(self.width as f32);
+        self.scroll = self.max_scroll(&m);
+        self.needs_rebuild = true;
+    }
+}
+
+impl Application for ListApp {
+    type Message = ListMessage;
+
+    fn new(
+        _qh: &QueueHandle<EngineState<Self>>,
+        _sender: calloop::channel::Sender<Self::Message>,
+    ) -> Self {
+        cce_ui::scale::set_scale_factor(1.0);
+        Self {
+            items: load_items(),
+            input_box: TextBox::new(String::new()).with_placeholder("Remember to…"),
+            ui_context: cce_ui::context::UiContext::new(),
+            width: WIDTH,
+            height: MIN_H,
+            scale_factor: 1.0,
+            needs_rebuild: true,
+            widgets_registered: false,
+            scroll: 0.0,
+            pointer: None,
+            hovered_row: None,
+        }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "cce-list".to_string(),
+            app_id: "cce-list".to_string(),
+            width: WIDTH,
+            height: MIN_H,
+            fullscreen: false,
+            min_size: None,
+        }
+    }
+
+    /// A tool whose shape is its contents: the compositor never sizes it and
+    /// never restores a stale geometry over what `desired_size` asks for.
+    fn utility(&self) -> bool {
+        true
+    }
+
+    /// Fixed width; height tracks the item count in whole-row steps until
+    /// [`MAX_H`], where the list starts scrolling instead.
+    fn desired_size(&self) -> Option<(u32, u32)> {
+        let m = metrics(WIDTH as f32);
+        // An empty list still shows one row's worth of hint text.
+        let rows = self.items.len().max(1) as f32;
+        let wanted = (m.list_top + rows * ROW_H + m.pad).ceil() as u32;
+        Some((WIDTH, wanted.clamp(MIN_H, MAX_H)))
+    }
+
+    fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
+        match msg {
+            ListMessage::Exit => *exit = true,
+        }
+    }
+
+    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        if self.ui_context.tick(dt) {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+    }
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+        // Register once, at self's final address (the registry stores pointers).
+        if !self.widgets_registered {
+            self.widgets_registered = true;
+            let ptr = self.input_box.as_ptr_mut();
+            let id = self.input_box.id();
+            self.ui_context.register_widget(id, ptr);
+        }
+
+        let size_changed = self.width != size.width as u32
+            || self.height != size.height as u32
+            || self.scale_factor != scale;
+        if size_changed {
+            self.width = size.width as u32;
+            self.height = size.height as u32;
+            self.scale_factor = scale;
+            cce_ui::scale::set_scale_factor(scale as f32);
+            self.clamp_scroll();
+        }
+        let m = metrics(self.width as f32);
+        if self.needs_rebuild || size_changed {
+            self.input_box
+                .set_rect(m.input.x, m.input.y, m.input.width, m.input.height);
+            self.needs_rebuild = false;
+            self.ui_context.rebuild_spatial_grid();
+        }
+
+        let (w, h) = (self.width as f32, self.height as f32);
+        let mut pc = PaintCtx::new();
+
+        // The window plate, then the title strip carved one step down into it
+        // (the recessed header idiom — its only wall faces the content).
+        let mut plate = cce_ui::color::page_low_color();
+        if plate[3] > 0.001 {
+            plate[3] = cce_ui::color::root_plate_opacity();
+        }
+        pc.plate_spec(&PlateSpec {
+            rect: Rect { x: 0.0, y: 0.0, width: w, height: h },
+            color: plate,
+            blur: false,
+            window_corners: (true, true, true, true),
+            depth: cce_ui::layout::bevel_width(),
+        });
+        pc.recess_edges(
+            Rect { x: 0.0, y: 0.0, width: w, height: m.band_h },
+            (0.0, 0.0, 0.0, 0.0),
+            cce_ui::layout::bar_wall_width(),
+            (false, false, true, false),
+        );
+
+        let (title_family, _) = cce_ui::layout::menubar_font_parsed();
+        pc.text_with(
+            "cce-list".to_string(),
+            m.pad,
+            cce_ui::layout::align_text_y(0.0, m.band_h, TITLE_FONT_SIZE, 0.0),
+            TITLE_FONT_SIZE,
+            srgb_u8(cce_ui::colors::TEXT_HEADER),
+            Some(title_family),
+            None,
+        );
+
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.input_box, &mut pc);
+
+        // The rows, clipped to the viewport so a scrolled list never bleeds
+        // into the input or the plate's bottom roll.
+        let (family, font_size) = cce_ui::layout::list_font_parsed();
+        let vp = self.list_viewport(&m);
+        pc.clip(vp, |pc| {
+            if self.items.is_empty() {
+                let r = self.row_rect(&m, 0);
+                pc.text_with(
+                    "nothing to remember".to_string(),
+                    r.x,
+                    cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0),
+                    font_size,
+                    srgb_u8(cce_ui::colors::TEXT_DIM),
+                    Some(family.clone()),
+                    None,
+                );
+            }
+            for (i, item) in self.items.iter().enumerate() {
+                let r = self.row_rect(&m, i);
+                if r.y + r.height < vp.y || r.y > vp.y + vp.height {
+                    continue;
+                }
+                let hovered = self.hovered_row == Some(i);
+                let (cx, cy) = (r.x + CHECK_R, r.y + r.height / 2.0);
+                pc.border(
+                    Rect {
+                        x: cx - CHECK_R,
+                        y: cy - CHECK_R,
+                        width: 2.0 * CHECK_R,
+                        height: 2.0 * CHECK_R,
+                    },
+                    (CHECK_R, CHECK_R, CHECK_R, CHECK_R),
+                    [0.0, 0.0, 0.0, 0.0],
+                    cce_ui::colors::TEXT_DIM,
+                    1.5,
+                );
+                if item.done {
+                    pc.circle(cx, cy, CHECK_R - 3.0, cce_ui::colors::TOGGLE_ON);
+                }
+                let color = if item.done {
+                    cce_ui::colors::TEXT_DIM
+                } else {
+                    cce_ui::colors::TEXT_FG
+                };
+                let text_x = cx + CHECK_R + 8.0;
+                // The ✕ zone bounds the label whether or not it is drawn, so
+                // hovering never truncates the text it just revealed the ✕ over.
+                let text_end = r.x + r.width - DELETE_S - 4.0;
+                let text_y = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
+                pc.text_with(
+                    item.text.clone(),
+                    text_x,
+                    text_y,
+                    font_size,
+                    srgb_u8(color),
+                    Some(family.clone()),
+                    Some([text_x, r.y, text_end, r.y + r.height]),
+                );
+                if item.done {
+                    // Struck through rather than restyled: the row stays
+                    // readable, it just reads as handled.
+                    let strike_w = ((item.text.chars().count() as f32) * font_size * 0.55)
+                        .min(text_end - text_x);
+                    pc.vector(
+                        text_x,
+                        cy,
+                        text_x + strike_w,
+                        cy,
+                        1.0,
+                        cce_ui::colors::TEXT_DIM,
+                        Cap::Flat,
+                    );
+                }
+                if hovered {
+                    let d = Self::delete_rect(r);
+                    let (dcx, dcy) = (d.x + d.width / 2.0, d.y + d.height / 2.0);
+                    let arm = 4.0;
+                    let dim = cce_ui::colors::TEXT_DIM;
+                    pc.vector(dcx - arm, dcy - arm, dcx + arm, dcy + arm, 1.5, dim, Cap::Round);
+                    pc.vector(dcx - arm, dcy + arm, dcx + arm, dcy - arm, 1.5, dim, Cap::Round);
+                }
+            }
+        });
+
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
+        Some(&self.ui_context)
+    }
+
+    fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
+        Some(&mut self.ui_context)
+    }
+
+    /// Drag the window by its title band; everywhere else is content.
+    fn is_movable_backplate_at(&self, _px: f32, py: f32) -> bool {
+        py <= metrics(self.width as f32).band_h
+    }
+
+    fn clear_color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        self.pointer = Some((pos.x, pos.y));
+        let hovered = self.row_at(pos.x, pos.y);
+        if hovered != self.hovered_row {
+            self.hovered_row = hovered;
+            *needs_rebuild = true;
+        }
+        let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
+        if self.ui_context.propagate_event(&ev, self.input_box.id()) {
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        let (px, py) = (pos.x, pos.y);
+        if button == MouseButton::Left && state == ElementState::Pressed {
+            if let Some(i) = self.row_at(px, py) {
+                let m = metrics(self.width as f32);
+                let d = Self::delete_rect(self.row_rect(&m, i));
+                if px >= d.x && px <= d.x + d.width && py >= d.y && py <= d.y + d.height {
+                    self.items.remove(i);
+                    self.clamp_scroll();
+                    self.hovered_row = self.row_at(px, py);
+                } else {
+                    self.items[i].done = !self.items[i].done;
+                }
+                save_items(&self.items);
+                self.needs_rebuild = true;
+                *needs_rebuild = true;
+                return None;
+            }
+        }
+        if state == ElementState::Pressed && !self.input_box.hit_test(px, py, &self.ui_context) {
+            self.input_box.unfocus();
+            *needs_rebuild = true;
+        }
+        let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
+        if self.ui_context.propagate_event(&ev, self.input_box.id()) {
+            *needs_rebuild = true;
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(
+        &mut self,
+        delta: &MouseScrollDelta,
+        _pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) {
+        let m = metrics(self.width as f32);
+        if self.max_scroll(&m) <= 0.0 {
+            return;
+        }
+        let before = self.scroll;
+        self.scroll -= delta.notches_y() * ROW_H;
+        self.clamp_scroll();
+        if self.scroll != before {
+            if let Some((px, py)) = self.pointer {
+                self.hovered_row = self.row_at(px, py);
+            }
+            self.needs_rebuild = true;
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_key_input(
+        &mut self,
+        event: &KeyEvent,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        if event.state == ElementState::Pressed && !event.repeat {
+            if event.ctrl {
+                if let Key::Character(ref c) = event.logical_key {
+                    if c == "q" {
+                        return Some(ListMessage::Exit);
+                    }
+                }
+            }
+            if let Key::Named(NamedKey::Escape) = event.logical_key {
+                self.input_box.unfocus();
+                *needs_rebuild = true;
+                return None;
+            }
+            if let Key::Named(NamedKey::Enter) = event.logical_key {
+                // The box's own edit mode, not `focused(&ctx)`: a click focuses
+                // through the thread-local focus registry, so the UiContext's
+                // focused_widget — which that checks — never learns of it.
+                if self.input_box.editing {
+                    self.submit_input();
+                    *needs_rebuild = true;
+                    return None;
+                }
+            }
+        }
+        let ev = Event::KeyInput(event.clone());
+        if self.ui_context.propagate_event(&ev, self.input_box.id()) {
+            *needs_rebuild = true;
+        }
+        None
+    }
+}
+
+fn main() {
+    env_logger::init();
+    cce_ui::engine::run::<ListApp>();
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn checklist_round_trips() {
+        let items = vec![
+            Item { text: "water the plants".into(), done: false },
+            Item { text: "renew passport".into(), done: true },
+        ];
+        assert_eq!(parse_items(&serialize_items(&items)), items);
+    }
+
+    /// A hand-edited file must survive a load/save cycle: plain lines are
+    /// adopted as items, not dropped, and `[X]` reads the same as `[x]`.
+    #[test]
+    fn foreign_lines_are_adopted_not_dropped() {
+        let parsed = parse_items("buy stamps\n- [X] call mom\n\n  - [ ] indented\n");
+        assert_eq!(
+            parsed,
+            vec![
+                Item { text: "buy stamps".into(), done: false },
+                Item { text: "call mom".into(), done: true },
+                Item { text: "indented".into(), done: false },
+            ]
+        );
+    }
+}