first commit

This commit is contained in:
Chris Punches
2026-03-10 05:16:50 -04:00
commit 0d6b8a43f4
40 changed files with 30599 additions and 0 deletions

87
src/grex.cpp Normal file
View File

@@ -0,0 +1,87 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include <gtk/gtk.h>
#include <iostream>
#include <string>
#include "models/project.h"
#include "models/grex_config.h"
#include "views/main_window.h"
static grex::Project* g_project = nullptr;
static grex::GrexConfig* g_grex_config = nullptr;
static grex::MainWindow* g_main_window = nullptr;
static std::string g_config_path;
static int on_command_line(GApplication* app, GApplicationCommandLine* cmdline, gpointer) {
GVariantDict* options = g_application_command_line_get_options_dict(cmdline);
const gchar* config_path = nullptr;
g_variant_dict_lookup(options, "config", "&s", &config_path);
if (config_path)
g_config_path = config_path;
g_application_activate(app);
return 0;
}
static void on_activate(GtkApplication* app, gpointer) {
if (g_main_window) {
gtk_window_present(GTK_WINDOW(g_main_window->widget()));
return;
}
try {
g_grex_config = new grex::GrexConfig(grex::GrexConfig::load());
if (!g_config_path.empty()) {
g_project = new grex::Project(grex::Project::load(g_config_path));
} else {
g_project = new grex::Project();
}
g_main_window = new grex::MainWindow(app, *g_project, *g_grex_config);
gtk_window_present(GTK_WINDOW(g_main_window->widget()));
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
auto* dialog = gtk_alert_dialog_new("Failed to start:\n%s", e.what());
gtk_alert_dialog_show(dialog, nullptr);
g_object_unref(dialog);
}
}
int main(int argc, char* argv[]) {
auto* app = gtk_application_new("org.darkhorselinux.grex", G_APPLICATION_HANDLES_COMMAND_LINE);
GOptionEntry entries[] = {
{"config", 'c', 0, G_OPTION_ARG_STRING, nullptr, "Path to rex.config", "FILE"},
{nullptr}
};
g_application_add_main_option_entries(G_APPLICATION(app), entries);
g_signal_connect(app, "command-line", G_CALLBACK(on_command_line), nullptr);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
int status = g_application_run(G_APPLICATION(app), argc, argv);
g_object_unref(app);
delete g_main_window;
delete g_project;
delete g_grex_config;
return status;
}

View File

@@ -0,0 +1,74 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/grex_config.h"
#include <fstream>
#include <cstdlib>
namespace grex {
namespace fs = std::filesystem;
fs::path GrexConfig::config_path() {
const char* xdg = std::getenv("XDG_CONFIG_HOME");
fs::path dir;
if (xdg && xdg[0] != '\0') {
dir = fs::path(xdg) / "grex";
} else {
const char* home = std::getenv("HOME");
dir = fs::path(home ? home : "/tmp") / ".config" / "grex";
}
return dir / "grex.ini";
}
GrexConfig GrexConfig::load() {
GrexConfig cfg;
cfg.filepath_ = config_path();
if (!fs::exists(cfg.filepath_)) {
fs::create_directories(cfg.filepath_.parent_path());
cfg.file_editor = "xterm -e vim";
cfg.save();
return cfg;
}
std::ifstream in(cfg.filepath_);
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#' || line[0] == ';')
continue;
auto eq = line.find('=');
if (eq == std::string::npos) continue;
auto key = line.substr(0, eq);
auto val = line.substr(eq + 1);
// trim whitespace
while (!key.empty() && key.back() == ' ') key.pop_back();
while (!val.empty() && val.front() == ' ') val.erase(val.begin());
if (key == "file_editor") cfg.file_editor = val;
}
return cfg;
}
void GrexConfig::save() const {
fs::create_directories(filepath_.parent_path());
std::ofstream out(filepath_);
out << "file_editor=" << file_editor << "\n";
}
}

37
src/models/grex_config.h Normal file
View File

@@ -0,0 +1,37 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <filesystem>
namespace grex {
class GrexConfig {
public:
static GrexConfig load();
void save() const;
std::string file_editor;
private:
std::filesystem::path filepath_;
static std::filesystem::path config_path();
};
}

55
src/models/plan.cpp Normal file
View File

@@ -0,0 +1,55 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/plan.h"
#include "util/json_helpers.h"
namespace grex {
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{
{"name", t.name},
{"dependencies", t.dependencies}
};
if (t.comment.has_value())
j["comment"] = t.comment.value();
}
void from_json(const nlohmann::json& j, Task& t) {
j.at("name").get_to(t.name);
j.at("dependencies").get_to(t.dependencies);
if (j.contains("comment"))
t.comment = j.at("comment").get<std::string>();
}
Plan Plan::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
Plan p;
p.name = filepath.stem().string();
p.filepath = filepath;
p.tasks = j.at("plan").get<std::vector<Task>>();
return p;
}
void Plan::save() const {
nlohmann::json j;
j["plan"] = tasks;
save_json_file(filepath, j);
}
}

46
src/models/plan.h Normal file
View File

@@ -0,0 +1,46 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <optional>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used as member type
namespace grex {
struct Task {
std::string name;
nlohmann::json dependencies; // JSON array preserved as-is for round-trip fidelity
std::optional<std::string> comment;
};
void to_json(nlohmann::json& j, const Task& t);
void from_json(const nlohmann::json& j, Task& t);
struct Plan {
std::string name;
std::filesystem::path filepath;
std::vector<Task> tasks;
static Plan load(const std::filesystem::path& filepath);
void save() const;
};
}

248
src/models/project.cpp Normal file
View File

@@ -0,0 +1,248 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/project.h"
#include <set>
namespace grex {
namespace fs = std::filesystem;
void Project::report_status(const std::string& msg) {
if (status_cb)
status_cb(msg, status_cb_data);
}
fs::path Project::resolved_project_root() const {
auto val = config.get("project_root");
if (val.empty() || !resolver.can_resolve(val))
return {};
return resolver.resolve(val);
}
fs::path Project::resolved_units_dir() const {
auto root = resolved_project_root();
if (root.empty()) return {};
auto units = config.get("units_path");
if (units.empty()) return {};
return root / units;
}
fs::path Project::resolved_shells_path() const {
auto root = resolved_project_root();
if (root.empty()) return {};
auto sp = config.get("shells_path");
if (sp.empty()) return {};
return root / sp;
}
Unit* Project::find_unit(const std::string& unit_name) {
for (auto& uf : unit_files) {
auto* u = uf.find_unit(unit_name);
if (u) return u;
}
return nullptr;
}
UnitFile* Project::find_unit_file(const std::string& unit_name) {
for (auto& uf : unit_files) {
if (uf.find_unit(unit_name))
return &uf;
}
return nullptr;
}
bool Project::is_unit_name_taken(const std::string& name, const Unit* exclude) const {
for (auto& uf : unit_files) {
for (auto& u : uf.units) {
if (u.name == name && &u != exclude)
return true;
}
}
return false;
}
void Project::load_all_units() {
auto u_dir = resolved_units_dir();
if (u_dir.empty()) {
report_status("Error: units path not resolved");
return;
}
if (!fs::is_directory(u_dir)) {
report_status("Error: units path is not a directory: " + u_dir.string());
return;
}
std::vector<UnitFile> loaded;
int total_units = 0;
int file_count = 0;
int duplicates = 0;
std::set<std::string> seen_names;
for (auto& entry : fs::directory_iterator(u_dir)) {
if (entry.path().extension() == ".units") {
try {
auto uf = UnitFile::load(entry.path());
// Remove units with duplicate names
auto it = uf.units.begin();
while (it != uf.units.end()) {
if (seen_names.count(it->name)) {
report_status("Warning: duplicate unit '" + it->name +
"' in " + entry.path().filename().string() + " — skipped");
it = uf.units.erase(it);
duplicates++;
} else {
seen_names.insert(it->name);
++it;
}
}
total_units += (int)uf.units.size();
file_count++;
loaded.push_back(std::move(uf));
} catch (const std::exception& e) {
report_status("Error: failed to load " + entry.path().filename().string() + ": " + e.what());
}
}
}
unit_files = std::move(loaded);
auto msg = "Loaded " + std::to_string(total_units) + " units from " +
std::to_string(file_count) + " files at '" + u_dir.string() + "'";
if (duplicates > 0)
msg += " (" + std::to_string(duplicates) + " duplicates skipped)";
report_status(msg);
}
Project Project::load(const fs::path& cfg_path) {
Project proj;
proj.config_path = fs::canonical(cfg_path);
proj.config = RexConfig::load(proj.config_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 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
}
}
// auto-load all units if path resolves
proj.load_all_units();
return proj;
}
void Project::load_plan(const fs::path& plan_path) {
plans.clear();
try {
plans.push_back(Plan::load(plan_path));
report_status("Loaded plan: " + plan_path.filename().string());
} catch (const std::exception& e) {
report_status("Error: failed to load plan: " + std::string(e.what()));
throw;
}
}
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()) {
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()));
}
}
void Project::save_config() {
config.save(config_path);
report_status("Saved config: " + config_path.filename().string());
}
void Project::save_plans() {
for (auto& p : plans) {
p.save();
report_status("Saved plan: " + p.name);
}
}
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);
resolver = VarResolver();
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 units
load_all_units();
// keep any loaded plans but note they may reference stale units now
report_status("Opened config: " + config_path.filename().string());
}
void Project::close_config() {
config = RexConfig();
config_path.clear();
resolver = VarResolver();
plans.clear();
unit_files.clear();
shells = ShellsFile();
report_status("Config closed");
}
}

68
src/models/project.h Normal file
View File

@@ -0,0 +1,68 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include <vector>
#include "models/rex_config.h"
#include "models/plan.h"
#include "models/unit.h"
#include "models/shell_def.h"
#include "util/var_resolver.h"
namespace grex {
class Project {
public:
static Project load(const std::filesystem::path& config_path);
RexConfig config;
std::filesystem::path config_path;
VarResolver resolver;
std::vector<Plan> plans;
std::vector<UnitFile> unit_files;
ShellsFile shells;
// status reporting
using StatusCallback = void(*)(const std::string& msg, void* data);
StatusCallback status_cb = nullptr;
void* status_cb_data = nullptr;
void report_status(const std::string& msg);
// resolved paths — empty if variables can't be resolved
std::filesystem::path resolved_project_root() const;
std::filesystem::path resolved_units_dir() const;
std::filesystem::path resolved_shells_path() const;
Unit* find_unit(const std::string& unit_name);
UnitFile* find_unit_file(const std::string& unit_name);
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();
void save_config();
void save_plans();
void save_shells();
void open_config(const std::filesystem::path& new_config_path);
void close_config();
};
}

56
src/models/rex_config.cpp Normal file
View File

@@ -0,0 +1,56 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/rex_config.h"
#include "util/json_helpers.h"
namespace grex {
RexConfig RexConfig::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
RexConfig c;
c.data_ = j.at("config");
return c;
}
void RexConfig::save(const std::filesystem::path& filepath) const {
nlohmann::json j;
j["config"] = data_;
save_json_file(filepath, j);
}
std::string RexConfig::get(const std::string& key) const {
if (data_.contains(key) && data_[key].is_string())
return data_[key].get<std::string>();
return {};
}
void RexConfig::set(const std::string& key, const std::string& value) {
data_[key] = value;
}
std::vector<std::string> RexConfig::all_string_values() const {
std::vector<std::string> vals;
for (auto& [key, val] : data_.items()) {
if (val.is_string())
vals.push_back(val.get<std::string>());
}
return vals;
}
}

47
src/models/rex_config.h Normal file
View File

@@ -0,0 +1,47 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used as member type
namespace grex {
class RexConfig {
public:
static RexConfig load(const std::filesystem::path& filepath);
void save(const std::filesystem::path& filepath) const;
// access the raw json "config" object
nlohmann::json& data() { return data_; }
const nlohmann::json& data() const { return data_; }
// convenience: get a string value by key, empty string if missing
std::string get(const std::string& key) const;
void set(const std::string& key, const std::string& value);
// return all string values (for variable scanning)
std::vector<std::string> all_string_values() const;
private:
nlohmann::json data_;
};
}

54
src/models/shell_def.cpp Normal file
View File

@@ -0,0 +1,54 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/shell_def.h"
#include "util/json_helpers.h"
namespace grex {
void to_json(nlohmann::json& j, const ShellDef& s) {
j = nlohmann::json{
{"name", s.name},
{"path", s.path},
{"execution_arg", s.execution_arg},
{"source_cmd", s.source_cmd}
};
}
void from_json(const nlohmann::json& j, ShellDef& s) {
j.at("name").get_to(s.name);
j.at("path").get_to(s.path);
j.at("execution_arg").get_to(s.execution_arg);
j.at("source_cmd").get_to(s.source_cmd);
}
ShellsFile ShellsFile::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
ShellsFile sf;
sf.filepath = filepath;
sf.shells = j.at("shells").get<std::vector<ShellDef>>();
return sf;
}
void ShellsFile::save() const {
nlohmann::json j;
j["shells"] = shells;
save_json_file(filepath, j);
}
}

45
src/models/shell_def.h Normal file
View File

@@ -0,0 +1,45 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used in to_json/from_json signatures
namespace grex {
struct ShellDef {
std::string name;
std::string path;
std::string execution_arg;
std::string source_cmd;
};
void to_json(nlohmann::json& j, const ShellDef& s);
void from_json(const nlohmann::json& j, ShellDef& s);
struct ShellsFile {
std::filesystem::path filepath;
std::vector<ShellDef> shells;
static ShellsFile load(const std::filesystem::path& filepath);
void save() const;
};
}

86
src/models/unit.cpp Normal file
View File

@@ -0,0 +1,86 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/unit.h"
#include "util/json_helpers.h"
namespace grex {
void to_json(nlohmann::json& j, const Unit& u) {
j = nlohmann::json{
{"name", u.name},
{"target", u.target},
{"is_shell_command", u.is_shell_command},
{"shell_definition", u.shell_definition},
{"force_pty", u.force_pty},
{"set_working_directory", u.set_working_directory},
{"working_directory", u.working_directory},
{"rectify", u.rectify},
{"rectifier", u.rectifier},
{"active", u.active},
{"required", u.required},
{"set_user_context", u.set_user_context},
{"user", u.user},
{"group", u.group},
{"supply_environment", u.supply_environment},
{"environment", u.environment}
};
}
void from_json(const nlohmann::json& j, Unit& u) {
j.at("name").get_to(u.name);
j.at("target").get_to(u.target);
j.at("is_shell_command").get_to(u.is_shell_command);
j.at("shell_definition").get_to(u.shell_definition);
j.at("force_pty").get_to(u.force_pty);
j.at("set_working_directory").get_to(u.set_working_directory);
j.at("working_directory").get_to(u.working_directory);
j.at("rectify").get_to(u.rectify);
j.at("rectifier").get_to(u.rectifier);
j.at("active").get_to(u.active);
j.at("required").get_to(u.required);
j.at("set_user_context").get_to(u.set_user_context);
j.at("user").get_to(u.user);
j.at("group").get_to(u.group);
j.at("supply_environment").get_to(u.supply_environment);
j.at("environment").get_to(u.environment);
}
UnitFile UnitFile::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
UnitFile uf;
uf.name = filepath.stem().string();
uf.filepath = filepath;
uf.units = j.at("units").get<std::vector<Unit>>();
return uf;
}
void UnitFile::save() const {
nlohmann::json j;
j["units"] = units;
save_json_file(filepath, j);
}
Unit* UnitFile::find_unit(const std::string& unit_name) {
for (auto& u : units)
if (u.name == unit_name)
return &u;
return nullptr;
}
}

59
src/models/unit.h Normal file
View File

@@ -0,0 +1,59 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used as member type
namespace grex {
struct Unit {
std::string name;
std::string target;
bool is_shell_command = true;
std::string shell_definition = "bash";
bool force_pty = false;
bool set_working_directory = false;
std::string working_directory;
bool rectify = false;
std::string rectifier;
bool active = true;
bool required = true;
bool set_user_context = false;
std::string user;
std::string group;
bool supply_environment = false;
std::string environment;
};
void to_json(nlohmann::json& j, const Unit& u);
void from_json(const nlohmann::json& j, Unit& u);
struct UnitFile {
std::string name;
std::filesystem::path filepath;
std::vector<Unit> units;
static UnitFile load(const std::filesystem::path& filepath);
void save() const;
Unit* find_unit(const std::string& unit_name);
};
}

39
src/util/json_helpers.cpp Normal file
View File

@@ -0,0 +1,39 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/json_helpers.h"
#include <fstream>
#include <stdexcept>
namespace grex {
nlohmann::json load_json_file(const std::filesystem::path& path) {
std::ifstream f(path);
if (!f.is_open())
throw std::runtime_error("Cannot open file: " + path.string());
return nlohmann::json::parse(f);
}
void save_json_file(const std::filesystem::path& path, const nlohmann::json& j) {
std::ofstream f(path);
if (!f.is_open())
throw std::runtime_error("Cannot write file: " + path.string());
f << j.dump(1, '\t') << '\n';
}
}

28
src/util/json_helpers.h Normal file
View File

@@ -0,0 +1,28 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include <nlohmann/json.hpp>
namespace grex {
nlohmann::json load_json_file(const std::filesystem::path& path);
void save_json_file(const std::filesystem::path& path, const nlohmann::json& j);
}

125
src/util/unit_picker.cpp Normal file
View File

@@ -0,0 +1,125 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/unit_picker.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 {
struct PickerData {
std::function<void(const std::string&)> on_select;
GtkWidget* listbox;
GtkWidget* window;
};
static std::string extract_unit_name(GtkListBoxRow* row) {
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto name = std::string(gtk_label_get_text(GTK_LABEL(label)));
if (name.rfind("\u25C6 ", 0) == 0) name = name.substr(strlen("\u25C6 "));
return name;
}
void show_unit_picker(GtkWindow* parent, Project& project,
std::function<void(const std::string& unit_name)> on_select) {
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "Defined Units");
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, 400);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_start(box, 8);
gtk_widget_set_margin_end(box, 8);
gtk_widget_set_margin_top(box, 8);
gtk_widget_set_margin_bottom(box, 8);
auto* header = gtk_label_new("Select a defined unit...");
gtk_label_set_xalign(GTK_LABEL(header), 0.0f);
gtk_box_append(GTK_BOX(box), header);
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* listbox = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(listbox), GTK_SELECTION_SINGLE);
gtk_list_box_set_sort_func(GTK_LIST_BOX(listbox), sort_listbox_alpha, nullptr, nullptr);
for (auto& uf : project.unit_files) {
for (auto& u : uf.units) {
auto* row = gtk_list_box_row_new();
auto utext = std::string("\u25C6 ") + u.name;
auto* label = gtk_label_new(utext.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_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), listbox);
gtk_box_append(GTK_BOX(box), scroll);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_select = gtk_button_new_with_label("Select");
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_select);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), box);
auto* pd = new PickerData{std::move(on_select), listbox, win};
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* pd = static_cast<PickerData*>(d);
gtk_window_close(GTK_WINDOW(pd->window));
delete pd;
}), pd);
g_signal_connect(btn_select, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* pd = static_cast<PickerData*>(d);
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(pd->listbox));
if (!row) return;
pd->on_select(extract_unit_name(row));
gtk_window_close(GTK_WINDOW(pd->window));
delete pd;
}), pd);
g_signal_connect(listbox, "row-activated", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer d) {
auto* pd = static_cast<PickerData*>(d);
pd->on_select(extract_unit_name(row));
gtk_window_close(GTK_WINDOW(pd->window));
delete pd;
}), pd);
gtk_window_present(GTK_WINDOW(win));
}
}

30
src/util/unit_picker.h Normal file
View File

@@ -0,0 +1,30 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <string>
#include <functional>
#include "models/project.h"
namespace grex {
void show_unit_picker(GtkWindow* parent, Project& project,
std::function<void(const std::string& unit_name)> on_select);
}

View File

@@ -0,0 +1,545 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/unit_properties_dialog.h"
#include <cstdlib>
#include <fstream>
namespace grex {
struct DialogState;
struct DlgSwitchBinding {
DialogState* state;
bool* target;
};
struct DlgEntryBinding {
DialogState* state;
std::string* target;
};
struct DialogState {
UnitDialogResult result = UnitDialogResult::Cancel;
GMainLoop* loop = nullptr;
GtkWidget* window = nullptr;
Unit working_copy;
Unit* original = nullptr;
Project* project = nullptr;
GrexConfig* grex_config = nullptr;
const std::vector<ShellDef>* shells = nullptr;
bool loading = false;
GCancellable* cancellable = nullptr;
// widgets
GtkWidget* entry_target = nullptr;
GtkWidget* box_target = nullptr;
GtkWidget* switch_shell_cmd = nullptr;
GtkWidget* dropdown_shell_def = nullptr;
GtkWidget* switch_force_pty = nullptr;
GtkWidget* switch_set_workdir = nullptr;
GtkWidget* entry_workdir = nullptr;
GtkWidget* box_workdir = nullptr;
GtkWidget* switch_rectify = nullptr;
GtkWidget* entry_rectifier = nullptr;
GtkWidget* switch_active = nullptr;
GtkWidget* switch_required = nullptr;
GtkWidget* switch_set_user_ctx = nullptr;
GtkWidget* entry_user = nullptr;
GtkWidget* entry_group = nullptr;
GtkWidget* switch_supply_env = nullptr;
GtkWidget* entry_environment = nullptr;
GtkWidget* box_environment = nullptr;
// labels for conditional visibility
GtkWidget* label_target = nullptr;
GtkWidget* label_shell_cmd = nullptr;
GtkWidget* label_shell_def = nullptr;
GtkWidget* label_force_pty = nullptr;
GtkWidget* label_set_workdir = nullptr;
GtkWidget* label_workdir = nullptr;
GtkWidget* label_rectify = nullptr;
GtkWidget* label_rectifier = nullptr;
GtkWidget* label_active = nullptr;
GtkWidget* label_required = nullptr;
GtkWidget* label_set_user_ctx = nullptr;
GtkWidget* label_user = nullptr;
GtkWidget* label_group = nullptr;
GtkWidget* label_supply_env = nullptr;
GtkWidget* label_environment = nullptr;
std::vector<DlgEntryBinding*> entry_bindings;
std::vector<DlgSwitchBinding*> switch_bindings;
std::vector<void*> helper_data;
};
static void update_sensitivity(DialogState* s);
static void dlg_switch_toggled_cb(GObject* sw, GParamSpec*, gpointer data) {
auto* b = static_cast<DlgSwitchBinding*>(data);
if (b->state) {
*b->target = gtk_switch_get_active(GTK_SWITCH(sw));
if (!b->state->loading)
update_sensitivity(b->state);
}
}
static int shell_index(const std::vector<ShellDef>& shells, const std::string& name) {
for (size_t i = 0; i < shells.size(); i++)
if (shells[i].name == name) return static_cast<int>(i);
return 0;
}
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_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* sw = gtk_switch_new();
gtk_widget_set_halign(sw, GTK_ALIGN_START);
gtk_grid_attach(GTK_GRID(grid), sw, 1, row, 1, 1);
return sw;
}
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_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_grid_attach(GTK_GRID(grid), entry, 1, row, 1, 1);
return entry;
}
static GtkWidget* make_browse_row(DialogState* s, GtkWidget* grid, int row, const char* label_text,
GtkWidget** label_out, GtkWidget** box_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_hexpand(hbox, TRUE);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(hbox), entry);
auto* btn = gtk_button_new_with_label("Select");
gtk_box_append(GTK_BOX(hbox), btn);
struct BrowseBtnData {
DialogState* state;
GtkWidget* entry;
};
auto* bbd = new BrowseBtnData{s, entry};
s->helper_data.push_back(bbd);
g_signal_connect(btn, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* bbd = static_cast<BrowseBtnData*>(d);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(bbd->entry, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Select File");
gtk_file_dialog_set_accept_label(dialog, "Select");
auto root = bbd->state->project->resolved_project_root();
if (!root.empty()) {
auto* initial = g_file_new_for_path(root.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
struct BD { GtkWidget* entry; };
auto* bd = new BD{bbd->entry};
gtk_file_dialog_open(dialog, window, bbd->state->cancellable,
+[](GObject* source, GAsyncResult* res, gpointer data) {
auto* bd = static_cast<BD*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), res, &error);
if (file) {
auto* path = g_file_get_path(file);
gtk_editable_set_text(GTK_EDITABLE(bd->entry), path);
g_free(path);
g_object_unref(file);
} else if (error) {
g_error_free(error);
}
delete bd;
}, bd);
}), bbd);
if (box_out) *box_out = hbox;
gtk_grid_attach(GTK_GRID(grid), hbox, 1, row, 1, 1);
return entry;
}
static GtkWidget* make_file_row(DialogState* s, GtkWidget* grid, int row, const char* label_text,
GtkWidget** label_out, GtkWidget** box_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_hexpand(hbox, TRUE);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(hbox), entry);
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);
struct FileBtnData {
DialogState* state;
GtkWidget* entry;
};
auto* fbd = new FileBtnData{s, entry};
s->helper_data.push_back(fbd);
// Browse
g_signal_connect(btn_browse, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* fbd = static_cast<FileBtnData*>(d);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(fbd->entry, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Select File");
gtk_file_dialog_set_accept_label(dialog, "Select");
auto root = fbd->state->project->resolved_project_root();
if (!root.empty()) {
auto* initial = g_file_new_for_path(root.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
struct CB { GtkWidget* entry; };
auto* cb = new CB{fbd->entry};
gtk_file_dialog_open(dialog, window, fbd->state->cancellable,
+[](GObject* source, GAsyncResult* res, gpointer data) {
auto* cb = static_cast<CB*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), res, &error);
if (file) {
auto* path = g_file_get_path(file);
gtk_editable_set_text(GTK_EDITABLE(cb->entry), path);
g_free(path);
g_object_unref(file);
} else if (error) {
g_error_free(error);
}
delete cb;
}, cb);
}), fbd);
// Open
g_signal_connect(btn_open, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* fbd = static_cast<FileBtnData*>(d);
auto raw = std::string(gtk_editable_get_text(GTK_EDITABLE(fbd->entry)));
if (raw.empty()) {
fbd->state->project->report_status("Error: no file path set");
return;
}
namespace fs = std::filesystem;
fs::path p(raw);
if (p.is_relative()) {
auto root = fbd->state->project->resolved_project_root();
if (!root.empty())
p = root / p;
}
std::error_code ec;
auto canonical = fs::canonical(p, ec);
auto full = ec ? p : canonical;
if (!fs::exists(full)) {
fbd->state->project->report_status("Error: file not found: " + full.string());
return;
}
auto cmd = fbd->state->grex_config->file_editor + " \"" + full.string() + "\" &";
std::system(cmd.c_str());
}), fbd);
// Create
auto on_new_clicked = +[](GtkButton*, gpointer d) {
auto* fbd = static_cast<FileBtnData*>(d);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(fbd->entry, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Create New File");
auto root = fbd->state->project->resolved_project_root();
if (!root.empty()) {
auto* initial = g_file_new_for_path(root.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
struct NewCB {
DialogState* state;
GtkWidget* entry;
};
auto* ncb = new NewCB{fbd->state, fbd->entry};
auto on_save_response = +[](GObject* source, GAsyncResult* res, gpointer data) {
auto* ncb = static_cast<NewCB*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), res, &error);
if (file) {
auto* path = g_file_get_path(file);
std::ofstream ofs(path);
ofs.close();
gtk_editable_set_text(GTK_EDITABLE(ncb->entry), path);
auto cmd = ncb->state->grex_config->file_editor + " \"" + std::string(path) + "\" &";
std::system(cmd.c_str());
g_free(path);
g_object_unref(file);
} else if (error) {
g_error_free(error);
}
delete ncb;
};
gtk_file_dialog_save(dialog, window, fbd->state->cancellable, on_save_response, ncb);
};
g_signal_connect(btn_new, "clicked", G_CALLBACK(on_new_clicked), fbd);
if (box_out) *box_out = hbox;
gtk_grid_attach(GTK_GRID(grid), hbox, 1, row, 1, 1);
return entry;
}
static void update_sensitivity(DialogState* s) {
auto show = [](bool visible, std::initializer_list<GtkWidget*> widgets) {
for (auto* w : widgets)
gtk_widget_set_visible(w, visible);
};
bool active = s->working_copy.active;
show(active, {
s->label_target, s->box_target,
s->label_shell_cmd, s->switch_shell_cmd,
s->label_set_workdir, s->switch_set_workdir,
s->label_rectify, s->switch_rectify,
s->label_required, s->switch_required,
s->label_set_user_ctx, s->switch_set_user_ctx,
s->label_supply_env, s->switch_supply_env,
});
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.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});
}
static void populate_and_connect(DialogState* s) {
auto& u = s->working_copy;
// Rebuild shell dropdown model
auto* string_list = gtk_string_list_new(nullptr);
for (auto& sh : *s->shells)
gtk_string_list_append(string_list, sh.name.c_str());
gtk_drop_down_set_model(GTK_DROP_DOWN(s->dropdown_shell_def), G_LIST_MODEL(string_list));
g_object_unref(string_list);
s->loading = true;
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));
gtk_switch_set_active(GTK_SWITCH(s->switch_force_pty), u.force_pty);
gtk_switch_set_active(GTK_SWITCH(s->switch_set_workdir), u.set_working_directory);
gtk_editable_set_text(GTK_EDITABLE(s->entry_workdir), u.working_directory.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_rectify), u.rectify);
gtk_editable_set_text(GTK_EDITABLE(s->entry_rectifier), u.rectifier.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_active), u.active);
gtk_switch_set_active(GTK_SWITCH(s->switch_required), u.required);
gtk_switch_set_active(GTK_SWITCH(s->switch_set_user_ctx), u.set_user_context);
gtk_editable_set_text(GTK_EDITABLE(s->entry_user), u.user.c_str());
gtk_editable_set_text(GTK_EDITABLE(s->entry_group), u.group.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_supply_env), u.supply_environment);
gtk_editable_set_text(GTK_EDITABLE(s->entry_environment), u.environment.c_str());
update_sensitivity(s);
s->loading = false;
// Entry bindings
auto bind_entry = [s](GtkWidget* entry, std::string* target) {
auto* eb = new DlgEntryBinding{s, target};
s->entry_bindings.push_back(eb);
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* eb = static_cast<DlgEntryBinding*>(d);
*eb->target = gtk_editable_get_text(e);
}), eb);
};
bind_entry(s->entry_target, &u.target);
bind_entry(s->entry_workdir, &u.working_directory);
bind_entry(s->entry_rectifier, &u.rectifier);
bind_entry(s->entry_user, &u.user);
bind_entry(s->entry_group, &u.group);
bind_entry(s->entry_environment, &u.environment);
// Switch bindings
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);
};
bind_switch(s->switch_shell_cmd, &u.is_shell_command);
bind_switch(s->switch_force_pty, &u.force_pty);
bind_switch(s->switch_set_workdir, &u.set_working_directory);
bind_switch(s->switch_rectify, &u.rectify);
bind_switch(s->switch_active, &u.active);
bind_switch(s->switch_required, &u.required);
bind_switch(s->switch_set_user_ctx, &u.set_user_context);
bind_switch(s->switch_supply_env, &u.supply_environment);
// Shell dropdown
g_signal_connect(s->dropdown_shell_def, "notify::selected", G_CALLBACK(+[](GObject* obj, GParamSpec*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
if (s->loading) return;
auto idx = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj));
if (idx < s->shells->size())
s->working_copy.shell_definition = (*s->shells)[idx].name;
}), s);
}
UnitDialogResult show_unit_properties_dialog(GtkWindow* parent,
Unit* unit, Project& project, GrexConfig& grex_config,
const std::vector<ShellDef>& shells)
{
auto* win = gtk_window_new();
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);
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_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* loop = g_main_loop_new(nullptr, FALSE);
auto* cancellable = g_cancellable_new();
DialogState state{};
state.loop = loop;
state.window = win;
state.working_copy = *unit;
state.original = unit;
state.project = &project;
state.grex_config = &grex_config;
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);
// 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);
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);
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);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), grid);
gtk_box_append(GTK_BOX(outer_box), scroll);
// Button row
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
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(outer_box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), outer_box);
gtk_window_set_default_widget(GTK_WINDOW(win), btn_save);
// Populate widgets and connect signals
populate_and_connect(&state);
// Button signals
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
s->result = UnitDialogResult::Cancel;
gtk_window_close(GTK_WINDOW(s->window));
}), &state);
g_signal_connect(btn_save, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
*s->original = s->working_copy;
s->result = UnitDialogResult::Save;
gtk_window_close(GTK_WINDOW(s->window));
}), &state);
g_signal_connect(win, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
g_main_loop_quit(s->loop);
}), &state);
gtk_window_present(GTK_WINDOW(win));
g_main_loop_run(loop);
g_main_loop_unref(loop);
// Cleanup
g_cancellable_cancel(cancellable);
g_object_unref(cancellable);
for (auto* eb : state.entry_bindings) delete eb;
for (auto* sb : state.switch_bindings) delete sb;
for (auto* p : state.helper_data) ::operator delete(p);
return state.result;
}
}

View File

@@ -0,0 +1,35 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <vector>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
enum class UnitDialogResult { Save, Cancel };
// Blocking modal dialog for editing unit properties.
// Edits a working copy; writes back to the original Unit on Save.
UnitDialogResult show_unit_properties_dialog(GtkWindow* parent,
Unit* unit, Project& project, GrexConfig& grex_config,
const std::vector<ShellDef>& shells);
}

View File

@@ -0,0 +1,89 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/unsaved_dialog.h"
namespace grex {
struct DialogState {
UnsavedResult result;
GMainLoop* loop;
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));
}
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);
}
UnsavedResult show_unsaved_dialog(GtkWindow* parent) {
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_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* label = gtk_label_new("You have unsaved changes.");
gtk_box_append(GTK_BOX(box), label);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_revert = gtk_button_new_with_label("Revert");
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_revert);
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);
auto* loop = g_main_loop_new(nullptr, FALSE);
DialogState state{UnsavedResult::Save, 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);
gtk_window_present(GTK_WINDOW(win));
g_main_loop_run(loop);
g_main_loop_unref(loop);
return state.result;
}
}

30
src/util/unsaved_dialog.h Normal file
View File

@@ -0,0 +1,30 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
namespace grex {
enum class UnsavedResult { Save, Revert };
// Shows a blocking modal "Unsaved Changes" dialog with Revert and Save buttons.
// Returns the user's choice. Uses a nested GLib main loop to block.
UnsavedResult show_unsaved_dialog(GtkWindow* parent);
}

107
src/util/var_resolver.cpp Normal file
View File

@@ -0,0 +1,107 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/var_resolver.h"
#include <cstdlib>
#include <regex>
namespace grex {
std::set<std::string> VarResolver::find_variables(const std::string& input) {
std::set<std::string> vars;
// match ${var_name} patterns
std::regex re(R"(\$\{([a-zA-Z_][a-zA-Z0-9_]*)\})");
auto begin = std::sregex_iterator(input.begin(), input.end(), re);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it)
vars.insert((*it)[1].str());
return vars;
}
void VarResolver::set(const std::string& name, const std::string& value) {
overrides_[name] = value;
}
std::string VarResolver::resolve(const std::string& input) const {
std::string result = input;
std::regex re(R"(\$\{([a-zA-Z_][a-zA-Z0-9_]*)\})");
std::string output;
auto begin = std::sregex_iterator(result.begin(), result.end(), re);
auto end = std::sregex_iterator();
size_t last_pos = 0;
for (auto it = begin; it != end; ++it) {
output.append(result, last_pos, it->position() - last_pos);
auto var_name = (*it)[1].str();
// check overrides first
auto ov = overrides_.find(var_name);
if (ov != overrides_.end() && !ov->second.empty()) {
output.append(ov->second);
} else {
// check process environment
const char* env_val = std::getenv(var_name.c_str());
if (env_val && env_val[0] != '\0') {
output.append(env_val);
} else {
// leave unresolved
output.append(it->str());
}
}
last_pos = it->position() + it->length();
}
output.append(result, last_pos);
return output;
}
bool VarResolver::can_resolve(const std::string& input) const {
auto vars = find_variables(input);
for (auto& v : vars) {
auto ov = overrides_.find(v);
if (ov != overrides_.end() && !ov->second.empty())
continue;
const char* env = std::getenv(v.c_str());
if (env && env[0] != '\0')
continue;
return false;
}
return true;
}
void VarResolver::scan_and_populate(const std::vector<std::string>& inputs) {
for (auto& input : inputs) {
auto vars = find_variables(input);
for (auto& v : vars) {
if (overrides_.find(v) != overrides_.end())
continue;
const char* env_val = std::getenv(v.c_str());
overrides_[v] = env_val ? env_val : "";
}
}
}
std::vector<std::string> VarResolver::unresolved() const {
std::vector<std::string> result;
for (auto& [name, value] : overrides_) {
if (value.empty() && !std::getenv(name.c_str()))
result.push_back(name);
}
return result;
}
}

57
src/util/var_resolver.h Normal file
View File

@@ -0,0 +1,57 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <map>
#include <set>
#include <vector>
namespace grex {
class VarResolver {
public:
// scan a string for ${var} patterns, return variable names found
static std::set<std::string> find_variables(const std::string& input);
// set a variable override (from UI)
void set(const std::string& name, const std::string& value);
// resolve all ${var} patterns in a string using:
// 1. user overrides (set via UI)
// 2. process environment (getenv)
// returns the string with variables expanded; unresolvable vars left as-is
std::string resolve(const std::string& input) const;
// check if all variables in a string can be resolved
bool can_resolve(const std::string& input) const;
// get all known variables and their values (empty string if unresolved)
const std::map<std::string, std::string>& overrides() const { return overrides_; }
// scan multiple strings, populate overrides_ with env values where available
void scan_and_populate(const std::vector<std::string>& inputs);
// get list of variable names that have no value
std::vector<std::string> unresolved() const;
private:
std::map<std::string, std::string> overrides_;
};
}

24765
src/vendor/nlohmann/json.hpp vendored Normal file

File diff suppressed because it is too large Load Diff

479
src/views/config_view.cpp Normal file
View File

