use std::rc::Rc; use anyhow::anyhow; use gpui::{ App, ClipboardItem, Entity, KeyDownEvent, Pixels, Point, SharedString, TaskExt, Window, point, }; use super::{ formula::filled, grid::{Face, Merge, OnCells, Sheet, Text, range}, }; use crate::{ forms::Editing, theme::{ActiveTheme, ControlSize, Density}, }; /// Sizes in pixels: a cell, a header, the row labels, and the frozen stretch. #[derive(Clone, Copy)] pub(crate) struct Metrics { pub wide: Pixels, pub tall: Pixels, pub label: Pixels, pub frozen: Point, } impl Metrics { /// A face's sizes at the window's rem. pub(crate) fn new(face: &Face, window: &Window, cx: &App) -> Self { let theme = cx.theme(); let rem = window.rem_size(); let (wide, tall) = ( theme.grid_column().to_pixels(rem), theme.table_row(Density::Compact).to_pixels(rem), ); Self { wide, tall, label: if face.numbered { theme.control_height(ControlSize::Lg).to_pixels(rem) * 1.25 } else { Pixels::ZERO }, frozen: point(wide * face.frozen.1 as f32, tall * face.frozen.0 as f32), } } /// The cell under a point within the grid, if any. pub(crate) fn cell_at( &self, at: Point, offset: Point, face: (usize, usize), ) -> Option<(usize, usize)> { let (x, y) = (at.x - self.label, at.y - self.tall); if x < Pixels::ZERO || y < Pixels::ZERO { return None; } let x = if x < self.frozen.x { x } else { x + offset.x }; let y = if y < self.frozen.y { y } else { y + offset.y }; let (col, row) = ((x / self.wide) as usize, (y / self.tall) as usize); (row < face.0 && col < face.1).then_some((row, col)) } } /// The cell a place stands for: a merge's first cell for any cell it covers. pub(crate) fn land(merges: &[Merge], (row, col): (usize, usize)) -> (usize, usize) { merges .iter() .find(|merge| { (merge.from.0..=merge.to.0).contains(&row) && (merge.from.1..=merge.to.1).contains(&col) }) .map_or((row, col), |merge| merge.from) } /// Where a key moves the cursor from a cell: past the far side of a merge it stands on, and onto the first cell of any merge it reaches. pub(crate) fn stepped( merges: &[Merge], (row, col): (usize, usize), key: &str, (rows, cols): (usize, usize), ) -> Option<(usize, usize)> { let far = merges .iter() .find(|merge| merge.from == (row, col)) .map_or((row, col), |merge| merge.to); let to = match key { "up" => (row.saturating_sub(1), col), "down" | "enter" => ((far.0 + 1).min(rows - 1), col), "left" => (row, col.saturating_sub(1)), "right" => (row, (far.1 + 1).min(cols - 1)), "home" => (row, 0), "end" => (row, cols - 1), _ => return None, }; Some(land(merges, to)) } /// Pulls the cursor, its anchor and a fill back inside a grid of `size`; whether anything moved. pub(crate) fn fit(sheet: &mut Sheet, size: (usize, usize)) -> bool { let last = (size.0.saturating_sub(1), size.1.saturating_sub(1)); let inside = |(row, col): (usize, usize)| (row.min(last.0), col.min(last.1)); let (cursor, anchor) = (inside(sheet.cursor), inside(sheet.anchor)); let moved = (cursor, anchor) != (sheet.cursor, sheet.anchor); if moved { log::info!("grid: now {size:?}; the cursor moved to {cursor:?}"); (sheet.cursor, sheet.anchor, sheet.fill) = (cursor, anchor, None); } moved } /// Tab and Shift-Tab: keep an open edit, then step across, staying inside the grid. pub(crate) fn across( (sheet, editor): (&Entity, &Entity), (merges, size, back): (&[Merge], (usize, usize), bool), (metrics, frozen): (Metrics, (usize, usize)), window: &mut Window, cx: &mut App, ) { cx.stop_propagation(); if size.0 == 0 || size.1 == 0 { return; } let (row, col) = sheet.read(cx).cursor; if sheet.read(cx).editing { editor.update(cx, |editing, cx| editing.finish(false, window, cx)); } let key = if back { "left" } else { "right" }; let to = stepped(merges, (row, col), key, size).expect("left and right always step"); sheet.update(cx, |sheet, cx| { (sheet.cursor, sheet.anchor) = (to, to); reveal(sheet, metrics, frozen); cx.notify(); }); } /// Scrolls just enough that the cursor shows, past the frozen rows and columns. pub(crate) fn reveal(sheet: &mut Sheet, metrics: Metrics, frozen: (usize, usize)) { let (row, col) = sheet.cursor; let room_x = sheet.bounds.size.width - metrics.label - metrics.frozen.x; let room_y = sheet.bounds.size.height - metrics.tall - metrics.frozen.y; if col >= frozen.1 { let x = metrics.wide * col as f32 - metrics.frozen.x; sheet.offset.x = sheet .offset .x .min(x) .max(x + metrics.wide - room_x) .max(Pixels::ZERO); } if row >= frozen.0 { let y = metrics.tall * row as f32 - metrics.frozen.y; sheet.offset.y = sheet .offset .y .min(y) .max(y + metrics.tall - room_y) .max(Pixels::ZERO); } } /// Opens the cursor's cell with `text`; a kept edit reports and moves down. pub(crate) fn begin( sheet: &Entity, editor: &Entity, on_change: Option, text: String, at_end: bool, window: &mut Window, cx: &mut App, ) { let (cell, rows) = (sheet.read(cx).cursor, sheet.read(cx).rows); let moved = sheet.clone(); editor.update(cx, |editing, _| { editing.on_commit = Some(Rc::new(move |text, window, cx| { log::info!("grid: {cell:?} is now {text}"); if let Some(on_change) = &on_change { on_change(&[(cell.0, cell.1, text.clone())], window, cx); } moved.update(cx, |sheet, _| { sheet.cursor.0 = (cell.0 + 1).min(rows.saturating_sub(1)); sheet.anchor = sheet.cursor; }); })) }); sheet.update(cx, |sheet, _| sheet.editing = true); let end = text.len(); Editing::begin(editor, text, window, cx); if at_end && let Some(field) = editor.read(cx).field() { field.update(cx, |field, cx| field.select(end..end, cx)); } } /// Text for the clipboard: the range's cells as they show, tabs between, lines below. fn copied(shown: &Text, sheet: &Sheet) -> String { let ((top, left), (bottom, right)) = range(sheet); (top..=bottom) .map(|row| { (left..=right) .map(|col| shown(row, col).to_string()) .collect::>() .join("\t") }) .collect::>() .join("\n") } /// Pasted text as cell edits from the cursor on, clipped to the grid. pub(crate) fn pasted( text: &str, from: (usize, usize), size: (usize, usize), ) -> Vec<(usize, usize, SharedString)> { text.lines() .enumerate() .flat_map(|(dr, line)| { line.split('\t').enumerate().map(move |(dc, cell)| { ( from.0 + dr, from.1 + dc, SharedString::from(cell.to_string()), ) }) }) .filter(|(row, col, _)| *row < size.0 && *col < size.1) .collect() } /// The key handler: arrows walk, stepping over merges, and Shift stretches the range; Enter steps down, typing or F2 edits, Delete clears, Cmd-C and Cmd-V copy and paste. pub(crate) fn keys( face: Face, metrics: Metrics, sheet: Entity, editor: Entity, ) -> impl Fn(&KeyDownEvent, &mut Window, &mut App) + 'static { move |event, window, cx| { if sheet.read(cx).editing { return; } let (rows, cols) = (face.rows, face.cols); if rows == 0 || cols == 0 { return; } let stroke = &event.keystroke; let held = &stroke.modifiers; let (row, col) = sheet.read(cx).cursor; if let Some(to) = stepped(&face.merges, (row, col), stroke.key.as_str(), (rows, cols)) { cx.stop_propagation(); let stretch = held.shift && stroke.key != "enter"; sheet.update(cx, |sheet, cx| { sheet.cursor = to; if !stretch { sheet.anchor = to; } reveal(sheet, metrics, face.frozen); cx.notify(); }); return; } // Browsers on a Mac send Cmd; wasm's secondary is Control. let command = held.secondary() || (cfg!(target_family = "wasm") && held.platform); match (stroke.key.as_str(), command) { ("escape", _) => sheet.update(cx, |sheet, cx| { sheet.anchor = sheet.cursor; cx.notify(); }), ("f2", _) => begin( &sheet, &editor, face.on_change.clone(), (face.raw)(row, col).to_string(), true, window, cx, ), ("backspace" | "delete", false) => { let ((top, left), (bottom, right)) = range(sheet.read(cx)); let cleared: Vec<_> = (top..=bottom) .flat_map(|row| { (left..=right).map(move |col| (row, col, SharedString::default())) }) .collect(); if let Some(on_change) = &face.on_change { on_change(&cleared, window, cx); } } ("c", true) => cx.write_to_clipboard(ClipboardItem::new_string(copied( &face.shown, sheet.read(cx), ))), ("v", true) => { // The web reads its clipboard only through the async reader. let read = cx.read_from_clipboard_async(); let on_change = face.on_change.clone(); window .spawn(cx, async move |cx| { let item = read.await.map_err(|error| anyhow!("{error:?}"))?; let Some(text) = item.and_then(|item| item.text()) else { log::info!("grid: the clipboard holds no text to paste"); return Ok(()); }; let edits = pasted(&text, (row, col), (rows, cols)); log::info!("grid: pasted {} cells", edits.len()); if let Some(on_change) = on_change { cx.update(|window, cx| on_change(&edits, window, cx))?; } anyhow::Ok(()) }) .detach_and_log_err(cx); } _ => { let typed = stroke.key_char.clone().filter(|typed| { !held.platform && !held.control && !typed.chars().any(char::is_control) }); let Some(typed) = typed else { return; }; begin( &sheet, &editor, face.on_change.clone(), typed, true, window, cx, ); } } cx.stop_propagation(); } } /// Applies a fill: the range's runs carry on down, or across, to where the handle was dropped. pub(crate) fn fill( sheet: &Entity, raw: &Text, on_change: &Option, window: &mut Window, cx: &mut App, ) { let Some((row, col)) = sheet.update(cx, |sheet, cx| { cx.notify(); sheet.fill.take() }) else { return; }; let ((top, left), (bottom, right)) = range(sheet.read(cx)); let mut edits = Vec::new(); if row > bottom { for c in left..=right { let run: Vec = (top..=bottom).map(|r| raw(r, c)).collect(); for (ix, text) in filled(&run, row - bottom, false).into_iter().enumerate() { edits.push((bottom + 1 + ix, c, text)); } } } else if col > right { for r in top..=bottom { let run: Vec = (left..=right).map(|c| raw(r, c)).collect(); for (ix, text) in filled(&run, col - right, true).into_iter().enumerate() { edits.push((r, right + 1 + ix, text)); } } } log::info!("grid: filled {} cells", edits.len()); if let Some(on_change) = on_change.as_ref().filter(|_| !edits.is_empty()) { on_change(&edits, window, cx); } } #[cfg(test)] mod tests { use super::pasted; #[test] fn pasted_text_lands_from_the_cursor_and_stops_at_the_edge() { let edits: Vec<(usize, usize, String)> = pasted("a\tb\nc\td", (1, 1), (3, 2)) .into_iter() .map(|(row, col, text)| (row, col, text.to_string())) .collect(); assert_eq!(edits, [(1, 1, "a".into()), (2, 1, "c".into())]); } }