Skip to content
5 changes: 5 additions & 0 deletions crates/app/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ actions!(
// Workspace commands.
NewWindow,
NewProject,
Save,
// Dock/panel toggles (handled on the App root — they need a `Window`).
ToggleLeftDock,
ToggleBottomDock,
Expand All @@ -29,6 +30,9 @@ pub fn key_bindings() -> Vec<KeyBinding> {
vec![
KeyBinding::new("ctrl-shift-n", NewWindow, None),
KeyBinding::new("ctrl-n", NewProject, None),
// Save the open project's data to its `.qrate` file. Global: saving shouldn't depend on
// where focus sits (a focused cell editor's `Input` context doesn't bind Ctrl+S).
KeyBinding::new("ctrl-s", Save, None),
KeyBinding::new("ctrl-b", ToggleLeftDock, None),
KeyBinding::new("ctrl-`", ToggleBottomDock, None),
KeyBinding::new("ctrl-alt-b", ToggleRightDock, None),
Expand All @@ -49,4 +53,5 @@ pub fn register_global_handlers(cx: &mut App) {
// ponytail: NewWindow is blocked on the bar registries being globals (single-window) —
// de-globalize them per-window first.
cx.on_action(|_: &NewWindow, _cx| eprintln!("NewWindow: TODO (bar registries are global)"));
cx.on_action(|_: &Save, cx| table::save_now(cx));
}
4 changes: 3 additions & 1 deletion crates/app/src/app_menus.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use gpui::*;
use window_wrapper::OpenBrowser;

use crate::actions::{NewProject, ToggleBottomDock, ToggleLeftDock, ToggleRightDock};
use crate::actions::{NewProject, Save, ToggleBottomDock, ToggleLeftDock, ToggleRightDock};
use crate::theming::{SwitchTheme, THEME_CHOICES};

// ponytail: Undo/Redo/Cut/Copy/Paste are declared and wired into the Edit menu but have no
Expand Down Expand Up @@ -31,6 +31,8 @@ pub fn app_menus() -> Vec<Menu> {
MenuItem::action("New Project…", NewProject),
MenuItem::Separator,
MenuItem::action("Open Projects…", OpenProjects),
MenuItem::action("Save", Save),
MenuItem::Separator,
MenuItem::action("Settings", OpenSettings),
MenuItem::Separator,
MenuItem::action("Quit", Quit),
Expand Down
245 changes: 148 additions & 97 deletions crates/app/src/app_settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use gpui::{
StatefulInteractiveElement as _, Styled as _, div, px,
};
use gpui_component::{
ActiveTheme as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
ActiveTheme as _, Icon, IconName, Sizable as _,
button::Button,
h_flex,
popover::Popover,
setting::{SettingField, SettingGroup, SettingItem, SettingPage},
Expand All @@ -15,32 +15,77 @@ use settings::{Setting, columns, project::CurrentProject};

pub fn build_pages(cx: &App) -> Vec<SettingPage> {
vec![
SettingPage::new("Table").group(
SettingGroup::new().title("Appearance").item(
Setting::Switch {
key: table::TABLE_STRIPES_KEY,
label: "Row Stripes",
description: "Alternate row background color in the data table.",
}
.into(),
),
),
SettingPage::new("Table")
.group(
SettingGroup::new().title("Appearance").item(
Setting::Switch {
key: table::TABLE_STRIPES_KEY,
label: "Row Stripes",
description: "Alternate row background color in the data table.",
}
.into(),
),
)
.group(saving_group(cx)),
columns_page(cx),
]
}

/// Per-column settings, project-scoped. The page starts empty: a column is added by name, which
/// is what surfaces its settings. Built from `&App` rather than a fixed list because the columns
/// Autosave as a toggle plus, when on, the method. Both edit the one `AUTOSAVE_KEY` the table reads
/// (`off`/`timed`/`immediate`): the switch is off iff the value is `off`, so an unset value reads as
/// on. The method row only exists while autosave is on — the Settings window observes settings
/// globals (see `SettingsWindow`), so flipping the switch rebuilds this page live.
fn saving_group(cx: &App) -> SettingGroup {
let mut group = SettingGroup::new().title("Saving").item(
SettingItem::new(
"Autosave",
SettingField::switch(
|cx: &App| settings::scoped_text(settings::AUTOSAVE_KEY, cx) != "off",
|on: bool, cx: &mut App| {
settings::set_scoped_text(
settings::AUTOSAVE_KEY,
if on { "timed" } else { "off" }.into(),
cx,
);
},
),
)
.description("Save cell edits automatically. Ctrl+S always saves."),
);

if settings::scoped_text(settings::AUTOSAVE_KEY, cx) != "off" {
group = group.item(
SettingItem::new(
"Method",
SettingField::dropdown(
vec![
("timed".into(), "After a short pause".into()),
("immediate".into(), "On every edit".into()),
],
|cx: &App| {
let v = settings::scoped_text(settings::AUTOSAVE_KEY, cx);
if v == "immediate" { v } else { "timed".into() }
},
|val: SharedString, cx: &mut App| {
settings::set_scoped_text(settings::AUTOSAVE_KEY, val, cx);
},
),
)
.description("When edits reach the file: after you pause typing, or on every edit."),
);
}
group
}

/// Per-column filters, project-scoped. A master switch gates the feature; when on, a multi-select
/// picker chooses which columns show a filter dropdown (a picked column *is* a filter-enabled one —
/// selection and `filter_enabled` are the same thing now). Built from `&App` because the columns
/// depend on whichever project is open.
///
/// These use `SettingField`/`SettingItem` directly rather than the `Setting::Switch` enum — that
/// one's `key` is `&'static str` and routes through the user/project scope helpers, while a column
/// key is computed at runtime and is always project-scoped.
fn columns_page(cx: &App) -> SettingPage {
let Some(project) = cx.try_global::<CurrentProject>() else {
return SettingPage::new("Columns").group(
SettingGroup::new()
.title("Add column")
.title("Filters")
.description("Open a project to configure its columns."),
);
};
Expand All @@ -54,109 +99,115 @@ fn columns_page(cx: &App) -> SettingPage {
.enumerate()
.map(|(ix, name)| (format!("c{ix}"), SharedString::from(name.clone())))
.collect();
let tracked = columns::tracked(cx);
let unadded: Vec<(String, SharedString)> = headers
.iter()
.filter(|(key, _)| !tracked.contains(key))
.cloned()
.collect();

// Heading + one subtitle row carrying the Add button on its right.
let mut page = SettingPage::new("Columns").group(SettingGroup::new().title("Add column").item(
let mut group = SettingGroup::new().title("Filters").item(
SettingItem::new(
"Add a column to change its settings",
SettingField::element(move |_opts: &_, _window: &mut _, _cx: &mut _| {
add_column_button(unadded.clone())
}),
),
));
"Enable column filters",
SettingField::switch(
|cx: &App| columns::filters_master_enabled(cx),
|on: bool, cx: &mut App| columns::set_filters_master_enabled(on, cx),
),
)
.description("Show a filter dropdown in the header of the columns you pick."),
);

for key in tracked {
let name = headers
.iter()
.find(|(k, _)| *k == key)
.map(|(_, n)| n.clone())
.unwrap_or_else(|| SharedString::from(key.clone()));
let (get_key, set_key, remove_key) = (key.clone(), key.clone(), key.clone());
page = page.group(
SettingGroup::new()
// Column name is the group title so it lists under "Columns" in the sidebar (titled groups only).
.title(name)
.item(SettingItem::render(move |_opts, _window, _cx| {
let remove_key = remove_key.clone();
h_flex().w_full().justify_end().child(
Button::new(SharedString::from(format!("remove-{remove_key}")))
.icon(IconName::Delete)
.label("Remove")
.ghost()
.xsmall()
.on_click(move |_, _, cx: &mut App| {
columns::remove(&remove_key, cx);
}),
)
}))
.item(
SettingItem::new(
"Enable filter",
SettingField::switch(
move |cx: &App| columns::get(&get_key, cx).filter_enabled,
move |on: bool, cx: &mut App| {
columns::update(&set_key, |s| s.filter_enabled = on, cx);
},
),
)
.description("Show a filter dropdown in this column's header."),
),
if columns::filters_master_enabled(cx) {
group = group.item(
SettingItem::new(
"Filtered columns",
SettingField::element(move |_opts: &_, _window: &mut _, cx: &mut _| {
let label = picker_label(&headers, cx);
filtered_columns_picker(headers.clone(), label)
}),
)
.description("Columns that show a filter dropdown."),
);
}
page
SettingPage::new("Columns").group(group)
}

/// Trigger label for the picker: "First +N others", or "No columns" when nothing is selected.
fn picker_label(headers: &[(String, SharedString)], cx: &App) -> SharedString {
let selected: Vec<&SharedString> = headers
.iter()
.filter(|(key, _)| columns::get(key, cx).filter_enabled)
.map(|(_, name)| name)
.collect();
match selected.len() {
0 => "No columns".into(),
1 => selected[0].clone(),
n => format!(
"{} +{} other{}",
selected[0],
n - 1,
if n - 1 == 1 { "" } else { "s" }
)
.into(),
}
}

/// The Add button and its column list. A popover rather than a `Dropdown` so the list can scroll
/// and so the trigger reads as an action rather than a value being picked.
fn add_column_button(unadded: Vec<(String, SharedString)>) -> impl gpui::IntoElement {
Popover::new("add-column")
/// The picker trigger ("First +N others") and its checklist. A popover, not a `Dropdown`, so the
/// list scrolls and clicking a row toggles that column's filter without dismissing.
fn filtered_columns_picker(
headers: Vec<(String, SharedString)>,
label: SharedString,
) -> impl gpui::IntoElement {
Popover::new("filtered-columns")
.trigger(
Button::new("add-column-btn")
.icon(IconName::Plus)
.label("Add")
Button::new("filtered-columns-btn")
.label(label)
.outline()
.xsmall(),
)
.content(move |_state, _window, cx| {
let (hover, muted) = (cx.theme().secondary_hover, cx.theme().muted_foreground);
// Match gpui-component's own menu items: text_sm rows, accent highlight on hover, a
// right-aligned check marking selection (see `menu/popup_menu.rs`).
let (accent, accent_fg, muted) = (
cx.theme().accent,
cx.theme().accent_foreground,
cx.theme().muted_foreground,
);
let radius = cx.theme().radius;
let popover = cx.entity();
let rows: Vec<_> = unadded.clone();
let rows = headers.clone();
v_flex()
.id("add-column-list")
.w(px(220.))
.max_h(px(280.))
// `overflow_y_scroll` only — `overflow_hidden` would set *both* axes and silently
// undo it.
.id("filtered-columns-list")
.w(px(240.))
.max_h(px(320.))
.p_1()
// `overflow_y_scroll` only — `overflow_hidden` would set *both* axes and undo it.
.overflow_y_scroll()
.when(rows.is_empty(), |list| {
list.child(
div()
.px_2()
.py_1()
.text_sm()
.text_color(muted)
.child("Every column has been added"),
.child("No columns in this project"),
)
})
.children(rows.into_iter().map(move |(key, label)| {
let popover = popover.clone();
div()
.id(SharedString::from(format!("add-{key}")))
.px_2()
.py_1()
.children(rows.into_iter().map(move |(key, name)| {
let on = columns::get(&key, cx).filter_enabled;
let toggle_key = key.clone();
h_flex()
.id(SharedString::from(format!("pick-{key}")))
.h(px(26.))
.px(px(8.))
.gap_x_1()
.rounded(radius)
.items_center()
.justify_between()
.text_sm()
.cursor_pointer()
.hover(|r| r.bg(hover))
.child(label)
.on_click(move |_, window, cx: &mut App| {
columns::add(&key, cx);
popover.update(cx, |state, cx| state.dismiss(window, cx));
.hover(|r| r.bg(accent).text_color(accent_fg))
.child(name)
.when(on, |r| r.child(Icon::new(IconName::Check).xsmall()))
.on_click(move |_, _, cx: &mut App| {
columns::update(
&toggle_key,
|s| s.filter_enabled = !s.filter_enabled,
cx,
);
})
}))
})
Expand Down
Loading
Loading