Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions linux/changelog.d/3880.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Enum and set cells pick from a menu of the column's labels, in the grid and in Row Details.
1 change: 1 addition & 0 deletions linux/changelog.d/3880.fixed.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Closing a cell's menu moved the keyboard focus off the grid.
1 change: 1 addition & 0 deletions linux/changelog.d/3880.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Clearing a MySQL SET cell wrote NULL instead of the empty set, and Set to Empty was not offered.
57 changes: 55 additions & 2 deletions linux/crates/app/src/services/grid/cell_editor_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ pub const INLINE_EDIT_MAX_BYTES: usize = 4096;
/// details editor lays out every line on the GTK thread and would stall.
pub const EDIT_MAX_BYTES: usize = 1_000_000;

/// Past this many labels an enum edits as text: a menu lays out a row
/// per label on the GTK thread, measured at half a second for 400.
pub const CHOICE_MAX_LABELS: usize = 100;

/// The editor a cell opens with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellEditorKind {
Expand All @@ -19,6 +23,8 @@ pub enum CellEditorKind {
purpose: TextPurpose,
},
Toggle,
/// A menu of the column's labels.
Choice,
Details(DetailsEditor),
ReadOnly(CellLock),
}
Expand Down Expand Up @@ -52,6 +58,7 @@ impl CellEditorKind {
};
match kind {
ColumnKind::Bool(_) => Self::Toggle,
ColumnKind::Enum { labels, multiple } if picks_from(labels, *multiple) => Self::Choice,
ColumnKind::Json { .. } => Self::Details(DetailsEditor::Json),
ColumnKind::Xml => Self::Details(DetailsEditor::Xml),
ColumnKind::Date => Self::Details(DetailsEditor::Date),
Expand Down Expand Up @@ -93,6 +100,13 @@ impl CellEditorKind {
}
}

/// Whether a menu can offer an enum's labels. Without labels from the
/// catalog there is nothing to offer, and a set with an empty label reads
/// the same with that member and without it.
fn picks_from(labels: &[String], multiple: bool) -> bool {
(1..=CHOICE_MAX_LABELS).contains(&labels.len()) && !(multiple && labels.iter().any(String::is_empty))
}

#[cfg(test)]
mod tests {
use tablepro_core::column::{
Expand All @@ -101,7 +115,7 @@ mod tests {
};
use tablepro_core::edit::{EditText, ReadOnlyReason};

use super::{CellEditorKind, DetailsEditor, INLINE_EDIT_MAX_BYTES, TextPurpose};
use super::{CHOICE_MAX_LABELS, CellEditorKind, DetailsEditor, INLINE_EDIT_MAX_BYTES, TextPurpose};
use crate::services::grid::CellLock;
use crate::services::grid::test_columns::grid_of_kind;

Expand Down Expand Up @@ -170,7 +184,7 @@ mod tests {
),
(
ColumnKind::Enum {
labels: vec!["a".to_owned()],
labels: Vec::new(),
multiple: false,
},
free(false),
Expand Down Expand Up @@ -291,4 +305,43 @@ mod tests {
CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Bytes))
);
}

