Autostart option added
This commit is contained in:
323
src/main.cpp
323
src/main.cpp
@@ -9,13 +9,18 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "autostart.h"
|
||||
#include "capturedialog.h"
|
||||
#include "daemonclient.h"
|
||||
#include "help.h"
|
||||
#include "ipc_ids.h"
|
||||
#include "keyrow.h"
|
||||
#include "mapping.h"
|
||||
#include "mappings.h"
|
||||
#include "optionsdialog.h"
|
||||
#include "settings.h"
|
||||
#include "singleinstance.h"
|
||||
#include "trayicon.h"
|
||||
|
||||
#ifndef PACKAGE
|
||||
#define PACKAGE "gkeymapper"
|
||||
@@ -32,19 +37,21 @@ using gkeymapper::kNumKeys;
|
||||
|
||||
std::vector<KeyRow> rows;
|
||||
|
||||
// Footer view of the daemon. It is filled by the asynchronous callbacks of
|
||||
// DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
|
||||
// for the daemon.
|
||||
struct DaemonFooter {
|
||||
bool reachable = false;
|
||||
bool configOk = true;
|
||||
std::string device;
|
||||
std::string lastKey;
|
||||
} daemonFooter;
|
||||
// The daemon view behind the footer. It is filled by the asynchronous callbacks
|
||||
// of DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
|
||||
// for the daemon; the options window shows the same data in full.
|
||||
gkeymapper::DaemonView daemonView;
|
||||
|
||||
GtkWidget* daemonLabel = nullptr;
|
||||
gkeymapper::DaemonClient daemonClient;
|
||||
|
||||
// The window can be hidden instead of closed (the tray brings it back), and the
|
||||
// options window keeps showing the daemon status while it is open.
|
||||
GtkWidget* mainWindow = nullptr;
|
||||
gkeymapper::TrayIcon tray;
|
||||
gkeymapper::GuiSettings guiSettings;
|
||||
gkeymapper::OptionsDialog optionsDialog;
|
||||
|
||||
// Rebuilds the footer line from the state above. " · " is a separator, not a
|
||||
// sentence, so it is not translated.
|
||||
static void refresh_footer() {
|
||||
@@ -52,47 +59,53 @@ static void refresh_footer() {
|
||||
return;
|
||||
}
|
||||
std::string text;
|
||||
if (!daemonFooter.reachable) {
|
||||
if (!daemonView.reachable) {
|
||||
text = _("Daemon: not reachable");
|
||||
} else {
|
||||
text = _("Daemon: running");
|
||||
text += " · ";
|
||||
if (daemonFooter.device.empty()) {
|
||||
if (daemonView.device.empty()) {
|
||||
// Reachable but no keyboard: unplugged, unsupported, or not found
|
||||
// yet. Saying so is the difference between "broken" and "unplugged".
|
||||
text += _("Device: not found");
|
||||
} else {
|
||||
g_autofree gchar* device =
|
||||
g_strdup_printf(_("Device: %s"), daemonFooter.device.c_str());
|
||||
g_strdup_printf(_("Device: %s"), daemonView.device.c_str());
|
||||
text += device;
|
||||
}
|
||||
if (!daemonFooter.configOk) {
|
||||
if (!daemonView.configOk) {
|
||||
text += " · ";
|
||||
text += _("Configuration error");
|
||||
}
|
||||
if (!daemonFooter.lastKey.empty()) {
|
||||
if (!daemonView.lastKey.empty()) {
|
||||
text += " · ";
|
||||
text += daemonFooter.lastKey;
|
||||
g_autofree gchar* lastKey =
|
||||
g_strdup_printf(_("Last key: %s"), daemonView.lastKey.c_str());
|
||||
text += lastKey;
|
||||
}
|
||||
}
|
||||
gtk_label_set_text(GTK_LABEL(daemonLabel), text.c_str());
|
||||
}
|
||||
|
||||
static void on_daemon_status(const gkeymapper::DaemonStatus& status) {
|
||||
daemonFooter.reachable = status.reachable;
|
||||
daemonFooter.configOk = status.configOk;
|
||||
daemonFooter.device = status.device;
|
||||
daemonView.reachable = status.reachable;
|
||||
daemonView.configOk = status.configOk;
|
||||
daemonView.version = status.version;
|
||||
daemonView.device = status.device;
|
||||
daemonView.configPath = status.configPath;
|
||||
if (!status.reachable) {
|
||||
daemonFooter.lastKey.clear(); // stale once the daemon is gone
|
||||
daemonView.lastKey.clear(); // stale once the daemon is gone
|
||||
}
|
||||
refresh_footer();
|
||||
optionsDialog.update(daemonView);
|
||||
}
|
||||
|
||||
static void on_daemon_key(int index, const std::string& action) {
|
||||
g_autofree gchar* text =
|
||||
g_strdup_printf(_("Last key: G%d → %s"), index + 1, action.c_str());
|
||||
daemonFooter.lastKey = text;
|
||||
// Just the fact ("G4 → key:ctrl+v"), no label: the footer and the options
|
||||
// window each put their own translated label in front of it.
|
||||
daemonView.lastKey = "G" + std::to_string(index + 1) + " → " + action;
|
||||
refresh_footer();
|
||||
optionsDialog.update(daemonView);
|
||||
}
|
||||
|
||||
static void on_pick_file_response(GtkDialog* dialog, int response, gpointer userData) {
|
||||
@@ -230,6 +243,22 @@ static GtkWidget* make_icon_image(int pixelSize) {
|
||||
return image;
|
||||
}
|
||||
|
||||
// GtkAboutDialog makes its texts selectable and hands the keyboard focus to the
|
||||
// program name (the first focusable widget), so the dialog opens with the name
|
||||
// drawn as selected — the blue block nobody asked for. Nothing in that dialog is
|
||||
// worth copying (the version is short, and the options window shows it too), so
|
||||
// the labels are switched off as selectable; that also stops the name from taking
|
||||
// the focus, and no other label claims it afterwards.
|
||||
static void make_labels_unselectable(GtkWidget* widget) {
|
||||
if (GTK_IS_LABEL(widget)) {
|
||||
gtk_label_set_selectable(GTK_LABEL(widget), FALSE);
|
||||
}
|
||||
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
|
||||
child = gtk_widget_get_next_sibling(child)) {
|
||||
make_labels_unselectable(child);
|
||||
}
|
||||
}
|
||||
|
||||
static void show_about_dialog(GtkWindow* parent) {
|
||||
GtkWidget* dialog = gtk_about_dialog_new();
|
||||
gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), _("gKeyMapper"));
|
||||
@@ -256,6 +285,8 @@ static void show_about_dialog(GtkWindow* parent) {
|
||||
g_object_unref(texture);
|
||||
}
|
||||
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
||||
// Before it is mapped: that is when the focus would go to the program name.
|
||||
make_labels_unselectable(dialog);
|
||||
// No "response" handler: GtkAboutDialog has no such signal in GTK4 (only
|
||||
// GtkDialog subclasses with buttons, like GtkFileChooserDialog, do). Closing
|
||||
// it goes through close-request, whose default handler destroys the window.
|
||||
@@ -339,6 +370,206 @@ static std::string find_asset(const std::string& name) {
|
||||
return local;
|
||||
}
|
||||
|
||||
// Size of the pixmap handed to the panel; the panel scales it to its own size.
|
||||
constexpr int kTrayIconSize = 64;
|
||||
|
||||
// Shown and hidden are the two states the tray works with: a hidden window is
|
||||
// still a window, so the app keeps running and the tray stays the way back.
|
||||
static void show_main_window() {
|
||||
if (mainWindow == nullptr) {
|
||||
return;
|
||||
}
|
||||
gtk_widget_set_visible(mainWindow, TRUE);
|
||||
// present() also restores a window the window manager had minimized.
|
||||
gtk_window_present(GTK_WINDOW(mainWindow));
|
||||
}
|
||||
|
||||
static void hide_main_window() {
|
||||
if (mainWindow != nullptr) {
|
||||
gtk_widget_set_visible(mainWindow, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
// Hiding is only safe while the tray really hosts the icon: without one there
|
||||
// would be no way back to the window, so the app quits as usual.
|
||||
static bool hides_to_tray() {
|
||||
return guiSettings.minimizeToTray && tray.ready();
|
||||
}
|
||||
|
||||
// Closing the window: with the tray option on it is hidden, otherwise the default
|
||||
// handler closes the window and the app quits with it.
|
||||
static gboolean on_close_request(GtkWindow* /*window*/, gpointer /*userData*/) {
|
||||
if (!hides_to_tray()) {
|
||||
return FALSE;
|
||||
}
|
||||
hide_main_window();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// GTK4 has no signal for "minimized" (GdkToplevel has no properties either, only
|
||||
// gdk_toplevel_get_state), so the one state that needs an action is polled: one
|
||||
// call per tick, and only while the option is on and the window is up.
|
||||
static gboolean on_minimize_watch(gpointer /*userData*/) {
|
||||
if (!hides_to_tray() || mainWindow == nullptr || !gtk_widget_get_visible(mainWindow)) {
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
GdkSurface* surface = gtk_native_get_surface(GTK_NATIVE(mainWindow));
|
||||
if (surface != nullptr && GDK_IS_TOPLEVEL(surface) &&
|
||||
(gdk_toplevel_get_state(GDK_TOPLEVEL(surface)) & GDK_TOPLEVEL_STATE_MINIMIZED) != 0) {
|
||||
hide_main_window();
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
// The panel is another process and cannot read the icon compiled into this
|
||||
// binary, so the tray icon travels as a pixmap. GdkPixbuf hands out RGBA, the
|
||||
// protocol wants ARGB32 in network byte order, so the bytes are reordered here.
|
||||
static bool load_tray_icon(std::vector<unsigned char>& pixels, int& width, int& height) {
|
||||
GError* error = nullptr;
|
||||
GdkPixbuf* pixbuf = have_embedded_icon()
|
||||
? gdk_pixbuf_new_from_resource(kIconResource, &error)
|
||||
: gdk_pixbuf_new_from_file(find_asset("icon.png").c_str(), &error);
|
||||
if (pixbuf == nullptr) {
|
||||
g_printerr("gkeymapper: no tray icon: %s\n",
|
||||
error != nullptr ? error->message : "unknown error");
|
||||
g_clear_error(&error);
|
||||
return false;
|
||||
}
|
||||
if (gdk_pixbuf_get_width(pixbuf) > kTrayIconSize ||
|
||||
gdk_pixbuf_get_height(pixbuf) > kTrayIconSize) {
|
||||
GdkPixbuf* scaled =
|
||||
gdk_pixbuf_scale_simple(pixbuf, kTrayIconSize, kTrayIconSize, GDK_INTERP_BILINEAR);
|
||||
g_object_unref(pixbuf);
|
||||
pixbuf = scaled;
|
||||
}
|
||||
if (!gdk_pixbuf_get_has_alpha(pixbuf)) {
|
||||
GdkPixbuf* withAlpha = gdk_pixbuf_add_alpha(pixbuf, FALSE, 0, 0, 0);
|
||||
g_object_unref(pixbuf);
|
||||
pixbuf = withAlpha;
|
||||
}
|
||||
width = gdk_pixbuf_get_width(pixbuf);
|
||||
height = gdk_pixbuf_get_height(pixbuf);
|
||||
const int stride = gdk_pixbuf_get_rowstride(pixbuf);
|
||||
const guchar* source = gdk_pixbuf_get_pixels(pixbuf);
|
||||
pixels.assign(static_cast<size_t>(width) * static_cast<size_t>(height) * 4, 0);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
const guchar* row = source + static_cast<size_t>(y) * static_cast<size_t>(stride);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
unsigned char* target =
|
||||
pixels.data() + (static_cast<size_t>(y) * static_cast<size_t>(width) + x) * 4;
|
||||
target[0] = row[x * 4 + 3]; // A
|
||||
target[1] = row[x * 4 + 0]; // R
|
||||
target[2] = row[x * 4 + 1]; // G
|
||||
target[3] = row[x * 4 + 2]; // B
|
||||
}
|
||||
}
|
||||
g_object_unref(pixbuf);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void start_tray(GtkApplication* app) {
|
||||
std::vector<unsigned char> pixels;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
if (!load_tray_icon(pixels, width, height)) {
|
||||
return;
|
||||
}
|
||||
tray.start(std::move(pixels), width, height, show_main_window,
|
||||
[app]() { g_application_quit(G_APPLICATION(app)); });
|
||||
}
|
||||
|
||||
static void build_ui(GtkApplication* app);
|
||||
static void open_options_dialog();
|
||||
|
||||
// Rebuilt from scratch when the language changes: the widgets keep the strings
|
||||
// they were made with, so switching means building them again. Runs as an idle,
|
||||
// because the caller is the dropdown that this destroys.
|
||||
static gboolean rebuild_ui_idle(gpointer /*userData*/) {
|
||||
if (mainWindow == nullptr) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
GtkApplication* app = gtk_window_get_application(GTK_WINDOW(mainWindow));
|
||||
// Both are transient windows: they go with the rebuild and come back if they
|
||||
// were open, so nothing on screen is left in the old language.
|
||||
const bool helpOpen = gkeymapper::help_window_open();
|
||||
const bool optionsOpen = optionsDialog.is_open();
|
||||
// Rows that were not saved yet are widget state, not file content; without
|
||||
// this the rebuilt window would silently show the last saved file again.
|
||||
const gkeymapper::MappingSet edits = gkeymapper::collect_mappings(rows);
|
||||
|
||||
gkeymapper::apply_gui_language(guiSettings.language);
|
||||
|
||||
// Destroying the last window would end the application, so it is held while
|
||||
// there is none.
|
||||
g_application_hold(G_APPLICATION(app));
|
||||
optionsDialog.close();
|
||||
gkeymapper::close_help_window();
|
||||
daemonLabel = nullptr; // dangling from here on, and a reply may still arrive
|
||||
gtk_window_destroy(GTK_WINDOW(mainWindow));
|
||||
mainWindow = nullptr;
|
||||
|
||||
build_ui(app);
|
||||
gkeymapper::apply_mappings(edits, rows);
|
||||
if (helpOpen) {
|
||||
gkeymapper::show_help_window(GTK_WINDOW(mainWindow));
|
||||
}
|
||||
if (optionsOpen) {
|
||||
open_options_dialog();
|
||||
}
|
||||
g_application_release(G_APPLICATION(app));
|
||||
|
||||
refresh_footer();
|
||||
// The panel mirrors the menu once, so the new labels have to be announced.
|
||||
tray.refresh_labels();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// Chosen in the options window. Only remembered here; the switch itself is what
|
||||
// the user sees next, and that is the rebuild.
|
||||
static void on_language_selected(const std::string& language) {
|
||||
if (language == guiSettings.language) {
|
||||
return;
|
||||
}
|
||||
guiSettings.language = language;
|
||||
gkeymapper::save_gui_settings(guiSettings);
|
||||
g_idle_add(rebuild_ui_idle, nullptr);
|
||||
}
|
||||
|
||||
static void open_options_dialog() {
|
||||
// None of the switches needs a restart: the tray one decides what closing
|
||||
// does next, autostart only writes or removes the session's entry, and the
|
||||
// language one rebuilds the windows right away.
|
||||
gkeymapper::OptionsState state;
|
||||
state.trayAvailable = tray.ready();
|
||||
state.autostart = gkeymapper::autostart_enabled();
|
||||
state.settings = guiSettings;
|
||||
state.view = daemonView;
|
||||
optionsDialog.show(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr, state,
|
||||
[](bool minimizeToTray) {
|
||||
guiSettings.minimizeToTray = minimizeToTray;
|
||||
gkeymapper::save_gui_settings(guiSettings);
|
||||
},
|
||||
[](bool autostart) {
|
||||
// Starting the window at login is a convenience; the G
|
||||
// keys work without it (the daemon is what has to run).
|
||||
if (!gkeymapper::set_autostart_enabled(autostart)) {
|
||||
g_printerr("gkeymapper: autostart entry not %s\n",
|
||||
autostart ? "written" : "removed");
|
||||
}
|
||||
},
|
||||
on_language_selected);
|
||||
}
|
||||
|
||||
static void action_help(GSimpleAction* /*action*/, GVariant* /*parameter*/,
|
||||
gpointer /*userData*/) {
|
||||
gkeymapper::show_help_window(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr);
|
||||
}
|
||||
|
||||
static void action_options(GSimpleAction* /*action*/, GVariant* /*parameter*/,
|
||||
gpointer /*userData*/) {
|
||||
open_options_dialog();
|
||||
}
|
||||
|
||||
void build_ui(GtkApplication* app) {
|
||||
GtkWidget* window = gtk_application_window_new(app);
|
||||
gtk_window_set_title(GTK_WINDOW(window), _("gKeyMapper – Logitech G910"));
|
||||
@@ -363,10 +594,21 @@ void build_ui(GtkApplication* app) {
|
||||
g_menu_append(menu, _("Export configuration..."), "app.export-config");
|
||||
g_menu_append(menu, _("Import configuration..."), "app.import-config");
|
||||
|
||||
GMenu* optionsSection = g_menu_new();
|
||||
g_menu_append(optionsSection, _("Options"), "app.options");
|
||||
g_menu_append_section(menu, NULL, G_MENU_MODEL(optionsSection));
|
||||
|
||||
GMenu* helpSection = g_menu_new();
|
||||
g_menu_append(helpSection, _("Help"), "app.help");
|
||||
g_menu_append(helpSection, _("About"), "app.about");
|
||||
g_menu_append_section(menu, NULL, G_MENU_MODEL(helpSection));
|
||||
|
||||
// While the window is hidden to the tray the menu is the second way out, so
|
||||
// Quit is there even though closing the window usually does the same.
|
||||
GMenu* quitSection = g_menu_new();
|
||||
g_menu_append(quitSection, _("Quit"), "app.quit");
|
||||
g_menu_append_section(menu, NULL, G_MENU_MODEL(quitSection));
|
||||
|
||||
GtkWidget* menuButton = gtk_menu_button_new();
|
||||
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(menuButton), "open-menu-symbolic");
|
||||
gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(menuButton), G_MENU_MODEL(menu));
|
||||
@@ -492,9 +734,11 @@ void build_ui(GtkApplication* app) {
|
||||
gtk_label_set_ellipsize(GTK_LABEL(daemonLabel), PANGO_ELLIPSIZE_END);
|
||||
gtk_box_append(GTK_BOX(footer), daemonLabel);
|
||||
|
||||
mainWindow = window;
|
||||
// Closing is a request, not the end: the handler decides whether the window
|
||||
// hides to the tray or the app quits with it.
|
||||
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), nullptr);
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
|
||||
daemonClient.init(on_daemon_status, on_daemon_key);
|
||||
}
|
||||
|
||||
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
|
||||
@@ -519,11 +763,34 @@ static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
|
||||
g_signal_connect(aboutAction, "activate", G_CALLBACK(action_about), app);
|
||||
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(aboutAction));
|
||||
|
||||
GSimpleAction* helpAction = g_simple_action_new("help", NULL);
|
||||
g_signal_connect(helpAction, "activate", G_CALLBACK(action_help), app);
|
||||
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(helpAction));
|
||||
|
||||
GSimpleAction* optionsAction = g_simple_action_new("options", NULL);
|
||||
g_signal_connect(optionsAction, "activate", G_CALLBACK(action_options), app);
|
||||
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(optionsAction));
|
||||
|
||||
GSimpleAction* quitAction = g_simple_action_new("quit", NULL);
|
||||
g_signal_connect(quitAction, "activate", G_CALLBACK(action_quit), app);
|
||||
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(quitAction));
|
||||
|
||||
const char* helpAccels[] = {"F1", NULL};
|
||||
gtk_application_set_accels_for_action(app, "app.help", helpAccels);
|
||||
const char* quitAccels[] = {"<Control>q", NULL};
|
||||
gtk_application_set_accels_for_action(app, "app.quit", quitAccels);
|
||||
|
||||
build_ui(app);
|
||||
|
||||
// One-time, and deliberately not part of build_ui(): the window is rebuilt
|
||||
// when the language changes, while the tray, its poll and the daemon client
|
||||
// keep running as they are.
|
||||
start_tray(app);
|
||||
// Four checks per second: one cheap call into the backend each, quick enough
|
||||
// that a minimized window does not linger in the task list. It does nothing
|
||||
// while the tray option is off.
|
||||
g_timeout_add(250, on_minimize_watch, nullptr);
|
||||
daemonClient.init(on_daemon_status, on_daemon_key);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
@@ -538,6 +805,14 @@ int main(int argc, char** argv) {
|
||||
bind_textdomain_codeset(PACKAGE, "UTF-8");
|
||||
textdomain(PACKAGE);
|
||||
|
||||
// Window preferences (the tray option, the language) live in their own file
|
||||
// next to the mappings, so a mapping file stays a mapping file.
|
||||
guiSettings = gkeymapper::load_gui_settings();
|
||||
|
||||
// A saved language overrides the session's, and it has to be in place before
|
||||
// the first translated string is built.
|
||||
gkeymapper::apply_gui_language(guiSettings.language);
|
||||
|
||||
// One GUI only, and it is the one that was just started: close the instance
|
||||
// that already owns the app id (an older build, or the same one) before
|
||||
// becoming the primary instance. Without this the new process just hands over
|
||||
|
||||
Reference in New Issue
Block a user