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
8 changes: 8 additions & 0 deletions linux/crates/app/src/ui/browse_tab/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ impl BrowseTab {
}
}

/// Commit the cell edit open in the grid, as Return does, so the close
/// prompts count what was typed.
pub fn commit_cell_edit(&self) {
if let Some(view) = self.read(|model| model.current_column_view.clone()).flatten() {
super::grid::commit_open_edit(&view);
}
}

/// `None` once the tab is closed, and while it is handling an input.
fn read<T>(&self, read: impl FnOnce(&BrowseModel) -> T) -> Option<T> {
self.imp().model.try_borrow().ok()?.as_ref().map(read)
Expand Down
36 changes: 36 additions & 0 deletions linux/crates/app/src/ui/grid/cell_focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use super::GridCell;
use super::cell_edit::begin_edit;
use super::edit_navigation::{EditDirection, next_edit_target};
use super::grid_columns::ShownColumn;
use super::inline_editor::commit_inline;
use super::selection::row_at;

fn window_focus(widget: &impl IsA<gtk::Widget>) -> Option<gtk::Widget> {
Expand Down Expand Up @@ -57,6 +58,18 @@ pub(super) fn return_focus(cell: &GridCell) {
}
}

/// Commit the one-line edit open in `view`, as Return does.
pub(crate) fn commit_open_edit(view: &gtk::ColumnView) {
let Some(cell) = focused_cell(view) else {
return;
};
if cell.inline_text().is_none() || cell.column_view().as_ref() != Some(view) {
return;
}
commit_inline(&cell);
return_focus(&cell);
}

/// Edit the cell Tab or Shift+Tab goes to from `cell`. With none left
/// on the page, the focus stays on `cell`.
pub(super) fn edit_next(cell: &GridCell, direction: EditDirection) {
Expand Down Expand Up @@ -247,6 +260,29 @@ mod tests {
assert!(grid.take_messages().is_empty(), "Escape sent the typed text");
}

/// GTK ends the focus change by walking up from the field the edit
/// was in. GTK 4.22 logs a critical forever and never returns when the
/// field has left its cell by then.
#[gtk4::test]
fn the_focus_moving_to_another_cell_commits_the_edit() {
let grid = mixed_rows();
let name = grid.focus(0, 1);
crate::ui::grid::begin_edit(&name);
name.inline_text().expect("an editor").set_text("a2");

let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL);
let note = grid.focus(1, 3);
glib::log_set_always_fatal(previous);

assert!(!name.is_editing(), "the edit outlived the focus");
assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, text("a2"))]);
assert_eq!(grid.focused_cell().as_ref(), Some(&note));
assert!(
!name.state_flags().contains(gtk::StateFlags::FOCUS_WITHIN),
"the cell the edit left still shows the focus"
);
}

