use std::path::PathBuf;
use indexmap::IndexMap;
use ticker_core::{
AnyDatasheet, Cell, Column, ColumnId, ColumnMeta, DatasheetId, FilteredDatasheet,
Formula, FormulaKind, NormalDatasheet, Project, Properties, PropertyValue, Settings,
Tick, TicksMeta, ticks_column_id, source_tick_column_id,
load_project, save_project, detect_format, ProjectFormat,
fmt_num_display, formula_to_display_string,
FormulaTree,
FormulaTarget, parse_formula,
FILTERED_MANDATORY_KEYS,
install_builtin_formatters,
resolve_formatter, fmt_cell_with_formatter,
};
use crate::mode::Mode;
use crate::menu;
pub(crate) const DEFAULT_TICKS: u64 = 100;
pub(crate) const DEFAULT_COL_W: f32 = 120.0;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Align {
Left,
Right,
}
pub(crate) struct CellDisplay {
pub(crate) text: String,
pub(crate) align: Align,
#[allow(dead_code)]
pub(crate) color: egui::Color32,
pub(crate) is_formula: bool,
pub(crate) is_propagated: bool,
}
pub(crate) struct App {
pub(crate) project: Project,
pub(crate) active_sheet_idx: usize,
pub(crate) cursor_tick: usize,
pub(crate) cursor_col: usize,
pub(crate) scroll_tick: usize,
pub(crate) scroll_col: usize,
pub(crate) mode: Mode,
pub(crate) edit_buffer: String,
pub(crate) formula_tree: FormulaTree,
pub(crate) formula_tree_buffer: String,
pub(crate) formula_kind: Option<FormulaKind>,
pub(crate) formula_args: Vec<String>,
pub(crate) formula_stack: Vec<(FormulaKind, Vec<String>)>,
pub(crate) formula_cmd_breadcrumb: Option<(String, Vec<&'static str>, Vec<String>)>,
pub(crate) formula_category: Option<char>,
pub(crate) command_category: Option<char>,
pub(crate) prop_focused: bool,
pub(crate) prop_cursor: usize,
pub(crate) editing_prop_key: Option<String>,
pub(crate) show_property_panel: bool,
pub(crate) status_msg: Option<String>,
pub(crate) command_error: Option<String>,
pub(crate) file_path: Option<PathBuf>,
pub(crate) file_format: ProjectFormat,
pub(crate) formula_target: FormulaTarget,
pub(crate) zoom: f32,
pub(crate) base_dpi: f32,
pub(crate) sheet_counter: usize,
pub(crate) col_counter: usize,
pub(crate) col_widths: IndexMap<ColumnId, f32>,
pub(crate) show_aggr_row: bool,
pub(crate) on_aggr_row: bool,
pub(crate) on_fmt_row: bool,
pub(crate) undo_stack: Vec<Project>,
pub(crate) redo_stack: Vec<Project>,
pub(crate) last_command: Option<String>,
pub(crate) menu_ids: menu::MenuIds,
pub(crate) menu_bar: muda::Menu,
pub(crate) menu_initialized: bool,
}
impl App {
pub(crate) fn new(cc: &eframe::CreationContext, menu_ids: menu::MenuIds, menu_bar: muda::Menu) -> Self {
let sheet_counter = 1;
let col_counter = 3;
let ds_id = DatasheetId("sheet_1".to_owned());
let mut ds = NormalDatasheet::new(
ds_id.clone(),
"data",
TicksMeta { total_ticks: DEFAULT_TICKS, ticks_per_row: 1 },
);
let col_id = ColumnId("col_1".to_owned());
ds.columns.insert(
col_id,
Column {
name: "value".to_owned(),
meta: ColumnMeta::Custom {
kind: "value".to_owned(),
attrs: Default::default(),
aggregator: None,
},
visible: true,
readonly: false,
},
);
let mut project = Project::new(Settings { name: "New Project".to_owned() });
project.datasheets.insert(ds_id, AnyDatasheet::Normal(ds));
install_builtin_formatters(&mut project);
Self {
project,
active_sheet_idx: 0,
cursor_tick: 0,
cursor_col: 0,
scroll_tick: 0,
scroll_col: 0,
mode: Mode::Normal,
edit_buffer: String::new(),
formula_tree: FormulaTree::empty(),
formula_tree_buffer: String::new(),
formula_kind: None,
formula_args: Vec::new(),
formula_stack: Vec::new(),
formula_cmd_breadcrumb: None,
formula_category: None,
command_category: None,
prop_focused: false,
prop_cursor: 0,
editing_prop_key: None,
show_property_panel: true,
status_msg: None,
command_error: None,
file_path: None,
file_format: ProjectFormat::default(),
formula_target: FormulaTarget::Cell,
zoom: 1.0,
base_dpi: cc.egui_ctx.pixels_per_point(),
sheet_counter,
col_counter,
col_widths: IndexMap::new(),
show_aggr_row: false,
on_aggr_row: false,
on_fmt_row: false,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
last_command: None,
menu_ids,
menu_bar,
menu_initialized: false,
}
}
pub(crate) fn active_ds(&self) -> Option<&NormalDatasheet> {
let (_, ds) = self.project.datasheets.get_index(self.active_sheet_idx)?;
match ds {
AnyDatasheet::Normal(d) => Some(d),
_ => None,
}
}
pub(crate) fn active_ds_mut(&mut self) -> Option<&mut NormalDatasheet> {
let (_, ds) = self.project.datasheets.get_index_mut(self.active_sheet_idx)?;
match ds {
AnyDatasheet::Normal(d) => Some(d),
_ => None,
}
}
pub(crate) fn active_filtered_ds(&self) -> Option<&FilteredDatasheet> {
let (_, ds) = self.project.datasheets.get_index(self.active_sheet_idx)?;
match ds {
AnyDatasheet::Filtered(d) => Some(d),
_ => None,
}
}
pub(crate) fn active_filtered_ds_mut(&mut self) -> Option<&mut FilteredDatasheet> {
let (_, ds) = self.project.datasheets.get_index_mut(self.active_sheet_idx)?;
match ds {
AnyDatasheet::Filtered(d) => Some(d),
_ => None,
}
}
pub(crate) fn formula_context_ds(&self) -> Option<&NormalDatasheet> {
if let Some(fd) = self.active_filtered_ds() {
match self.project.datasheets.get(&fd.source)? {
AnyDatasheet::Normal(ds) => return Some(ds),
_ => return None,
}
}
self.active_ds()
}
pub(crate) fn active_any_ds_props(&self) -> Option<&Properties> {
let (_, ds) = self.project.datasheets.get_index(self.active_sheet_idx)?;
match ds {
AnyDatasheet::Normal(d) => Some(&d.properties),
AnyDatasheet::Filtered(d) => Some(&d.properties),
_ => None,
}
}
pub(crate) fn data_col_ids(&self) -> Vec<ColumnId> {
if let Some(fd) = self.active_filtered_ds() {
let src_ticks_id = ticks_column_id();
let mut cols = vec![source_tick_column_id()];
if let Some(AnyDatasheet::Normal(src_ds)) = self.project.datasheets.get(&fd.source) {
for (id, _col) in src_ds.columns.iter() {
if *id == src_ticks_id {
continue;
}
cols.push(id.clone());
}
}
return cols;
}
let Some(ds) = self.active_ds() else { return vec![] };
let ticks_id = ticks_column_id();
ds.columns
.iter()
.filter(|(id, col)| **id != ticks_id && col.visible)
.map(|(id, _)| id.clone())
.collect()
}
pub(crate) fn data_col_ids_hidden(&self) -> Vec<ColumnId> {
let Some(ds) = self.active_ds() else { return vec![] };
let ticks_id = ticks_column_id();
ds.columns
.iter()
.filter(|(id, col)| **id != ticks_id && !col.visible)
.map(|(id, _)| id.clone())
.collect()
}
pub(crate) fn col_count(&self) -> usize {
if let Some(fd) = self.active_filtered_ds() {
return 1 + self
.project
.datasheets
.get(&fd.source)
.and_then(|ds| {
if let AnyDatasheet::Normal(d) = ds {
Some(d.columns.len().saturating_sub(1))
} else {
None
}
})
.unwrap_or(0);
}
self.data_col_ids().len()
}
pub(crate) fn total_col_count(&self) -> usize {
self.active_ds()
.map(|d| d.columns.len().saturating_sub(1))
.unwrap_or(0)
}
pub(crate) fn tick_count(&self) -> usize {
match self.project.datasheets.get_index(self.active_sheet_idx).map(|(_, d)| d) {
Some(AnyDatasheet::Normal(d)) => {
let total = d.tick_count();
let period = d.properties.period().max(1);
let offset = if period > 1 { d.properties.period_offset() } else { 0 };
let header = offset.min(total) as usize;
let remaining = total.saturating_sub(offset);
let period_rows = if remaining == 0 { 0 } else {
((remaining + period - 1) / period) as usize
};
header + period_rows
}
Some(AnyDatasheet::Filtered(d)) => d.tick_count() as usize,
_ => 0,
}
}
pub(crate) fn period_for_active_sheet(&self) -> u64 {
self.active_ds().map(|ds| ds.period_resolved()).unwrap_or(1)
}
pub(crate) fn period_offset_for_active_sheet(&self) -> u64 {
self.active_ds().map(|ds| ds.period_offset_resolved()).unwrap_or(0)
}
pub(crate) fn period_anchor_for_active_sheet(&self) -> u64 {
self.active_ds().map(|ds| ds.period_anchor_resolved()).unwrap_or(0)
}
pub(crate) fn tick_for_row(&self, row: usize) -> u64 {
let period = self.period_for_active_sheet();
let offset = if period > 1 { self.period_offset_for_active_sheet() } else { 0 };
if (row as u64) < offset {
row as u64
} else {
offset + (row as u64 - offset) * period
}
}
pub(crate) fn display_tick_for_row(&self, row: usize) -> u64 {
let period_start = self.tick_for_row(row);
let period = self.period_for_active_sheet();
let offset = if period > 1 { self.period_offset_for_active_sheet() } else { 0 };
if (row as u64) < offset || period <= 1 {
period_start
} else {
let anchor = self.period_anchor_for_active_sheet().min(period - 1);
let tc = self.active_ds().map(|d| d.tick_count()).unwrap_or(0);
(period_start + anchor).min(tc.saturating_sub(1))
}
}
pub(crate) fn prop_count(&self) -> usize {
self.active_any_ds_props().map(|p| p.len()).unwrap_or(0)
}
pub(crate) fn cursor_col_id(&self) -> Option<ColumnId> {
if let Some(fd) = self.active_filtered_ds() {
let src_ticks_id = ticks_column_id();
let mut cols = vec![source_tick_column_id()];
if let Some(AnyDatasheet::Normal(src_ds)) = self.project.datasheets.get(&fd.source) {
for (id, _col) in src_ds.columns.iter() {
if *id == src_ticks_id {
continue;
}
cols.push(id.clone());
}
}
return cols.into_iter().nth(self.cursor_col);
}
self.data_col_ids().into_iter().nth(self.cursor_col)
}
pub(crate) fn col_width(&self, id: &ColumnId) -> f32 {
self.col_widths.get(id).copied().unwrap_or(DEFAULT_COL_W)
}
pub(crate) fn selected_prop_key(&self) -> Option<String> {
self.active_any_ds_props()?
.iter()
.nth(self.prop_cursor)
.map(|(k, _)| k.to_owned())
}
pub(crate) fn name_in_use(&self, name: &str, exclude_col: Option<&ColumnId>) -> bool {
let ds = match self.active_ds() {
Some(d) => d,
None => return false,
};
let lower = name.to_lowercase();
let ticks_id = ticks_column_id();
for (id, col) in ds.columns.iter() {
if *id == ticks_id {
continue;
}
if exclude_col == Some(id) {
continue;
}
if col.name.to_lowercase() == lower {
return true;
}
}
for (key, _) in ds.properties.iter() {
if key.to_lowercase() == lower {
return true;
}
}
false
}
pub(crate) fn clamp_cursor(&mut self) {
let col_count = self.col_count();
let tick_count = self.tick_count();
self.cursor_col = if col_count == 0 { 0 } else { self.cursor_col.min(col_count - 1) };
self.cursor_tick =
if tick_count == 0 { 0 } else { self.cursor_tick.min(tick_count - 1) };
}
pub(crate) fn scroll_into_view(&mut self, viewport_rows: usize, viewport_cols: usize) {
if self.cursor_col < self.scroll_col {
self.scroll_col = self.cursor_col;
} else if viewport_cols > 0 && self.cursor_col >= self.scroll_col + viewport_cols {
self.scroll_col = self.cursor_col + 1 - viewport_cols;
}
if self.cursor_tick < self.scroll_tick {
self.scroll_tick = self.cursor_tick;
} else if viewport_rows > 0 && self.cursor_tick >= self.scroll_tick + viewport_rows {
self.scroll_tick = self.cursor_tick + 1 - viewport_rows;
}
}
pub(crate) fn clamp_all_cursors(&mut self) {
let n_sheets = self.project.datasheets.len();
if n_sheets == 0 {
self.active_sheet_idx = 0;
self.cursor_col = 0;
self.cursor_tick = 0;
return;
}
self.active_sheet_idx = self.active_sheet_idx.min(n_sheets - 1);
let n_cols = self.col_count();
self.cursor_col = if n_cols > 0 { self.cursor_col.min(n_cols - 1) } else { 0 };
let n_ticks = self.tick_count();
self.cursor_tick = if n_ticks > 0 { self.cursor_tick.min(n_ticks - 1) } else { 0 };
self.scroll_col = self.scroll_col.min(self.cursor_col);
self.scroll_tick = self.scroll_tick.min(self.cursor_tick);
let n_props = self.prop_count();
self.prop_cursor = if n_props > 0 { self.prop_cursor.min(n_props - 1) } else { 0 };
}
pub(crate) fn push_undo(&mut self) {
self.undo_stack.push(self.project.clone());
self.redo_stack.clear();
if self.undo_stack.len() > 100 {
self.undo_stack.remove(0);
}
}
pub(crate) fn propagating_formula<'a>(
ds: &'a NormalDatasheet,
tick: Tick,
col: &ColumnId,
) -> Option<&'a Formula> {
let col_data = ds.cells.get(col)?;
match col_data.range(..=tick).next_back()? {
(_, Cell::Formula(f)) => Some(f),
_ => None,
}
}
pub(crate) fn cell_display(&self, ds: &NormalDatasheet, display_tick: Tick, period_start: Tick, col_id: &ColumnId, period: u64) -> CellDisplay {
let formatter = ds.columns.get(col_id)
.and_then(|c| c.formatter_name())
.and_then(|name| resolve_formatter(&self.project, name));
let fmt_number = |n: f64| -> String {
if let Some(fmt) = formatter {
fmt_cell_with_formatter(n, fmt)
} else {
fmt_num_display(n)
}
};
match ds.get(display_tick, col_id) {
Some(Cell::Number(n)) => CellDisplay {
text: fmt_number(*n),
align: Align::Right,
color: egui::Color32::PLACEHOLDER,
is_formula: false,
is_propagated: false,
},
Some(Cell::Text(s)) => CellDisplay {
text: s.clone(),
align: Align::Left,
color: egui::Color32::PLACEHOLDER,
is_formula: false,
is_propagated: false,
},
Some(Cell::Bool(b)) => CellDisplay {
text: b.to_string(),
align: Align::Left,
color: egui::Color32::PLACEHOLDER,
is_formula: false,
is_propagated: false,
},
Some(Cell::Formula(_f)) => {
let val = ds.resolve_aggregated(period_start, display_tick, col_id, period);
let text = match val {
Some(n) => fmt_number(n),
None => "#ERR".to_owned(),
};
CellDisplay {
text,
align: Align::Right,
color: egui::Color32::PLACEHOLDER,
is_formula: true,
is_propagated: false,
}
}
None => {
match Self::propagating_formula(ds, display_tick, col_id) {
Some(_f) => {
let val = ds.resolve_aggregated(period_start, display_tick, col_id, period);
let text = match val {
Some(n) => fmt_number(n),
None => "#ERR".to_owned(),
};
CellDisplay {
text,
align: Align::Right,
color: egui::Color32::PLACEHOLDER,
is_formula: false,
is_propagated: true,
}
}
None => CellDisplay {
text: String::new(),
align: Align::Right,
color: egui::Color32::PLACEHOLDER,
is_formula: false,
is_propagated: false,
},
}
}
}
}
pub(crate) fn cursor_value_str(&self) -> String {
let col_id = match self.cursor_col_id() {
Some(c) => c,
None => return String::new(),
};
let cursor_tick = Tick(self.display_tick_for_row(self.cursor_tick));
if let Some(fd) = self.active_filtered_ds() {
return fd
.resolve(cursor_tick, &col_id, &self.project.datasheets)
.map(|n| fmt_num_display(n))
.unwrap_or_default();
}
let ds = match self.active_ds() {
Some(d) => d,
None => return String::new(),
};
match ds.get(cursor_tick, &col_id) {
Some(Cell::Number(n)) => fmt_num_display(*n),
Some(Cell::Text(s)) => s.clone(),
Some(Cell::Bool(b)) => b.to_string(),
Some(Cell::Formula(f)) => {
format!("={}", formula_to_display_string(f, ds))
}
None => match Self::propagating_formula(ds, cursor_tick, &col_id) {
Some(f) => format!("~{}", formula_to_display_string(f, ds)),
None => String::new(),
},
}
}
pub(crate) fn cell_eval_str(&self) -> String {
let col_id = match self.cursor_col_id() {
Some(c) => c,
None => return String::new(),
};
let cursor_tick = Tick(self.display_tick_for_row(self.cursor_tick));
if let Some(fd) = self.active_filtered_ds() {
return fd
.resolve(cursor_tick, &col_id, &self.project.datasheets)
.map(fmt_num_display)
.unwrap_or_default();
}
let ds = match self.active_ds() {
Some(d) => d,
None => return String::new(),
};
ds.resolve(cursor_tick, &col_id)
.map(fmt_num_display)
.unwrap_or_default()
}
pub(crate) fn cursor_addr_str(&self) -> String {
let col_name = self
.cursor_col_id()
.and_then(|id| {
self.active_ds()
.and_then(|ds| ds.columns.get(&id).map(|c| c.name.clone()))
.or_else(|| Some(id.0.clone()))
})
.unwrap_or_default();
format!("{}[{}]", col_name, self.display_tick_for_row(self.cursor_tick))
}
pub(crate) fn add_sheet(&mut self, name: String) {
self.push_undo();
self.sheet_counter += 1;
let ds_id = DatasheetId(format!("sheet_{}", self.sheet_counter));
let tick_count = self
.active_ds()
.map(|d| d.tick_count())
.unwrap_or(DEFAULT_TICKS);
let mut ds = NormalDatasheet::new(
ds_id.clone(),
name,
TicksMeta { total_ticks: tick_count, ticks_per_row: 1 },
);
self.col_counter += 1;
let col_id = ColumnId(format!("col_{}", self.col_counter));
ds.columns.insert(col_id, Column {
name: "value".to_owned(),
meta: ColumnMeta::Custom { kind: "value".to_owned(), attrs: Default::default(), aggregator: None },
visible: true,
readonly: false,
});
self.project.datasheets.insert(ds_id, AnyDatasheet::Normal(ds));
self.active_sheet_idx = self.project.datasheets.len() - 1;
self.cursor_col = 0;
self.cursor_tick = 0;
self.scroll_col = 0;
self.scroll_tick = 0;
}
pub(crate) fn rename_sheet(&mut self, name: String) {
self.push_undo();
if let Some(ds) = self.active_ds_mut() {
ds.name = name;
}
}
pub(crate) fn delete_sheet(&mut self) -> bool {
self.push_undo();
if self.project.datasheets.len() <= 1 {
self.status_msg = Some("Cannot delete the last sheet.".to_owned());
return false;
}
if let Some((id, _)) = self.project.datasheets.get_index(self.active_sheet_idx) {
let id = id.clone();
self.project.datasheets.shift_remove(&id);
}
if self.active_sheet_idx >= self.project.datasheets.len() {
self.active_sheet_idx = self.project.datasheets.len().saturating_sub(1);
}
self.cursor_col = 0;
self.cursor_tick = 0;
self.scroll_col = 0;
self.scroll_tick = 0;
true
}
pub(crate) fn add_column(&mut self, name: String) -> bool {
self.push_undo();
if self.name_in_use(&name, None) {
self.status_msg = Some(format!("'{}' is already in use.", name));
return false;
}
self.col_counter += 1;
let col_id = ColumnId(format!("col_{}", self.col_counter));
let cursor_id = self.cursor_col_id();
if let Some(ds) = self.active_ds_mut() {
ds.columns.insert(
col_id.clone(),
Column {
name,
meta: ColumnMeta::Custom {
kind: "value".to_owned(),
attrs: Default::default(),
aggregator: None,
},
visible: true,
readonly: false,
},
);
if let Some(cursor_id) = cursor_id {
if let (Some(cur_pos), Some(new_pos)) = (
ds.columns.get_index_of(&cursor_id),
ds.columns.get_index_of(&col_id),
) {
ds.columns.move_index(new_pos, cur_pos + 1);
}
}
}
true
}
pub(crate) fn duplicate_sheet(&mut self) {
self.push_undo();
if let Some((_, ds)) = self.project.datasheets.get_index(self.active_sheet_idx) {
let mut new_ds = ds.clone();
self.sheet_counter += 1;
let new_id = DatasheetId(format!("sheet_{}", self.sheet_counter));
match &mut new_ds {
AnyDatasheet::Normal(d) => {
d.id = new_id.clone();
d.name = format!("{} (copy)", d.name);
}
AnyDatasheet::Filtered(d) => {
d.id = new_id.clone();
d.name = format!("{} (copy)", d.name);
}
_ => {}
}
self.project.datasheets.insert(new_id, new_ds);
self.active_sheet_idx = self.project.datasheets.len() - 1;
self.cursor_col = 0;
self.cursor_tick = 0;
self.scroll_col = 0;
self.scroll_tick = 0;
}
}
pub(crate) fn insert_ticks(&mut self, n: u64) {
self.push_undo();
let tick = self.display_tick_for_row(self.cursor_tick) as u64;
if let Some(ds) = self.active_ds_mut() {
ds.insert_ticks(ticker_core::tick::Tick(tick), n);
}
self.clamp_cursor();
}
pub(crate) fn delete_tick(&mut self) {
self.push_undo();
let tick = self.display_tick_for_row(self.cursor_tick) as u64;
let can_delete = self.active_ds().map(|ds| ds.tick_count() > 1).unwrap_or(false);
if can_delete {
if let Some(ds) = self.active_ds_mut() {
ds.delete_tick(ticker_core::tick::Tick(tick));
}
}
self.clamp_cursor();
}
pub(crate) fn clear_tick_row(&mut self) {
self.push_undo();
let tick = self.display_tick_for_row(self.cursor_tick) as u64;
if let Some(ds) = self.active_ds_mut() {
ds.clear_tick_row(ticker_core::tick::Tick(tick));
}
}
pub(crate) fn clear_all_ticks(&mut self) {
self.push_undo();
if let Some(ds) = self.active_ds_mut() {
ds.clear_all_cells();
}
}
pub(crate) fn rename_column(&mut self, name: String) -> bool {
self.push_undo();
let col_id = match self.cursor_col_id() {
Some(c) => c,
None => return false,
};
if self.name_in_use(&name, Some(&col_id)) {
self.status_msg = Some(format!("'{}' is already in use.", name));
return false;
}
if let Some(ds) = self.active_ds_mut() {
if let Some(col) = ds.columns.get_mut(&col_id) {
col.name = name;
}
}
true
}
pub(crate) fn delete_column(&mut self) -> bool {
self.push_undo();
if self.total_col_count() <= 1 {
self.status_msg = Some("Cannot delete the last column.".to_owned());
return false;
}
let col_id = match self.cursor_col_id() {
Some(c) => c,
None => return false,
};
if let Some(ds) = self.active_ds_mut() {
ds.columns.shift_remove(&col_id);
ds.cells.remove(&col_id);
}
self.cursor_col = self.cursor_col.min(self.col_count().saturating_sub(1));
true
}
pub(crate) fn move_column(&mut self, left: bool) -> bool {
self.push_undo();
let col_ids = self.data_col_ids();
let idx = self.cursor_col;
let other = if left {
if idx == 0 {
return false;
}
idx - 1
} else {
if idx + 1 >= col_ids.len() {
return false;
}
idx + 1
};
if let Some(ds) = self.active_ds_mut() {
let a = ds.columns.get_index_of(&col_ids[idx]).unwrap();
let b = ds.columns.get_index_of(&col_ids[other]).unwrap();
ds.columns.swap_indices(a, b);
}
self.cursor_col = other;
true
}
pub(crate) fn clear_column(&mut self) {
self.push_undo();
let col_id = match self.cursor_col_id() {
Some(c) => c,
None => return,
};
let ticks: Vec<Tick> = self
.active_ds()
.and_then(|ds| ds.column_data(&col_id))
.map(|m| m.keys().copied().collect())
.unwrap_or_default();
if ticks.is_empty() {
self.status_msg = Some("Column is already empty.".to_owned());
return;
}
if let Some(ds) = self.active_ds_mut() {
for tick in ticks {
ds.remove(tick, &col_id);
}
}
self.status_msg = Some("Column cleared.".to_owned());
}
pub(crate) fn hide_current_column(&mut self) {
self.push_undo();
if self.col_count() <= 1 {
self.status_msg = Some("Cannot hide the last visible column.".to_owned());
return;
}
let col_id = match self.cursor_col_id() {
Some(c) => c,
None => return,
};
if let Some(ds) = self.active_ds_mut() {
if let Some(col) = ds.columns.get_mut(&col_id) {
col.visible = false;
}
}
let count = self.col_count();
if count > 0 {
self.cursor_col = self.cursor_col.min(count - 1);
}
self.status_msg = Some("Column hidden. Use :sh <name> to show it.".to_owned());
}
pub(crate) fn show_hidden_column(&mut self, name: &str) {
self.push_undo();
if name.is_empty() {
self.status_msg = Some("Usage: sh <column name>".to_owned());
return;
}
let lower = name.to_lowercase();
let ticks_id = ticks_column_id();
let found = self.active_ds_mut().and_then(|ds| {
for (id, col) in ds.columns.iter_mut() {
if *id == ticks_id {
continue;
}
if col.name.to_lowercase() == lower {
col.visible = true;
return Some(col.name.clone());
}
}
None
});
match found {
Some(n) => self.status_msg = Some(format!("Column '{}' is now visible.", n)),
None => self.status_msg = Some(format!("No hidden column named '{}'.", name)),
}
}
pub(crate) fn list_hidden_columns(&mut self) {
let ds = match self.active_ds() {
Some(d) => d,
None => return,
};
let ticks_id = ticks_column_id();
let hidden: Vec<String> = ds
.columns
.iter()
.filter(|(id, col)| **id != ticks_id && !col.visible)
.map(|(_, col)| col.name.clone())
.collect();
if hidden.is_empty() {
self.status_msg = Some("No hidden columns.".to_owned());
} else {
self.status_msg = Some(format!("Hidden: {}", hidden.join(", ")));
}
}
pub(crate) fn hide_datasheet(&mut self, name: Option<&str>) {
let idx = if let Some(n) = name {
self.project.datasheets.iter().position(|(_, ds)| {
if let AnyDatasheet::Normal(d) = ds { d.name == n } else { false }
})
} else {
Some(self.active_sheet_idx)
};
if let Some(i) = idx {
if let Some((_, AnyDatasheet::Normal(ds))) = self.project.datasheets.get_index_mut(i) {
ds.properties.set("hidden", PropertyValue::Bool(true));
self.status_msg = Some(format!("Sheet '{}' hidden.", ds.name));
}
} else {
self.status_msg = Some("Sheet not found.".to_owned());
}
}
pub(crate) fn show_datasheet(&mut self, name: &str) {
let found = self.project.datasheets.values_mut().find_map(|ds| {
if let AnyDatasheet::Normal(d) = ds {
if d.name == name { return Some(d); }
}
None
});
if let Some(ds) = found {
ds.properties.set("hidden", PropertyValue::Bool(false));
self.status_msg = Some(format!("Sheet '{}' shown.", ds.name));
} else {
self.status_msg = Some(format!("Sheet '{}' not found.", name));
}
}
pub(crate) fn list_datasheets_status(&mut self) {
let list: Vec<String> = self.project.datasheets.values().map(|ds| {
match ds {
AnyDatasheet::Normal(d) => format!("{}{}", d.name, if d.is_hidden() { " [hidden]" } else { "" }),
_ => "<other>".to_owned(),
}
}).collect();
self.status_msg = Some(list.join(" | "));
}
pub(crate) fn set_column_formatter(&mut self, name: &str) {
let col_ids = self.data_col_ids();
if let Some(col_id) = col_ids.get(self.cursor_col).cloned() {
if let Some((_, AnyDatasheet::Normal(ds))) = self.project.datasheets.get_index_mut(self.active_sheet_idx) {
if let Some(col) = ds.columns.get_mut(&col_id) {
if let ColumnMeta::Custom { attrs, .. } = &mut col.meta {
if name.is_empty() {
attrs.remove("formatter");
self.status_msg = Some("Formatter cleared.".to_owned());
} else {
attrs.insert("formatter".to_owned(), name.to_owned());
self.status_msg = Some(format!("Formatter set to '{}'.", name));
}
}
}
}
}
}
pub(crate) fn convert_column_to_values(&mut self) {
self.push_undo();
let col_id = match self.cursor_col_id() {
Some(c) => c,
None => {
self.status_msg = Some("No column selected.".to_owned());
return;
}
};
let ds = match self.active_ds() {
Some(d) => d,
None => return,
};
let first_tick = match ds.column_data(&col_id) {
Some(col_data) => match col_data.keys().next() {
Some(&t) => t,
None => {
self.status_msg = Some("Column is empty.".to_owned());
return;
}
},
None => {
self.status_msg = Some("Column has no data.".to_owned());
return;
}
};
let tick_count = ds.tick_count();
let mut values: Vec<(Tick, Option<f64>)> = Vec::new();
for t in first_tick.0..tick_count {
let tick = Tick(t);
values.push((tick, ds.resolve(tick, &col_id)));
}
let ds = match self.active_ds_mut() {
Some(d) => d,
None => return,
};
for (tick, val) in values {
match val {
Some(n) => {
ds.set(tick, col_id.clone(), Cell::Number(n));
}
None => {
ds.remove(tick, &col_id);
}
}
}
self.status_msg = Some("Column converted to values.".to_owned());
}
pub(crate) fn set_property(&mut self, key: &str, value: PropertyValue) -> bool {
self.push_undo();
if self.active_filtered_ds().is_some() {
return self.set_filtered_property(key, value);
}
let is_new = self.active_ds().map(|ds| ds.properties.get(key).is_none()).unwrap_or(false);
if is_new {
let lower = key.to_lowercase();
let conflict = self
.active_ds()
.map(|ds| {
let ticks_id = ticks_column_id();
ds.columns
.iter()
.filter(|(id, _)| **id != ticks_id)
.any(|(_, col)| col.name.to_lowercase() == lower)
})
.unwrap_or(false);
if conflict {
self.status_msg = Some(format!("'{}' is already used as a column name.", key));
return false;
}
}
if key == "tickCount" {
if let Some(n) = value.as_u64() {
if let Some(ds) = self.active_ds_mut() {
ds.ticks_meta_mut().total_ticks = n;
}
}
}
if let Some(ds) = self.active_ds_mut() {
ds.properties.set(key, value);
}
let count = self.prop_count();
if count > 0 {
self.prop_cursor = self.prop_cursor.min(count - 1);
}
true
}
pub(crate) fn set_filtered_property(&mut self, key: &str, value: PropertyValue) -> bool {
if key == "tickCount" {
self.status_msg = Some("tickCount is read-only in a filtered sheet.".to_owned());
return false;
}
if key == "filter" {
match value {
PropertyValue::Formula(f) => {
self.rebuild_filtered(f);
return true;
}
_ => {
self.status_msg =
Some("filter must be a formula — press = to enter one.".to_owned());
return false;
}
}
}
if let Some(fd) = self.active_filtered_ds_mut() {
fd.properties.set(key, value);
}
true
}
pub(crate) fn delete_property(&mut self, key: &str) -> bool {
self.push_undo();
if self.active_filtered_ds().is_some() {
if FILTERED_MANDATORY_KEYS.contains(&key) {
return false;
}
let removed = self
.active_filtered_ds_mut()
.map(|fd| fd.properties.remove(key))
.unwrap_or(false);
if removed {
let count = self.prop_count();
if count > 0 {
self.prop_cursor = self.prop_cursor.min(count - 1);
}
}
return removed;
}
let removed = self
.active_ds_mut()
.map(|ds| ds.properties.remove(key))
.unwrap_or(false);
if removed {
let count = self.prop_count();
if count > 0 {
self.prop_cursor = self.prop_cursor.min(count - 1);
}
}
removed
}
pub(crate) fn rebuild_filtered(&mut self, new_condition: Formula) {
let fd = match self.active_filtered_ds() {
Some(fd) => fd.clone(),
None => return,
};
let source_ds = match self.project.datasheets.get(&fd.source) {
Some(AnyDatasheet::Normal(ds)) => ds.clone(),
_ => {
self.status_msg = Some("Source sheet not found.".to_owned());
return;
}
};
let new_fd = FilteredDatasheet::build(
fd.id.clone(),
&fd.name,
fd.source.clone(),
new_condition,
&source_ds,
);
let tick_count = new_fd.tick_count();
if let Some((_, ds)) = self.project.datasheets.get_index_mut(self.active_sheet_idx) {
*ds = AnyDatasheet::Filtered(new_fd);
}
self.cursor_tick = self.cursor_tick.min(tick_count.saturating_sub(1) as usize);
self.status_msg = Some(format!("Filter updated ({} rows).", tick_count));
}
pub(crate) fn add_filter(&mut self, source_name: &str, cond_str: &str) {
self.push_undo();
let source_id: DatasheetId;
let source_ds_name: String;
if source_name.is_empty() {
let ds = match self.active_ds() {
Some(d) => d,
None => {
self.status_msg = Some("No active sheet.".to_owned());
return;
}
};
source_id = ds.id.clone();
source_ds_name = ds.name.clone();
} else {
let lower = source_name.to_lowercase();
let found = self.project.datasheets.iter().find_map(|(id, ds)| {
if ds.name().to_lowercase() == lower {
if let AnyDatasheet::Normal(_) = ds {
Some(id.clone())
} else {
None
}
} else {
None
}
});
match found {
Some(id) => {
source_ds_name =
self.project.datasheets.get(&id).unwrap().name().to_owned();
source_id = id;
}
None => {
self.status_msg = Some(format!("Sheet '{}' not found.", source_name));
return;
}
}
}
let source_ds = match self.project.datasheets.get(&source_id) {
Some(AnyDatasheet::Normal(ds)) => ds.clone(),
_ => {
self.status_msg = Some("Source must be a normal sheet.".to_owned());
return;
}
};
let condition = match parse_formula(cond_str, &source_ds, false) {
Some(f) => f,
None => {
self.status_msg = Some(format!("Invalid formula: '{}'", cond_str));
return;
}
};
self.sheet_counter += 1;
let new_id = DatasheetId(format!("sheet_{}", self.sheet_counter));
let new_name = format!("{}_filtered", source_ds_name);
let fd =
FilteredDatasheet::build(new_id.clone(), &new_name, source_id, condition, &source_ds);
let tick_count = fd.tick_count();
self.project.datasheets.insert(new_id, AnyDatasheet::Filtered(fd));
self.active_sheet_idx = self.project.datasheets.len() - 1;
self.cursor_col = 0;
self.cursor_tick = 0;
self.scroll_col = 0;
self.scroll_tick = 0;
self.status_msg = Some(format!("Filter created ({} rows).", tick_count));
}
pub(crate) fn save_project(&mut self, path: Option<&str>) {
let (target, format) = if let Some(p) = path {
let target = PathBuf::from(p);
let fmt = detect_format(&target).unwrap_or(ProjectFormat::Tic);
(target, fmt)
} else if let Some(ref p) = self.file_path.clone() {
(p.clone(), self.file_format)
} else {
self.status_msg = Some("No path — use :w <path>".to_owned());
return;
};
match save_project(&self.project, &target, format) {
Ok(()) => {
self.file_path = Some(target.clone());
self.file_format = format;
self.status_msg = Some(format!("Saved to '{}'.", target.display()));
}
Err(e) => self.status_msg = Some(format!("Save failed: {}", e)),
}
}
pub(crate) fn open_project(&mut self, path: &str) {
let p = std::path::Path::new(path);
match load_project(p) {
Ok(loaded) => {
self.project = loaded.project;
install_builtin_formatters(&mut self.project);
self.sheet_counter = loaded.sheet_counter;
self.col_counter = loaded.col_counter;
self.file_path = Some(p.to_path_buf());
self.file_format = detect_format(p).unwrap_or_default();
self.active_sheet_idx = 0;
self.cursor_col = 0;
self.cursor_tick = 0;
self.scroll_col = 0;
self.scroll_tick = 0;
self.prop_cursor = 0;
self.undo_stack.clear();
self.redo_stack.clear();
self.status_msg = Some(format!("Opened '{}'.", path));
}
Err(e) => self.status_msg = Some(format!("Open failed: {}", e)),
}
}
pub(crate) fn find_cell(&mut self, term: &str) -> bool {
let term_lower = term.to_lowercase();
let ds = match self.active_ds() {
Some(d) => d,
None => {
self.status_msg = Some(format!("No match for '{}'", term));
return false;
}
};
let col_ids = self.data_col_ids();
let tick_count = self.tick_count();
let period = self.period_for_active_sheet();
for (col_idx, col_id) in col_ids.iter().enumerate() {
if let Some(col) = ds.columns.get(col_id) {
if col.name.to_lowercase().contains(&term_lower) {
self.cursor_col = col_idx;
self.status_msg = Some(format!("Found '{}' in column name.", term));
return true;
}
}
}
for row in 0..tick_count {
let period_start = Tick(self.tick_for_row(row));
let display_tick = Tick(self.display_tick_for_row(row));
for (col_idx, col_id) in col_ids.iter().enumerate() {
let cell_str: String = match ds.get(display_tick, col_id) {
Some(Cell::Number(n)) => fmt_num_display(*n),
Some(Cell::Text(s)) => s.clone(),
Some(Cell::Bool(b)) => b.to_string(),
Some(Cell::Formula(_)) => {
match ds.resolve_aggregated(period_start, display_tick, col_id, period) {
Some(n) => fmt_num_display(n),
None => String::new(),
}
}
None => {
match ds.resolve_aggregated(period_start, display_tick, col_id, period) {
Some(n) => fmt_num_display(n),
None => String::new(),
}
}
};
if cell_str.to_lowercase().contains(&term_lower) {
self.cursor_col = col_idx;
self.cursor_tick = row;
self.status_msg = Some(format!("Found '{}' at col {}, tick {}.", term, col_idx, row));
return true;
}
}
}
self.status_msg = Some(format!("No match for '{}'", term));
false
}
}