├── tests ├── __init__.py └── test_sheets.py ├── keyhint ├── __init__.py ├── resources │ ├── keyhint.png │ ├── keyhint_128.png │ ├── keyhint_32.png │ ├── keyhint_48.png │ ├── keyhint_64.png │ ├── style.css │ ├── keyhint_icon.svg │ ├── window.ui │ └── headerbar.ui ├── __main__.py ├── config │ ├── gnome-terminal.toml │ ├── vscode-neovim.toml │ ├── foot.toml │ ├── keyhint.toml │ ├── firefox.toml │ ├── tmux.toml │ ├── github.toml │ ├── alacritty.toml │ ├── vscode-copilot.toml │ ├── forge.toml │ ├── tilix.toml │ ├── pop-shell.toml │ ├── kitty.toml │ ├── cli.toml │ ├── firefox-tridactyl.toml │ ├── vscode.toml │ └── vim.toml ├── css.py ├── headerbar.py ├── config.py ├── app.py ├── binding.py ├── sheets.py ├── context.py └── window.py ├── .github └── workflows │ ├── coverage.yml │ └── python.yml ├── LICENSE ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── .gitignore ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /keyhint/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.0" 2 | -------------------------------------------------------------------------------- /keyhint/resources/keyhint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynobo/keyhint/HEAD/keyhint/resources/keyhint.png -------------------------------------------------------------------------------- /keyhint/resources/keyhint_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynobo/keyhint/HEAD/keyhint/resources/keyhint_128.png -------------------------------------------------------------------------------- /keyhint/resources/keyhint_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynobo/keyhint/HEAD/keyhint/resources/keyhint_32.png -------------------------------------------------------------------------------- /keyhint/resources/keyhint_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynobo/keyhint/HEAD/keyhint/resources/keyhint_48.png -------------------------------------------------------------------------------- /keyhint/resources/keyhint_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dynobo/keyhint/HEAD/keyhint/resources/keyhint_64.png -------------------------------------------------------------------------------- /keyhint/__main__.py: -------------------------------------------------------------------------------- 1 | """Package entry for keyhint.""" 2 | 3 | from keyhint import app 4 | 5 | if __name__ == "__main__": 6 | app.main() 7 | -------------------------------------------------------------------------------- /keyhint/config/gnome-terminal.toml: -------------------------------------------------------------------------------- 1 | id = "gnome-terminal" 2 | url = "https://help.gnome.org/users/gnome-terminal/stable/adv-keyboard-shortcuts.html.en" 3 | include = ["cli"] 4 | 5 | [match] 6 | regex_wmclass = "gnome-terminal" 7 | regex_title = ".*" 8 | 9 | [section] 10 | 11 | [section.Tabs] 12 | "Shift + Ctrl + T" = "New Tab" 13 | "Shift + Ctrl + N" = "New Window" 14 | "Shift + Ctrl + W" = "Close Tab" 15 | "Shift + Ctrl + Q" = "Close Window" 16 | -------------------------------------------------------------------------------- /tests/test_sheets.py: -------------------------------------------------------------------------------- 1 | """Test the utility functions.""" 2 | 3 | from pathlib import Path 4 | 5 | from keyhint import sheets 6 | 7 | 8 | def test_load_default_sheets(): 9 | """Test loading of toml files shipped with package.""" 10 | default_sheets = sheets.load_default_sheets() 11 | toml_files = (Path(__file__).parent.parent / "keyhint" / "config").glob("*.toml") 12 | 13 | assert len(default_sheets) == len(list(toml_files)) 14 | -------------------------------------------------------------------------------- /keyhint/config/vscode-neovim.toml: -------------------------------------------------------------------------------- 1 | id = "vscode-neovim" 2 | url = "" 3 | hidden = true 4 | 5 | [match] 6 | regex_wmclass = "^$" 7 | regex_title = "^$" 8 | 9 | [section] 10 | [section.General] 11 | "ma / mA" = "Multicursor after cursor / line" 12 | "mi / mI" = "Multicursor before cursor / line" 13 | "K" = "Show hover, repeat to focus hover" 14 | "gd" = "Goto definition" 15 | "gf" = "Goto declaration" 16 | "gH" = "Reference Search" 17 | "Ctrl + n / p" = "Next / previous list item" 18 | -------------------------------------------------------------------------------- /keyhint/config/foot.toml: -------------------------------------------------------------------------------- 1 | id = "foot-terminal" 2 | url = "https://codeberg.org/dnkl/foot#user-content-shortcuts" 3 | include = ["cli"] 4 | 5 | [match] 6 | regex_wmclass = "foot" 7 | regex_title = ".*" 8 | 9 | [section] 10 | [section.Foot] 11 | "Shift + PageUp / PageDown" = "Scroll up/down" 12 | "Ctrl + Shift + c / v" = "Copy/paste from clipboard" 13 | "Ctrl + Shift + r" = "Search mode" 14 | "Ctrl + r / s" = "Search for previous / next match" 15 | "Ctrl + Shift + u" = "URL mode" 16 | t = "Toggle URL in label" 17 | -------------------------------------------------------------------------------- /keyhint/config/keyhint.toml: -------------------------------------------------------------------------------- 1 | id = "keyhint" 2 | url = "https://github.com/dynobo/keyhint/README.md" 3 | 4 | [match] 5 | regex_wmclass = "keyhint" 6 | regex_title = ".*" 7 | 8 | [section] 9 | [section.General] 10 | F11 = "Toggle Fullscreen" 11 | "Ctrl + f" = "Focus search field" 12 | "Ctrl + Backspace / u" = "Clear search field" 13 | Esc = "Exit" 14 | 15 | [section."Switch Cheatsheet"] 16 | "Ctrl + Down" = "Next cheatsheet" 17 | "Ctrl + Up" = "Previous cheatsheet" 18 | "Ctrl + s & Enter" = "Focus & open sheet selection dropdown" 19 | 20 | [section.Scroll] 21 | "Down or Ctrl + j" = "Forward" 22 | "Up or Ctrl + k" = "Backward" 23 | PageDown = "Page forward" 24 | PageUp = "Page backward" 25 | -------------------------------------------------------------------------------- /keyhint/config/firefox.toml: -------------------------------------------------------------------------------- 1 | id = "firefox" 2 | url = "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly" 3 | 4 | [match] 5 | regex_wmclass = "Firefox" 6 | regex_title = ".*" 7 | 8 | [section] 9 | [section."URL Navigation"] 10 | "Ctrl + L" = "Location bar" 11 | "Alt + Left" = "Back in History" 12 | "Alt + Right" = "Forward in History" 13 | 14 | [section."Tab Management"] 15 | "Ctrl + T" = "New tab" 16 | "Ctrl + Tab" = "Next tab" 17 | "Ctrl + 1..8" = "Select tab 1..8" 18 | "Ctrl + W" = "Close tab" 19 | "Ctrl + Shift + T" = "Undo close tab" 20 | "\\/" = "Quick Find" 21 | 22 | [section.Various] 23 | "Ctrl + Shift + Y" = "Show downloads" 24 | "Ctrl + g" = "Search in site" 25 | "Ctrl + G" = "Search next match" 26 | -------------------------------------------------------------------------------- /keyhint/config/tmux.toml: -------------------------------------------------------------------------------- 1 | id = "tmux" 2 | url = "https://github.com/gnunn1/tilix/blob/master/data/resources/ui/shortcuts.ui" 3 | include = ["cli"] 4 | 5 | [match] 6 | regex_wmclass = "foot" 7 | regex_title = "tmux" 8 | 9 | [section] 10 | [section.General] 11 | "Ctrl + a" = "Prefix key for all shortcuts!" 12 | "?" = "Show shortcuts" 13 | 14 | [section.Panes] 15 | "\"" = "Split vertically" 16 | "%" = "Split horizontally" 17 | Space = "Switch layouts" 18 | z = "Zoom current" 19 | x = "Kill current" 20 | "`Up Down Left Right`" = "Select" 21 | "Alt + Up Down Left Right" = "Resize" 22 | o = "Switch to next" 23 | "Ctrl + o" = "Rotate" 24 | e = "Spread equally" 25 | 26 | [section.Window] 27 | "&" = "Kill current" 28 | 29 | [section.Session] 30 | d = "Detach" 31 | "$" = "Rename" 32 | -------------------------------------------------------------------------------- /keyhint/config/github.toml: -------------------------------------------------------------------------------- 1 | id = "github.com" 2 | url = "https://help.github.com/en/github/getting-started-with-github/keyboard-shortcuts" 3 | hidden = true 4 | 5 | [match] 6 | regex_wmclass = ".*" 7 | regex_title = ".*github\\.com.*" 8 | 9 | [section] 10 | [section."Site Wide"] 11 | "S / \\/" = "Focus search bar" 12 | gn = "Goto notifications" 13 | esc = "Close hovercard, if open" 14 | 15 | [section.Repositories] 16 | gc = "Goto code tab" 17 | gi = "Goto issues tab" 18 | gp = "Goto pull request tab" 19 | gb = "Goto projects tab" 20 | gw = "Goto wiki tab" 21 | 22 | [section."Browse Code"] 23 | t = "Activate file finder" 24 | l = "Jump to line" 25 | w = "Switch to new branch/tag" 26 | y = "Expand URL to canonical" 27 | i = "Toggle comments on diffs" 28 | b = "Open blame view" 29 | -------------------------------------------------------------------------------- /keyhint/config/alacritty.toml: -------------------------------------------------------------------------------- 1 | id = "alacritty" 2 | url = "https://github.com/alacritty/alacritty/blob/master/docs/features.md" 3 | include = ["cli"] 4 | 5 | [match] 6 | regex_wmclass = "alacritty" 7 | regex_title = ".*" 8 | 9 | [section] 10 | 11 | [section.Search] 12 | "Ctrl + Shift + F" = "Search forward" 13 | "Ctrl + Shift + B" = "Search backwards" 14 | "Ctrl + Shift + S" = "Paste from selection" 15 | "Enter / Shift + Enter" = "Next / previous match" 16 | "Esc" = "Leave search" 17 | 18 | [section."Vi Mode"] 19 | "Ctrl + Shift + Space" = "Launch Vi mode" 20 | "/" = "Search forward" 21 | "?" = "Search backwards" 22 | "Enter" = "Activate hint (e.g. open URL)" 23 | 24 | [section.Mouse] 25 | "`Double click`" = "Select word" 26 | "`Triple click`" = "Select line" 27 | "Ctrl + select" = "Select block" 28 | -------------------------------------------------------------------------------- /keyhint/css.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from gi.repository import Gdk, Gtk 4 | 5 | 6 | def new_provider(display: Gdk.Display, css_file: Path | None = None) -> Gtk.CssProvider: 7 | """Create a new css provider which applies globally. 8 | 9 | Args: 10 | display: Target Gtk.Display. 11 | css_file: Path to file with css rules to be loaded. If None, an empty css 12 | provider is created. 13 | 14 | Returns: 15 | css provider which rules apply to the whole application. 16 | """ 17 | provider = Gtk.CssProvider() 18 | Gtk.StyleContext().add_provider_for_display( 19 | display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER 20 | ) 21 | if css_file and css_file.exists(): 22 | provider.load_from_path(str(css_file.resolve())) 23 | return provider 24 | -------------------------------------------------------------------------------- /keyhint/config/vscode-copilot.toml: -------------------------------------------------------------------------------- 1 | id = "vscode-copilot" 2 | url = "https://docs.github.com/en/copilot/configuring-github-copilot/configuring-github-copilot-in-your-environment" 3 | hidden = true 4 | 5 | [match] 6 | regex_wmclass = "^$" 7 | regex_title = "^$" 8 | 9 | [section] 10 | 11 | [section.Suggestions] 12 | "Tab" = "Accept all" 13 | "Ctrl + r Right" = "Accept next word" 14 | "Alt + ] / [ " = "Show next / previous" 15 | "Alt + \\" = "Trigger suggestion" 16 | "Ctrl + Enter" = "Open 10 suggestions" 17 | "Esc" = "Dismiss" 18 | 19 | [section.Chat] 20 | "Ctrl + Alt + i" = "Open chat in sidebar" 21 | "Ctrl + i" = "Open inline chat" 22 | "@" = "Add experts a.k.a. 'participants'" 23 | "#" = "Add context" 24 | "/new" = "New conversation" 25 | "/fix" = "Propose fix for problem in selected code" 26 | "/clear" = "Clear conversation" 27 | "/delete" = "Delete conversation" 28 | "/help" = "GitHub Copilot quick reference" 29 | -------------------------------------------------------------------------------- /keyhint/config/forge.toml: -------------------------------------------------------------------------------- 1 | id = "forge" 2 | url = "https://github.com/forge-ext/forge" 3 | hidden = true 4 | 5 | [match] 6 | regex_wmclass = "^$" 7 | regex_title = "^$" 8 | 9 | [section] 10 | 11 | [section."Move window"] 12 | "Super + Shift + hjkl" = "Move window" 13 | "Super + Ctrl + hjkl" = "Swap window" 14 | "Super + Enter" = "Swap with last active" 15 | "Super + g" = "Toggle vertical/horizontal" 16 | "Super + c" = "Toggle floating" 17 | 18 | [section.Navigation] 19 | "Super + Direction" = "Move focus" 20 | "Super + Ctrl + UpDown" = "Navigate between workspaces" 21 | 22 | [section."Manipulate window"] 23 | "Super + q" = "Quit" 24 | "Super + Ctrl + yuio" = "Increase size left / bottom / top / right /" 25 | "Super + Ctrl + Shift + yuio" = "Decrease size left / bottom / top / right /" 26 | 27 | [section."Manage workspaces"] 28 | 29 | [section.Miscellaneous] 30 | "Super + Tab" = "Switch apps" 31 | "Alt + F2" = "Run command" 32 | "Ctrl + Alt + Del" = "Logout" 33 | -------------------------------------------------------------------------------- /keyhint/config/tilix.toml: -------------------------------------------------------------------------------- 1 | id = "tilix" 2 | url = "https://github.com/gnunn1/tilix/blob/master/data/resources/ui/shortcuts.ui" 3 | include = ["cli"] 4 | 5 | [match] 6 | regex_wmclass = "tilix" 7 | regex_title = ".*" 8 | 9 | [section] 10 | [section.Window] 11 | F11 = "Toggle fullscreen" 12 | F12 = "View session sidebar" 13 | 14 | [section.Sessions] 15 | "Ctrl + Shift + T" = "Open new session" 16 | "Ctrl + PageUp / PageDown" = "Switch to next/preview session" 17 | "Ctrl + Shift + Q" = "Close current session" 18 | 19 | [section.Tiling] 20 | "Ctrl + Alt + A" = "Add terminal automatically" 21 | "Ctrl + Alt + R" = "Add terminal right" 22 | "Ctrl + Alt + D" = "Add terminal down" 23 | "Shift + Alt + Up Down Left Right" = "Resize terminal" 24 | "Ctrl + Shift + W" = "Close" 25 | 26 | [section.Terminal] 27 | "Shift + Ctrl + F" = "Find" 28 | "Shift + Ctrl + G / H" = "Find next / previous" 29 | "Shift + Ctrl + Up Down" = "Scroll up / down" 30 | "Shift + PageUp PageDown" = "Page up / down" 31 | -------------------------------------------------------------------------------- /keyhint/config/pop-shell.toml: -------------------------------------------------------------------------------- 1 | id = "pop-shell" 2 | url = "https://support.system76.com/articles/pop-keyboard-shortcuts/" 3 | 4 | [match] 5 | regex_wmclass = "pop-shell" 6 | regex_title = ".*" 7 | 8 | [section] 9 | [section."Manipulate windows"] 10 | "Super + Direction" = "Move focus" 11 | "Super + O" = "Switch tiling orientation" 12 | "Super + M" = "Toggle maximized" 13 | "Super + Y" = "Toggle tiling" 14 | "Super + G" = "Toggle floating" 15 | "Super + S" = "Toggle stacking" 16 | "Super + Q" = "Quit window" 17 | 18 | [section."Adjustment Mode"] 19 | "Super + Enter" = "Enter window adjustment mode" 20 | Direction = "Move window" 21 | "Shift + Direction" = "Resize window" 22 | "Ctrl + Direction" = "Swap windows" 23 | Enter = "Apply changes" 24 | Esc = "Exit window adjustment mode" 25 | 26 | [section."Manage workspaces"] 27 | "Super + Ctrl + UpDown" = "Navigate between workspaces" 28 | "Super + Shift + Direction" = "Move window between workspaces" 29 | 30 | [section.Miscellaneous] 31 | "Super + Tab" = "Switch apps" 32 | "Super + ^" = "Switch windows of current app" 33 | "Alt + F2" = "Run command" 34 | "Ctrl + Alt + Del" = "Logout" 35 | -------------------------------------------------------------------------------- /keyhint/config/kitty.toml: -------------------------------------------------------------------------------- 1 | id = "kitty" 2 | url = "https://sw.kovidgoyal.net/kitty/overview/" 3 | include = ["cli"] 4 | 5 | [match] 6 | regex_wmclass = "kitty" 7 | regex_title = ".*" 8 | 9 | [section] 10 | [section.Scrolling] 11 | "Ctrl + Shift + UpDown" = "Line up/down" 12 | "Ctrl + Shift + PageUpDown" = "Page up/down" 13 | "Ctrl + Shift + Home" = "Top" 14 | "Ctrl + Shift + End" = "Bottom" 15 | "Ctrl + Shift + z" = "Previous shell prompt" 16 | "Ctrl + Shift + x" = "Next shell prompt" 17 | 18 | [section.Clipboard] 19 | "Ctrl + Shift + C" = "Copy to clipboard" 20 | "Ctrl + Shift + V" = "Paste from clipboard" 21 | "Ctrl + Shift + S" = "Paste from selection" 22 | 23 | [section.Windows] 24 | "Ctrl + Shift + Enter" = "New window" 25 | "Ctrl + Shift + w" = "Close window" 26 | "Ctrl + Shift + ]" = "Next window" 27 | "Ctrl + Shift + [" = "Previous window" 28 | "Ctrl + Shift + f" = "Move window forward" 29 | "Ctrl + Shift + b" = "Move window backward" 30 | "Ctrl + Shift + l" = "Switch between 7 layouts" 31 | 32 | [section.Miscellaneous] 33 | "Ctrl + Shift + PlusMinus" = "Increase/decrease font size" 34 | "Ctrl + Shift + e" = "Open URL in browser" 35 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: "coverage.io" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Coverage 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v5 18 | with: 19 | enable-cache: true 20 | 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version-file: "pyproject.toml" 24 | 25 | - name: Install system deps 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get install \ 29 | girepository-2.0 \ 30 | libcairo2-dev \ 31 | python3-gi \ 32 | gobject-introspection \ 33 | libgtk-3-dev 34 | 35 | - name: Install the project 36 | run: uv sync --all-extras --dev 37 | 38 | - name: Run pytest 39 | run: uv run pytest 40 | 41 | - name: Coveralls 42 | run: uv run coveralls 43 | env: 44 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 dynobo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /keyhint/resources/style.css: -------------------------------------------------------------------------------- 1 | @define-color bg_color rgba(0,0,0,0.2); 2 | @define-color borders_color rgba(0,0,0,0.5); 3 | @define-color shadow_color rgba(0,0,0,0.3); 4 | @define-color accent_color #FF2E88; 5 | @define-color banner_bg_color rgba(42, 123, 222, 0.3); 6 | 7 | .keycap { 8 | min-width: 15px; 9 | min-height: 25px; 10 | margin-top: 2px; 11 | padding-bottom: 3px; 12 | padding-left: 5px; 13 | padding-right: 5px; 14 | background-color: @bg_color; 15 | border: 1px solid; 16 | border-radius: 5px; 17 | border-color: @borders_color; 18 | box-shadow: inset 0 -3px @shadow_color; 19 | } 20 | 21 | .bindings-section header label { 22 | color: @accent_color; 23 | margin-top: 26px; 24 | filter: unset; 25 | } 26 | 27 | .popover_menu_box > list > row { 28 | border-radius: 10px; 29 | } 30 | 31 | .popover_menu_box .setting_label { 32 | font-size: 85%; 33 | opacity: 0.8; 34 | } 35 | 36 | .popover_menu_box .menu_entry { 37 | font-weight: normal; 38 | } 39 | 40 | .transparent, 41 | .bindings-section, 42 | .bindings-section .view { 43 | background-color: transparent; 44 | } 45 | 46 | .banner { 47 | background-color: @banner_bg_color; 48 | font-weight: bold; 49 | } 50 | 51 | .dim-label { 52 | margin: 0px -2px; 53 | } 54 | -------------------------------------------------------------------------------- /keyhint/config/cli.toml: -------------------------------------------------------------------------------- 1 | id = "cli" 2 | url = "" 3 | hidden = true 4 | 5 | [match] 6 | regex_wmclass = "^$" 7 | regex_title = "^$" 8 | 9 | [section] 10 | 11 | [section."Shell History"] 12 | "Ctrl + r" = "Reverse cmd search / find next" 13 | "`history | grep `" = "Search for \"\"" 14 | "`history 10`" = "Print last 10 cmds" 15 | "!3" = "Execute cmd 3 from history" 16 | 17 | [section."Data Wrangling"] 18 | "`awk '{print $1}'`" = "Column base manipulations" 19 | "`sed -E 's/.*(d+)/\\1/'`" = "Line base manipulations" 20 | "`paste -sd,`" = "Concatenate lines" 21 | "`tail -n10`" = "Last lines" 22 | "`head -n10`" = "First lines" 23 | "`uniq -c`" = "Count unique lines" 24 | "`sort -nk1,1" = "Sort by column 1" 25 | 26 | [section.Tools] 27 | bc = "Calculator" 28 | "gtop / htop" = "System Monitors" 29 | xplr = "File manager" 30 | moc = "Music Player in Console" 31 | nvtop = "NVidia Card usage" 32 | fd = "Easy to use find clone" 33 | hyperfine = "Benchmarking" 34 | tl = "TooLong log file viewer" 35 | bat = "cat clone to view files with style" 36 | eza = "ls clone with coloring" 37 | duf = "Basic disk usage" 38 | ncdu = "Recursive disk usage analyzer" 39 | s-tui = "Stress test and monitor" 40 | btm = "System resources dashboard" 41 | 42 | [section.Navigating] 43 | pwd = "Print working dir" 44 | cd = "Change to home dir" 45 | "`cd -`" = "Change to previous dir" 46 | 47 | [section.Debugging] 48 | "journalctl --since \"1m ago\"" = "System logs since time" 49 | "journalctl -k" = "System logs since boot" 50 | -------------------------------------------------------------------------------- /keyhint/config/firefox-tridactyl.toml: -------------------------------------------------------------------------------- 1 | id = "firefox-tridactyl" 2 | url = "https://github.com/tridactyl/tridactyl" 3 | hidden = true 4 | 5 | [match] 6 | regex_wmclass = "$^" 7 | regex_title = "$^" 8 | 9 | [section] 10 | [section.Modes] 11 | Esc = "Normal" 12 | f = "Hint" 13 | v = "Visual" 14 | ":" = "Command" 15 | "Shift + Esc" = "Ignore" 16 | 17 | [section.Tabs] 18 | "J / K" = "Next / previous" 19 | "d / x" = "Close" 20 | "u / X" = "Reopen" 21 | t = "Create new" 22 | b = "Switch between" 23 | "gt / GT" = "Go to next / previous" 24 | yt = "Duplicate" 25 | W = "Move tab new window" 26 | 27 | [section.Normal] 28 | "H / L" = "History back / forward" 29 | "o / O" = "Open URL in current tab" 30 | "t / T" = "Open URL in new tab" 31 | "w / W" = "Open URL in new Window" 32 | "p / P" = "Open clipboard in current/new tab" 33 | "s / S" = "Search with engine / in new tab" 34 | "gg / G" = "Scroll to start / end" 35 | yy = "Copy URL to clipboard" 36 | "zi / zo / zz" = "Zoom in / out / reset" 37 | "\\/" = "Search" 38 | 39 | [section.Hint] 40 | f = "Open in current tab" 41 | F = "Open in new background tab" 42 | "; + h" = "Select element" 43 | "; + y" = "Copy link to clipboard" 44 | "; + p" = "Copy element to clipboard" 45 | "; + k" = "Kill element" 46 | 47 | [section.Visual] 48 | hjklewb = "Adjust selection" 49 | y = "yank to clipboard" 50 | "s / S" = "Search selected text in current /new tab" 51 | f = "Open link in current tab" 52 | F = "Open link new current tab" 53 | H = "Back in History" 54 | L = "Forward in History" 55 | -------------------------------------------------------------------------------- /keyhint/headerbar.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from pathlib import Path 3 | from typing import cast 4 | 5 | from gi.repository import Gtk 6 | 7 | RESOURCE_PATH = Path(__file__).parent / "resources" 8 | 9 | 10 | @Gtk.Template(filename=f"{RESOURCE_PATH}/headerbar.ui") 11 | class HeaderBarBox(Gtk.HeaderBar): 12 | __gtype_name__ = "headerbar" 13 | 14 | sheet_dropdown = cast(Gtk.DropDown, Gtk.Template.Child()) 15 | search_entry = cast(Gtk.SearchEntry, Gtk.Template.Child()) 16 | fullscreen_button = cast(Gtk.ToggleButton, Gtk.Template.Child()) 17 | zoom_scale = cast(Gtk.Scale, Gtk.Template.Child()) 18 | fallback_sheet_entry = cast(Gtk.Entry, Gtk.Template.Child()) 19 | fallback_sheet_button = cast(Gtk.Button, Gtk.Template.Child()) 20 | 21 | def __init__(self, for_fullscreen: bool = False) -> None: 22 | super().__init__() 23 | if for_fullscreen: 24 | self.set_decoration_layout(":minimize,close") 25 | self.fullscreen_button.set_icon_name("view-restore-symbolic") 26 | self.set_visible(False) 27 | else: 28 | self.set_visible(True) 29 | 30 | 31 | class HeaderBars: 32 | """Utility class for easier accessing the two header bars. 33 | 34 | The 'normal' header bar is used as application title bar. It is shown in the normal 35 | window mode, and automatically hidden in fullscreen mode (by design of GTK). 36 | 37 | The 'fullscreen' header bar is added as a widget to the window content. Its 38 | visibility needs to be toggled depending on window state. 39 | """ 40 | 41 | normal = HeaderBarBox() 42 | fullscreen = HeaderBarBox(for_fullscreen=True) 43 | 44 | def __iter__(self) -> Iterator[HeaderBarBox]: 45 | yield from (self.normal, self.fullscreen) 46 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com/ for usage and config 2 | fail_fast: true 3 | 4 | repos: 5 | - repo: https://github.com/compilerla/conventional-pre-commit 6 | rev: v4.0.0 7 | hooks: 8 | - id: conventional-pre-commit 9 | stages: [commit-msg] 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v5.0.0 12 | hooks: 13 | - id: check-ast 14 | - id: check-toml 15 | - id: end-of-file-fixer 16 | exclude: ".srt$" 17 | - id: trailing-whitespace 18 | exclude: ".srt$" 19 | - id: mixed-line-ending 20 | - repo: local 21 | hooks: 22 | - id: ruff-check 23 | name: ruff check --fix . 24 | stages: [pre-commit] 25 | language: system 26 | entry: ruff check . 27 | pass_filenames: false 28 | - id: ruff-format . 29 | name: ruff format 30 | stages: [pre-commit] 31 | language: system 32 | entry: ruff check . 33 | pass_filenames: false 34 | - id: md-format . 35 | name: md format 36 | stages: [pre-commit] 37 | language: system 38 | entry: mdformat --end-of-line keep . 39 | pass_filenames: false 40 | - id: mypy 41 | name: mypy 42 | stages: [pre-commit] 43 | language: system 44 | entry: mypy 45 | pass_filenames: false 46 | - id: pytest 47 | name: pytest 48 | stages: [pre-commit] 49 | language: system 50 | entry: pytest 51 | pass_filenames: false 52 | - id: coverage 53 | name: coverage 54 | stages: [pre-commit] 55 | language: system 56 | entry: coverage lcov 57 | pass_filenames: false 58 | - id: pip-audit 59 | name: pip-audit 60 | stages: [pre-commit] 61 | language: system 62 | entry: pip-audit 63 | pass_filenames: false 64 | -------------------------------------------------------------------------------- /keyhint/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from configparser import ConfigParser 4 | from pathlib import Path 5 | 6 | if xdg_conf := os.getenv("XDG_CONFIG_HOME", None): 7 | CONFIG_PATH = Path(xdg_conf) / "keyhint" 8 | else: 9 | CONFIG_PATH = Path.home() / ".config" / "keyhint" 10 | CONFIG_FILE = CONFIG_PATH / "keyhint.ini" 11 | 12 | logger = logging.getLogger("keyhint") 13 | 14 | 15 | class WritingConfigParser(ConfigParser): 16 | def set_persistent( 17 | self, section: str, option: str, value: str | bool | int 18 | ) -> None: 19 | """Updates the config file on disk, in case the value has changed. 20 | 21 | Args: 22 | section: Config section in the toml file. 23 | option: Setting name in the section. 24 | value: Setting value to be updated. 25 | """ 26 | if self.get(section, option) == str(value): 27 | return 28 | self.set(section, option, str(value)) 29 | if not CONFIG_FILE.parent.exists(): 30 | CONFIG_FILE.parent.mkdir(exist_ok=True, parents=True) 31 | 32 | self.write(CONFIG_FILE.open("w")) 33 | 34 | 35 | def load() -> WritingConfigParser: 36 | """Load the settings file or create a default settings file if it doesn't exist.""" 37 | config = WritingConfigParser( 38 | defaults={ 39 | "fullscreen": "True", 40 | "sort_by": "size", 41 | "orientation": "vertical", 42 | "fallback_cheatsheet": "keyhint", 43 | "zoom": "100", 44 | }, 45 | ) 46 | if CONFIG_FILE.exists(): 47 | config.read(CONFIG_FILE) 48 | logger.debug("Loaded config from %s.", CONFIG_FILE) 49 | if not config.has_section("main"): 50 | config.add_section("main") 51 | logger.debug("Created missing 'main' section.") 52 | return config 53 | 54 | 55 | if __name__ == "__main__": 56 | load() 57 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | 4 | concurrency: 5 | group: cicd-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | jobs: 9 | test: 10 | name: Test on Linux64 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v5 17 | with: 18 | enable-cache: true 19 | 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version-file: "pyproject.toml" 23 | 24 | - name: Install system deps 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install \ 28 | girepository-2.0-dev \ 29 | libcairo2-dev \ 30 | python3-gi \ 31 | gobject-introspection \ 32 | libgtk-4-dev 33 | 34 | - name: Install the project 35 | run: uv sync --all-extras --dev 36 | 37 | - name: Run project checks 38 | run: uv run pre-commit run --all-files 39 | 40 | publish: 41 | name: Build & Publish 42 | needs: test 43 | if: startsWith(github.ref, 'refs/tags/v') 44 | runs-on: ubuntu-latest 45 | permissions: 46 | # Used to authenticate to PyPI via OIDC. 47 | # Used to sign the release's artifacts with sigstore-python. 48 | id-token: write 49 | # Used to attach signing artifacts to the published release. 50 | contents: write 51 | 52 | steps: 53 | - uses: actions/checkout@v4 54 | 55 | - name: Install uv 56 | uses: astral-sh/setup-uv@v5 57 | with: 58 | enable-cache: true 59 | 60 | - uses: actions/setup-python@v5 61 | with: 62 | python-version-file: "pyproject.toml" 63 | 64 | - name: Install system deps 65 | run: | 66 | sudo apt-get update 67 | sudo apt-get install \ 68 | libgirepository1.0-dev \ 69 | libcairo2-dev \ 70 | python3-gi \ 71 | gobject-introspection \ 72 | libgtk-4-dev 73 | 74 | - name: Build Python package 75 | run: uv build 76 | 77 | - name: Publish to PyPi 78 | run: uv publish 79 | 80 | - uses: ncipollo/release-action@v1 81 | with: 82 | body: See [CHANGELOG.md](https://github.com/dynobo/keyhint/blob/main/CHANGELOG.md) for details. 83 | -------------------------------------------------------------------------------- /keyhint/config/vscode.toml: -------------------------------------------------------------------------------- 1 | id = "vscode" 2 | url = "https://code.visualstudio.com/shortcuts/keyboard-shortcuts-linux.pdf" 3 | 4 | [match] 5 | regex_wmclass = "code" 6 | regex_title = ".*" 7 | 8 | [section] 9 | [section."Navigating UI"] 10 | "Ctrl + 0" = "Focus sidebar" 11 | "Ctrl + 1" = "Focus editor 1" 12 | "Ctrl + 2" = "Focus editor 2" 13 | 14 | [section.Sidebar] 15 | "Ctrl + B" = "Toggle sidebar" 16 | "Ctrl + Shift + E" = "Explorer in sidebar" 17 | "Ctrl + Shift + G" = "Git in sidebar" 18 | "Ctrl + Shift + D" = "Debug in sidebar" 19 | 20 | [section.Panel] 21 | "Ctrl + J" = "Toggle panel" 22 | "Ctrl + `" = "Toggle terminal" 23 | "Ctrl + Shift + M" = "Toggle problems" 24 | "Ctrl + Shift + Y" = "Toggle debug console" 25 | 26 | [section.View] 27 | "Ctrl + K + Z" = "Zen mode" 28 | 29 | [section."Basic Editing"] 30 | "Ctrl + L" = "Select current line" 31 | "Ctrl + Backspace" = "Delete last word" 32 | "Alt + Up Down" = "Move line up/down" 33 | "Alt + Shift + Up Down" = "Duplicate cursor" 34 | "Ctrl+ Alt + Shift + Up Down" = "Duplicate line" 35 | "Shift + Alt + Left Right" = "Expand/shrink selection" 36 | "Ctrl + H" = "Search and replace" 37 | D = "Delete from cursor till end of line" 38 | 39 | [section."Language Editing"] 40 | "Ctrl + Space" = "Trigger suggestion" 41 | "Ctrl + Shift + Space" = "Trigger parameter suggestion" 42 | "Ctrl + K & I" = "Show hover docs" 43 | F12 = "Go to definition" 44 | "Ctrl + K & F12" = "Open definition to the side" 45 | "Ctrl + Shift + F10" = "Peek definition" 46 | F2 = "Rename symbol" 47 | "Ctrl + K & C" = "Add line comment(s)" 48 | "Ctrl + K & U" = "Remove line comment(s)" 49 | 50 | [section.Navigation] 51 | "Ctrl + G" = "Go to line..." 52 | "Ctrl + P" = "Go to file..." 53 | "Ctrl + T" = "Go to any symbol..." 54 | "Ctrl + Shift + O" = "Go to symbol in current file..." 55 | "Ctrl + Alt + -" = "Go back" 56 | "Ctrl + Shift + -" = "Go forward" 57 | F8 = "Go to next error" 58 | "Shift + F8" = "Go to previous error" 59 | 60 | [section.Debugging] 61 | F9 = "Toogle breakpoint" 62 | F5 = "Start/continue" 63 | "Shift + F5" = "Stop" 64 | F11 = "Step into" 65 | "Shift + F11" = "Step out" 66 | F10 = "Step over" 67 | 68 | [section."File/Tab Management"] 69 | "Ctrl + Tab" = "Switch between tabs" 70 | "Ctrl + P" = "Quick open file" 71 | 72 | [section.Commands] 73 | "Ctrl + Shift + P" = "Open command palette" 74 | "Ctrl + ; & A" = "Run all tests" 75 | "Ctrl + ; & F" = "Run tests in current file" 76 | "Ctrl + ; & C" = "Run test at cursor" 77 | "Ctrl + ; & Ctrl + C" = "Debug test at cursor" 78 | -------------------------------------------------------------------------------- /keyhint/app.py: -------------------------------------------------------------------------------- 1 | """Cheatsheet for keyboard shortcuts & commands. 2 | 3 | Main entry point that get's executed on start. 4 | """ 5 | 6 | import logging 7 | import sys 8 | 9 | import gi 10 | 11 | gi.require_version("Gtk", "4.0") 12 | gi.require_version("Adw", "1") 13 | 14 | from gi.repository import Adw, Gio, GLib # noqa: E402 15 | 16 | from keyhint.window import KeyhintWindow # noqa: E402 17 | 18 | logging.basicConfig( 19 | format="%(asctime)s - %(levelname)-7s - %(module)s.py:%(lineno)d - %(message)s", 20 | datefmt="%H:%M:%S", 21 | level="WARNING", 22 | ) 23 | logger = logging.getLogger("keyhint") 24 | 25 | 26 | class Application(Adw.Application): 27 | """Main application class. 28 | 29 | Handle command line options and display the window. 30 | 31 | Args: 32 | Gtk (Gtk.Application): Application Class 33 | """ 34 | 35 | def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 36 | """Initialize application with command line options.""" 37 | kwargs.update( 38 | application_id="com.github.dynobo.keyhint", 39 | flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, 40 | ) 41 | super().__init__( 42 | *args, 43 | **kwargs, 44 | ) 45 | self.options: dict = {} 46 | 47 | self.add_main_option( 48 | "cheatsheet", 49 | ord("c"), 50 | GLib.OptionFlags.NONE, 51 | GLib.OptionArg.STRING, 52 | "Show cheatsheet with this ID on startup", 53 | "SHEET-ID", 54 | ) 55 | self.add_main_option( 56 | "verbose", 57 | ord("v"), 58 | GLib.OptionFlags.NONE, 59 | GLib.OptionArg.NONE, 60 | "Verbose log output for debugging", 61 | None, 62 | ) 63 | 64 | def do_activate(self, *_, **__) -> None: # noqa: ANN002, ANN003 65 | """Create and activate a window.""" 66 | window = KeyhintWindow(self.options) 67 | window.set_application(self) 68 | window.present() 69 | 70 | def do_command_line(self, cli: Gio.ApplicationCommandLine) -> int: 71 | """Store command line options in class attribute for later usage.""" 72 | self.options = cli.get_options_dict().end().unpack() 73 | 74 | if "verbose" in self.options: 75 | logger.setLevel("DEBUG") 76 | logger.debug("CLI Options: %s", self.options) 77 | 78 | self.activate() 79 | return 0 80 | 81 | 82 | def main() -> None: 83 | """Start application on script call.""" 84 | app = Application() 85 | app.run(sys.argv) 86 | 87 | 88 | if __name__ == "__main__": 89 | main() 90 | -------------------------------------------------------------------------------- /keyhint/config/vim.toml: -------------------------------------------------------------------------------- 1 | id = "vim" 2 | url = "" 3 | 4 | [match] 5 | regex_wmclass = "(foot|kitty|gnome-terminal)" 6 | regex_title = "^(n)?vim( .*)?$" 7 | 8 | [section] 9 | 10 | [section."Verbs (Operators)"] 11 | "y / Y" = "Yank (copy) / until end" 12 | "p / ]p" = "Paste / and adjust indent" 13 | "d / D" = "Delete / until end" 14 | "c / C" = "Change / until end" 15 | "v / V" = "Visually select / whole line" 16 | "> / <" = "Add / remove indentation" 17 | 18 | [section.Modifiers] 19 | i = "Inner" 20 | a = "Around" 21 | "{int}" = "x times" 22 | "t / T + {c}" = "Toward next / previous char" 23 | "f / F + {c}" = "Find next / previous char" 24 | "/" = "Search" 25 | 26 | [section."Nouns (Motions)"] 27 | "Ctrl + d / u" = "Half page down / up" 28 | "H / M / L" = "Top / middle / bottom of screen" 29 | "0 / ^ / $" = "Line start / first non-blank / end" 30 | "* / #" = "Next / previous token under cursor" 31 | "b / B" = "Beginning of word / token" 32 | "w / W" = "Next word / token" 33 | "e / E" = "End of word / token" 34 | "} / {" = "Next paragraph / previous paragraph" 35 | "%" = "Jump to matching bracket" 36 | "{mod} + p" = "Paragraph" 37 | "{mod} + s" = "Sentence" 38 | "{mod} + b" = "Block" 39 | "{mod} + t" = "XML Tag" 40 | "{mod} + \"" = "Quoted string" 41 | "{mod} + ( / )" = "Paired brackets" 42 | 43 | [section.Search] 44 | "\\/ / ?" = "Search forward / backwards" 45 | "n / N" = "Next / previous occurance" 46 | "* / #" = "Search next / previous word under cursor" 47 | 48 | [section.Navigating] 49 | gd = "Go to definition" 50 | gf = "Go to file in import" 51 | "gg / G" = "Go to top/bottom of file" 52 | 5G = "Go to line 5" 53 | "+" = "Go to first char of next line" 54 | "ma / `a" = "Mark a / jump to a" 55 | 56 | [section.Editing] 57 | "i / I" = "Insert before cursor / line" 58 | "a / A" = "Append after cursor / line" 59 | "o / O" = "Open new line below / above" 60 | "r / R" = "Replace char / and insert" 61 | "s / S" = "Substitute char / line" 62 | J = "Join lines" 63 | "x / X" = "Exterminate char under / before cursor" 64 | "u / Ctrl + r" = "Undo / redo" 65 | 66 | [section."Useful commands"] 67 | d5j = "Delete 5 lines downwards" 68 | "df\"" = "Delete in line until \"" 69 | "dt\"" = "Delete in line until before \"" 70 | ea = "Append to end of word" 71 | "d/foo" = "Delete from cursor until foo" 72 | ciw = "Change inner word" 73 | "gu / gU" = "Lowercase / uppercase" 74 | "Ctrl + v & j / k" = "Visual block select" 75 | "I & # & Esc" = "Comment selected visual block" 76 | "\"ayi\"" = "Yank inner \" to register a" 77 | "vi\"\"ap" = "Replace inner \" from register a" 78 | "V\"0p" = "Replace line with register 0 (w/o replacing R0)" 79 | 80 | [section.Registers] 81 | "`\"ay`" = "yank to regesiter a" 82 | "`\"ap`" = "paste from register a" 83 | "`\"+y`" = "yank to system clipboard" 84 | "`\"+p`" = "paste from system clipboard" 85 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.6.0 (2025-10-30) 4 | 5 | - Breaking: Drop support for GTK 4.11 and below. 6 | - Slightly speed up startup 7 | - Fix `GTK-Critical` warnings in terminal. 8 | 9 | ## v0.5.6 (2025-04-12) 10 | 11 | - Revert pygobject version bump to stay compatible with Ubuntu 22.04 LTS (last try wasn't enough) 12 | 13 | ## v0.5.5 (2025-03-27) 14 | 15 | - Revert pygobject version bump to stay compatible with Ubuntu 22.04 LTS 16 | 17 | ## v0.5.4 (2025-03-23) 18 | 19 | - Slightly speed up startup 20 | - Fix potential error when window titles contain single quotes 21 | - Extend vscode cheatsheet 22 | - View bindings a bit more compact 23 | 24 | ## v0.5.3 (2025-03-21) 25 | 26 | - Add shortcuts `Ctrl + Up`/`Down` to switch to next/previous cheatsheet: 27 | 28 | ## v0.5.2 (2024-10-04) 29 | 30 | - Add cheatsheet for Alacritty. 31 | - Changed `cli` cheatsheet to hidden by default. 32 | - Renamed browser- and vscode-extension cheatsheets. 33 | 34 | ## v0.5.1 (2024-07-04) 35 | 36 | - Fix support for older version of `libadwaita`. 37 | - Add cheatsheet for GH copilot. 38 | 39 | ## v0.5.0 (2024-04-23) 40 | 41 | - Breaking changes: 42 | - Renamed the attribute `regex_process` in `toml`-files to `regex_wmclass`. 43 | - Renamed the attribute `source` in `toml`-files to `url`. 44 | - Removed cli-args which are now covered by settings menu. 45 | - Fix duplicate IDs in included sections. 46 | - Add settings menu to ui. 47 | - Add Support KDE + Wayland. 48 | - Focus search field on start. 49 | - Cheatsheets: 50 | - Moved some CLI commands into separate cheatsheet and include it in terminal apps. 51 | - Added sheet for Keyhint itself. 52 | 53 | ## v0.4.3 (2024-02-13) 54 | 55 | - Fix background color in GTK 4.6. 56 | 57 | ## v0.4.2 (2024-02-13) 58 | 59 | - Fix missing method in GTK 4.6. 60 | 61 | ## v0.4.1 (2024-02-04) 62 | 63 | - Fix `No module named 'toml'`. 64 | 65 | ## v0.4.0 (2024-02-04) 66 | 67 | - Breaking changes in config files: 68 | - Switch from yaml to toml format for shortcuts (you can use 69 | [`yq`](https://mikefarah.gitbook.io/yq/) to convert yaml to toml) 70 | - The key `hints` is renamed to `section` 71 | - Dropping binary builds! Please install via `pipx install keyhint` instead. 72 | - Add filter for shortcuts or section. 73 | - Add possibility to hide whole cheatsheets via `hidden = true` in config files. 74 | - Add fullscreen mode as default (toggle via `F11`) 75 | 76 | ## v0.3.0 (2023-02-12) 77 | 78 | - Update app to Gtk4 79 | - Adjust hint files 80 | 81 | ## v0.2.4 (2022-03-09) 82 | 83 | - Switch to Nuitka for building binary release 84 | 85 | ## v0.2.3 (2022-03-06) 86 | 87 | - Add accent color for section titles 88 | 89 | ## v0.2.2 (2022-03-05) 90 | 91 | - Add hints for kitty 92 | - Add hints for pop-shell 93 | - Update dependencies 94 | 95 | ## v0.2.1 (2021-05-18) 96 | 97 | - Slightly improve shortcuts for vscode 98 | 99 | ## v0.2.0 (2021-04-03) 100 | 101 | - Complete rewrite 102 | - Drop support for Windows (for now) 103 | - Use GTK+ framework 104 | 105 | ## v0.1.3 (2020-10-18) 106 | 107 | - Switch to TKinter 108 | - Speed improvements 109 | 110 | ## v0.1.0 (2020-05-15) 111 | 112 | - Initial version 113 | -------------------------------------------------------------------------------- /keyhint/binding.py: -------------------------------------------------------------------------------- 1 | """Utility functions to format and view bindings (shortcut + label).""" 2 | 3 | import logging 4 | 5 | from gi.repository import GObject, Gtk 6 | 7 | logger = logging.getLogger("keyhint") 8 | 9 | 10 | def replace_keys(text: str) -> str: 11 | """Replace certain key names by corresponding unicode symbol. 12 | 13 | Args: 14 | text (str): Text with key names. 15 | 16 | Returns: 17 | str: Text where some key names have been replaced by unicode symbol. 18 | """ 19 | if text in {"PageUp", "PageDown"}: 20 | text = text.replace("Page", "Page ") 21 | 22 | text = text.replace("Down", "↓") 23 | text = text.replace("Up", "↑") 24 | text = text.replace("Left", "←") 25 | text = text.replace("Right", "→") 26 | text = text.replace("Direction", "←↓↑→") 27 | text = text.replace("PlusMinus", "±") 28 | text = text.replace("Plus", "+") # noqa: RUF001 29 | text = text.replace("Minus", "−") # noqa: RUF001 30 | text = text.replace("Slash", "/") 31 | 32 | return text # noqa: RET504 33 | 34 | 35 | def style_key(text: str) -> tuple[str, list[str]]: 36 | """Style the key as keycap or as divider (between two keycaps). 37 | 38 | Args: 39 | text: A single partition of a shortcut. 40 | 41 | Returns: 42 | (Unescaped) key of the shortcut, css classes to use. 43 | """ 44 | key_dividers = ["+", "/", "&", "or"] 45 | if text in key_dividers: 46 | css_classes = ["dim-label"] 47 | else: 48 | text = text.replace("\\/", "/") 49 | text = text.replace("\\+", "+") 50 | text = text.replace("\\&", "&") 51 | css_classes = ["keycap"] 52 | return text, css_classes 53 | 54 | 55 | class Row(GObject.Object): 56 | shortcut: str 57 | label: str 58 | filter_text: str 59 | 60 | def __init__(self, shortcut: str, label: str, section: str) -> None: 61 | super().__init__() 62 | self.shortcut = shortcut 63 | self.label = label 64 | self.filter_text = f"{shortcut} {label} {section}" 65 | 66 | 67 | def create_shortcut(text: str) -> Gtk.Box: 68 | box = Gtk.Box( 69 | orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END 70 | ) 71 | keys = [text.replace("`", "")] if text.startswith("`") else text.split() 72 | for k in keys: 73 | key = replace_keys(text=k.strip()) 74 | key, css_classes = style_key(text=key) 75 | label = Gtk.Label(label=key, css_classes=css_classes) 76 | box.append(label) 77 | return box 78 | 79 | 80 | def create_column_view_column( 81 | title: str, 82 | factory: Gtk.SignalListItemFactory, 83 | fixed_width: float | None = None, 84 | ) -> Gtk.ColumnViewColumn: 85 | column = Gtk.ColumnViewColumn(title=title, factory=factory) 86 | if fixed_width: 87 | column.set_fixed_width(int(fixed_width)) 88 | return column 89 | 90 | 91 | def create_column_view( 92 | selection: Gtk.SelectionModel, 93 | shortcut_column: Gtk.ColumnViewColumn, 94 | label_column: Gtk.ColumnViewColumn, 95 | ) -> Gtk.ColumnView: 96 | column_view = Gtk.ColumnView( 97 | hexpand=True, halign=Gtk.Align.START, valign=Gtk.Align.START, model=selection 98 | ) 99 | column_view.get_style_context().add_class("bindings-section") 100 | column_view.append_column(shortcut_column) 101 | column_view.append_column(label_column) 102 | return column_view 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cheatsheet.jpeg 2 | .virtualenvs 3 | .vscode 4 | run.sh 5 | *.*~ 6 | .envrc 7 | .ruff_cache 8 | coverage.lcov 9 | 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # OSX useful to ignore 17 | *.DS_Store 18 | .AppleDouble 19 | .LSOverride 20 | 21 | # Thumbnails 22 | ._* 23 | 24 | # Files that might appear in the root of a volume 25 | .DocumentRevisions-V100 26 | .fseventsd 27 | .Spotlight-V100 28 | .TemporaryItems 29 | .Trashes 30 | .VolumeIcon.icns 31 | .com.apple.timemachine.donotpresent 32 | 33 | # Directories potentially created on remote AFP share 34 | .AppleDB 35 | .AppleDesktop 36 | Network Trash Folder 37 | Temporary Items 38 | .apdisk 39 | 40 | # C extensions 41 | *.so 42 | 43 | # Distribution / packaging 44 | .Python 45 | env/ 46 | build/ 47 | develop-eggs/ 48 | dist/ 49 | downloads/ 50 | eggs/ 51 | .eggs/ 52 | lib/ 53 | lib64/ 54 | parts/ 55 | sdist/ 56 | var/ 57 | *.egg-info/ 58 | .installed.cfg 59 | *.egg 60 | 61 | # IntelliJ Idea family of suites 62 | .idea 63 | *.iml 64 | ## File-based project format: 65 | *.ipr 66 | *.iws 67 | ## mpeltonen/sbt-idea plugin 68 | .idea_modules/ 69 | 70 | # Briefcase build directories 71 | iOS/ 72 | macOS/ 73 | windows/ 74 | android/ 75 | linux/ 76 | django/ 77 | 78 | # Byte-compiled / optimized / DLL files 79 | __pycache__/ 80 | *.py[cod] 81 | *$py.class 82 | 83 | # C extensions 84 | *.so 85 | 86 | # Distribution / packaging 87 | .Python 88 | build/ 89 | develop-eggs/ 90 | dist/ 91 | downloads/ 92 | eggs/ 93 | .eggs/ 94 | lib/ 95 | lib64/ 96 | parts/ 97 | sdist/ 98 | var/ 99 | wheels/ 100 | pip-wheel-metadata/ 101 | share/python-wheels/ 102 | *.egg-info/ 103 | .installed.cfg 104 | *.egg 105 | MANIFEST 106 | 107 | # PyInstaller 108 | # Usually these files are written by a python script from a template 109 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 110 | *.manifest 111 | *.spec 112 | 113 | # Installer logs 114 | pip-log.txt 115 | pip-delete-this-directory.txt 116 | 117 | # Unit test / coverage reports 118 | htmlcov/ 119 | .tox/ 120 | .nox/ 121 | .coverage 122 | .coverage.* 123 | .cache 124 | nosetests.xml 125 | coverage.xml 126 | *.cover 127 | *.py,cover 128 | .hypothesis/ 129 | .pytest_cache/ 130 | 131 | # Translations 132 | *.mo 133 | *.pot 134 | 135 | # Django stuff: 136 | *.log 137 | local_settings.py 138 | db.sqlite3 139 | db.sqlite3-journal 140 | 141 | # Flask stuff: 142 | instance/ 143 | .webassets-cache 144 | 145 | # Scrapy stuff: 146 | .scrapy 147 | 148 | # Sphinx documentation 149 | docs/_build/ 150 | 151 | # PyBuilder 152 | target/ 153 | 154 | # Jupyter Notebook 155 | .ipynb_checkpoints 156 | 157 | # IPython 158 | profile_default/ 159 | ipython_config.py 160 | 161 | # pyenv 162 | .python-version 163 | 164 | # pipenv 165 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 166 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 167 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 168 | # install all needed dependencies. 169 | #Pipfile.lock 170 | 171 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 172 | __pypackages__/ 173 | 174 | # Celery stuff 175 | celerybeat-schedule 176 | celerybeat.pid 177 | 178 | # SageMath parsed files 179 | *.sage.py 180 | 181 | # Environments 182 | .env 183 | .venv 184 | env/ 185 | venv/ 186 | ENV/ 187 | env.bak/ 188 | venv.bak/ 189 | 190 | # Spyder project settings 191 | .spyderproject 192 | .spyproject 193 | 194 | # Rope project settings 195 | .ropeproject 196 | 197 | # mkdocs documentation 198 | /site 199 | 200 | # mypy 201 | .mypy_cache/ 202 | .dmypy.json 203 | dmypy.json 204 | 205 | # Pyre type checker 206 | .pyre/ 207 | -------------------------------------------------------------------------------- /keyhint/resources/keyhint_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 23 | 24 | 26 | image/svg+xml 27 | 29 | 30 | 31 | 32 | 33 | 35 | 56 | 61 | 64 | 70 | 76 | 80 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /keyhint/resources/window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "keyhint" 7 | version = "0.6.0" 8 | description = "Cheat-sheets for shortcuts & commands at your fingertips." 9 | keywords = ["shortcuts", "keybindings", "hints", "helper", "cheatsheet"] 10 | readme = "README.md" 11 | requires-python = ">=3.11" 12 | authors = [{ name = "dynobo", email = "dynobo@mailbox.org" }] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: Implementation :: CPython", 20 | "Programming Language :: Python :: Implementation :: PyPy", 21 | "Topic :: Utilities", 22 | "Intended Audience :: End Users/Desktop", 23 | "Operating System :: POSIX :: Linux", 24 | ] 25 | dependencies = ["PyGObject>=3.42.2"] 26 | 27 | [project.urls] 28 | Documentation = "https://github.com/dynobo/keyhint#readme" 29 | Issues = "https://github.com/dynobo/keyhint/issues" 30 | Source = "https://github.com/dynobo/keyhint" 31 | 32 | [project.scripts] 33 | keyhint = "keyhint.app:main" 34 | 35 | [dependency-groups] 36 | dev = [ 37 | "coverage[toml]>=6.5", 38 | "pytest", 39 | "pytest-cov", 40 | "pre-commit", 41 | "coveralls", 42 | "types-toml", 43 | "tbump", 44 | "ruff", 45 | "pip-audit", 46 | "mypy", 47 | "mdformat", 48 | "pygobject-stubs", 49 | ] 50 | 51 | [tool.ruff] 52 | target-version = "py311" 53 | line-length = 88 54 | exclude = [".venv"] 55 | 56 | [tool.ruff.lint] 57 | select = [ 58 | "F", # Pyflakes 59 | "E", # pycodestyle 60 | "I", # Isort 61 | "D", # pydocstyle 62 | "W", # warning 63 | "UP", # pyupgrad 64 | "N", # pep8-naming 65 | "C90", # mccabe 66 | "TRY", # tryceratops (exception handling) 67 | "ANN", # flake8-annotations 68 | "S", # flake8-bandits 69 | "C4", # flake8-comprehensions 70 | "B", # flake8-bugbear 71 | "A", # flake8-builtins 72 | "ISC", # flake8-implicit-str-concat 73 | "ICN", # flake8-import-conventions 74 | "T20", # flake8-print 75 | "PYI", # flake8-pyi 76 | "PT", # flake8-pytest-style 77 | "Q", # flake8-quotes 78 | "RET", # flake8-return 79 | "SIM", # flake8-simplify 80 | "PTH", # flake8-use-pathlib 81 | "G", # flake8-logging-format 82 | "PL", # pylint 83 | "RUF", # meta rules (unused noqa) 84 | "PL", # meta rules (unused noqa) 85 | "PERF", # perflint 86 | ] 87 | ignore = [ 88 | "D100", # Missing docstring in public module 89 | "D101", # Missing docstring in public class 90 | "D102", # Missing docstring in public method 91 | "D103", # Missing docstring in public function 92 | "D104", # Missing docstring in public package 93 | "D105", # Missing docstring in magic method 94 | "D107", # Missing docstring in __init__ 95 | "ANN101", # Missing type annotation for `self` in method 96 | "TRY003", # Avoid specifying long messages outside the exception class 97 | "ISC001", # Rule conflicts with ruff's formaatter 98 | ] 99 | 100 | [tool.ruff.lint.flake8-tidy-imports] 101 | ban-relative-imports = "all" 102 | 103 | [tool.ruff.lint.per-file-ignores] 104 | "tests/**/*" = ["PLR2004", "PLR0913", "S101", "TID252", "ANN", "D"] 105 | 106 | [tool.ruff.lint.pydocstyle] 107 | convention = "google" 108 | 109 | [tool.ruff.lint.isort] 110 | known-first-party = ["keyhint"] 111 | 112 | [tool.mypy] 113 | files = ["keyhint/**/*.py", "tests/**/*.py"] 114 | follow_imports = "skip" 115 | ignore_missing_imports = true 116 | 117 | [tool.pytest.ini_options] 118 | testpaths = ["tests"] 119 | addopts = [ 120 | "--durations=5", 121 | "--showlocals", 122 | "--cov", 123 | "--cov-report=xml", 124 | "--cov-report=html", 125 | ] 126 | 127 | [tool.coverage.run] 128 | source_pkgs = ["keyhint"] 129 | branch = true 130 | parallel = true 131 | omit = [] 132 | 133 | [tool.mdformat] 134 | wrap = 88 135 | number = true 136 | end_of_line = "keep" 137 | 138 | [tool.tbump] 139 | 140 | [tool.tbump.version] 141 | current = "0.6.0" 142 | regex = ''' 143 | (?P\d+) 144 | \. 145 | (?P\d+) 146 | \. 147 | (?P\d+) 148 | ((?P.+))? 149 | ''' 150 | 151 | [tool.tbump.git] 152 | message_template = "Bump to {new_version}" 153 | tag_template = "v{new_version}" 154 | 155 | [[tool.tbump.file]] 156 | src = "pyproject.toml" 157 | search = 'version = "{current_version}"' 158 | 159 | [[tool.tbump.file]] 160 | src = "keyhint/__init__.py" 161 | 162 | [[tool.tbump.before_commit]] 163 | name = "check changelog" 164 | cmd = "grep -q {new_version} CHANGELOG.md" 165 | -------------------------------------------------------------------------------- /keyhint/sheets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import tomllib 5 | from copy import deepcopy 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from keyhint import config 10 | 11 | logger = logging.getLogger("keyhint") 12 | 13 | 14 | def _load_toml(file_path: str | os.PathLike) -> dict: 15 | """Load a toml file from resource path or other path. 16 | 17 | Args: 18 | file_path: Filename in resources, or complete path to file. 19 | from_resources: Set to true to load from resource. Defaults 20 | to False. 21 | 22 | Returns: 23 | [description] 24 | """ 25 | try: 26 | with Path(file_path).open("rb") as fh: 27 | result = tomllib.load(fh) 28 | except Exception as exc: 29 | logger.warning("Could not loading toml file %s: %s", file_path, exc) 30 | result = {} 31 | 32 | return result 33 | 34 | 35 | def load_default_sheets() -> list[dict]: 36 | """Load default keyhints from toml files shipped with the package. 37 | 38 | Returns: 39 | List[dict]: List of application keyhints and meta info. 40 | """ 41 | default_sheet_path = Path(__file__).parent / "config" 42 | sheets = [_load_toml(f) for f in default_sheet_path.glob("*.toml")] 43 | logger.debug("Found %s default sheets.", len(sheets)) 44 | return sorted(sheets, key=lambda k: k["id"]) 45 | 46 | 47 | def load_user_sheets() -> list[dict]: 48 | """Load cheatsheets from toml files in the users .config/keyhint/ directory. 49 | 50 | Returns: 51 | List[dict]: List of application keyhints and meta info. 52 | """ 53 | files = config.CONFIG_PATH.glob("*.toml") 54 | sheets = [_load_toml(f) for f in files] 55 | logger.debug("Found %s user sheets in %s/.", len(sheets), config.CONFIG_PATH) 56 | return sorted(sheets, key=lambda k: k["id"]) 57 | 58 | 59 | def _expand_includes(sheets: list[dict]) -> list[dict]: 60 | new_sheets = [] 61 | for s in sheets: 62 | for include in s.get("include", []): 63 | included_sheets = [c for c in sheets if c["id"] == include] 64 | if not included_sheets: 65 | message = f"Sheet '{include}' included by '{s['id']}' not found!" 66 | raise ValueError(message) 67 | included_sheet = deepcopy(included_sheets[0]) 68 | included_sheet["section"] = { 69 | f"[{included_sheet['id']}] {k}": v 70 | for k, v in included_sheet["section"].items() 71 | } 72 | s["section"].update(included_sheet["section"]) 73 | new_sheets.append(s) 74 | return new_sheets 75 | 76 | 77 | def _remove_empty_sections(sheets: list[dict]) -> list[dict]: 78 | for sheet in sheets: 79 | sheet["section"] = {k: v for k, v in sheet["section"].items() if v} 80 | return sheets 81 | 82 | 83 | def _remove_hidden(sheets: list[dict]) -> list[dict]: 84 | return [s for s in sheets if not s.get("hidden", False)] 85 | 86 | 87 | def _update_or_append(sheets: list[dict], new_sheet: dict) -> list[dict]: 88 | for sheet in sheets: 89 | if sheet["id"] == new_sheet["id"]: 90 | # Update existing default sheet by user sheet 91 | sheet["section"].update(new_sheet.pop("section", {})) 92 | sheet["match"].update(new_sheet.pop("match", {})) 93 | sheet.update(new_sheet) 94 | break 95 | else: 96 | # If default sheet didn't exist, append as new 97 | sheets.append(new_sheet) 98 | return sheets 99 | 100 | 101 | def load_sheets() -> list[dict]: 102 | """Load unified default keyhints and keyhints from user config. 103 | 104 | First the default keyhints are loaded, then they are update (added/overwritten) 105 | by the keyhints loaded from user config. 106 | 107 | Returns: 108 | List[dict]: List of application keyhints and meta info. 109 | """ 110 | sheets = load_default_sheets() 111 | user_sheets = load_user_sheets() 112 | 113 | for user_sheet in user_sheets: 114 | sheets = _update_or_append(sheets, user_sheet) 115 | 116 | sheets = _expand_includes(sheets) 117 | sheets = _remove_hidden(sheets) 118 | sheets = _remove_empty_sections(sheets) 119 | logger.debug("Loaded %s sheets.", len(sheets)) 120 | return sheets 121 | 122 | 123 | def get_sheet_by_id(sheets: list[dict], sheet_id: str) -> dict[str, Any]: 124 | return next(sheet for sheet in sheets if sheet["id"] == sheet_id) 125 | 126 | 127 | def get_sheet_id_by_active_window( 128 | sheets: list[dict], wm_class: str, window_title: str 129 | ) -> str | None: 130 | matching_sheets = [ 131 | h 132 | for h in sheets 133 | if re.search(h["match"]["regex_wmclass"], wm_class, re.IGNORECASE) 134 | and re.search(h["match"]["regex_title"], window_title, re.IGNORECASE) 135 | ] 136 | 137 | if not matching_sheets: 138 | return None 139 | 140 | # First sort by secondary criterion 141 | matching_sheets.sort(key=lambda h: len(h["match"]["regex_title"]), reverse=True) 142 | 143 | # Then sort by primary criterion 144 | matching_sheets.sort(key=lambda h: len(h["match"]["regex_wmclass"]), reverse=True) 145 | 146 | # First element is (hopefully) the best fitting sheet id 147 | return matching_sheets[0]["id"] 148 | 149 | 150 | if __name__ == "__main__": 151 | load_sheets() 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KeyHint 2 | 3 | **_Utility to display keyboard shortcuts or other hints based on the active window on 4 | Linux._** 5 | 6 |