/// Another window taking the focus is no focus change in this one,
/// so the edit stays open, and typing goes on there once it is back.
#[gtk4::test]
Expand Down
18 changes: 18 additions & 0 deletions linux/crates/app/src/ui/grid/grid_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ mod imp {
pub content: OnceCell<gtk::Widget>,
/// The one-line editor, laid over the hidden display while open.
pub inline: RefCell<Option<InlineEditor>>,
/// A one-line field that lost the focus, hidden until the cell's
/// next edit.
pub closed_field: RefCell<Option<gtk::Text>>,
/// An open editor popover. Unbind pops it down, or the recycled
/// cell is finalized with a GtkPopover child still in it.
pub popover: RefCell<Option<gtk::Popover>>,
Expand Down Expand Up @@ -69,6 +72,9 @@ mod imp {
if let Some(editor) = self.inline.take() {
editor.widget().unparent();
}
if let Some(field) = self.closed_field.take() {
field.unparent();
}
if let Some(popover) = self.popover.take() {
popover.unparent();
}
Expand Down Expand Up @@ -292,6 +298,18 @@ impl GridCell {
self.imp().inline.take()
}

pub(super) fn keep_closed_field(&self, field: gtk::Text) {
if let Some(previous) = self.imp().closed_field.replace(Some(field)) {
previous.unparent();
}
}

pub(super) fn remove_closed_field(&self) {
if let Some(field) = self.imp().closed_field.take() {
field.unparent();
}
}

/// Parent `popover` to the cell and keep it there until it closes.
/// A popover the user closes ends its edit; one the editor took back
/// first has already dealt with it.
Expand Down
16 changes: 12 additions & 4 deletions linux/crates/app/src/ui/grid/inline_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub struct InlineEditor {
impl InlineEditor {
pub(super) fn begin(cell: &GridCell, snapshot: EditSnapshot, initial: &str, options: InlineOptions) {
cell.cancel_edit();
cell.remove_closed_field();
let text = gtk::Text::new();
text.set_hexpand(true);
text.set_margin_start(8);
Expand All @@ -55,11 +56,18 @@ impl InlineEditor {
&self.text
}

/// Take the field out of the cell and show the display again, with
/// the text the field held.
/// Show the display again, with the text the field held. GTK ends a
/// focus change by walking up from the widget that lost the focus, and
/// GTK 4.22 loops forever when that widget has no parent by then, so a
/// field that lost it stays in the cell, hidden, until the next edit.
pub(super) fn close(self, cell: &GridCell) -> String {
let typed = self.text.text().to_string();
self.text.unparent();
if holds_window_focus(self.text.upcast_ref()) {
self.text.unparent();
} else {
self.text.set_visible(false);
cell.keep_closed_field(self.text);
}
if let Some(display) = cell.content() {
display.set_visible(true);
}
Expand All @@ -72,7 +80,7 @@ pub(super) fn commit_inline(cell: &GridCell) {
finish_inline(cell, true);
}

/// End the cell's one-line edit. The field leaves before the commit is
/// End the cell's one-line edit. The field closes before the commit is
/// sent, so the redraw the commit causes meets no open editor.
fn finish_inline(cell: &GridCell, commit: bool) {
let Some(editor) = cell.take_inline() else {
Expand Down
2 changes: 1 addition & 1 deletion linux/crates/app/src/ui/grid/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ mod value_order;
#[cfg(test)]
pub(crate) use self::cell_edit::begin_edit;
pub(crate) use self::cell_editability::cell_editability;
pub(crate) use self::cell_focus::edit_row;
pub(crate) use self::cell_focus::{commit_open_edit, edit_row};
pub(crate) use self::cell_lock::CellLock;
pub(crate) use self::cell_preset::{CellPreset, cell_presets};
pub(crate) use self::column_factory::{GridSorting, build_column_view};
Expand Down
6 changes: 5 additions & 1 deletion linux/crates/app/src/ui/session_window/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,12 @@ impl SessionWindow {
}
}

/// What the close prompts ask about, with a value typed into a cell
/// counted among the unsaved changes.
fn workspace_handle(&self) -> Option<WorkspaceHandle> {
self.workspace().and_then(|workspace| workspace.handle())
let workspace = self.workspace()?;
workspace.commit_cell_edits();
workspace.handle()
}

fn handle_input(&self, message: SessionWindowInput) {
Expand Down
54 changes: 52 additions & 2 deletions linux/crates/app/src/ui/workspace/close_flow.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
use std::collections::HashSet;
use std::sync::Arc;

use gtk4 as gtk;
use gtk4::glib;
use gtk4::prelude::*;
use libadwaita as adw;
use libadwaita::prelude::*;
use tablepro_core::EditorSession;
use uuid::Uuid;

use super::unsaved_icon::apply_unsaved_icon;
use super::workspace_persistence::WorkspaceTabs;
use super::{WorkspaceInput, WorkspaceModel, WorkspaceTab};
use crate::runtime::DeliverInput;
use crate::ui::browse_tab::BrowseTab;
use crate::workspace::CloseDecision;

/// Every editor tab's connection, taken from its tab. Each tab's run is
Expand Down Expand Up @@ -61,9 +64,26 @@ impl WorkspaceModel {
take_editor_sessions(&self.workspace_tabs)
}

/// The table tabs, or only `id` when it is one.
pub(super) fn browse_tabs(&self, id: Option<Uuid>) -> Vec<BrowseTab> {
self.workspace_tabs
.borrow()
.iter()
.filter(|(tab, _)| id.is_none_or(|id| id == **tab))
.filter_map(|(_, tab)| match tab {
WorkspaceTab::Table(slot) => Some(slot.browse.clone()),
WorkspaceTab::Editor(_) | WorkspaceTab::Structure(_) => None,
})
.collect()
}

/// A tab asked to close. One with nothing to lose closes now; any
/// other waits for the prompts.
/// other waits for the prompts. A value typed into a cell is an
/// unsaved change by then, as it is once the focus leaves the cell.
pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid) {
for browse in self.browse_tabs(Some(id)) {
browse.commit_cell_edit();
}
let tab_view = self.tab_view.clone();
if self.close_guards.may_close_tab(&self.workspace_handle, id) {
self.finish_close_workspace_tab(id, &tab_view);
Expand All @@ -82,6 +102,36 @@ impl WorkspaceModel {
});
}

/// Take the keyboard focus out of `page` before it closes. The page
/// the view selects next is selected first, so the view hands it the
/// focus while `page` is still whole. GTK 4.22 leaks a focus that is
/// hidden and then unparented in one go, which the close does when
/// that page takes no focus.
pub(super) fn move_focus_off(&self, page: &adw::TabPage) {
let Some(window) = self.window() else {
return;
};
let child = page.child();
let holds_focus = || {
gtk::prelude::GtkWindowExt::focus(&window).is_some_and(|focus| focus == child || focus.is_ancestor(&child))
};
if !holds_focus() {
return;
}
// The documented choice for a page with no parent, and this
// workspace never gives one. The last page leaves the focus to
// the empty page the stack shows next.
if page.is_selected() && !self.tab_view.select_next_page() && !self.tab_view.select_previous_page() {
return;
}
// Where GTK moves a focus left in a removed widget, from the
// window's first control.
if holds_focus() {
gtk::prelude::GtkWindowExt::set_focus(&window, None::<&gtk::Widget>);
window.child_focus(gtk::DirectionType::TabForward);
}
}

pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) {
let tab_view = self.tab_view.clone();
if approved {
Expand Down
Loading
Loading