@@ -0,0 +1,479 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/config_view.h"
#include "util/unsaved_dialog.h"
#include "util/json_helpers.h"
#include <cstdlib>
#include <filesystem>
namespace grex {
// binding structs to avoid commas in g_signal_connect macro args
struct CfgFieldBinding {
ConfigView* view;
std::string key;
};
struct CfgVarBinding {
ConfigView* view;
std::string var_name;
};
ConfigView::ConfigView(Project& project) : project_(project) {
root_ = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(root_), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
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);
// === 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");
gtk_widget_set_hexpand(btn_open_, TRUE);
gtk_widget_set_hexpand(btn_create_, TRUE);
gtk_widget_set_hexpand(btn_close_, TRUE);
gtk_box_append(GTK_BOX(btn_row), btn_open_);
gtk_box_append(GTK_BOX(btn_row), btn_create_);
gtk_box_append(GTK_BOX(btn_row), btn_close_);
gtk_box_append(GTK_BOX(box), btn_row);
g_signal_connect(btn_open_, "clicked", G_CALLBACK(on_open_config), this);
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 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);
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_);
build_config_fields();
// === Resolved paths section ===
gtk_box_append(GTK_BOX(box), 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);
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_);
// === Variables section ===
gtk_box_append(GTK_BOX(box), 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);
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);
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_);
build_variables_section();
update_resolved_labels();
// === Save button ===
gtk_box_append(GTK_BOX(box), 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_);
g_signal_connect(btn_save_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
static_cast<ConfigView*>(d)->apply_config();
}), this);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(root_), box);
update_config_buttons();
}
void ConfigView::build_config_fields() {
// free old bindings
for (auto* p : field_bindings_) delete static_cast<CfgFieldBinding*>(p);
field_bindings_.clear();
// clear grid
GtkWidget* child;
while ((child = gtk_widget_get_first_child(config_grid_)) != nullptr)
gtk_grid_remove(GTK_GRID(config_grid_), child);
// iterate all keys in the config JSON object
int row = 0;
for (auto& [key, val] : project_.config.data().items()) {
auto* label = gtk_label_new(key.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(config_grid_), label, 0, row, 1, 1);
auto* entry = gtk_entry_new();
if (val.is_string())
gtk_editable_set_text(GTK_EDITABLE(entry), val.get<std::string>().c_str());
else
gtk_editable_set_text(GTK_EDITABLE(entry), val.dump().c_str());
gtk_widget_set_hexpand(entry, TRUE);
gtk_grid_attach(GTK_GRID(config_grid_), entry, 1, row, 1, 1);
auto* binding = new CfgFieldBinding{this, key};
field_bindings_.push_back(binding);
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* b = static_cast<CfgFieldBinding*>(d);
b->view->project_.config.set(b->key, gtk_editable_get_text(e));
// rescan all values for variables
b->view->project_.resolver.scan_and_populate(
b->view->project_.config.all_string_values());
b->view->rebuild_variables();
b->view->update_resolved_labels();
if (!b->view->rebuilding_) { b->view->dirty_ = true; gtk_widget_add_css_class(b->view->btn_save_, "suggested-action"); }
}), binding);
row++;
}
}
void ConfigView::rebuild_variables() {
build_variables_section();
}
void ConfigView::build_variables_section() {
rebuilding_ = true;
// free old bindings
for (auto* p : var_bindings_) delete static_cast<CfgVarBinding*>(p);
var_bindings_.clear();
GtkWidget* child;
while ((child = gtk_widget_get_first_child(vars_grid_)) != nullptr)
gtk_grid_remove(GTK_GRID(vars_grid_), child);
auto& overrides = project_.resolver.overrides();
if (overrides.empty()) {
auto* lbl = gtk_label_new("No variables found in config values.");
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f);
gtk_grid_attach(GTK_GRID(vars_grid_), lbl, 0, 0, 2, 1);
rebuilding_ = false;
return;
}
int row = 0;
for (auto& [name, value] : overrides) {
auto var_label = "${" + name + "}";
auto* lbl = gtk_label_new(var_label.c_str());
gtk_label_set_xalign(GTK_LABEL(lbl), 1.0f);
gtk_grid_attach(GTK_GRID(vars_grid_), lbl, 0, row, 1, 1);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
// populate from override first, then check live env
const char* env_val = std::getenv(name.c_str());
if (!value.empty()) {
gtk_editable_set_text(GTK_EDITABLE(entry), value.c_str());
} else if (env_val) {
gtk_editable_set_text(GTK_EDITABLE(entry), env_val);
// store the env value so resolver uses it
project_.resolver.set(name, env_val);
}
if (env_val) {
auto hint = std::string("from env: ") + env_val;
gtk_widget_set_tooltip_text(entry, hint.c_str());
}
gtk_grid_attach(GTK_GRID(vars_grid_), entry, 1, row, 1, 1);
auto* binding = new CfgVarBinding{this, name};
var_bindings_.push_back(binding);
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* b = static_cast<CfgVarBinding*>(d);
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++;
}
rebuilding_ = false;
}
void ConfigView::update_resolved_labels() {
// clear existing rows
GtkWidget* child;
while ((child = gtk_widget_get_first_child(resolved_grid_)) != nullptr)
gtk_grid_remove(GTK_GRID(resolved_grid_), child);
int row = 0;
auto root = project_.resolved_project_root();
for (auto& [key, val] : project_.config.data().items()) {
if (!val.is_string()) continue;
auto raw = val.get<std::string>();
bool has_vars = !VarResolver::find_variables(raw).empty();
bool is_path_key = key.find("path") != std::string::npos ||
key.find("root") != std::string::npos ||
key.find("dir") != std::string::npos;
if (!has_vars && !is_path_key) continue;
std::string display;
if (has_vars && !project_.resolver.can_resolve(raw)) {
display = "(unresolved)";
} else {
auto resolved = project_.resolver.resolve(raw);
// relative paths get combined with project root (except project_root itself)
if (!root.empty() && !resolved.empty() && resolved[0] != '/' && key != "project_root")
display = (root / resolved).string();
else
display = resolved;
}
auto* lbl = gtk_label_new(key.c_str());
gtk_label_set_xalign(GTK_LABEL(lbl), 1.0f);
gtk_grid_attach(GTK_GRID(resolved_grid_), lbl, 0, row, 1, 1);
auto* val_label = gtk_label_new(nullptr);
gtk_label_set_xalign(GTK_LABEL(val_label), 0.0f);
gtk_label_set_selectable(GTK_LABEL(val_label), TRUE);
gtk_widget_set_hexpand(val_label, TRUE);
if (display == "(unresolved)") {
gtk_label_set_markup(GTK_LABEL(val_label),
"<span foreground=\"red\">(unresolved)</span>");
} else if (std::filesystem::is_directory(display)) {
auto markup = "<span foreground=\"green\">" + display + "</span>";
gtk_label_set_markup(GTK_LABEL(val_label), markup.c_str());
} else if (std::filesystem::exists(display)) {
auto markup = "<span foreground=\"green\">" + display + "</span>";
gtk_label_set_markup(GTK_LABEL(val_label), markup.c_str());
} else {
auto markup = "<span foreground=\"red\">" + display + "</span>";
gtk_label_set_markup(GTK_LABEL(val_label), markup.c_str());
}
gtk_grid_attach(GTK_GRID(resolved_grid_), val_label, 1, row, 1, 1);
row++;
}
if (row == 0) {
auto* lbl = gtk_label_new("No resolvable paths in config.");
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f);
gtk_grid_attach(GTK_GRID(resolved_grid_), lbl, 0, 0, 2, 1);
}
}
void ConfigView::set_apply_callback(ApplyCallback cb, void* data) {
apply_cb_ = cb;
apply_cb_data_ = data;
}
void ConfigView::apply_config() {
namespace fs = std::filesystem;
try {
project_.config.save(project_.config_path);
dirty_ = false;
gtk_widget_remove_css_class(btn_save_, "suggested-action");
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());
}
// notify MainWindow to refresh other views
if (apply_cb_)
apply_cb_(apply_cb_data_);
} catch (const std::exception& e) {
project_.report_status(std::string("Error: apply failed: ") + e.what());
}
}
void ConfigView::update_config_buttons() {
bool has_config = !project_.config_path.empty();
if (has_config) {
auto markup = std::string("<b>Current Rex Config:</b> ") + project_.config_path.filename().string();
gtk_label_set_markup(GTK_LABEL(config_label_), markup.c_str());
} else {
gtk_label_set_markup(GTK_LABEL(config_label_), "<b>Current Rex Config:</b> No config loaded");
}
gtk_widget_set_visible(btn_open_, !has_config);
gtk_widget_set_visible(btn_create_, !has_config);
gtk_widget_set_visible(btn_close_, has_config);
}
void ConfigView::refresh() {
build_config_fields();
build_variables_section();
update_resolved_labels();
update_config_buttons();
dirty_ = false;
gtk_widget_remove_css_class(btn_save_, "suggested-action");
}
void ConfigView::on_open_config(GtkButton*, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Open Rex Config");
gtk_file_dialog_set_accept_label(dialog, "Select");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Config files (*.config)");
gtk_file_filter_add_pattern(filter, "*.config");
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);
gtk_file_dialog_open(dialog, window, nullptr, on_open_config_response, self);
}
void ConfigView::on_open_config_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_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);
try {
self->project_.open_config(path);
self->refresh();
if (self->apply_cb_)
self->apply_cb_(self->apply_cb_data_);
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error opening config: ") + e.what());
}
g_free(path);
}
void ConfigView::on_create_config(GtkButton*, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Create Rex Config");
gtk_file_dialog_set_initial_name(dialog, "rex.config");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Config files (*.config)");
gtk_file_filter_add_pattern(filter, "*.config");
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);
gtk_file_dialog_save(dialog, window, nullptr, on_create_config_response, self);
}
void ConfigView::on_create_config_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<ConfigView*>(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);
try {
// Write skeleton config
nlohmann::json j;
j["config"] = {
{"config_version", "5"},
{"project_root", ""},
{"units_path", "units"},
{"shells_path", ""},
{"logs_path", ""}
};
std::filesystem::path fp(path);
save_json_file(fp, j);
self->project_.open_config(fp);
self->refresh();
if (self->apply_cb_)
self->apply_cb_(self->apply_cb_data_);
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error creating config: ") + e.what());
}
g_free(path);
}
void ConfigView::on_close_config(GtkButton*, gpointer data) {
auto* self = static_cast<ConfigView*>(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::Save)
self->apply_config();
}
self->project_.close_config();
self->refresh();
if (self->apply_cb_)
self->apply_cb_(self->apply_cb_data_);
}
}

72
src/views/config_view.h Normal file
View File

@@ -0,0 +1,72 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <vector>
#include "models/project.h"
namespace grex {
class ConfigView {
public:
explicit ConfigView(Project& project);
GtkWidget* widget() { return root_; }
void rebuild_variables();
void update_resolved_labels();
void apply_config();
void refresh();
bool is_dirty() const { return dirty_; }
using ApplyCallback = void(*)(void* data);
void set_apply_callback(ApplyCallback cb, void* data);
private:
Project& project_;
GtkWidget* root_;
GtkWidget* config_grid_;
GtkWidget* vars_grid_;
GtkWidget* resolved_grid_;
ApplyCallback apply_cb_ = nullptr;
void* apply_cb_data_ = nullptr;
bool rebuilding_ = false;
bool dirty_ = false;
std::vector<void*> field_bindings_; // CfgFieldBinding*
std::vector<void*> var_bindings_; // CfgVarBinding*
GtkWidget* config_label_;
GtkWidget* btn_open_;
GtkWidget* btn_create_;
GtkWidget* btn_close_;
GtkWidget* btn_save_;
void build_config_fields();
void build_variables_section();
void update_config_buttons();
static void on_open_config(GtkButton* btn, gpointer data);
static void on_open_config_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_create_config(GtkButton* btn, gpointer data);
static void on_create_config_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_close_config(GtkButton* btn, gpointer data);
};
}

298
src/views/main_window.cpp Normal file
View File

@@ -0,0 +1,298 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/main_window.h"
#include "views/config_view.h"
#include "views/plan_view.h"
#include "views/units_view.h"
#include "views/shells_view.h"
#include "util/unsaved_dialog.h"
namespace grex {
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_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)
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_ = 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_);
// Wire project status reporting to status bar
project_.status_cb = on_project_status;
project_.status_cb_data = this;
// Rex Config view
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");
// Plan view
plan_view_ = new PlanView(project_, grex_config_);
gtk_stack_add_titled(GTK_STACK(stack), plan_view_->widget(), "plans", "Plans");
// Units view
units_view_ = new UnitsView(project_, grex_config_);
gtk_stack_add_titled(GTK_STACK(stack), units_view_->widget(), "units", "Units");
// Shells view
shells_view_ = new ShellsView(project_);
gtk_stack_add_titled(GTK_STACK(stack), shells_view_->widget(), "shells", "Shells");
gtk_widget_set_vexpand(stack, TRUE);
gtk_box_append(GTK_BOX(vbox), stack);
g_signal_connect(stack, "notify::visible-child", G_CALLBACK(on_stack_page_changed), this);
// Status bar
gtk_box_append(GTK_BOX(vbox), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* status_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_set_size_request(status_bar, -1, 28);
status_label_ = gtk_label_new("Ready");
gtk_label_set_xalign(GTK_LABEL(status_label_), 0.0f);
gtk_label_set_ellipsize(GTK_LABEL(status_label_), PANGO_ELLIPSIZE_END);
gtk_label_set_selectable(GTK_LABEL(status_label_), TRUE);
gtk_widget_set_hexpand(status_label_, TRUE);
gtk_widget_set_margin_start(status_label_, 8);
gtk_widget_set_margin_end(status_label_, 8);
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();
}
MainWindow::~MainWindow() {
delete config_view_;
delete plan_view_;
delete units_view_;
delete shells_view_;
}
void MainWindow::set_status(const std::string& msg) {
gtk_label_set_text(GTK_LABEL(status_label_), msg.c_str());
}
void MainWindow::on_config_applied(void* data) {
auto* self = static_cast<MainWindow*>(data);
self->update_tab_sensitivity();
self->plan_view_->refresh();
self->units_view_->refresh();
self->shells_view_->refresh();
}
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++;
}
// 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();
}
void MainWindow::refresh_visible_page() {
auto* visible = gtk_stack_get_visible_child(GTK_STACK(stack_));
// 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");
// Refresh the newly visible page
if (visible == plan_view_->widget()) {
project_.load_all_units();
if (!project_.plans.empty()) {
auto& plan = project_.plans[0];
try {
plan = Plan::load(plan.filepath);
} catch (const std::exception& e) {
project_.report_status(std::string("Error reloading plan: ") + e.what());
}
}
plan_view_->refresh();
} else if (visible == units_view_->widget()) {
units_view_->refresh();
} else if (visible == shells_view_->widget()) {
project_.reload_shells();
shells_view_->refresh();
}
}
void MainWindow::on_project_status(const std::string& msg, void* data) {
auto* self = static_cast<MainWindow*>(data);
self->set_status(msg);
}
struct GCDialogData {
MainWindow* main;
GtkWidget* win;
GtkWidget* entry;
};
void MainWindow::on_grex_config_clicked(GtkButton*, gpointer data) {
auto* self = static_cast<MainWindow*>(data);
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "Grex Config");
gtk_window_set_transient_for(GTK_WINDOW(win), GTK_WINDOW(self->window_));
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* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 8);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
auto* label = gtk_label_new("File Editor");
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, 0, 1, 1);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
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);
gtk_box_append(GTK_BOX(box), grid);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
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_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);
auto* dd = new GCDialogData{self, win, entry};
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* dd = static_cast<GCDialogData*>(d);
gtk_window_close(GTK_WINDOW(dd->win));
delete dd;
}), dd);
auto on_save = +[](GtkButton*, gpointer d) {
auto* dd = static_cast<GCDialogData*>(d);
dd->main->grex_config_.file_editor = gtk_editable_get_text(GTK_EDITABLE(dd->entry));
try {
dd->main->grex_config_.save();
dd->main->set_status("Saved grex config");
} catch (const std::exception& e) {
dd->main->set_status(std::string("Error saving grex config: ") + e.what());
}
gtk_window_close(GTK_WINDOW(dd->win));
delete dd;
};
g_signal_connect(btn_save, "clicked", G_CALLBACK(on_save), dd);
gtk_window_present(GTK_WINDOW(win));
}
}

