Compare commits
2 commits
dc1defd7e8
...
06a3de6126
| Author | SHA1 | Date | |
|---|---|---|---|
| 06a3de6126 | |||
| 328690dfbf |
11 changed files with 898 additions and 34 deletions
|
|
@ -1697,6 +1697,16 @@ fn sort_cmp(a: &SortKey, b: &SortKey) -> std::cmp::Ordering {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The active row sort: which column, which direction, and how to put the
|
||||||
|
/// rows back. `restore[view_row]` is the row that data originally lived on,
|
||||||
|
/// so `unsort_rows` can restore the pre-sort order after any chain of sorts.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RowSort {
|
||||||
|
pub ascending: bool,
|
||||||
|
pub key_col: u32,
|
||||||
|
restore: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SpreadsheetData {
|
pub struct SpreadsheetData {
|
||||||
pub cells: HashMap<CellId, CellData>,
|
pub cells: HashMap<CellId, CellData>,
|
||||||
|
|
@ -1733,6 +1743,10 @@ pub struct SpreadsheetData {
|
||||||
/// error instead of inventing a date. Transient state — never
|
/// error instead of inventing a date. Transient state — never
|
||||||
/// serialized.
|
/// serialized.
|
||||||
pub now_serial: Option<f64>,
|
pub now_serial: Option<f64>,
|
||||||
|
/// The active row sort, if any. Transient state — never serialized.
|
||||||
|
/// `sort_rows` sets it; `unsort_rows` restores the pre-sort order and
|
||||||
|
/// clears it.
|
||||||
|
pub sort_order: Option<RowSort>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SpreadsheetData {
|
impl Default for SpreadsheetData {
|
||||||
|
|
@ -1752,6 +1766,7 @@ impl Default for SpreadsheetData {
|
||||||
spills: HashMap::new(),
|
spills: HashMap::new(),
|
||||||
blocked_spills: HashMap::new(),
|
blocked_spills: HashMap::new(),
|
||||||
now_serial: None,
|
now_serial: None,
|
||||||
|
sort_order: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2013,6 +2028,12 @@ impl SpreadsheetData {
|
||||||
WorkbookCommand::SetSlider { row, col, slider } => {
|
WorkbookCommand::SetSlider { row, col, slider } => {
|
||||||
self.mutate_cell(row, col, |cell| cell.style.slider = slider)
|
self.mutate_cell(row, col, |cell| cell.style.slider = slider)
|
||||||
}
|
}
|
||||||
|
WorkbookCommand::SetChoices { row, col, choices } => {
|
||||||
|
self.mutate_cell(row, col, |cell| cell.style.choices = choices)
|
||||||
|
}
|
||||||
|
WorkbookCommand::SetButton { row, col, button } => {
|
||||||
|
self.mutate_cell(row, col, |cell| cell.style.button = button)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2948,6 +2969,11 @@ impl SpreadsheetData {
|
||||||
/// Undo history is cleared — a sort is a destructive bulk reorder and
|
/// Undo history is cleared — a sort is a destructive bulk reorder and
|
||||||
/// cannot be cleanly undone. Row heights, column widths and named
|
/// cannot be cleanly undone. Row heights, column widths and named
|
||||||
/// ranges are positional and stay put, matching Excel.
|
/// ranges are positional and stay put, matching Excel.
|
||||||
|
///
|
||||||
|
/// The sort is *physical*: cells are remapped into their new rows. To
|
||||||
|
/// make the header's "off" state possible, the mapping back to the
|
||||||
|
/// pre-sort order is remembered in `sort_order` (composed across sort
|
||||||
|
/// chains) and applied by [`SpreadsheetData::unsort_rows`].
|
||||||
pub fn sort_rows(&mut self, ascending: bool, key_col: u32) {
|
pub fn sort_rows(&mut self, ascending: bool, key_col: u32) {
|
||||||
// Fresh computed values so the sort keys use formula results.
|
// Fresh computed values so the sort keys use formula results.
|
||||||
self.recalculate_all();
|
self.recalculate_all();
|
||||||
|
|
@ -2983,6 +3009,18 @@ impl SpreadsheetData {
|
||||||
for (new_pos, &old_row) in rows.iter().enumerate() {
|
for (new_pos, &old_row) in rows.iter().enumerate() {
|
||||||
position[old_row as usize] = new_pos as u32;
|
position[old_row as usize] = new_pos as u32;
|
||||||
}
|
}
|
||||||
|
// `restore[new_pos] = original_row`, composed through any prior
|
||||||
|
// sort so a single `unsort_rows` undoes the whole chain.
|
||||||
|
let restore: Vec<u32> = match &self.sort_order {
|
||||||
|
Some(prev) => rows.iter().map(|&old| prev.restore[old as usize]).collect(),
|
||||||
|
None => rows.clone(),
|
||||||
|
};
|
||||||
|
self.sort_order = Some(RowSort {
|
||||||
|
ascending,
|
||||||
|
key_col,
|
||||||
|
restore,
|
||||||
|
});
|
||||||
|
|
||||||
let remapped: HashMap<CellId, CellData> = self
|
let remapped: HashMap<CellId, CellData> = self
|
||||||
.cells
|
.cells
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -3004,6 +3042,37 @@ impl SpreadsheetData {
|
||||||
self.pending_recording = None;
|
self.pending_recording = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore the rows to their pre-sort order (the header sort's "off"
|
||||||
|
/// state). No-op when there is no active sort. Undo history is cleared,
|
||||||
|
/// like a sort.
|
||||||
|
pub fn unsort_rows(&mut self) {
|
||||||
|
let Some(sort) = self.sort_order.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let restore = sort.restore;
|
||||||
|
let remapped: HashMap<CellId, CellData> = self
|
||||||
|
.cells
|
||||||
|
.iter()
|
||||||
|
.map(|(&id, cell)| {
|
||||||
|
(
|
||||||
|
CellId::new(restore[id.row() as usize], id.col()),
|
||||||
|
cell.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.cells = remapped;
|
||||||
|
|
||||||
|
self.recalculate_all();
|
||||||
|
self.undo_stack.clear();
|
||||||
|
self.redo_stack.clear();
|
||||||
|
self.pending_recording = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active sort, if any: `(key column, ascending)`.
|
||||||
|
pub fn sort_state(&self) -> Option<(u32, bool)> {
|
||||||
|
self.sort_order.as_ref().map(|s| (s.key_col, s.ascending))
|
||||||
|
}
|
||||||
|
|
||||||
/// The sort key for `(row, col)`: the computed value for a formula
|
/// The sort key for `(row, col)`: the computed value for a formula
|
||||||
/// cell, the raw value otherwise.
|
/// cell, the raw value otherwise.
|
||||||
fn sort_key_for(&self, row: u32, col: u32) -> SortKey {
|
fn sort_key_for(&self, row: u32, col: u32) -> SortKey {
|
||||||
|
|
@ -3115,6 +3184,7 @@ impl SpreadsheetData {
|
||||||
self.undo_stack.clear();
|
self.undo_stack.clear();
|
||||||
self.redo_stack.clear();
|
self.redo_stack.clear();
|
||||||
self.pending_recording = None;
|
self.pending_recording = None;
|
||||||
|
self.sort_order = None;
|
||||||
|
|
||||||
let trimmed = data.trim_start();
|
let trimmed = data.trim_start();
|
||||||
|
|
||||||
|
|
@ -3217,6 +3287,10 @@ impl SpreadsheetData {
|
||||||
out.push_str(if cell.style.markdown { "1" } else { "0" });
|
out.push_str(if cell.style.markdown { "1" } else { "0" });
|
||||||
out.push('\t');
|
out.push('\t');
|
||||||
out.push_str(if cell.style.slider { "1" } else { "0" });
|
out.push_str(if cell.style.slider { "1" } else { "0" });
|
||||||
|
out.push('\t');
|
||||||
|
out.push_str(if cell.style.button { "1" } else { "0" });
|
||||||
|
out.push('\t');
|
||||||
|
out.push_str(&crate::style::choices_to_str(&cell.style.choices));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3305,10 +3379,13 @@ impl SpreadsheetData {
|
||||||
cell.style.border_bottom = border_from_str(bb);
|
cell.style.border_bottom = border_from_str(bb);
|
||||||
cell.style.border_left = border_from_str(bl);
|
cell.style.border_left = border_from_str(bl);
|
||||||
cell.style.border_right = border_from_str(br);
|
cell.style.border_right = border_from_str(br);
|
||||||
// The Markdown/slider flags are appended after the borders;
|
// The Markdown/slider/button flags and the choice list are
|
||||||
// older files end at `br`, so they default to off.
|
// appended after the borders; older files end at `br`, so
|
||||||
|
// they default to off/empty.
|
||||||
cell.style.markdown = it.next().unwrap_or("0") == "1";
|
cell.style.markdown = it.next().unwrap_or("0") == "1";
|
||||||
cell.style.slider = it.next().unwrap_or("0") == "1";
|
cell.style.slider = it.next().unwrap_or("0") == "1";
|
||||||
|
cell.style.button = it.next().unwrap_or("0") == "1";
|
||||||
|
cell.style.choices = crate::style::choices_from_str(it.next().unwrap_or(""));
|
||||||
|
|
||||||
if cell.formula.is_none() {
|
if cell.formula.is_none() {
|
||||||
cell.computed_value.clear();
|
cell.computed_value.clear();
|
||||||
|
|
@ -5218,3 +5295,206 @@ mod rich_cell_style_tests {
|
||||||
assert!(!sheet.cells.get(&CellId::new(0, 1)).unwrap().style.slider);
|
assert!(!sheet.cells.get(&CellId::new(0, 1)).unwrap().style.slider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod unsort_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// After a sort, `sort_state` reports the key and direction; after an
|
||||||
|
/// unsort it is `None` and the rows are back in their original order.
|
||||||
|
#[test]
|
||||||
|
fn unsort_restores_the_original_order() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
sheet.set_cell(0, 0, "3");
|
||||||
|
sheet.set_cell(0, 1, "c");
|
||||||
|
sheet.set_cell(1, 0, "1");
|
||||||
|
sheet.set_cell(1, 1, "a");
|
||||||
|
sheet.set_cell(2, 0, "2");
|
||||||
|
sheet.set_cell(2, 1, "b");
|
||||||
|
|
||||||
|
sheet.sort_rows(true, 0);
|
||||||
|
assert_eq!(sheet.sort_state(), Some((0, true)));
|
||||||
|
assert_eq!(sheet.get_raw(0, 0), "1");
|
||||||
|
assert_eq!(sheet.get_raw(2, 0), "3");
|
||||||
|
|
||||||
|
sheet.unsort_rows();
|
||||||
|
assert_eq!(sheet.sort_state(), None);
|
||||||
|
assert_eq!(sheet.get_raw(0, 0), "3");
|
||||||
|
assert_eq!(sheet.get_raw(0, 1), "c");
|
||||||
|
assert_eq!(sheet.get_raw(1, 0), "1");
|
||||||
|
assert_eq!(sheet.get_raw(2, 0), "2");
|
||||||
|
assert_eq!(sheet.get_raw(2, 1), "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chain of sorts composes: unsorting once undoes all of them, not
|
||||||
|
/// just the last.
|
||||||
|
#[test]
|
||||||
|
fn unsort_undoes_the_whole_sort_chain() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
// Distinct values in two columns so the two sorts actually differ.
|
||||||
|
sheet.set_cell(0, 0, "30");
|
||||||
|
sheet.set_cell(0, 1, "1");
|
||||||
|
sheet.set_cell(1, 0, "10");
|
||||||
|
sheet.set_cell(1, 1, "3");
|
||||||
|
sheet.set_cell(2, 0, "20");
|
||||||
|
sheet.set_cell(2, 1, "2");
|
||||||
|
|
||||||
|
sheet.sort_rows(true, 0); // order by A asc
|
||||||
|
sheet.sort_rows(true, 1); // then by B asc
|
||||||
|
|
||||||
|
sheet.unsort_rows();
|
||||||
|
assert_eq!(sheet.get_raw(0, 0), "30");
|
||||||
|
assert_eq!(sheet.get_raw(0, 1), "1");
|
||||||
|
assert_eq!(sheet.get_raw(1, 0), "10");
|
||||||
|
assert_eq!(sheet.get_raw(1, 1), "3");
|
||||||
|
assert_eq!(sheet.get_raw(2, 0), "20");
|
||||||
|
assert_eq!(sheet.get_raw(2, 1), "2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsorting without a sort is a no-op, not a panic.
|
||||||
|
#[test]
|
||||||
|
fn unsort_without_a_sort_is_a_noop() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
sheet.set_cell(0, 0, "x");
|
||||||
|
sheet.unsort_rows();
|
||||||
|
assert_eq!(sheet.get_raw(0, 0), "x");
|
||||||
|
assert_eq!(sheet.sort_state(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sort and its unsort both clear undo history.
|
||||||
|
#[test]
|
||||||
|
fn unsort_is_not_undoable() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
sheet.snapshot();
|
||||||
|
sheet.set_cell(0, 0, "b");
|
||||||
|
sheet.set_cell(1, 0, "a");
|
||||||
|
sheet.sort_rows(true, 0);
|
||||||
|
assert!(!sheet.undo(), "sort must not be undoable");
|
||||||
|
|
||||||
|
sheet.unsort_rows();
|
||||||
|
assert!(!sheet.undo(), "unsort must not be undoable");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsorting also recalculates moved formulas.
|
||||||
|
#[test]
|
||||||
|
fn unsort_recalculates_formulas() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
sheet.set_cell(0, 0, "2");
|
||||||
|
sheet.set_cell(0, 1, "=A1");
|
||||||
|
sheet.set_cell(1, 0, "1");
|
||||||
|
sheet.set_cell(1, 1, "=A2");
|
||||||
|
|
||||||
|
sheet.sort_rows(true, 0);
|
||||||
|
sheet.unsort_rows();
|
||||||
|
|
||||||
|
assert_eq!(sheet.get_raw(0, 0), "2");
|
||||||
|
assert_eq!(sheet.get_display_value(0, 1), "2");
|
||||||
|
assert_eq!(sheet.get_raw(1, 0), "1");
|
||||||
|
assert_eq!(sheet.get_display_value(1, 1), "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sort order is transient: it survives cell edits but is reset by
|
||||||
|
/// a deserialize (fresh data has no sort).
|
||||||
|
#[test]
|
||||||
|
fn sort_order_is_cleared_on_deserialize() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
sheet.set_cell(0, 0, "3");
|
||||||
|
sheet.set_cell(1, 0, "1");
|
||||||
|
sheet.sort_rows(true, 0);
|
||||||
|
assert!(sheet.sort_state().is_some());
|
||||||
|
|
||||||
|
let serialized = sheet.serialize();
|
||||||
|
let mut restored = SpreadsheetData::default();
|
||||||
|
restored.deserialize(&serialized);
|
||||||
|
assert_eq!(restored.sort_state(), None);
|
||||||
|
// Cells serialize in their sorted positions; the restored sheet
|
||||||
|
// holds them there with no active sort.
|
||||||
|
assert_eq!(restored.get_raw(0, 0), "1");
|
||||||
|
assert_eq!(restored.get_raw(1, 0), "3");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dropdown_button_style_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The button flag and dropdown choice list survive a serialize →
|
||||||
|
/// deserialize round trip, including choices with escaped separators.
|
||||||
|
#[test]
|
||||||
|
fn button_and_choices_survive_round_trip() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
sheet.set_cell(0, 0, "B2+10");
|
||||||
|
sheet.set_cell(1, 0, "Low");
|
||||||
|
sheet
|
||||||
|
.cells
|
||||||
|
.get_mut(&CellId::new(0, 0))
|
||||||
|
.unwrap()
|
||||||
|
.style
|
||||||
|
.button = true;
|
||||||
|
sheet
|
||||||
|
.cells
|
||||||
|
.get_mut(&CellId::new(1, 0))
|
||||||
|
.unwrap()
|
||||||
|
.style
|
||||||
|
.choices = vec!["Low".into(), "Med|ium".into(), "High".into()];
|
||||||
|
|
||||||
|
let serialized = sheet.serialize();
|
||||||
|
let mut restored = SpreadsheetData::default();
|
||||||
|
restored.deserialize(&serialized);
|
||||||
|
|
||||||
|
assert!(restored.cells.get(&CellId::new(0, 0)).unwrap().style.button);
|
||||||
|
assert!(!restored.cells.get(&CellId::new(1, 0)).unwrap().style.button);
|
||||||
|
assert_eq!(
|
||||||
|
restored
|
||||||
|
.cells
|
||||||
|
.get(&CellId::new(1, 0))
|
||||||
|
.unwrap()
|
||||||
|
.style
|
||||||
|
.choices,
|
||||||
|
vec!["Low".to_string(), "Med|ium".to_string(), "High".to_string()]
|
||||||
|
);
|
||||||
|
assert!(restored
|
||||||
|
.cells
|
||||||
|
.get(&CellId::new(0, 0))
|
||||||
|
.unwrap()
|
||||||
|
.style
|
||||||
|
.choices
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `apply_command` routes SetChoices and SetButton to the cell style,
|
||||||
|
/// creating the cell if it was empty.
|
||||||
|
#[test]
|
||||||
|
fn apply_command_sets_choices_and_button() {
|
||||||
|
let mut sheet = SpreadsheetData::default();
|
||||||
|
sheet.apply_command(WorkbookCommand::SetButton {
|
||||||
|
row: 0,
|
||||||
|
col: 0,
|
||||||
|
button: true,
|
||||||
|
});
|
||||||
|
sheet.apply_command(WorkbookCommand::SetChoices {
|
||||||
|
row: 0,
|
||||||
|
col: 1,
|
||||||
|
choices: vec!["a".into(), "b".into()],
|
||||||
|
});
|
||||||
|
assert!(sheet.cells.get(&CellId::new(0, 0)).unwrap().style.button);
|
||||||
|
assert_eq!(
|
||||||
|
sheet.cells.get(&CellId::new(0, 1)).unwrap().style.choices,
|
||||||
|
vec!["a".to_string(), "b".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clearing the choices empties the list.
|
||||||
|
sheet.apply_command(WorkbookCommand::SetChoices {
|
||||||
|
row: 0,
|
||||||
|
col: 1,
|
||||||
|
choices: vec![],
|
||||||
|
});
|
||||||
|
assert!(sheet
|
||||||
|
.cells
|
||||||
|
.get(&CellId::new(0, 1))
|
||||||
|
.unwrap()
|
||||||
|
.style
|
||||||
|
.choices
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,12 @@ pub struct CellStyle {
|
||||||
pub markdown: bool,
|
pub markdown: bool,
|
||||||
/// Render a numeric value cell as a slider (0–100) instead of text.
|
/// Render a numeric value cell as a slider (0–100) instead of text.
|
||||||
pub slider: bool,
|
pub slider: bool,
|
||||||
|
/// Render the cell as a dropdown: its value is the current selection
|
||||||
|
/// and `choices` is the list it cycles through on click.
|
||||||
|
pub choices: Vec<String>,
|
||||||
|
/// Render the cell as a button: its value is a `TARGET[+N]` spec that
|
||||||
|
/// increments the target cell on click.
|
||||||
|
pub button: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Serialization helpers ---
|
// --- Serialization helpers ---
|
||||||
|
|
@ -181,6 +187,43 @@ pub fn color_from_str(s: &str) -> Option<Color> {
|
||||||
Some(Color::new(r, g, b, a))
|
Some(Color::new(r, g, b, a))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize a dropdown's choice list as one `|`-separated field, escaping
|
||||||
|
/// `|` and `\` so choices containing either round-trip.
|
||||||
|
pub fn choices_to_str(choices: &[String]) -> String {
|
||||||
|
choices
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.replace('\\', "\\\\").replace('|', "\\|"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`choices_to_str`]: an empty string is an empty list.
|
||||||
|
pub fn choices_from_str(s: &str) -> Vec<String> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut chars = s.chars();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
match ch {
|
||||||
|
'\\' => match chars.next() {
|
||||||
|
Some('|') => cur.push('|'),
|
||||||
|
Some('\\') => cur.push('\\'),
|
||||||
|
Some(other) => {
|
||||||
|
cur.push('\\');
|
||||||
|
cur.push(other);
|
||||||
|
}
|
||||||
|
None => cur.push('\\'),
|
||||||
|
},
|
||||||
|
'|' => out.push(std::mem::take(&mut cur)),
|
||||||
|
other => cur.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -255,4 +298,29 @@ mod tests {
|
||||||
assert_eq!(NumberFormat::default(), NumberFormat::General);
|
assert_eq!(NumberFormat::default(), NumberFormat::General);
|
||||||
assert_eq!(CellAlign::default(), CellAlign::Left);
|
assert_eq!(CellAlign::default(), CellAlign::Left);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The choice list round-trips, including choices that contain the
|
||||||
|
/// separators themselves.
|
||||||
|
#[test]
|
||||||
|
fn choices_round_trip() {
|
||||||
|
for list in [
|
||||||
|
vec!["Low".to_string(), "Medium".to_string(), "High".to_string()],
|
||||||
|
vec!["a|b".to_string(), "c".to_string()],
|
||||||
|
vec!["back\\slash".to_string(), "pipe|back\\both".to_string()],
|
||||||
|
vec!["single".to_string()],
|
||||||
|
vec![],
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
choices_from_str(&choices_to_str(&list)),
|
||||||
|
list,
|
||||||
|
"round trip failed for {list:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty string is an empty list.
|
||||||
|
#[test]
|
||||||
|
fn choices_empty_string_is_an_empty_list() {
|
||||||
|
assert!(choices_from_str("").is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,18 @@ pub enum WorkbookCommand {
|
||||||
col: u32,
|
col: u32,
|
||||||
slider: bool,
|
slider: bool,
|
||||||
},
|
},
|
||||||
|
/// Set a cell's dropdown choice list (empty clears it).
|
||||||
|
SetChoices {
|
||||||
|
row: u32,
|
||||||
|
col: u32,
|
||||||
|
choices: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Render the cell as a button (`TARGET[+N]` action on click).
|
||||||
|
SetButton {
|
||||||
|
row: u32,
|
||||||
|
col: u32,
|
||||||
|
button: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Workbook {
|
pub struct Workbook {
|
||||||
|
|
@ -402,6 +414,14 @@ impl Workbook {
|
||||||
self.active_data_mut()
|
self.active_data_mut()
|
||||||
.mutate_cell(row, col, |cell| cell.style.slider = slider);
|
.mutate_cell(row, col, |cell| cell.style.slider = slider);
|
||||||
}
|
}
|
||||||
|
WorkbookCommand::SetChoices { row, col, choices } => {
|
||||||
|
self.active_data_mut()
|
||||||
|
.mutate_cell(row, col, |cell| cell.style.choices = choices);
|
||||||
|
}
|
||||||
|
WorkbookCommand::SetButton { row, col, button } => {
|
||||||
|
self.active_data_mut()
|
||||||
|
.mutate_cell(row, col, |cell| cell.style.button = button);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Value-changing commands may move data other sheets read.
|
// Value-changing commands may move data other sheets read.
|
||||||
let active = self.active;
|
let active = self.active;
|
||||||
|
|
@ -555,6 +575,19 @@ impl Workbook {
|
||||||
self.after_value_mutation(active);
|
self.after_value_mutation(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore the active sheet's rows to their pre-sort order (the header
|
||||||
|
/// sort's "off" state). Cross-sheet dependents are recalculated.
|
||||||
|
pub fn unsort_active_sheet(&mut self) {
|
||||||
|
let active = self.active;
|
||||||
|
self.sheets[active].data.unsort_rows();
|
||||||
|
self.after_value_mutation(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active sheet's sort, if any: `(key column, ascending)`.
|
||||||
|
pub fn active_sort_state(&self) -> Option<(u32, bool)> {
|
||||||
|
self.active_data().sort_state()
|
||||||
|
}
|
||||||
|
|
||||||
/// Move a column of the active sheet from its current position to the
|
/// Move a column of the active sheet from its current position to the
|
||||||
/// insertion index `to`. Cross-sheet dependents are recalculated so they
|
/// insertion index `to`. Cross-sheet dependents are recalculated so they
|
||||||
/// see the rearranged values.
|
/// see the rearranged values.
|
||||||
|
|
@ -1538,6 +1571,14 @@ mod tests {
|
||||||
// The reader must now see the sorted top value.
|
// The reader must now see the sorted top value.
|
||||||
wb.set_active_sheet(0);
|
wb.set_active_sheet(0);
|
||||||
assert_eq!(wb.get_display_value(0, 0), "1");
|
assert_eq!(wb.get_display_value(0, 0), "1");
|
||||||
|
|
||||||
|
// Unsorting restores the original order, and the reader follows.
|
||||||
|
wb.set_active_sheet(1);
|
||||||
|
assert_eq!(wb.active_sort_state(), Some((0, true)));
|
||||||
|
wb.unsort_active_sheet();
|
||||||
|
assert_eq!(wb.active_sort_state(), None);
|
||||||
|
wb.set_active_sheet(0);
|
||||||
|
assert_eq!(wb.get_display_value(0, 0), "3");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moving a column works at the workbook level, and a cross-sheet
|
/// Moving a column works at the workbook level, and a cross-sheet
|
||||||
|
|
|
||||||
109
crates/apps/spreadsheet/spreadsheet-ui/src/button.rs
Normal file
109
crates/apps/spreadsheet/spreadsheet-ui/src/button.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
//! Button-cell logic, extracted from the grid for testability.
|
||||||
|
//!
|
||||||
|
//! A cell whose `style.button` flag is set renders as a button and its
|
||||||
|
//! value is an action spec: `TARGET[+N]` / `TARGET[-N]`, where `TARGET` is
|
||||||
|
//! an A1-style cell reference and the optional signed `N` is the increment
|
||||||
|
//! (default +1). Clicking increments the target cell's numeric value —
|
||||||
|
//! the spreadsheet-native counterpart of the reference datagrid's "+10"
|
||||||
|
//! boost button.
|
||||||
|
|
||||||
|
use spreadsheet_engine::data::{SpreadsheetData, NUM_COLS, NUM_ROWS};
|
||||||
|
|
||||||
|
/// Parse a button action spec into `(target_row, target_col, step)`.
|
||||||
|
/// `None` for a malformed spec or an out-of-bounds target.
|
||||||
|
pub fn parse_button_spec(value: &str) -> Option<(u32, u32, f64)> {
|
||||||
|
let v = value.trim();
|
||||||
|
if v.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let chars: Vec<char> = v.chars().collect();
|
||||||
|
// The step starts at the first '+'/'-' after the reference.
|
||||||
|
let mut split = None;
|
||||||
|
for (n, &c) in chars.iter().enumerate() {
|
||||||
|
if n > 0 && (c == '+' || c == '-') {
|
||||||
|
split = Some(n);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (ref_part, step) = match split {
|
||||||
|
Some(n) => {
|
||||||
|
let ref_s: String = chars[..n].iter().collect();
|
||||||
|
let step_s: String = chars[n..].iter().collect();
|
||||||
|
(ref_s, step_s.parse::<f64>().ok()?)
|
||||||
|
}
|
||||||
|
None => (v.to_string(), 1.0),
|
||||||
|
};
|
||||||
|
let (row, col) = SpreadsheetData::parse_cell_ref(ref_part.trim())?;
|
||||||
|
if row >= NUM_ROWS || col >= NUM_COLS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((row, col, step))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target's new value after a button step: `current + step`, or the
|
||||||
|
/// step itself (never below zero) for an empty/non-numeric target.
|
||||||
|
pub fn button_step(current: Option<f64>, step: f64) -> f64 {
|
||||||
|
match current {
|
||||||
|
Some(v) => v + step,
|
||||||
|
None => step.max(0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a stepped value: integers render without a fraction, fractional
|
||||||
|
/// steps keep their digits.
|
||||||
|
pub fn format_step_value(v: f64) -> String {
|
||||||
|
if v.fract().abs() < 1e-9 {
|
||||||
|
format!("{:.0}", v)
|
||||||
|
} else {
|
||||||
|
format!("{}", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `REF`, `REF+N` and `REF-N` all parse.
|
||||||
|
#[test]
|
||||||
|
fn specs_parse_to_target_and_step() {
|
||||||
|
assert_eq!(parse_button_spec("B2"), Some((1, 1, 1.0)));
|
||||||
|
assert_eq!(parse_button_spec("B2+10"), Some((1, 1, 10.0)));
|
||||||
|
assert_eq!(parse_button_spec("B2-5"), Some((1, 1, -5.0)));
|
||||||
|
assert_eq!(parse_button_spec("A1+0.5"), Some((0, 0, 0.5)));
|
||||||
|
assert_eq!(parse_button_spec(" Z42 +3 "), Some((41, 25, 3.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Malformed specs and out-of-bounds targets yield `None`.
|
||||||
|
#[test]
|
||||||
|
fn bad_specs_are_rejected() {
|
||||||
|
assert_eq!(parse_button_spec(""), None);
|
||||||
|
assert_eq!(parse_button_spec("not a ref"), None);
|
||||||
|
assert_eq!(parse_button_spec("+10"), None, "no target before the step");
|
||||||
|
assert_eq!(parse_button_spec("B2+abc"), None);
|
||||||
|
// A target column past the grid is rejected.
|
||||||
|
assert_eq!(parse_button_spec("A99999"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stepping adds to an existing value and starts an empty target at the
|
||||||
|
/// step (clamped to zero for a negative step).
|
||||||
|
#[test]
|
||||||
|
fn steps_apply_to_numeric_and_empty_targets() {
|
||||||
|
assert_eq!(button_step(Some(42.0), 10.0), 52.0);
|
||||||
|
assert_eq!(button_step(Some(42.0), -10.0), 32.0);
|
||||||
|
assert_eq!(button_step(None, 10.0), 10.0);
|
||||||
|
assert_eq!(
|
||||||
|
button_step(None, -10.0),
|
||||||
|
0.0,
|
||||||
|
"a decrement can't start negative"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stepped value formats cleanly for a cell.
|
||||||
|
#[test]
|
||||||
|
fn step_values_format() {
|
||||||
|
assert_eq!(format_step_value(52.0), "52");
|
||||||
|
assert_eq!(format_step_value(0.0), "0");
|
||||||
|
assert_eq!(format_step_value(0.5), "0.5");
|
||||||
|
assert_eq!(format_step_value(-3.25), "-3.25");
|
||||||
|
}
|
||||||
|
}
|
||||||
77
crates/apps/spreadsheet/spreadsheet-ui/src/dropdown.rs
Normal file
77
crates/apps/spreadsheet/spreadsheet-ui/src/dropdown.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
//! Dropdown-cell logic, extracted from the grid for testability.
|
||||||
|
//!
|
||||||
|
//! A cell whose `style.choices` is non-empty renders as a dropdown: its
|
||||||
|
//! value is the current selection and a click cycles to the next choice.
|
||||||
|
//! The list is stored on the cell style; the toolbar's "Drop" button parses
|
||||||
|
//! the cell value as a `|`-separated list to set it.
|
||||||
|
|
||||||
|
/// The next choice after `current`, cycling to the front at the end. When
|
||||||
|
/// `current` is not in the list (e.g. the list was just set), the first
|
||||||
|
/// choice is returned. An empty list yields `None`.
|
||||||
|
pub fn next_choice(current: &str, choices: &[String]) -> Option<String> {
|
||||||
|
if choices.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match choices.iter().position(|c| c == current) {
|
||||||
|
Some(i) => Some(choices[(i + 1) % choices.len()].clone()),
|
||||||
|
None => Some(choices[0].clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a pipe-separated choice list from a cell value (`"Low|Med|High"`).
|
||||||
|
/// Entries are trimmed and empty entries dropped.
|
||||||
|
pub fn parse_choice_list(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split('|')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Cycling walks the list in order and wraps around.
|
||||||
|
#[test]
|
||||||
|
fn next_choice_cycles_in_order() {
|
||||||
|
let choices = ["Low", "Medium", "High"].map(String::from).to_vec();
|
||||||
|
assert_eq!(next_choice("Low", &choices).as_deref(), Some("Medium"));
|
||||||
|
assert_eq!(next_choice("Medium", &choices).as_deref(), Some("High"));
|
||||||
|
assert_eq!(next_choice("High", &choices).as_deref(), Some("Low"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A current value not in the list starts at the first choice.
|
||||||
|
#[test]
|
||||||
|
fn next_choice_starts_at_the_first_when_absent() {
|
||||||
|
let choices = ["a", "b"].map(String::from).to_vec();
|
||||||
|
assert_eq!(next_choice("zzz", &choices).as_deref(), Some("a"));
|
||||||
|
assert_eq!(next_choice("", &choices).as_deref(), Some("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty list has no next choice.
|
||||||
|
#[test]
|
||||||
|
fn next_choice_of_an_empty_list_is_none() {
|
||||||
|
assert_eq!(next_choice("x", &[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pipe list parse trims and drops empties.
|
||||||
|
#[test]
|
||||||
|
fn parse_choice_list_splits_trims_and_drops_empties() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_choice_list("Low|Medium|High"),
|
||||||
|
vec!["Low".to_string(), "Medium".to_string(), "High".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_choice_list(" a | b "),
|
||||||
|
vec!["a".to_string(), "b".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_choice_list("a||b"),
|
||||||
|
vec!["a".to_string(), "b".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(parse_choice_list(""), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_choice_list("only"), vec!["only".to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -548,6 +548,22 @@ impl Widget for SpreadsheetGrid {
|
||||||
self.request_commit_edit();
|
self.request_commit_edit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A button cell runs its TARGET[+N] action on click.
|
||||||
|
if self.apply_button_cell(cx, row, col) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dropdown cell cycles to its next choice on click.
|
||||||
|
if self.cycle_dropdown_cell(cx, row, col) {
|
||||||
|
self.input_controller.record(InputIntent::Select {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
extend: false,
|
||||||
|
});
|
||||||
|
cx.widget_action(self.widget_uid(), CellAction::Selected(row, col));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// A slider cell jumps to the pressed position and then
|
// A slider cell jumps to the pressed position and then
|
||||||
// drags; one snapshot covers the whole drag.
|
// drags; one snapshot covers the whole drag.
|
||||||
if self.cell_slider_value(row, col).is_some() {
|
if self.cell_slider_value(row, col).is_some() {
|
||||||
|
|
@ -1095,7 +1111,9 @@ impl SpreadsheetGrid {
|
||||||
| WorkbookCommand::SetBackground { row, col, .. }
|
| WorkbookCommand::SetBackground { row, col, .. }
|
||||||
| WorkbookCommand::SetBorders { row, col, .. }
|
| WorkbookCommand::SetBorders { row, col, .. }
|
||||||
| WorkbookCommand::SetMarkdown { row, col, .. }
|
| WorkbookCommand::SetMarkdown { row, col, .. }
|
||||||
| WorkbookCommand::SetSlider { row, col, .. } => Some(CellId::new(*row, *col)),
|
| WorkbookCommand::SetSlider { row, col, .. }
|
||||||
|
| WorkbookCommand::SetChoices { row, col, .. }
|
||||||
|
| WorkbookCommand::SetButton { row, col, .. } => Some(CellId::new(*row, *col)),
|
||||||
WorkbookCommand::SetColumnWidth { .. }
|
WorkbookCommand::SetColumnWidth { .. }
|
||||||
| WorkbookCommand::SetRowHeight { .. }
|
| WorkbookCommand::SetRowHeight { .. }
|
||||||
| WorkbookCommand::MoveColumn { .. } => None,
|
| WorkbookCommand::MoveColumn { .. } => None,
|
||||||
|
|
@ -1722,7 +1740,8 @@ impl SpreadsheetGrid {
|
||||||
let checkbox_layout =
|
let checkbox_layout =
|
||||||
crate::checkbox::checkbox_layout(x, y, cw, rh, checkbox_checked);
|
crate::checkbox::checkbox_layout(x, y, cw, rh, checkbox_checked);
|
||||||
|
|
||||||
// Markdown and slider are style flags on the cell.
|
// Markdown, slider, dropdown and button are style flags on
|
||||||
|
// the cell.
|
||||||
let is_markdown = cached_state
|
let is_markdown = cached_state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.markdown)
|
.map(|s| s.markdown)
|
||||||
|
|
@ -1733,6 +1752,16 @@ impl SpreadsheetGrid {
|
||||||
.map(|s| s.slider)
|
.map(|s| s.slider)
|
||||||
.or_else(|| cell_ref.map(|c| c.style.slider))
|
.or_else(|| cell_ref.map(|c| c.style.slider))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let has_choices = cached_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| !s.choices.is_empty())
|
||||||
|
.or_else(|| cell_ref.map(|c| !c.style.choices.is_empty()))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let is_button = cached_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.button)
|
||||||
|
.or_else(|| cell_ref.map(|c| c.style.button))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
self.draw_cell_bg.color = if is_selected {
|
self.draw_cell_bg.color = if is_selected {
|
||||||
self.selected_bg_color
|
self.selected_bg_color
|
||||||
|
|
@ -1827,7 +1856,45 @@ impl SpreadsheetGrid {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
if let Some(v) = slider_value {
|
if is_button && !is_editing_this_cell {
|
||||||
|
// Button: a raised box with the action spec centred.
|
||||||
|
let bx = x + 2.0;
|
||||||
|
let by = y + 2.0;
|
||||||
|
let bw = (cw - 4.0).max(1.0);
|
||||||
|
let bh = (rh - 4.0).max(1.0);
|
||||||
|
self.draw_cell_bg.color = self.selected_bg_color;
|
||||||
|
self.draw_cell_bg.draw_abs(
|
||||||
|
cx,
|
||||||
|
Rect {
|
||||||
|
pos: dvec2(bx, by),
|
||||||
|
size: dvec2(bw, bh),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.draw_grid_line.color = self.selected_border_color;
|
||||||
|
for &(ex, ey, ew, eh) in &[
|
||||||
|
(bx, by, bw, 1.0),
|
||||||
|
(bx, by + bh - 1.0, bw, 1.0),
|
||||||
|
(bx, by, 1.0, bh),
|
||||||
|
(bx + bw - 1.0, by, 1.0, bh),
|
||||||
|
] {
|
||||||
|
self.draw_grid_line.draw_abs(
|
||||||
|
cx,
|
||||||
|
Rect {
|
||||||
|
pos: dvec2(ex, ey),
|
||||||
|
size: dvec2(ew, eh),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !display_is_empty {
|
||||||
|
let w = self.text_measure_cache.measure(&display_buf, false);
|
||||||
|
self.draw_text.color = self.text_color;
|
||||||
|
self.draw_text.draw_abs(
|
||||||
|
cx,
|
||||||
|
dvec2(x + (cw - w) * 0.5, y + (rh - 14.0) * 0.5),
|
||||||
|
&display_buf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if let Some(v) = slider_value {
|
||||||
let layout = crate::slider::slider_layout(x, y, cw, rh, v);
|
let layout = crate::slider::slider_layout(x, y, cw, rh, v);
|
||||||
self.draw_grid_line.color = self.header_border_color;
|
self.draw_grid_line.color = self.header_border_color;
|
||||||
let (tx, ty, tw, th) = layout.track;
|
let (tx, ty, tw, th) = layout.track;
|
||||||
|
|
@ -1963,6 +2030,8 @@ impl SpreadsheetGrid {
|
||||||
sparkline: cell.sparkline.clone(),
|
sparkline: cell.sparkline.clone(),
|
||||||
markdown: cell.style.markdown,
|
markdown: cell.style.markdown,
|
||||||
slider: cell.style.slider,
|
slider: cell.style.slider,
|
||||||
|
choices: cell.style.choices.clone(),
|
||||||
|
button: cell.style.button,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1986,6 +2055,13 @@ impl SpreadsheetGrid {
|
||||||
};
|
};
|
||||||
draw_text.draw_abs(cx, dvec2(text_x, text_y), &display_buf);
|
draw_text.draw_abs(cx, dvec2(text_x, text_y), &display_buf);
|
||||||
|
|
||||||
|
// A dropdown shows a trailing ▾ affordance.
|
||||||
|
if has_choices {
|
||||||
|
self.draw_header_text.color = self.header_text_color;
|
||||||
|
self.draw_header_text
|
||||||
|
.draw_abs(cx, dvec2(x + cw - 16.0, text_y), "▾");
|
||||||
|
}
|
||||||
|
|
||||||
if cached_state
|
if cached_state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|state| state.underline)
|
.map(|state| state.underline)
|
||||||
|
|
@ -2243,6 +2319,62 @@ impl SpreadsheetGrid {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If `(row, col)` is a button cell, run its `TARGET[+N]` action:
|
||||||
|
/// increment the target cell's numeric value (undoable). Returns `true`
|
||||||
|
/// when an action ran.
|
||||||
|
fn apply_button_cell(&mut self, cx: &mut Cx, row: u32, col: u32) -> bool {
|
||||||
|
let spec = self.with_data(|data| {
|
||||||
|
data.cells
|
||||||
|
.get(&CellId::new(row, col))
|
||||||
|
.filter(|c| c.style.button && c.formula.is_none())
|
||||||
|
.map(|c| c.value.clone())
|
||||||
|
});
|
||||||
|
let Some(spec) = spec else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((tr, tc, step)) = crate::button::parse_button_spec(&spec) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let current = self.with_data(|data| {
|
||||||
|
data.cells
|
||||||
|
.get(&CellId::new(tr, tc))
|
||||||
|
.and_then(|c| c.value.trim().parse::<f64>().ok())
|
||||||
|
});
|
||||||
|
let new_val = crate::button::button_step(current, step);
|
||||||
|
let text = crate::button::format_step_value(new_val);
|
||||||
|
self.with_data_mut(|data| data.snapshot());
|
||||||
|
self.apply_command(WorkbookCommand::SetCell {
|
||||||
|
row: tr,
|
||||||
|
col: tc,
|
||||||
|
value: text,
|
||||||
|
});
|
||||||
|
self.redraw(cx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `(row, col)` is a dropdown cell, cycle its value to the next
|
||||||
|
/// choice (undoable). Returns `true` when it cycled.
|
||||||
|
fn cycle_dropdown_cell(&mut self, cx: &mut Cx, row: u32, col: u32) -> bool {
|
||||||
|
let (current, choices) = self.with_data(|data| {
|
||||||
|
data.cells
|
||||||
|
.get(&CellId::new(row, col))
|
||||||
|
.filter(|c| c.formula.is_none() && !c.style.choices.is_empty())
|
||||||
|
.map(|c| (c.value.clone(), c.style.choices.clone()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
let Some(next) = crate::dropdown::next_choice(¤t, &choices) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.with_data_mut(|data| data.snapshot());
|
||||||
|
self.apply_command(WorkbookCommand::SetCell {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
value: next,
|
||||||
|
});
|
||||||
|
self.redraw(cx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn request_begin_edit(&mut self, row: u32, col: u32) {
|
fn request_begin_edit(&mut self, row: u32, col: u32) {
|
||||||
self.input_controller.record(route_edit_begin(row, col));
|
self.input_controller.record(route_edit_begin(row, col));
|
||||||
}
|
}
|
||||||
|
|
@ -2726,6 +2858,89 @@ impl SpreadsheetGrid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn toggle_button(&mut self, cx: &mut Cx) {
|
||||||
|
if let Some(sel) = self.selection_bounds() {
|
||||||
|
self.with_data_mut(|data| data.snapshot());
|
||||||
|
let mut any_on = false;
|
||||||
|
for r in sel.0..=sel.2 {
|
||||||
|
for c in sel.1..=sel.3 {
|
||||||
|
let on = self.with_data(|data| {
|
||||||
|
data.cells
|
||||||
|
.get(&CellId::new(r, c))
|
||||||
|
.map(|cell| cell.style.button)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
any_on |= on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let target = !any_on;
|
||||||
|
for r in sel.0..=sel.2 {
|
||||||
|
for c in sel.1..=sel.3 {
|
||||||
|
self.apply_command(WorkbookCommand::SetButton {
|
||||||
|
row: r,
|
||||||
|
col: c,
|
||||||
|
button: target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.redraw(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle dropdowns on the selection. Turning on parses each cell's
|
||||||
|
/// value as a `|`-separated choice list and selects the first choice;
|
||||||
|
/// turning off clears the list, leaving the value as plain text.
|
||||||
|
pub fn toggle_dropdown(&mut self, cx: &mut Cx) {
|
||||||
|
if let Some(sel) = self.selection_bounds() {
|
||||||
|
self.with_data_mut(|data| data.snapshot());
|
||||||
|
let mut any_on = false;
|
||||||
|
for r in sel.0..=sel.2 {
|
||||||
|
for c in sel.1..=sel.3 {
|
||||||
|
let on = self.with_data(|data| {
|
||||||
|
data.cells
|
||||||
|
.get(&CellId::new(r, c))
|
||||||
|
.map(|cell| !cell.style.choices.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
any_on |= on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let target = !any_on;
|
||||||
|
for r in sel.0..=sel.2 {
|
||||||
|
for c in sel.1..=sel.3 {
|
||||||
|
if target {
|
||||||
|
let value = self.with_data(|data| {
|
||||||
|
data.cells
|
||||||
|
.get(&CellId::new(r, c))
|
||||||
|
.map(|cell| cell.value.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
let choices = crate::dropdown::parse_choice_list(&value);
|
||||||
|
if let Some(first) = choices.first().cloned() {
|
||||||
|
self.apply_command(WorkbookCommand::SetChoices {
|
||||||
|
row: r,
|
||||||
|
col: c,
|
||||||
|
choices,
|
||||||
|
});
|
||||||
|
self.apply_command(WorkbookCommand::SetCell {
|
||||||
|
row: r,
|
||||||
|
col: c,
|
||||||
|
value: first,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.apply_command(WorkbookCommand::SetChoices {
|
||||||
|
row: r,
|
||||||
|
col: c,
|
||||||
|
choices: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.redraw(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_borders(&mut self, cx: &mut Cx, target: BorderTarget) {
|
pub fn set_borders(&mut self, cx: &mut Cx, target: BorderTarget) {
|
||||||
if let Some(sel) = self.selection_bounds() {
|
if let Some(sel) = self.selection_bounds() {
|
||||||
self.with_data_mut(|data| data.snapshot());
|
self.with_data_mut(|data| data.snapshot());
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@
|
||||||
//! Depends on `spreadsheet-engine` for all logic. Converts `Color`
|
//! Depends on `spreadsheet-engine` for all logic. Converts `Color`
|
||||||
//! ↔ `Vec4f` at the rendering boundary.
|
//! ↔ `Vec4f` at the rendering boundary.
|
||||||
|
|
||||||
|
pub mod button;
|
||||||
pub mod chart;
|
pub mod chart;
|
||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
|
pub mod dropdown;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub mod event_router;
|
pub mod event_router;
|
||||||
pub mod formula_bar;
|
pub mod formula_bar;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ pub struct CellRenderState {
|
||||||
pub markdown: bool,
|
pub markdown: bool,
|
||||||
/// The cell renders as a slider (numeric value).
|
/// The cell renders as a slider (numeric value).
|
||||||
pub slider: bool,
|
pub slider: bool,
|
||||||
|
/// The cell renders as a dropdown; `choices` is its cycle list.
|
||||||
|
pub choices: Vec<String>,
|
||||||
|
/// The cell renders as a button (`TARGET[+N]` action).
|
||||||
|
pub button: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Default, Debug)]
|
||||||
|
|
@ -152,6 +156,8 @@ mod tests {
|
||||||
sparkline: None,
|
sparkline: None,
|
||||||
markdown: false,
|
markdown: false,
|
||||||
slider: false,
|
slider: false,
|
||||||
|
choices: Vec::new(),
|
||||||
|
button: false,
|
||||||
};
|
};
|
||||||
cache.insert(id, state.clone());
|
cache.insert(id, state.clone());
|
||||||
assert_eq!(cache.get(id), Some(&state));
|
assert_eq!(cache.get(id), Some(&state));
|
||||||
|
|
@ -189,6 +195,8 @@ mod tests {
|
||||||
sparkline: None,
|
sparkline: None,
|
||||||
markdown: false,
|
markdown: false,
|
||||||
slider: false,
|
slider: false,
|
||||||
|
choices: Vec::new(),
|
||||||
|
button: false,
|
||||||
});
|
});
|
||||||
assert_eq!(built.display_text, "built");
|
assert_eq!(built.display_text, "built");
|
||||||
assert!(built.is_formula);
|
assert!(built.is_formula);
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,20 @@
|
||||||
//! unit-tested; the decision it makes — which column is sorted next, and in
|
//! unit-tested; the decision it makes — which column is sorted next, and in
|
||||||
//! which direction — is pure, so it lives here.
|
//! which direction — is pure, so it lives here.
|
||||||
|
|
||||||
/// Advance the header sort state after a click on `col`. Clicking the
|
/// Advance the header sort state after a click on `col`, matching the
|
||||||
/// already-sorted column flips its direction; clicking any other column
|
/// datagrid reference's ascending → descending → off cycle:
|
||||||
/// starts a fresh ascending sort on that column.
|
|
||||||
///
|
///
|
||||||
/// The datagrid reference cycles ascending → descending → off. We stop at
|
/// - clicking a new column starts ascending on it;
|
||||||
/// two states: `sort_rows` physically permutes the sheet and clears its undo
|
/// - clicking the ascending column flips it to descending;
|
||||||
/// history, so there is no earlier order for "off" to restore. A future
|
/// - clicking the descending column turns the sort off (`None`).
|
||||||
/// display-order sort could widen this back to three states.
|
///
|
||||||
pub fn next_sort_direction(current: Option<(u32, bool)>, col: u32) -> (u32, bool) {
|
/// "Off" is `None` and is applied by the engine's `unsort_rows`, which
|
||||||
|
/// restores the pre-sort order.
|
||||||
|
pub fn next_sort_state(current: Option<(u32, bool)>, col: u32) -> Option<(u32, bool)> {
|
||||||
match current {
|
match current {
|
||||||
Some((c, asc)) if c == col => (col, !asc),
|
Some((c, true)) if c == col => Some((col, false)),
|
||||||
_ => (col, true),
|
Some((c, false)) if c == col => None,
|
||||||
|
_ => Some((col, true)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,16 +25,32 @@ pub fn next_sort_direction(current: Option<(u32, bool)>, col: u32) -> (u32, bool
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A new column (or no sort yet) starts ascending.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_new_column_starts_ascending() {
|
fn a_new_column_starts_ascending() {
|
||||||
assert_eq!(next_sort_direction(None, 3), (3, true));
|
assert_eq!(next_sort_state(None, 3), Some((3, true)));
|
||||||
assert_eq!(next_sort_direction(Some((1, true)), 3), (3, true));
|
assert_eq!(next_sort_state(Some((1, true)), 3), Some((3, true)));
|
||||||
assert_eq!(next_sort_direction(Some((1, false)), 3), (3, true));
|
assert_eq!(next_sort_state(Some((1, false)), 3), Some((3, true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same column cycles ascending → descending → off.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_same_column_flips_direction() {
|
fn the_same_column_cycles_asc_desc_off() {
|
||||||
assert_eq!(next_sort_direction(Some((4, true)), 4), (4, false));
|
assert_eq!(next_sort_state(Some((4, true)), 4), Some((4, false)));
|
||||||
assert_eq!(next_sort_direction(Some((4, false)), 4), (4, true));
|
assert_eq!(next_sort_state(Some((4, false)), 4), None);
|
||||||
|
// Off cycles back to ascending.
|
||||||
|
assert_eq!(next_sort_state(None, 4), Some((4, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full three-click cycle returns to where it started.
|
||||||
|
#[test]
|
||||||
|
fn a_full_cycle_returns_to_off() {
|
||||||
|
let mut state = None;
|
||||||
|
state = next_sort_state(state, 2);
|
||||||
|
assert_eq!(state, Some((2, true)));
|
||||||
|
state = next_sort_state(state, 2);
|
||||||
|
assert_eq!(state, Some((2, false)));
|
||||||
|
state = next_sort_state(state, 2);
|
||||||
|
assert_eq!(state, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ pub struct SpreadsheetWorkspace {
|
||||||
/// the label when the numbers actually change (scroll/resize).
|
/// the label when the numbers actually change (scroll/resize).
|
||||||
#[rust]
|
#[rust]
|
||||||
status_cache: String,
|
status_cache: String,
|
||||||
|
/// The last header-sort report ("sorted N rows by A ascending in 2 ms"),
|
||||||
|
/// appended to the status bar.
|
||||||
|
#[rust]
|
||||||
|
sort_status: String,
|
||||||
|
|
||||||
// --- Dynamic tab strip (B16) ---
|
// --- Dynamic tab strip (B16) ---
|
||||||
/// Draw resources for manually rendering tab buttons.
|
/// Draw resources for manually rendering tab buttons.
|
||||||
|
|
@ -149,6 +153,7 @@ impl Widget for SpreadsheetWorkspace {
|
||||||
self.formula_bar = FormulaBar::new();
|
self.formula_bar = FormulaBar::new();
|
||||||
self.formula_was_focused = false;
|
self.formula_was_focused = false;
|
||||||
self.status_cache.clear();
|
self.status_cache.clear();
|
||||||
|
self.sort_status.clear();
|
||||||
|
|
||||||
self.initialized = true;
|
self.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +184,17 @@ impl Widget for SpreadsheetWorkspace {
|
||||||
// change it). Cached so the label only re-lays-out on a change.
|
// change it). Cached so the label only re-lays-out on a change.
|
||||||
if let Some(grid) = self.view.widget(cx, ids!(grid)).borrow::<SpreadsheetGrid>() {
|
if let Some(grid) = self.view.widget(cx, ids!(grid)).borrow::<SpreadsheetGrid>() {
|
||||||
let (vc, vr) = grid.visible_cell_counts();
|
let (vc, vr) = grid.visible_cell_counts();
|
||||||
let status = format!("Ready | {} × {} visible = {} cells", vc, vr, vc * vr);
|
let status = if self.sort_status.is_empty() {
|
||||||
|
format!("Ready | {} × {} visible = {} cells", vc, vr, vc * vr)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Ready | {} × {} visible = {} cells | {}",
|
||||||
|
vc,
|
||||||
|
vr,
|
||||||
|
vc * vr,
|
||||||
|
self.sort_status
|
||||||
|
)
|
||||||
|
};
|
||||||
if status != self.status_cache {
|
if status != self.status_cache {
|
||||||
self.status_cache = status.clone();
|
self.status_cache = status.clone();
|
||||||
self.label(cx, ids!(status_label)).set_text(cx, &status);
|
self.label(cx, ids!(status_label)).set_text(cx, &status);
|
||||||
|
|
@ -658,6 +673,18 @@ impl WidgetMatchEvent for SpreadsheetWorkspace {
|
||||||
grid.toggle_slider(cx);
|
grid.toggle_slider(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.button(cx, ids!(drop_btn)).clicked(actions) {
|
||||||
|
self.commit_formula_bar_if_dirty(cx);
|
||||||
|
if let Some(mut grid) = self.widget(cx, ids!(grid)).borrow_mut::<SpreadsheetGrid>() {
|
||||||
|
grid.toggle_dropdown(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.button(cx, ids!(btn_btn)).clicked(actions) {
|
||||||
|
self.commit_formula_bar_if_dirty(cx);
|
||||||
|
if let Some(mut grid) = self.widget(cx, ids!(grid)).borrow_mut::<SpreadsheetGrid>() {
|
||||||
|
grid.toggle_button(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.button(cx, ids!(align_left_btn)).clicked(actions) {
|
if self.button(cx, ids!(align_left_btn)).clicked(actions) {
|
||||||
self.commit_formula_bar_if_dirty(cx);
|
self.commit_formula_bar_if_dirty(cx);
|
||||||
|
|
@ -836,26 +863,43 @@ impl WidgetMatchEvent for SpreadsheetWorkspace {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CellAction::HeaderClicked(col) => {
|
CellAction::HeaderClicked(col) => {
|
||||||
// Toggle the header sort state and re-sort the active
|
// Ascending → descending → off, timed so the status bar
|
||||||
// sheet. Clicking the sorted column flips its direction;
|
// reports how long the sort took.
|
||||||
// any other column starts a fresh ascending sort.
|
|
||||||
let next = if let Some(grid) =
|
let next = if let Some(grid) =
|
||||||
self.widget(cx, ids!(grid)).borrow::<SpreadsheetGrid>()
|
self.widget(cx, ids!(grid)).borrow::<SpreadsheetGrid>()
|
||||||
{
|
{
|
||||||
crate::sort_state::next_sort_direction(grid.sort_state, col)
|
crate::sort_state::next_sort_state(grid.sort_state, col)
|
||||||
} else {
|
} else {
|
||||||
(col, true)
|
Some((col, true))
|
||||||
};
|
};
|
||||||
if let Some(mut grid) =
|
if let Some(mut grid) =
|
||||||
self.widget(cx, ids!(grid)).borrow_mut::<SpreadsheetGrid>()
|
self.widget(cx, ids!(grid)).borrow_mut::<SpreadsheetGrid>()
|
||||||
{
|
{
|
||||||
grid.sort_state = Some(next);
|
grid.sort_state = next;
|
||||||
grid.invalidate_all();
|
grid.invalidate_all();
|
||||||
}
|
}
|
||||||
self.model
|
let label = spreadsheet_engine::util::col_letters(col);
|
||||||
.borrow_mut()
|
match next {
|
||||||
.workbook_mut()
|
Some((key_col, asc)) => {
|
||||||
.sort_active_sheet(next.1, next.0);
|
let t0 = std::time::Instant::now();
|
||||||
|
self.model
|
||||||
|
.borrow_mut()
|
||||||
|
.workbook_mut()
|
||||||
|
.sort_active_sheet(asc, key_col);
|
||||||
|
let ms = t0.elapsed().as_millis();
|
||||||
|
self.sort_status = format!(
|
||||||
|
"sorted {} rows by {} {} in {} ms",
|
||||||
|
spreadsheet_engine::data::NUM_ROWS,
|
||||||
|
label,
|
||||||
|
if asc { "ascending" } else { "descending" },
|
||||||
|
ms
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.model.borrow_mut().workbook_mut().unsort_active_sheet();
|
||||||
|
self.sort_status = format!("sort by {label} cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
self.view.redraw(cx);
|
self.view.redraw(cx);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -914,6 +958,8 @@ script_mod! {
|
||||||
separator5 := View { width: 1.0, height: 26.0, draw_bg +: { color: #x3a3a4e } }
|
separator5 := View { width: 1.0, height: 26.0, draw_bg +: { color: #x3a3a4e } }
|
||||||
md_btn := Button { text: "Md", width: 36.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } } }
|
md_btn := Button { text: "Md", width: 36.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } } }
|
||||||
slider_btn := Button { text: "Slider", width: 50.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 10.5 } } }
|
slider_btn := Button { text: "Slider", width: 50.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 10.5 } } }
|
||||||
|
drop_btn := Button { text: "Drop", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 10.5 } } }
|
||||||
|
btn_btn := Button { text: "Btn", width: 38.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } } }
|
||||||
separator5b := View { width: 1.0, height: 26.0, draw_bg +: { color: #x3a3a4e } }
|
separator5b := View { width: 1.0, height: 26.0, draw_bg +: { color: #x3a3a4e } }
|
||||||
border_all_btn := Button { text: "B-All", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 10.0 } } }
|
border_all_btn := Button { text: "B-All", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 10.0 } } }
|
||||||
border_outer_btn := Button { text: "B-Out", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 10.0 } } }
|
border_outer_btn := Button { text: "B-Out", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 10.0 } } }
|
||||||
|
|
|
||||||
|
|
@ -267,13 +267,13 @@ if [[ "$COVERAGE_TARGET" == "all" || "$COVERAGE_TARGET" == "ui" ]]; then
|
||||||
# Only the controller modules are measured; see the exclusion note above.
|
# Only the controller modules are measured; see the exclusion note above.
|
||||||
report_for "ui-controllers" "$WORK/ui.profdata" \
|
report_for "ui-controllers" "$WORK/ui.profdata" \
|
||||||
"$IGNORE_UI" "$UI_FLOOR" \
|
"$IGNORE_UI" "$UI_FLOOR" \
|
||||||
"$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \
|
"$UI/src/button.rs" "$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/dropdown.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \
|
||||||
"$UI/src/formula_bar.rs" \
|
"$UI/src/formula_bar.rs" \
|
||||||
"$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \
|
"$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \
|
||||||
"$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs"
|
"$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs"
|
||||||
if [[ "$KEEP_COVERAGE" == "1" ]]; then
|
if [[ "$KEEP_COVERAGE" == "1" ]]; then
|
||||||
uncovered_listing "ui" "$WORK/ui.profdata" "$IGNORE_UI" \
|
uncovered_listing "ui" "$WORK/ui.profdata" "$IGNORE_UI" \
|
||||||
"$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \
|
"$UI/src/button.rs" "$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/dropdown.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \
|
||||||
"$UI/src/formula_bar.rs" \
|
"$UI/src/formula_bar.rs" \
|
||||||
"$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \
|
"$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \
|
||||||
"$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs"
|
"$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue