Beautify UI across all tabs, restructure shells to multi-file, polish unit dialog

- Reorder tabs: Rex Config, Units, Plans, Shells
- Beautify Units, Plans, Shells, and Config tabs with framed lists,
  linked button groups, consistent margins, and dim labels
- Grey out plan controls and task properties when no plan/task loaded
- Restructure shells from single-file to multi-file directory model,
  paralleling the units architecture (create/delete/save files,
  create/delete/edit/move shells within files)
- Fix shells loading to scan directories for .shells files
- Beautify unit properties dialog with GtkFrame sections, dim-label
  field labels, internal padding, and linked file action buttons
- Add rectifier Select/Open/Create file buttons
- Fix GtkSwitch multi-click issue using state-set signal
- Move Save File button to unit files sidebar
- Sync unit editor state after name changes in properties dialog
This commit is contained in:
Chris Punches
2026-03-12 00:17:19 -04:00
parent 0d6b8a43f4
commit 6d02bedcbe
16 changed files with 900 additions and 505 deletions

View File

@@ -133,13 +133,17 @@ Project Project::load(const fs::path& cfg_path) {
// scan all config string values for variables, populate from environment
proj.resolver.scan_and_populate(proj.config.all_string_values());
// auto-load shells if path resolves
// auto-load all shells files from shells directory
auto sp = proj.resolved_shells_path();
if (!sp.empty() && fs::exists(sp)) {
try {
proj.shells = ShellsFile::load(sp);
} catch (const std::exception& e) {
// no status_cb set yet at load time — caller handles
if (!sp.empty() && fs::is_directory(sp)) {
for (auto& entry : fs::directory_iterator(sp)) {
if (entry.path().extension() == ".shells") {
try {
proj.shell_files.push_back(ShellsFile::load(entry.path()));
} catch (const std::exception&) {
// no status_cb set yet at load time
}
}
}
}
@@ -160,33 +164,28 @@ void Project::load_plan(const fs::path& plan_path) {
}
}
void Project::load_shells(const fs::path& shells_path) {
shells = ShellsFile();
try {
shells = ShellsFile::load(shells_path);
report_status("Loaded shells: " + shells_path.filename().string());
} catch (const std::exception& e) {
report_status("Error: failed to load shells: " + std::string(e.what()));
throw;
void Project::reload_shells() {
auto sp = resolved_shells_path();
if (sp.empty()) return;
if (!fs::is_directory(sp)) return;
shell_files.clear();
for (auto& entry : fs::directory_iterator(sp)) {
if (entry.path().extension() == ".shells") {
try {
shell_files.push_back(ShellsFile::load(entry.path()));
} catch (const std::exception& e) {
report_status("Error loading shells: " + std::string(e.what()));
}
}
}
}
void Project::reload_shells() {
auto sp = resolved_shells_path();
if (sp.empty()) {
report_status("Error: shells path not resolved");
return;
}
if (!fs::exists(sp)) {
report_status("Error: shells file not found: " + sp.string());
return;
}
try {
shells = ShellsFile::load(sp);
report_status("Loaded shells: " + sp.filename().string());
} catch (const std::exception& e) {
report_status("Error: failed to load shells: " + std::string(e.what()));
}
std::vector<ShellDef> Project::all_shells() const {
std::vector<ShellDef> result;
for (auto& sf : shell_files)
result.insert(result.end(), sf.shells.begin(), sf.shells.end());
return result;
}
void Project::save_config() {
@@ -201,15 +200,6 @@ void Project::save_plans() {
}
}
void Project::save_shells() {
if (shells.filepath.empty()) {
report_status("Error: no shells loaded to save");
return;
}
shells.save();
report_status("Saved shells: " + shells.filepath.filename().string());
}
void Project::open_config(const fs::path& new_config_path) {
config_path = fs::canonical(new_config_path);
config = RexConfig::load(config_path);
@@ -217,16 +207,7 @@ void Project::open_config(const fs::path& new_config_path) {
resolver.scan_and_populate(config.all_string_values());
// reload shells
shells = ShellsFile();
auto sp = resolved_shells_path();
if (!sp.empty() && fs::exists(sp)) {
try {
shells = ShellsFile::load(sp);
report_status("Loaded shells: " + sp.filename().string());
} catch (const std::exception& e) {
report_status(std::string("Error loading shells: ") + e.what());
}
}
reload_shells();
// reload units
load_all_units();
@@ -241,7 +222,7 @@ void Project::close_config() {
resolver = VarResolver();
plans.clear();
unit_files.clear();
shells = ShellsFile();
shell_files.clear();
report_status("Config closed");
}

View File

@@ -37,7 +37,7 @@ public:
std::vector<Plan> plans;
std::vector<UnitFile> unit_files;
ShellsFile shells;
std::vector<ShellsFile> shell_files;
// status reporting
using StatusCallback = void(*)(const std::string& msg, void* data);
@@ -55,11 +55,10 @@ public:
bool is_unit_name_taken(const std::string& name, const Unit* exclude = nullptr) const;
void load_all_units();
void load_plan(const std::filesystem::path& plan_path);
void load_shells(const std::filesystem::path& shells_path);
void reload_shells();
std::vector<ShellDef> all_shells() const;
void save_config();
void save_plans();
void save_shells();
void open_config(const std::filesystem::path& new_config_path);
void close_config();

View File

@@ -49,6 +49,7 @@ struct DialogState {
GCancellable* cancellable = nullptr;
// widgets
GtkWidget* entry_name = nullptr;
GtkWidget* entry_target = nullptr;
GtkWidget* box_target = nullptr;
GtkWidget* switch_shell_cmd = nullptr;
@@ -59,6 +60,7 @@ struct DialogState {
GtkWidget* box_workdir = nullptr;
GtkWidget* switch_rectify = nullptr;
GtkWidget* entry_rectifier = nullptr;
GtkWidget* box_rectifier = nullptr;
GtkWidget* switch_active = nullptr;
GtkWidget* switch_required = nullptr;
GtkWidget* switch_set_user_ctx = nullptr;
@@ -92,13 +94,15 @@ struct DialogState {
static void update_sensitivity(DialogState* s);
static void dlg_switch_toggled_cb(GObject* sw, GParamSpec*, gpointer data) {
static gboolean dlg_switch_state_set_cb(GtkSwitch* sw, gboolean new_state, gpointer data) {
auto* b = static_cast<DlgSwitchBinding*>(data);
gtk_switch_set_state(sw, new_state);
if (b->state) {
*b->target = gtk_switch_get_active(GTK_SWITCH(sw));
*b->target = new_state;
if (!b->state->loading)
update_sensitivity(b->state);
}
return TRUE;
}
static int shell_index(const std::vector<ShellDef>& shells, const std::string& name) {
@@ -110,6 +114,7 @@ static int shell_index(const std::vector<ShellDef>& shells, const std::string& n
static GtkWidget* make_switch_row(GtkWidget* grid, int row, const char* label_text, GtkWidget** label_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_widget_add_css_class(label, "dim-label");
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* sw = gtk_switch_new();
@@ -121,6 +126,7 @@ static GtkWidget* make_switch_row(GtkWidget* grid, int row, const char* label_te
static GtkWidget* make_entry_row(GtkWidget* grid, int row, const char* label_text, GtkWidget** label_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_widget_add_css_class(label, "dim-label");
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* entry = gtk_entry_new();
@@ -133,6 +139,7 @@ static GtkWidget* make_browse_row(DialogState* s, GtkWidget* grid, int row, cons
GtkWidget** label_out, GtkWidget** box_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_widget_add_css_class(label, "dim-label");
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
@@ -143,7 +150,7 @@ static GtkWidget* make_browse_row(DialogState* s, GtkWidget* grid, int row, cons
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(hbox), entry);
auto* btn = gtk_button_new_with_label("Select");
auto* btn = gtk_button_new_with_label("Select...");
gtk_box_append(GTK_BOX(hbox), btn);
struct BrowseBtnData {
@@ -195,6 +202,7 @@ static GtkWidget* make_file_row(DialogState* s, GtkWidget* grid, int row, const
GtkWidget** label_out, GtkWidget** box_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_widget_add_css_class(label, "dim-label");
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
@@ -205,12 +213,15 @@ static GtkWidget* make_file_row(DialogState* s, GtkWidget* grid, int row, const
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(hbox), entry);
auto* file_btn_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(file_btn_group, "linked");
auto* btn_browse = gtk_button_new_with_label("Select");
auto* btn_open = gtk_button_new_with_label("Open");
auto* btn_new = gtk_button_new_with_label("Create");
gtk_box_append(GTK_BOX(hbox), btn_browse);
gtk_box_append(GTK_BOX(hbox), btn_open);
gtk_box_append(GTK_BOX(hbox), btn_new);
gtk_box_append(GTK_BOX(file_btn_group), btn_browse);
gtk_box_append(GTK_BOX(file_btn_group), btn_open);
gtk_box_append(GTK_BOX(file_btn_group), btn_new);
gtk_box_append(GTK_BOX(hbox), file_btn_group);
struct FileBtnData {
DialogState* state;
@@ -345,7 +356,7 @@ static void update_sensitivity(DialogState* s) {
show(active && s->working_copy.is_shell_command, {s->label_shell_def, s->dropdown_shell_def, s->label_force_pty, s->switch_force_pty});
show(active && s->working_copy.set_working_directory, {s->label_workdir, s->box_workdir});
show(active && s->working_copy.rectify, {s->label_rectifier, s->entry_rectifier});
show(active && s->working_copy.rectify, {s->label_rectifier, s->box_rectifier});
show(active && s->working_copy.set_user_context, {s->label_user, s->entry_user, s->label_group, s->entry_group});
show(active && s->working_copy.supply_environment, {s->label_environment, s->box_environment});
}
@@ -361,6 +372,7 @@ static void populate_and_connect(DialogState* s) {
g_object_unref(string_list);
s->loading = true;
gtk_editable_set_text(GTK_EDITABLE(s->entry_name), u.name.c_str());
gtk_editable_set_text(GTK_EDITABLE(s->entry_target), u.target.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_shell_cmd), u.is_shell_command);
gtk_drop_down_set_selected(GTK_DROP_DOWN(s->dropdown_shell_def), shell_index(*s->shells, u.shell_definition));
@@ -388,6 +400,7 @@ static void populate_and_connect(DialogState* s) {
*eb->target = gtk_editable_get_text(e);
}), eb);
};
bind_entry(s->entry_name, &u.name);
bind_entry(s->entry_target, &u.target);
bind_entry(s->entry_workdir, &u.working_directory);
bind_entry(s->entry_rectifier, &u.rectifier);
@@ -399,7 +412,7 @@ static void populate_and_connect(DialogState* s) {
auto bind_switch = [s](GtkWidget* sw, bool* target) {
auto* sb = new DlgSwitchBinding{s, target};
s->switch_bindings.push_back(sb);
g_signal_connect(sw, "notify::active", G_CALLBACK(dlg_switch_toggled_cb), sb);
g_signal_connect(sw, "state-set", G_CALLBACK(dlg_switch_state_set_cb), sb);
};
bind_switch(s->switch_shell_cmd, &u.is_shell_command);
bind_switch(s->switch_force_pty, &u.force_pty);
@@ -428,29 +441,24 @@ UnitDialogResult show_unit_properties_dialog(GtkWindow* parent,
gtk_window_set_title(GTK_WINDOW(win), ("Unit Properties: " + unit->name).c_str());
gtk_window_set_transient_for(GTK_WINDOW(win), parent);
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 600, 550);
gtk_window_set_default_size(GTK_WINDOW(win), 650, 620);
auto* outer_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(outer_box, 16);
gtk_widget_set_margin_end(outer_box, 16);
gtk_widget_set_margin_start(outer_box, 20);
gtk_widget_set_margin_end(outer_box, 20);
gtk_widget_set_margin_top(outer_box, 16);
gtk_widget_set_margin_bottom(outer_box, 16);
// Unit name header
auto* name_header = gtk_label_new(nullptr);
auto header_markup = std::string("<big><b>") + unit->name + "</b></big>";
gtk_label_set_markup(GTK_LABEL(name_header), header_markup.c_str());
gtk_widget_set_halign(name_header, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(outer_box), name_header);
// Scrolled content area
auto* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
auto* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
auto* content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_start(content, 4);
gtk_widget_set_margin_end(content, 4);
gtk_widget_set_margin_top(content, 8);
gtk_widget_set_margin_bottom(content, 8);
auto* loop = g_main_loop_new(nullptr, FALSE);
auto* cancellable = g_cancellable_new();
@@ -464,38 +472,93 @@ UnitDialogResult show_unit_properties_dialog(GtkWindow* parent,
state.shells = &shells;
state.cancellable = cancellable;
int r = 0;
state.entry_target = make_file_row(&state, grid, r++, "Target", &state.label_target, &state.box_target);
state.switch_shell_cmd = make_switch_row(grid, r++, "Shell Command", &state.label_shell_cmd);
// Helper to create a framed section with a bold title label
auto make_section = [&](const char* title) {
auto* frame = gtk_frame_new(nullptr);
auto* frame_label = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(frame_label), (std::string(" <b>") + title + "</b> ").c_str());
gtk_frame_set_label_widget(GTK_FRAME(frame), frame_label);
gtk_box_append(GTK_BOX(content), frame);
return frame;
};
// Helper to create a grid inside a frame with internal padding
auto make_grid = [&](GtkWidget* frame) {
auto* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 8);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
gtk_widget_set_margin_start(grid, 12);
gtk_widget_set_margin_end(grid, 12);
gtk_widget_set_margin_top(grid, 8);
gtk_widget_set_margin_bottom(grid, 12);
gtk_frame_set_child(GTK_FRAME(frame), grid);
return grid;
};
// === Identity ===
auto* id_frame = make_section("Identity");
auto* id_grid = make_grid(id_frame);
state.entry_name = make_entry_row(id_grid, 0, "Name", nullptr);
// === Status ===
auto* status_frame = make_section("Status");
auto* status_grid = make_grid(status_frame);
state.switch_active = make_switch_row(status_grid, 0, "Active", &state.label_active);
state.switch_required = make_switch_row(status_grid, 1, "Required", &state.label_required);
// === Execution ===
auto* exec_frame = make_section("Execution");
auto* exec_grid = make_grid(exec_frame);
int r = 0;
state.entry_target = make_file_row(&state, exec_grid, r++, "Target", &state.label_target, &state.box_target);
state.switch_shell_cmd = make_switch_row(exec_grid, r++, "Shell Command", &state.label_shell_cmd);
// Shell definition dropdown
state.label_shell_def = gtk_label_new("Shell Definition");
gtk_label_set_xalign(GTK_LABEL(state.label_shell_def), 1.0f);
gtk_grid_attach(GTK_GRID(grid), state.label_shell_def, 0, r, 1, 1);
gtk_widget_add_css_class(state.label_shell_def, "dim-label");
gtk_grid_attach(GTK_GRID(exec_grid), state.label_shell_def, 0, r, 1, 1);
auto* string_list = gtk_string_list_new(nullptr);
state.dropdown_shell_def = gtk_drop_down_new(G_LIST_MODEL(string_list), nullptr);
gtk_widget_set_hexpand(state.dropdown_shell_def, TRUE);
gtk_grid_attach(GTK_GRID(grid), state.dropdown_shell_def, 1, r++, 1, 1);
gtk_grid_attach(GTK_GRID(exec_grid), state.dropdown_shell_def, 1, r++, 1, 1);
state.switch_force_pty = make_switch_row(grid, r++, "Force PTY", &state.label_force_pty);
state.switch_set_workdir = make_switch_row(grid, r++, "Set Working Dir", &state.label_set_workdir);
state.entry_workdir = make_browse_row(&state, grid, r++, "Working Directory", &state.label_workdir, &state.box_workdir);
state.switch_rectify = make_switch_row(grid, r++, "Rectify", &state.label_rectify);
state.entry_rectifier = make_entry_row(grid, r++, "Rectifier", &state.label_rectifier);
state.switch_active = make_switch_row(grid, r++, "Active", &state.label_active);
state.switch_required = make_switch_row(grid, r++, "Required", &state.label_required);
state.switch_set_user_ctx = make_switch_row(grid, r++, "Set User Context", &state.label_set_user_ctx);
state.entry_user = make_entry_row(grid, r++, "User", &state.label_user);
state.entry_group = make_entry_row(grid, r++, "Group", &state.label_group);
state.switch_supply_env = make_switch_row(grid, r++, "Supply Environment", &state.label_supply_env);
state.entry_environment = make_file_row(&state, grid, r++, "Environment", &state.label_environment, &state.box_environment);
state.switch_force_pty = make_switch_row(exec_grid, r++, "Force PTY", &state.label_force_pty);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), grid);
// === Working Directory ===
auto* wd_frame = make_section("Working Directory");
auto* wd_grid = make_grid(wd_frame);
state.switch_set_workdir = make_switch_row(wd_grid, 0, "Set Working Dir", &state.label_set_workdir);
state.entry_workdir = make_browse_row(&state, wd_grid, 1, "Working Directory", &state.label_workdir, &state.box_workdir);
// === Validation ===
auto* val_frame = make_section("Validation");
auto* val_grid = make_grid(val_frame);
state.switch_rectify = make_switch_row(val_grid, 0, "Rectify", &state.label_rectify);
state.entry_rectifier = make_file_row(&state, val_grid, 1, "Rectifier", &state.label_rectifier, &state.box_rectifier);
// === Security ===
auto* sec_frame = make_section("Security");
auto* sec_grid = make_grid(sec_frame);
state.switch_set_user_ctx = make_switch_row(sec_grid, 0, "Set User Context", &state.label_set_user_ctx);
state.entry_user = make_entry_row(sec_grid, 1, "User", &state.label_user);
state.entry_group = make_entry_row(sec_grid, 2, "Group", &state.label_group);
// === Environment ===
auto* env_frame = make_section("Environment");
auto* env_grid = make_grid(env_frame);
state.switch_supply_env = make_switch_row(env_grid, 0, "Supply Environment", &state.label_supply_env);
state.entry_environment = make_file_row(&state, env_grid, 1, "Environment", &state.label_environment, &state.box_environment);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), content);
gtk_box_append(GTK_BOX(outer_box), scroll);
// Button row
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
auto* btn_sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_margin_top(btn_sep, 4);
gtk_box_append(GTK_BOX(outer_box), btn_sep);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
gtk_widget_set_margin_top(btn_row, 4);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_save = gtk_button_new_with_label("Save");
gtk_widget_add_css_class(btn_save, "suggested-action");
@@ -518,6 +581,15 @@ UnitDialogResult show_unit_properties_dialog(GtkWindow* parent,
g_signal_connect(btn_save, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
if (s->working_copy.name.empty()) {
s->project->report_status("Error: unit name cannot be empty");
return;
}
if (s->working_copy.name != s->original->name &&
s->project->is_unit_name_taken(s->working_copy.name, s->original)) {
s->project->report_status("Error: unit '" + s->working_copy.name + "' already exists");
return;
}
*s->original = s->working_copy;
s->result = UnitDialogResult::Save;
gtk_window_close(GTK_WINDOW(s->window));

View File

@@ -17,6 +17,7 @@
*/
#include "util/unsaved_dialog.h"
#include <string>
namespace grex {
@@ -26,46 +27,58 @@ struct DialogState {
GtkWidget* window;
};
static void on_revert_clicked(GtkButton*, gpointer d) {
auto* state = static_cast<DialogState*>(d);
state->result = UnsavedResult::Revert;
gtk_window_close(GTK_WINDOW(state->window));
// When the parent gets WM focus while our dialog is up, yank it back.
static void on_parent_activated(GObject*, GParamSpec*, gpointer d) {
auto* dialog_win = static_cast<GtkWidget*>(d);
gtk_window_present(GTK_WINDOW(dialog_win));
}
static void on_save_clicked(GtkButton*, gpointer d) {
auto* state = static_cast<DialogState*>(d);
state->result = UnsavedResult::Save;
gtk_window_close(GTK_WINDOW(state->window));
}
static void on_dialog_closed(GtkWidget*, gpointer d) {
auto* state = static_cast<DialogState*>(d);
g_main_loop_quit(state->loop);
}
static bool dialog_active = false;
UnsavedResult show_unsaved_dialog(GtkWindow* parent) {
if (dialog_active)
return UnsavedResult::Cancel;
dialog_active = true;
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "Unsaved Changes");
gtk_window_set_transient_for(GTK_WINDOW(win), parent);
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 350, -1);
gtk_window_set_default_size(GTK_WINDOW(win), 380, -1);
gtk_window_set_resizable(GTK_WINDOW(win), FALSE);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(box, 16);
gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16);
gtk_widget_set_margin_bottom(box, 16);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16);
gtk_widget_set_margin_start(box, 24);
gtk_widget_set_margin_end(box, 24);
gtk_widget_set_margin_top(box, 20);
gtk_widget_set_margin_bottom(box, 20);
auto* label = gtk_label_new("You have unsaved changes.");
gtk_box_append(GTK_BOX(box), label);
// Header
auto* header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(header), "<big><b>Unsaved Changes</b></big>");
gtk_widget_set_halign(header, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(box), header);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
// Description
auto* desc = gtk_label_new("You have unsaved changes that will be lost if you continue without saving.");
gtk_label_set_wrap(GTK_LABEL(desc), TRUE);
gtk_label_set_xalign(GTK_LABEL(desc), 0.5f);
gtk_widget_add_css_class(desc, "dim-label");
gtk_box_append(GTK_BOX(box), desc);
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// Button row
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_revert = gtk_button_new_with_label("Revert");
auto* btn_discard = gtk_button_new_with_label("Discard");
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_save = gtk_button_new_with_label("Save");
gtk_widget_add_css_class(btn_discard, "destructive-action");
gtk_widget_add_css_class(btn_save, "suggested-action");
gtk_box_append(GTK_BOX(btn_row), btn_revert);
gtk_box_append(GTK_BOX(btn_row), btn_discard);
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_save);
gtk_box_append(GTK_BOX(box), btn_row);
@@ -73,15 +86,35 @@ UnsavedResult show_unsaved_dialog(GtkWindow* parent) {
gtk_window_set_default_widget(GTK_WINDOW(win), btn_save);
auto* loop = g_main_loop_new(nullptr, FALSE);
DialogState state{UnsavedResult::Save, loop, win};
DialogState state{UnsavedResult::Cancel, loop, win};
g_signal_connect(btn_revert, "clicked", G_CALLBACK(on_revert_clicked), &state);
g_signal_connect(btn_save, "clicked", G_CALLBACK(on_save_clicked), &state);
g_signal_connect(win, "destroy", G_CALLBACK(on_dialog_closed), &state);
auto on_btn = +[](GtkButton* btn, gpointer d) {
auto* s = static_cast<DialogState*>(d);
auto label = std::string(gtk_button_get_label(btn));
if (label == "Save") s->result = UnsavedResult::Save;
else if (label == "Discard") s->result = UnsavedResult::Discard;
else s->result = UnsavedResult::Cancel;
gtk_window_close(GTK_WINDOW(s->window));
};
g_signal_connect(btn_discard, "clicked", G_CALLBACK(on_btn), &state);
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(on_btn), &state);
g_signal_connect(btn_save, "clicked", G_CALLBACK(on_btn), &state);
g_signal_connect(win, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
g_main_loop_quit(s->loop);
}), &state);
// Steal focus back if WM raises the parent while dialog is up
auto focus_guard = g_signal_connect(parent, "notify::is-active",
G_CALLBACK(on_parent_activated), win);
gtk_window_present(GTK_WINDOW(win));
g_main_loop_run(loop);
g_signal_handler_disconnect(parent, focus_guard);
g_main_loop_unref(loop);
dialog_active = false;
return state.result;
}

View File

@@ -21,9 +21,9 @@
namespace grex {
enum class UnsavedResult { Save, Revert };
enum class UnsavedResult { Save, Discard, Cancel };
// Shows a blocking modal "Unsaved Changes" dialog with Revert and Save buttons.
// Shows a blocking modal "Unsaved Changes" dialog with Discard, Cancel, and Save buttons.
// Returns the user's choice. Uses a nested GLib main loop to block.
UnsavedResult show_unsaved_dialog(GtkWindow* parent);

View File

@@ -48,13 +48,13 @@ ConfigView::ConfigView(Project& project) : project_(project) {
// === Config file label + Open/Close buttons ===
config_label_ = gtk_label_new(nullptr);
gtk_label_set_xalign(GTK_LABEL(config_label_), 0.0f);
gtk_widget_add_css_class(config_label_, "title-3");
gtk_box_append(GTK_BOX(box), config_label_);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
btn_open_ = gtk_button_new_with_label("Open Config");
btn_create_ = gtk_button_new_with_label("Create Config");
btn_close_ = gtk_button_new_with_label("Close Config");
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(btn_row, "linked");
btn_open_ = gtk_button_new_with_label("Open");
btn_create_ = gtk_button_new_with_label("Create");
btn_close_ = gtk_button_new_with_label("Close");
gtk_widget_set_hexpand(btn_open_, TRUE);
gtk_widget_set_hexpand(btn_create_, TRUE);
gtk_widget_set_hexpand(btn_close_, TRUE);
@@ -67,67 +67,72 @@ ConfigView::ConfigView(Project& project) : project_(project) {
g_signal_connect(btn_create_, "clicked", G_CALLBACK(on_create_config), this);
g_signal_connect(btn_close_, "clicked", G_CALLBACK(on_close_config), this);
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// === Config content — greyed out when no config loaded ===
config_content_ = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_box_append(GTK_BOX(config_content_), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// === Config fields section ===
auto* config_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(config_header), "<b>Configuration</b>");
gtk_label_set_xalign(GTK_LABEL(config_header), 0.0f);
gtk_box_append(GTK_BOX(box), config_header);
gtk_box_append(GTK_BOX(config_content_), config_header);
config_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(config_grid_), 8);
gtk_grid_set_column_spacing(GTK_GRID(config_grid_), 12);
gtk_box_append(GTK_BOX(box), config_grid_);
gtk_box_append(GTK_BOX(config_content_), config_grid_);
build_config_fields();
// === Resolved paths section ===
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(config_content_), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* resolved_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(resolved_header), "<b>Resolved Paths</b>");
gtk_label_set_xalign(GTK_LABEL(resolved_header), 0.0f);
gtk_box_append(GTK_BOX(box), resolved_header);
gtk_box_append(GTK_BOX(config_content_), resolved_header);
resolved_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(resolved_grid_), 4);
gtk_grid_set_column_spacing(GTK_GRID(resolved_grid_), 12);
gtk_box_append(GTK_BOX(box), resolved_grid_);
gtk_box_append(GTK_BOX(config_content_), resolved_grid_);
// === Variables section ===
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(config_content_), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* vars_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(vars_header), "<b>Variables</b>");
gtk_label_set_xalign(GTK_LABEL(vars_header), 0.0f);
gtk_box_append(GTK_BOX(box), vars_header);
gtk_box_append(GTK_BOX(config_content_), vars_header);
auto* vars_desc = gtk_label_new("Set values for variables found in config fields. Environment variables are used automatically.");
gtk_label_set_xalign(GTK_LABEL(vars_desc), 0.0f);
gtk_label_set_wrap(GTK_LABEL(vars_desc), TRUE);
gtk_box_append(GTK_BOX(box), vars_desc);
gtk_widget_add_css_class(vars_desc, "dim-label");
gtk_box_append(GTK_BOX(config_content_), vars_desc);
vars_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(vars_grid_), 8);
gtk_grid_set_column_spacing(GTK_GRID(vars_grid_), 12);
gtk_box_append(GTK_BOX(box), vars_grid_);
gtk_box_append(GTK_BOX(config_content_), vars_grid_);
build_variables_section();
update_resolved_labels();
// === Save button ===
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(config_content_), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
btn_save_ = gtk_button_new_with_label("Save Config");
gtk_widget_set_halign(btn_save_, GTK_ALIGN_END);
gtk_box_append(GTK_BOX(box), btn_save_);
gtk_box_append(GTK_BOX(config_content_), btn_save_);
g_signal_connect(btn_save_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
static_cast<ConfigView*>(d)->apply_config();
}), this);
gtk_box_append(GTK_BOX(box), config_content_);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(root_), box);
update_config_buttons();
@@ -233,8 +238,6 @@ void ConfigView::build_variables_section() {
if (b->view->rebuilding_) return;
b->view->project_.resolver.set(b->var_name, gtk_editable_get_text(e));
b->view->update_resolved_labels();
b->view->dirty_ = true;
gtk_widget_add_css_class(b->view->btn_save_, "suggested-action");
}), binding);
row++;
@@ -322,13 +325,7 @@ void ConfigView::apply_config() {
project_.report_status("Config saved: " + project_.config_path.filename().string());
// reload shells if path now resolves
auto sp = project_.resolved_shells_path();
if (!sp.empty() && fs::exists(sp)) {
project_.shells = ShellsFile::load(sp);
project_.report_status("Loaded shells: " + sp.filename().string());
} else if (!sp.empty()) {
project_.report_status("Error: shells path not found: " + sp.string());
}
project_.reload_shells();
// notify MainWindow to refresh other views
if (apply_cb_)
@@ -349,6 +346,7 @@ void ConfigView::update_config_buttons() {
gtk_widget_set_visible(btn_open_, !has_config);
gtk_widget_set_visible(btn_create_, !has_config);
gtk_widget_set_visible(btn_close_, has_config);
gtk_widget_set_sensitive(config_content_, has_config);
}
void ConfigView::refresh() {
@@ -466,6 +464,8 @@ void ConfigView::on_close_config(GtkButton*, gpointer data) {
if (self->dirty_) {
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Cancel)
return;
if (result == UnsavedResult::Save)
self->apply_config();
}

View File

@@ -57,6 +57,7 @@ private:
GtkWidget* btn_create_;
GtkWidget* btn_close_;
GtkWidget* btn_save_;
GtkWidget* config_content_;
void build_config_fields();
void build_variables_section();

View File

@@ -25,60 +25,95 @@
namespace grex {
struct TabClickData {
MainWindow* self;
int index;
};
MainWindow::MainWindow(GtkApplication* app, Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
window_ = gtk_application_window_new(app);
gtk_window_set_title(GTK_WINDOW(window_), "grex");
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Rex Config");
gtk_window_set_default_size(GTK_WINDOW(window_), 1200, 800);
auto* vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
// Header bar
auto* header = gtk_header_bar_new();
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Rex Config");
gtk_window_set_titlebar(GTK_WINDOW(window_), header);
// Grex Config button in header bar (opens modal dialog)
// Grex Config button in header bar
auto* grex_btn = gtk_button_new_with_label("Grex Config");
gtk_widget_remove_css_class(grex_btn, "flat");
gtk_header_bar_pack_end(GTK_HEADER_BAR(header), grex_btn);
g_signal_connect(grex_btn, "clicked", G_CALLBACK(on_grex_config_clicked), this);
// Stack + switcher
// Stack (no GtkStackSwitcher — we use our own buttons)
stack_ = gtk_stack_new();
auto* stack = stack_;
gtk_stack_set_transition_type(GTK_STACK(stack), GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT);
switcher_ = gtk_stack_switcher_new();
gtk_stack_switcher_set_stack(GTK_STACK_SWITCHER(switcher_), GTK_STACK(stack));
gtk_widget_set_halign(switcher_, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(vbox), switcher_);
gtk_stack_set_transition_type(GTK_STACK(stack_), GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT);
// Wire project status reporting to status bar
project_.status_cb = on_project_status;
project_.status_cb_data = this;
// Rex Config view
// Create views
config_view_ = new ConfigView(project_);
config_view_->set_apply_callback(on_config_applied, this);
gtk_stack_add_titled(GTK_STACK(stack), config_view_->widget(), "config", "Rex Config");
gtk_stack_add_named(GTK_STACK(stack_), config_view_->widget(), "config");
// Plan view
plan_view_ = new PlanView(project_, grex_config_);
gtk_stack_add_titled(GTK_STACK(stack), plan_view_->widget(), "plans", "Plans");
gtk_stack_add_named(GTK_STACK(stack_), plan_view_->widget(), "plans");
// Units view
units_view_ = new UnitsView(project_, grex_config_);
gtk_stack_add_titled(GTK_STACK(stack), units_view_->widget(), "units", "Units");
gtk_stack_add_named(GTK_STACK(stack_), units_view_->widget(), "units");
// Shells view
shells_view_ = new ShellsView(project_);
gtk_stack_add_titled(GTK_STACK(stack), shells_view_->widget(), "shells", "Shells");
gtk_stack_add_named(GTK_STACK(stack_), shells_view_->widget(), "shells");
gtk_widget_set_vexpand(stack, TRUE);
gtk_box_append(GTK_BOX(vbox), stack);
// Tab bar — plain buttons, we control switching
auto* tab_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_set_halign(tab_bar, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(tab_bar, "linked");
g_signal_connect(stack, "notify::visible-child", G_CALLBACK(on_stack_page_changed), this);
const char* tab_labels[] = {"Rex Config", "Units", "Plans", "Shells"};
GtkWidget* tab_pages[] = {
config_view_->widget(), units_view_->widget(),
plan_view_->widget(), shells_view_->widget()
};
for (int i = 0; i < 4; i++) {
auto* btn = gtk_toggle_button_new_with_label(tab_labels[i]);
gtk_box_append(GTK_BOX(tab_bar), btn);
tabs_.push_back({tab_labels[i], tab_pages[i], btn});
auto* data = new TabClickData{this, i};
g_signal_connect(btn, "clicked", G_CALLBACK(+[](GtkToggleButton* btn, gpointer d) {
auto* td = static_cast<TabClickData*>(d);
auto* self = td->self;
int idx = td->index;
// If clicking the already-active tab, keep it toggled and do nothing
if (idx == self->current_tab_) {
gtk_toggle_button_set_active(btn, TRUE);
return;
}
// Check dirty state before allowing switch
if (!self->check_dirty_and_resolve()) {
// Cancelled — re-toggle current tab button, untoggle this one
self->update_tab_buttons();
return;
}
self->switch_to_tab(idx);
}), data);
}
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(tabs_[0].button), TRUE);
gtk_box_append(GTK_BOX(vbox), tab_bar);
gtk_widget_set_vexpand(stack_, TRUE);
gtk_box_append(GTK_BOX(vbox), stack_);
// Status bar
gtk_box_append(GTK_BOX(vbox), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
@@ -94,10 +129,7 @@ MainWindow::MainWindow(GtkApplication* app, Project& project, GrexConfig& grex_c
gtk_box_append(GTK_BOX(status_bar), status_label_);
gtk_box_append(GTK_BOX(vbox), status_bar);
prev_page_ = config_view_->widget();
gtk_window_set_child(GTK_WINDOW(window_), vbox);
update_tab_sensitivity();
}
@@ -112,6 +144,54 @@ void MainWindow::set_status(const std::string& msg) {
gtk_label_set_text(GTK_LABEL(status_label_), msg.c_str());
}
void MainWindow::update_tab_buttons() {
for (int i = 0; i < (int)tabs_.size(); i++)
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(tabs_[i].button), i == current_tab_);
}
bool MainWindow::check_dirty_and_resolve() {
auto* cur = tabs_[current_tab_].page;
bool dirty = false;
if (cur == config_view_->widget() && config_view_->is_dirty())
dirty = true;
else if (cur == plan_view_->widget() && plan_view_->is_dirty())
dirty = true;
else if (cur == units_view_->widget() && units_view_->is_dirty())
dirty = true;
if (!dirty)
return true;
auto result = show_unsaved_dialog(GTK_WINDOW(window_));
if (result == UnsavedResult::Cancel)
return false;
if (result == UnsavedResult::Save) {
if (cur == config_view_->widget())
config_view_->apply_config();
else if (cur == plan_view_->widget())
plan_view_->save_dirty();
else if (cur == units_view_->widget())
units_view_->save_current_file();
} else {
if (cur == config_view_->widget())
config_view_->refresh();
else if (cur == plan_view_->widget())
plan_view_->revert_dirty();
else if (cur == units_view_->widget())
units_view_->refresh();
}
return true;
}
void MainWindow::switch_to_tab(int idx) {
current_tab_ = idx;
gtk_stack_set_visible_child(GTK_STACK(stack_), tabs_[idx].page);
update_tab_buttons();
refresh_visible_page();
}
void MainWindow::on_config_applied(void* data) {
auto* self = static_cast<MainWindow*>(data);
self->update_tab_sensitivity();
@@ -123,82 +203,25 @@ void MainWindow::on_config_applied(void* data) {
void MainWindow::update_tab_sensitivity() {
bool has_config = !project_.config_path.empty();
// Enable/disable the page content
gtk_widget_set_sensitive(plan_view_->widget(), has_config);
gtk_widget_set_sensitive(units_view_->widget(), has_config);
gtk_widget_set_sensitive(shells_view_->widget(), has_config);
// Grey out the switcher buttons for disabled tabs
// GtkStackSwitcher children are toggle buttons in page order
auto* child = gtk_widget_get_first_child(switcher_);
int i = 0;
while (child) {
// child 0 = Rex Config (always enabled), 1-3 = Plans/Units/Shells
if (i > 0)
gtk_widget_set_sensitive(child, has_config);
child = gtk_widget_get_next_sibling(child);
i++;
}
// Disable tab buttons for non-config tabs when no config loaded
for (int i = 1; i < (int)tabs_.size(); i++)
gtk_widget_set_sensitive(tabs_[i].button, has_config);
// If config was just closed and we're on a non-config tab, switch to config
if (!has_config) {
auto* visible = gtk_stack_get_visible_child(GTK_STACK(stack_));
if (visible != config_view_->widget())
gtk_stack_set_visible_child(GTK_STACK(stack_), config_view_->widget());
}
}
void MainWindow::on_stack_page_changed(GObject* stack, GParamSpec*, gpointer data) {
auto* self = static_cast<MainWindow*>(data);
auto* visible = gtk_stack_get_visible_child(GTK_STACK(stack));
// Check if previous page has unsaved changes
auto* prev = self->prev_page_;
self->prev_page_ = visible;
bool prev_dirty = false;
if (prev == self->config_view_->widget() && self->config_view_->is_dirty())
prev_dirty = true;
else if (prev == self->plan_view_->widget() && self->plan_view_->is_dirty())
prev_dirty = true;
else if (prev == self->units_view_->widget() && self->units_view_->is_dirty())
prev_dirty = true;
if (prev_dirty) {
auto result = show_unsaved_dialog(GTK_WINDOW(self->window_));
if (result == UnsavedResult::Save) {
if (self->config_view_->is_dirty())
self->config_view_->apply_config();
if (self->plan_view_->is_dirty())
self->plan_view_->save_dirty();
if (self->units_view_->is_dirty())
self->units_view_->save_current_file();
} else {
if (self->config_view_->is_dirty())
self->config_view_->refresh();
if (self->plan_view_->is_dirty())
self->plan_view_->revert_dirty();
if (self->units_view_->is_dirty())
self->units_view_->refresh();
}
}
// No dirty state — refresh immediately
self->refresh_visible_page();
if (!has_config && current_tab_ != 0)
switch_to_tab(0);
}
void MainWindow::refresh_visible_page() {
auto* visible = gtk_stack_get_visible_child(GTK_STACK(stack_));
auto* visible = tabs_[current_tab_].page;
// Update window title for current tab
if (visible == config_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Rex Config");
else if (visible == plan_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Plans");
else if (visible == units_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Units");
else if (visible == shells_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Shells");
// Update window title
auto title = std::string("GREX: ") + tabs_[current_tab_].name;
gtk_window_set_title(GTK_WINDOW(window_), title.c_str());
// Refresh the newly visible page
if (visible == plan_view_->widget()) {
@@ -239,14 +262,28 @@ void MainWindow::on_grex_config_clicked(GtkButton*, gpointer data) {
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 400, -1);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(box, 16);
gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16);
gtk_widget_set_margin_bottom(box, 16);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16);
gtk_widget_set_margin_start(box, 24);
gtk_widget_set_margin_end(box, 24);
gtk_widget_set_margin_top(box, 20);
gtk_widget_set_margin_bottom(box, 20);
// Header
auto* header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(header), "<big><b>GREX Preferences</b></big>");
gtk_widget_set_halign(header, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(box), header);
auto* desc = gtk_label_new("Application settings stored in ~/.config/grex/grex.ini");
gtk_label_set_xalign(GTK_LABEL(desc), 0.5f);
gtk_widget_add_css_class(desc, "dim-label");
gtk_box_append(GTK_BOX(box), desc);
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// Settings grid
auto* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 8);
gtk_grid_set_row_spacing(GTK_GRID(grid), 10);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
auto* label = gtk_label_new("File Editor");
@@ -255,20 +292,33 @@ void MainWindow::on_grex_config_clicked(GtkButton*, gpointer data) {
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "e.g. xdg-open, vim, code");
gtk_editable_set_text(GTK_EDITABLE(entry), self->grex_config_.file_editor.c_str());
gtk_grid_attach(GTK_GRID(grid), entry, 1, 0, 1, 1);
auto* hint = gtk_label_new("Command used to open files from unit target/environment fields.");
gtk_label_set_xalign(GTK_LABEL(hint), 0.0f);
gtk_label_set_wrap(GTK_LABEL(hint), TRUE);
gtk_widget_add_css_class(hint, "dim-label");
gtk_grid_attach(GTK_GRID(grid), hint, 1, 1, 1, 1);
gtk_box_append(GTK_BOX(box), grid);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// Buttons
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_save = gtk_button_new_with_label("Save");
gtk_widget_add_css_class(btn_save, "suggested-action");
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_save);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), box);
gtk_window_set_default_widget(GTK_WINDOW(win), btn_save);
gtk_entry_set_activates_default(GTK_ENTRY(entry), TRUE);
auto* dd = new GCDialogData{self, win, entry};

View File

@@ -19,6 +19,7 @@
#pragma once
#include <gtk/gtk.h>
#include <string>
#include <vector>
#include "models/project.h"
#include "models/grex_config.h"
@@ -45,15 +46,23 @@ private:
ShellsView* shells_view_;
GtkWidget* stack_;
GtkWidget* switcher_;
GtkWidget* status_label_;
GtkWidget* prev_page_ = nullptr;
struct TabInfo {
const char* name;
GtkWidget* page;
GtkWidget* button;
};
std::vector<TabInfo> tabs_;
int current_tab_ = 0;
void update_tab_sensitivity();
void refresh_visible_page();
bool check_dirty_and_resolve();
void switch_to_tab(int idx);
void update_tab_buttons();
static void on_config_applied(void* data);
static void on_stack_page_changed(GObject* stack, GParamSpec* pspec, gpointer data);
static void on_project_status(const std::string& msg, void* data);
static void on_grex_config_clicked(GtkButton* btn, gpointer data);
};

View File

@@ -31,27 +31,26 @@ PlanView::PlanView(Project& project, GrexConfig& grex_config)
gtk_paned_set_position(GTK_PANED(root_), 300);
// === Left panel ===
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_size_request(left, 200, -1);
gtk_widget_set_margin_start(left, 8);
gtk_widget_set_margin_end(left, 4);
gtk_widget_set_margin_top(left, 8);
gtk_widget_set_margin_bottom(left, 8);
// Plan label
plan_label_ = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
gtk_label_set_xalign(GTK_LABEL(plan_label_), 0.0f);
gtk_widget_set_margin_start(plan_label_, 4);
gtk_widget_set_margin_end(plan_label_, 4);
gtk_widget_set_margin_top(plan_label_, 4);
gtk_widget_add_css_class(plan_label_, "title-3");
gtk_box_append(GTK_BOX(left), plan_label_);
// Plan management buttons (Open/Create shown when no plan, Close shown when plan loaded)
auto* mgmt_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(mgmt_row, 4);
gtk_widget_set_margin_end(mgmt_row, 4);
auto* mgmt_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(mgmt_row, "linked");
btn_open_plan_ = gtk_button_new_with_label("Open Plan");
btn_create_plan_ = gtk_button_new_with_label("Create Plan");
btn_close_plan_ = gtk_button_new_with_label("Close Plan");
btn_open_plan_ = gtk_button_new_with_label("Open");
btn_create_plan_ = gtk_button_new_with_label("Create");
btn_close_plan_ = gtk_button_new_with_label("Close");
gtk_widget_set_hexpand(btn_open_plan_, TRUE);
gtk_widget_set_hexpand(btn_create_plan_, TRUE);
gtk_widget_set_hexpand(btn_close_plan_, TRUE);
@@ -60,38 +59,44 @@ PlanView::PlanView(Project& project, GrexConfig& grex_config)
gtk_box_append(GTK_BOX(mgmt_row), btn_close_plan_);
gtk_box_append(GTK_BOX(left), mgmt_row);
// Task controls — greyed out when no plan loaded
task_controls_ = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_vexpand(task_controls_, TRUE);
// Task list
auto* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
gtk_widget_add_css_class(scroll, "frame");
task_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(task_listbox_), GTK_SELECTION_SINGLE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), task_listbox_);
gtk_box_append(GTK_BOX(left), scroll);
gtk_box_append(GTK_BOX(task_controls_), scroll);
// Buttons
auto* btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(btn_box, 4);
gtk_widget_set_margin_end(btn_box, 4);
gtk_widget_set_margin_bottom(btn_box, 4);
// Task action buttons — grouped by function
auto* btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
auto* btn_add = gtk_button_new_with_label("Add Task");
auto* btn_del = gtk_button_new_with_label("Delete Task");
auto* task_edit_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(task_edit_group, "linked");
auto* btn_add = gtk_button_new_with_label("Add");
auto* btn_del = gtk_button_new_with_label("Delete");
gtk_box_append(GTK_BOX(task_edit_group), btn_add);
gtk_box_append(GTK_BOX(task_edit_group), btn_del);
gtk_box_append(GTK_BOX(btn_box), task_edit_group);
auto* move_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(move_group, "linked");
auto* btn_up = gtk_button_new_with_label("Up");
auto* btn_down = gtk_button_new_with_label("Down");
gtk_box_append(GTK_BOX(btn_box), btn_add);
gtk_box_append(GTK_BOX(btn_box), btn_del);
gtk_box_append(GTK_BOX(btn_box), btn_up);
gtk_box_append(GTK_BOX(btn_box), btn_down);
gtk_box_append(GTK_BOX(left), btn_box);
gtk_box_append(GTK_BOX(move_group), btn_up);
gtk_box_append(GTK_BOX(move_group), btn_down);
gtk_box_append(GTK_BOX(btn_box), move_group);
// Save Plan button
btn_save_plan_ = gtk_button_new_with_label("Save Plan");
gtk_widget_set_margin_start(btn_save_plan_, 4);
gtk_widget_set_margin_end(btn_save_plan_, 4);
gtk_widget_set_margin_bottom(btn_save_plan_, 4);
gtk_widget_set_hexpand(btn_save_plan_, TRUE);
gtk_box_append(GTK_BOX(left), btn_save_plan_);
gtk_box_append(GTK_BOX(btn_box), btn_save_plan_);
gtk_box_append(GTK_BOX(task_controls_), btn_box);
gtk_box_append(GTK_BOX(left), task_controls_);
gtk_paned_set_start_child(GTK_PANED(root_), left);
gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE);
@@ -269,6 +274,8 @@ void PlanView::on_task_selected(GtkListBox*, GtkListBoxRow* row, gpointer data)
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Cancel)
return;
if (result == UnsavedResult::Save)
self->save_dirty();
else
@@ -374,6 +381,8 @@ void PlanView::on_close_plan(GtkButton*, gpointer data) {
if (self->is_dirty()) {
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Cancel)
return;
if (result == UnsavedResult::Save)
self->save_dirty();
}
@@ -443,6 +452,9 @@ void PlanView::update_plan_buttons() {
gtk_widget_set_visible(btn_open_plan_, !has_plan);
gtk_widget_set_visible(btn_create_plan_, !has_plan);
gtk_widget_set_visible(btn_close_plan_, has_plan);
gtk_widget_set_sensitive(task_controls_, has_plan);
if (!has_plan)
unit_editor_->clear();
}
void PlanView::refresh() {

View File

@@ -44,6 +44,7 @@ private:
GtkWidget* btn_create_plan_;
GtkWidget* btn_close_plan_;
GtkWidget* btn_save_plan_;
GtkWidget* task_controls_; // container for task list + buttons
UnitEditor* unit_editor_;
int current_task_idx_ = -1;

View File

@@ -17,51 +17,114 @@
*/
#include "views/shells_view.h"
#include <cstring>
static int sort_listbox_alpha(GtkListBoxRow* a, GtkListBoxRow* b, gpointer) {
auto* la = GTK_LABEL(gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(a)));
auto* lb = GTK_LABEL(gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(b)));
return std::strcmp(gtk_label_get_text(la), gtk_label_get_text(lb));
}
namespace grex {
ShellsView::ShellsView(Project& project) : project_(project), shells_(project.shells) {
ShellsView::ShellsView(Project& project) : project_(project) {
root_ = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_position(GTK_PANED(root_), 200);
gtk_paned_set_position(GTK_PANED(root_), 300);
// Left panel: shell list
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_size_request(left, 150, -1);
// === Left panel: shell files ===
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_size_request(left, 200, -1);
gtk_widget_set_margin_start(left, 8);
gtk_widget_set_margin_end(left, 4);
gtk_widget_set_margin_top(left, 8);
gtk_widget_set_margin_bottom(left, 8);
auto* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(listbox_), GTK_SELECTION_SINGLE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), listbox_);
gtk_box_append(GTK_BOX(left), scroll);
auto* file_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(file_header), "<b>Shell Files</b>");
gtk_label_set_xalign(GTK_LABEL(file_header), 0.0f);
gtk_box_append(GTK_BOX(left), file_header);
auto* btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(btn_box, 4);
gtk_widget_set_margin_end(btn_box, 4);
gtk_widget_set_margin_bottom(btn_box, 4);
auto* btn_add = gtk_button_new_with_label("Add");
auto* btn_del = gtk_button_new_with_label("Delete");
auto* btn_save = gtk_button_new_with_label("Save Shells");
gtk_box_append(GTK_BOX(btn_box), btn_add);
gtk_box_append(GTK_BOX(btn_box), btn_del);
gtk_box_append(GTK_BOX(btn_box), btn_save);
gtk_box_append(GTK_BOX(left), btn_box);
auto* file_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(file_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(file_scroll, TRUE);
gtk_widget_add_css_class(file_scroll, "frame");
file_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(file_listbox_), GTK_SELECTION_SINGLE);
gtk_list_box_set_sort_func(GTK_LIST_BOX(file_listbox_), sort_listbox_alpha, nullptr, nullptr);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(file_scroll), file_listbox_);
gtk_box_append(GTK_BOX(left), file_scroll);
auto* file_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
auto* file_edit_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(file_edit_group, "linked");
auto* btn_new_file = gtk_button_new_with_label("New");
auto* btn_del_file = gtk_button_new_with_label("Delete");
gtk_box_append(GTK_BOX(file_edit_group), btn_new_file);
gtk_box_append(GTK_BOX(file_edit_group), btn_del_file);
gtk_box_append(GTK_BOX(file_btn_box), file_edit_group);
btn_save_ = gtk_button_new_with_label("Save File");
gtk_box_append(GTK_BOX(file_btn_box), btn_save_);
gtk_box_append(GTK_BOX(left), file_btn_box);
gtk_paned_set_start_child(GTK_PANED(root_), left);
gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE);
// Right panel: editor
auto* right_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(right_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
// === Right panel: shells in selected file + editor ===
auto* right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(right, 4);
gtk_widget_set_margin_end(right, 8);
gtk_widget_set_margin_top(right, 8);
gtk_widget_set_margin_bottom(right, 8);
file_label_ = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(file_label_), "<b>No file selected</b>");
gtk_label_set_xalign(GTK_LABEL(file_label_), 0.0f);
gtk_box_append(GTK_BOX(right), file_label_);
auto* shell_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(shell_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(shell_scroll, TRUE);
gtk_widget_add_css_class(shell_scroll, "frame");
shell_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(shell_listbox_), GTK_SELECTION_SINGLE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(shell_scroll), shell_listbox_);
gtk_box_append(GTK_BOX(right), shell_scroll);
// Shell action buttons
auto* shell_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
auto* shell_edit_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(shell_edit_group, "linked");
auto* btn_new_shell = gtk_button_new_with_label("New");
auto* btn_del_shell = gtk_button_new_with_label("Delete");
gtk_box_append(GTK_BOX(shell_edit_group), btn_new_shell);
gtk_box_append(GTK_BOX(shell_edit_group), btn_del_shell);
gtk_box_append(GTK_BOX(shell_btn_box), shell_edit_group);
auto* move_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(move_group, "linked");
auto* btn_move_up = gtk_button_new_with_label("Up");
auto* btn_move_down = gtk_button_new_with_label("Down");
gtk_box_append(GTK_BOX(move_group), btn_move_up);
gtk_box_append(GTK_BOX(move_group), btn_move_down);
gtk_box_append(GTK_BOX(shell_btn_box), move_group);
gtk_box_append(GTK_BOX(right), shell_btn_box);
// Shell properties editor
gtk_box_append(GTK_BOX(right), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* editor_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(editor_header), "<b>Shell Properties</b>");
gtk_label_set_xalign(GTK_LABEL(editor_header), 0.0f);
gtk_box_append(GTK_BOX(right), editor_header);
editor_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(editor_grid_), 8);
gtk_grid_set_column_spacing(GTK_GRID(editor_grid_), 12);
gtk_widget_set_margin_start(editor_grid_, 16);
gtk_widget_set_margin_end(editor_grid_, 16);
gtk_widget_set_margin_top(editor_grid_, 16);
gtk_widget_set_margin_bottom(editor_grid_, 16);
auto make_row = [&](int row, const char* label_text) -> GtkWidget* {
auto* label = gtk_label_new(label_text);
@@ -78,74 +141,132 @@ ShellsView::ShellsView(Project& project) : project_(project), shells_(project.sh
entry_exec_arg_ = make_row(2, "Execution Arg");
entry_source_cmd_ = make_row(3, "Source Cmd");
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(right_scroll), editor_grid_);
gtk_paned_set_end_child(GTK_PANED(root_), right_scroll);
gtk_box_append(GTK_BOX(right), editor_grid_);
gtk_paned_set_end_child(GTK_PANED(root_), right);
gtk_paned_set_shrink_end_child(GTK_PANED(root_), FALSE);
// Signals
g_signal_connect(listbox_, "row-selected", G_CALLBACK(on_shell_selected), this);
g_signal_connect(btn_add, "clicked", G_CALLBACK(on_add_shell), this);
g_signal_connect(btn_del, "clicked", G_CALLBACK(on_delete_shell), this);
g_signal_connect(file_listbox_, "row-selected", G_CALLBACK(on_file_selected), this);
g_signal_connect(btn_new_file, "clicked", G_CALLBACK(on_new_file), this);
g_signal_connect(btn_del_file, "clicked", G_CALLBACK(on_delete_file), this);
g_signal_connect(btn_new_shell, "clicked", G_CALLBACK(on_new_shell), this);
g_signal_connect(btn_del_shell, "clicked", G_CALLBACK(on_delete_shell), this);
g_signal_connect(btn_move_up, "clicked", G_CALLBACK(on_move_up), this);
g_signal_connect(btn_move_down, "clicked", G_CALLBACK(on_move_down), this);
g_signal_connect(btn_save, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
g_signal_connect(shell_listbox_, "row-selected", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer d) {
auto* self = static_cast<ShellsView*>(d);
if (!row) { self->clear_editor(); return; }
self->load_shell(gtk_list_box_row_get_index(row));
}), this);
g_signal_connect(btn_save_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<ShellsView*>(d);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.shell_files.size()) {
self->project_.report_status("Error: no shell file selected");
return;
}
auto& sf = self->project_.shell_files[self->current_file_idx_];
try {
self->project_.save_shells();
sf.save();
self->file_dirty_ = false;
gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->project_.report_status("Saved shell file: " + sf.filepath.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}), this);
// Bind entry changes to model
auto bind = [this](GtkWidget* entry, int field) {
auto bind = [this](GtkWidget* entry) {
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* self = static_cast<ShellsView*>(d);
if (self->loading_ || self->current_idx_ < 0 || self->current_idx_ >= (int)self->shells_.shells.size())
if (self->loading_ || self->current_file_idx_ < 0 || self->current_shell_idx_ < 0)
return;
auto& s = self->shells_.shells[self->current_idx_];
auto& sf = self->project_.shell_files[self->current_file_idx_];
if (self->current_shell_idx_ >= (int)sf.shells.size()) return;
auto& s = sf.shells[self->current_shell_idx_];
auto text = std::string(gtk_editable_get_text(e));
// determine which field by checking widget pointer
if (GTK_WIDGET(e) == self->entry_name_) {
s.name = text;
// refresh list row
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->listbox_), self->current_idx_);
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->shell_listbox_), self->current_shell_idx_);
if (row) {
auto* lbl = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
if (GTK_IS_LABEL(lbl)) gtk_label_set_text(GTK_LABEL(lbl), text.c_str());
if (GTK_IS_LABEL(lbl)) gtk_label_set_text(GTK_LABEL(lbl), (std::string("\u25B8 ") + text).c_str());
}
} else if (GTK_WIDGET(e) == self->entry_path_) s.path = text;
else if (GTK_WIDGET(e) == self->entry_exec_arg_) s.execution_arg = text;
else if (GTK_WIDGET(e) == self->entry_source_cmd_) s.source_cmd = text;
self->mark_file_dirty();
}), this);
};
bind(entry_name_, 0);
bind(entry_path_, 1);
bind(entry_exec_arg_, 2);
bind(entry_source_cmd_, 3);
bind(entry_name_);
bind(entry_path_);
bind(entry_exec_arg_);
bind(entry_source_cmd_);
populate_list();
populate_file_list();
clear_editor();
}
void ShellsView::populate_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(listbox_), child);
void ShellsView::mark_file_dirty() {
file_dirty_ = true;
gtk_widget_add_css_class(btn_save_, "suggested-action");
}
for (auto& s : shells_.shells) {
void ShellsView::populate_file_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(file_listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(file_listbox_), child);
current_file_idx_ = -1;
for (auto& sf : project_.shell_files) {
auto* row = gtk_list_box_row_new();
auto stext = std::string("\u25B8 ") + s.name;
auto* label = gtk_label_new(stext.c_str());
auto text = std::string("\u25C6 ") + sf.filepath.filename().string();
auto* label = gtk_label_new(text.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_append(GTK_LIST_BOX(listbox_), row);
gtk_list_box_append(GTK_LIST_BOX(file_listbox_), row);
}
current_idx_ = -1;
populate_shell_list();
}
void ShellsView::populate_shell_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(shell_listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(shell_listbox_), child);
current_shell_idx_ = -1;
clear_editor();
if (current_file_idx_ < 0 || current_file_idx_ >= (int)project_.shell_files.size()) {
gtk_label_set_markup(GTK_LABEL(file_label_), "<b>No file selected</b>");
return;
}
auto& sf = project_.shell_files[current_file_idx_];
gtk_label_set_markup(GTK_LABEL(file_label_),
(std::string("<b>") + sf.filepath.filename().string() + "</b>").c_str());
for (auto& s : sf.shells) {
auto* row = gtk_list_box_row_new();
auto text = std::string("\u25B8 ") + s.name;
auto* label = gtk_label_new(text.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_append(GTK_LIST_BOX(shell_listbox_), row);
}
}
void ShellsView::clear_editor() {
@@ -159,13 +280,18 @@ void ShellsView::clear_editor() {
}
void ShellsView::load_shell(int idx) {
if (idx < 0 || idx >= (int)shells_.shells.size()) {
if (current_file_idx_ < 0 || current_file_idx_ >= (int)project_.shell_files.size()) {
clear_editor();
return;
}
auto& sf = project_.shell_files[current_file_idx_];
if (idx < 0 || idx >= (int)sf.shells.size()) {
clear_editor();
return;
}
loading_ = true;
current_idx_ = idx;
auto& s = shells_.shells[idx];
current_shell_idx_ = idx;
auto& s = sf.shells[idx];
gtk_widget_set_sensitive(editor_grid_, TRUE);
gtk_editable_set_text(GTK_EDITABLE(entry_name_), s.name.c_str());
gtk_editable_set_text(GTK_EDITABLE(entry_path_), s.path.c_str());
@@ -174,37 +300,182 @@ void ShellsView::load_shell(int idx) {
loading_ = false;
}
void ShellsView::on_shell_selected(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (!row) { self->clear_editor(); return; }
self->load_shell(gtk_list_box_row_get_index(row));
void ShellsView::refresh() {
project_.reload_shells();
populate_file_list();
}
void ShellsView::on_add_shell(GtkButton*, gpointer data) {
void ShellsView::on_file_selected(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (!row) {
self->current_file_idx_ = -1;
self->populate_shell_list();
return;
}
self->current_file_idx_ = gtk_list_box_row_get_index(row);
self->file_dirty_ = false;
gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->populate_shell_list();
}
void ShellsView::on_new_file(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
auto* window = gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW);
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Create Shell File");
gtk_file_dialog_set_accept_label(dialog, "Create");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Shell files (*.shells)");
gtk_file_filter_add_pattern(filter, "*.shells");
auto* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
g_object_unref(filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
g_object_unref(filters);
auto shells_dir = self->project_.resolved_shells_path();
if (!shells_dir.empty()) {
auto* initial = g_file_new_for_path(shells_dir.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
gtk_file_dialog_save(dialog, GTK_WINDOW(window), nullptr, on_new_file_response, self);
}
void ShellsView::on_new_file_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), res, &error);
if (!file) {
if (error) g_error_free(error);
return;
}
auto* path = g_file_get_path(file);
g_object_unref(file);
std::filesystem::path fp(path);
g_free(path);
if (fp.extension() != ".shells")
fp += ".shells";
ShellsFile sf;
sf.filepath = fp;
try {
sf.save();
self->project_.shell_files.push_back(std::move(sf));
self->populate_file_list();
int last = (int)self->project_.shell_files.size() - 1;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->file_listbox_), last);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->file_listbox_), row);
self->project_.report_status("Created shell file: " + fp.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}
void ShellsView::on_delete_file(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.shell_files.size())
return;
auto& sf = self->project_.shell_files[self->current_file_idx_];
auto name = sf.filepath.filename().string();
self->project_.shell_files.erase(self->project_.shell_files.begin() + self->current_file_idx_);
self->current_file_idx_ = -1;
self->populate_file_list();
self->project_.report_status("Removed shell file from project: " + name + " (file not deleted from disk)");
}
void ShellsView::on_new_shell(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.shell_files.size()) {
self->project_.report_status("Error: select a shell file first");
return;
}
auto& sf = self->project_.shell_files[self->current_file_idx_];
ShellDef s;
s.name = "new_shell";
s.path = "/usr/bin/new_shell";
s.execution_arg = "-c";
s.source_cmd = "source";
self->shells_.shells.push_back(s);
self->populate_list();
sf.shells.push_back(s);
self->populate_shell_list();
self->mark_file_dirty();
int last = (int)self->shells_.shells.size() - 1;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->listbox_), last);
int last = (int)sf.shells.size() - 1;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->shell_listbox_), last);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->listbox_), row);
gtk_list_box_select_row(GTK_LIST_BOX(self->shell_listbox_), row);
}
void ShellsView::on_delete_shell(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (self->current_idx_ < 0) return;
self->shells_.shells.erase(self->shells_.shells.begin() + self->current_idx_);
self->populate_list();
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.shell_files.size())
return;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->shell_listbox_));
if (!row) return;
int idx = gtk_list_box_row_get_index(row);
auto& sf = self->project_.shell_files[self->current_file_idx_];
if (idx < 0 || idx >= (int)sf.shells.size()) return;
auto name = sf.shells[idx].name;
sf.shells.erase(sf.shells.begin() + idx);
self->populate_shell_list();
self->mark_file_dirty();
self->project_.report_status("Deleted shell: " + name);
}
void ShellsView::refresh() {
populate_list();
void ShellsView::on_move_up(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.shell_files.size())
return;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->shell_listbox_));
if (!row) return;
int idx = gtk_list_box_row_get_index(row);
auto& sf = self->project_.shell_files[self->current_file_idx_];
if (idx <= 0) return;
std::swap(sf.shells[idx], sf.shells[idx - 1]);
self->mark_file_dirty();
self->populate_shell_list();
auto* new_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->shell_listbox_), idx - 1);
if (new_row) gtk_list_box_select_row(GTK_LIST_BOX(self->shell_listbox_), new_row);
}
void ShellsView::on_move_down(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.shell_files.size())
return;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->shell_listbox_));
if (!row) return;
int idx = gtk_list_box_row_get_index(row);
auto& sf = self->project_.shell_files[self->current_file_idx_];
if (idx < 0 || idx >= (int)sf.shells.size() - 1) return;
std::swap(sf.shells[idx], sf.shells[idx + 1]);
self->mark_file_dirty();
self->populate_shell_list();
auto* new_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->shell_listbox_), idx + 1);
if (new_row) gtk_list_box_select_row(GTK_LIST_BOX(self->shell_listbox_), new_row);
}
}

