diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index fba7bf89e..2505810ef 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -11,6 +11,7 @@ const App = @This(); const std = @import("std"); +const assert = std.debug.assert; const builtin = @import("builtin"); const glfw = @import("glfw"); const configpkg = @import("../../config.zig"); @@ -20,6 +21,7 @@ const CoreSurface = @import("../../Surface.zig"); const Surface = @import("Surface.zig"); const Window = @import("Window.zig"); +const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig"); const c = @import("c.zig"); const log = std.log.scoped(.gtk); @@ -35,6 +37,9 @@ ctx: *c.GMainContext, /// The "none" cursor. We use one that is shared across the entire app. cursor_none: ?*c.GdkCursor, +/// The configuration errors window, if it is currently open. +config_errors_window: ?*ConfigErrorsWindow = null, + /// This is set to false when the main loop should exit. running: bool = true, @@ -80,7 +85,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { _ = c.g_signal_connect_data( app, "activate", - c.G_CALLBACK(&activate), + c.G_CALLBACK(>kActivate), core_app, null, c.G_CONNECT_DEFAULT, @@ -155,9 +160,31 @@ pub fn reloadConfig(self: *App) !?*const Config { self.config.deinit(); self.config = config; + // If there were errors, report them + self.updateConfigErrors() catch |err| { + log.warn("error handling configuration errors err={}", .{err}); + }; + return &self.config; } +/// This should be called whenever the configuration changes to update +/// the state of our config errors window. This will show the window if +/// there are new configuration errors and hide the window if the errors +/// are resolved. +fn updateConfigErrors(self: *App) !void { + if (!self.config._errors.empty()) { + if (self.config_errors_window == null) { + try ConfigErrorsWindow.create(self); + assert(self.config_errors_window != null); + } + } + + if (self.config_errors_window) |window| { + window.update(); + } +} + /// Called by CoreApp to wake up the event loop. pub fn wakeup(self: App) void { _ = self; @@ -166,6 +193,14 @@ pub fn wakeup(self: App) void { /// Run the event loop. This doesn't return until the app exits. pub fn run(self: *App) !void { + if (!self.running) return; + + // On startup, we want to check for configuration errors right away + // so we can show our error window. + self.updateConfigErrors() catch |err| { + log.warn("error handling configuration errors err={}", .{err}); + }; + while (self.running) { _ = c.g_main_context_iteration(self.ctx, 1); @@ -286,7 +321,7 @@ fn gtkQuitConfirmation( /// This is called by the "activate" signal. This is sent on program /// startup and also when a secondary instance launches and requests /// a new window. -fn activate(app: *c.GtkApplication, ud: ?*anyopaque) callconv(.C) void { +fn gtkActivate(app: *c.GtkApplication, ud: ?*anyopaque) callconv(.C) void { _ = app; const core_app: *CoreApp = @ptrCast(@alignCast(ud orelse return)); diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig new file mode 100644 index 000000000..f582c459a --- /dev/null +++ b/src/apprt/gtk/ConfigErrorsWindow.zig @@ -0,0 +1,201 @@ +/// Configuration errors window. +const ConfigErrors = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const configpkg = @import("../../config.zig"); +const Config = configpkg.Config; + +const App = @import("App.zig"); +const View = @import("View.zig"); +const c = @import("c.zig"); + +const log = std.log.scoped(.gtk); + +app: *App, +window: *c.GtkWindow, +view: PrimaryView, + +pub fn create(app: *App) !void { + if (app.config_errors_window != null) return error.InvalidOperation; + + const alloc = app.core_app.alloc; + const self = try alloc.create(ConfigErrors); + errdefer alloc.destroy(self); + try self.init(app); + + app.config_errors_window = self; +} + +pub fn update(self: *ConfigErrors) void { + if (self.app.config._errors.empty()) { + c.gtk_window_destroy(@ptrCast(self.window)); + return; + } + + self.view.update(&self.app.config); + _ = c.gtk_window_present(self.window); + _ = c.gtk_widget_grab_focus(@ptrCast(self.window)); +} + +/// Not public because this should be called by the GTK lifecycle. +fn destroy(self: *ConfigErrors) void { + const alloc = self.app.core_app.alloc; + self.app.config_errors_window = null; + alloc.destroy(self); +} + +fn init(self: *ConfigErrors, app: *App) !void { + // Create the window + const window = c.gtk_application_window_new(app.app); + const gtk_window: *c.GtkWindow = @ptrCast(window); + errdefer c.gtk_window_destroy(gtk_window); + c.gtk_window_set_title(gtk_window, "Configuration Errors"); + c.gtk_window_set_default_size(gtk_window, 600, 275); + c.gtk_window_set_resizable(gtk_window, 0); + _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); + + // Set some state + self.* = .{ + .app = app, + .window = gtk_window, + .view = undefined, + }; + + // Show the window + const view = try PrimaryView.init(self); + self.view = view; + c.gtk_window_set_child(@ptrCast(window), view.root); + c.gtk_widget_show(window); +} + +fn gtkDestroy(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { + const self = userdataSelf(ud.?); + self.destroy(); +} + +fn userdataSelf(ud: *anyopaque) *ConfigErrors { + return @ptrCast(@alignCast(ud)); +} + +const PrimaryView = struct { + root: *c.GtkWidget, + text: *c.GtkTextView, + + pub fn init(root: *ConfigErrors) !PrimaryView { + // All our widgets + const label = c.gtk_label_new( + "One or more configuration errors were found while loading " ++ + "the configuration. Please review the errors below and reload " ++ + "your configuration or ignore the erroneous lines.", + ); + const buf = contentsBuffer(&root.app.config); + defer c.g_object_unref(buf); + const buttons = try ButtonsView.init(root); + const text_scroll = c.gtk_scrolled_window_new(); + errdefer c.g_object_unref(text_scroll); + const text = c.gtk_text_view_new_with_buffer(buf); + errdefer c.g_object_unref(text); + c.gtk_scrolled_window_set_child(@ptrCast(text_scroll), text); + + // Create our view + const view = try View.init(&.{ + .{ .name = "label", .widget = label }, + .{ .name = "text", .widget = text_scroll }, + .{ .name = "buttons", .widget = buttons.root }, + }, &vfl); + errdefer view.deinit(); + + // We can do additional settings once the layout is setup + c.gtk_label_set_wrap(@ptrCast(label), 1); + c.gtk_text_view_set_editable(@ptrCast(text), 0); + c.gtk_text_view_set_cursor_visible(@ptrCast(text), 0); + c.gtk_text_view_set_top_margin(@ptrCast(text), 8); + c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8); + c.gtk_text_view_set_left_margin(@ptrCast(text), 8); + c.gtk_text_view_set_right_margin(@ptrCast(text), 8); + + return .{ .root = view.root, .text = @ptrCast(text) }; + } + + pub fn update(self: *PrimaryView, config: *const Config) void { + const buf = contentsBuffer(config); + defer c.g_object_unref(buf); + c.gtk_text_view_set_buffer(@ptrCast(self.text), buf); + } + + /// Returns the GtkTextBuffer for the config errors that we want to show. + fn contentsBuffer(config: *const Config) *c.GtkTextBuffer { + const buf = c.gtk_text_buffer_new(null); + errdefer c.g_object_unref(buf); + + for (config._errors.list.items) |err| { + c.gtk_text_buffer_insert_at_cursor(buf, err.message, @intCast(err.message.len)); + c.gtk_text_buffer_insert_at_cursor(buf, "\n", -1); + } + + return buf; + } + + const vfl = [_][*:0]const u8{ + "H:|-8-[label]-8-|", + "H:|[text]|", + "H:|[buttons]|", + "V:|[label(<=80)][text(>=100)]-[buttons]-|", + }; +}; + +const ButtonsView = struct { + root: *c.GtkWidget, + + pub fn init(root: *ConfigErrors) !ButtonsView { + const ignore_button = c.gtk_button_new_with_label("Ignore"); + errdefer c.g_object_unref(ignore_button); + + const reload_button = c.gtk_button_new_with_label("Reload Configuration"); + errdefer c.g_object_unref(reload_button); + + // Create our view + const view = try View.init(&.{ + .{ .name = "ignore", .widget = ignore_button }, + .{ .name = "reload", .widget = reload_button }, + }, &vfl); + + // Signals + _ = c.g_signal_connect_data( + ignore_button, + "clicked", + c.G_CALLBACK(>kIgnoreClick), + root, + null, + c.G_CONNECT_DEFAULT, + ); + _ = c.g_signal_connect_data( + reload_button, + "clicked", + c.G_CALLBACK(>kReloadClick), + root, + null, + c.G_CONNECT_DEFAULT, + ); + + return .{ .root = view.root }; + } + + fn gtkIgnoreClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { + const self: *ConfigErrors = @ptrCast(@alignCast(ud)); + c.gtk_window_destroy(@ptrCast(self.window)); + } + + fn gtkReloadClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { + const self: *ConfigErrors = @ptrCast(@alignCast(ud)); + _ = self.app.reloadConfig() catch |err| { + log.warn("error reloading config error={}", .{err}); + return; + }; + } + + const vfl = [_][*:0]const u8{ + "H:[ignore]-8-[reload]-8-|", + }; +}; diff --git a/src/apprt/gtk/View.zig b/src/apprt/gtk/View.zig new file mode 100644 index 000000000..2287b4737 --- /dev/null +++ b/src/apprt/gtk/View.zig @@ -0,0 +1,73 @@ +/// View helps with creating a view with a constraint layout by +/// managing all the boilerplate. The caller is responsible for +/// providing the widgets, their names, and the VFL code and gets +/// a root box as a result ready to be used. +const View = @This(); + +const std = @import("std"); +const c = @import("c.zig"); + +const log = std.log.scoped(.gtk); + +/// The box that contains all of the widgets. +root: *c.GtkWidget, + +/// A single widget used in the view. +pub const Widget = struct { + /// The name of the widget used for the layout code. This is also + /// the name set for the widget for CSS styling. + name: [:0]const u8, + + /// The widget itself. + widget: *c.GtkWidget, +}; + +/// Initialize a new constraint layout view with the given widgets +/// and VFL. +pub fn init(widgets: []const Widget, vfl: []const [*:0]const u8) !View { + // Box to store all our widgets + const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); + errdefer c.g_object_unref(box); + c.gtk_widget_set_vexpand(box, 1); + c.gtk_widget_set_hexpand(box, 1); + + // Setup our constraint layout and attach it to the box + const layout = c.gtk_constraint_layout_new(); + errdefer c.g_object_unref(layout); + c.gtk_widget_set_layout_manager(@ptrCast(box), layout); + + // Setup our views table + const views = c.g_hash_table_new(c.g_str_hash, c.g_str_equal); + defer c.g_hash_table_unref(views); + + // Add our widgets + for (widgets) |widget| { + c.gtk_widget_set_parent(widget.widget, box); + c.gtk_widget_set_name(widget.widget, widget.name); + _ = c.g_hash_table_insert( + views, + @constCast(@ptrCast(widget.name.ptr)), + widget.widget, + ); + } + + // Add all of our constraints for layout + var err_: ?*c.GError = null; + const list = c.gtk_constraint_layout_add_constraints_from_descriptionv( + @ptrCast(layout), + vfl.ptr, + vfl.len, + 8, + 8, + views, + &err_, + ); + if (err_) |err| { + defer c.g_error_free(err); + log.warn("error building view message={s}", .{err.message}); + return error.OperationFailed; + } + c.g_list_free(list); + + return .{ .root = box }; +}