Skip to content

Commit 37eeabd

Browse files
committed
refactor(structure-tab): split into columns / indexes / fks submodules
Pure code organization, no behavior change. structure_tab.rs (1504 LOC) becomes structure_tab/{mod,columns,indexes,fks}.rs: - columns.rs (363 LOC): per-driver type lists, ALTER capability flags, format_column_subtitle, build_column_expander_row, build_type_suggestions_button, default_type_for. - indexes.rs (65 LOC): index_badge, build_index_row. - fks.rs (66 LOC): driver_can_drop_foreign_key, build_fk_row. - mod.rs (1048 LOC): public types, StructureTab impl, SimpleComponent init/update, validation, shared utilities, apply_sql_scheme. clippy + tests pass.
1 parent 8413ab7 commit 37eeabd

4 files changed

Lines changed: 502 additions & 464 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
//! Column-row builder + per-driver type helpers used by the Columns
2+
//! page of the Structure tab.
3+
4+
use std::cell::{Cell, RefCell};
5+
use std::rc::Rc;
6+
7+
use relm4::adw::prelude::*;
8+
use relm4::gtk::gio;
9+
use relm4::{ComponentSender, adw, gtk};
10+
11+
use tablepro_core::sql_ddl::DraftColumn;
12+
13+
use super::{ColumnField, StructureTab, StructureTabInput};
14+
15+
/// Curated type lists per driver. Free-text input still allowed via
16+
/// the combo box's editable entry; this list seeds the dropdown so
17+
/// common types are one click away. Order matters — most-common at
18+
/// the top.
19+
pub(super) fn driver_types(driver_id: &str) -> &'static [&'static str] {
20+
match driver_id {
21+
"postgres" => &[
22+
"integer",
23+
"bigint",
24+
"smallint",
25+
"text",
26+
"varchar(255)",
27+
"boolean",
28+
"timestamp",
29+
"timestamp with time zone",
30+
"date",
31+
"time",
32+
"numeric(10, 2)",
33+
"real",
34+
"double precision",
35+
"uuid",
36+
"jsonb",
37+
"json",
38+
"bytea",
39+
"serial",
40+
"bigserial",
41+
],
42+
"mysql" => &[
43+
"INT",
44+
"BIGINT",
45+
"SMALLINT",
46+
"TINYINT",
47+
"VARCHAR(255)",
48+
"TEXT",
49+
"BOOLEAN",
50+
"DATETIME",
51+
"TIMESTAMP",
52+
"DATE",
53+
"TIME",
54+
"DECIMAL(10, 2)",
55+
"FLOAT",
56+
"DOUBLE",
57+
"JSON",
58+
"BLOB",
59+
"CHAR(36)",
60+
],
61+
"sqlite" => &[
62+
"INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE",
63+
],
64+
_ => &["TEXT"],
65+
}
66+
}
67+
68+
/// Whether the driver supports column-level ALTER on an existing
69+
/// column (type / nullable / default). SQLite blocks all three; the
70+
/// UI uses this to grey out non-supported cells with explanatory
71+
/// tooltips.
72+
fn driver_can_alter_existing_column(driver_id: &str) -> bool {
73+
!matches!(driver_id, "sqlite")
74+
}
75+
76+
fn driver_can_drop_column(_driver_id: &str) -> bool {
77+
// SQLite ≥ 3.35 supports DROP COLUMN; the builder doesn't probe
78+
// the runtime version. Always enable; the driver surfaces the
79+
// error if running against an older SQLite.
80+
true
81+
}
82+
83+
pub(super) fn default_type_for(driver_id: &str) -> String {
84+
match driver_id {
85+
"postgres" => "text".into(),
86+
"mysql" => "VARCHAR(255)".into(),
87+
"sqlite" => "TEXT".into(),
88+
_ => "TEXT".into(),
89+
}
90+
}
91+
92+
/// Render a draft column's summary line for the collapsed expander
93+
/// header — `varchar(255) · NOT NULL · Primary key`. Order: type,
94+
/// nullability, primary key, auto-increment. Empty parts are skipped
95+
/// so a freshly-added column with default state shows the minimum
96+
/// useful information.
97+
fn format_column_subtitle(col: &DraftColumn) -> String {
98+
let mut parts: Vec<String> = Vec::new();
99+
if !col.data_type.trim().is_empty() {
100+
parts.push(col.data_type.clone());
101+
}
102+
parts.push(if col.nullable {
103+
crate::tr!("nullable")
104+
} else {
105+
crate::tr!("NOT NULL")
106+
});
107+
if col.primary_key {
108+
parts.push(crate::tr!("Primary key"));
109+
}
110+
if col.auto_increment {
111+
parts.push(crate::tr!("auto-increment"));
112+
}
113+
parts.join(" · ")
114+
}
115+
116+
/// Build one collapsible column row as `adw::ExpanderRow`. Collapsed
117+
/// state shows the column name (title) + summary subtitle; expanded
118+
/// reveals one `AdwEntryRow` / `AdwSwitchRow` per editable attribute.
119+
/// SQLite-restricted fields render as `set_sensitive(false)` with
120+
/// explanatory tooltips so the user understands why they can't edit.
121+
/// Non-original (newly-added) columns always allow full editing —
122+
/// those become `AddColumn` ops which SQLite accepts at execution.
123+
///
124+
/// `suppress_emit` lets the caller mark a window during which signal
125+
/// callbacks should NOT push edits onto the model. Used during
126+
/// `rebuild_columns_view` to silence the `changed` / `toggled`
127+
/// emissions GTK fires while initial values are stamped onto the
128+
/// freshly-built widgets.
129+
pub(super) fn build_column_expander_row(
130+
index: usize,
131+
col: &DraftColumn,
132+
driver_id: &str,
133+
sender: ComponentSender<StructureTab>,
134+
suppress_emit: Rc<Cell<bool>>,
135+
popover_registry: Rc<RefCell<Vec<gtk::Popover>>>,
136+
) -> adw::ExpanderRow {
137+
let is_existing = col.original.is_some();
138+
let limit_for_existing = is_existing && driver_id == "sqlite";
139+
140+
let row = adw::ExpanderRow::builder()
141+
.title(glib::markup_escape_text(&col.name))
142+
.subtitle(glib::markup_escape_text(&format_column_subtitle(col)))
143+
.build();
144+
row.set_widget_name(&format!("col-row-{index}"));
145+
146+
// Trash button as a header-suffix on the expander row itself —
147+
// remains visible whether the row is expanded or collapsed.
148+
let remove_button = gtk::Button::builder()
149+
.icon_name("user-trash-symbolic")
150+
.tooltip_text(crate::tr!("Remove column"))
151+
.valign(gtk::Align::Center)
152+
.build();
153+
remove_button.add_css_class("flat");
154+
if is_existing && !driver_can_drop_column(driver_id) {
155+
remove_button.set_sensitive(false);
156+
}
157+
let sender_for_remove = sender.clone();
158+
remove_button.connect_clicked(move |_| sender_for_remove.input(StructureTabInput::RemoveColumn(index)));
159+
row.add_suffix(&remove_button);
160+
161+
// Name (AdwEntryRow). The expander's title mirrors this entry
162+
// live so the collapsed header always reflects the user's input.
163+
let name_row = adw::EntryRow::builder().title(crate::tr!("Name")).build();
164+
name_row.set_text(&col.name);
165+
name_row.set_widget_name(&format!("col-name-{index}"));
166+
let sender_for_name = sender.clone();
167+
let suppress_for_name = suppress_emit.clone();
168+
let row_for_name = row.clone();
169+
name_row.connect_changed(move |e| {
170+
if suppress_for_name.get() {
171+
return;
172+
}
173+
let text = e.text().to_string();
174+
row_for_name.set_title(&glib::markup_escape_text(&text));
175+
sender_for_name.input(StructureTabInput::ColumnEdited {
176+
index,
177+
field: ColumnField::Name(text),
178+
});
179+
});
180+
row.add_row(&name_row);
181+
182+
// Type (AdwEntryRow — free text). A suffix MenuButton offers the
183+
// curated `driver_types()` suggestions; free-text input remains the
184+
// primary path so custom types like `decimal(10,2)` or Postgres
185+
// `enum` literals work without enumeration.
186+
let type_row = adw::EntryRow::builder().title(crate::tr!("Type")).build();
187+
type_row.set_text(&col.data_type);
188+
if limit_for_existing && !driver_can_alter_existing_column(driver_id) {
189+
type_row.set_sensitive(false);
190+
type_row.set_tooltip_text(Some(&crate::tr!("Type changes aren't supported by SQLite.")));
191+
}
192+
let sender_for_type = sender.clone();
193+
let suppress_for_type = suppress_emit.clone();
194+
type_row.connect_changed(move |e| {
195+
if suppress_for_type.get() {
196+
return;
197+
}
198+
sender_for_type.input(StructureTabInput::ColumnEdited {
199+
index,
200+
field: ColumnField::Type(e.text().to_string()),
201+
});
202+
});
203+
let (suggestions_button, suggestions_popover) = build_type_suggestions_button(driver_id, &type_row);
204+
type_row.add_suffix(&suggestions_button);
205+
popover_registry.borrow_mut().push(suggestions_popover);
206+
row.add_row(&type_row);
207+
208+
// Nullable (AdwSwitchRow).
209+
let nullable_row = adw::SwitchRow::builder()
210+
.title(crate::tr!("Nullable"))
211+
.active(col.nullable)
212+
.build();
213+
if limit_for_existing && !driver_can_alter_existing_column(driver_id) {
214+
nullable_row.set_sensitive(false);
215+
nullable_row.set_tooltip_text(Some(&crate::tr!("Nullability changes aren't supported by SQLite.")));
216+
}
217+
let sender_for_null = sender.clone();
218+
let suppress_for_null = suppress_emit.clone();
219+
nullable_row.connect_active_notify(move |s| {
220+
if suppress_for_null.get() {
221+
return;
222+
}
223+
sender_for_null.input(StructureTabInput::ColumnEdited {
224+
index,
225+
field: ColumnField::Nullable(s.is_active()),
226+
});
227+
});
228+
row.add_row(&nullable_row);
229+
230+
// Default value (AdwEntryRow). Empty input means no DEFAULT clause.
231+
let default_row = adw::EntryRow::builder().title(crate::tr!("Default value")).build();
232+
default_row.set_text(col.default_value.as_deref().unwrap_or(""));
233+
if limit_for_existing && !driver_can_alter_existing_column(driver_id) {
234+
default_row.set_sensitive(false);
235+
default_row.set_tooltip_text(Some(&crate::tr!("Default changes aren't supported by SQLite.")));
236+
}
237+
let sender_for_default = sender.clone();
238+
let suppress_for_default = suppress_emit.clone();
239+
default_row.connect_changed(move |e| {
240+
if suppress_for_default.get() {
241+
return;
242+
}
243+
let text = e.text().to_string();
244+
let value = if text.is_empty() { None } else { Some(text) };
245+
sender_for_default.input(StructureTabInput::ColumnEdited {
246+
index,
247+
field: ColumnField::Default(value),
248+
});
249+
});
250+
row.add_row(&default_row);
251+
252+
// Primary key (AdwSwitchRow).
253+
let pk_row = adw::SwitchRow::builder()
254+
.title(crate::tr!("Primary key"))
255+
.active(col.primary_key)
256+
.build();
257+
let sender_for_pk = sender.clone();
258+
let suppress_for_pk = suppress_emit.clone();
259+
pk_row.connect_active_notify(move |s| {
260+
if suppress_for_pk.get() {
261+
return;
262+
}
263+
sender_for_pk.input(StructureTabInput::ColumnEdited {
264+
index,
265+
field: ColumnField::PrimaryKey(s.is_active()),
266+
});
267+
});
268+
row.add_row(&pk_row);
269+
270+
// Auto-increment (AdwSwitchRow). Bound `sensitive` to PK's
271+
// `active` so the affordance reflects the driver-level constraint
272+
// (MySQL rejects AUTO_INCREMENT on non-PK; Postgres SERIAL
273+
// implies PK).
274+
let auto_row = adw::SwitchRow::builder()
275+
.title(crate::tr!("Auto increment"))
276+
.subtitle(crate::tr!("MySQL AUTO_INCREMENT / Postgres SERIAL"))
277+
.active(col.auto_increment)
278+
.build();
279+
auto_row.set_sensitive(col.primary_key);
280+
pk_row
281+
.bind_property("active", &auto_row, "sensitive")
282+
.sync_create()
283+
.build();
284+
let sender_for_auto = sender.clone();
285+
let suppress_for_auto = suppress_emit;
286+
auto_row.connect_active_notify(move |s| {
287+
if suppress_for_auto.get() {
288+
return;
289+
}
290+
sender_for_auto.input(StructureTabInput::ColumnEdited {
291+
index,
292+
field: ColumnField::AutoIncrement(s.is_active()),
293+
});
294+
});
295+
row.add_row(&auto_row);
296+
297+
row
298+
}
299+
300+
/// Build a suffix MenuButton for the type AdwEntryRow that opens a
301+
/// native `gtk::PopoverMenu` listing curated `driver_types()` for
302+
/// `driver_id`. Selecting an entry rewrites the target row's text
303+
/// (which fires the row's `changed` signal — the existing handler
304+
/// picks up the new value). Free-text input via the entry stays as
305+
/// the primary path.
306+
///
307+
/// Implementation: a `gio::Menu` model + `MenuButton.set_menu_model`
308+
/// causes GTK to render a `gtk::PopoverMenu` automatically. That is
309+
/// the same widget powering app menus, right-click menus, and
310+
/// gnome-menus across the desktop, so the rendering is identical to
311+
/// every other GNOME menu the user has ever seen — proper menu-item
312+
/// padding, hover/active states, separators, focus ring, all native.
313+
///
314+
/// Each type-name menu item activates a single SimpleAction
315+
/// (`types.apply`) parameterised by the type string. The action is
316+
/// stored in a per-button action group so two columns' menus don't
317+
/// collide on the action name.
318+
///
319+
/// Returns the button plus the auto-built PopoverMenu so the caller
320+
/// can register it for popdown on rebuild — otherwise an open menu
321+
/// would keep its captured target alive and dispatch a click into a
322+
/// detached AdwEntryRow.
323+
fn build_type_suggestions_button(driver_id: &str, target: &adw::EntryRow) -> (gtk::MenuButton, gtk::Popover) {
324+
// Per-button action group: one action `apply` keyed by `String`
325+
// parameter. Each menu item activates `types.apply::<typename>`.
326+
let action_group = gio::SimpleActionGroup::new();
327+
let apply_action = gio::SimpleAction::new("apply", Some(&String::static_variant_type()));
328+
let target_for_action = target.clone();
329+
apply_action.connect_activate(move |_, param| {
330+
if let Some(s) = param.and_then(|v| v.get::<String>()) {
331+
target_for_action.set_text(&s);
332+
}
333+
});
334+
action_group.add_action(&apply_action);
335+
336+
// Build the menu model: every type becomes a labeled item that
337+
// activates the apply action with the type string as parameter.
338+
// gtk::PopoverMenu renders each gio::MenuItem as a native menu
339+
// entry (no separator handling needed for a flat list).
340+
let menu = gio::Menu::new();
341+
for ty in driver_types(driver_id) {
342+
let item = gio::MenuItem::new(Some(ty), None);
343+
item.set_action_and_target_value(Some("types.apply"), Some(&ty.to_variant()));
344+
menu.append_item(&item);
345+
}
346+
347+
let button = gtk::MenuButton::builder()
348+
.icon_name("pan-down-symbolic")
349+
.tooltip_text(crate::tr!("Suggested types"))
350+
.valign(gtk::Align::Center)
351+
.build();
352+
button.add_css_class("flat");
353+
button.insert_action_group("types", Some(&action_group));
354+
button.set_menu_model(Some(&menu));
355+
356+
// The PopoverMenu is auto-created by MenuButton from the menu
357+
// model. Hand it back so the caller can `popdown` it before the
358+
// owning column row is torn down on Refresh.
359+
let popover = button
360+
.popover()
361+
.expect("MenuButton creates a PopoverMenu when a menu model is set");
362+
(button, popover)
363+
}

0 commit comments

Comments
 (0)