View File

@@ -30,26 +30,42 @@ public:
private:
Project& project_;
ShellsFile& shells_;
GtkWidget* root_;
GtkWidget* listbox_;
// Left panel: shell files
GtkWidget* file_listbox_;
GtkWidget* btn_save_;
// Right panel: shells in selected file
GtkWidget* file_label_;
GtkWidget* shell_listbox_;
// Editor
GtkWidget* editor_grid_;
GtkWidget* entry_name_;
GtkWidget* entry_path_;
GtkWidget* entry_exec_arg_;
GtkWidget* entry_source_cmd_;
GtkWidget* editor_grid_;
int current_idx_ = -1;
int current_file_idx_ = -1;
int current_shell_idx_ = -1;
bool loading_ = false;
bool file_dirty_ = false;
void populate_list();
void populate_file_list();
void populate_shell_list();
void load_shell(int idx);
void clear_editor();
void mark_file_dirty();
static void on_shell_selected(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_add_shell(GtkButton* btn, gpointer data);
static void on_file_selected(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_new_file(GtkButton* btn, gpointer data);
static void on_new_file_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_delete_file(GtkButton* btn, gpointer data);
static void on_new_shell(GtkButton* btn, gpointer data);
static void on_delete_shell(GtkButton* btn, gpointer data);
static void on_move_up(GtkButton* btn, gpointer data);
static void on_move_down(GtkButton* btn, gpointer data);
};
}