#[test]
fn an_enum_or_set_with_labels_opens_a_menu_of_them() {
for multiple in [false, true] {
let labelled = ColumnKind::Enum {
labels: vec!["a".to_owned(), "b".to_owned()],
multiple,
};
assert_eq!(
CellEditorKind::for_cell(&grid_of_kind("e", labelled), Ok(())),
CellEditorKind::Choice
);
}
let many = ColumnKind::Enum {
labels: (0..=CHOICE_MAX_LABELS).map(|label| label.to_string()).collect(),
multiple: false,
};
assert_eq!(CellEditorKind::for_cell(&grid_of_kind("e", many), Ok(())), free(false));
let set_with_empty = ColumnKind::Enum {
labels: vec!["a".to_owned(), String::new()],
multiple: true,
};
assert_eq!(
CellEditorKind::for_cell(&grid_of_kind("s", set_with_empty), Ok(())),
free(false),
"a set whose empty member cannot be read back offered a menu"
);
let labelled = grid_of_kind(
"e",
ColumnKind::Enum {
labels: vec!["a".to_owned()],
multiple: false,
},
);
assert_eq!(
CellEditorKind::for_cell(&labelled, Err(CellLock::KeyColumn)),
CellEditorKind::ReadOnly(CellLock::KeyColumn)
);
}
}
7 changes: 6 additions & 1 deletion linux/crates/app/src/ui/data_grid/edit/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ pub(super) fn refresh(controller: &EditController, grid: &DataGrid, target: Opti
let edits = !typing
&& matches!(
kind,
Some(CellEditorKind::Inline { .. } | CellEditorKind::Toggle | CellEditorKind::Details(_))
Some(
CellEditorKind::Inline { .. }
| CellEditorKind::Toggle
| CellEditorKind::Choice
| CellEditorKind::Details(_)
)
);
enable(grid, "edit", edits);
enable(grid, "toggle", !typing && kind == Some(CellEditorKind::Toggle));
Expand Down
83 changes: 64 additions & 19 deletions linux/crates/app/src/ui/data_grid/edit/cell_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ pub(super) struct OpenEdit {
/// What editing a cell comes to.
pub(super) enum Plan {
Inline(Box<InlineStart>),
/// A menu of the column's labels. Typing, Tab and a new row edit its
/// text in place instead, where it fits a one-line editor.
Choice {
start: Box<InlineStart>,
in_place: bool,
},
Toggle,
/// The value is edited in the detail pane.
Details,
Expand All @@ -45,14 +51,26 @@ pub(super) enum Plan {
}

pub(super) struct InlineStart {
column: GridColumn,
row: GridRow,
start: EditStart,
pub(super) column: GridColumn,
pub(super) row: GridRow,
pub(super) start: EditStart,
text: String,
accepts_line_breaks: bool,
purpose: TextPurpose,
}

impl Plan {
/// Where the cell's text can be edited in place.
pub(super) fn in_place(self) -> Option<Box<InlineStart>> {
match self {
Self::Inline(start) | Self::Choice { start, in_place: true } => Some(start),
Self::Choice { in_place: false, .. } | Self::Toggle | Self::Details | Self::Refused(_) | Self::Locked => {
None
}
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Opened {
/// The whole value, selected, so the first key replaces it.
Expand Down Expand Up @@ -123,6 +141,10 @@ impl EditController {
self.open_inline(&grid, cell, start, Opened::ToEdit);
true
}
Plan::Choice { start, .. } => {
self.open_choice(&grid, cell, &start);
true
}
}
}

Expand All @@ -135,12 +157,13 @@ impl EditController {
return None;
}
match self.plan(&grid, &cell.row()?, cell.column()) {
Plan::Inline(start) => Some(self.open_inline(&grid, &cell, start, Opened::ByTyping)),
Plan::Refused(why) => {
grid.toast(why);
None
}
Plan::Toggle | Plan::Details | Plan::Locked => None,
plan => plan
.in_place()
.map(|start| self.open_inline(&grid, &cell, start, Opened::ByTyping)),
}
}

Expand Down Expand Up @@ -214,6 +237,7 @@ impl EditController {
CellEditorKind::ReadOnly(_) => return Plan::Locked,
CellEditorKind::Toggle => return Plan::Toggle,
CellEditorKind::Details(_) => return Plan::Details,
CellEditorKind::Choice => (false, TextPurpose::FreeForm),
CellEditorKind::Inline {
accepts_line_breaks,
purpose,
Expand All @@ -226,34 +250,55 @@ impl EditController {
CellInput::Value(value) => edit_text(value, column.value_type()),
CellInput::Default => EditText::Editable(String::new()),
};
match &text {
let refusal = match &text {
EditText::ReadOnly(reason) => {
return Plan::Refused(edit_parse_message(&EditParseError::NotEditable(*reason)));
}
// A GTK editor holds text only up to a NUL, and committing it
// would cut the value there.
EditText::Editable(editable) if editable.contains('\0') => {
return Plan::Refused(gettext("Values with a NUL character can't be edited here"));
}
EditText::Editable(editable) if editable.len() > EDIT_MAX_BYTES => {
return Plan::Refused(too_large_to_edit());
Some(gettext("Values with a NUL character can't be edited here"))
}
EditText::Editable(_) => {}
}
if !matches!(kind.for_text(&text), CellEditorKind::Inline { .. }) {
return Plan::Details;
}
EditText::Editable(editable) if editable.len() > EDIT_MAX_BYTES => Some(too_large_to_edit()),
EditText::Editable(_) => None,
};
let one_line = CellEditorKind::Inline {
accepts_line_breaks,
purpose,
};
let in_place = refusal.is_none() && matches!(one_line.for_text(&text), CellEditorKind::Inline { .. });
let EditText::Editable(text) = text else {
return Plan::Locked;
};
Plan::Inline(Box::new(InlineStart {
let start = Box::new(InlineStart {
column,
row: row.clone(),
start,
text,
accepts_line_breaks,
purpose,
}))
});
match (kind, refusal) {
// The menu holds no text, so any value opens it.
(CellEditorKind::Choice, _) => Plan::Choice { start, in_place },
(_, Some(why)) => Plan::Refused(why),
_ if in_place => Plan::Inline(start),
_ => Plan::Details,
}
}

/// Edit `cell`'s text in place, as Tab and a new row do, a cell that
/// picks from a menu included.
pub(super) fn edit_in_place(&self, cell: &GridCell) {
let (Some(grid), Some(row)) = (self.grid.upgrade(), cell.row()) else {
return;
};
if self.open_in(cell).is_some() {
return;
}
if let Some(start) = self.plan(&grid, &row, cell.column()).in_place() {
self.open_inline(&grid, cell, start, Opened::ToEdit);
}
}

fn open_inline(&self, grid: &DataGrid, cell: &GridCell, plan: Box<InlineStart>, opened: Opened) -> gtk::Text {
Expand Down Expand Up @@ -476,7 +521,7 @@ impl EditController {
shown
.get(at)
.zip(selection.item(position).and_downcast::<GridRow>())
.is_some_and(|(column, row)| matches!(self.plan(&grid, &row, *column), Plan::Inline(_)))
.is_some_and(|(column, row)| self.plan(&grid, &row, *column).in_place().is_some())
};
let target = next_edit_target(
opens_inline,
Expand All @@ -490,7 +535,7 @@ impl EditController {
match target {
Some((position, column)) => {
if let Some(cell) = grid.focus_cell(position, column) {
self.edit_cell(&cell);
self.edit_in_place(&cell);
}
}
None => {
Expand Down
Loading
Loading