7 | Tests passing 8 | License: MIT 9 | Code style: black 10 | Coverage Status 11 |

12 | 13 | ![Keyhint Screenshot](https://raw.githubusercontent.com/dynobo/keyhint/main/keyhint/resources/keyhint.png) 14 | 15 | ## Prerequisites 16 | 17 | - Python 3.11+ 18 | - GTK 4.12+ (shipped since Ubuntu 23.10) + related dev packages: 19 | ```sh 20 | sudo apt-get install \ 21 | libgirepository1.0-dev \ 22 | libcairo2-dev \ 23 | python3-gi \ 24 | gobject-introspection \ 25 | libgtk-4-dev 26 | ``` 27 | - Wayland & Gnome: The 28 | [Gnome Extension "Window-Calls"](https://extensions.gnome.org/extension/4724/window-calls/) 29 | is required to auto-select the cheatsheet based on the current active application. 30 | 31 | ## Installation 32 | 33 | - `uv tool install keyhint` (recommended, requires [uv](https://docs.astral.sh/uv/)) 34 | - `pipx install keyhint` (requires [pipx](https://pipx.pypa.io/)) 35 | - _or_ `pip install keyhint` 36 | 37 | ## Usage 38 | 39 | - Configure a **global hotkey** (e.g. `Ctrl + F1`) **via your system settings** to 40 | launch `keyhint`. 41 | - If KeyHint is launched via hotkey, it detects the current active application and shows 42 | the appropriate hints. (This feature won't work reliably when KeyHint ist started via 43 | Menu or Launcher.) 44 | 45 | ## CLI Options 46 | 47 | ``` 48 | Application Options: 49 | -c, --cheatsheet=SHEET-ID Show cheatsheet with this ID on startup 50 | -v, --verbose Verbose log output for debugging 51 | ``` 52 | 53 | ## Cheatsheet Configuration 54 | 55 | The content which KeyHint displays is configured using [`toml`](https://toml.io/en/) 56 | configuration files. 57 | 58 | KeyHint reads those files from two locations: 59 | 60 | 1. The [built-in directory](https://github.com/dynobo/keyhint/tree/main/keyhint/config) 61 | 1. The user directory, usually located in `~/.config/keyhint` 62 | 63 | ### How Keyhint selects the cheatsheet to show 64 | 65 | - The cheatsheet to be displayed on startup are selected by comparing the value of 66 | `regex_wmclass` with the wm_class of the active window and the value of `regex_title` 67 | with the title of the active window. 68 | - The potential cheatsheets are processed alphabetically by filename, the first file 69 | that matches both wm_class and title are getting displayed. 70 | - Both of `regex_` values are interpreted as **case in-sensitive regular expressions**. 71 | - Check "Debug Info" in the application menu to get insights about the active window and 72 | the selected cheatsheet file. 73 | 74 | ### Customize or add cheatsheets 75 | 76 | - To **change built-in** cheatsheets, copy 77 | [the corresponding .toml-file](https://github.com/dynobo/keyhint/tree/main/keyhint/config) 78 | into the config directory. Make your changes in a text editor. As long as you don't 79 | change the `id` it will overwrite the defaults. 80 | - To **create new** cheatsheets, I suggest you start with 81 | [one of the existing .toml-file](https://github.com/dynobo/keyhint/tree/main/keyhint/config): 82 | - Place it in the config directory and give it a good file name. 83 | - Change the value `id` to something unique. 84 | - Adjust `regex_wmclass` and `regex_title` so it will be selected based on the active 85 | window. (See [Tips](#tips)) 86 | - Add the `shortcuts` & `label` to a `section`. 87 | - If you think your cheatsheet might be useful for others, please consider opening a 88 | pull request or an issue! 89 | - You can always **reset cheatsheets** to the shipped version by deleting the 90 | corresponding `.toml` files from the config folder. 91 | - You can **include shortcuts from other cheatsheets** by adding 92 | `include = [""]` 93 | 94 | ### Examples 95 | 96 | #### Hide existing cheatsheets 97 | 98 | To hide a cheatsheet, e.g. the 99 | [built-in](https://github.com/dynobo/keyhint/blob/main/keyhint/config/tilix.toml) one 100 | with the ID `tilix`, create a new file `~/.config/keyhint/tilix.toml` with the content: 101 | 102 | ```toml 103 | id = "tilix" 104 | hidden = true 105 | ``` 106 | 107 | #### Extend existing cheatsheets 108 | 109 | To add keybindings to an existing cheatsheet, e.g. the 110 | [built-in](https://github.com/dynobo/keyhint/blob/main/keyhint/config/firefox.toml) one 111 | with the ID `firefox`, create a new file `~/.config/keyhint/firefox.toml` which only 112 | contains the ID and the additional bindings: 113 | 114 | ```toml 115 | id = "firefox" 116 | 117 | [section] 118 | [section."My Personal Favorites"] # New section 119 | "Ctrl + Shift + Tab" = "Show all Tabs" 120 | # ... 121 | ``` 122 | 123 | #### Add new cheatsheet which never gets auto-selected 124 | 125 | To add a new cheatsheet, which never gets automatically selected and displayed by 126 | KeyHint, but remains accessible through KeyHint's cheatsheet dropdown, create a file 127 | `~/.config/keyhint/my-app.toml`: 128 | 129 | ```toml 130 | id = "my-app" 131 | url = "url-to-my-apps-keybindings" 132 | 133 | [match] 134 | regex_wmclass = "a^" # Patter which never matches 135 | regex_title = "a^" 136 | 137 | [section] 138 | [section.General] 139 | "Ctrl + C" = "Copy" 140 | # ... 141 | 142 | ``` 143 | 144 | #### Different cheatsheets for different Websites 145 | 146 | For showing different browser-cheatsheets depending on the current website, you might 147 | want to use a browser extension like 148 | "[Add URL To Window Title](https://addons.mozilla.org/en-US/firefox/addon/add-url-to-window-title/)" 149 | and configure the `[match]` section to look for the url in the title. E.g. 150 | `~/.config/keyhint/github.toml` 151 | 152 | ```toml 153 | id = "github.com" 154 | 155 | [match] 156 | regex_wmclass = "Firefox" 157 | regex_title = ".*github\\.com.*" # URL added by browser extensions to window title 158 | 159 | [section] 160 | [section.Repositories] 161 | gc = "Goto code tab" 162 | # ... 163 | ``` 164 | 165 | ## Contribute 166 | 167 | I'm happy about any contribution! Especially I would appreciate submissions to improve 168 | the 169 | [shipped cheatsheets](https://github.com/dynobo/keyhint/tree/main/keyhint/config). 170 | (The current set are the cheatsheets I personally use). 171 | 172 | ## Design Principles 173 | 174 | - **Don't run as service**
It shouldn't consume resources in the background, even if 175 | this leads to slightly slower start-up time. 176 | - **No network connection**
Everything should run locally without any network 177 | communication. 178 | - **Dependencies**
The fewer dependencies, the better. 179 | 180 | ## Certification 181 | 182 | ![WOMM](https://raw.githubusercontent.com/dynobo/lmdiag/master/badge.png) 183 | -------------------------------------------------------------------------------- /keyhint/context.py: -------------------------------------------------------------------------------- 1 | """Functions to provide info about the context in which keyhint was started.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import re 7 | import shutil 8 | import subprocess 9 | import tempfile 10 | import textwrap 11 | from datetime import datetime 12 | from functools import cache 13 | 14 | from gi.repository import Gio 15 | 16 | logger = logging.getLogger("keyhint") 17 | 18 | 19 | def is_using_wayland() -> bool: 20 | """Check if we are running on Wayland DE. 21 | 22 | Returns: 23 | [bool] -- {True} if probably Wayland 24 | """ 25 | return "WAYLAND_DISPLAY" in os.environ 26 | 27 | 28 | def has_xprop() -> bool: 29 | """Check if xprop is installed. 30 | 31 | Returns: 32 | [bool] -- {True} if xprop is installed 33 | """ 34 | return shutil.which("xprop") is not None 35 | 36 | 37 | def get_gnome_version() -> str: 38 | """Detect Gnome version of current session. 39 | 40 | Returns: 41 | Version string or '(n/a)'. 42 | """ 43 | if not shutil.which("gnome-shell"): 44 | return "(n/a)" 45 | 46 | try: 47 | output = subprocess.check_output( 48 | ["gnome-shell", "--version"], # noqa: S607 49 | shell=False, 50 | text=True, 51 | ) 52 | if result := re.search(r"\s+([\d\.]+)", output.strip()): 53 | gnome_version = result.groups()[0] 54 | except Exception as e: 55 | logger.warning("Exception when trying to get gnome version from cli %s", e) 56 | return "(n/a)" 57 | else: 58 | return gnome_version 59 | 60 | 61 | def get_kde_version() -> str: 62 | """Detect KDE platform version of current session. 63 | 64 | Returns: 65 | Version string or '(n/a)'. 66 | """ 67 | if not shutil.which("plasmashell"): 68 | return "(n/a)" 69 | 70 | try: 71 | output = subprocess.check_output( 72 | ["plasmashell", "--version"], # noqa: S607 73 | shell=False, 74 | text=True, 75 | ) 76 | if result := re.search(r"([\d+\.]+)", output.strip()): 77 | kde_version = result.groups()[0] 78 | except Exception as e: 79 | logger.warning("Exception when trying to get kde version from cli %s", e) 80 | return "(n/a)" 81 | else: 82 | return kde_version 83 | 84 | 85 | @cache 86 | def get_desktop_environment() -> str: 87 | """Detect used desktop environment.""" 88 | kde_full_session = os.environ.get("KDE_FULL_SESSION", "").lower() 89 | xdg_current_desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() 90 | desktop_session = os.environ.get("DESKTOP_SESSION", "").lower() 91 | gnome_desktop_session_id = os.environ.get("GNOME_DESKTOP_SESSION_ID", "") 92 | hyprland_instance_signature = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE", "") 93 | 94 | if gnome_desktop_session_id == "this-is-deprecated": 95 | gnome_desktop_session_id = "" 96 | 97 | de = "(DE not detected)" 98 | 99 | if gnome_desktop_session_id or "gnome" in xdg_current_desktop: 100 | de = "Gnome" 101 | if kde_full_session or "kde-plasma" in desktop_session: 102 | de = "KDE" 103 | if "sway" in xdg_current_desktop or "sway" in desktop_session: 104 | de = "Sway" 105 | if "unity" in xdg_current_desktop: 106 | de = "Unity" 107 | if hyprland_instance_signature: 108 | de = "Hyprland" 109 | if "awesome" in xdg_current_desktop: 110 | de = "Awesome" 111 | 112 | return de 113 | 114 | 115 | def get_active_window_via_window_calls() -> tuple[str, str]: 116 | """Retrieve active window class and active window title on Gnome + Wayland. 117 | 118 | Returns: 119 | Tuple(str, str): window class, window title 120 | """ 121 | wm_class = "" 122 | title = "" 123 | 124 | bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) 125 | proxy = Gio.DBusProxy.new_sync( 126 | bus, 127 | Gio.DBusProxyFlags.NONE, 128 | None, 129 | "org.gnome.Shell", 130 | "/org/gnome/Shell/Extensions/Windows", 131 | "org.gnome.Shell.Extensions.Windows", 132 | None, 133 | ) 134 | result = proxy.call_sync( 135 | "List", 136 | None, 137 | Gio.DBusCallFlags.NONE, 138 | -1, 139 | None, 140 | ) 141 | 142 | windows = json.loads(result.unpack()[0]) 143 | 144 | focused_windows = [w for w in windows if w.get("focus")] 145 | focused_window = focused_windows[0] 146 | 147 | wm_class = focused_window.get("wm_class", "") 148 | title = focused_window.get("title", "") 149 | 150 | return wm_class, title 151 | 152 | 153 | # TODO: Migrate to using gtk dbus 154 | def get_active_window_via_kwin() -> tuple[str, str]: 155 | """Retrieve active window class and active window title on KDE + Wayland. 156 | 157 | Returns: 158 | Tuple(str, str): window class, window title 159 | """ 160 | kwin_script = textwrap.dedent(""" 161 | console.info("keyhint test"); 162 | client = workspace.activeClient; 163 | title = client.caption; 164 | wm_class = client.resourceClass; 165 | console.info(`keyhint_out: wm_class=${wm_class}, window_title=${title}`); 166 | """) 167 | 168 | with tempfile.NamedTemporaryFile(suffix=".js", delete=False) as fh: 169 | fh.write(kwin_script.encode()) 170 | cmd_load = ( 171 | "gdbus call --session --dest org.kde.KWin " 172 | "--object-path /Scripting " 173 | f"--method org.kde.kwin.Scripting.loadScript '{fh.name}'" 174 | ) 175 | logger.debug("cmd_load: %s", cmd_load) 176 | stdout = subprocess.check_output(cmd_load, shell=True).decode() # noqa: S602 177 | 178 | logger.debug("loadScript output: %s", stdout) 179 | script_id = stdout.strip().strip("()").split(",")[0] 180 | 181 | since = str(datetime.now()) 182 | 183 | cmd_run = ( 184 | "gdbus call --session --dest org.kde.KWin " 185 | f"--object-path /{script_id} " 186 | "--method org.kde.kwin.Script.run" 187 | ) 188 | subprocess.check_output(cmd_run, shell=True) # noqa: S602 189 | 190 | cmd_unload = ( 191 | "gdbus call --session --dest org.kde.KWin " 192 | "--object-path /Scripting " 193 | f"--method org.kde.kwin.Scripting.unloadScript {script_id}" 194 | ) 195 | subprocess.check_output(cmd_unload, shell=True) # noqa: S602 196 | 197 | # Unfortunately, we can read script output from stdout, because of a KDE bug: 198 | # https://bugs.kde.org/show_bug.cgi?id=445058 199 | # The output has to be read through journalctl instead. A timestamp for 200 | # filtering speeds up the process. 201 | log_lines = ( 202 | subprocess.check_output( # noqa: S602 203 | f'journalctl --user -o cat --since "{since}"', 204 | shell=True, 205 | ) 206 | .decode() 207 | .split("\n") 208 | ) 209 | logger.debug("Journal message: %s", log_lines) 210 | result_line = [m for m in log_lines if "keyhint_out" in m][-1] 211 | match = re.search(r"keyhint_out: wm_class=(.+), window_title=(.+)", result_line) 212 | if match: 213 | wm_class = match.group(1) 214 | title = match.group(2) 215 | else: 216 | logger.warning("Could not extract window info from KWin log!") 217 | wm_class = title = "" 218 | 219 | return wm_class, title 220 | 221 | 222 | def get_active_window_via_xprop() -> tuple[str, str]: 223 | """Retrieve active window class and active window title on Xorg desktops. 224 | 225 | Returns: 226 | Tuple(str, str): window class, window title 227 | """ 228 | # Query id of active window 229 | stdout_bytes: bytes = subprocess.check_output( # noqa: S602 230 | "xprop -root _NET_ACTIVE_WINDOW", # noqa: S607 231 | shell=True, 232 | ) 233 | stdout = stdout_bytes.decode() 234 | 235 | # Identify id of active window in output 236 | match = re.search(r"^_NET_ACTIVE_WINDOW.* ([\w]+)$", stdout) 237 | if match is None: 238 | # Stop, if there is not active window detected 239 | return "", "" 240 | window_id: str = match.group(1) 241 | 242 | # Query app_title and app_process 243 | stdout_bytes = subprocess.check_output( # noqa: S602 244 | f"xprop -id {window_id} WM_NAME WM_CLASS", 245 | shell=True, 246 | ) 247 | stdout = stdout_bytes.decode() 248 | 249 | # Extract app_title and app_process from output 250 | title = wm_class = "" 251 | 252 | match = re.search(r'WM_NAME\(\w+\) = "(?P.+)"', stdout) 253 | if match is not None: 254 | title = match.group("name") 255 | 256 | match = re.search(r'WM_CLASS\(\w+\) =.*"(?P.+?)"$', stdout) 257 | if match is not None: 258 | wm_class = match.group("class") 259 | 260 | return wm_class, title 261 | -------------------------------------------------------------------------------- /keyhint/resources/headerbar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | popover_menu 8 | 250 9 | 10 | 11 | popover_menu_box 12 | 1 13 | 16 | 17 | 18 | Section Order 19 | 22 | 23 | 24 | 25 | 26 | 0 27 | true 28 | 3 29 | 4 30 | 14 31 | 34 | 35 | 36 | Sort sections by height 37 | true 38 | win.sort_by 39 | 'size' 40 | 41 | 42 | By Size 43 | 44 | 45 | 46 | 47 | 48 | 49 | Sort sections alphabetically 50 | sort_by_size_button 51 | win.sort_by 52 | 'title' 53 | 54 | 55 | By Title 56 | 57 | 58 | 59 | 60 | 61 | 62 | Use section order from config files 63 | sort_by_size_button 64 | win.sort_by 65 | 'native' 66 | 67 | 68 | Native 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | Scroll Orientation 78 | 81 | 82 | 83 | 84 | 85 | 0 86 | true 87 | 3 88 | 4 89 | 14 90 | 93 | 94 | 95 | Scroll left ↔ right 96 | win.orientation 97 | 'vertical' 98 | true 99 | 100 | 101 | Vertical 102 | 103 | 104 | 105 | 106 | 107 | 108 | Scroll up ↔ down 109 | win.orientation 110 | 'horizontal' 111 | scroll_vertical_button 112 | 113 | 114 | Horizontal 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | Zoom % 124 | 127 | 128 | 129 | 130 | 131 | Adjust font size 132 | 0 133 | 14 134 | 0 135 | 0 136 | 137 | 138 | 100 139 | 75 140 | 150 141 | 142 | 143 | 144 | 145 | 146 | 147 | Fallback Cheatsheet 148 | 151 | 152 | 153 | 154 | 155 | 0 156 | 3 157 | 4 158 | 12 159 | 162 | 163 | 164 | keyhint 165 | false 166 | Shown if no matching cheatsheet is found 167 | 168 | 169 | 170 | 171 | pin 172 | Set to current cheatsheet 173 | 174 | 175 | 176 | 177 | 178 | 179 | 4 180 | 6 181 | False 182 | 183 | 184 | 185 | 186 | 0 187 | win.open_folder 188 | 191 | 192 | 193 | Open Cheatsheet Folder... 194 | 0 195 | 196 | 197 | 198 | 199 | 200 | 201 | 0 202 | win.debug_info 203 | 206 | 207 | 208 | Show Debug Info 209 | 0 210 | 211 | 212 | 213 | 214 | 215 | 216 | 0 217 | win.about 218 | 221 | 222 | 223 | About Keyhint 224 | 0 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 267 | 268 | -------------------------------------------------------------------------------- /keyhint/window.py: -------------------------------------------------------------------------------- 1 | """Logic handler used by the application window. 2 | 3 | Does the rendering of the cheatsheets as well interface actions. 4 | 5 | Naming Hierarchy: 6 | 1. Keyhint works with multiple cheatsheets, in short "Sheets". 7 | 2. A single "Sheet" corresponds to one application and is rendered as one page in UI. 8 | The sheet to render can be selected in the dropdown field. Each sheet has a 9 | "Sheet ID" which must be unique. 10 | 3. A "Sheet" consists of multiple "Sections", which group together shortcuts or commands 11 | into a blocks. Each section has a section title. 12 | 4. A "Section" consists of multiple "Bindings" 13 | 5. A "Binding" consists of a "Shortcut", which contains the key combination or command, 14 | and a "Label" which describes the combination/command. 15 | 16 | """ 17 | 18 | import logging 19 | import platform 20 | import subprocess 21 | from collections.abc import Callable 22 | from pathlib import Path 23 | from typing import Literal, TypeVar, cast 24 | 25 | from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk 26 | 27 | from keyhint import __version__, binding, config, context, css, headerbar, sheets 28 | 29 | logger = logging.getLogger("keyhint") 30 | 31 | RESOURCE_PATH = Path(__file__).parent / "resources" 32 | 33 | TKeyhintWindow = TypeVar("TKeyhintWindow", bound="KeyhintWindow") 34 | TActionCallback = Callable[[TKeyhintWindow, Gio.SimpleAction, GLib.Variant], None] 35 | 36 | 37 | def check_state(func: TActionCallback) -> TActionCallback: 38 | """Decorator to only execute a function if the action state really changed.""" 39 | 40 | def wrapper(cls: Gtk.Widget, action: Gio.SimpleAction, state: GLib.Variant) -> None: 41 | if action.get_state_type() and action.get_state() == state: 42 | return None 43 | 44 | if state: 45 | action.set_state(state) 46 | 47 | return func(cls, action, state) 48 | 49 | return wrapper 50 | 51 | 52 | @Gtk.Template(filename=f"{RESOURCE_PATH}/window.ui") 53 | class KeyhintWindow(Gtk.ApplicationWindow): 54 | """The main UI for Keyhint.""" 55 | 56 | __gtype_name__ = "main_window" 57 | 58 | overlay = cast(Adw.ToastOverlay, Gtk.Template.Child()) 59 | banner_window_calls = cast(Gtk.Revealer, Gtk.Template.Child()) 60 | banner_xprop = cast(Gtk.Revealer, Gtk.Template.Child()) 61 | scrolled_window = cast(Gtk.ScrolledWindow, Gtk.Template.Child()) 62 | container = cast(Gtk.Box, Gtk.Template.Child()) 63 | sheet_container_box = cast(Gtk.FlowBox, Gtk.Template.Child()) 64 | 65 | shortcut_column_factory = Gtk.SignalListItemFactory() 66 | label_column_factory = Gtk.SignalListItemFactory() 67 | 68 | headerbars = headerbar.HeaderBars() 69 | 70 | max_shortcut_width = 0 71 | 72 | def __init__(self, cli_args: dict) -> None: 73 | super().__init__() 74 | 75 | self.cli_args = cli_args 76 | self.skip_search_changed: bool = False 77 | self.search_text: str = "" 78 | 79 | GLib.idle_add(self._finish_initialization) 80 | 81 | def _finish_initialization(self) -> None: 82 | self.config = config.load() 83 | self.sheets = sheets.load_sheets() 84 | self.wm_class, self.window_title = self.init_last_active_window_info() 85 | 86 | self.css_provider = css.new_provider( 87 | display=self.get_display(), css_file=RESOURCE_PATH / "style.css" 88 | ) 89 | self.zoom_css_provider = css.new_provider(display=self.get_display()) 90 | 91 | self.set_titlebar(self.headerbars.normal) 92 | self.container.prepend(self.headerbars.fullscreen) 93 | 94 | self.bindings_filter = Gtk.CustomFilter.new(match_func=self.bindings_match_func) 95 | self.sheet_container_box.set_filter_func(filter_func=self.sections_filter_func) 96 | self.sheet_container_box.set_sort_func(sort_func=self.sections_sort_func) 97 | 98 | self.shortcut_column_factory.connect("bind", self.bind_shortcuts_callback) 99 | self.label_column_factory.connect("bind", self.bind_labels_callback) 100 | 101 | self.init_sheet_dropdown() 102 | self.init_action_sheet() 103 | self.init_action_fullscreen() 104 | self.init_action_sort_by() 105 | self.init_action_zoom() 106 | self.init_action_orientation() 107 | self.init_action_fallback_sheet() 108 | self.init_actions_for_menu_entries() 109 | self.init_actions_for_toasts() 110 | self.init_actions_for_banners() 111 | self.init_search_entry() 112 | self.init_key_event_controllers() 113 | 114 | self.focus_search_entry() 115 | 116 | def init_last_active_window_info(self) -> tuple[str, str]: 117 | """Get class and title of active window. 118 | 119 | Identify the OS and display server and pick the method accordingly. 120 | 121 | Returns: 122 | Tuple[str, str]: wm_class, window title 123 | """ 124 | wm_class = wm_title = "" 125 | 126 | on_wayland = context.is_using_wayland() 127 | desktop_environment = context.get_desktop_environment().lower() 128 | 129 | match (on_wayland, desktop_environment): 130 | case True, "gnome": 131 | try: 132 | wm_class, wm_title = context.get_active_window_via_window_calls() 133 | except Exception: 134 | self.banner_window_calls.set_reveal_child(True) 135 | logger.exception( 136 | "Window Calls extension not installed or " 137 | "not working as expected." 138 | ) 139 | case True, "kde": 140 | wm_class, wm_title = context.get_active_window_via_kwin() 141 | case False, _: 142 | if context.has_xprop(): 143 | wm_class, wm_title = context.get_active_window_via_xprop() 144 | else: 145 | self.banner_xprop.set_reveal_child(True) 146 | logger.error("xprop not found!") 147 | 148 | logger.debug("Detected wm_class: '%s'.", wm_class) 149 | logger.debug("Detected window_title: '%s'.", wm_title) 150 | 151 | if "" in [wm_class, wm_title]: 152 | logger.warning("Couldn't detect active window!") 153 | 154 | return wm_class, wm_title 155 | 156 | def init_action_sort_by(self) -> None: 157 | action = Gio.SimpleAction.new_stateful( 158 | name="sort_by", 159 | state=GLib.Variant("s", ""), 160 | parameter_type=GLib.VariantType.new("s"), 161 | ) 162 | action.connect("activate", self.on_change_sort) 163 | action.connect("change-state", self.on_change_sort) 164 | self.add_action(action) 165 | 166 | # Connect to button happens via headerbar.ui 167 | 168 | self.change_action_state( 169 | "sort_by", GLib.Variant("s", self.config["main"].get("sort_by", "size")) 170 | ) 171 | 172 | def init_action_zoom(self) -> None: 173 | action = Gio.SimpleAction.new_stateful( 174 | name="zoom", 175 | state=GLib.Variant("i", 0), 176 | parameter_type=GLib.VariantType.new("i"), 177 | ) 178 | action.connect("change-state", self.on_change_zoom) 179 | self.add_action(action) 180 | 181 | for bar in self.headerbars: 182 | bar.zoom_scale.connect( 183 | "value-changed", 184 | lambda btn: self.change_action_state( 185 | "zoom", GLib.Variant("i", btn.get_value()) 186 | ), 187 | ) 188 | slider_range = bar.zoom_scale.get_adjustment() 189 | lower_bound = int(slider_range.get_lower()) 190 | upper_bound = int(slider_range.get_upper()) 191 | for i in range(lower_bound, upper_bound + 1, 25): 192 | bar.zoom_scale.add_mark( 193 | i, Gtk.PositionType.BOTTOM, f"{i}" 194 | ) 195 | 196 | self.change_action_state( 197 | "zoom", GLib.Variant("i", self.config["main"].getint("zoom", 100)) 198 | ) 199 | 200 | def init_action_fullscreen(self) -> None: 201 | action = Gio.SimpleAction.new_stateful( 202 | name="fullscreen", 203 | state=GLib.Variant("b", False), 204 | parameter_type=None, 205 | ) 206 | action.connect("activate", self.on_change_fullscreen) 207 | action.connect("change-state", self.on_change_fullscreen) 208 | self.add_action(action) 209 | 210 | # Connect to button happens via headerbar.ui 211 | 212 | self.connect("notify::fullscreened", self.on_fullscreen_state_changed) 213 | 214 | self.change_action_state( 215 | "fullscreen", 216 | GLib.Variant("b", self.config["main"].getboolean("fullscreen", False)), 217 | ) 218 | 219 | def init_action_orientation(self) -> None: 220 | action = Gio.SimpleAction.new_stateful( 221 | name="orientation", 222 | state=GLib.Variant("s", ""), 223 | parameter_type=GLib.VariantType.new("s"), 224 | ) 225 | action.connect("activate", self.on_change_orientation) 226 | action.connect("change-state", self.on_change_orientation) 227 | self.add_action(action) 228 | 229 | # Connect to button happens via headerbar.ui 230 | 231 | self.change_action_state( 232 | "orientation", 233 | GLib.Variant("s", self.config["main"].get("orientation", "vertical")), 234 | ) 235 | 236 | def init_action_fallback_sheet(self) -> None: 237 | action = Gio.SimpleAction.new_stateful( 238 | name="fallback_sheet", 239 | state=GLib.Variant("s", ""), 240 | parameter_type=GLib.VariantType.new("s"), 241 | ) 242 | action.connect("change-state", self.on_set_fallback_sheet) 243 | self.add_action(action) 244 | 245 | for bar in self.headerbars: 246 | bar.fallback_sheet_button.connect( 247 | "clicked", 248 | lambda *args: self.change_action_state( 249 | "fallback_sheet", 250 | GLib.Variant("s", self.get_current_sheet_id() or "keyhint"), 251 | ), 252 | ) 253 | 254 | self.change_action_state( 255 | "fallback_sheet", 256 | GLib.Variant( 257 | "s", self.config["main"].get("fallback_cheatsheet", "keyhint") 258 | ), 259 | ) 260 | 261 | def init_action_sheet(self) -> None: 262 | action = Gio.SimpleAction.new_stateful( 263 | name="sheet", 264 | state=GLib.Variant("s", ""), 265 | parameter_type=GLib.VariantType.new("s"), 266 | ) 267 | action.connect("change-state", self.on_change_sheet) 268 | self.add_action(action) 269 | 270 | for bar in self.headerbars: 271 | bar.sheet_dropdown.connect( 272 | "notify::selected-item", 273 | lambda btn, param: self.change_action_state( 274 | "sheet", GLib.Variant("s", btn.get_selected_item().get_string()) 275 | ), 276 | ) 277 | 278 | self.change_action_state( 279 | "sheet", GLib.Variant("s", self.get_appropriate_sheet_id()) 280 | ) 281 | 282 | def init_search_entry(self) -> None: 283 | for bar in self.headerbars: 284 | self.search_changed_handler = bar.search_entry.connect( 285 | "search-changed", self.on_search_entry_changed 286 | ) 287 | 288 | def init_actions_for_menu_entries(self) -> None: 289 | action = Gio.SimpleAction.new("about", None) 290 | action.connect("activate", self.on_about_action) 291 | self.add_action(action) 292 | 293 | action = Gio.SimpleAction.new("debug_info", None) 294 | action.connect("activate", self.on_debug_action) 295 | self.add_action(action) 296 | 297 | action = Gio.SimpleAction.new("open_folder", None) 298 | action.connect("activate", self.on_open_folder_action) 299 | self.add_action(action) 300 | 301 | def init_actions_for_toasts(self) -> None: 302 | """Register actions which are triggered from toast notifications.""" 303 | action = Gio.SimpleAction.new("create_new_sheet", None) 304 | action.connect("activate", self.on_create_new_sheet) 305 | self.add_action(action) 306 | 307 | def init_actions_for_banners(self) -> None: 308 | """Register actions which are triggered from banners.""" 309 | action = Gio.SimpleAction.new("visit_window_calls", None) 310 | action.connect( 311 | "activate", 312 | lambda *args: Gio.AppInfo.launch_default_for_uri( 313 | "https://extensions.gnome.org/extension/4724/window-calls/" 314 | ), 315 | ) 316 | self.add_action(action) 317 | 318 | def init_key_event_controllers(self) -> None: 319 | """Register key press handlers.""" 320 | evk = Gtk.EventControllerKey() 321 | evk.connect("key-pressed", self.on_key_pressed) 322 | self.add_controller(evk) 323 | 324 | evk = Gtk.EventControllerKey() 325 | evk.connect("key-pressed", self.on_search_entry_key_pressed) 326 | self.headerbars.normal.search_entry.add_controller(evk) 327 | 328 | # NOTE: Reusing the same evk would lead to critical assertion error! 329 | evk = Gtk.EventControllerKey() 330 | evk.connect("key-pressed", self.on_search_entry_key_pressed) 331 | self.headerbars.fullscreen.search_entry.add_controller(evk) 332 | 333 | def init_sheet_dropdown(self) -> None: 334 | """Populate sheet dropdown with available sheet IDs.""" 335 | # Use the model from normal dropdown also for the fullscreen dropdown 336 | model = self.headerbars.normal.sheet_dropdown.get_model() 337 | self.headerbars.fullscreen.sheet_dropdown.set_model(model) 338 | 339 | # Satisfy type checker 340 | if not isinstance(model, Gtk.StringList): 341 | raise TypeError("Sheet dropdown model is not a Gtk.StringList.") 342 | 343 | for sheet_id in sorted([s["id"] for s in self.sheets]): 344 | model.append(sheet_id) 345 | 346 | @property 347 | def active_headerbar(self) -> headerbar.HeaderBarBox: 348 | """Return the currently active headerbar depending on window state.""" 349 | return ( 350 | self.headerbars.fullscreen 351 | if self.is_fullscreen() 352 | else self.headerbars.normal 353 | ) 354 | 355 | @check_state 356 | def on_set_fallback_sheet( 357 | self, action: Gio.SimpleAction, state: GLib.Variant 358 | ) -> None: 359 | """Set the sheet to use in case no matching sheet is found.""" 360 | sheet_id = state.get_string() 361 | self.config.set_persistent("main", "fallback_cheatsheet", sheet_id) 362 | for bar in self.headerbars: 363 | bar.fallback_sheet_entry.set_text(sheet_id) 364 | 365 | @check_state 366 | def on_change_sheet(self, action: Gio.SimpleAction, state: GLib.Variant) -> None: 367 | """Get selected sheet and render it in the UI.""" 368 | dropdown_model = self.headerbars.normal.sheet_dropdown.get_model() 369 | 370 | # Satisfy type checker 371 | if not dropdown_model: 372 | raise TypeError("Sheet dropdown model is not a Gtk.StringList.") 373 | 374 | dropdown_strings = [ 375 | s.get_string() for s in dropdown_model if isinstance(s, Gtk.StringObject) 376 | ] 377 | 378 | sheet_id = state.get_string() 379 | select_idx = dropdown_strings.index(sheet_id) 380 | 381 | for bar in self.headerbars: 382 | bar.sheet_dropdown.set_selected(select_idx) 383 | 384 | self.show_sheet(sheet_id=sheet_id) 385 | 386 | @check_state 387 | def on_change_zoom(self, action: Gio.SimpleAction, state: GLib.Variant) -> None: 388 | """Set the zoom level of the sheet container via css.""" 389 | value = state.get_int32() 390 | 391 | for bar in self.headerbars: 392 | bar.zoom_scale.set_value(value) 393 | 394 | css_style = f""" 395 | .sheet_container_box, 396 | .sheet_container_box .bindings-section header label {{ 397 | font-size: {value}%; 398 | }} 399 | """ 400 | 401 | self.zoom_css_provider.load_from_string(css_style) 402 | self.config.set_persistent("main", "zoom", str(int(value))) 403 | 404 | @check_state 405 | def on_change_orientation( 406 | self, action: Gio.SimpleAction, state: GLib.Variant 407 | ) -> None: 408 | """Set the orientation or scroll direction of the sheet container.""" 409 | # The used GTK orientation is the opposite of the config value, which follows 410 | # the naming from user perspective! 411 | value = state.get_string() 412 | gtk_orientation = ( 413 | Gtk.Orientation.VERTICAL 414 | if value == "horizontal" 415 | else Gtk.Orientation.HORIZONTAL 416 | ) 417 | 418 | self.sheet_container_box.set_orientation(gtk_orientation) 419 | self.config.set_persistent("main", "orientation", value) 420 | 421 | @check_state 422 | def on_change_sort(self, action: Gio.SimpleAction, state: GLib.Variant) -> None: 423 | """Set the order of the sections.""" 424 | self.config.set_persistent("main", "sort_by", state.get_string()) 425 | self.sheet_container_box.invalidate_sort() 426 | 427 | @check_state 428 | def on_change_fullscreen( 429 | self, action: Gio.SimpleAction, state: GLib.Variant 430 | ) -> None: 431 | """Set the fullscreen state.""" 432 | if state is not None: 433 | to_fullscreen = bool(state) 434 | else: 435 | # If state is not provided, just toggle the action's state 436 | to_fullscreen = not bool(action.get_state()) 437 | action.set_state(GLib.Variant("b", to_fullscreen)) 438 | 439 | # Set flag to temporarily ignore search entry changes (to avoid recursion) 440 | self.skip_search_changed = True 441 | 442 | for bar in self.headerbars: 443 | bar.search_entry.set_text(self.search_text) 444 | 445 | if to_fullscreen: 446 | self.fullscreen() 447 | else: 448 | self.unfullscreen() 449 | 450 | self.config.set_persistent("main", "fullscreen", to_fullscreen) 451 | 452 | def scroll(self, to_start: bool, by_page: bool) -> None: 453 | """Scroll the sheet container by a certain distance.""" 454 | if self.sheet_container_box.get_orientation() == 1: 455 | adj = self.scrolled_window.get_hadjustment() 456 | else: 457 | adj = self.scrolled_window.get_vadjustment() 458 | 459 | default_distance = 25 460 | distance = adj.get_page_size() if by_page else default_distance 461 | if to_start: 462 | distance *= -1 463 | 464 | adj.set_value(adj.get_value() + distance) 465 | 466 | def cycle_sheets(self, direction: Literal["next", "previous"]) -> None: 467 | dropdown = self.headerbars.normal.sheet_dropdown 468 | dropdown_model = dropdown.get_model() 469 | 470 | # Satisfy type checker 471 | if not dropdown_model: 472 | raise TypeError("Sheet dropdown model is not a Gtk.StringList.") 473 | 474 | relative_change = 1 if direction == "next" else -1 475 | 476 | new_position = dropdown.get_selected() + relative_change 477 | new_position = max(0, new_position) 478 | new_position = min(new_position, dropdown_model.get_n_items()) 479 | dropdown.set_selected(position=new_position) 480 | 481 | def focus_search_entry(self) -> None: 482 | """Focus search entry of the active headerbar.""" 483 | self.active_headerbar.search_entry.grab_focus() 484 | self.active_headerbar.search_entry.set_position(-1) 485 | 486 | def show_create_new_sheet_toast(self) -> None: 487 | """Display a toast notification to offer the creation of a new cheatsheet.""" 488 | toast = Adw.Toast.new(f"No cheatsheet found for '{self.wm_class}'.") 489 | toast.set_button_label("Create new") 490 | toast.set_action_name("win.create_new_sheet") 491 | toast.set_timeout(5) 492 | self.overlay.add_toast(toast) 493 | 494 | def on_fullscreen_state_changed(self, _: Gtk.Widget, __: GObject.Parameter) -> None: 495 | """Toggle fullscreen header bar according to current window state.""" 496 | self.headerbars.fullscreen.set_visible(self.is_fullscreen()) 497 | self.focus_search_entry() 498 | 499 | def on_search_entry_changed(self, search_entry: Gtk.SearchEntry) -> None: 500 | """Execute on change of the sheet selection dropdown.""" 501 | if self.skip_search_changed: 502 | self.skip_search_changed = False 503 | return 504 | 505 | if search_entry.get_text() == self.search_text: 506 | return 507 | 508 | self.search_text = search_entry.get_text() 509 | self.bindings_filter.changed(Gtk.FilterChange.DIFFERENT) 510 | self.sheet_container_box.invalidate_filter() 511 | 512 | def on_search_entry_key_pressed( 513 | self, 514 | evk: Gtk.EventControllerKey, 515 | keycode: int, 516 | keyval: int, 517 | modifier: Gdk.ModifierType, 518 | ) -> None: 519 | """Handle key press events in the search entry field. 520 | 521 | Note: The search itself is triggered by the 'change' event, not by 'key-pressed' 522 | """ 523 | if keycode == Gdk.KEY_Escape: 524 | self.close() 525 | 526 | def on_key_pressed( # noqa: C901 527 | self, 528 | evk: Gtk.EventControllerKey, 529 | keycode: int, 530 | keyval: int, 531 | modifier: Gdk.ModifierType, 532 | ) -> None: 533 | """Handle key press events in the main window.""" 534 | ctrl_pressed = modifier == Gdk.ModifierType.CONTROL_MASK 535 | match keycode, ctrl_pressed: 536 | case Gdk.KEY_Escape, _: 537 | self.close() 538 | 539 | case Gdk.KEY_F11, _: 540 | self.activate_action("win.fullscreen") 541 | 542 | case Gdk.KEY_f, True: 543 | self.active_headerbar.grab_focus() 544 | case Gdk.KEY_s, True: 545 | self.active_headerbar.sheet_dropdown.grab_focus() 546 | 547 | case (Gdk.KEY_Up, False) | (Gdk.KEY_k, True): 548 | self.scroll(to_start=True, by_page=False) 549 | case (Gdk.KEY_Down, False) | (Gdk.KEY_j, True): 550 | self.scroll(to_start=False, by_page=False) 551 | case Gdk.KEY_Page_Up, False: 552 | self.scroll(to_start=True, by_page=True) 553 | case Gdk.KEY_Page_Down, False: 554 | self.scroll(to_start=False, by_page=True) 555 | 556 | case (Gdk.KEY_Up, True): 557 | self.cycle_sheets(direction="previous") 558 | case (Gdk.KEY_Down, True): 559 | self.cycle_sheets(direction="next") 560 | 561 | def on_about_action(self, _: Gio.SimpleAction, __: None) -> None: 562 | """Show modal 'About' dialog.""" 563 | logo = Gtk.Image.new_from_file(f"{RESOURCE_PATH}/keyhint_icon.svg") 564 | Gtk.AboutDialog( 565 | program_name="KeyHint", 566 | comments="Cheatsheet for keyboard shortcuts & commands", 567 | version=__version__, 568 | website_label="Github", 569 | website="https://github.com/dynobo/keyhint", 570 | logo=logo.get_paintable(), 571 | license_type=Gtk.License.MIT_X11, 572 | modal=True, 573 | resizable=True, 574 | transient_for=self, 575 | ).show() 576 | 577 | def on_debug_action(self, _: Gio.SimpleAction, __: None) -> None: 578 | """Show modal dialog with information useful for error reporting.""" 579 | label = Gtk.Label( 580 | label=self.get_debug_info_text(), 581 | wrap=True, 582 | selectable=True, 583 | margin_start=24, 584 | margin_end=24, 585 | use_markup=True, 586 | ) 587 | 588 | def _on_copy_clicked(button: Gtk.Button) -> None: 589 | if display := Gdk.Display.get_default(): 590 | clipboard = display.get_clipboard() 591 | clipboard.set(f"### Debug Info\n\n```\n{label.get_text().strip()}\n```") 592 | button.set_icon_name("object-select-symbolic") 593 | button.set_tooltip_text("Copied!") 594 | 595 | copy_button = Gtk.Button( 596 | icon_name="edit-copy", 597 | tooltip_text="Copy to clipboard", 598 | ) 599 | copy_button.connect("clicked", _on_copy_clicked) 600 | 601 | dialog = Gtk.Dialog(title="Debug Info", transient_for=self, modal=True) 602 | dialog.get_content_area().append(label) 603 | dialog.add_action_widget(copy_button, Gtk.ResponseType.NONE) 604 | dialog.show() 605 | 606 | def on_create_new_sheet(self, _: Gio.SimpleAction, __: None) -> None: 607 | """Create a new text file with a template for a new cheatsheet.""" 608 | title = self.wm_class.lower().replace(" ", "") 609 | template = ( 610 | f'id = "{title:<26}" # Unique ID, used e.g. in cheatsheet dropdown\n' 611 | 'url = "" # (Optional) URL to keybinding docs\n' 612 | "\n" 613 | "[match]\n" 614 | f'regex_wmclass = "{self.wm_class}"\n' 615 | 'regex_title = ".*" # (Optional) Narrow down by window title' 616 | "\n" 617 | "\n" 618 | "[section]\n" 619 | "\n" 620 | '[section."My Section Title"] # Add as many sections you like ...\n' 621 | '"Ctrl + c" = "Copy to clipboard" # ... with keybinding + description\n' 622 | '"Ctrl + v" = "Paste from clipboard"\n' 623 | ) 624 | 625 | new_file = config.CONFIG_PATH / f"{title}.toml" 626 | 627 | # Make sure the file name is unique 628 | idx = 1 629 | while new_file.exists(): 630 | new_file = new_file.with_name(f"{title}_{idx}.toml") 631 | idx += 1 632 | 633 | new_file.write_text(template) 634 | subprocess.Popen(["xdg-open", str(new_file.resolve())]) # noqa: S603, S607 635 | 636 | def on_open_folder_action(self, _: Gio.SimpleAction, __: None) -> None: 637 | """Open config folder in default file manager.""" 638 | subprocess.Popen(["xdg-open", str(config.CONFIG_PATH.resolve())]) # noqa: S603, S607 639 | 640 | def sections_filter_func(self, child: Gtk.FlowBoxChild) -> bool: 641 | """Filter binding sections based on the search entry text.""" 642 | # If no text, show all sections 643 | if not self.search_text: 644 | return True 645 | 646 | # If text, show only sections with 1 or more visible bindings 647 | column_view = child.get_child() 648 | if not isinstance(column_view, Gtk.ColumnView): 649 | raise TypeError("Child is not a ColumnView.") 650 | 651 | selection = column_view.get_model() 652 | if not isinstance(selection, Gtk.NoSelection): 653 | raise TypeError("ColumnView model is not a NoSelection.") 654 | 655 | filter_model = selection.get_model() 656 | if not isinstance(filter_model, Gtk.FilterListModel): 657 | raise TypeError("ColumnView model is not a FilterListModel.") 658 | 659 | return filter_model.get_n_items() > 0 660 | 661 | def sections_sort_func( 662 | self, child_a: Gtk.FlowBoxChild, child_b: Gtk.FlowBoxChild 663 | ) -> bool: 664 | """Sort function for the sections of the cheatsheet.""" 665 | sub_child_a = child_a.get_child() 666 | sub_child_b = child_b.get_child() 667 | 668 | if not isinstance(sub_child_a, Gtk.ColumnView) or not isinstance( 669 | sub_child_b, Gtk.ColumnView 670 | ): 671 | raise TypeError("Child is not a ColumnView.") 672 | 673 | sort_by = self.config["main"].get("sort_by", "size") 674 | 675 | if sort_by == "native": 676 | # The names use the format 'section-{INDEX}', so just sort by that 677 | return sub_child_a.get_name() > sub_child_b.get_name() 678 | 679 | if sort_by == "size": 680 | # Sorts by number of bindings in the section 681 | model_a = sub_child_a.get_model() 682 | model_b = sub_child_b.get_model() 683 | if not isinstance(model_a, Gtk.NoSelection) or not isinstance( 684 | model_b, Gtk.NoSelection 685 | ): 686 | raise TypeError("ColumnView model is not a NoSelection.") 687 | return model_a.get_n_items() < model_b.get_n_items() 688 | 689 | if sort_by == "title": 690 | column_a = sub_child_a.get_columns().get_item(1) 691 | column_b = sub_child_b.get_columns().get_item(1) 692 | if not isinstance(column_a, Gtk.ColumnViewColumn) or not isinstance( 693 | column_b, Gtk.ColumnViewColumn 694 | ): 695 | raise TypeError("Column is not a ColumnViewColumn.") 696 | return (column_a.get_title() or "") > (column_b.get_title() or "") 697 | 698 | raise ValueError(f"Invalid sort_by value: {sort_by}") 699 | 700 | def get_appropriate_sheet_id(self) -> str: 701 | """Determine the sheet ID based on context or configuration.""" 702 | sheet_id = None 703 | 704 | # If sheet-id was provided via cli option, use that one 705 | if sheet_id := self.cli_args.get("cheatsheet", None): 706 | logger.debug("Using provided sheet-id: %s.", sheet_id) 707 | return sheet_id 708 | 709 | # Else try to find cheatsheet for active window 710 | if sheet_id := sheets.get_sheet_id_by_active_window( 711 | sheets=self.sheets, wm_class=self.wm_class, window_title=self.window_title 712 | ): 713 | logger.debug("Found matching sheet: %s.", sheet_id) 714 | return sheet_id 715 | 716 | # If no sheet found, show toast to create new one... 717 | self.show_create_new_sheet_toast() 718 | 719 | # ...and use the configured fallback sheet 720 | if sheet_id := self.config["main"].get("fallback_cheatsheet", ""): 721 | logger.debug("Using provided fallback sheet-id: %s.", sheet_id) 722 | return sheet_id 723 | 724 | # If that fallback sheet also does not exist, just use the first in dropdown 725 | model = self.headerbars.normal.sheet_dropdown.get_model() 726 | item = model.get_item(0) if model else None 727 | sheet_id = ( 728 | item.get_string() if isinstance(item, Gtk.StringObject) else "keyhint" 729 | ) 730 | logger.debug("No matching or fallback sheet found. Using first sheet.") 731 | return sheet_id 732 | 733 | def bind_shortcuts_callback( 734 | self, _: Gtk.SignalListItemFactory, item: Gtk.ColumnViewCell 735 | ) -> None: 736 | row = cast(binding.Row, item.get_item()) 737 | shortcut = binding.create_shortcut(row.shortcut) 738 | self.max_shortcut_width = max( 739 | self.max_shortcut_width, 740 | shortcut.get_preferred_size().natural_size.width, # type: ignore # False Positive 741 | ) 742 | item.set_child(shortcut) 743 | 744 | def bind_labels_callback( 745 | self, _: Gtk.SignalListItemFactory, item: Gtk.ColumnViewCell 746 | ) -> None: 747 | row = cast(binding.Row, item.get_item()) 748 | if row.shortcut: 749 | child = Gtk.Label(label=row.label, xalign=0.0) 750 | else: 751 | # Section title 752 | child = Gtk.Label(label=f"{row.label}", xalign=0.0) 753 | item.set_child(child) 754 | 755 | def bindings_match_func(self, bindings_row: binding.Row) -> bool: 756 | if self.search_text: 757 | return self.search_text.lower() in bindings_row.filter_text.lower() 758 | return True 759 | 760 | def show_sheet(self, sheet_id: str) -> None: 761 | """Clear sheet container and populate it with the selected sheet.""" 762 | self.sheet_container_box.remove_all() 763 | self.max_shortcut_width = 0 764 | 765 | sheet = sheets.get_sheet_by_id(sheets=self.sheets, sheet_id=sheet_id) 766 | sections = sheet["section"] 767 | for index, (section, bindings) in enumerate(sections.items()): 768 | section_child = self.create_section(section, bindings) 769 | section_child.set_name(f"section-{index:03}") 770 | self.sheet_container_box.append(section_child) 771 | 772 | def create_section(self, section: str, bindings: dict[str, str]) -> Gtk.ColumnView: 773 | ls = Gio.ListStore() 774 | for shortcut, label in bindings.items(): 775 | ls.append(binding.Row(shortcut=shortcut, label=label, section=section)) 776 | 777 | filter_list = Gtk.FilterListModel(model=ls, filter=self.bindings_filter) 778 | selection = Gtk.NoSelection.new(filter_list) 779 | 780 | # The fixed width is needed to have unified width of the shortcut columns in 781 | # all sections. 782 | # TODO: Dynamically calculate fixed width based on widest shortcut 783 | shortcut_column_width = self.config["main"].getint("zoom", 100) * 1.1 + 135 784 | 785 | shortcut_column = binding.create_column_view_column( 786 | title="", 787 | factory=self.shortcut_column_factory, 788 | fixed_width=shortcut_column_width, 789 | ) 790 | label_column = binding.create_column_view_column( 791 | title=section, 792 | factory=self.label_column_factory, 793 | ) 794 | return binding.create_column_view(selection, shortcut_column, label_column) 795 | 796 | def get_current_sheet_id(self) -> str: 797 | action = self.lookup_action("sheet") 798 | state = action.get_state() if action else None 799 | return state.get_string() if state else "" 800 | 801 | def get_debug_info_text(self) -> str: 802 | """Compile information which is useful for error analysis.""" 803 | sheet_id = self.get_current_sheet_id() 804 | sheet = ( 805 | sheets.get_sheet_by_id(sheets=self.sheets, sheet_id=sheet_id) 806 | if sheet_id 807 | else {} 808 | ) 809 | regex_wm_class = sheet.get("match", {}).get("regex_wmclass", "n/a") 810 | regex_title = sheet.get("match", {}).get("regex_title", "n/a") 811 | url = sheet.get("url", "") 812 | link = f"{url or 'n/a'}" 813 | desktop_env = context.get_desktop_environment() 814 | if desktop_env.lower() == "gnome": 815 | desktop_env += " " + context.get_gnome_version() 816 | elif desktop_env.lower() == "kde": 817 | desktop_env += " " + context.get_kde_version() 818 | 819 | return ( 820 | "\n" 821 | "Last Active Application\n" 822 | "\n" 823 | f"title: {self.window_title}\n" 824 | f"wmclass: {self.wm_class}\n" 825 | "\n" 826 | "Selected Cheatsheet\n" 827 | "\n" 828 | f"ID: {sheet_id}\n" 829 | f"regex_wmclass: {regex_wm_class}\n" 830 | f"regex_title: {regex_title}\n" 831 | f"source: {link}\n" 832 | "\n" 833 | "System Information\n" 834 | "\n" 835 | f"Platform: {platform.platform()}\n" 836 | f"Desktop Environment: {desktop_env}\n" 837 | f"Wayland: {context.is_using_wayland()}\n" 838 | f"Python: {platform.python_version()}\n" 839 | f"Keyhint: v{__version__}\n" 840 | ) 841 | --------------------------------------------------------------------------------