/* 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 . */ #include "views/plan_view.h" #include "views/unit_editor.h" #include "util/unsaved_dialog.h" #include "util/unit_picker.h" #include #include 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, 8); gtk_widget_set_size_request(left, 200, -1); gtk_widget_set_margin_start(left, 8); gtk_widget_set_margin_end(left, 4); gtk_widget_set_margin_top(left, 8); gtk_widget_set_margin_bottom(left, 8); // Plan label plan_label_ = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(plan_label_), "Plan: No plan loaded"); gtk_label_set_xalign(GTK_LABEL(plan_label_), 0.0f); 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, 0); gtk_widget_add_css_class(mgmt_row, "linked"); btn_open_plan_ = gtk_button_new_with_label("Open"); btn_create_plan_ = gtk_button_new_with_label("Create"); btn_close_plan_ = gtk_button_new_with_label("Close"); gtk_widget_set_hexpand(btn_open_plan_, TRUE); gtk_widget_set_hexpand(btn_create_plan_, TRUE); gtk_widget_set_hexpand(btn_close_plan_, TRUE); 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 controls — greyed out when no plan loaded task_controls_ = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_widget_set_vexpand(task_controls_, TRUE); // Task list auto* scroll = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_widget_set_vexpand(scroll, TRUE); gtk_widget_add_css_class(scroll, "frame"); task_listbox_ = gtk_list_box_new(); gtk_list_box_set_selection_mode(GTK_LIST_BOX(task_listbox_), GTK_SELECTION_SINGLE); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), task_listbox_); gtk_box_append(GTK_BOX(task_controls_), scroll); // Plan Controls — plan-level actions auto* plan_ctrl_frame = gtk_frame_new("Plan Controls"); auto* plan_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_margin_start(plan_btn_box, 8); gtk_widget_set_margin_end(plan_btn_box, 8); gtk_widget_set_margin_top(plan_btn_box, 8); gtk_widget_set_margin_bottom(plan_btn_box, 8); btn_save_plan_ = gtk_button_new_with_label("Save Plan"); gtk_box_append(GTK_BOX(plan_btn_box), btn_save_plan_); auto* btn_refresh = gtk_button_new_with_label("Reload Plan"); gtk_box_append(GTK_BOX(plan_btn_box), btn_refresh); auto* btn_delete_plan = gtk_button_new_with_label("Delete Plan"); gtk_widget_add_css_class(btn_delete_plan, "destructive-action"); gtk_box_append(GTK_BOX(plan_btn_box), btn_delete_plan); gtk_frame_set_child(GTK_FRAME(plan_ctrl_frame), plan_btn_box); gtk_box_append(GTK_BOX(task_controls_), plan_ctrl_frame); gtk_box_append(GTK_BOX(left), task_controls_); gtk_paned_set_start_child(GTK_PANED(root_), left); gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE); // === 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); // Task Controls — appended inside UnitEditor's content area task_ctrl_frame_ = gtk_frame_new("Task Controls"); auto* task_ctrl_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_widget_set_margin_start(task_ctrl_box, 8); gtk_widget_set_margin_end(task_ctrl_box, 8); gtk_widget_set_margin_top(task_ctrl_box, 8); gtk_widget_set_margin_bottom(task_ctrl_box, 8); auto* task_btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); auto* task_edit_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_widget_add_css_class(task_edit_group, "linked"); auto* btn_add = gtk_button_new_with_label("Add Task"); auto* btn_del = gtk_button_new_with_label("Delete Task"); gtk_box_append(GTK_BOX(task_edit_group), btn_add); gtk_box_append(GTK_BOX(task_edit_group), btn_del); gtk_box_append(GTK_BOX(task_btn_row), task_edit_group); auto* move_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_widget_add_css_class(move_group, "linked"); btn_move_up_ = gtk_button_new_with_label("Move Up"); btn_move_down_ = gtk_button_new_with_label("Move Down"); gtk_box_append(GTK_BOX(move_group), btn_move_up_); gtk_box_append(GTK_BOX(move_group), btn_move_down_); gtk_box_append(GTK_BOX(task_btn_row), move_group); gtk_box_append(GTK_BOX(task_ctrl_box), task_btn_row); gtk_box_append(GTK_BOX(task_ctrl_box), unit_editor_->unit_controls()); gtk_frame_set_child(GTK_FRAME(task_ctrl_frame_), task_ctrl_box); gtk_box_append(GTK_BOX(unit_editor_->content_box()), task_ctrl_frame_); // Callback fired after any unit edit in the dialog unit_editor_->set_unit_edited_callback([](const std::string&, bool name_changed, void* data) { auto* self = static_cast(data); if (name_changed) { 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_move_up_, "clicked", G_CALLBACK(on_move_up), this); g_signal_connect(btn_move_down_, "clicked", G_CALLBACK(on_move_down), this); g_signal_connect(btn_save_plan_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) { auto* self = static_cast(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); g_signal_connect(btn_refresh, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) { auto* self = static_cast(d); self->reload_plan_from_disk(); }), this); g_signal_connect(btn_delete_plan, "clicked", G_CALLBACK(on_delete_plan), 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* label = gtk_label_new(nullptr); auto* escaped = g_markup_escape_text(task.name.c_str(), -1); Unit* unit = project_.find_unit(task.name); bool valid = unit && project_.check_unit_valid(*unit); std::string markup; if (valid) markup = std::string("\u25B6 ") + escaped; else markup = std::string("\u25B6 ") + escaped + ""; g_free(escaped); gtk_label_set_markup(GTK_LABEL(label), markup.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); } update_move_buttons(); } 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& task = plan->tasks[idx]; auto* escaped = g_markup_escape_text(task.name.c_str(), -1); Unit* unit = project_.find_unit(task.name); bool valid = unit && project_.check_unit_valid(*unit); std::string markup; if (valid) markup = std::string("\u25B6 ") + escaped; else markup = std::string("\u25B6 ") + escaped + ""; g_free(escaped); gtk_label_set_markup(GTK_LABEL(label), markup.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); if (unit) project_.check_unit_valid(*unit); else project_.report_status("Unit not found: " + task.name); unit_editor_->load(&task, unit); update_move_buttons(); } // --- Open Plan --- void PlanView::on_open_plan(GtkButton*, gpointer data) { auto* self = static_cast(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(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("Plan: ") + 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(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::Cancel) return; 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(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(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(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(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_), "Plan: 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(data); if (self->is_dirty()) { auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW)); auto result = show_unsaved_dialog(parent); if (result == UnsavedResult::Cancel) return; if (result == UnsavedResult::Save) self->save_dirty(); } self->close_plan_impl(); } void PlanView::on_delete_plan(GtkButton*, gpointer data) { auto* self = static_cast(data); auto* plan = self->current_plan(); if (!plan) { self->project_.report_status("Error: no plan loaded"); return; } auto filepath = plan->filepath; auto name = filepath.filename().string(); // Confirm deletion auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW)); auto* dialog = gtk_alert_dialog_new("Delete plan file '%s' from disk?", name.c_str()); gtk_alert_dialog_set_detail(dialog, "This action cannot be undone."); gtk_alert_dialog_set_buttons(dialog, (const char*[]){"Cancel", "Delete", nullptr}); gtk_alert_dialog_set_cancel_button(dialog, 0); gtk_alert_dialog_set_default_button(dialog, 0); struct DeleteCtx { PlanView* view; std::filesystem::path path; std::string name; }; auto* ctx = new DeleteCtx{self, filepath, name}; gtk_alert_dialog_choose(dialog, parent, nullptr, +[](GObject* source, GAsyncResult* res, gpointer d) { auto* ctx = static_cast(d); GError* error = nullptr; int choice = gtk_alert_dialog_choose_finish(GTK_ALERT_DIALOG(source), res, &error); if (error) { g_error_free(error); delete ctx; return; } if (choice != 1) { delete ctx; return; } // not "Delete" // Close the plan first ctx->view->close_plan_impl(); // Delete from disk std::error_code ec; if (std::filesystem::remove(ctx->path, ec)) ctx->view->project_.report_status("Deleted plan file: " + ctx->name); else ctx->view->project_.report_status("Error: could not delete " + ctx->name + (ec ? ": " + ec.message() : "")); delete ctx; }, ctx); } void PlanView::on_create_plan(GtkButton*, gpointer data) { auto* self = static_cast(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(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("Plan: ") + 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); gtk_widget_set_sensitive(task_controls_, has_plan); gtk_widget_set_sensitive(task_ctrl_frame_, has_plan); if (!has_plan) unit_editor_->clear(); } void PlanView::update_move_buttons() { auto* plan = current_plan(); if (!plan || current_task_idx_ < 0) { gtk_widget_set_sensitive(btn_move_up_, FALSE); gtk_widget_set_sensitive(btn_move_down_, FALSE); return; } int count = (int)plan->tasks.size(); gtk_widget_set_sensitive(btn_move_up_, current_task_idx_ > 0); gtk_widget_set_sensitive(btn_move_down_, current_task_idx_ < count - 1); } void PlanView::reload_plan_from_disk() { auto* plan = current_plan(); if (!plan) return; try { *plan = Plan::load(plan->filepath); project_.report_status("Reloaded plan: " + plan->filepath.filename().string()); } catch (const std::exception& e) { project_.report_status("Error reloading plan: " + std::string(e.what())); } refresh(); } 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("Plan: ") + plan->filepath.filename().string()).c_str()); else gtk_label_set_markup(GTK_LABEL(plan_label_), "Plan: 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_; } 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()); } } } }