View File

@@ -108,12 +108,12 @@ void UnitEditor::clear() {
gtk_label_set_text(GTK_LABEL(name_display_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_comment_), "");
gtk_widget_set_sensitive(entry_comment_, FALSE);
gtk_widget_set_sensitive(btn_edit_unit_, FALSE);
gtk_widget_set_sensitive(root_, FALSE);
clear_dirty();
}
void UnitEditor::load(Task* task, Unit* unit) {
gtk_widget_set_sensitive(root_, TRUE);
g_signal_handlers_disconnect_by_data(entry_comment_, this);
current_task_ = task;
current_unit_ = unit;
@@ -142,16 +142,15 @@ void UnitEditor::load(Task* task, Unit* unit) {
}
void UnitEditor::save_current() {
if (current_unit_name_.empty()) return;
auto* unit = project_.find_unit(current_unit_name_);
if (!unit) return;
current_unit_ = unit;
if (project_.is_unit_name_taken(current_unit_->name, current_unit_)) {
project_.report_status("Error: unit name '" + current_unit_->name + "' conflicts with another unit");
if (!current_unit_) {
project_.report_status("Error: no unit loaded to save");
return;
}
auto* uf = project_.find_unit_file(current_unit_->name);
if (!uf) return;
if (!uf) {
project_.report_status("Error: cannot find unit file for '" + current_unit_->name + "'");
return;
}
try {
uf->save();
clear_dirty();
@@ -195,10 +194,19 @@ void UnitEditor::on_edit_unit(GtkButton*, gpointer data) {
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unit_properties_dialog(parent, self->current_unit_,
self->project_, self->grex_config_, self->project_.shells.shells);
self->project_, self->grex_config_, self->project_.all_shells());
if (result == UnitDialogResult::Save)
if (result == UnitDialogResult::Save) {
// Sync state in case the unit name changed in the dialog
auto new_name = self->current_unit_->name;
self->current_unit_name_ = new_name;
if (self->current_task_)
self->current_task_->name = new_name;
gtk_label_set_text(GTK_LABEL(self->name_display_), new_name.c_str());
if (self->name_cb_)
self->name_cb_(new_name, self->name_cb_data_);
self->mark_dirty();
}
}
void UnitEditor::on_select_unit(GtkButton*, gpointer data) {

View File

@@ -36,85 +36,92 @@ UnitsView::UnitsView(Project& project, GrexConfig& grex_config)
gtk_paned_set_position(GTK_PANED(root_), 300);
// === Left panel: unit files ===
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_size_request(left, 200, -1);
gtk_widget_set_margin_start(left, 8);
gtk_widget_set_margin_end(left, 4);
gtk_widget_set_margin_top(left, 8);
gtk_widget_set_margin_bottom(left, 8);
auto* file_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(file_header), "<b>Unit Files</b>");
gtk_label_set_xalign(GTK_LABEL(file_header), 0.0f);
gtk_widget_set_margin_start(file_header, 4);
gtk_widget_set_margin_end(file_header, 4);
gtk_widget_set_margin_top(file_header, 4);
gtk_widget_add_css_class(file_header, "title-3");
gtk_box_append(GTK_BOX(left), file_header);
auto* file_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(file_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(file_scroll, TRUE);
gtk_widget_add_css_class(file_scroll, "frame");
file_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(file_listbox_), GTK_SELECTION_SINGLE);
gtk_list_box_set_sort_func(GTK_LIST_BOX(file_listbox_), sort_listbox_alpha, nullptr, nullptr);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(file_scroll), file_listbox_);
gtk_box_append(GTK_BOX(left), file_scroll);
auto* file_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(file_btn_box, 4);
gtk_widget_set_margin_end(file_btn_box, 4);
gtk_widget_set_margin_bottom(file_btn_box, 4);
auto* btn_new_file = gtk_button_new_with_label("New File");
auto* btn_del_file = gtk_button_new_with_label("Delete File");
gtk_box_append(GTK_BOX(file_btn_box), btn_new_file);
gtk_box_append(GTK_BOX(file_btn_box), btn_del_file);
// File lifecycle buttons — grouped
auto* file_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
auto* file_edit_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(file_edit_group, "linked");
auto* btn_new_file = gtk_button_new_with_label("New");
auto* btn_del_file = gtk_button_new_with_label("Delete");
gtk_box_append(GTK_BOX(file_edit_group), btn_new_file);
gtk_box_append(GTK_BOX(file_edit_group), btn_del_file);
gtk_box_append(GTK_BOX(file_btn_box), file_edit_group);
btn_save_ = gtk_button_new_with_label("Save File");
gtk_box_append(GTK_BOX(file_btn_box), btn_save_);
gtk_box_append(GTK_BOX(left), file_btn_box);
gtk_paned_set_start_child(GTK_PANED(root_), left);
gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE);
// === Right panel: units in selected file ===
auto* right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
auto* right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(right, 4);
gtk_widget_set_margin_end(right, 8);
gtk_widget_set_margin_top(right, 8);
gtk_widget_set_margin_bottom(right, 8);
file_label_ = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(file_label_), "<b>No file selected</b>");
gtk_label_set_xalign(GTK_LABEL(file_label_), 0.0f);
gtk_widget_set_margin_start(file_label_, 4);
gtk_widget_set_margin_end(file_label_, 4);
gtk_widget_set_margin_top(file_label_, 4);
gtk_widget_add_css_class(file_label_, "title-3");
gtk_box_append(GTK_BOX(right), file_label_);
auto* unit_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(unit_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(unit_scroll, TRUE);
gtk_widget_add_css_class(unit_scroll, "frame");
unit_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(unit_listbox_), GTK_SELECTION_SINGLE);
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(unit_listbox_), FALSE);
// No sort — units appear in file order
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(unit_scroll), unit_listbox_);
gtk_box_append(GTK_BOX(right), unit_scroll);
auto* unit_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(unit_btn_box, 4);
gtk_widget_set_margin_end(unit_btn_box, 4);
gtk_widget_set_margin_bottom(unit_btn_box, 4);
auto* btn_new_unit = gtk_button_new_with_label("New Unit");
auto* btn_del_unit = gtk_button_new_with_label("Delete Unit");
auto* btn_edit_unit = gtk_button_new_with_label("Edit Unit...");
auto* btn_move_up = gtk_button_new_with_label("Move Up");
auto* btn_move_down = gtk_button_new_with_label("Move Down");
gtk_box_append(GTK_BOX(unit_btn_box), btn_new_unit);
gtk_box_append(GTK_BOX(unit_btn_box), btn_del_unit);
gtk_box_append(GTK_BOX(unit_btn_box), btn_edit_unit);
gtk_box_append(GTK_BOX(unit_btn_box), btn_move_up);
gtk_box_append(GTK_BOX(unit_btn_box), btn_move_down);
gtk_box_append(GTK_BOX(right), unit_btn_box);
// Unit action buttons — grouped by function
auto* unit_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
// Save button
btn_save_ = gtk_button_new_with_label("Save Unit File");
gtk_widget_set_margin_start(btn_save_, 4);
gtk_widget_set_margin_end(btn_save_, 4);
gtk_widget_set_margin_bottom(btn_save_, 4);
gtk_widget_set_hexpand(btn_save_, TRUE);
gtk_box_append(GTK_BOX(right), btn_save_);
auto* unit_edit_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(unit_edit_group, "linked");
auto* btn_new_unit = gtk_button_new_with_label("New");
auto* btn_del_unit = gtk_button_new_with_label("Delete");
gtk_box_append(GTK_BOX(unit_edit_group), btn_new_unit);
gtk_box_append(GTK_BOX(unit_edit_group), btn_del_unit);
gtk_box_append(GTK_BOX(unit_btn_box), unit_edit_group);
auto* btn_edit_unit = gtk_button_new_with_label("Edit Unit...");
gtk_box_append(GTK_BOX(unit_btn_box), btn_edit_unit);
auto* move_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_add_css_class(move_group, "linked");
auto* btn_move_up = gtk_button_new_with_label("Up");
auto* btn_move_down = gtk_button_new_with_label("Down");
gtk_box_append(GTK_BOX(move_group), btn_move_up);
gtk_box_append(GTK_BOX(move_group), btn_move_down);
gtk_box_append(GTK_BOX(unit_btn_box), move_group);
gtk_box_append(GTK_BOX(right), unit_btn_box);
gtk_paned_set_end_child(GTK_PANED(root_), right);
gtk_paned_set_shrink_end_child(GTK_PANED(root_), FALSE);
@@ -246,6 +253,8 @@ void UnitsView::on_file_selected(GtkListBox*, GtkListBoxRow* row, gpointer data)
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Cancel)
return;
if (result == UnsavedResult::Save) {
auto& uf = self->project_.unit_files[self->current_file_idx_];
try { uf.save(); } catch (const std::exception& e) {
@@ -390,11 +399,14 @@ void UnitsView::on_new_unit(GtkButton*, gpointer data) {
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_create = gtk_button_new_with_label("Create");
gtk_widget_add_css_class(btn_create, "suggested-action");
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_create);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), box);
gtk_window_set_default_widget(GTK_WINDOW(win), btn_create);
gtk_entry_set_activates_default(GTK_ENTRY(entry), TRUE);
struct NewUnitData {
UnitsView* view;
@@ -458,99 +470,35 @@ void UnitsView::on_delete_unit(GtkButton*, gpointer data) {
self->project_.report_status("Deleted unit: " + name);
}
void UnitsView::cancel_rename() {
if (!rename_active_) return;
rename_active_ = false;
// Restore original label
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(rename_row_), rename_old_label_);
g_object_unref(rename_old_label_);
rename_old_label_ = nullptr;
rename_row_ = nullptr;
rename_unit_idx_ = -1;
}
void UnitsView::finish_rename(const std::string& new_name) {
if (!rename_active_) return;
rename_active_ = false;
auto& uf = project_.unit_files[current_file_idx_];
auto* unit = &uf.units[rename_unit_idx_];
if (!new_name.empty() && new_name != unit->name) {
if (project_.is_unit_name_taken(new_name, unit)) {
project_.report_status("Error: unit '" + new_name + "' already exists");
} else {
auto old = unit->name;
unit->name = new_name;
file_dirty_ = true;
gtk_widget_add_css_class(btn_save_, "suggested-action");
project_.report_status("Renamed unit: " + old + " -> " + new_name);
}
}
// Update label text to current name and restore it
auto text = std::string("\u2022 ") + uf.units[rename_unit_idx_].name;
gtk_label_set_text(GTK_LABEL(rename_old_label_), text.c_str());
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(rename_row_), rename_old_label_);
g_object_unref(rename_old_label_);
rename_old_label_ = nullptr;
rename_row_ = nullptr;
rename_unit_idx_ = -1;
}
void UnitsView::on_unit_activated(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size())
return;
// Cancel any in-progress rename first
self->cancel_rename();
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Get unit name from the row's label (strip bullet prefix)
auto* old_label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto label_text = std::string(gtk_label_get_text(GTK_LABEL(old_label)));
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto label_text = std::string(gtk_label_get_text(GTK_LABEL(label)));
if (label_text.rfind("\u2022 ", 0) == 0)
label_text = label_text.substr(strlen("\u2022 "));
// Find the actual index in the units vector by name
int idx = -1;
for (int i = 0; i < (int)uf.units.size(); i++) {
if (uf.units[i].name == label_text) { idx = i; break; }
// Find unit by name
Unit* unit = nullptr;
for (auto& u : uf.units) {
if (u.name == label_text) { unit = &u; break; }
}
if (idx < 0) return;
if (!unit) return;
g_object_ref(old_label);
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unit_properties_dialog(parent, unit,
self->project_, self->grex_config_, self->project_.all_shells());
self->rename_active_ = true;
self->rename_row_ = row;
self->rename_old_label_ = old_label;
self->rename_unit_idx_ = idx;
auto* entry = gtk_entry_new();
gtk_editable_set_text(GTK_EDITABLE(entry), uf.units[idx].name.c_str());
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), entry);
// Commit on Enter
g_signal_connect(entry, "activate", G_CALLBACK(+[](GtkEntry* e, gpointer d) {
auto* self = static_cast<UnitsView*>(d);
self->finish_rename(gtk_editable_get_text(GTK_EDITABLE(e)));
}), self);
// Cancel on focus loss — deferred to idle to avoid re-entrancy during selection
auto* focus_controller = gtk_event_controller_focus_new();
g_signal_connect(focus_controller, "leave", G_CALLBACK(+[](GtkEventControllerFocus*, gpointer d) {
g_idle_add(+[](gpointer d) -> gboolean {
auto* self = static_cast<UnitsView*>(d);
self->cancel_rename();
return G_SOURCE_REMOVE;
}, d);
}), self);
gtk_widget_add_controller(entry, focus_controller);
gtk_widget_grab_focus(entry);
if (result == UnitDialogResult::Save) {
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
}
}
void UnitsView::on_move_up(GtkButton*, gpointer data) {
@@ -644,11 +592,12 @@ void UnitsView::on_edit_unit(GtkButton*, gpointer data) {
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unit_properties_dialog(parent, unit,
self->project_, self->grex_config_, self->project_.shells.shells);
self->project_, self->grex_config_, self->project_.all_shells());
if (result == UnitDialogResult::Save) {
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
}
}

View File

@@ -57,13 +57,6 @@ private:
static void on_edit_unit(GtkButton* btn, gpointer data);
static void on_move_up(GtkButton* btn, gpointer data);
static void on_move_down(GtkButton* btn, gpointer data);
void cancel_rename();
void finish_rename(const std::string& new_name);
GtkListBoxRow* rename_row_ = nullptr;
GtkWidget* rename_old_label_ = nullptr;
int rename_unit_idx_ = -1;
bool rename_active_ = false;
};
}