From 673af1ae43be3afe4f57a099862cd2074edd7b8f Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 18 Oct 2026 20:26:22 +0200 Subject: [PATCH] Fix Dropdown issue --- AGENTS.md | 136 ++++++++-- Makefile | 25 +- README.md | 37 ++- docs/daemon-plan.md | 6 + po/README.md | 178 +++++++++++++ po/de.po | 317 +++++++++++----------- po/en.po | 304 ++++++++++----------- po/es.po | 313 +++++++++++----------- po/fr.po | 326 +++++++++++------------ po/gkeymapper.pot | 580 +++++++++++++++++++++-------------------- scripts/i18n.sh | 535 +++++++++++++++++++++++++++++++++++++ shared/ipc_ids.h | 10 + src/autostart.cpp | 6 +- src/autostart.h | 7 + src/help.cpp | 8 +- src/main.cpp | 117 +++++++-- src/mappings.cpp | 8 + src/mappings.h | 7 + src/optionsdialog.cpp | 26 +- src/popovers.cpp | 144 ++++++++++ src/popovers.h | 18 ++ src/singleinstance.cpp | 68 ++++- src/singleinstance.h | 18 +- 23 files changed, 2219 insertions(+), 975 deletions(-) create mode 100644 po/README.md create mode 100755 scripts/i18n.sh create mode 100644 src/popovers.cpp create mode 100644 src/popovers.h diff --git a/AGENTS.md b/AGENTS.md index 7802c63..6ed7bc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,12 +26,28 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` ## i18n — easy to get wrong - English is the source language (the `msgid`) **and** also has an explicit - identity catalog `po/en.po`. Languages: `de`, `en`, `es`, `fr`. -- `make translations` does **not** regenerate the `.pot`. When you add or change - a UI string, edit `po/gkeymapper.pot` and `po/*.po` by hand, then run - `make translations`; validate with `msgfmt --check -o /dev/null po/.po`. -- Never edit the generated `.mo` files under `locale/` (gitignored). Delete any - `po/*.po~` backups before committing. + identity catalog `po/en.po`. Languages: `de`, `en`, `es`, `fr`. The workflow, + the rules behind it and adding a language are documented in `po/README.md`; the + commands are `scripts/i18n.sh` (`make pot`, `make i18n-check`, + `make i18n-status`). +- `make translations` does **not** regenerate the `.pot` (a pot written by a build + is a pot nobody reviews). When you add or change a UI string: `make pot`, then + `scripts/i18n.sh update` to merge it into the catalogs, then fill in the msgstrs + in `po/*.po` — and in `po/en.po`, whose msgstr repeats the msgid. `_()` is + translated where it is used, `N_()` is only collected (the help paragraphs in + `src/help.cpp`); the script always passes both keywords to `xgettext`. +- The catalogs carry **no** `#: file:line` comments; the locations live in the pot + only (`update` merges with `msgmerge --no-location`) and + `scripts/i18n.sh todo ` shows them from there. `make i18n-check` fails + when the pot is behind the sources, when a catalog misses a message or a + translation, or when `po/*.po` and `kLanguageCodes[]` (`src/optionsdialog.cpp`) + disagree — a catalog nobody can select is dead weight. +- The daemon is **not** translated (no `setlocale`/`bindtextdomain`, only the GUI + sets gettext up in `main()`); `i18n-check` reports a `_()` marker under + `daemon/`. +- Never edit the generated `.mo` files under `locale/` (gitignored). A manual + `msgmerge --update` leaves `po/*.po~` backups — the script deletes them, by hand + delete them before committing. - All documentation and source comments are English. German appears only inside `po/de.po`. @@ -43,8 +59,8 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` app id, the version and the D-Bus names both sides use. The GUI side is `main.cpp` (window, actions) plus `keyrow.h`, `mappings`, `capturedialog`, `daemonclient`, `singleinstance`, `trayicon` (StatusNotifierItem + dbusmenu), - `settings` (`gui.conf`), `autostart` (the session's `.desktop` entry), `help` - and `optionsdialog`. + `settings` (`gui.conf`), `autostart` (the session's `.desktop` entry), `help`, + `optionsdialog` and `popovers` (the popup input grab, see below). - The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp` + the generated `build/gresources.c`, see Assets) **and** the daemon (`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`, @@ -120,9 +136,18 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` with `SIGTERM` after checking that the bus name really belongs to a `gkeymapper` process. Only then does the new process become the primary, because otherwise `GtkApplication` would hand over to the old process and its - window — possibly one from an older build — would stay on screen. Row edits - that were not saved are gone with the replaced window; there is no implicit - save. + window — possibly one from an older build — would stay on screen. +- The takeover also carries the rows over, so an edit that was typed but never + saved is not lost with the replaced window: before the quit, + `replace_running_instance()` asks for them (`Window.GetRows` at + `ipc::kWindowObjectPath`) and `build_ui()` starts the new window from that text + instead of the file. The answering side is `serve_rows_handover()` — called from + `on_activate` **after** `build_ui()`, since that is what fills the rows — with + `mappings_text(rows)`, i.e. the mappings file format, so both ends use + `src/mappings`. An instance that does not answer (an older build) leaves the new + window with the file; asking and serving are best effort and never fatal. The + file itself is untouched until the user presses `Save mappings` — the handover + carries row state, it does not save. - `on_activate` presents the window that is already there (`gtk_application_get_windows()`), it does not build a second one: a remote activation — an older build starting next to a new one — would otherwise add a @@ -168,9 +193,17 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` deleting it. The `Exec` line is the binary that is running now (`g_file_read_link("/proc/self/exe")`, quoted, falls back to the name from `PATH`), so an enabled switch starts the build the user was looking at, and the - `Comment` reuses the About text (no second sentence for translators). Only the - **window** is started; the daemon is a systemd user service and stays untouched - — the GUI is a convenience, the daemon is the part that has to run. + `Comment` reuses the About text (no second sentence for translators). That + `Exec` line also carries `--start-hidden` (`kStartHiddenArg`, taken off the + command line in `main()`), so the window is built but stays in the tray: a login + does not put it in front of the user, while a start by hand (no flag) always + shows it. Keeping it hidden is only safe while the panel really hosts the icon, + and only the panel can answer that, asynchronously — so + `on_start_hidden_timeout()` shows the window after `kStartHiddenGraceMs` if the + icon never turned up. A rebuild (language switch) comes up the way the window + was left: `build_ui()` takes the visibility as an argument. Only the **window** + is started; the daemon is a systemd user service and stays untouched — the GUI + is a convenience, the daemon is the part that has to run. - `Minimize to tray` hides the window on close (`"close-request"` handler) and on minimize. GTK4 has **no** minimize signal and `GdkToplevel` exposes no properties, so `gdk_toplevel_get_state()` is polled every 250 ms while the @@ -222,6 +255,30 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` `GtkScrolledWindow` (one collapsed the rows earlier). Keep the fixed widths (Type dropdown 150 px, target entry 440 px min, action button 180 px) so rows do not jump when the language changes. +- Both dropdowns (Type in the main window, Language in the options window) give + every entry a **trailing space**, added where the model is built — the msgids + stay the bare words. `GtkDropDown` appends the check mark of the selected entry + directly behind the text (`setup_item()` in `gtkdropdown.c`), and without that + space it touches the last letter. The selection is read back by index, never by + the string, so the space is harmless everywhere else. +- `src/popovers.{h,cpp}` takes the input grab off the popovers of a window (the + dropdowns' popup lists and the header bar menu) and closes them itself: on a + click in the window outside the popup, on Escape, and when the window loses the + focus. GTK 4.6 grabs the pointer for an open popover, an X11 grab swallows the + clicks of **every** other client, and this GTK does not close the popup on a + click outside the application either — with one dropdown open nothing else + could be clicked any more, not another program and not the desktop, and the + popup stayed open until Escape (measured with a plain GTK4 probe: the click on + the bare desktop never reached it and `_NET_ACTIVE_WINDOW` did not change; a + click inside the app's own window did close the popup). GTK3 replayed such a + click to the window under the pointer, GTK4 dropped that. `autohide` is what + installs the grab, so switching it off hands the clicks back to the desktop; + the dismissal is then done by hand, which is the rest of the module. It runs + once per toplevel window (`build_ui`, `OptionsDialog::show`), notices popovers + GTK creates later on the press that opens them, and leaves the press on the + popup itself alone — that one is an entry choice and must reach the entry + (recognised by its own surface, not by its position, because GTK reports it in + the popup's coordinates). - Because it is pure GTK4 it follows the system theme (Mint-Y ships a `gtk-4.0` theme). Do **not** add the `suggested-action` CSS class — Mint-Y tints it green and it clashes with the rest of the layout. @@ -415,7 +472,11 @@ replaced process exits `0` when it was closed through the D-Bus `quit` action, o instance from an older commit (`git worktree add /tmp/legacy `), start it and then start the current build next to it. Starting the GUI twice also covers the `activate` guard: without it the second start adds a second window to the -running process. +running process. The rows come along (see the takeover notes above), which is +checkable the same way: type into a row of the first instance without saving, +start the second one and read its rows back (next bullet) — they show the edit, +while the mappings file still has the old content. An instance from an older +commit cannot hand rows over, so there the new window shows the file. The footer's daemon line is a plain `GtkLabel`, so its text can be read back over AT-SPI: ` → frame → panel → label` whose name starts with the translated @@ -433,7 +494,12 @@ is up. Three notes for tests: `import -window $(wmctrl -l | grep 'gKeyMapper – Logitech' | awk '{print $1}') /tmp/gui.png`. The footer label works over AT-SPI (its accessible *name* is the text), but a screenshot of the cropped footer is easier and shows what the user - sees. + sees. Easier than a screenshot for the rows themselves: the running instance + answers `gdbus call --session --dest dev.rager.gkeymapper --object-path + /dev/rager/gkeymapper/Window --method dev.rager.gkeymapper.Window.GetRows` with + exactly the mappings text its rows hold (`G1=command:/bin/true…`). It is the + same channel the takeover uses, so it also tells whether an edit is still + unsaved: compare it with the file. - `XTest`-synthesized clicks need the window mapped and focused; the `Save` button is `push button` + name `Mappings speichern` in the German locale. @@ -447,12 +513,24 @@ The tray icon, the help and the options window need neither hardware nor a click pid, two registrations) means the "register once" rule broke. - Its menu is readable without the panel: `gdbus call --session --dest org.kde.StatusNotifierItem--1 --object-path - /MenuBar --method com.canonical.dbusmenu.GetLayout 0 -1` returns the root node - with the two children. Drive a click the way the panel does — **batched**, as - `EventGroup`, because that is the path a real click takes: + /MenuBar --method com.canonical.dbusmenu.GetLayout -- 0 -1 "[]"` returns the + root node with the two children (the `--` keeps gdbus from reading the `-1` as + an option, and the empty array is the property-name list `GetLayout` expects). + Drive a click the way the panel does — **batched**, as `EventGroup`, because + that is the path a real click takes: `gdbus call … --method com.canonical.dbusmenu.EventGroup - "[(1, 'clicked', <'', >, 0)]"` shows the window, id `2` quits the program. - A single `Event` call works too but is not what the watcher sends. + "[(1, 'clicked', <''>, 0)]"` shows the window, id `2` quits the program (the + empty string is the variant `v` of the event; `<''>` is the only spelling gdbus + accepts). A single `Event` call works too but is not what the watcher sends. +- A start with `--start-hidden` (what the autostart entry passes) is checkable the + same way: no window in `wmctrl -lp` while the item above is registered and the + process stays, and the menu's `Show` brings the window up. Without a tray watcher + the window has to appear by itself after ~5 s — on a private bus that is awkward + to see, because a fresh `dbus-daemon` makes GTK's startup wait on gvfs/portal + services (`GIO_USE_VFS=local` unblocks it, and it can still take ~20 s), so + `on_activate` runs much later than on the session bus. Do not read "no window" as + the flag working until the tray item or the window object + (`Window.GetRows`) proves the app got that far. - The watcher is fragile by construction: it walks the layout it mirrors, so a malformed node (bare ids instead of full nodes) aborts it. After a change to the layout, check it survived (`pgrep -a xapp-sn-watcher`) and that no new @@ -482,6 +560,22 @@ The tray icon, the help and the options window need neither hardware nor a click One pitfall while clicking: the options window is transient for the main window, so it always stays above it — clicks meant for the main window hit the options window unless it is closed first. +- The popups (`src/popovers.cpp`) are checkable without hardware, and a click + test has to know two things: a popup is an override-redirect window that + disappears from `xwininfo`'s map state (or a `viewable.py`-style walk) but never + appears in `wmctrl -l`, and a click on the dropdown that is *already* open + closes it again — so "click the dropdown" does not mean "the popup is open", + always read the map state. What to check: with a type dropdown open, a click on + a *different* window must arrive there (`_NET_ACTIVE_WINDOW` changes to that + client, which proves the press was delivered and not swallowed) and the popup + must be gone; the same for a click on the bare desktop; a click in the window + outside the popup closes it without changing the focus; Escape closes it; a + click on an entry closes it and changes the row (`Window.GetRows`, e.g. + `G1=key:…` becomes `G1=command:…`); a click on the header bar's menu button + opens the menu and behaves like the dropdown. Every synthetic click must be + guarded by the id of the window that is expected to be topmost — the popup is + not in `_NET_CLIENT_LIST_STACKING`, so a click aimed at the popup has to be + checked against the window *below* it. - The autostart switch needs no click to read and one click to write, because the `.desktop` entry *is* the state: put `$HOME/.config/autostart/gkeymapper.desktop` there (or delete it), close the options window (`wmctrl -i -c `) and diff --git a/Makefile b/Makefile index 0aabb74..360bdbb 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ # gKeyMapper - Build -# Dependencies: g++, pkg-config, msgfmt, libgtk-4-dev, libusb-1.0-0-dev, libxkbcommon-dev +# Dependencies: g++, pkg-config, gettext (msgfmt, for the translations), +# libgtk-4-dev, libusb-1.0-0-dev, libxkbcommon-dev # Installation: sudo apt install build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev CXX ?= g++ @@ -42,12 +43,15 @@ DBUS_DIR := $(PREFIX)/share/dbus-1/services CXXFLAGS += -DPACKAGE=\"$(PACKAGE)\" -DLOCALEDIR=\"$(LOCALEDIR)\" -DASSETS_DIR=\"$(ASSETS_DIR)\" CXXFLAGS += -I$(SHARED_DIR) -# Translations +# Translations. The .pot is generated from the sources (make pot) and the +# catalogs are merged against it (scripts/i18n.sh update); the rules are in +# po/README.md. PO_FILES := $(wildcard po/*.po) LANGS := $(patsubst po/%.po,%,$(PO_FILES)) MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS)) +I18N := $(CURDIR)/scripts/i18n.sh -.PHONY: all clean run install translations daemon +.PHONY: all clean run install translations daemon pot i18n-check i18n-status all: $(TARGET) translations $(DAEMON) @@ -72,6 +76,21 @@ locale/%/LC_MESSAGES/$(PACKAGE).mo: po/%.po @mkdir -p $(@D) $(MSGFMT) -o $@ $< +# Regenerate po/gkeymapper.pot from the sources. "make translations" does not: a +# pot that is written by a build is a pot that nobody has looked at, and the +# strings are reviewed by hand (po/README.md, "Adding or changing a string"). +pot: + $(I18N) extract + +# Everything that can be wrong with the translations at once: the pot against the +# sources, every catalog against the pot, and the language list of the GUI against +# po/*.po. Run it before committing a string change. +i18n-check: + $(I18N) check + +i18n-status: + $(I18N) status + run: $(TARGET) translations GKEYMAPPER_LOCALEDIR=$(CURDIR)/locale GKEYMAPPER_ASSETS_DIR=$(CURDIR)/assets ./$(TARGET) diff --git a/README.md b/README.md index 80b449b..98bf98d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,16 @@ window icon works without an installed `assets/` directory. make translations ``` +Adding or changing a translation, adding a language, and the checks around them +are described in [po/README.md](po/README.md). `scripts/i18n.sh` does the work and +is wired into the Makefile: + +```bash +make pot # regenerate po/gkeymapper.pot from the sources +make i18n-check # pot, catalogs and the language list of the GUI +make i18n-status # per language: translated, fuzzy, untranslated +``` + ## Run Normal: @@ -105,8 +115,10 @@ window rewrites the same content instead of emptying the file. Only one window is open at a time, and it is the one you just started: a new start closes the instance that is already running — including one from an older build — and then shows its own window, so a stale window can never stay next to a -fresh one. Row edits that were not saved are lost with the replaced window, -because saving is always explicit (`Save mappings`). +fresh one. The rows of the window it replaced come along, so an edit that was +typed but never saved is still there; an instance from an older build cannot hand +them over, and then the window shows the saved mappings. Saving itself stays +explicit either way: press `Save mappings`. ### Menu, tray icon and minimize to tray @@ -122,12 +134,15 @@ configuration...`, `Options`, `Help` (also `F1`), `About` and `Quit` (`Ctrl+Q`). (`System language`, or a fixed `de`/`en`/`es`/`fr`): it switches the window, the menus and the tray menu right away — nothing to restart, and rows that were not saved yet are kept. -* `Start with the session` opens the window when you log in. It writes the usual - autostart entry (`~/.config/autostart/gkeymapper.desktop`, starting the binary - that is running) and removes it again when you switch it off; an entry that the - desktop's own startup tool has disabled counts as off, too. Like the window - itself this is a convenience: the G keys do not need it, only the daemon has to - run (see `Running the daemon` below). +* `Start with the session` starts the window minimized in the tray when you log + in: it writes the usual autostart entry + (`~/.config/autostart/gkeymapper.desktop`, starting the binary that is running + with the `--start-hidden` argument) and removes it again when you switch it off; + an entry that the desktop's own startup tool has disabled counts as off, too. The + window then waits in the tray until its `Show`, and only a desktop that hosts that + icon can leave it hidden — one without a tray gets the window a few seconds after + login. Like the window itself this is a convenience: the G keys do not need it, + only the daemon has to run (see `Running the daemon` below). `Minimize to tray` keeps gKeyMapper reachable from the panel: the window is hidden instead of closed when you close or minimize it, and the tray icon's menu @@ -340,7 +355,8 @@ src/keyrow.h # KeyRow (one per G key) src/mappings.{h,cpp} # GUI glue: config file <-> row widgets src/capturedialog.{h,cpp} # key-capture dialog src/daemonclient.{h,cpp} # D-Bus client for the daemon (status/reload) -src/singleinstance.{h,cpp} # one GUI only: a new start closes a running instance +src/singleinstance.{h,cpp} # one GUI only: a new start replaces a running one, + # taking the rows of its window along (unsaved edits) src/trayicon.{h,cpp} # tray icon: StatusNotifierItem + dbusmenu over GIO src/settings.{h,cpp} # gui.conf (window preferences, e.g. minimize to tray) src/help.{h,cpp} # help window @@ -361,7 +377,8 @@ data/gkeymapper-uinput.conf # modules-load.d fragment for uinput data/gkeymapper-daemon.service# systemd user unit (installed by make install) data/dev.rager.gkeymapper.Daemon.service # D-Bus activation (session bus) docs/daemon-plan.md # daemon plan (reference: g910-gkey-macro-support) -po/ # Translations (pot/po/mo targets) +po/ # Translations (pot/po/mo targets, po/README.md) +scripts/i18n.sh # extract/update/check/status/todo/new for the catalogs Makefile # Build via pkg-config + gettext .vscode/tasks.json # VS Code tasks for make README.md diff --git a/docs/daemon-plan.md b/docs/daemon-plan.md index 99e78dc..3e18740 100644 --- a/docs/daemon-plan.md +++ b/docs/daemon-plan.md @@ -361,6 +361,12 @@ Start-up orders: comes from a build without the action — and `on_activate` re-presents the window that is already there instead of building a second one. A stale window therefore never stays next to a fresh one, and the daemon is not involved. + Closing the old window would drop row edits that were never saved, so the + takeover asks it for its rows first (`GetRows` at `/dev/rager/gkeymapper/Window`, + see `shared/ipc_ids.h`) and the new `build_ui()` starts from that mappings text + instead of from `mappings.conf`. An instance that does not answer — an older + build — leaves the new window with the file, and the file stays untouched until + the user presses `Save mappings`. - Menu, help and options: `Help` (also `F1`) opens a one-page explanation of the rows, types, capture dialog, daemon status and tray; `Options` opens a small non-modal window with the GUI's own switches (tray behaviour, language, diff --git a/po/README.md b/po/README.md new file mode 100644 index 0000000..b21655b --- /dev/null +++ b/po/README.md @@ -0,0 +1,178 @@ +# Translations (i18n) + +Everything about the catalogs, in one place: which file is which, how a string gets +from the source code into a translated window, and how to add a language. The +commands live in `scripts/i18n.sh`, wired into the Makefile as `pot`, +`i18n-check` and `i18n-status`. + +## The pieces + +| file | what it is | +| --- | --- | +| `po/gkeymapper.pot` | the template: every translatable string in the sources, with the file and line it comes from. **Generated** by `make pot` — never translated, and its msgids are never edited by hand. | +| `po/.po` | one catalog per language. This is what a translator edits. | +| `locale//LC_MESSAGES/gkeymapper.mo` | the compiled catalog the program reads at runtime. Generated by `make translations`, gitignored. **Never edit** — it is overwritten on every build. | +| `scripts/i18n.sh` | `extract`, `update`, `check`, `status`, `todo`, `new` (see below). | + +Languages: `de`, `en`, `es`, `fr`. The set is `po/*.po` — the Makefile picks up a +new catalog by itself (`LANGS`), nothing has to be registered. + +`en.po` is special: English is the source language, and its catalog repeats every +msgid as its msgstr (an *identity* catalog). It exists so that choosing +"English" works on a German desktop, where the session locale would otherwise +select the German catalog. `i18n-check` enforces the identity — a translation that +drifts in is reported. + +## How a string reaches the window + +1. `_("...")` marks it in `src/` (or `shared/`) — the two directories the GUI is + built from. `N_("...")` is for strings that are only *collected* when they are + not translated yet: `src/help.cpp` keeps its paragraphs in a static array and + translates them when the window is built (`_(text)`). Both keywords are in the + pot; a string marked with neither is invisible to translators. +2. `make pot` writes `po/gkeymapper.pot` from the sources. +3. `scripts/i18n.sh update` merges the pot into every catalog. New strings come in + with an empty msgstr (= untranslated), existing translations are kept. +4. Translating is editing `po/.po`: replace the msgstr, leave the msgid + alone. Any text editor, or `poedit` if you prefer one. +5. `make translations` compiles `locale//LC_MESSAGES/gkeymapper.mo`. +6. The program reads the catalog whose language it was started with. The GUI picks + it up automatically once gettext is set up in `src/main.cpp`; *which* language is + used comes from the options window (stored in `~/.config/gKeyMapper/gui.conf`, + `language=de`), or from the session when that setting is empty. Internally + `apply_gui_language()` (`src/settings.cpp`) sets `LANGUAGE` and re-runs + `setlocale` — `LANGUAGE` wins over `LANG`/`LC_ALL` in gettext, which is what + makes switching without a restart and an explicit `en` on a German desktop work. + The catalogs are searched under `$GKEYMAPPER_LOCALEDIR`, falling back to the + compiled-in `LOCALEDIR`. + +## The common case: a string is added or changed + +```bash +# 1. in the code +_("New text") # or N_("...") for a deferred one + +# 2. the template +make pot # prints what is new and what is gone + +# 3. what is missing where +make i18n-check # names every catalog that lacks it + +# 4. every catalog takes it in (empty msgstr) +scripts/i18n.sh update # or: scripts/i18n.sh update de + +# 5. translate +scripts/i18n.sh todo de # untranslated + fuzzy, with the source location +$EDITOR po/de.po # and es, fr - and en, where the msgstr is the msgid + +# 6. check and compile +make i18n-check # must print "everything in sync" +make translations + +# 7. see it +GKEYMAPPER_LOCALEDIR=$PWD/locale LANGUAGE=de_DE.UTF-8 ./gkeymapper +``` + +Commit the source change, `po/gkeymapper.pot` and the catalogs **together**: a pot +without the catalogs is a half-done change, and `make i18n-check` says so. + +A new string in one place and the same text in another is *one* message for +gettext — the pot lists both locations, and one msgstr covers both. + +## Adding a language + +```bash +scripts/i18n.sh new it Italiano # po/it.po, header in the project's shape, + # every msgstr empty (the second word is + # Language-Team; it defaults to the code) +scripts/i18n.sh todo it # work through it +$EDITOR po/it.po +``` + +Then offer it in the GUI, or nobody can select it — `src/optionsdialog.cpp`: + +* `kLanguageCodes[]` — the codes, in the same order as the labels ("", the empty + one, means "follow the session" and has to stay first); +* `languageOptions[]` — the labels. Use the **endonym** ("Italiano", not + "Italian"): the list reads the same in every language, so a user who lands in + the wrong language can still find their own. + +`make i18n-check` checks exactly that pairing (a catalog that cannot be selected, a +code without a catalog, two arrays of different length). `make translations` +compiles the new catalog; `make run` and the test command above use the working +tree's `locale/`. + +## Commands + +| command | what it does | +| --- | --- | +| `scripts/i18n.sh extract` (`make pot`) | Regenerate `po/gkeymapper.pot` from the sources. Prints the messages that appeared and disappeared. Refuses to write an empty pot, so a broken gettext cannot empty the good one. | +| `scripts/i18n.sh update [lang...]` | Merge the pot into the catalogs, without source locations (see Conventions). Keeps translations, marks new strings untranslated. | +| `scripts/i18n.sh check` (`make i18n-check`) | Everything below. Exit code 1 when something is wrong, so it also works as a pre-commit step. | +| `scripts/i18n.sh status` (`make i18n-status`) | Per language: messages, translated, fuzzy, untranslated, and whether the compiled catalog is older than the po. | +| `scripts/i18n.sh todo ` | The untranslated and fuzzy messages, with the source locations taken from the pot. The output is a valid po file, if you want to keep it. | +| `scripts/i18n.sh new [team]` | Create `po/.po` from the pot. `new en` writes the identity catalog. | + +The gettext tools are taken from `PATH` and can be overridden like the Makefile's +variables, e.g. `XGETTEXT=/usr/bin/xgettext scripts/i18n.sh extract`. + +## What `check` looks at + +* the pot against the sources: same messages? A string added or changed without + `make pot` is the mistake this catches (a `#:` line number that moved is only + reported as a note — refresh it whenever it suits you); +* every catalog: it parses (`msgfmt -c`, which also compares the format + specifiers of msgid and msgstr), it defines the same messages as the pot, nothing + is untranslated or fuzzy, and its `Language:` header matches its file name; +* `po/en.po` is an identity catalog; +* `po/*.po` and the language list of the options window (`src/optionsdialog.cpp`) + agree; +* no strings are marked in `daemon/`: the daemon has no gettext setup, so `_()` + there would be translated into a catalog that is never read. + +Notes that are *not* failures: catalogs that were not merged since the last +extract, and obsolete entries (`#~`, strings that are gone from the sources — +`msgattrib --no-obsolete po/.po > tmp && mv tmp po/.po` drops them). + +## Conventions + +* **No `#: file:line` comments in the catalogs.** The locations are noise for a + translator and churn whenever a line moves, so they live only in the pot + (`update` therefore uses `msgmerge --no-location`) and `todo` shows them from + there when they are needed. This is also why the catalogs are merged with the + script instead of a bare `msgmerge --update`, which would copy the locations in. +* **The build does not regenerate the pot** (`make translations` only runs + `msgfmt`). A template that is rewritten by every build is a template nobody + looks at; instead `make pot` is a deliberate step and `make i18n-check` fails + when it was forgotten. +* **Mnemonics stay mnemonics.** `_Cancel` uses the `_` to underline a letter; + `_Abbrechen` moves it to a letter that exists in that language. A translation + without the underscore loses the keyboard shortcut silently. +* **Format specifiers** (`%s`, `%d`) keep their number and order — `msgfmt -c` + catches a mismatch. +* **No plurals yet**: the program only uses `_()`, so no catalog carries a + `Plural-Forms` header. With the first `ngettext()` that header has to go into + every catalog (and `scripts/i18n.sh new` should write it). +* **HTML-ish markup** in a msgid (``, ``) has to survive in the msgstr; the + help window renders it. +* German belongs in `po/de.po` only — sources, comments and documentation are + English. + +## Troubleshooting + +* *A window stays English while another window is translated* — the string is + missing from the catalog (or the catalog was not recompiled: `make translations`). + `scripts/i18n.sh todo ` lists what is open. +* *Shortcut without an underline* — the mnemonic `_` is missing in the msgstr. +* *`po/*.po~` files appear* — that is `msgmerge`'s backup from a manual + `msgmerge --update`; the script deletes it. Never commit them. +* *A fuzzy entry is shown in English* — on purpose: fuzzy means "the text was + changed, this translation needs a look", and gettext falls back to the msgid + until it is confirmed (remove the `#, fuzzy` line). +* *"not merged against the current pot" in `check`* — run `scripts/i18n.sh update`; + until then the catalog header carries an older date than the pot. +* *Nothing is extracted at all* — `xgettext`'s built-in keyword list is not the + same everywhere, so the script always passes `--keyword=_ --keyword=N_`. If the + pot comes out empty, the script stops instead of replacing the pot. + +The rest of the project's conventions (GUI, daemon, mappings) are in `AGENTS.md`. diff --git a/po/de.po b/po/de.po index bff1489..21a2e2f 100644 --- a/po/de.po +++ b/po/de.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: gkeymapper 0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-18 03:08+0200\n" +"POT-Creation-Date: 2026-10-18 20:18+0200\n" "PO-Revision-Date: 2026-10-18 03:08+0200\n" "Last-Translator: \n" "Language-Team: German\n" @@ -15,14 +15,8 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -msgid "Choose file" -msgstr "Datei auswählen" - -msgid "_Cancel" -msgstr "_Abbrechen" - -msgid "_Open" -msgstr "_Öffnen" +msgid "G-Key mapper for the Logitech G910 (GTK4)." +msgstr "G-Key-Mapper für die Logitech G910 (GTK4)." msgid "No keys recorded yet" msgstr "Noch keine Tasten aufgezeichnet" @@ -30,6 +24,14 @@ msgstr "Noch keine Tasten aufgezeichnet" msgid "Capture key sequence" msgstr "Tastensequenz aufnehmen" +msgid "" +"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " +"recorded too). Click Done to apply or Cancel to discard." +msgstr "" +"Tasten der Reihe nach drücken (Modifikatoren wie Strg, Alt, Shift, Super und " +"AltGr werden mit aufgezeichnet). Mit Fertig übernehmen oder mit Abbrechen " +"verwerfen." + msgid "Clear" msgstr "Leeren" @@ -40,12 +42,120 @@ msgid "Done" msgstr "Fertig" msgid "" -"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " -"recorded too). Click Done to apply or Cancel to discard." +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." msgstr "" -"Tasten der Reihe nach drücken (Modifikatoren wie Strg, Alt, Shift, Super und " -"AltGr werden mit aufgezeichnet). Mit Fertig übernehmen oder mit Abbrechen " -"verwerfen." +"Die neun Zeilen – eine pro G-Taste. Typ wählen, Ziel eingeben oder " +"aufnehmen und Mappings speichern drücken." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Taste – drückt die aufgenommene Sequenz einmal, z. B. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, e." +"g. for push-to-talk. Only a single step can be held." +msgstr "" +"Taste (halten) – drückt sie und hält sie, solange die G-Taste " +"gehalten wird, z. B. für Push-to-Talk. Nur ein einzelner Schritt kann " +"gehalten werden." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox --" +"new-window." +msgstr "" +"Befehl – führt ein Programm oder eine Kommandozeile aus, z. B. /usr/" +"bin/firefox --new-window." + +msgid "Disabled – the key does nothing." +msgstr "Deaktiviert – die Taste tut nichts." + +msgid "" +"Capture – records the keys you press next: press them, then Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Aufnehmen – zeichnet die nächsten Tastenanschläge auf: drücken, dann " +"Fertig. Modifikatoren bleiben erhalten (shift, altgr, …) und die " +"Namen sind layoutunabhängig, ctrl+z bedeutet also die Taste, die auf deinem " +"Layout z tippt." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Mappings speichern – schreibt ~/.config/gKeyMapper/mappings.conf und " +"lässt den Daemon sie neu laden. Die Menüeinträge Konfiguration " +"exportieren... und Konfiguration importieren... machen dasselbe " +"mit einer Datei deiner Wahl." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. Options shows the same in more detail." +msgstr "" +"Daemon-Status – die Fußzeile zeigt, ob gkeymapper-daemon läuft, " +"welche Tastatur gefunden wurde und welche G-Taste zuletzt ausgelöst hat. " +"Optionen zeigt dasselbe ausführlicher." + +msgid "" +"Autostart – Start with the session opens this window minimized " +"to the tray when you log in. The G keys work without it: gkeymapper-daemon " +"(a user service) reads them and replays the actions, and that daemon is the " +"only part that has to run." +msgstr "" +"Autostart – Beim Anmelden starten startet dieses Fenster " +"minimiert im Infobereich. Die G-Tasten funktionieren auch ohne: gkeymapper-" +"daemon (ein Benutzerdienst) liest sie und spielt die Aktionen ab – nur " +"dieser Daemon muss laufen." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved yet come " +"along." +msgstr "" +"Infobereich und Fenster – mit In den Infobereich minimieren " +"wird das Fenster beim Schließen oder Minimieren versteckt statt geschlossen; " +"das Symbol im Infobereich zeigt es wieder (Anzeigen) und beendet das " +"Programm (Beenden). Ein erneuter Start von gKeyMapper schließt das " +"alte Fenster – noch nicht gespeicherte Zeilen kommen dabei mit." + +msgid "Help" +msgstr "Hilfe" + +msgid "Close window" +msgstr "Fenster schließen" + +msgid "Daemon: not reachable" +msgstr "Daemon: nicht erreichbar" + +msgid "Daemon: running" +msgstr "Daemon: läuft" + +msgid "Device: not found" +msgstr "Gerät: nicht gefunden" + +#, c-format +msgid "Device: %s" +msgstr "Gerät: %s" + +msgid "Configuration error" +msgstr "Konfigurationsfehler" + +#, c-format +msgid "Last key: %s" +msgstr "Letzte Taste: %s" + +msgid "Choose file" +msgstr "Datei auswählen" + +msgid "_Cancel" +msgstr "_Abbrechen" + +msgid "_Open" +msgstr "_Öffnen" msgid "Choose file..." msgstr "Datei auswählen..." @@ -56,12 +166,12 @@ msgstr "z. B. /usr/bin/firefox oder eigener Befehl" msgid "Capture" msgstr "Aufnehmen" -msgid "e.g. ctrl+c, ctrl+v" -msgstr "z. B. Strg+C, Strg+V" - msgid "e.g. ctrl+w (held while the G key is held)" msgstr "z. B. Strg+W (gehalten, solange die G-Taste gedrückt ist)" +msgid "e.g. ctrl+c, ctrl+v" +msgstr "z. B. Strg+C, Strg+V" + #, c-format msgid "Could not save %s.\n" msgstr "Konnte %s nicht speichern.\n" @@ -69,19 +179,12 @@ msgstr "Konnte %s nicht speichern.\n" msgid "Mappings saved to " msgstr "Mappings gespeichert nach " -#, c-format -msgid "Could not open %s.\n" -msgstr "Konnte %s nicht öffnen.\n" - msgid "Could not export mappings.\n" msgstr "Mappings konnten nicht exportiert werden.\n" msgid "gKeyMapper" msgstr "gKeyMapper" -msgid "G-Key mapper for the Logitech G910 (GTK4)." -msgstr "G-Key-Mapper für die Logitech G910 (GTK4)." - msgid "Export configuration" msgstr "Konfiguration exportieren" @@ -100,9 +203,15 @@ msgstr "Konfiguration exportieren..." msgid "Import configuration..." msgstr "Konfiguration importieren..." +msgid "Options" +msgstr "Optionen" + msgid "About" msgstr "Über" +msgid "Quit" +msgstr "Beenden" + msgid "G1 to G9 of the Logitech G910 remapped to keys or commands" msgstr "G1 bis G9 der Logitech G910 auf Tasten oder Befehle ummappen" @@ -136,44 +245,12 @@ msgstr "Mappings speichern" msgid "Saved to ~/.config/gKeyMapper/mappings.conf" msgstr "Gespeichert in ~/.config/gKeyMapper/mappings.conf" -msgid "Daemon: not reachable" -msgstr "Daemon: nicht erreichbar" - -msgid "Daemon: running" -msgstr "Daemon: läuft" - -msgid "Device: %s" -msgstr "Gerät: %s" - -msgid "Device: not found" -msgstr "Gerät: nicht gefunden" - -msgid "Configuration error" -msgstr "Konfigurationsfehler" - -msgid "Last key: %s" -msgstr "Letzte Taste: %s" - msgid "Daemon: checking..." msgstr "Daemon: wird geprüft ..." -msgid "Options" -msgstr "Optionen" - -msgid "Help" -msgstr "Hilfe" - -msgid "Quit" -msgstr "Beenden" - -msgid "Show" -msgstr "Anzeigen" - -msgid "Close" -msgstr "Beenden" - -msgid "Close window" -msgstr "Fenster schließen" +#, c-format +msgid "Could not open %s.\n" +msgstr "Konnte %s nicht öffnen.\n" msgid "Window" msgstr "Fenster" @@ -181,13 +258,15 @@ msgstr "Fenster" msgid "Minimize to tray" msgstr "In den Infobereich minimieren" +msgid "Start with the session" +msgstr "Beim Anmelden starten" + msgid "" -"Closing or minimizing the window hides it instead of quitting. The tray icon " -"shows it again (Show) and quits the program (Close)." +"Opens this window minimized to the tray when you log in. Not needed for the " +"G keys - the daemon does the work and is the only part that has to run." msgstr "" -"Beim Schließen oder Minimieren wird das Fenster versteckt statt beendet. Das " -"Symbol im Infobereich zeigt es wieder (Anzeigen) und beendet das Programm " -"(Beenden)." +"Öffnet dieses Fenster beim Anmelden minimiert im Infobereich. Für die G-" +"Tasten nicht nötig – die Arbeit macht der Daemon, und nur der muss laufen." msgid "Language" msgstr "Sprache" @@ -198,21 +277,6 @@ msgstr "Systemsprache" msgid "The interface switches right away, including the tray menu." msgstr "Die Oberfläche wechselt sofort – auch das Menü im Infobereich." -msgid "Start with the session" -msgstr "Beim Anmelden starten" - -msgid "" -"Opens this window when you log in. Not needed for the G keys - the daemon " -"does the work and is the only part that has to run." -msgstr "" -"Öffnet dieses Fenster beim Anmelden. Für die G-Tasten nicht nötig – die " -"Arbeit macht der Daemon, und nur der muss laufen." - -msgid "No tray is available on this desktop, so the window cannot be hidden." -msgstr "" -"Auf diesem Desktop gibt es keinen Infobereich, daher kann das Fenster nicht " -"versteckt werden." - msgid "Daemon status" msgstr "Daemon-Status" @@ -234,6 +298,19 @@ msgstr "Konfigurationsdatei" msgid "Last key" msgstr "Letzte Taste" +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"Beim Schließen oder Minimieren wird das Fenster versteckt statt beendet. Das " +"Symbol im Infobereich zeigt es wieder (Anzeigen) und beendet das Programm " +"(Beenden)." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" +"Auf diesem Desktop gibt es keinen Infobereich, daher kann das Fenster nicht " +"versteckt werden." + msgid "active" msgstr "aktiv" @@ -249,82 +326,8 @@ msgstr "nicht erreichbar" msgid "not found" msgstr "nicht gefunden" -msgid "" -"The nine rows – one per G key. Choose the type, then enter or capture " -"the target and press Save mappings." -msgstr "" -"Die neun Zeilen – eine pro G-Taste. Typ wählen, Ziel eingeben oder " -"aufnehmen und Mappings speichern drücken." +msgid "Show" +msgstr "Anzeigen" -msgid "Key – presses the captured sequence once, e.g. ctrl+c." -msgstr "Taste – drückt die aufgenommene Sequenz einmal, z. B. ctrl+c." - -msgid "" -"Key (hold) – presses it and keeps it down while the G key is held, " -"e.g. for push-to-talk. Only a single step can be held." -msgstr "" -"Taste (halten) – drückt sie und hält sie, solange die G-Taste " -"gehalten wird, z. B. für Push-to-Talk. Nur ein einzelner Schritt kann " -"gehalten werden." - -msgid "" -"Command – runs a program or a command line, e.g. /usr/bin/firefox " -"--new-window." -msgstr "" -"Befehl – führt ein Programm oder eine Kommandozeile aus, z. B. " -"/usr/bin/firefox --new-window." - -msgid "Disabled – the key does nothing." -msgstr "Deaktiviert – die Taste tut nichts." - -msgid "" -"Capture – records the keys you press next: press them, then " -"Done. Modifiers are kept (shift, altgr, …) and the names are layout " -"independent, so ctrl+z means the key that types z on your layout." -msgstr "" -"Aufnehmen – zeichnet die nächsten Tastenanschläge auf: drücken, dann " -"Fertig. Modifikatoren bleiben erhalten (shift, altgr, …) und die " -"Namen sind layoutunabhängig, ctrl+z bedeutet also die Taste, die auf deinem " -"Layout z tippt." - -msgid "" -"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " -"the daemon reload it. The menu entries Export configuration... and " -"Import configuration... do the same with a file you choose." -msgstr "" -"Mappings speichern – schreibt ~/.config/gKeyMapper/mappings.conf und " -"lässt den Daemon sie neu laden. Die Menüeinträge Konfiguration " -"exportieren... und Konfiguration importieren... machen dasselbe " -"mit einer Datei deiner Wahl." - -msgid "" -"Daemon status – the footer shows whether gkeymapper-daemon is " -"running, which keyboard it found and the last G key that fired. " -"Options shows the same in more detail." -msgstr "" -"Daemon-Status – die Fußzeile zeigt, ob gkeymapper-daemon läuft, " -"welche Tastatur gefunden wurde und welche G-Taste zuletzt ausgelöst hat. " -"Optionen zeigt dasselbe ausführlicher." - -msgid "" -"Autostart – Start with the session only opens this window when " -"you log in. The G keys work without it: gkeymapper-daemon (a user service) " -"reads them and replays the actions, and that daemon is the only part that has " -"to run." -msgstr "" -"Autostart – Beim Anmelden starten öffnet nur dieses Fenster. " -"Die G-Tasten funktionieren auch ohne: gkeymapper-daemon (ein Benutzerdienst) " -"liest sie und spielt die Aktionen ab – nur dieser Daemon muss laufen." - -msgid "" -"Tray and window – with Minimize to tray the window is hidden " -"instead of closed when you close or minimize it; the tray icon shows it " -"again (Show) and quits the program (Close). Starting " -"gKeyMapper again closes the old window – rows that were not saved are lost " -"with it." -msgstr "" -"Infobereich und Fenster – mit In den Infobereich minimieren " -"wird das Fenster beim Schließen oder Minimieren versteckt statt geschlossen; " -"das Symbol im Infobereich zeigt es wieder (Anzeigen) und beendet das " -"Programm (Beenden). Ein erneuter Start von gKeyMapper schließt das " -"alte Fenster – nicht gespeicherte Zeilen gehen damit verloren." +msgid "Close" +msgstr "Beenden" diff --git a/po/en.po b/po/en.po index 738531a..d74600a 100644 --- a/po/en.po +++ b/po/en.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gkeymapper 0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-18 03:08+0200\n" +"POT-Creation-Date: 2026-10-18 20:18+0200\n" "PO-Revision-Date: 2026-10-18 03:08+0200\n" "Last-Translator: \n" "Language-Team: English\n" @@ -16,14 +16,8 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -msgid "Choose file" -msgstr "Choose file" - -msgid "_Cancel" -msgstr "_Cancel" - -msgid "_Open" -msgstr "_Open" +msgid "G-Key mapper for the Logitech G910 (GTK4)." +msgstr "G-Key mapper for the Logitech G910 (GTK4)." msgid "No keys recorded yet" msgstr "No keys recorded yet" @@ -31,6 +25,13 @@ msgstr "No keys recorded yet" msgid "Capture key sequence" msgstr "Capture key sequence" +msgid "" +"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " +"recorded too). Click Done to apply or Cancel to discard." +msgstr "" +"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " +"recorded too). Click Done to apply or Cancel to discard." + msgid "Clear" msgstr "Clear" @@ -41,11 +42,117 @@ msgid "Done" msgstr "Done" msgid "" -"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " -"recorded too). Click Done to apply or Cancel to discard." +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." msgstr "" -"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " -"recorded too). Click Done to apply or Cancel to discard." +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Key – presses the captured sequence once, e.g. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, e." +"g. for push-to-talk. Only a single step can be held." +msgstr "" +"Key (hold) – presses it and keeps it down while the G key is held, e." +"g. for push-to-talk. Only a single step can be held." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox --" +"new-window." +msgstr "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox --" +"new-window." + +msgid "Disabled – the key does nothing." +msgstr "Disabled – the key does nothing." + +msgid "" +"Capture – records the keys you press next: press them, then Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Capture – records the keys you press next: press them, then Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. Options shows the same in more detail." +msgstr "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. Options shows the same in more detail." + +msgid "" +"Autostart – Start with the session opens this window minimized " +"to the tray when you log in. The G keys work without it: gkeymapper-daemon " +"(a user service) reads them and replays the actions, and that daemon is the " +"only part that has to run." +msgstr "" +"Autostart – Start with the session opens this window minimized " +"to the tray when you log in. The G keys work without it: gkeymapper-daemon " +"(a user service) reads them and replays the actions, and that daemon is the " +"only part that has to run." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved yet come " +"along." +msgstr "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved yet come " +"along." + +msgid "Help" +msgstr "Help" + +msgid "Close window" +msgstr "Close window" + +msgid "Daemon: not reachable" +msgstr "Daemon: not reachable" + +msgid "Daemon: running" +msgstr "Daemon: running" + +msgid "Device: not found" +msgstr "Device: not found" + +#, c-format +msgid "Device: %s" +msgstr "Device: %s" + +msgid "Configuration error" +msgstr "Configuration error" + +#, c-format +msgid "Last key: %s" +msgstr "Last key: %s" + +msgid "Choose file" +msgstr "Choose file" + +msgid "_Cancel" +msgstr "_Cancel" + +msgid "_Open" +msgstr "_Open" msgid "Choose file..." msgstr "Choose file..." @@ -56,12 +163,12 @@ msgstr "e.g. /usr/bin/firefox or custom command" msgid "Capture" msgstr "Capture" -msgid "e.g. ctrl+c, ctrl+v" -msgstr "e.g. ctrl+c, ctrl+v" - msgid "e.g. ctrl+w (held while the G key is held)" msgstr "e.g. ctrl+w (held while the G key is held)" +msgid "e.g. ctrl+c, ctrl+v" +msgstr "e.g. ctrl+c, ctrl+v" + #, c-format msgid "Could not save %s.\n" msgstr "Could not save %s.\n" @@ -69,19 +176,12 @@ msgstr "Could not save %s.\n" msgid "Mappings saved to " msgstr "Mappings saved to " -#, c-format -msgid "Could not open %s.\n" -msgstr "Could not open %s.\n" - msgid "Could not export mappings.\n" msgstr "Could not export mappings.\n" msgid "gKeyMapper" msgstr "gKeyMapper" -msgid "G-Key mapper for the Logitech G910 (GTK4)." -msgstr "G-Key mapper for the Logitech G910 (GTK4)." - msgid "Export configuration" msgstr "Export configuration" @@ -100,9 +200,15 @@ msgstr "Export configuration..." msgid "Import configuration..." msgstr "Import configuration..." +msgid "Options" +msgstr "Options" + msgid "About" msgstr "About" +msgid "Quit" +msgstr "Quit" + msgid "G1 to G9 of the Logitech G910 remapped to keys or commands" msgstr "G1 to G9 of the Logitech G910 remapped to keys or commands" @@ -136,44 +242,12 @@ msgstr "Save mappings" msgid "Saved to ~/.config/gKeyMapper/mappings.conf" msgstr "Saved to ~/.config/gKeyMapper/mappings.conf" -msgid "Daemon: not reachable" -msgstr "Daemon: not reachable" - -msgid "Daemon: running" -msgstr "Daemon: running" - -msgid "Device: %s" -msgstr "Device: %s" - -msgid "Device: not found" -msgstr "Device: not found" - -msgid "Configuration error" -msgstr "Configuration error" - -msgid "Last key: %s" -msgstr "Last key: %s" - msgid "Daemon: checking..." msgstr "Daemon: checking..." -msgid "Options" -msgstr "Options" - -msgid "Help" -msgstr "Help" - -msgid "Quit" -msgstr "Quit" - -msgid "Show" -msgstr "Show" - -msgid "Close" -msgstr "Close" - -msgid "Close window" -msgstr "Close window" +#, c-format +msgid "Could not open %s.\n" +msgstr "Could not open %s.\n" msgid "Window" msgstr "Window" @@ -181,12 +255,15 @@ msgstr "Window" msgid "Minimize to tray" msgstr "Minimize to tray" +msgid "Start with the session" +msgstr "Start with the session" + msgid "" -"Closing or minimizing the window hides it instead of quitting. The tray icon " -"shows it again (Show) and quits the program (Close)." +"Opens this window minimized to the tray when you log in. Not needed for the " +"G keys - the daemon does the work and is the only part that has to run." msgstr "" -"Closing or minimizing the window hides it instead of quitting. The tray icon " -"shows it again (Show) and quits the program (Close)." +"Opens this window minimized to the tray when you log in. Not needed for the " +"G keys - the daemon does the work and is the only part that has to run." msgid "Language" msgstr "Language" @@ -197,19 +274,6 @@ msgstr "System language" msgid "The interface switches right away, including the tray menu." msgstr "The interface switches right away, including the tray menu." -msgid "Start with the session" -msgstr "Start with the session" - -msgid "" -"Opens this window when you log in. Not needed for the G keys - the daemon " -"does the work and is the only part that has to run." -msgstr "" -"Opens this window when you log in. Not needed for the G keys - the daemon " -"does the work and is the only part that has to run." - -msgid "No tray is available on this desktop, so the window cannot be hidden." -msgstr "No tray is available on this desktop, so the window cannot be hidden." - msgid "Daemon status" msgstr "Daemon status" @@ -231,6 +295,16 @@ msgstr "Configuration file" msgid "Last key" msgstr "Last key" +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "No tray is available on this desktop, so the window cannot be hidden." + msgid "active" msgstr "active" @@ -246,80 +320,8 @@ msgstr "not reachable" msgid "not found" msgstr "not found" -msgid "" -"The nine rows – one per G key. Choose the type, then enter or capture " -"the target and press Save mappings." -msgstr "" -"The nine rows – one per G key. Choose the type, then enter or capture " -"the target and press Save mappings." +msgid "Show" +msgstr "Show" -msgid "Key – presses the captured sequence once, e.g. ctrl+c." -msgstr "Key – presses the captured sequence once, e.g. ctrl+c." - -msgid "" -"Key (hold) – presses it and keeps it down while the G key is held, " -"e.g. for push-to-talk. Only a single step can be held." -msgstr "" -"Key (hold) – presses it and keeps it down while the G key is held, " -"e.g. for push-to-talk. Only a single step can be held." - -msgid "" -"Command – runs a program or a command line, e.g. /usr/bin/firefox " -"--new-window." -msgstr "" -"Command – runs a program or a command line, e.g. /usr/bin/firefox " -"--new-window." - -msgid "Disabled – the key does nothing." -msgstr "Disabled – the key does nothing." - -msgid "" -"Capture – records the keys you press next: press them, then " -"Done. Modifiers are kept (shift, altgr, …) and the names are layout " -"independent, so ctrl+z means the key that types z on your layout." -msgstr "" -"Capture – records the keys you press next: press them, then " -"Done. Modifiers are kept (shift, altgr, …) and the names are layout " -"independent, so ctrl+z means the key that types z on your layout." - -msgid "" -"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " -"the daemon reload it. The menu entries Export configuration... and " -"Import configuration... do the same with a file you choose." -msgstr "" -"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " -"the daemon reload it. The menu entries Export configuration... and " -"Import configuration... do the same with a file you choose." - -msgid "" -"Daemon status – the footer shows whether gkeymapper-daemon is " -"running, which keyboard it found and the last G key that fired. " -"Options shows the same in more detail." -msgstr "" -"Daemon status – the footer shows whether gkeymapper-daemon is " -"running, which keyboard it found and the last G key that fired. " -"Options shows the same in more detail." - -msgid "" -"Autostart – Start with the session only opens this window when " -"you log in. The G keys work without it: gkeymapper-daemon (a user service) " -"reads them and replays the actions, and that daemon is the only part that has " -"to run." -msgstr "" -"Autostart – Start with the session only opens this window when " -"you log in. The G keys work without it: gkeymapper-daemon (a user service) " -"reads them and replays the actions, and that daemon is the only part that has " -"to run." - -msgid "" -"Tray and window – with Minimize to tray the window is hidden " -"instead of closed when you close or minimize it; the tray icon shows it " -"again (Show) and quits the program (Close). Starting " -"gKeyMapper again closes the old window – rows that were not saved are lost " -"with it." -msgstr "" -"Tray and window – with Minimize to tray the window is hidden " -"instead of closed when you close or minimize it; the tray icon shows it " -"again (Show) and quits the program (Close). Starting " -"gKeyMapper again closes the old window – rows that were not saved are lost " -"with it." +msgid "Close" +msgstr "Close" diff --git a/po/es.po b/po/es.po index 42a1d44..d40814e 100644 --- a/po/es.po +++ b/po/es.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: gkeymapper 0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-18 03:08+0200\n" +"POT-Creation-Date: 2026-10-18 20:18+0200\n" "PO-Revision-Date: 2026-10-18 03:08+0200\n" "Last-Translator: \n" "Language-Team: Spanish\n" @@ -13,14 +13,8 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -msgid "Choose file" -msgstr "Elegir archivo" - -msgid "_Cancel" -msgstr "_Cancelar" - -msgid "_Open" -msgstr "_Abrir" +msgid "G-Key mapper for the Logitech G910 (GTK4)." +msgstr "Asignador de teclas G para el Logitech G910 (GTK4)." msgid "No keys recorded yet" msgstr "Aún no se ha registrado ninguna tecla" @@ -28,6 +22,14 @@ msgstr "Aún no se ha registrado ninguna tecla" msgid "Capture key sequence" msgstr "Capturar secuencia de teclas" +msgid "" +"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " +"recorded too). Click Done to apply or Cancel to discard." +msgstr "" +"Pulsa las teclas en orden (los modificadores como Ctrl, Alt, Mayús, Super y " +"AltGr también se registran). Haz clic en Aceptar para aplicar o en Cancelar " +"para descartar." + msgid "Clear" msgstr "Borrar" @@ -38,12 +40,119 @@ msgid "Done" msgstr "Aceptar" msgid "" -"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " -"recorded too). Click Done to apply or Cancel to discard." +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." msgstr "" -"Pulsa las teclas en orden (los modificadores como Ctrl, Alt, Mayús, Super y " -"AltGr también se registran). Haz clic en Aceptar para aplicar o en Cancelar " -"para descartar." +"Las nueve filas – una por tecla G. Elige el tipo, introduce o captura " +"el destino y pulsa Guardar asignaciones." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Tecla – pulsa la secuencia capturada una vez, p. ej. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, e." +"g. for push-to-talk. Only a single step can be held." +msgstr "" +"Tecla (mantener) – la pulsa y la mantiene mientras se mantiene la " +"tecla G, p. ej. para pulsar para hablar. Solo se puede mantener un paso." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox --" +"new-window." +msgstr "" +"Comando – ejecuta un programa o una línea de comandos, p. ej. /usr/" +"bin/firefox --new-window." + +msgid "Disabled – the key does nothing." +msgstr "Desactivado – la tecla no hace nada." + +msgid "" +"Capture – records the keys you press next: press them, then Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Capturar – graba las siguientes teclas que pulses: púlsalas y luego " +"Aceptar. Los modificadores se conservan (shift, altgr, …) y los " +"nombres no dependen de la distribución, así que ctrl+z es la tecla que " +"escribe z en tu distribución." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Guardar asignaciones – escribe ~/.config/gKeyMapper/mappings.conf y " +"hace que el daemon la recargue. Las entradas de menú Exportar " +"configuración... e Importar configuración... hacen lo mismo con " +"el archivo que elijas." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. Options shows the same in more detail." +msgstr "" +"Estado del daemon – el pie de ventana muestra si gkeymapper-daemon " +"está en ejecución, qué teclado ha encontrado y la última tecla G que se ha " +"pulsado. Opciones muestra lo mismo con más detalle." + +msgid "" +"Autostart – Start with the session opens this window minimized " +"to the tray when you log in. The G keys work without it: gkeymapper-daemon " +"(a user service) reads them and replays the actions, and that daemon is the " +"only part that has to run." +msgstr "" +"Inicio automático – Iniciar con la sesión abre esta ventana " +"minimizada en la bandeja al iniciar la sesión. Las teclas G funcionan sin " +"ella: gkeymapper-daemon (un servicio de usuario) las lee y reproduce las " +"acciones, y ese daemon es lo único que tiene que estar en marcha." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved yet come " +"along." +msgstr "" +"Bandeja y ventana – con Minimizar a la bandeja, al cerrar o " +"minimizar la ventana se oculta en lugar de cerrarse; el icono de la bandeja " +"la vuelve a mostrar (Mostrar) y cierra el programa (Cerrar). " +"Volver a iniciar gKeyMapper cierra la ventana antigua: las filas aún sin " +"guardar se conservan." + +msgid "Help" +msgstr "Ayuda" + +msgid "Close window" +msgstr "Cerrar ventana" + +msgid "Daemon: not reachable" +msgstr "Demonio: no accesible" + +msgid "Daemon: running" +msgstr "Demonio: en ejecución" + +msgid "Device: not found" +msgstr "Dispositivo: no encontrado" + +#, c-format +msgid "Device: %s" +msgstr "Dispositivo: %s" + +msgid "Configuration error" +msgstr "Error de configuración" + +#, c-format +msgid "Last key: %s" +msgstr "Última tecla: %s" + +msgid "Choose file" +msgstr "Elegir archivo" + +msgid "_Cancel" +msgstr "_Cancelar" + +msgid "_Open" +msgstr "_Abrir" msgid "Choose file..." msgstr "Elegir archivo..." @@ -54,12 +163,12 @@ msgstr "p. ej. /usr/bin/firefox o comando personalizado" msgid "Capture" msgstr "Capturar" -msgid "e.g. ctrl+c, ctrl+v" -msgstr "p. ej. ctrl+c, ctrl+v" - msgid "e.g. ctrl+w (held while the G key is held)" msgstr "p. ej. ctrl+w (mantenida mientras se mantiene pulsada la tecla G)" +msgid "e.g. ctrl+c, ctrl+v" +msgstr "p. ej. ctrl+c, ctrl+v" + #, c-format msgid "Could not save %s.\n" msgstr "No se pudo guardar %s.\n" @@ -67,19 +176,12 @@ msgstr "No se pudo guardar %s.\n" msgid "Mappings saved to " msgstr "Asignaciones guardadas en " -#, c-format -msgid "Could not open %s.\n" -msgstr "No se pudo abrir %s.\n" - msgid "Could not export mappings.\n" msgstr "No se pudieron exportar las asignaciones.\n" msgid "gKeyMapper" msgstr "gKeyMapper" -msgid "G-Key mapper for the Logitech G910 (GTK4)." -msgstr "Asignador de teclas G para el Logitech G910 (GTK4)." - msgid "Export configuration" msgstr "Exportar configuración" @@ -98,9 +200,15 @@ msgstr "Exportar configuración..." msgid "Import configuration..." msgstr "Importar configuración..." +msgid "Options" +msgstr "Opciones" + msgid "About" msgstr "Acerca de" +msgid "Quit" +msgstr "Salir" + msgid "G1 to G9 of the Logitech G910 remapped to keys or commands" msgstr "Reasigna las G1 a G9 del Logitech G910 a teclas o comandos" @@ -134,44 +242,12 @@ msgstr "Guardar asignaciones" msgid "Saved to ~/.config/gKeyMapper/mappings.conf" msgstr "Guardado en ~/.config/gKeyMapper/mappings.conf" -msgid "Daemon: not reachable" -msgstr "Demonio: no accesible" - -msgid "Daemon: running" -msgstr "Demonio: en ejecución" - -msgid "Device: %s" -msgstr "Dispositivo: %s" - -msgid "Device: not found" -msgstr "Dispositivo: no encontrado" - -msgid "Configuration error" -msgstr "Error de configuración" - -msgid "Last key: %s" -msgstr "Última tecla: %s" - msgid "Daemon: checking..." msgstr "Demonio: comprobando..." -msgid "Options" -msgstr "Opciones" - -msgid "Help" -msgstr "Ayuda" - -msgid "Quit" -msgstr "Salir" - -msgid "Show" -msgstr "Mostrar" - -msgid "Close" -msgstr "Cerrar" - -msgid "Close window" -msgstr "Cerrar ventana" +#, c-format +msgid "Could not open %s.\n" +msgstr "No se pudo abrir %s.\n" msgid "Window" msgstr "Ventana" @@ -179,12 +255,16 @@ msgstr "Ventana" msgid "Minimize to tray" msgstr "Minimizar a la bandeja" +msgid "Start with the session" +msgstr "Iniciar con la sesión" + msgid "" -"Closing or minimizing the window hides it instead of quitting. The tray icon " -"shows it again (Show) and quits the program (Close)." +"Opens this window minimized to the tray when you log in. Not needed for the " +"G keys - the daemon does the work and is the only part that has to run." msgstr "" -"Al cerrar o minimizar, la ventana se oculta en lugar de salir. El icono de " -"la bandeja la vuelve a mostrar (Mostrar) y cierra el programa (Cerrar)." +"Abre esta ventana minimizada en la bandeja al iniciar la sesión. No hace " +"falta para las teclas G: el trabajo lo hace el daemon, y solo él tiene que " +"estar en marcha." msgid "Language" msgstr "Idioma" @@ -195,20 +275,6 @@ msgstr "Idioma del sistema" msgid "The interface switches right away, including the tray menu." msgstr "La interfaz cambia al instante, incluido el menú de la bandeja." -msgid "Start with the session" -msgstr "Iniciar con la sesión" - -msgid "" -"Opens this window when you log in. Not needed for the G keys - the daemon " -"does the work and is the only part that has to run." -msgstr "" -"Abre esta ventana al iniciar la sesión. No hace falta para las teclas G: el " -"trabajo lo hace el daemon, y solo él tiene que estar en marcha." - -msgid "No tray is available on this desktop, so the window cannot be hidden." -msgstr "" -"Este escritorio no tiene bandeja, así que la ventana no se puede ocultar." - msgid "Daemon status" msgstr "Estado del daemon" @@ -230,6 +296,17 @@ msgstr "Archivo de configuración" msgid "Last key" msgstr "Última tecla" +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"Al cerrar o minimizar, la ventana se oculta en lugar de salir. El icono de " +"la bandeja la vuelve a mostrar (Mostrar) y cierra el programa (Cerrar)." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" +"Este escritorio no tiene bandeja, así que la ventana no se puede ocultar." + msgid "active" msgstr "activo" @@ -245,82 +322,8 @@ msgstr "no accesible" msgid "not found" msgstr "no encontrado" -msgid "" -"The nine rows – one per G key. Choose the type, then enter or capture " -"the target and press Save mappings." -msgstr "" -"Las nueve filas – una por tecla G. Elige el tipo, introduce o captura " -"el destino y pulsa Guardar asignaciones." +msgid "Show" +msgstr "Mostrar" -msgid "Key – presses the captured sequence once, e.g. ctrl+c." -msgstr "Tecla – pulsa la secuencia capturada una vez, p. ej. ctrl+c." - -msgid "" -"Key (hold) – presses it and keeps it down while the G key is held, " -"e.g. for push-to-talk. Only a single step can be held." -msgstr "" -"Tecla (mantener) – la pulsa y la mantiene mientras se mantiene la " -"tecla G, p. ej. para pulsar para hablar. Solo se puede mantener un paso." - -msgid "" -"Command – runs a program or a command line, e.g. /usr/bin/firefox " -"--new-window." -msgstr "" -"Comando – ejecuta un programa o una línea de comandos, p. ej. " -"/usr/bin/firefox --new-window." - -msgid "Disabled – the key does nothing." -msgstr "Desactivado – la tecla no hace nada." - -msgid "" -"Capture – records the keys you press next: press them, then " -"Done. Modifiers are kept (shift, altgr, …) and the names are layout " -"independent, so ctrl+z means the key that types z on your layout." -msgstr "" -"Capturar – graba las siguientes teclas que pulses: púlsalas y luego " -"Aceptar. Los modificadores se conservan (shift, altgr, …) y los " -"nombres no dependen de la distribución, así que ctrl+z es la tecla que " -"escribe z en tu distribución." - -msgid "" -"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " -"the daemon reload it. The menu entries Export configuration... and " -"Import configuration... do the same with a file you choose." -msgstr "" -"Guardar asignaciones – escribe ~/.config/gKeyMapper/mappings.conf y " -"hace que el daemon la recargue. Las entradas de menú Exportar " -"configuración... e Importar configuración... hacen lo mismo con " -"el archivo que elijas." - -msgid "" -"Daemon status – the footer shows whether gkeymapper-daemon is " -"running, which keyboard it found and the last G key that fired. " -"Options shows the same in more detail." -msgstr "" -"Estado del daemon – el pie de ventana muestra si gkeymapper-daemon " -"está en ejecución, qué teclado ha encontrado y la última tecla G que se ha " -"pulsado. Opciones muestra lo mismo con más detalle." - -msgid "" -"Autostart – Start with the session only opens this window when " -"you log in. The G keys work without it: gkeymapper-daemon (a user service) " -"reads them and replays the actions, and that daemon is the only part that has " -"to run." -msgstr "" -"Inicio automático – Iniciar con la sesión solo abre esta " -"ventana. Las teclas G funcionan sin ella: gkeymapper-daemon (un servicio de " -"usuario) las lee y reproduce las acciones, y ese daemon es lo único que tiene " -"que estar en marcha." - -msgid "" -"Tray and window – with Minimize to tray the window is hidden " -"instead of closed when you close or minimize it; the tray icon shows it " -"again (Show) and quits the program (Close). Starting " -"gKeyMapper again closes the old window – rows that were not saved are lost " -"with it." -msgstr "" -"Bandeja y ventana – con Minimizar a la bandeja, al cerrar o " -"minimizar la ventana se oculta en lugar de cerrarse; el icono de la bandeja " -"la vuelve a mostrar (Mostrar) y cierra el programa (Cerrar). " -"Volver a iniciar gKeyMapper cierra la ventana antigua: las filas no " -"guardadas se pierden con ella." +msgid "Close" +msgstr "Cerrar" diff --git a/po/fr.po b/po/fr.po index 90aac75..c14a447 100644 --- a/po/fr.po +++ b/po/fr.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: gkeymapper 0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-18 03:08+0200\n" +"POT-Creation-Date: 2026-10-18 20:18+0200\n" "PO-Revision-Date: 2026-10-18 03:08+0200\n" "Last-Translator: \n" "Language-Team: French\n" @@ -13,14 +13,8 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -msgid "Choose file" -msgstr "Choisir un fichier" - -msgid "_Cancel" -msgstr "_Annuler" - -msgid "_Open" -msgstr "_Ouvrir" +msgid "G-Key mapper for the Logitech G910 (GTK4)." +msgstr "Assignateur de touches G pour le Logitech G910 (GTK4)." msgid "No keys recorded yet" msgstr "Aucune touche enregistrée pour le moment" @@ -28,6 +22,14 @@ msgstr "Aucune touche enregistrée pour le moment" msgid "Capture key sequence" msgstr "Capturer une séquence de touches" +msgid "" +"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " +"recorded too). Click Done to apply or Cancel to discard." +msgstr "" +"Appuyez sur les touches dans l'ordre (les modificateurs comme Ctrl, Alt, " +"Maj, Super et AltGr sont également enregistrés). Cliquez sur Terminer pour " +"appliquer ou sur Annuler pour abandonner." + msgid "Clear" msgstr "Effacer" @@ -38,12 +40,122 @@ msgid "Done" msgstr "Terminer" msgid "" -"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " -"recorded too). Click Done to apply or Cancel to discard." +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." msgstr "" -"Appuyez sur les touches dans l'ordre (les modificateurs comme Ctrl, Alt, " -"Maj, Super et AltGr sont également enregistrés). Cliquez sur Terminer pour " -"appliquer ou sur Annuler pour abandonner." +"Les neuf lignes – une par touche G. Choisissez le type, saisissez ou " +"capturez la cible puis cliquez sur Enregistrer les attributions." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Touche – presse une fois la séquence capturée, par ex. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, e." +"g. for push-to-talk. Only a single step can be held." +msgstr "" +"Touche (maintenue) – l'appuie et la maintient tant que la touche G " +"est maintenue, p. ex. pour appuyer-pour-parler. Une seule étape peut être " +"maintenue." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox --" +"new-window." +msgstr "" +"Commande – lance un programme ou une ligne de commande, par ex. /usr/" +"bin/firefox --new-window." + +msgid "Disabled – the key does nothing." +msgstr "Désactivé – la touche ne fait rien." + +msgid "" +"Capture – records the keys you press next: press them, then Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Capturer – enregistre les touches que vous pressez ensuite : pressez-" +"les puis Terminer. Les modificateurs sont conservés (shift, altgr, …) " +"et les noms ne dépendent pas de la disposition, donc ctrl+z désigne la " +"touche qui tape z sur votre disposition." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Enregistrer les attributions – écrit ~/.config/gKeyMapper/mappings." +"conf et fait recharger le fichier au démon. Les entrées de menu Exporter " +"la configuration... et Importer la configuration... font la même " +"chose avec le fichier de votre choix." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. Options shows the same in more detail." +msgstr "" +"État du démon – le pied de fenêtre indique si gkeymapper-daemon est " +"en cours d'exécution, quel clavier a été trouvé et la dernière touche G " +"déclenchée. Options affiche la même chose plus en détail." + +msgid "" +"Autostart – Start with the session opens this window minimized " +"to the tray when you log in. The G keys work without it: gkeymapper-daemon " +"(a user service) reads them and replays the actions, and that daemon is the " +"only part that has to run." +msgstr "" +"Démarrage automatique – Démarrer avec la session ouvre cette " +"fenêtre réduite dans la zone de notification à l'ouverture de la session. " +"Les touches G fonctionnent sans elle : gkeymapper-daemon (un service " +"utilisateur) les lit et rejoue les actions, et ce démon est la seule partie " +"qui doit tourner." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved yet come " +"along." +msgstr "" +"Zone de notification et fenêtre – avec Réduire dans la zone de " +"notification, la fenêtre est masquée au lieu d'être fermée lorsque vous " +"la fermez ou la réduisez ; l'icône de la zone de notification la réaffiche " +"(Afficher) et quitte le programme (Fermer). Relancer " +"gKeyMapper ferme l'ancienne fenêtre : les lignes pas encore enregistrées " +"sont conservées." + +msgid "Help" +msgstr "Aide" + +msgid "Close window" +msgstr "Fermer la fenêtre" + +msgid "Daemon: not reachable" +msgstr "Démon : injoignable" + +msgid "Daemon: running" +msgstr "Démon : en cours d'exécution" + +msgid "Device: not found" +msgstr "Appareil : introuvable" + +#, c-format +msgid "Device: %s" +msgstr "Appareil : %s" + +msgid "Configuration error" +msgstr "Erreur de configuration" + +#, c-format +msgid "Last key: %s" +msgstr "Dernière touche : %s" + +msgid "Choose file" +msgstr "Choisir un fichier" + +msgid "_Cancel" +msgstr "_Annuler" + +msgid "_Open" +msgstr "_Ouvrir" msgid "Choose file..." msgstr "Choisir un fichier..." @@ -54,12 +166,12 @@ msgstr "ex. /usr/bin/firefox ou commande personnalisée" msgid "Capture" msgstr "Capturer" -msgid "e.g. ctrl+c, ctrl+v" -msgstr "ex. ctrl+c, ctrl+v" - msgid "e.g. ctrl+w (held while the G key is held)" msgstr "ex. ctrl+w (maintenue tant que la touche G est enfoncée)" +msgid "e.g. ctrl+c, ctrl+v" +msgstr "ex. ctrl+c, ctrl+v" + #, c-format msgid "Could not save %s.\n" msgstr "Impossible d'enregistrer %s.\n" @@ -67,19 +179,12 @@ msgstr "Impossible d'enregistrer %s.\n" msgid "Mappings saved to " msgstr "Attributions enregistrées dans " -#, c-format -msgid "Could not open %s.\n" -msgstr "Impossible d'ouvrir %s.\n" - msgid "Could not export mappings.\n" msgstr "Impossible d'exporter les attributions.\n" msgid "gKeyMapper" msgstr "gKeyMapper" -msgid "G-Key mapper for the Logitech G910 (GTK4)." -msgstr "Assignateur de touches G pour le Logitech G910 (GTK4)." - msgid "Export configuration" msgstr "Exporter la configuration" @@ -98,11 +203,19 @@ msgstr "Exporter la configuration..." msgid "Import configuration..." msgstr "Importer la configuration..." +msgid "Options" +msgstr "Options" + msgid "About" msgstr "À propos" +msgid "Quit" +msgstr "Quitter" + msgid "G1 to G9 of the Logitech G910 remapped to keys or commands" -msgstr "Réassignation des touches G1 à G9 du Logitech G910 vers des touches ou des commandes" +msgstr "" +"Réassignation des touches G1 à G9 du Logitech G910 vers des touches ou des " +"commandes" msgid "G-Key" msgstr "Touche G" @@ -134,44 +247,12 @@ msgstr "Enregistrer les attributions" msgid "Saved to ~/.config/gKeyMapper/mappings.conf" msgstr "Enregistré dans ~/.config/gKeyMapper/mappings.conf" -msgid "Daemon: not reachable" -msgstr "Démon : injoignable" - -msgid "Daemon: running" -msgstr "Démon : en cours d'exécution" - -msgid "Device: %s" -msgstr "Appareil : %s" - -msgid "Device: not found" -msgstr "Appareil : introuvable" - -msgid "Configuration error" -msgstr "Erreur de configuration" - -msgid "Last key: %s" -msgstr "Dernière touche : %s" - msgid "Daemon: checking..." msgstr "Démon : vérification..." -msgid "Options" -msgstr "Options" - -msgid "Help" -msgstr "Aide" - -msgid "Quit" -msgstr "Quitter" - -msgid "Show" -msgstr "Afficher" - -msgid "Close" -msgstr "Fermer" - -msgid "Close window" -msgstr "Fermer la fenêtre" +#, c-format +msgid "Could not open %s.\n" +msgstr "Impossible d'ouvrir %s.\n" msgid "Window" msgstr "Fenêtre" @@ -179,13 +260,16 @@ msgstr "Fenêtre" msgid "Minimize to tray" msgstr "Réduire dans la zone de notification" +msgid "Start with the session" +msgstr "Démarrer avec la session" + msgid "" -"Closing or minimizing the window hides it instead of quitting. The tray icon " -"shows it again (Show) and quits the program (Close)." +"Opens this window minimized to the tray when you log in. Not needed for the " +"G keys - the daemon does the work and is the only part that has to run." msgstr "" -"À la fermeture ou à la réduction, la fenêtre est masquée au lieu de quitter. " -"L'icône de la zone de notification la réaffiche (Afficher) et quitte le " -"programme (Fermer)." +"Ouvre cette fenêtre réduite dans la zone de notification à l'ouverture de la " +"session. Inutile pour les touches G : c'est le démon qui fait le travail, et " +"lui seul doit tourner." msgid "Language" msgstr "Langue" @@ -198,21 +282,6 @@ msgstr "" "L'interface change immédiatement, y compris le menu de la zone de " "notification." -msgid "Start with the session" -msgstr "Démarrer avec la session" - -msgid "" -"Opens this window when you log in. Not needed for the G keys - the daemon " -"does the work and is the only part that has to run." -msgstr "" -"Ouvre cette fenêtre à l'ouverture de la session. Inutile pour les touches G : " -"c'est le démon qui fait le travail, et lui seul doit tourner." - -msgid "No tray is available on this desktop, so the window cannot be hidden." -msgstr "" -"Ce bureau n'a pas de zone de notification, la fenêtre ne peut donc pas être " -"masquée." - msgid "Daemon status" msgstr "État du démon" @@ -234,6 +303,19 @@ msgstr "Fichier de configuration" msgid "Last key" msgstr "Dernière touche" +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"À la fermeture ou à la réduction, la fenêtre est masquée au lieu de quitter. " +"L'icône de la zone de notification la réaffiche (Afficher) et quitte le " +"programme (Fermer)." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" +"Ce bureau n'a pas de zone de notification, la fenêtre ne peut donc pas être " +"masquée." + msgid "active" msgstr "actif" @@ -249,84 +331,8 @@ msgstr "inaccessible" msgid "not found" msgstr "introuvable" -msgid "" -"The nine rows – one per G key. Choose the type, then enter or capture " -"the target and press Save mappings." -msgstr "" -"Les neuf lignes – une par touche G. Choisissez le type, saisissez ou " -"capturez la cible puis cliquez sur Enregistrer les attributions." +msgid "Show" +msgstr "Afficher" -msgid "Key – presses the captured sequence once, e.g. ctrl+c." -msgstr "Touche – presse une fois la séquence capturée, par ex. ctrl+c." - -msgid "" -"Key (hold) – presses it and keeps it down while the G key is held, " -"e.g. for push-to-talk. Only a single step can be held." -msgstr "" -"Touche (maintenue) – l'appuie et la maintient tant que la touche G " -"est maintenue, p. ex. pour appuyer-pour-parler. Une seule étape peut être " -"maintenue." - -msgid "" -"Command – runs a program or a command line, e.g. /usr/bin/firefox " -"--new-window." -msgstr "" -"Commande – lance un programme ou une ligne de commande, par ex. " -"/usr/bin/firefox --new-window." - -msgid "Disabled – the key does nothing." -msgstr "Désactivé – la touche ne fait rien." - -msgid "" -"Capture – records the keys you press next: press them, then " -"Done. Modifiers are kept (shift, altgr, …) and the names are layout " -"independent, so ctrl+z means the key that types z on your layout." -msgstr "" -"Capturer – enregistre les touches que vous pressez ensuite : " -"pressez-les puis Terminer. Les modificateurs sont conservés (shift, " -"altgr, …) et les noms ne dépendent pas de la disposition, donc ctrl+z " -"désigne la touche qui tape z sur votre disposition." - -msgid "" -"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " -"the daemon reload it. The menu entries Export configuration... and " -"Import configuration... do the same with a file you choose." -msgstr "" -"Enregistrer les attributions – écrit " -"~/.config/gKeyMapper/mappings.conf et fait recharger le fichier au démon. " -"Les entrées de menu Exporter la configuration... et Importer la " -"configuration... font la même chose avec le fichier de votre choix." - -msgid "" -"Daemon status – the footer shows whether gkeymapper-daemon is " -"running, which keyboard it found and the last G key that fired. " -"Options shows the same in more detail." -msgstr "" -"État du démon – le pied de fenêtre indique si gkeymapper-daemon est " -"en cours d'exécution, quel clavier a été trouvé et la dernière touche G " -"déclenchée. Options affiche la même chose plus en détail." - -msgid "" -"Autostart – Start with the session only opens this window when " -"you log in. The G keys work without it: gkeymapper-daemon (a user service) " -"reads them and replays the actions, and that daemon is the only part that has " -"to run." -msgstr "" -"Démarrage automatique – Démarrer avec la session ouvre " -"seulement cette fenêtre. Les touches G fonctionnent sans elle : " -"gkeymapper-daemon (un service utilisateur) les lit et rejoue les actions, et " -"ce démon est la seule partie qui doit tourner." - -msgid "" -"Tray and window – with Minimize to tray the window is hidden " -"instead of closed when you close or minimize it; the tray icon shows it " -"again (Show) and quits the program (Close). Starting " -"gKeyMapper again closes the old window – rows that were not saved are lost " -"with it." -msgstr "" -"Zone de notification et fenêtre – avec Réduire dans la zone de " -"notification, la fenêtre est masquée au lieu d'être fermée lorsque vous " -"la fermez ou la réduisez ; l'icône de la zone de notification la réaffiche " -"(Afficher) et quitte le programme (Fermer). Relancer " -"gKeyMapper ferme l'ancienne fenêtre : les lignes non enregistrées sont " -"perdues avec elle." +msgid "Close" +msgstr "Fermer" diff --git a/po/gkeymapper.pot b/po/gkeymapper.pot index 78524c5..87ec1d3 100644 --- a/po/gkeymapper.pot +++ b/po/gkeymapper.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-18 03:08+0200\n" +"POT-Creation-Date: 2026-10-18 20:18+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,16 +17,8 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/main.cpp:126 -msgid "Choose file" -msgstr "" - -#: src/main.cpp:129 src/main.cpp:300 src/main.cpp:312 -msgid "_Cancel" -msgstr "" - -#: src/main.cpp:130 src/main.cpp:313 -msgid "_Open" +#: src/autostart.cpp:97 src/main.cpp:280 src/trayicon.cpp:371 +msgid "G-Key mapper for the Logitech G910 (GTK4)." msgstr "" #: src/capturedialog.cpp:115 src/capturedialog.cpp:226 @@ -37,6 +29,12 @@ msgstr "" msgid "Capture key sequence" msgstr "" +#: src/capturedialog.cpp:220 +msgid "" +"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " +"recorded too). Click Done to apply or Cancel to discard." +msgstr "" + #: src/capturedialog.cpp:250 msgid "Clear" msgstr "" @@ -49,270 +47,6 @@ msgstr "" msgid "Done" msgstr "" -#: src/capturedialog.cpp:220 -msgid "" -"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " -"recorded too). Click Done to apply or Cancel to discard." -msgstr "" - -#: src/main.cpp:160 -msgid "Choose file..." -msgstr "" - -#: src/main.cpp:162 -msgid "e.g. /usr/bin/firefox or custom command" -msgstr "" - -#: src/main.cpp:164 src/main.cpp:168 src/main.cpp:685 -msgid "Capture" -msgstr "" - -#: src/main.cpp:170 src/main.cpp:678 -msgid "e.g. ctrl+c, ctrl+v" -msgstr "" - -#: src/main.cpp:166 -msgid "e.g. ctrl+w (held while the G key is held)" -msgstr "" - -#: src/main.cpp:185 -#, c-format -msgid "Could not save %s.\n" -msgstr "" - -#: src/main.cpp:193 -msgid "Mappings saved to " -msgstr "" - -#: src/mappings.cpp:41 -#, c-format -msgid "Could not open %s.\n" -msgstr "" - -#: src/main.cpp:203 -msgid "Could not export mappings.\n" -msgstr "" - -#: src/main.cpp:264 src/main.cpp:590 src/trayicon.cpp:370 src/trayicon.cpp:500 -msgid "gKeyMapper" -msgstr "" - -#: src/autostart.cpp:97 src/main.cpp:267 src/trayicon.cpp:371 -msgid "G-Key mapper for the Logitech G910 (GTK4)." -msgstr "" - -#: src/main.cpp:297 -msgid "Export configuration" -msgstr "" - -#: src/main.cpp:301 -msgid "_Save" -msgstr "" - -#: src/main.cpp:309 -msgid "Import configuration" -msgstr "" - -#: src/main.cpp:575 -msgid "gKeyMapper – Logitech G910" -msgstr "" - -#: src/main.cpp:594 -msgid "Export configuration..." -msgstr "" - -#: src/main.cpp:595 -msgid "Import configuration..." -msgstr "" - -#: src/main.cpp:603 -msgid "About" -msgstr "" - -#: src/main.cpp:623 -msgid "G1 to G9 of the Logitech G910 remapped to keys or commands" -msgstr "" - -#: src/main.cpp:644 -msgid "G-Key" -msgstr "" - -#: src/main.cpp:644 -msgid "Type" -msgstr "" - -#: src/main.cpp:644 -msgid "Target (Key/Command)" -msgstr "" - -#: src/main.cpp:644 -msgid "Action" -msgstr "" - -#: src/main.cpp:653 -msgid "Key" -msgstr "" - -#: src/main.cpp:653 -msgid "Command" -msgstr "" - -#: src/main.cpp:653 -msgid "Disabled" -msgstr "" - -#: src/main.cpp:654 -msgid "Key (hold)" -msgstr "" - -#: src/main.cpp:713 -msgid "Save mappings" -msgstr "" - -#: src/main.cpp:720 -msgid "Saved to ~/.config/gKeyMapper/mappings.conf" -msgstr "" - -#: src/main.cpp:63 -msgid "Daemon: not reachable" -msgstr "" - -#: src/main.cpp:65 -msgid "Daemon: running" -msgstr "" - -#: src/main.cpp:70 -msgid "Device: not found" -msgstr "" - -#: src/main.cpp:73 -msgid "Device: %s" -msgstr "" - -#: src/main.cpp:78 src/optionsdialog.cpp:212 -msgid "Configuration error" -msgstr "" - -#: src/main.cpp:83 -msgid "Last key: %s" -msgstr "" - -#: src/main.cpp:731 -msgid "Daemon: checking..." -msgstr "" - -#: src/main.cpp:598 src/optionsdialog.cpp:81 -msgid "Options" -msgstr "" - -#: src/help.cpp:67 src/help.cpp:86 src/main.cpp:602 -msgid "Help" -msgstr "" - -#: src/main.cpp:609 -msgid "Quit" -msgstr "" - -#: src/trayicon.cpp:136 -msgid "Show" -msgstr "" - -#: src/trayicon.cpp:139 -msgid "Close" -msgstr "" - -#: src/help.cpp:95 src/optionsdialog.cpp:164 -msgid "Close window" -msgstr "" - -#: src/optionsdialog.cpp:95 -msgid "Window" -msgstr "" - -#: src/optionsdialog.cpp:97 -msgid "Minimize to tray" -msgstr "" - -#: src/optionsdialog.cpp:200 -msgid "" -"Closing or minimizing the window hides it instead of quitting. The tray icon " -"shows it again (Show) and quits the program (Close)." -msgstr "" - -#: src/optionsdialog.cpp:128 src/optionsdialog.cpp:136 -msgid "Language" -msgstr "" - -#: src/optionsdialog.cpp:131 -msgid "System language" -msgstr "" - -#: src/optionsdialog.cpp:141 -msgid "The interface switches right away, including the tray menu." -msgstr "" - -#: src/optionsdialog.cpp:110 -msgid "Start with the session" -msgstr "" - -#: src/optionsdialog.cpp:122 -msgid "" -"Opens this window when you log in. Not needed for the G keys - the daemon " -"does the work and is the only part that has to run." -msgstr "" - -#: src/optionsdialog.cpp:202 -msgid "No tray is available on this desktop, so the window cannot be hidden." -msgstr "" - -#: src/optionsdialog.cpp:144 -msgid "Daemon status" -msgstr "" - -#: src/optionsdialog.cpp:157 -msgid "Tray icon" -msgstr "" - -#: src/optionsdialog.cpp:158 -msgid "State" -msgstr "" - -#: src/optionsdialog.cpp:159 -msgid "Version" -msgstr "" - -#: src/optionsdialog.cpp:160 -msgid "Device" -msgstr "" - -#: src/optionsdialog.cpp:161 -msgid "Configuration file" -msgstr "" - -#: src/optionsdialog.cpp:162 -msgid "Last key" -msgstr "" - -#: src/optionsdialog.cpp:205 -msgid "active" -msgstr "" - -#: src/optionsdialog.cpp:205 -msgid "not available" -msgstr "" - -#: src/optionsdialog.cpp:206 -msgid "running" -msgstr "" - -#: src/optionsdialog.cpp:206 -msgid "not reachable" -msgstr "" - -#: src/optionsdialog.cpp:208 -msgid "not found" -msgstr "" - #: src/help.cpp:34 msgid "" "The nine rows – one per G key. Choose the type, then enter or capture " @@ -325,14 +59,14 @@ msgstr "" #: src/help.cpp:37 msgid "" -"Key (hold) – presses it and keeps it down while the G key is held, " -"e.g. for push-to-talk. Only a single step can be held." +"Key (hold) – presses it and keeps it down while the G key is held, e." +"g. for push-to-talk. Only a single step can be held." msgstr "" #: src/help.cpp:39 msgid "" -"Command – runs a program or a command line, e.g. /usr/bin/firefox " -"--new-window." +"Command – runs a program or a command line, e.g. /usr/bin/firefox --" +"new-window." msgstr "" #: src/help.cpp:40 @@ -341,8 +75,8 @@ msgstr "" #: src/help.cpp:41 msgid "" -"Capture – records the keys you press next: press them, then " -"Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"Capture – records the keys you press next: press them, then Done. Modifiers are kept (shift, altgr, …) and the names are layout " "independent, so ctrl+z means the key that types z on your layout." msgstr "" @@ -356,16 +90,16 @@ msgstr "" #: src/help.cpp:47 msgid "" "Daemon status – the footer shows whether gkeymapper-daemon is " -"running, which keyboard it found and the last G key that fired. " -"Options shows the same in more detail." +"running, which keyboard it found and the last G key that fired. Options shows the same in more detail." msgstr "" #: src/help.cpp:50 msgid "" -"Autostart – Start with the session only opens this window when " -"you log in. The G keys work without it: gkeymapper-daemon (a user service) " -"reads them and replays the actions, and that daemon is the only part that has " -"to run." +"Autostart – Start with the session opens this window minimized " +"to the tray when you log in. The G keys work without it: gkeymapper-daemon " +"(a user service) reads them and replays the actions, and that daemon is the " +"only part that has to run." msgstr "" #: src/help.cpp:53 @@ -373,6 +107,274 @@ msgid "" "Tray and window – with Minimize to tray the window is hidden " "instead of closed when you close or minimize it; the tray icon shows it " "again (Show) and quits the program (Close). Starting " -"gKeyMapper again closes the old window – rows that were not saved are lost " -"with it." +"gKeyMapper again closes the old window – rows that were not saved yet come " +"along." +msgstr "" + +#: src/help.cpp:67 src/help.cpp:86 src/main.cpp:635 +msgid "Help" +msgstr "" + +#: src/help.cpp:95 src/optionsdialog.cpp:177 +msgid "Close window" +msgstr "" + +#: src/main.cpp:76 +msgid "Daemon: not reachable" +msgstr "" + +#: src/main.cpp:78 +msgid "Daemon: running" +msgstr "" + +#: src/main.cpp:83 +msgid "Device: not found" +msgstr "" + +#: src/main.cpp:86 +#, c-format +msgid "Device: %s" +msgstr "" + +#: src/main.cpp:91 src/optionsdialog.cpp:228 +msgid "Configuration error" +msgstr "" + +#: src/main.cpp:96 +#, c-format +msgid "Last key: %s" +msgstr "" + +#: src/main.cpp:139 +msgid "Choose file" +msgstr "" + +#: src/main.cpp:142 src/main.cpp:313 src/main.cpp:325 +msgid "_Cancel" +msgstr "" + +#: src/main.cpp:143 src/main.cpp:326 +msgid "_Open" +msgstr "" + +#: src/main.cpp:173 +msgid "Choose file..." +msgstr "" + +#: src/main.cpp:175 +msgid "e.g. /usr/bin/firefox or custom command" +msgstr "" + +#: src/main.cpp:177 src/main.cpp:181 src/main.cpp:726 +msgid "Capture" +msgstr "" + +#: src/main.cpp:179 +msgid "e.g. ctrl+w (held while the G key is held)" +msgstr "" + +#: src/main.cpp:183 src/main.cpp:719 +msgid "e.g. ctrl+c, ctrl+v" +msgstr "" + +#: src/main.cpp:198 +#, c-format +msgid "Could not save %s.\n" +msgstr "" + +#: src/main.cpp:206 +msgid "Mappings saved to " +msgstr "" + +#: src/main.cpp:216 +msgid "Could not export mappings.\n" +msgstr "" + +#: src/main.cpp:277 src/main.cpp:623 src/trayicon.cpp:370 src/trayicon.cpp:500 +msgid "gKeyMapper" +msgstr "" + +#: src/main.cpp:310 +msgid "Export configuration" +msgstr "" + +#: src/main.cpp:314 +msgid "_Save" +msgstr "" + +#: src/main.cpp:322 +msgid "Import configuration" +msgstr "" + +#: src/main.cpp:608 +msgid "gKeyMapper – Logitech G910" +msgstr "" + +#: src/main.cpp:627 +msgid "Export configuration..." +msgstr "" + +#: src/main.cpp:628 +msgid "Import configuration..." +msgstr "" + +#: src/main.cpp:631 src/optionsdialog.cpp:83 +msgid "Options" +msgstr "" + +#: src/main.cpp:636 +msgid "About" +msgstr "" + +#: src/main.cpp:642 +msgid "Quit" +msgstr "" + +#: src/main.cpp:656 +msgid "G1 to G9 of the Logitech G910 remapped to keys or commands" +msgstr "" + +#: src/main.cpp:677 +msgid "G-Key" +msgstr "" + +#: src/main.cpp:677 +msgid "Type" +msgstr "" + +#: src/main.cpp:677 +msgid "Target (Key/Command)" +msgstr "" + +#: src/main.cpp:677 +msgid "Action" +msgstr "" + +#: src/main.cpp:689 +msgid "Key" +msgstr "" + +#: src/main.cpp:689 +msgid "Command" +msgstr "" + +#: src/main.cpp:689 +msgid "Disabled" +msgstr "" + +#: src/main.cpp:690 +msgid "Key (hold)" +msgstr "" + +#: src/main.cpp:761 +msgid "Save mappings" +msgstr "" + +#: src/main.cpp:768 +msgid "Saved to ~/.config/gKeyMapper/mappings.conf" +msgstr "" + +#: src/main.cpp:779 +msgid "Daemon: checking..." +msgstr "" + +#: src/mappings.cpp:41 +#, c-format +msgid "Could not open %s.\n" +msgstr "" + +#: src/optionsdialog.cpp:97 +msgid "Window" +msgstr "" + +#: src/optionsdialog.cpp:99 +msgid "Minimize to tray" +msgstr "" + +#: src/optionsdialog.cpp:112 +msgid "Start with the session" +msgstr "" + +#: src/optionsdialog.cpp:124 +msgid "" +"Opens this window minimized to the tray when you log in. Not needed for the " +"G keys - the daemon does the work and is the only part that has to run." +msgstr "" + +#: src/optionsdialog.cpp:131 src/optionsdialog.cpp:149 +msgid "Language" +msgstr "" + +#: src/optionsdialog.cpp:137 +msgid "System language" +msgstr "" + +#: src/optionsdialog.cpp:154 +msgid "The interface switches right away, including the tray menu." +msgstr "" + +#: src/optionsdialog.cpp:157 +msgid "Daemon status" +msgstr "" + +#: src/optionsdialog.cpp:170 +msgid "Tray icon" +msgstr "" + +#: src/optionsdialog.cpp:171 +msgid "State" +msgstr "" + +#: src/optionsdialog.cpp:172 +msgid "Version" +msgstr "" + +#: src/optionsdialog.cpp:173 +msgid "Device" +msgstr "" + +#: src/optionsdialog.cpp:174 +msgid "Configuration file" +msgstr "" + +#: src/optionsdialog.cpp:175 +msgid "Last key" +msgstr "" + +#: src/optionsdialog.cpp:216 +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" + +#: src/optionsdialog.cpp:218 +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" + +#: src/optionsdialog.cpp:221 +msgid "active" +msgstr "" + +#: src/optionsdialog.cpp:221 +msgid "not available" +msgstr "" + +#: src/optionsdialog.cpp:222 +msgid "running" +msgstr "" + +#: src/optionsdialog.cpp:222 +msgid "not reachable" +msgstr "" + +#: src/optionsdialog.cpp:224 +msgid "not found" +msgstr "" + +#: src/trayicon.cpp:136 +msgid "Show" +msgstr "" + +#: src/trayicon.cpp:139 +msgid "Close" msgstr "" diff --git a/scripts/i18n.sh b/scripts/i18n.sh new file mode 100755 index 0000000..edab615 --- /dev/null +++ b/scripts/i18n.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash +# +# gKeyMapper - everything that touches po/*.po, po/gkeymapper.pot and the compiled +# catalogs, in one place, so a translation change is a command instead of a list of +# steps to remember. The rules behind it are written down in po/README.md. +# +# Usage: scripts/i18n.sh [arguments] +# extract regenerate po/gkeymapper.pot from the sources +# update [lang...] merge the pot into the catalogs: new strings come in +# untranslated, existing translations are kept +# check validate the pot, the catalogs and the language list +# status per-language overview (counts + compiled catalog) +# todo what is still untranslated or fuzzy, with the source +# locations from the pot +# new [name] create po/.po from the pot (name = Language-Team, +# e.g. "Italiano"); "new en" writes the identity catalog +# help this text +# +# Only gettext tools and coreutils are used. The tools can be overridden from the +# environment, like the Makefile's variables: XGETTEXT=... scripts/i18n.sh extract + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# Everything is addressed relative to the repository root and run from there: the +# source locations xgettext writes into the pot ("src/main.cpp:582") and the file +# names the gettext tools print are relative to the current directory, and absolute +# paths would end up in the pot and in every message this script shows. +cd -- "$ROOT" || exit 1 +PO_DIR="po" +POT="$PO_DIR/gkeymapper.pot" +LOCALE_DIR="locale" +PACKAGE="gkeymapper" +# What the catalogs carry in Project-Id-Version (see po/*.po). +PO_VERSION="0.1" + +XGETTEXT=${XGETTEXT:-xgettext} +MSGFMT=${MSGFMT:-msgfmt} +MSGMERGE=${MSGMERGE:-msgmerge} +MSGATTRIB=${MSGATTRIB:-msgattrib} +MSGCAT=${MSGCAT:-msgcat} + +# The counts and message lists below are parsed, so the tools have to answer in +# English, whatever the session's language is. +export LC_ALL=C + +# A temp file for a pot, removed on every exit - also on the error paths, where it +# is the half-written file that must not stay behind. +POT_TMP="" +trap 'rm -f "${POT_TMP:-}"' EXIT + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +die() { + printf 'i18n: %s\n' "$*" >&2 + exit 1 +} + +note() { + printf '%s\n' "$*" +} + +require_tools() { + local tool + for tool in "$@"; do + command -v "$tool" >/dev/null 2>&1 || + die "$tool not found - install gettext (see README, Requirements)" + done +} + +# Every source that may hold translatable strings: the GUI, which is the only +# program with gettext set up (src/main.cpp) - the same two directories the Makefile +# compiles into it. The daemon is English on purpose (see `check`), so its sources +# are not scanned. The pot is generated from this list, in this order, so `extract` +# is reproducible: a hand-ordered file list once made the pot's message order depend +# on the machine it ran on. +sources() { + local file + for file in "$ROOT"/src/*.cpp "$ROOT"/shared/*.cpp; do + if [ -e "$file" ]; then printf '%s\n' "${file#"$ROOT"/}"; fi + done +} + +# The languages that have a catalog, i.e. po/*.po - the same list the Makefile +# compiles and installs. +languages() { + local file + for file in "$PO_DIR"/*.po; do + if [ -e "$file" ]; then basename "$file" .po; fi + done | sort +} + +# The messages of a po/pot, one per line and multi-line strings joined: msgcat does +# the joining, so `_("a" "b")` in the sources and the pot entry are the same string. +# The header (empty msgid) is left out. +msgids() { + "$MSGCAT" --stringtable-output "$1" 2>/dev/null | + sed -n 's/^"\(.*\)" = .*$/\1/p' | sed '/^$/d' +} + +# translated/fuzzy/untranslated of one catalog, into three globals. msgfmt prints +# them as a comma separated list ("81 translated messages, 1 untranslated +# message.") and leaves the parts out that are zero, so the list is split first - +# a pattern like ".*\([0-9]\+\) untranslated" would stop at the last digit of a +# count and read 82 as 2. +stats() { + local out parts + out=$("$MSGFMT" --statistics -o /dev/null "$1" 2>&1) || die "$MSGFMT rejected $1" + parts=$(printf '%s\n' "$out" | tr ',' '\n') + translated=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) translated.*/\1/p'); : "${translated:=0}" + fuzzy=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) fuzzy.*/\1/p'); : "${fuzzy:=0}" + untranslated=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) untranslated.*/\1/p'); : "${untranslated:=0}" +} + +# The `Language:` header of a catalog, so a copy-pasted file is noticed. +po_language() { + sed -n 's/^"Language: \(.*\)\\n"$/\1/p' "$1" +} + +# The POT-Creation-Date of a po/pot. It does not decide whether a file is up to +# date (the message set does); it tells whether a merge happened after the last +# extract. +pot_date() { + sed -n 's/^"POT-Creation-Date: \(.*\)\\n"$/\1/p' "$1" +} + +# One line per entry whose msgstr is not the msgid again - the rule for po/en.po, +# which is an identity catalog because English is the source language. Compared as +# raw (still escaped) text: both sides come from the same file, so the escaping +# matches. Plurals need more than this and are not used anywhere (no ngettext). +identity_mismatches() { + awk ' + function quoted(line) { sub(/^[^"]*"/, "", line); sub(/"$/, "", line); return line } + function check() { if (id != "" && str != id) { print id; mismatches++ } } + /^msgid_plural|^msgstr\[/ { print "plural entries are not supported"; exit 2 } + /^msgid / { check(); id = quoted($0); str = ""; in_id = 1; next } + /^msgstr / { str = quoted($0); in_id = 0; next } + /^"/ { if (in_id) id = id quoted($0); else str = str quoted($0); next } + /^#/ { next } + /^[[:space:]]*$/ { next } + END { check(); exit (mismatches > 0 ? 1 : 0) } + ' "$1" +} + +# The pot as xgettext writes it right now, into the given file. `_()` marks +# strings that are translated when they are used, `N_()` the ones that are only +# collected (the help text is assembled later on); both are named explicitly, +# because a gettext that does not know `_` by itself is not unheard of. +extract_to() { + local out="$1" file + require_tools "$XGETTEXT" + local -a files=() + while IFS= read -r file; do files+=("$file"); done < <(sources) + [ "${#files[@]}" -gt 0 ] || die "no sources found under src/ or shared/" + + "$XGETTEXT" --from-code=UTF-8 --language=C++ --keyword=_ --keyword=N_ \ + --output="$out" "${files[@]}" || die "$XGETTEXT failed" + + # Nothing or header-only means the extraction is broken, never that the program + # has no strings. Checked before the pot is replaced, so a broken gettext + # cannot empty a good pot. + [ -s "$out" ] || die "$XGETTEXT produced no output (is gettext working? $XGETTEXT --version)" + [ "$(grep -c '^msgid "' "$out")" -gt 1 ] || + die "$XGETTEXT extracted no message - are the keywords right?" +} + +# The pot without its header entry and without source locations: the entries of a +# fresh catalog. With identity=1 every msgstr repeats its msgid (po/en.po). +pot_body() { + awk -v identity="$2" ' + NR == 1 { skipping = 1 } + skipping { if ($0 ~ /^$/) skipping = 0; next } + /^#:/ { next } + /^msgid / { + if (identity) { buffer = $0; sub(/^msgid/, "msgstr", buffer); collecting = 1 } + print; next + } + /^"/ { if (collecting) buffer = buffer "\n" $0; print; next } + /^msgstr ""$/ { + if (collecting) { print buffer; collecting = 0; next } + print; next + } + { print } + ' "$1" +} + +# The language codes the GUI offers (src/optionsdialog.cpp, without the leading +# "" for "follow the session"). A catalog that is not in this list cannot be +# picked; a code without a catalog is a window that stays English. +gui_language_codes() { + sed -n 's/.*kLanguageCodes\[\] *= *{\(.*\)};.*/\1/p' "src/optionsdialog.cpp" | + tr ',' '\n' | tr -d ' "' | sed '/^$/d' +} + +# The labels of that dropdown: one more than the codes, the first being +# "System language". +gui_language_labels() { + sed -n '/languageOptions\[\] *= *{/,/nullptr/p' "src/optionsdialog.cpp" | + sed 's/.*{//; s/nullptr.*//' | tr ',' '\n' | grep -c '[^[:space:]]' +} + +# --------------------------------------------------------------------------- +# commands +# --------------------------------------------------------------------------- + +cmd_extract() { + POT_TMP=$(mktemp "${TMPDIR:-/tmp}/$PACKAGE-pot.XXXXXX") + extract_to "$POT_TMP" + + if [ -f "$POT" ]; then + local added gone + added=$(comm -13 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) + gone=$(comm -23 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) + note "po/gkeymapper.pot: $(msgids "$POT_TMP" | wc -l) messages" + [ -z "$added" ] || { note "new:"; printf '%s\n' "$added" | sed 's/^/ /'; } + [ -z "$gone" ] || { note "gone:"; printf '%s\n' "$gone" | sed 's/^/ /'; } + else + note "po/gkeymapper.pot: $(msgids "$POT_TMP" | wc -l) messages" + fi + + mv "$POT_TMP" "$POT" + POT_TMP="" # moved, nothing to clean up + note "wrote po/gkeymapper.pot - now: scripts/i18n.sh update" +} + +cmd_update() { + local lang po + require_tools "$MSGMERGE" "$MSGFMT" + [ -f "$POT" ] || die "$POT is missing - run: scripts/i18n.sh extract" + + local -a targets + if [ "$#" -gt 0 ]; then targets=("$@"); else mapfile -t targets < <(languages); fi + [ "${#targets[@]}" -gt 0 ] || die "no po/*.po to update" + + for lang in "${targets[@]}"; do + po="$PO_DIR/$lang.po" + [ -f "$po" ] || die "$po does not exist (a new language: scripts/i18n.sh new $lang)" + # --no-location: the catalogs carry no "#: file:line" comments on purpose - + # they are noise for translators and churn whenever a line moves. The + # locations live in the pot, and `todo` shows them from there. + "$MSGMERGE" --quiet --update --no-location "$po" "$POT" + # msgmerge keeps the previous file as ~; a backup in git is noise. + rm -f "$po~" + stats "$po" + note "po/$lang.po: $translated translated, $fuzzy fuzzy, $untranslated untranslated" + done +} + +cmd_check() { + local lang po code out failures=0 stale_dates="" + require_tools "$MSGFMT" "$MSGATTRIB" "$MSGCAT" "$MSGMERGE" "$XGETTEXT" + [ -f "$POT" ] || die "$POT is missing" + + local langs + langs=$(languages) + [ -n "$langs" ] || die "no po/*.po found" + + # 1. Is the pot what the sources say? A message that was added or changed + # without touching the pot is what this catches - the source locations only + # drift when a line moves, which is worth a note, not a failure. + POT_TMP=$(mktemp "${TMPDIR:-/tmp}/$PACKAGE-pot.XXXXXX") + extract_to "$POT_TMP" + local added gone + added=$(comm -13 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) + gone=$(comm -23 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort)) + if [ -n "$added" ] || [ -n "$gone" ]; then + note "po/gkeymapper.pot is out of date:" + [ -z "$added" ] || { note " in the sources, not in the pot:"; printf '%s\n' "$added" | sed 's/^/ /'; } + [ -z "$gone" ] || { note " in the pot, not in the sources:"; printf '%s\n' "$gone" | sed 's/^/ /'; } + note " fix: scripts/i18n.sh extract && scripts/i18n.sh update" + failures=$((failures + 1)) + elif ! diff -q <(grep -v '^"POT-Creation-Date:' "$POT_TMP") \ + <(grep -v '^"POT-Creation-Date:' "$POT") >/dev/null; then + note "po/gkeymapper.pot has the same messages, but an older extract (line numbers, order)" + note " only the locations in 'todo' are affected - scripts/i18n.sh extract refreshes them" + fi + + # 2. Every catalog: parsable, complete, translated, and named what it says. + for lang in $langs; do + po="$PO_DIR/$lang.po" + + # -c checks the format strings (%s, %d, ...) and the entry structure. + if ! out=$("$MSGFMT" -c -o /dev/null "$po" 2>&1); then + note "$lang: $MSGFMT -c failed:" + printf '%s\n' "$out" | sed 's/^/ /' + failures=$((failures + 1)) + fi + + # Does the catalog define the same messages as the pot? Compared as two + # lists rather than with msgcmp, which would also report every untranslated + # entry and drown the real problem (the counts below say what is missing). + local missing extra + missing=$(comm -23 <(msgids "$POT" | sort) <(msgids "$po" | sort)) + extra=$(comm -13 <(msgids "$POT" | sort) <(msgids "$po" | sort)) + if [ -n "$missing" ]; then + note "$lang: $po does not define:" + printf '%s\n' "$missing" | sed 's/^/ /' + failures=$((failures + 1)) + fi + if [ -n "$extra" ]; then + note "$lang: $po defines messages the pot does not know:" + printf '%s\n' "$extra" | sed 's/^/ /' + failures=$((failures + 1)) + fi + + stats "$po" + if [ "$untranslated" -ne 0 ]; then + note "$lang: $untranslated untranslated message(s) - scripts/i18n.sh todo $lang" + failures=$((failures + 1)) + fi + if [ "$fuzzy" -ne 0 ]; then + note "$lang: $fuzzy fuzzy message(s) - those are shown in English" + failures=$((failures + 1)) + fi + + # Strings that vanished from the program: msgmerge keeps them as "#~" + # entries. Not an error, but stale food for the next translator. + local obsolete=0 + obsolete=$(grep -c '^#~' "$po" || true) + [ "$obsolete" -eq 0 ] || + note "$lang: $obsolete obsolete entries (gone from the sources; msgattrib --no-obsolete drops them)" + + if [ "$(po_language "$po")" != "$lang" ]; then + note "$lang: the header says Language: $(po_language "$po")" + failures=$((failures + 1)) + fi + + # English has a catalog of its own (an explicit language has to work on a + # German desktop) and it is an identity one. + if [ "$lang" = "en" ]; then + if ! out=$(identity_mismatches "$po"); then + note "en: po/en.po is not an identity catalog:" + printf '%s\n' "$out" | head -20 | sed 's/^/ /' + failures=$((failures + 1)) + fi + fi + + # A note, not a failure: it only means the file was not merged after the + # last extract, so its header carries an older date than the pot. Collected + # into one line - it says nothing about a single language. + if [ "$(pot_date "$po")" != "$(pot_date "$POT")" ]; then + stale_dates="$stale_dates $lang" + fi + done + [ -z "$stale_dates" ] || + note "not merged against the current pot:$stale_dates (scripts/i18n.sh update)" + + # 3. The languages the GUI offers (src/optionsdialog.cpp): a catalog that cannot + # be selected is dead weight, a code without a catalog is an English window + # with a German label. + local codes + codes=$(gui_language_codes) + [ -n "$codes" ] || die "could not read kLanguageCodes[] from src/optionsdialog.cpp" + for lang in $langs; do + if ! grep -qx "$lang" <<<"$codes"; then + note "po/$lang.po exists, but $lang is not offered in the options window" + note " add it to kLanguageCodes[] and languageOptions[] (src/optionsdialog.cpp)" + failures=$((failures + 1)) + fi + done + for code in $codes; do + if [ ! -f "$PO_DIR/$code.po" ]; then + note "the options window offers $code, but po/$code.po is missing" + failures=$((failures + 1)) + fi + done + if [ "$(gui_language_labels)" -ne "$(( $(printf '%s\n' "$codes" | wc -l) + 1 ))" ]; then + note "kLanguageCodes[] and languageOptions[] differ in length (src/optionsdialog.cpp)" + failures=$((failures + 1)) + fi + + # 4. Strings marked in the daemon. It has no gettext setup (no setlocale, no + # bindtextdomain - see daemon/main.cpp), so a `_()` there would end up in the + # pot, get translated, and still show English forever. The GUI is the + # translated program. + local daemon_marked + daemon_marked=$(grep -lE '(^|[^[:alnum:]_])_\("|N_\("' daemon/*.cpp 2>/dev/null || true) + if [ -n "$daemon_marked" ]; then + note "the daemon is not translated, but these files mark strings:" + printf '%s\n' "$daemon_marked" | sed 's/^/ /' + note " either drop the marker, or give the daemon its own gettext setup and" + note " add daemon/ to sources() in scripts/i18n.sh" + failures=$((failures + 1)) + fi + + if [ "$failures" -gt 0 ]; then + note "i18n: $failures problem(s)" + return 1 + fi + note "i18n: $(printf '%s\n' "$langs" | wc -l) languages, $(msgids "$POT" | wc -l) messages, everything in sync" +} + +cmd_status() { + local lang po mo mo_state + require_tools "$MSGFMT" "$MSGCAT" + [ -f "$POT" ] || die "$POT is missing" + printf '%-4s %-8s %10s %6s %11s %-9s %s\n' \ + lang messages translated fuzzy untranslated catalog file + for lang in $(languages); do + po="$PO_DIR/$lang.po" + stats "$po" + mo="$LOCALE_DIR/$lang/LC_MESSAGES/$PACKAGE.mo" + mo_state="not built" + if [ -f "$mo" ]; then + # `make translations` rebuilds a catalog that is older than its po. + if [ "$mo" -nt "$po" ]; then mo_state="current"; else mo_state="stale"; fi + fi + printf '%-4s %-8s %10s %6s %11s %-9s %s\n' "$lang" \ + "$((translated + fuzzy + untranslated))" "$translated" "$fuzzy" "$untranslated" \ + "$mo_state" "po/$lang.po" + done + printf '\npot: %s messages, %s\n' "$(msgids "$POT" | wc -l)" "$(pot_date "$POT")" +} + +cmd_todo() { + local lang="${1:-}" + [ -n "$lang" ] || die "usage: scripts/i18n.sh todo " + local po="$PO_DIR/$lang.po" + [ -f "$po" ] || die "$po does not exist" + require_tools "$MSGMERGE" "$MSGATTRIB" + + # Merging the pot into the catalog is what puts the source locations next to + # the messages - the catalogs themselves have none (see cmd_update). + local open + open=$("$MSGMERGE" --quiet "$po" "$POT" | "$MSGATTRIB" --untranslated --no-obsolete) + if [ -n "$open" ]; then + # A valid po comment, so the output can be kept as a file and translated. + note "# --- untranslated in po/$lang.po ---" + printf '%s\n' "$open" + else + note "po/$lang.po: nothing untranslated" + fi + + local fuzzy + fuzzy=$("$MSGMERGE" --quiet "$po" "$POT" | "$MSGATTRIB" --only-fuzzy --no-obsolete) + if [ -n "$fuzzy" ]; then + note + note "# --- fuzzy: shown in English until reviewed, the old text is kept as a comment ---" + printf '%s\n' "$fuzzy" + fi +} + +cmd_new() { + local lang="${1:-}" team="${2:-}" + [ -n "$lang" ] || die "usage: scripts/i18n.sh new [Language-Team]" + case "$lang" in + *[!a-zA-Z_@.-]*) die "unexpected language code: $lang" ;; + esac + [ -f "$POT" ] || die "$POT is missing - run: scripts/i18n.sh extract" + local po="$PO_DIR/$lang.po" + [ -e "$po" ] && die "$po already exists" + if [ -z "$team" ]; then + # The two catalogs for a language that has been here from the start carry + # the English name of the language; anything else falls back to its code. + case "$lang" in + en) team="English" ;; + *) team="$lang" ;; + esac + fi + + { + if [ "$lang" = "en" ]; then + printf '# English translation for %s.\n' "$PACKAGE" + printf '# English is the source language; this catalog keeps the strings explicit.\n' + else + printf '# %s translation for %s.\n' "$team" "$PACKAGE" + printf '# Copyright (C) %s\n' "$(date '+%Y')" + printf '# This file is distributed under the same license as the %s package.\n' "$PACKAGE" + fi + printf '#\n' + # The header in the shape the other catalogs use, so a new language does not + # start out looking different. + cat <"$po" + + note "wrote po/$lang.po" + if [ "$lang" != "en" ]; then + note "next:" + note " translate it, then: scripts/i18n.sh check && make translations" + note " offer it in the options window, or nobody can pick it:" + note " src/optionsdialog.cpp: kLanguageCodes[] and languageOptions[]" + fi +} + +usage() { + cat <<'EOF' +scripts/i18n.sh [arguments] + + extract regenerate po/gkeymapper.pot from the sources + update [lang...] merge the pot into the catalogs: new strings come in + untranslated, existing translations are kept + check validate the pot, the catalogs and the language list + status per-language overview (counts + compiled catalog) + todo what is still untranslated or fuzzy, with the source + locations from the pot + new [name] create po/.po from the pot (name = Language-Team, + e.g. "Italiano"); "new en" writes the identity catalog + help this text + +The rules behind the commands are in po/README.md. gettext tools are used from +PATH; override them like the Makefile's variables, e.g. XGETTEXT=/usr/bin/xgettext +EOF +} + +command="${1:-help}" +shift || true +case "$command" in + extract|pot) cmd_extract "$@" ;; + update) cmd_update "$@" ;; + check) cmd_check "$@" ;; + status) cmd_status "$@" ;; + todo) cmd_todo "$@" ;; + new) cmd_new "$@" ;; + help|-h|--help) usage ;; + *) usage; exit 1 ;; +esac diff --git a/shared/ipc_ids.h b/shared/ipc_ids.h index f00be6c..63f9f04 100644 --- a/shared/ipc_ids.h +++ b/shared/ipc_ids.h @@ -24,6 +24,16 @@ inline constexpr const char* kBusName = "dev.rager.gkeymapper.Daemon"; inline constexpr const char* kObjectPath = "/dev/rager/gkeymapper/Daemon"; inline constexpr const char* kInterface = "dev.rager.gkeymapper.Daemon"; +// Where the GUI answers a second start (see src/singleinstance and src/main.cpp): +// it hands out the rows its window shows, so an instance that is replaced does not +// take unsaved edits with it. The name is the app id itself - GtkApplication owns +// that (see kBusName above), and the GUI is not a service, it only answers while a +// window is up. +inline constexpr const char* kWindowObjectPath = "/dev/rager/gkeymapper/Window"; +inline constexpr const char* kWindowInterface = "dev.rager.gkeymapper.Window"; +// The one method: no arguments, the rows as the mappings text (the file format). +inline constexpr const char* kWindowRowsMethod = "GetRows"; + } // namespace gkeymapper::ipc #endif // GKEYMAPPER_IPC_IDS_H diff --git a/src/autostart.cpp b/src/autostart.cpp index fd3d065..25fb73e 100644 --- a/src/autostart.cpp +++ b/src/autostart.cpp @@ -95,8 +95,10 @@ bool set_autostart_enabled(bool enabled) { // About dialog, so no translator has to carry a second sentence for it. g_key_file_set_string(file, kGroup, "Comment", _("G-Key mapper for the Logitech G910 (GTK4).")); - // Quoted: a path may contain a space. - g_autofree gchar* command = g_strdup_printf("\"%s\"", program_path().c_str()); + // Quoted: a path may contain a space. The flag keeps the window out of the way + // at login, see kStartHiddenArg. + g_autofree gchar* command = + g_strdup_printf("\"%s\" %s", program_path().c_str(), kStartHiddenArg); g_key_file_set_string(file, kGroup, "Exec", command); g_key_file_set_string(file, kGroup, "Icon", "gkeymapper"); g_key_file_set_boolean(file, kGroup, "Terminal", FALSE); diff --git a/src/autostart.h b/src/autostart.h index fcb2229..fd41d48 100644 --- a/src/autostart.h +++ b/src/autostart.h @@ -11,6 +11,13 @@ namespace gkeymapper { // else - no flag in gui.conf that could disagree with it. std::string autostart_file_path(); +// The argument the entry hands to the program: the window is then built but stays +// in the tray (main.cpp), so a login does not put a window in front of the user, +// and asking for it - the tray's Show, or starting gKeyMapper by hand - brings it +// up. It belongs on the Exec line and not in gui.conf: the entry is what the +// session runs, so the flag is the whole truth about what happens at login. +inline constexpr const char* kStartHiddenArg = "--start-hidden"; + // True when that file exists and starts the program. An entry the desktop's own // tool has switched off (Hidden=true, or X-GNOME-Autostart-enabled=false) counts // as off as well, so the switch shows what really happens at login. diff --git a/src/help.cpp b/src/help.cpp index 1438b62..3de9e74 100644 --- a/src/help.cpp +++ b/src/help.cpp @@ -47,13 +47,13 @@ const char* kParagraphs[] = { N_("Daemon status – the footer shows whether gkeymapper-daemon is running, which " "keyboard it found and the last G key that fired. Options shows the same in " "more detail."), - N_("Autostart – Start with the session only opens this window when you log " - "in. The G keys work without it: gkeymapper-daemon (a user service) reads them and " - "replays the actions, and that daemon is the only part that has to run."), + N_("Autostart – Start with the session opens this window minimized to the tray " + "when you log in. The G keys work without it: gkeymapper-daemon (a user service) reads " + "them and replays the actions, and that daemon is the only part that has to run."), N_("Tray and window – with Minimize to tray the window is hidden instead of " "closed when you close or minimize it; the tray icon shows it again (Show) and " "quits the program (Close). Starting gKeyMapper again closes the old window – " - "rows that were not saved are lost with it."), + "rows that were not saved yet come along."), }; } // namespace diff --git a/src/main.cpp b/src/main.cpp index cc9bebd..ec916cc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "mapping.h" #include "mappings.h" #include "optionsdialog.h" +#include "popovers.h" #include "settings.h" #include "singleinstance.h" #include "trayicon.h" @@ -37,6 +38,18 @@ using gkeymapper::kNumKeys; std::vector rows; +// The rows of the instance this start replaced, as a mappings text (empty when +// there was none, or when it did not hand them over - an older build). main() +// fills this in before the window exists; build_ui() starts from it instead of +// from the file, so edits that were never saved survive a second start. +std::string handedOverRows; + +// Set by main() when the session's autostart entry started this instance (the +// --start-hidden argument): the window is built but stays in the tray, so a login +// does not put it in the way. on_activate is what reads it - a window that is +// built later (a language switch) comes up the way it was left. +bool startHidden = false; + // 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. @@ -373,6 +386,10 @@ static std::string find_asset(const std::string& name) { // Size of the pixmap handed to the panel; the panel scales it to its own size. constexpr int kTrayIconSize = 64; +// How long a start from the autostart entry waits for the tray icon before it +// gives up and shows the window (on_start_hidden_timeout). +constexpr guint kStartHiddenGraceMs = 5000; + // 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() { @@ -421,6 +438,19 @@ static gboolean on_minimize_watch(gpointer /*userData*/) { return G_SOURCE_CONTINUE; } +// A start from the session's autostart entry keeps the window out of the way, but +// only a hosted tray icon can bring it back - a desktop without one would leave the +// program with no window and no way to open one. Nothing knows that at startup +// (the panel answers asynchronously), so the window is built hidden and appears +// here if the icon did not turn up in the meantime. +static gboolean on_start_hidden_timeout(gpointer /*userData*/) { + if (!tray.ready()) { + g_printerr("gkeymapper: no tray icon, showing the window\n"); + show_main_window(); + } + return G_SOURCE_REMOVE; +} + // 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. @@ -478,7 +508,7 @@ static void start_tray(GtkApplication* app) { [app]() { g_application_quit(G_APPLICATION(app)); }); } -static void build_ui(GtkApplication* app); +static void build_ui(GtkApplication* app, bool show); static void open_options_dialog(); // Rebuilt from scratch when the language changes: the widgets keep the strings @@ -493,6 +523,9 @@ static gboolean rebuild_ui_idle(gpointer /*userData*/) { // 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(); + // A window that was in the tray (or was built hidden at login) stays there: the + // new one comes up the way the old one was, not in front of the user. + const bool windowShown = gtk_widget_get_visible(mainWindow); // 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); @@ -508,7 +541,7 @@ static gboolean rebuild_ui_idle(gpointer /*userData*/) { gtk_window_destroy(GTK_WINDOW(mainWindow)); mainWindow = nullptr; - build_ui(app); + build_ui(app, windowShown); gkeymapper::apply_mappings(edits, rows); if (helpOpen) { gkeymapper::show_help_window(GTK_WINDOW(mainWindow)); @@ -570,7 +603,7 @@ static void action_options(GSimpleAction* /*action*/, GVariant* /*parameter*/, open_options_dialog(); } -void build_ui(GtkApplication* app) { +void build_ui(GtkApplication* app, bool show) { 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); @@ -650,8 +683,16 @@ void build_ui(GtkApplication* app) { gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1); } + // GTK draws the check mark of the selected entry right behind the text of a + // dropdown row, so every entry carries a trailing space. The catalogs keep + // the bare words; the space is added here, where the model is built. const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"), _("Key (hold)"), nullptr }; + g_autoptr(GtkStringList) typeModel = gtk_string_list_new(nullptr); + for (int i = 0; typeOptions[i] != nullptr; ++i) { + g_autofree gchar* shown = g_strconcat(typeOptions[i], " ", nullptr); + gtk_string_list_append(typeModel, shown); + } rows.resize(kNumKeys); for (int i = 0; i < kNumKeys; ++i) { @@ -665,7 +706,7 @@ void build_ui(GtkApplication* app) { rows[i].keyLabel = keyLabel; // Type dropdown (fixed width so rows don't jump between languages) - GtkWidget* drop = gtk_drop_down_new_from_strings(typeOptions); + GtkWidget* drop = gtk_drop_down_new(G_LIST_MODEL(typeModel), nullptr); 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); @@ -691,12 +732,19 @@ void build_ui(GtkApplication* app) { update_row_state(&rows[i]); } - // Start from the saved mappings. Without this the rows are empty and the - // first "Save mappings" of a freshly opened window would overwrite the - // user's file with empty rows. A missing file is not an error (first start). - const std::string configPath = gkeymapper::default_config_path(); - if (g_file_test(configPath.c_str(), G_FILE_TEST_EXISTS)) { - gkeymapper::read_mappings(configPath, rows); + // Start from the rows the replaced instance had on screen, or - when this is + // the only instance - from the saved mappings. Without either the rows are + // empty and the first "Save mappings" of a freshly opened window would + // overwrite the user's file with empty rows; a missing file is not an error + // (first start). Taken once: after this the live rows are the source. + if (!handedOverRows.empty()) { + gkeymapper::apply_mappings_text(handedOverRows, rows); + handedOverRows.clear(); + } else { + const std::string configPath = gkeymapper::default_config_path(); + if (g_file_test(configPath.c_str(), G_FILE_TEST_EXISTS)) { + gkeymapper::read_mappings(configPath, rows); + } } // Footer with save button and the daemon status line @@ -735,10 +783,18 @@ void build_ui(GtkApplication* app) { gtk_box_append(GTK_BOX(footer), daemonLabel); mainWindow = window; + // The type dropdowns and the header bar menu are popovers; without this they + // grab the pointer and freeze the rest of the desktop while they are open. + gkeymapper::make_popovers_release_input(GTK_WINDOW(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)); + // `show` is false for the one start that must not put a window in the way: the + // one the session's autostart entry makes (on_start_hidden_timeout is what then + // decides whether it has to appear after all). + if (show) { + gtk_window_present(GTK_WINDOW(window)); + } } static void on_activate(GtkApplication* app, gpointer /*user_data*/) { @@ -780,12 +836,27 @@ static void on_activate(GtkApplication* app, gpointer /*user_data*/) { const char* quitAccels[] = {"q", NULL}; gtk_application_set_accels_for_action(app, "app.quit", quitAccels); - build_ui(app); + // A start from the session leaves the window in the tray; a start by hand shows + // it (main() reads the flag off the command line). + build_ui(app, !startHidden); + + // From here on this instance answers a new start with the rows its window + // shows, so the instance that replaces it does not lose edits that were never + // saved (see replace_running_instance). It has to come after build_ui(), which + // is what fills the rows. + gkeymapper::serve_rows_handover([]() { return gkeymapper::mappings_text(rows); }); // 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); + if (startHidden) { + // The tray is what keeps a hidden window reachable; is it one? Only the + // panel can answer that, and it does so asynchronously (start_tray above is + // the question). Until then the window stays hidden and comes up if that + // question was never answered. + g_timeout_add(kStartHiddenGraceMs, on_start_hidden_timeout, nullptr); + } // 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. @@ -813,16 +884,32 @@ int main(int argc, char** argv) { // the first translated string is built. gkeymapper::apply_gui_language(guiSettings.language); + // The autostart entry starts the window in the tray and says so with an argument + // of its own (kStartHiddenArg). GTK does not know that argument and would stop + // on it, so it is taken out of the command line here, before it sees it. + std::vector args; + args.reserve(static_cast(argc) + 1); + for (int i = 0; i < argc; ++i) { + if (g_strcmp0(argv[i], gkeymapper::kStartHiddenArg) == 0) { + startHidden = true; + continue; + } + args.push_back(argv[i]); + } + args.push_back(nullptr); + // 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 - // to the old one and its stale window stays on screen. - gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId); + // to the old one and its stale window stays on screen. Its rows come along, + // so a mapping that was typed but not saved is not lost with it. + gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId, &handedOverRows); GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId, G_APPLICATION_FLAGS_NONE); g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr); - int status = g_application_run(G_APPLICATION(app), argc, argv); + int status = g_application_run(G_APPLICATION(app), static_cast(args.size()) - 1, + args.data()); g_object_unref(app); return status; } diff --git a/src/mappings.cpp b/src/mappings.cpp index c81d596..d7d5d7c 100644 --- a/src/mappings.cpp +++ b/src/mappings.cpp @@ -63,4 +63,12 @@ void apply_mappings(const MappingSet& set, const std::vector& rows) { } } +std::string mappings_text(const std::vector& rows) { + return format_mappings(collect_mappings(rows)); +} + +void apply_mappings_text(const std::string& text, const std::vector& rows) { + apply_mappings(parse_mappings(text), rows); +} + } // namespace gkeymapper diff --git a/src/mappings.h b/src/mappings.h index 279f18b..f813df5 100644 --- a/src/mappings.h +++ b/src/mappings.h @@ -17,6 +17,13 @@ namespace gkeymapper { MappingSet collect_mappings(const std::vector& rows); void apply_mappings(const MappingSet& set, const std::vector& rows); +// The same round trip as text (the mappings file format), for rows that have to +// leave the process: the window that replaces a running instance is given them by +// it (see src/singleinstance), so edits that were never saved survive a second +// start as well. +std::string mappings_text(const std::vector& rows); +void apply_mappings_text(const std::string& text, const std::vector& rows); + // Collects the current widget state and writes it to path. bool write_mappings(const std::string& path, const std::vector& rows); diff --git a/src/optionsdialog.cpp b/src/optionsdialog.cpp index 8ed66bd..7c657ca 100644 --- a/src/optionsdialog.cpp +++ b/src/optionsdialog.cpp @@ -10,6 +10,8 @@ #include +#include "popovers.h" + namespace gkeymapper { namespace { @@ -119,19 +121,30 @@ void OptionsDialog::show(GtkWindow* parent, const OptionsState& state, ToggleFn gtk_box_append(GTK_BOX(box), autostartRow); // Static text, so unlike the tray hint it needs no member: the switch is // what changes. - GtkWidget* autostartHint = hint_label(_("Opens this window when you log in. Not needed " - "for the G keys - the daemon does the work and " - "is the only part that has to run.")); + GtkWidget* autostartHint = hint_label(_("Opens this window minimized to the tray when you " + "log in. Not needed for the G keys - the daemon " + "does the work and is the only part that has to " + "run.")); gtk_box_append(GTK_BOX(box), autostartHint); gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); gtk_box_append(GTK_BOX(box), section_label(_("Language"))); // GtkDropDown has no label of its own; the heading above it says what it // is, and the accessible name says it again for screen readers. + + // Trailing space like the type dropdown in the main window: GTK draws the + // check mark of the selected entry right behind the text. const char* languageOptions[] = { _("System language"), "Deutsch", "English", "Español", "Français", nullptr }; - languageDrop_ = gtk_drop_down_new_from_strings(languageOptions); - gtk_widget_set_halign(languageDrop_, GTK_ALIGN_START); + g_autoptr(GtkStringList) languageModel = gtk_string_list_new(nullptr); + for (int i = 0; languageOptions[i] != nullptr; ++i) { + g_autofree gchar* shown = g_strconcat(languageOptions[i], " ", nullptr); + gtk_string_list_append(languageModel, shown); + } + languageDrop_ = gtk_drop_down_new(G_LIST_MODEL(languageModel), nullptr); + // Right-aligned like the switches above: the GUI's own controls sit on one + // line along the right edge, the text explains them on the left. + gtk_widget_set_halign(languageDrop_, GTK_ALIGN_END); gtk_accessible_update_property(GTK_ACCESSIBLE(languageDrop_), GTK_ACCESSIBLE_PROPERTY_LABEL, _("Language"), -1); g_signal_connect(languageDrop_, "notify::selected", G_CALLBACK(on_language_changed), this); @@ -168,6 +181,9 @@ void OptionsDialog::show(GtkWindow* parent, const OptionsState& state, ToggleFn gtk_box_append(GTK_BOX(box), closeButton); g_signal_connect(window_, "destroy", G_CALLBACK(on_destroy), this); + // The language dropdown is a popover; without this it grabs the pointer + // and freezes the rest of the desktop while it is open. + make_popovers_release_input(GTK_WINDOW(window_)); } apply(); diff --git a/src/popovers.cpp b/src/popovers.cpp new file mode 100644 index 0000000..0ed0093 --- /dev/null +++ b/src/popovers.cpp @@ -0,0 +1,144 @@ +// gKeyMapper - popup handling that GTK gets wrong in this version. +// +// GTK 4.6's popovers - the list a dropdown opens and the header bar menu - grab +// the pointer while they are open. An X11 grab swallows every click of the +// other clients, and this GTK does not close the popup on a click outside the +// application either: with a dropdown open, nothing on the desktop can be +// clicked any more, neither another window nor the desktop itself, until Escape +// is pressed. GTK 3 replayed such a click to the window under the pointer, GTK 4 +// dropped that. A popup that is left open is not the problem; a desktop that is +// frozen is. +// +// A popover only grabs while its "autohide" is on, so switching that off hands +// the clicks back to the rest of the desktop. The popups are then closed here, +// which is the part that autohide would have done: +// - a click in the window, outside the popup: on the press, so the click still +// reaches the widget under the pointer (a previous popup must not eat it), +// - a click on the widget that opened it: left alone, its own toggle closes it, +// - a click in the popup: left alone, it picks the entry and closes the popup, +// - a click anywhere else (another program, the desktop): the window loses the +// focus, and that is what closes it here - the click itself goes to whatever +// was clicked, which is the whole point, +// - Escape. + +#include "popovers.h" + +#include + +namespace gkeymapper { +namespace { + +void collect_popovers(GtkWidget* widget, std::vector* popovers) { + for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr; + child = gtk_widget_get_next_sibling(child)) { + if (GTK_IS_POPOVER(child)) { + popovers->push_back(child); + } + collect_popovers(child, popovers); + } +} + +void popovers_of(GtkWindow* window, std::vector* popovers) { + collect_popovers(GTK_WIDGET(window), popovers); +} + +// A press in the popup itself is a choice and must reach the entry under the +// pointer. GTK hands such a press to the popup's own surface, so this controller +// normally does not see it at all - the check is a guard for the case where it +// does, and it has to be by surface and not by position, because the coordinates +// of that press are the popup's and not the window's. +bool press_on_popup(GdkEvent* event, GtkWidget* popover) { + GdkSurface* popup = gtk_native_get_surface(GTK_NATIVE(popover)); + return popup != nullptr && gdk_event_get_surface(event) == popup; +} + +bool press_inside(GtkWidget* widget, GtkWindow* window, double x, double y) { + graphene_rect_t rect; + if (widget == nullptr || !gtk_widget_compute_bounds(widget, GTK_WIDGET(window), &rect)) { + return false; + } + return x >= rect.origin.x && x <= rect.origin.x + rect.size.width && y >= rect.origin.y && + y <= rect.origin.y + rect.size.height; +} + +void close_visible_popovers(GtkWindow* window) { + std::vector popovers; + popovers_of(window, &popovers); + for (GtkWidget* popover : popovers) { + if (gtk_widget_get_visible(popover)) { + gtk_popover_popdown(GTK_POPOVER(popover)); + } + } +} + +gboolean on_event(GtkEventControllerLegacy* /*controller*/, GdkEvent* event, gpointer userData) { + GtkWindow* window = GTK_WINDOW(userData); + const GdkEventType type = gdk_event_get_event_type(event); + if (type != GDK_BUTTON_PRESS && type != GDK_KEY_PRESS) { + return GDK_EVENT_PROPAGATE; + } + + std::vector popovers; + popovers_of(window, &popovers); + for (GtkWidget* popover : popovers) { + // A popover GTK made after this was installed must not grab either; the + // press that opens it is seen here before the widget acts on it, and + // switching a visible one off drops the grab right away. + gtk_popover_set_autohide(GTK_POPOVER(popover), FALSE); + } + + if (type == GDK_KEY_PRESS) { + if (g_strcmp0(gdk_keyval_name(gdk_key_event_get_keyval(event)), "Escape") == 0) { + for (GtkWidget* popover : popovers) { + if (gtk_widget_get_visible(popover)) { + gtk_popover_popdown(GTK_POPOVER(popover)); + } + } + } + return GDK_EVENT_PROPAGATE; + } + + double x = 0; + double y = 0; + gdk_event_get_position(event, &x, &y); + for (GtkWidget* popover : popovers) { + if (!gtk_widget_get_visible(popover) || press_on_popup(event, popover)) { + continue; + } + // The widget that opened the popup sits in the window, so the position is + // in the window's coordinates here. + if (press_inside(gtk_widget_get_parent(popover), window, x, y)) { + continue; + } + gtk_popover_popdown(GTK_POPOVER(popover)); + } + return GDK_EVENT_PROPAGATE; +} + +void on_focus_leave(GtkEventControllerFocus* /*controller*/, gpointer userData) { + close_visible_popovers(GTK_WINDOW(userData)); +} + +} // namespace + +void make_popovers_release_input(GtkWindow* window) { + std::vector popovers; + popovers_of(window, &popovers); + for (GtkWidget* popover : popovers) { + gtk_popover_set_autohide(GTK_POPOVER(popover), FALSE); + } + + // A legacy controller does not claim the events it sees, so the widgets keep + // behaving as usual; the capture phase is what makes it see a press before + // the widget under the pointer does anything with it. + GtkEventController* events = gtk_event_controller_legacy_new(); + gtk_event_controller_set_propagation_phase(events, GTK_PHASE_CAPTURE); + g_signal_connect(events, "event", G_CALLBACK(on_event), window); + gtk_widget_add_controller(GTK_WIDGET(window), events); + + GtkEventController* focus = gtk_event_controller_focus_new(); + g_signal_connect(focus, "leave", G_CALLBACK(on_focus_leave), window); + gtk_widget_add_controller(GTK_WIDGET(window), focus); +} + +} // namespace gkeymapper diff --git a/src/popovers.h b/src/popovers.h new file mode 100644 index 0000000..9a4f428 --- /dev/null +++ b/src/popovers.h @@ -0,0 +1,18 @@ +// gKeyMapper - popup handling that GTK gets wrong in this version. +// +// See popovers.cpp for why this exists; in short, a popover that keeps its +// input grab makes the rest of the desktop unclickable on X11. + +#pragma once + +#include + +namespace gkeymapper { + +// Call once per toplevel window, after the widgets are built. Every popover in +// the window (the dropdowns' popup lists, the header bar menu) then lets the +// pointer go, and is closed by us instead - outside click, Escape, or the +// window losing the focus. +void make_popovers_release_input(GtkWindow* window); + +} // namespace gkeymapper diff --git a/src/singleinstance.cpp b/src/singleinstance.cpp index 16dbc56..f36d8de 100644 --- a/src/singleinstance.cpp +++ b/src/singleinstance.cpp @@ -3,16 +3,20 @@ // GtkApplication already makes a second process hand over to the first one (the // app id is unique), but that leaves the *old* window on screen - including one // from an earlier build. So main() first closes what is running and only then -// becomes the primary instance itself. +// becomes the primary instance itself. What it takes along are the rows that +// window shows: the takeover would otherwise drop edits that were never saved. #include "singleinstance.h" #include +#include #include #include #include +#include "ipc_ids.h" + namespace gkeymapper { namespace { @@ -22,6 +26,9 @@ constexpr int kQuitWaitMs = 1500; constexpr int kTermWaitMs = 1500; constexpr int kPollMs = 50; +// Asked by the next start (see serve_rows_handover). +RowsProvider gRowsProvider; + GDBusConnection* session_bus() { GError* error = nullptr; GDBusConnection* bus = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); @@ -94,6 +101,33 @@ bool ask_to_quit(GDBusConnection* bus, const std::string& name) { return true; } +// The rows of the instance that is running, fetched before it goes away. No answer +// (an older build has no such method) simply leaves the new window with the file. +bool ask_for_rows(GDBusConnection* bus, const std::string& name, std::string& out) { + GVariant* reply = call(bus, name.c_str(), ipc::kWindowObjectPath, ipc::kWindowInterface, + ipc::kWindowRowsMethod, nullptr, G_VARIANT_TYPE("(s)")); + if (reply == nullptr) { + return false; + } + const gchar* text = nullptr; + g_variant_get(reply, "(&s)", &text); + out = text != nullptr ? text : ""; + g_variant_unref(reply); + return true; +} + +// Answers the next start. This runs on the main loop, which is the thread that +// owns the widgets, so the provider may read them. +void on_get_rows(GDBusConnection* /*connection*/, const gchar* /*sender*/, + const gchar* /*objectPath*/, const gchar* /*interfaceName*/, + const gchar* /*methodName*/, GVariant* /*parameters*/, + GDBusMethodInvocation* invocation, gpointer /*userData*/) { + const std::string rows = gRowsProvider ? gRowsProvider() : std::string(); + g_dbus_method_invocation_return_value(invocation, g_variant_new("(s)", rows.c_str())); +} + +const GDBusInterfaceVTable kHandoverVTable = {on_get_rows, nullptr, nullptr, {}}; + pid_t owner_pid(GDBusConnection* bus, const std::string& name) { GVariant* reply = call(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetConnectionUnixProcessID", @@ -124,7 +158,32 @@ bool is_our_gui(pid_t pid) { } // namespace -bool replace_running_instance(const std::string& appId) { +void serve_rows_handover(RowsProvider provider) { + gRowsProvider = std::move(provider); + + GDBusConnection* bus = session_bus(); + if (bus == nullptr) { + return; // without a bus nobody can ask + } + // Built from the ids, so the XML and the constants cannot drift apart. + g_autofree gchar* xml = g_strdup_printf( + "" + "", + ipc::kWindowInterface, ipc::kWindowRowsMethod); + GError* error = nullptr; + GDBusNodeInfo* info = g_dbus_node_info_new_for_xml(xml, &error); + if (info != nullptr) { + // The registration lives as long as the process; being called twice would + // only report "already registered", which is not worth a message. + g_dbus_connection_register_object(bus, ipc::kWindowObjectPath, info->interfaces[0], + &kHandoverVTable, nullptr, nullptr, &error); + g_dbus_node_info_unref(info); + } + g_clear_error(&error); + g_object_unref(bus); +} + +bool replace_running_instance(const std::string& appId, std::string* rowsOut) { GDBusConnection* bus = session_bus(); if (bus == nullptr) { return true; // without a bus there is no other instance either @@ -132,6 +191,11 @@ bool replace_running_instance(const std::string& appId) { bool free = !has_owner(bus, appId); if (!free) { + // The rows first, then the quit: after it there is nobody left to ask. + if (rowsOut != nullptr) { + ask_for_rows(bus, appId, *rowsOut); + } + // Clean path: the running instance quits itself. ask_to_quit(bus, appId); free = wait_until_free(bus, appId, kQuitWaitMs); diff --git a/src/singleinstance.h b/src/singleinstance.h index f7d5289..c19f320 100644 --- a/src/singleinstance.h +++ b/src/singleinstance.h @@ -2,6 +2,7 @@ #ifndef GKEYMAPPER_SINGLEINSTANCE_H #define GKEYMAPPER_SINGLEINSTANCE_H +#include #include namespace gkeymapper { @@ -13,10 +14,25 @@ namespace gkeymapper { // terminated with SIGTERM instead, after checking that the bus name really // belongs to a gkeymapper process. // +// Before the quit it asks that instance for the rows its window shows and puts +// them into `rowsOut` (the mappings text, the same format as the file). A new +// window starts from that instead of from the file, so edits that were never +// saved are not lost with the replaced window. An instance that does not answer +// (an older build) leaves `rowsOut` alone; pass nullptr to skip the asking. +// // Returns true when the name is free afterwards, which is also the case when // nobody owned it. The caller still runs its usual single-instance logic, so a // `false` (a hung instance that cannot be closed) only means "no new window". -bool replace_running_instance(const std::string& appId); +bool replace_running_instance(const std::string& appId, std::string* rowsOut = nullptr); + +// The rows of this window, as `mappings_text` produces them. +using RowsProvider = std::function; + +// The other side of the above: answers a new start that asks for the rows. Call it +// once the window exists; `provider` runs on this process's main loop, so reading +// the widgets is safe. Registering twice keeps the second provider, which only +// matters if a start could reach here more than once. +void serve_rows_handover(RowsProvider provider); } // namespace gkeymapper