61
src/views/main_window.h Normal file
View File

@@ -0,0 +1,61 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <string>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class ConfigView;
class PlanView;
class UnitsView;
class ShellsView;
class MainWindow {
public:
MainWindow(GtkApplication* app, Project& project, GrexConfig& grex_config);
~MainWindow();
GtkWidget* widget() { return window_; }
void set_status(const std::string& msg);
private:
Project& project_;
GrexConfig& grex_config_;
GtkWidget* window_;
ConfigView* config_view_;
PlanView* plan_view_;
UnitsView* units_view_;
ShellsView* shells_view_;
GtkWidget* stack_;
GtkWidget* switcher_;
GtkWidget* status_label_;
GtkWidget* prev_page_ = nullptr;
void update_tab_sensitivity();
void refresh_visible_page();
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);
};
}

498
src/views/plan_view.cpp Normal file
View File

@@ -0,0 +1,498 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/plan_view.h"
#include "views/unit_editor.h"
#include "util/unsaved_dialog.h"
#include "util/unit_picker.h"
#include <algorithm>
#include <cstring>
namespace grex {
PlanView::PlanView(Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
root_ = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_position(GTK_PANED(root_), 300);
// === Left panel ===
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_size_request(left, 200, -1);
// 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);
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");
gtk_widget_set_hexpand(btn_open_plan_, TRUE);
gtk_widget_set_hexpand(btn_create_plan_, TRUE);
gtk_widget_set_hexpand(btn_close_plan_, TRUE);
gtk_box_append(GTK_BOX(mgmt_row), btn_open_plan_);
gtk_box_append(GTK_BOX(mgmt_row), btn_create_plan_);
gtk_box_append(GTK_BOX(mgmt_row), btn_close_plan_);
gtk_box_append(GTK_BOX(left), mgmt_row);
// 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);
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);
// 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);
auto* btn_add = gtk_button_new_with_label("Add Task");
auto* btn_del = gtk_button_new_with_label("Delete Task");
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);
// 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_paned_set_start_child(GTK_PANED(root_), left);
gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE);
// === Right panel: Unit editor ===
unit_editor_ = new UnitEditor(project_, grex_config_);
gtk_paned_set_end_child(GTK_PANED(root_), unit_editor_->widget());
gtk_paned_set_shrink_end_child(GTK_PANED(root_), FALSE);
// Name change callback to refresh the task list row label
unit_editor_->set_name_changed_callback([](const std::string&, void* data) {
auto* self = static_cast<PlanView*>(data);
self->plan_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
if (self->current_task_idx_ >= 0)
self->refresh_task_row(self->current_task_idx_);
}, this);
// Signals
g_signal_connect(btn_open_plan_, "clicked", G_CALLBACK(on_open_plan), this);
g_signal_connect(btn_create_plan_, "clicked", G_CALLBACK(on_create_plan), this);
g_signal_connect(btn_close_plan_, "clicked", G_CALLBACK(on_close_plan), this);
g_signal_connect(task_listbox_, "row-selected", G_CALLBACK(on_task_selected), this);
g_signal_connect(btn_add, "clicked", G_CALLBACK(on_add_task), this);
g_signal_connect(btn_del, "clicked", G_CALLBACK(on_delete_task), this);
g_signal_connect(btn_up, "clicked", G_CALLBACK(on_move_up), this);
g_signal_connect(btn_down, "clicked", G_CALLBACK(on_move_down), this);
g_signal_connect(btn_save_plan_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<PlanView*>(d);
try {
self->project_.save_plans();
self->plan_dirty_ = false;
gtk_widget_remove_css_class(self->btn_save_plan_, "suggested-action");
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}), this);
update_plan_buttons();
}
Plan* PlanView::current_plan() {
if (project_.plans.empty())
return nullptr;
return &project_.plans[0];
}
void PlanView::populate_task_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(task_listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(task_listbox_), child);
unit_editor_->clear();
current_task_idx_ = -1;
auto* plan = current_plan();
if (!plan) return;
for (auto& task : plan->tasks) {
auto* row = gtk_list_box_row_new();
auto text = std::string("\u25B6 ") + task.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(task_listbox_), row);
}
}
void PlanView::refresh_task_row(int idx) {
auto* plan = current_plan();
if (!plan || idx < 0 || idx >= (int)plan->tasks.size()) return;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(task_listbox_), idx);
if (!row) return;
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
if (GTK_IS_LABEL(label)) {
auto text = std::string("\u25B6 ") + plan->tasks[idx].name;
gtk_label_set_text(GTK_LABEL(label), text.c_str());
}
}
void PlanView::select_task(int idx) {
auto* plan = current_plan();
if (!plan || idx < 0 || idx >= (int)plan->tasks.size()) {
unit_editor_->clear();
return;
}
current_task_idx_ = idx;
auto& task = plan->tasks[idx];
// ensure units are loaded if paths resolve
if (project_.unit_files.empty())
project_.load_all_units();
Unit* unit = project_.find_unit(task.name);
unit_editor_->load(&task, unit);
}
// --- Open Plan ---
void PlanView::on_open_plan(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* window = gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW);
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Open Plan File");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Plan files (*.plan)");
gtk_file_filter_add_pattern(filter, "*.plan");
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);
gtk_file_dialog_open(dialog, GTK_WINDOW(window), nullptr, on_open_plan_response, self);
}
void PlanView::on_open_plan_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<PlanView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_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);
try {
self->project_.load_plan(path);
auto* plan = self->current_plan();
if (plan)
gtk_label_set_markup(GTK_LABEL(self->plan_label_), (std::string("<b>Plan:</b> ") + plan->filepath.filename().string()).c_str());
self->populate_task_list();
self->update_plan_buttons();
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: failed to load plan: ") + e.what());
}
g_free(path);
}
// --- Task operations ---
void PlanView::on_task_selected(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<PlanView*>(data);
if (self->suppress_selection_) return;
if (!row) {
self->unit_editor_->clear();
self->current_task_idx_ = -1;
return;
}
int new_idx = gtk_list_box_row_get_index(row);
if (self->unit_editor_->is_dirty() && self->current_task_idx_ >= 0 && new_idx != self->current_task_idx_) {
// Re-select old row while dialog is showing
self->suppress_selection_ = true;
auto* old_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), self->current_task_idx_);
if (old_row) gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), old_row);
self->suppress_selection_ = false;
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Save)
self->save_dirty();
else
self->revert_dirty();
self->suppress_selection_ = true;
auto* target_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), new_idx);
if (target_row) gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), target_row);
self->suppress_selection_ = false;
self->select_task(new_idx);
return;
}
self->select_task(new_idx);
}
void PlanView::on_add_task(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan) return;
// ensure units are loaded
if (self->project_.unit_files.empty())
self->project_.load_all_units();
if (self->project_.unit_files.empty()) {
self->project_.report_status("Error: no units loaded");
return;
}
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
show_unit_picker(parent, self->project_, [self](const std::string& unit_name) {
auto* plan = self->current_plan();
if (plan) {
Task t;
t.name = unit_name;
t.dependencies = nlohmann::json::array({nullptr});
plan->tasks.push_back(t);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
int last = (int)plan->tasks.size() - 1;
auto* task_row = gtk_list_box_get_row_at_index(
GTK_LIST_BOX(self->task_listbox_), last);
if (task_row)
gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), task_row);
}
});
}
void PlanView::on_delete_task(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan || self->current_task_idx_ < 0) return;
int idx = self->current_task_idx_;
plan->tasks.erase(plan->tasks.begin() + idx);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
}
void PlanView::on_move_up(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan || self->current_task_idx_ <= 0) return;
int idx = self->current_task_idx_;
std::swap(plan->tasks[idx], plan->tasks[idx - 1]);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), idx - 1);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), row);
}
void PlanView::on_move_down(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan || self->current_task_idx_ < 0 || self->current_task_idx_ >= (int)plan->tasks.size() - 1) return;
int idx = self->current_task_idx_;
std::swap(plan->tasks[idx], plan->tasks[idx + 1]);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), idx + 1);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), row);
}
void PlanView::close_plan_impl() {
project_.plans.clear();
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
populate_task_list();
update_plan_buttons();
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
project_.report_status("Plan closed");
}
void PlanView::on_close_plan(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(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::Save)
self->save_dirty();
}
self->close_plan_impl();
}
void PlanView::on_create_plan(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(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 Plan File");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Plan files (*.plan)");
gtk_file_filter_add_pattern(filter, "*.plan");
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);
gtk_file_dialog_save(dialog, GTK_WINDOW(window), nullptr, on_create_plan_response, self);
}
void PlanView::on_create_plan_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<PlanView*>(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 plan_path(path);
g_free(path);
// Ensure .plan extension
if (plan_path.extension() != ".plan")
plan_path += ".plan";
// Create a new empty plan
Plan plan;
plan.name = plan_path.stem().string();
plan.filepath = plan_path;
try {
plan.save();
self->project_.plans.clear();
self->project_.plans.push_back(std::move(plan));
auto* p = self->current_plan();
if (p)
gtk_label_set_markup(GTK_LABEL(self->plan_label_), (std::string("<b>Plan:</b> ") + p->filepath.filename().string()).c_str());
self->populate_task_list();
self->update_plan_buttons();
self->project_.report_status("Created plan: " + plan_path.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: failed to create plan: ") + e.what());
}
}
void PlanView::update_plan_buttons() {
bool has_plan = current_plan() != nullptr;
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);
}
void PlanView::refresh() {
// reload units if paths now resolve
project_.load_all_units();
auto* plan = current_plan();
if (plan)
gtk_label_set_markup(GTK_LABEL(plan_label_), (std::string("<b>Plan:</b> ") + plan->filepath.filename().string()).c_str());
else
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
populate_task_list();
update_plan_buttons();
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
}
bool PlanView::is_dirty() const {
return plan_dirty_ || unit_editor_->is_dirty();
}
void PlanView::save_dirty() {
if (unit_editor_->is_dirty())
unit_editor_->save_current();
if (plan_dirty_) {
try {
project_.save_plans();
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
}
void PlanView::revert_dirty() {
if (unit_editor_->is_dirty())
unit_editor_->revert_current();
if (plan_dirty_ && !project_.plans.empty()) {
auto& plan = project_.plans[0];
try {
auto reloaded = Plan::load(plan.filepath);
plan = std::move(reloaded);
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
}
}

74
src/views/plan_view.h Normal file
View File

@@ -0,0 +1,74 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class UnitEditor;
class PlanView {
public:
PlanView(Project& project, GrexConfig& grex_config);
GtkWidget* widget() { return root_; }
void refresh();
bool is_dirty() const;
void save_dirty();
void revert_dirty();
private:
Project& project_;
GrexConfig& grex_config_;
GtkWidget* root_;
GtkWidget* plan_label_;
GtkWidget* task_listbox_;
GtkWidget* btn_open_plan_;
GtkWidget* btn_create_plan_;
GtkWidget* btn_close_plan_;
GtkWidget* btn_save_plan_;
UnitEditor* unit_editor_;
int current_task_idx_ = -1;
bool plan_dirty_ = false;
bool suppress_selection_ = false;
void populate_task_list();
void select_task(int idx);
Plan* current_plan();
static void on_open_plan(GtkButton* btn, gpointer data);
static void on_open_plan_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_task_selected(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_add_task(GtkButton* btn, gpointer data);
static void on_delete_task(GtkButton* btn, gpointer data);
static void on_move_up(GtkButton* btn, gpointer data);
static void on_move_down(GtkButton* btn, gpointer data);
static void on_close_plan(GtkButton* btn, gpointer data);
static void on_create_plan(GtkButton* btn, gpointer data);
static void on_create_plan_response(GObject* source, GAsyncResult* res, gpointer data);
void refresh_task_row(int idx);
void update_plan_buttons();
void close_plan_impl();
};
}

210
src/views/shells_view.cpp Normal file
View File

@@ -0,0 +1,210 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/shells_view.h"
namespace grex {
ShellsView::ShellsView(Project& project) : project_(project), shells_(project.shells) {
root_ = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_position(GTK_PANED(root_), 200);
// Left panel: shell list
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_size_request(left, 150, -1);
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* 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);
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);
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);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(editor_grid_), label, 0, row, 1, 1);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_grid_attach(GTK_GRID(editor_grid_), entry, 1, row, 1, 1);
return entry;
};
entry_name_ = make_row(0, "Name");
entry_path_ = make_row(1, "Path");
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_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(btn_save, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<ShellsView*>(d);
try {
self->project_.save_shells();
} 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) {
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())
return;
auto& s = self->shells_.shells[self->current_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_);
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());
}
} 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;
}), this);
};
bind(entry_name_, 0);
bind(entry_path_, 1);
bind(entry_exec_arg_, 2);
bind(entry_source_cmd_, 3);
populate_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);
for (auto& s : shells_.shells) {
auto* row = gtk_list_box_row_new();
auto stext = std::string("\u25B8 ") + s.name;
auto* label = gtk_label_new(stext.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(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);
}
current_idx_ = -1;
clear_editor();
}
void ShellsView::clear_editor() {
loading_ = true;
gtk_editable_set_text(GTK_EDITABLE(entry_name_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_path_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_exec_arg_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_source_cmd_), "");
gtk_widget_set_sensitive(editor_grid_, FALSE);
loading_ = false;
}
void ShellsView::load_shell(int idx) {
if (idx < 0 || idx >= (int)shells_.shells.size()) {
clear_editor();
return;
}
loading_ = true;
current_idx_ = idx;
auto& s = shells_.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());
gtk_editable_set_text(GTK_EDITABLE(entry_exec_arg_), s.execution_arg.c_str());
gtk_editable_set_text(GTK_EDITABLE(entry_source_cmd_), s.source_cmd.c_str());
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::on_add_shell(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
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();
int last = (int)self->shells_.shells.size() - 1;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->listbox_), last);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->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();
}
void ShellsView::refresh() {
populate_list();
}
}

