2026-10-16 22:11:52 +02:00
// gKeyMapper - GTK4 GUI for remapping the G keys (G1-G9) of the Logitech G910
// GUI only for now. Mappings are stored as a simple text file.
# include <gtk/gtk.h>
# include <algorithm>
2026-10-17 02:42:09 +02:00
# include <cctype>
2026-10-16 22:11:52 +02:00
# include <fstream>
# include <iostream>
# include <locale.h>
# include <libintl.h>
2026-10-17 02:42:09 +02:00
# include <set>
2026-10-16 22:11:52 +02:00
# include <string>
# include <vector>
# ifndef PACKAGE
# define PACKAGE "gkeymapper"
# endif
# ifndef LOCALEDIR
# define LOCALEDIR " / usr / local / share / locale"
# endif
# define _(s) gettext(s)
# define N_(s) (s)
namespace {
constexpr int NUM_KEYS = 9 ;
struct KeyRow {
int index ; // 0..8 -> G1..G9
GtkWidget * keyLabel = nullptr ;
GtkWidget * typeDrop = nullptr ;
GtkWidget * targetEntry = nullptr ;
GtkWidget * captureButton = nullptr ;
} ;
std : : vector < KeyRow > rows ;
static void on_pick_file_response ( GtkDialog * dialog , int response , gpointer userData ) {
if ( response = = GTK_RESPONSE_ACCEPT ) {
KeyRow * row = static_cast < KeyRow * > ( userData ) ;
GtkFileChooser * chooser = GTK_FILE_CHOOSER ( dialog ) ;
g_autoptr ( GFile ) file = gtk_file_chooser_get_file ( chooser ) ;
char * path = g_file_get_path ( file ) ;
if ( path ) {
gtk_editable_set_text ( GTK_EDITABLE ( row - > targetEntry ) , path ) ;
g_free ( path ) ;
}
}
gtk_window_destroy ( GTK_WINDOW ( dialog ) ) ;
}
static void pick_command_file ( KeyRow * row , GtkWindow * parent ) {
GtkWidget * dialog = gtk_file_chooser_dialog_new ( _ ( " Choose file " ) ,
parent ,
GTK_FILE_CHOOSER_ACTION_OPEN ,
_ ( " _Cancel " ) , GTK_RESPONSE_CANCEL ,
_ ( " _Open " ) , GTK_RESPONSE_ACCEPT ,
NULL ) ;
g_signal_connect ( dialog , " response " , G_CALLBACK ( on_pick_file_response ) , row ) ;
gtk_window_present ( GTK_WINDOW ( dialog ) ) ;
}
2026-10-17 02:42:09 +02:00
struct CaptureSession {
KeyRow * row = nullptr ;
GtkWidget * displayLabel = nullptr ;
std : : vector < std : : string > steps ;
std : : set < guint > heldKeys ; // non-modifier keyvals currently held (ignores auto-repeat)
} ;
static bool is_modifier_keyval ( guint keyval ) {
switch ( keyval ) {
case GDK_KEY_Control_L :
case GDK_KEY_Control_R :
case GDK_KEY_Alt_L :
case GDK_KEY_Alt_R :
case GDK_KEY_Shift_L :
case GDK_KEY_Shift_R :
case GDK_KEY_Super_L :
case GDK_KEY_Super_R :
case GDK_KEY_Meta_L :
case GDK_KEY_Meta_R :
case GDK_KEY_ISO_Level3_Shift : // AltGr
return true ;
default :
return false ;
}
}
// Format one step like the reference project: modifiers joined with '+',
// e.g. "ctrl+shift+t". Multiple steps are joined with ',' ("ctrl+c,ctrl+v").
static std : : string format_capture_step ( GdkModifierType state , guint keyval ) {
std : : string step ;
if ( state & GDK_CONTROL_MASK ) {
step + = " ctrl+ " ;
}
if ( state & GDK_ALT_MASK ) {
step + = " alt+ " ;
}
if ( state & GDK_SHIFT_MASK ) {
step + = " shift+ " ;
}
if ( state & ( GDK_SUPER_MASK | GDK_META_MASK ) ) {
step + = " super+ " ;
}
const char * name = gdk_keyval_name ( keyval ) ;
std : : string key = name ? name : " ? " ;
std : : transform ( key . begin ( ) , key . end ( ) , key . begin ( ) ,
[ ] ( unsigned char c ) { return std : : tolower ( c ) ; } ) ;
step + = key ;
return step ;
}
static std : : string join_capture_steps ( const std : : vector < std : : string > & steps ) {
std : : string out ;
for ( size_t i = 0 ; i < steps . size ( ) ; + + i ) {
if ( i > 0 ) {
out + = " , " ;
}
out + = steps [ i ] ;
}
return out ;
}
static void update_capture_display ( CaptureSession * session ) {
std : : string text = join_capture_steps ( session - > steps ) ;
if ( text . empty ( ) ) {
text = _ ( " No keys recorded yet " ) ;
}
gtk_label_set_text ( GTK_LABEL ( session - > displayLabel ) , text . c_str ( ) ) ;
}
static gboolean on_capture_key_pressed ( GtkEventControllerKey * /*controller*/ ,
guint keyval , guint /*keycode*/ ,
GdkModifierType state , gpointer userData ) {
auto * session = static_cast < CaptureSession * > ( userData ) ;
if ( is_modifier_keyval ( keyval ) ) {
return TRUE ; // modifiers are recorded as part of the next real key
}
if ( session - > heldKeys . count ( keyval ) ) {
return TRUE ; // ignore auto-repeat while the key is held down
}
session - > heldKeys . insert ( keyval ) ;
constexpr GdkModifierType kModMask = static_cast < GdkModifierType > (
GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK |
GDK_SUPER_MASK | GDK_META_MASK ) ;
session - > steps . push_back ( format_capture_step (
static_cast < GdkModifierType > ( state & kModMask ) , keyval ) ) ;
update_capture_display ( session ) ;
return TRUE ;
}
static gboolean on_capture_key_released ( GtkEventControllerKey * /*controller*/ ,
guint keyval , guint /*keycode*/ ,
GdkModifierType /*state*/ , gpointer userData ) {
auto * session = static_cast < CaptureSession * > ( userData ) ;
session - > heldKeys . erase ( keyval ) ;
return TRUE ;
}
static void on_capture_session_destroy ( GtkWidget * /*widget*/ , gpointer userData ) {
delete static_cast < CaptureSession * > ( userData ) ;
}
static void on_capture_done_clicked ( GtkButton * btn , gpointer userData ) {
auto * session = static_cast < CaptureSession * > ( userData ) ;
if ( ! session - > steps . empty ( ) ) {
gtk_editable_set_text ( GTK_EDITABLE ( session - > row - > targetEntry ) ,
join_capture_steps ( session - > steps ) . c_str ( ) ) ;
}
GtkWindow * window = GTK_WINDOW ( gtk_widget_get_root ( GTK_WIDGET ( btn ) ) ) ;
gtk_window_destroy ( window ) ; // triggers session cleanup via "destroy"
}
static void on_capture_cancel_clicked ( GtkButton * btn , gpointer /*userData*/ ) {
GtkWindow * window = GTK_WINDOW ( gtk_widget_get_root ( GTK_WIDGET ( btn ) ) ) ;
gtk_window_destroy ( window ) ; // triggers session cleanup via "destroy"
}
static void on_capture_clear_clicked ( GtkButton * /*btn*/ , gpointer userData ) {
auto * session = static_cast < CaptureSession * > ( userData ) ;
session - > steps . clear ( ) ;
session - > heldKeys . clear ( ) ;
update_capture_display ( session ) ;
}
static void open_key_capture_dialog ( KeyRow * row , GtkWindow * parent ) {
auto * session = new CaptureSession ( ) ;
session - > row = row ;
GtkWidget * dialog = gtk_window_new ( ) ;
gtk_window_set_title ( GTK_WINDOW ( dialog ) , _ ( " Capture key sequence " ) ) ;
gtk_window_set_transient_for ( GTK_WINDOW ( dialog ) , parent ) ;
gtk_window_set_modal ( GTK_WINDOW ( dialog ) , TRUE ) ;
gtk_window_set_resizable ( GTK_WINDOW ( dialog ) , FALSE ) ;
gtk_window_set_default_size ( GTK_WINDOW ( dialog ) , 540 , 300 ) ;
g_signal_connect ( dialog , " destroy " , G_CALLBACK ( on_capture_session_destroy ) , session ) ;
GtkWidget * outer = gtk_box_new ( GTK_ORIENTATION_VERTICAL , 16 ) ;
gtk_widget_set_margin_top ( outer , 20 ) ;
gtk_widget_set_margin_bottom ( outer , 20 ) ;
gtk_widget_set_margin_start ( outer , 20 ) ;
gtk_widget_set_margin_end ( outer , 20 ) ;
gtk_window_set_child ( GTK_WINDOW ( dialog ) , outer ) ;
GtkWidget * title = gtk_label_new ( nullptr ) ;
gtk_label_set_markup ( GTK_LABEL ( title ) ,
g_strdup_printf ( " <span size='large' weight='bold'>%s</span> " ,
_ ( " Capture key sequence " ) ) ) ;
gtk_label_set_xalign ( GTK_LABEL ( title ) , 0.0f ) ;
gtk_box_append ( GTK_BOX ( outer ) , title ) ;
GtkWidget * info = gtk_label_new ( _ ( " Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded too). Click Done to apply or Cancel to discard. " ) ) ;
gtk_label_set_wrap ( GTK_LABEL ( info ) , TRUE ) ;
gtk_label_set_xalign ( GTK_LABEL ( info ) , 0.0f ) ;
gtk_widget_add_css_class ( info , " dim-label " ) ;
gtk_box_append ( GTK_BOX ( outer ) , info ) ;
session - > displayLabel = gtk_label_new ( _ ( " No keys recorded yet " ) ) ;
gtk_label_set_xalign ( GTK_LABEL ( session - > displayLabel ) , 0.0f ) ;
gtk_label_set_ellipsize ( GTK_LABEL ( session - > displayLabel ) , PANGO_ELLIPSIZE_MIDDLE ) ;
gtk_widget_add_css_class ( session - > displayLabel , " monospace " ) ;
gtk_widget_set_margin_top ( session - > displayLabel , 12 ) ;
gtk_widget_set_margin_bottom ( session - > displayLabel , 12 ) ;
gtk_widget_set_margin_start ( session - > displayLabel , 12 ) ;
gtk_widget_set_margin_end ( session - > displayLabel , 12 ) ;
GtkWidget * frame = gtk_frame_new ( nullptr ) ;
gtk_frame_set_child ( GTK_FRAME ( frame ) , session - > displayLabel ) ;
gtk_widget_set_size_request ( frame , - 1 , 64 ) ;
gtk_widget_set_vexpand ( frame , TRUE ) ;
gtk_box_append ( GTK_BOX ( outer ) , frame ) ;
gtk_box_append ( GTK_BOX ( outer ) , gtk_separator_new ( GTK_ORIENTATION_HORIZONTAL ) ) ;
// Buttons: Clear on the left, Cancel/Done grouped on the right.
GtkWidget * buttonRow = gtk_box_new ( GTK_ORIENTATION_HORIZONTAL , 0 ) ;
gtk_box_append ( GTK_BOX ( outer ) , buttonRow ) ;
GtkWidget * clearBtn = gtk_button_new_with_label ( _ ( " Clear " ) ) ;
gtk_widget_set_size_request ( clearBtn , 110 , - 1 ) ;
g_signal_connect ( clearBtn , " clicked " , G_CALLBACK ( on_capture_clear_clicked ) , session ) ;
gtk_box_append ( GTK_BOX ( buttonRow ) , clearBtn ) ;
GtkWidget * spacer = gtk_box_new ( GTK_ORIENTATION_HORIZONTAL , 0 ) ;
gtk_widget_set_hexpand ( spacer , TRUE ) ;
gtk_box_append ( GTK_BOX ( buttonRow ) , spacer ) ;
GtkWidget * endBox = gtk_box_new ( GTK_ORIENTATION_HORIZONTAL , 8 ) ;
gtk_box_append ( GTK_BOX ( buttonRow ) , endBox ) ;
GtkWidget * cancelBtn = gtk_button_new_with_label ( _ ( " Cancel " ) ) ;
gtk_widget_set_size_request ( cancelBtn , 110 , - 1 ) ;
g_signal_connect ( cancelBtn , " clicked " , G_CALLBACK ( on_capture_cancel_clicked ) , nullptr ) ;
gtk_box_append ( GTK_BOX ( endBox ) , cancelBtn ) ;
GtkWidget * doneBtn = gtk_button_new_with_label ( _ ( " Done " ) ) ;
gtk_widget_set_size_request ( doneBtn , 110 , - 1 ) ;
g_signal_connect ( doneBtn , " clicked " , G_CALLBACK ( on_capture_done_clicked ) , session ) ;
gtk_box_append ( GTK_BOX ( endBox ) , doneBtn ) ;
// Key events go to the dialog; buttons must not steal Space/Return.
GtkEventController * keyCtrl = gtk_event_controller_key_new ( ) ;
g_signal_connect ( keyCtrl , " key-pressed " , G_CALLBACK ( on_capture_key_pressed ) , session ) ;
g_signal_connect ( keyCtrl , " key-released " , G_CALLBACK ( on_capture_key_released ) , session ) ;
gtk_widget_add_controller ( dialog , keyCtrl ) ;
gtk_widget_set_focusable ( clearBtn , FALSE ) ;
gtk_widget_set_focusable ( cancelBtn , FALSE ) ;
gtk_widget_set_focusable ( doneBtn , FALSE ) ;
gtk_window_present ( GTK_WINDOW ( dialog ) ) ;
}
2026-10-16 22:11:52 +02:00
void on_capture_clicked ( GtkButton * btn , gpointer userData ) {
auto * row = static_cast < KeyRow * > ( userData ) ;
guint sel = gtk_drop_down_get_selected ( GTK_DROP_DOWN ( row - > typeDrop ) ) ;
2026-10-17 02:42:09 +02:00
GtkWindow * window = GTK_WINDOW ( gtk_widget_get_root ( GTK_WIDGET ( btn ) ) ) ;
2026-10-16 22:11:52 +02:00
if ( sel = = 1 ) {
pick_command_file ( row , window ) ;
return ;
}
if ( sel = = 0 ) {
2026-10-17 02:42:09 +02:00
open_key_capture_dialog ( row , window ) ;
2026-10-16 22:11:52 +02:00
}
}
static void update_row_state ( KeyRow * row ) {
guint sel = gtk_drop_down_get_selected ( GTK_DROP_DOWN ( row - > typeDrop ) ) ;
gboolean enabled = ( sel ! = 2 ) ;
gtk_widget_set_sensitive ( row - > targetEntry , enabled ) ;
gtk_widget_set_sensitive ( row - > captureButton , enabled ) ;
if ( sel = = 1 ) {
gtk_button_set_label ( GTK_BUTTON ( row - > captureButton ) , _ ( " Choose file... " ) ) ;
2026-10-17 02:42:09 +02:00
gtk_entry_set_placeholder_text ( GTK_ENTRY ( row - > targetEntry ) ,
_ ( " e.g. /usr/bin/firefox or custom command " ) ) ;
2026-10-16 22:11:52 +02:00
} else {
gtk_button_set_label ( GTK_BUTTON ( row - > captureButton ) , _ ( " Capture " ) ) ;
2026-10-17 02:42:09 +02:00
gtk_entry_set_placeholder_text ( GTK_ENTRY ( row - > targetEntry ) ,
_ ( " e.g. ctrl+c, ctrl+v " ) ) ;
2026-10-16 22:11:52 +02:00
}
gtk_widget_set_size_request ( row - > captureButton , 180 , - 1 ) ;
}
static void on_type_changed ( GObject * /*object*/ , GParamSpec * /*pspec*/ , gpointer userData ) {
update_row_state ( static_cast < KeyRow * > ( userData ) ) ;
}
static bool write_mappings_to_file ( const std : : string & path ) {
std : : ofstream out ( path ) ;
if ( ! out ) {
return false ;
}
out < < " # gKeyMapper mappings (G1..G9) \n " ;
for ( const auto & row : rows ) {
const char * type = " disabled " ;
switch ( gtk_drop_down_get_selected ( GTK_DROP_DOWN ( row . typeDrop ) ) ) {
case 0 : type = " key " ; break ;
case 1 : type = " command " ; break ;
default : type = " disabled " ; break ;
}
const char * target = gtk_editable_get_text ( GTK_EDITABLE ( row . targetEntry ) ) ;
out < < " G " < < ( row . index + 1 ) < < " = " < < type < < " : " < < ( target ? target : " " ) < < " \n " ;
}
out . close ( ) ;
return true ;
}
void on_save_clicked ( GtkButton * /*btn*/ , gpointer /*userData*/ ) {
const char * home = g_get_home_dir ( ) ;
std : : string dir = std : : string ( home ) + " /.config/gKeyMapper " ;
g_mkdir_with_parents ( dir . c_str ( ) , 0755 ) ;
std : : string path = dir + " /mappings.conf " ;
if ( ! write_mappings_to_file ( path ) ) {
g_printerr ( _ ( " Could not save %s. \n " ) , path . c_str ( ) ) ;
return ;
}
std : : cout < < _ ( " Mappings saved to " ) < < path < < " \n " ;
}
static void import_mappings_from_file ( const std : : string & path ) {
std : : ifstream in ( path ) ;
if ( ! in ) {
g_printerr ( _ ( " Could not open %s. \n " ) , path . c_str ( ) ) ;
return ;
}
std : : string line ;
while ( std : : getline ( in , line ) ) {
if ( line . empty ( ) | | line [ 0 ] = = ' # ' ) {
continue ;
}
auto eq = line . find ( ' = ' ) ;
if ( eq = = std : : string : : npos ) {
continue ;
}
std : : string key = line . substr ( 0 , eq ) ;
if ( key . size ( ) < 2 | | key [ 0 ] ! = ' G ' ) {
continue ;
}
int n = 0 ;
try {
n = std : : stoi ( key . substr ( 1 ) ) - 1 ;
} catch ( . . . ) {
continue ;
}
if ( n < 0 | | n > = ( int ) rows . size ( ) ) {
continue ;
}
std : : string rest = line . substr ( eq + 1 ) ;
auto col = rest . find ( ' : ' ) ;
std : : string typeToken = ( col = = std : : string : : npos ) ? rest : rest . substr ( 0 , col ) ;
std : : string target = ( col = = std : : string : : npos ) ? std : : string ( ) : rest . substr ( col + 1 ) ;
guint idx = 2 ;
if ( typeToken = = " key " ) {
idx = 0 ;
} else if ( typeToken = = " command " ) {
idx = 1 ;
} else if ( typeToken = = " disabled " ) {
idx = 2 ;
}
gtk_drop_down_set_selected ( GTK_DROP_DOWN ( rows [ n ] . typeDrop ) , idx ) ;
gtk_editable_set_text ( GTK_EDITABLE ( rows [ n ] . targetEntry ) , target . c_str ( ) ) ;
}
}
static void on_export_response ( GtkDialog * dialog , int response , gpointer /*userData*/ ) {
if ( response = = GTK_RESPONSE_ACCEPT ) {
GtkFileChooser * chooser = GTK_FILE_CHOOSER ( dialog ) ;
g_autoptr ( GFile ) file = gtk_file_chooser_get_file ( chooser ) ;
char * path = g_file_get_path ( file ) ;
if ( path ) {
if ( ! write_mappings_to_file ( path ) ) {
g_printerr ( _ ( " Could not export mappings. \n " ) ) ;
}
g_free ( path ) ;
}
}
gtk_window_destroy ( GTK_WINDOW ( dialog ) ) ;
}
static void on_import_response ( GtkDialog * dialog , int response , gpointer /*userData*/ ) {
if ( response = = GTK_RESPONSE_ACCEPT ) {
GtkFileChooser * chooser = GTK_FILE_CHOOSER ( dialog ) ;
g_autoptr ( GFile ) file = gtk_file_chooser_get_file ( chooser ) ;
char * path = g_file_get_path ( file ) ;
if ( path ) {
import_mappings_from_file ( path ) ;
g_free ( path ) ;
}
}
gtk_window_destroy ( GTK_WINDOW ( dialog ) ) ;
}
static std : : string find_asset ( const std : : string & name ) ;
static void show_about_dialog ( GtkWindow * parent ) {
GtkWidget * dialog = gtk_about_dialog_new ( ) ;
gtk_about_dialog_set_program_name ( GTK_ABOUT_DIALOG ( dialog ) , _ ( " gKeyMapper " ) ) ;
gtk_about_dialog_set_version ( GTK_ABOUT_DIALOG ( dialog ) , " 0.1.0 " ) ;
gtk_about_dialog_set_comments ( GTK_ABOUT_DIALOG ( dialog ) ,
_ ( " G-Key mapper for the Logitech G910 (GTK4). " ) ) ;
gtk_about_dialog_set_website ( GTK_ABOUT_DIALOG ( dialog ) , " https://rager.dev " ) ;
2026-10-17 02:42:09 +02:00
gtk_about_dialog_set_website_label ( GTK_ABOUT_DIALOG ( dialog ) , " Thomas Rager - rager.dev " ) ;
2026-10-16 22:11:52 +02:00
std : : string iconPath = find_asset ( " icon.png " ) ;
GError * error = nullptr ;
GdkTexture * texture = gdk_texture_new_from_filename ( iconPath . c_str ( ) , & error ) ;
if ( texture ) {
gtk_about_dialog_set_logo ( GTK_ABOUT_DIALOG ( dialog ) , GDK_PAINTABLE ( texture ) ) ;
g_object_unref ( texture ) ;
} else if ( error ) {
g_printerr ( " Could not load icon: %s \n " , error - > message ) ;
g_error_free ( error ) ;
}
gtk_window_set_transient_for ( GTK_WINDOW ( dialog ) , parent ) ;
g_signal_connect ( dialog , " response " , G_CALLBACK ( gtk_window_destroy ) , NULL ) ;
gtk_window_present ( GTK_WINDOW ( dialog ) ) ;
}
static void do_export ( GtkWindow * parent ) {
GtkWidget * dialog = gtk_file_chooser_dialog_new ( _ ( " Export configuration " ) ,
parent ,
GTK_FILE_CHOOSER_ACTION_SAVE ,
_ ( " _Cancel " ) , GTK_RESPONSE_CANCEL ,
_ ( " _Save " ) , GTK_RESPONSE_ACCEPT ,
NULL ) ;
gtk_file_chooser_set_current_name ( GTK_FILE_CHOOSER ( dialog ) , " gkeymapper.conf " ) ;
g_signal_connect ( dialog , " response " , G_CALLBACK ( on_export_response ) , NULL ) ;
gtk_window_present ( GTK_WINDOW ( dialog ) ) ;
}
static void do_import ( GtkWindow * parent ) {
GtkWidget * dialog = gtk_file_chooser_dialog_new ( _ ( " Import configuration " ) ,
parent ,
GTK_FILE_CHOOSER_ACTION_OPEN ,
_ ( " _Cancel " ) , GTK_RESPONSE_CANCEL ,
_ ( " _Open " ) , GTK_RESPONSE_ACCEPT ,
NULL ) ;
g_signal_connect ( dialog , " response " , G_CALLBACK ( on_import_response ) , NULL ) ;
gtk_window_present ( GTK_WINDOW ( dialog ) ) ;
}
static void action_export ( GSimpleAction * /*action*/ , GVariant * /*parameter*/ , gpointer userData ) {
GtkApplication * app = GTK_APPLICATION ( userData ) ;
GtkWindow * window = gtk_application_get_active_window ( app ) ;
if ( window ) {
do_export ( window ) ;
}
}
static void action_import ( GSimpleAction * /*action*/ , GVariant * /*parameter*/ , gpointer userData ) {
GtkApplication * app = GTK_APPLICATION ( userData ) ;
GtkWindow * window = gtk_application_get_active_window ( app ) ;
if ( window ) {
do_import ( window ) ;
}
}
static void action_about ( GSimpleAction * /*action*/ , GVariant * /*parameter*/ , gpointer userData ) {
GtkApplication * app = GTK_APPLICATION ( userData ) ;
GtkWindow * window = gtk_application_get_active_window ( app ) ;
if ( window ) {
show_about_dialog ( window ) ;
}
}
static std : : string find_asset ( const std : : string & name ) {
const char * env = g_getenv ( " GKEYMAPPER_ASSETS_DIR " ) ;
if ( env & & * env ) {
std : : string path = std : : string ( env ) + " / " + name ;
if ( g_file_test ( path . c_str ( ) , G_FILE_TEST_EXISTS ) ) {
return path ;
}
}
std : : string local = std : : string ( " ./assets/ " ) + name ;
if ( g_file_test ( local . c_str ( ) , G_FILE_TEST_EXISTS ) ) {
return local ;
}
# ifndef ASSETS_DIR
# define ASSETS_DIR " / usr / local / share / gkeymapper / assets"
# endif
std : : string installed = std : : string ( ASSETS_DIR ) + " / " + name ;
if ( g_file_test ( installed . c_str ( ) , G_FILE_TEST_EXISTS ) ) {
return installed ;
}
return local ;
}
void build_ui ( GtkApplication * app ) {
GtkWidget * window = gtk_application_window_new ( app ) ;
gtk_window_set_title ( GTK_WINDOW ( window ) , _ ( " gKeyMapper – Logitech G910 " ) ) ;
gtk_window_set_resizable ( GTK_WINDOW ( window ) , FALSE ) ;
GtkWidget * headerBar = gtk_header_bar_new ( ) ;
gtk_window_set_titlebar ( GTK_WINDOW ( window ) , headerBar ) ;
gtk_window_set_icon_name ( GTK_WINDOW ( window ) , " gkeymapper " ) ;
GtkWidget * titleBox = gtk_box_new ( GTK_ORIENTATION_HORIZONTAL , 8 ) ;
GtkWidget * iconImage = gtk_image_new_from_file ( find_asset ( " icon.png " ) . c_str ( ) ) ;
gtk_image_set_pixel_size ( GTK_IMAGE ( iconImage ) , 24 ) ;
gtk_box_append ( GTK_BOX ( titleBox ) , iconImage ) ;
gtk_box_append ( GTK_BOX ( titleBox ) , gtk_label_new ( _ ( " gKeyMapper " ) ) ) ;
gtk_header_bar_set_title_widget ( GTK_HEADER_BAR ( headerBar ) , titleBox ) ;
GMenu * menu = g_menu_new ( ) ;
g_menu_append ( menu , _ ( " Export configuration... " ) , " app.export-config " ) ;
g_menu_append ( menu , _ ( " Import configuration... " ) , " app.import-config " ) ;
GMenu * helpSection = g_menu_new ( ) ;
g_menu_append ( helpSection , _ ( " About " ) , " app.about " ) ;
g_menu_append_section ( menu , NULL , G_MENU_MODEL ( helpSection ) ) ;
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 ) ) ;
gtk_header_bar_pack_end ( GTK_HEADER_BAR ( headerBar ) , menuButton ) ;
GtkWidget * outer = gtk_box_new ( GTK_ORIENTATION_VERTICAL , 0 ) ;
gtk_window_set_child ( GTK_WINDOW ( window ) , outer ) ;
// Title / subtitle
GtkWidget * headerBox = gtk_box_new ( GTK_ORIENTATION_VERTICAL , 2 ) ;
gtk_widget_set_margin_top ( headerBox , 16 ) ;
gtk_widget_set_margin_bottom ( headerBox , 8 ) ;
gtk_widget_set_margin_start ( headerBox , 16 ) ;
gtk_widget_set_margin_end ( headerBox , 16 ) ;
gtk_box_append ( GTK_BOX ( outer ) , headerBox ) ;
GtkWidget * title = gtk_label_new ( nullptr ) ;
gtk_label_set_markup ( GTK_LABEL ( title ) , " <span size='x-large' weight='bold'>G-Key Mapper</span> " ) ;
gtk_label_set_xalign ( GTK_LABEL ( title ) , 0.0f ) ;
gtk_box_append ( GTK_BOX ( headerBox ) , title ) ;
GtkWidget * subtitle = gtk_label_new ( _ ( " G1 to G9 of the Logitech G910 remapped to keys or commands " ) ) ;
gtk_label_set_xalign ( GTK_LABEL ( subtitle ) , 0.0f ) ;
gtk_widget_add_css_class ( subtitle , " dim-label " ) ;
gtk_box_append ( GTK_BOX ( headerBox ) , subtitle ) ;
// Table with all nine G keys (no scrolling needed, everything stays visible)
GtkWidget * grid = gtk_grid_new ( ) ;
gtk_grid_set_row_spacing ( GTK_GRID ( grid ) , 6 ) ;
gtk_grid_set_column_spacing ( GTK_GRID ( grid ) , 8 ) ;
gtk_widget_set_margin_top ( grid , 8 ) ;
gtk_widget_set_margin_bottom ( grid , 4 ) ;
gtk_widget_set_margin_start ( grid , 16 ) ;
gtk_widget_set_margin_end ( grid , 16 ) ;
gtk_widget_set_hexpand ( grid , TRUE ) ;
gtk_box_append ( GTK_BOX ( outer ) , grid ) ;
// Column headers
const char * headers [ ] = { _ ( " G-Key " ) , _ ( " Type " ) , _ ( " Target (Key/Command) " ) , _ ( " Action " ) } ;
for ( int c = 0 ; c < 4 ; + + c ) {
GtkWidget * h = gtk_label_new ( headers [ c ] ) ;
gtk_label_set_markup ( GTK_LABEL ( h ) , g_strdup_printf ( " <b>%s</b> " , headers [ c ] ) ) ;
gtk_widget_add_css_class ( h , " dim-label " ) ;
gtk_grid_attach ( GTK_GRID ( grid ) , h , c , 0 , 1 , 1 ) ;
}
const char * typeOptions [ ] = { _ ( " Key " ) , _ ( " Command " ) , _ ( " Disabled " ) , nullptr } ;
rows . resize ( NUM_KEYS ) ;
for ( int i = 0 ; i < NUM_KEYS ; + + i ) {
rows [ i ] . index = i ;
// G key label
GtkWidget * keyLabel = gtk_label_new ( g_strdup_printf ( " G%d " , i + 1 ) ) ;
gtk_label_set_markup ( GTK_LABEL ( keyLabel ) , g_strdup_printf ( " <b>G%d</b> " , i + 1 ) ) ;
gtk_grid_attach ( GTK_GRID ( grid ) , keyLabel , 0 , i + 1 , 1 , 1 ) ;
rows [ i ] . keyLabel = keyLabel ;
// Type dropdown (fixed width so rows don't jump between languages)
GtkWidget * drop = gtk_drop_down_new_from_strings ( typeOptions ) ;
gtk_drop_down_set_selected ( GTK_DROP_DOWN ( drop ) , 0 ) ;
gtk_widget_set_size_request ( drop , 150 , - 1 ) ;
gtk_grid_attach ( GTK_GRID ( grid ) , drop , 1 , i + 1 , 1 , 1 ) ;
rows [ i ] . typeDrop = drop ;
g_signal_connect ( drop , " notify::selected " , G_CALLBACK ( on_type_changed ) , & rows [ i ] ) ;
// Target input (wide: commands and file paths need room)
GtkWidget * entry = gtk_entry_new ( ) ;
2026-10-17 02:42:09 +02:00
gtk_entry_set_placeholder_text ( GTK_ENTRY ( entry ) , _ ( " e.g. ctrl+c, ctrl+v " ) ) ;
2026-10-16 22:11:52 +02:00
gtk_widget_set_size_request ( entry , 440 , - 1 ) ;
gtk_widget_set_hexpand ( entry , TRUE ) ;
gtk_grid_attach ( GTK_GRID ( grid ) , entry , 2 , i + 1 , 1 , 1 ) ;
rows [ i ] . targetEntry = entry ;
// Capture button
GtkWidget * capture = gtk_button_new_with_label ( _ ( " Capture " ) ) ;
gtk_widget_set_size_request ( capture , 180 , - 1 ) ;
g_signal_connect ( capture , " clicked " , G_CALLBACK ( on_capture_clicked ) , & rows [ i ] ) ;
gtk_grid_attach ( GTK_GRID ( grid ) , capture , 3 , i + 1 , 1 , 1 ) ;
rows [ i ] . captureButton = capture ;
update_row_state ( & rows [ i ] ) ;
}
// Footer with save button
GtkWidget * footer = gtk_box_new ( GTK_ORIENTATION_HORIZONTAL , 8 ) ;
gtk_widget_set_margin_top ( footer , 8 ) ;
gtk_widget_set_margin_bottom ( footer , 16 ) ;
gtk_widget_set_margin_start ( footer , 16 ) ;
gtk_widget_set_margin_end ( footer , 16 ) ;
gtk_box_append ( GTK_BOX ( outer ) , footer ) ;
GtkWidget * saveBtn = gtk_button_new_with_label ( _ ( " Save mappings " ) ) ;
2026-10-17 02:42:09 +02:00
// No "suggested-action" class on purpose: it gets a strong accent color
// (e.g. Mint-Y green) that clashes with the rest of the layout. The plain
// button follows the system theme like everything else.
2026-10-16 22:11:52 +02:00
g_signal_connect ( saveBtn , " clicked " , G_CALLBACK ( on_save_clicked ) , nullptr ) ;
gtk_box_append ( GTK_BOX ( footer ) , saveBtn ) ;
GtkWidget * hint = gtk_label_new ( _ ( " Saved to ~/.config/gKeyMapper/mappings.conf " ) ) ;
gtk_widget_add_css_class ( hint , " dim-label " ) ;
gtk_widget_set_halign ( hint , GTK_ALIGN_END ) ;
gtk_widget_set_hexpand ( hint , TRUE ) ;
gtk_box_append ( GTK_BOX ( footer ) , hint ) ;
gtk_window_present ( GTK_WINDOW ( window ) ) ;
}
static void on_activate ( GtkApplication * app , gpointer /*user_data*/ ) {
GSimpleAction * exportAction = g_simple_action_new ( " export-config " , NULL ) ;
g_signal_connect ( exportAction , " activate " , G_CALLBACK ( action_export ) , app ) ;
g_action_map_add_action ( G_ACTION_MAP ( app ) , G_ACTION ( exportAction ) ) ;
GSimpleAction * importAction = g_simple_action_new ( " import-config " , NULL ) ;
g_signal_connect ( importAction , " activate " , G_CALLBACK ( action_import ) , app ) ;
g_action_map_add_action ( G_ACTION_MAP ( app ) , G_ACTION ( importAction ) ) ;
GSimpleAction * aboutAction = g_simple_action_new ( " about " , NULL ) ;
g_signal_connect ( aboutAction , " activate " , G_CALLBACK ( action_about ) , app ) ;
g_action_map_add_action ( G_ACTION_MAP ( app ) , G_ACTION ( aboutAction ) ) ;
build_ui ( app ) ;
}
} // namespace
int main ( int argc , char * * argv ) {
setlocale ( LC_ALL , " " ) ;
const char * localedir = g_getenv ( " GKEYMAPPER_LOCALEDIR " ) ;
if ( ! localedir | | ! * localedir ) {
localedir = LOCALEDIR ;
}
bindtextdomain ( PACKAGE , localedir ) ;
bind_textdomain_codeset ( PACKAGE , " UTF-8 " ) ;
textdomain ( PACKAGE ) ;
GtkApplication * app = gtk_application_new ( " com.example.gkeymapper " ,
G_APPLICATION_FLAGS_NONE ) ;
g_signal_connect ( app , " activate " , G_CALLBACK ( on_activate ) , nullptr ) ;
int status = g_application_run ( G_APPLICATION ( app ) , argc , argv ) ;
g_object_unref ( app ) ;
return status ;
}