55
src/views/shells_view.h Normal file
View File

@@ -0,0 +1,55 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include "models/project.h"
namespace grex {
class ShellsView {
public:
explicit ShellsView(Project& project);
GtkWidget* widget() { return root_; }
void refresh();
private:
Project& project_;
ShellsFile& shells_;
GtkWidget* root_;
GtkWidget* listbox_;
GtkWidget* entry_name_;
GtkWidget* entry_path_;
GtkWidget* entry_exec_arg_;
GtkWidget* entry_source_cmd_;
GtkWidget* editor_grid_;
int current_idx_ = -1;
bool loading_ = false;
void populate_list();
void load_shell(int idx);
void clear_editor();
static void on_shell_selected(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_add_shell(GtkButton* btn, gpointer data);
static void on_delete_shell(GtkButton* btn, gpointer data);
};
}

225
src/views/unit_editor.cpp Normal file
View File

@@ -0,0 +1,225 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/unit_editor.h"
#include "util/unit_picker.h"
#include "util/unit_properties_dialog.h"
namespace grex {
UnitEditor::UnitEditor(Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
root_ = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(root_), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
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);
// Task section header
auto* task_label = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(task_label), "<b>Task Properties</b>");
gtk_label_set_xalign(GTK_LABEL(task_label), 0.0f);
gtk_box_append(GTK_BOX(box), task_label);
auto* task_grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(task_grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(task_grid), 12);
// Name (read-only label)
auto* name_label = gtk_label_new("Name");
gtk_label_set_xalign(GTK_LABEL(name_label), 1.0f);
gtk_grid_attach(GTK_GRID(task_grid), name_label, 0, 0, 1, 1);
name_display_ = gtk_label_new("");
gtk_label_set_xalign(GTK_LABEL(name_display_), 0.0f);
gtk_widget_set_hexpand(name_display_, TRUE);
gtk_grid_attach(GTK_GRID(task_grid), name_display_, 1, 0, 1, 1);
// Comment
auto* comment_label = gtk_label_new("Comment");
gtk_label_set_xalign(GTK_LABEL(comment_label), 1.0f);
gtk_grid_attach(GTK_GRID(task_grid), comment_label, 0, 1, 1, 1);
entry_comment_ = gtk_entry_new();
gtk_widget_set_hexpand(entry_comment_, TRUE);
gtk_grid_attach(GTK_GRID(task_grid), entry_comment_, 1, 1, 1, 1);
// Change/Select Unit button — aligned with the value column
btn_select_unit_ = gtk_button_new_with_label("Change/Select Unit...");
gtk_widget_set_halign(btn_select_unit_, GTK_ALIGN_START);
g_signal_connect(btn_select_unit_, "clicked", G_CALLBACK(on_select_unit), this);
gtk_grid_attach(GTK_GRID(task_grid), btn_select_unit_, 1, 2, 1, 1);
gtk_box_append(GTK_BOX(box), task_grid);
// Buttons below task properties
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
btn_edit_unit_ = gtk_button_new_with_label("Edit Unit...");
g_signal_connect(btn_edit_unit_, "clicked", G_CALLBACK(on_edit_unit), this);
gtk_box_append(GTK_BOX(btn_row), btn_edit_unit_);
btn_save_unit_ = gtk_button_new_with_label("Save Unit");
g_signal_connect(btn_save_unit_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<UnitEditor*>(d);
self->save_current();
}), this);
gtk_box_append(GTK_BOX(btn_row), btn_save_unit_);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(root_), box);
clear();
}
void UnitEditor::mark_dirty() {
dirty_ = true;
gtk_widget_add_css_class(btn_save_unit_, "suggested-action");
}
void UnitEditor::clear_dirty() {
dirty_ = false;
gtk_widget_remove_css_class(btn_save_unit_, "suggested-action");
}
void UnitEditor::clear() {
g_signal_handlers_disconnect_by_data(entry_comment_, this);
current_task_ = nullptr;
current_unit_ = nullptr;
current_unit_name_.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);
clear_dirty();
}
void UnitEditor::load(Task* task, Unit* unit) {
g_signal_handlers_disconnect_by_data(entry_comment_, this);
current_task_ = task;
current_unit_ = unit;
current_unit_name_ = task ? task->name : "";
gtk_widget_set_sensitive(entry_comment_, TRUE);
if (unit) {
gtk_label_set_text(GTK_LABEL(name_display_), task->name.c_str());
} else {
auto markup = std::string("<span foreground=\"red\">") + task->name + "</span>";
gtk_label_set_markup(GTK_LABEL(name_display_), markup.c_str());
}
gtk_editable_set_text(GTK_EDITABLE(entry_comment_), task->comment.value_or("").c_str());
gtk_widget_set_sensitive(btn_edit_unit_, unit != nullptr);
// Connect comment change signal
g_signal_connect(entry_comment_, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* self = static_cast<UnitEditor*>(d);
if (!self->current_task_) return;
self->mark_dirty();
auto text = std::string(gtk_editable_get_text(e));
self->current_task_->comment = text.empty() ? std::nullopt : std::optional<std::string>(text);
}), this);
clear_dirty();
}
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");
return;
}
auto* uf = project_.find_unit_file(current_unit_->name);
if (!uf) return;
try {
uf->save();
clear_dirty();
project_.report_status("Saved unit file: " + uf->filepath.filename().string());
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
void UnitEditor::revert_current() {
if (current_unit_name_.empty()) return;
auto* uf = project_.find_unit_file(current_unit_name_);
if (uf) {
try {
auto reloaded = UnitFile::load(uf->filepath);
*uf = std::move(reloaded);
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
if (!project_.plans.empty()) {
auto& plan = project_.plans[0];
try {
auto reloaded = Plan::load(plan.filepath);
plan = std::move(reloaded);
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
clear_dirty();
}
void UnitEditor::set_name_changed_callback(NameChangedCallback cb, void* data) {
name_cb_ = cb;
name_cb_data_ = data;
}
void UnitEditor::on_edit_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitEditor*>(data);
if (!self->current_unit_) return;
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);
if (result == UnitDialogResult::Save)
self->mark_dirty();
}
void UnitEditor::on_select_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitEditor*>(data);
if (!self->current_task_) return;
if (self->project_.unit_files.empty())
self->project_.load_all_units();
if (self->project_.unit_files.empty()) {
self->project_.report_status("Error: no units loaded");
return;
}
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
show_unit_picker(parent, self->project_, [self](const std::string& unit_name) {
if (self->current_task_) self->current_task_->name = unit_name;
Unit* unit = self->project_.find_unit(unit_name);
self->load(self->current_task_, unit);
if (self->name_cb_) self->name_cb_(unit_name, self->name_cb_data_);
});
}
}

69
src/views/unit_editor.h Normal file
View File

@@ -0,0 +1,69 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <string>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class UnitEditor {
public:
UnitEditor(Project& project, GrexConfig& grex_config);
~UnitEditor() = default;
GtkWidget* widget() { return root_; }
void load(Task* task, Unit* unit);
void clear();
bool is_dirty() const { return dirty_; }
void mark_dirty();
void clear_dirty();
void save_current();
void revert_current();
using NameChangedCallback = void(*)(const std::string& new_name, void* data);
void set_name_changed_callback(NameChangedCallback cb, void* data);
private:
Project& project_;
GrexConfig& grex_config_;
Task* current_task_ = nullptr;
Unit* current_unit_ = nullptr;
std::string current_unit_name_;
GtkWidget* root_;
// task fields
GtkWidget* name_display_;
GtkWidget* btn_select_unit_;
GtkWidget* btn_edit_unit_;
GtkWidget* btn_save_unit_;
GtkWidget* entry_comment_;
NameChangedCallback name_cb_ = nullptr;
void* name_cb_data_ = nullptr;
bool dirty_ = false;
static void on_select_unit(GtkButton* btn, gpointer data);
static void on_edit_unit(GtkButton* btn, gpointer data);
};
}

655
src/views/units_view.cpp Normal file
View File

@@ -0,0 +1,655 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/units_view.h"
#include "util/unsaved_dialog.h"
#include "util/unit_properties_dialog.h"
#include <cstring>
#include <fstream>
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 {
UnitsView::UnitsView(Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
root_ = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_position(GTK_PANED(root_), 300);
// === Left panel: unit files ===
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_size_request(left, 200, -1);
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);
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);
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);
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);
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);
// 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_);
gtk_paned_set_end_child(GTK_PANED(root_), right);
gtk_paned_set_shrink_end_child(GTK_PANED(root_), FALSE);
// Signals
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_unit, "clicked", G_CALLBACK(on_new_unit), this);
g_signal_connect(btn_del_unit, "clicked", G_CALLBACK(on_delete_unit), this);
g_signal_connect(btn_edit_unit, "clicked", G_CALLBACK(on_edit_unit), 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(unit_listbox_, "row-activated", G_CALLBACK(on_unit_activated), this);
g_signal_connect(btn_save_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<UnitsView*>(d);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size()) {
self->project_.report_status("Error: no unit file selected");
return;
}
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Check for cross-file duplicates before saving
for (auto& u : uf.units) {
if (self->project_.is_unit_name_taken(u.name, &u)) {
self->project_.report_status("Error: unit '" + u.name + "' conflicts with a unit in another file");
return;
}
}
try {
uf.save();
self->file_dirty_ = false;
gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->project_.report_status("Saved unit file: " + uf.filepath.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}), this);
}
void UnitsView::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& uf : project_.unit_files) {
auto* row = gtk_list_box_row_new();
auto text = std::string("\u25C6 ") + uf.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(file_listbox_), row);
}
populate_unit_list();
}
void UnitsView::populate_unit_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(unit_listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(unit_listbox_), child);
if (current_file_idx_ < 0 || current_file_idx_ >= (int)project_.unit_files.size()) {
gtk_label_set_markup(GTK_LABEL(file_label_), "<b>No file selected</b>");
return;
}
auto& uf = project_.unit_files[current_file_idx_];
gtk_label_set_markup(GTK_LABEL(file_label_),
(std::string("<b>") + uf.filepath.filename().string() + "</b>").c_str());
for (auto& u : uf.units) {
auto* row = gtk_list_box_row_new();
auto text = std::string("\u2022 ") + u.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(unit_listbox_), row);
}
}
void UnitsView::refresh() {
project_.load_all_units();
populate_file_list();
}
void UnitsView::save_current_file() {
if (current_file_idx_ < 0 || current_file_idx_ >= (int)project_.unit_files.size())
return;
auto& uf = project_.unit_files[current_file_idx_];
try {
uf.save();
file_dirty_ = false;
gtk_widget_remove_css_class(btn_save_, "suggested-action");
project_.report_status("Saved unit file: " + uf.filepath.filename().string());
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
void UnitsView::on_file_selected(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->suppress_selection_) return;
if (!row) {
self->current_file_idx_ = -1;
self->populate_unit_list();
return;
}
int new_idx = gtk_list_box_row_get_index(row);
if (self->file_dirty_ && self->current_file_idx_ >= 0 && new_idx != self->current_file_idx_) {
int old_idx = self->current_file_idx_;
self->suppress_selection_ = true;
auto* old_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->file_listbox_), old_idx);
if (old_row) gtk_list_box_select_row(GTK_LIST_BOX(self->file_listbox_), old_row);
self->suppress_selection_ = false;
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Save) {
auto& uf = self->project_.unit_files[self->current_file_idx_];
try { uf.save(); } catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
} else {
auto idx = self->current_file_idx_;
if (idx >= 0 && idx < (int)self->project_.unit_files.size()) {
auto& uf = self->project_.unit_files[idx];
try { uf = UnitFile::load(uf.filepath); } catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}
}
self->file_dirty_ = false; gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->suppress_selection_ = true;
auto* target_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->file_listbox_), new_idx);
if (target_row) gtk_list_box_select_row(GTK_LIST_BOX(self->file_listbox_), target_row);
self->suppress_selection_ = false;
self->current_file_idx_ = new_idx;
self->populate_unit_list();
return;
}
self->current_file_idx_ = new_idx;
self->file_dirty_ = false; gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
}
void UnitsView::on_new_file(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(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 Unit File");
gtk_file_dialog_set_accept_label(dialog, "Create");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Unit files (*.units)");
gtk_file_filter_add_pattern(filter, "*.units");
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 units_dir = self->project_.resolved_units_dir();
if (!units_dir.empty()) {
auto* initial = g_file_new_for_path(units_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 UnitsView::on_new_file_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<UnitsView*>(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() != ".units")
fp += ".units";
// Create empty unit file
UnitFile uf;
uf.name = fp.stem().string();
uf.filepath = fp;
try {
uf.save();
self->project_.unit_files.push_back(std::move(uf));
self->populate_file_list();
// Select the new file
int last = (int)self->project_.unit_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 unit file: " + fp.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}
void UnitsView::on_delete_file(GtkButton*, 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;
auto& uf = self->project_.unit_files[self->current_file_idx_];
auto name = uf.filepath.filename().string();
self->project_.unit_files.erase(self->project_.unit_files.begin() + self->current_file_idx_);
self->current_file_idx_ = -1;
self->populate_file_list();
self->project_.report_status("Removed unit file from project: " + name + " (file not deleted from disk)");
}
void UnitsView::on_new_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size()) {
self->project_.report_status("Error: select a unit file first");
return;
}
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "New Unit");
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);
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* header = gtk_label_new("Enter a name for the new unit...");
gtk_label_set_xalign(GTK_LABEL(header), 0.0f);
gtk_box_append(GTK_BOX(box), header);
auto* entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "unit_name");
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(box), entry);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
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_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);
struct NewUnitData {
UnitsView* view;
GtkWidget* win;
GtkWidget* entry;
};
auto* nd = new NewUnitData{self, win, entry};
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* nd = static_cast<NewUnitData*>(d);
gtk_window_close(GTK_WINDOW(nd->win));
delete nd;
}), nd);
auto on_create = +[](GtkButton*, gpointer d) {
auto* nd = static_cast<NewUnitData*>(d);
auto name = std::string(gtk_editable_get_text(GTK_EDITABLE(nd->entry)));
if (name.empty()) {
nd->view->project_.report_status("Error: unit name cannot be empty");
return;
}
// Check for duplicate across all unit files
if (nd->view->project_.is_unit_name_taken(name)) {
nd->view->project_.report_status("Error: unit '" + name + "' already exists");
return;
}
auto& uf = nd->view->project_.unit_files[nd->view->current_file_idx_];
Unit u;
u.name = name;
uf.units.push_back(u);
nd->view->populate_unit_list();
nd->view->file_dirty_ = true; gtk_widget_add_css_class(nd->view->btn_save_, "suggested-action");
nd->view->project_.report_status("Created unit: " + name);
gtk_window_close(GTK_WINDOW(nd->win));
delete nd;
};
g_signal_connect(btn_create, "clicked", G_CALLBACK(on_create), nd);
gtk_window_present(GTK_WINDOW(win));
}
void UnitsView::on_delete_unit(GtkButton*, 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;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) return;
int idx = gtk_list_box_row_get_index(row);
auto& uf = self->project_.unit_files[self->current_file_idx_];
if (idx < 0 || idx >= (int)uf.units.size()) return;
auto name = uf.units[idx].name;
uf.units.erase(uf.units.begin() + idx);
self->populate_unit_list();
self->file_dirty_ = true; gtk_widget_add_css_class(self->btn_save_, "suggested-action");
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)));
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; }
}
if (idx < 0) return;
g_object_ref(old_label);
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);
}
void UnitsView::on_move_up(GtkButton*, 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;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) return;
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Find unit index by name from the row 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 "));
int idx = -1;
for (int i = 0; i < (int)uf.units.size(); i++) {
if (uf.units[i].name == label_text) { idx = i; break; }
}
if (idx <= 0) return;
std::swap(uf.units[idx], uf.units[idx - 1]);
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
// Re-select the moved unit
auto* new_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->unit_listbox_), idx - 1);
if (new_row) gtk_list_box_select_row(GTK_LIST_BOX(self->unit_listbox_), new_row);
}
void UnitsView::on_move_down(GtkButton*, 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;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) return;
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Find unit index by name from the row 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 "));
int idx = -1;
for (int i = 0; i < (int)uf.units.size(); i++) {
if (uf.units[i].name == label_text) { idx = i; break; }
}
if (idx < 0 || idx >= (int)uf.units.size() - 1) return;
std::swap(uf.units[idx], uf.units[idx + 1]);
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
// Re-select the moved unit
auto* new_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->unit_listbox_), idx + 1);
if (new_row) gtk_list_box_select_row(GTK_LIST_BOX(self->unit_listbox_), new_row);
}
void UnitsView::on_edit_unit(GtkButton*, 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;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) {
self->project_.report_status("Error: select a unit first");
return;
}
// Get unit name from row label (strip bullet prefix)
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 unit by name
auto& uf = self->project_.unit_files[self->current_file_idx_];
Unit* unit = nullptr;
for (auto& u : uf.units) {
if (u.name == label_text) { unit = &u; break; }
}
if (!unit) return;
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);
if (result == UnitDialogResult::Save) {
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
}
}
}

69
src/views/units_view.h Normal file
View File

@@ -0,0 +1,69 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class UnitsView {
public:
UnitsView(Project& project, GrexConfig& grex_config);
GtkWidget* widget() { return root_; }
void refresh();
bool is_dirty() const { return file_dirty_; }
void save_current_file();
private:
Project& project_;
GrexConfig& grex_config_;
GtkWidget* root_;
GtkWidget* file_listbox_;
GtkWidget* unit_listbox_;
GtkWidget* file_label_;
GtkWidget* btn_save_;
int current_file_idx_ = -1;
bool file_dirty_ = false;
bool suppress_selection_ = false;
void populate_file_list();
void populate_unit_list();
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_unit(GtkButton* btn, gpointer data);
static void on_delete_unit(GtkButton* btn, gpointer data);
static void on_unit_activated(GtkListBox* box, GtkListBoxRow* row, gpointer data);
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;
};
}