├── .github └── workflows │ └── main.yml ├── .gitignore ├── COPYING ├── README.md ├── build-aux └── meson │ └── postinstall.py ├── com.github.hezral.quickword.yml ├── data ├── application.css ├── com.github.hezral.quickword.appdata.xml.in ├── com.github.hezral.quickword.desktop.in ├── com.github.hezral.quickword.gschema.xml ├── demo.gif ├── icons │ ├── 128.svg │ ├── 16-symbolic.svg │ ├── 16.svg │ ├── 24.svg │ ├── 32.svg │ ├── 48.svg │ ├── 64.svg │ ├── 96.svg │ ├── com.github.hezral.quickword-coffee.svg │ ├── com.github.hezral.quickword-left.svg │ ├── com.github.hezral.quickword-right.svg │ ├── com.github.hezral.quickword-symbolic.svg │ ├── com.github.hezral.quickword.svg │ └── prep_icons.py ├── logo_type.png ├── meson.build ├── screenshot-01.png ├── screenshot-02.png └── screenshot-03.png ├── debian ├── changelog ├── compat ├── control ├── copyright ├── rules └── source │ └── format ├── meson.build ├── po ├── LINGUAS ├── POTFILES └── meson.build └── src ├── __init__.py ├── active_window_manager.py ├── clipboard_manager.py ├── data_manager.py ├── main.py ├── main_window.py ├── meson.build ├── noword_view.py ├── quickword.in ├── settings_view.py ├── updater_view.py ├── utils.py ├── word_lookup.py └── word_view.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # This workflow will run for any pull request or pushed commit 4 | on: [push, pull_request] 5 | 6 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 7 | jobs: 8 | # This workflow contains a single job called "flatpak" 9 | flatpak: 10 | # The type of runner that the job will run on 11 | runs-on: ubuntu-latest 12 | 13 | # This job runs in a special container designed for building Flatpaks for AppCenter 14 | container: 15 | image: ghcr.io/elementary/flatpak-platform/runtime:6 16 | options: --privileged 17 | 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so the job can access it 21 | - uses: actions/checkout@v2 22 | 23 | # Builds your flatpak manifest using the Flatpak Builder action 24 | - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v4 25 | with: 26 | # This is the name of the Bundle file we're building and can be anything you like 27 | bundle: quickword.flatpak 28 | # This uses your app's RDNN ID 29 | manifest-path: com.github.hezral.quickword.yml 30 | 31 | # You can automatically run any of the tests you've created as part of this workflow 32 | run-tests: false 33 | 34 | # These lines specify the location of the elementary Runtime and Sdk 35 | repository-name: appcenter 36 | repository-url: https://flatpak.elementary.io/repo.flatpakrepo 37 | cache-key: "flatpak-builder-${{ github.sha }}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | MANIFEST 24 | 25 | # Environments 26 | .env 27 | .venv 28 | env/ 29 | venv/ 30 | ENV/ 31 | env.bak/ 32 | venv.bak/ 33 | 34 | # Flatpak 35 | .flatpak/ 36 | .flatpak-builder/ 37 | build-dir/ 38 | 39 | # VSCode 40 | .vscode/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![icon](data/icons/com.github.hezral.quickword.svg) 4 | 5 | # ![logo_type](data/logo_type.png?raw=true) 6 |
7 | 8 | If you like what i make, it would really be nice to have someone buy me a coffee 9 | 10 | Buy Me A Coffee 11 | 12 | ### Quickly lookup words on the fly and offline 13 | * On the fly word lookup 14 | * Works offline (first run will require internet to download dictionary data) 15 | * Copy definitions and examples to clipboard with just a click 16 | * Word definitions 17 | * Examples of word sentences 18 | * Synonyms 19 | * Explore synonyms from each word 20 | 21 | | ![Screenshot](data/screenshot-01.png?raw=true) | ![Screenshot](data/screenshot-02.png?raw=true) | ![Screenshot](data/screenshot-03.png?raw=true) | 22 | |------------------------------------------|-----------------------------------------|-----------------------------------------| 23 | 24 | ## Demo and How to use 25 | QuickWord can be used by manual lookup and via a keyboard shortcut to open it on the fly. 26 | 27 | Select a word and hit the a keyboard shortcut to get the lookup. Recommended shortcut: ⌘ + D 28 | * Manual entry of words 29 | * Selecting a text 30 | 31 | ![](data/demo.gif) 32 | 33 | # Installation 34 | QuickWord is availble for installation in the following Linux Distributions 35 | 36 | Packaging status 37 | 38 | 39 | 40 | ## Build using flatpak 41 | * requires that you have flatpak-builder installed 42 | * flatpak enabled 43 | * flathub remote enabled 44 | 45 | ``` 46 | flatpak-builder --user --force-clean --install build-dir com.github.hezral.quickword.yml 47 | ``` 48 | 49 | ### Build using meson 50 | Ensure you have these dependencies installed 51 | 52 | * python3 53 | * python3-gi 54 | * libgranite-dev 55 | * python-xlib 56 | * xclip 57 | * pynput 58 | 59 | Download the updated source [here](https://github.com/hezral/quickword/archive/master.zip), or use git: 60 | ```bash 61 | git clone https://github.com/hezral/quickword.git 62 | cd clips 63 | meson build --prefix=/usr 64 | cd build 65 | ninja build 66 | sudo ninja install 67 | ``` 68 | The desktop launcher should show up on the application launcher for your desktop environment 69 | if it doesn't, try running 70 | ``` 71 | com.github.hezral.quickword 72 | ``` 73 | 74 | ## Thanks/Credits 75 | - [Wordnet](http://wordnetweb.princeton.edu/perl/webwn) Definitions are based on Wordnet. 76 | - [Ideogram](https://appcenter.elementary.io/com.github.cassidyjames.ideogram/) Inspired by it. Also borrowed/forked some code. 77 | - [ElementaryPython](https://github.com/mirkobrombin/ElementaryPython) This started me off on coding with Python and GTK. 78 | -------------------------------------------------------------------------------- /build-aux/meson/postinstall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import environ, path 4 | from subprocess import call 5 | 6 | prefix = environ.get('MESON_INSTALL_PREFIX', '/usr/local') 7 | datadir = path.join(prefix, 'share') 8 | destdir = environ.get('DESTDIR', '') 9 | 10 | # Package managers set this so we don't need to run 11 | if not destdir: 12 | print('Updating icon cache...') 13 | call(['gtk-update-icon-cache', '-qtf', path.join(datadir, 'icons', 'hicolor')]) 14 | 15 | print('Updating desktop database...') 16 | call(['update-desktop-database', '-q', path.join(datadir, 'applications')]) 17 | 18 | print('Compiling GSettings schemas...') 19 | call(['glib-compile-schemas', path.join(datadir, 'glib-2.0', 'schemas')]) 20 | 21 | 22 | -------------------------------------------------------------------------------- /com.github.hezral.quickword.yml: -------------------------------------------------------------------------------- 1 | app-id: com.github.hezral.quickword 2 | runtime: io.elementary.Platform 3 | runtime-version: '6' 4 | sdk: io.elementary.Sdk 5 | command: com.github.hezral.quickword 6 | finish-args: 7 | # for espeak to work 8 | - --socket=pulseaudio 9 | - --share=ipc 10 | - --socket=wayland 11 | - --socket=fallback-x11 12 | - --share=network 13 | 14 | cleanup: 15 | - '/include' 16 | - '/lib/pkgconfig' 17 | - '/lib/cmake' 18 | - '/lib/girepository-1.0' 19 | - '/share/gir-1.0' 20 | - '/share/vala' 21 | - '*.a' 22 | - '*.la' 23 | modules: 24 | - name: python-xlib 25 | buildsystem: simple 26 | build-options: 27 | build-args: 28 | - --share=network 29 | build-commands: 30 | - "pip3 install --prefix=${FLATPAK_DEST} python-xlib" 31 | 32 | - name: ntlk 33 | buildsystem: simple 34 | build-options: 35 | build-args: 36 | - --share=network 37 | build-commands: 38 | - "pip3 install --prefix=${FLATPAK_DEST} nltk" 39 | 40 | - name: pcaudiolib 41 | buildsystem: autotools 42 | sources: 43 | - type: git 44 | url: https://github.com/espeak-ng/pcaudiolib.git 45 | 46 | # this is the version the builds with flatpak 47 | - name: espeak-ng 48 | buildsystem: simple 49 | build-commands: 50 | - "./autogen.sh" 51 | - "CC=clang CFLAGS=-Wextra ./configure --prefix=${FLATPAK_DEST}" 52 | - "make" 53 | - "make install" 54 | sources: 55 | - type: git 56 | url: https://github.com/espeak-ng/espeak-ng.git 57 | tag: "1.50" 58 | commit: "b702b03996de94035fadae0eb5ad9506c5a09f35" 59 | 60 | - name: quickword 61 | buildsystem: meson 62 | sources: 63 | - type: dir 64 | path: . -------------------------------------------------------------------------------- /data/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | # SPDX-FileCopyrightText: 2021 Adi Hezral 4 | */ 5 | 6 | /* -- colors -- */ 7 | /* ------------------------------ */ 8 | @define-color shaded_dark shade(@theme_base_color, 0.95); 9 | @define-color shaded_darker shade(@theme_base_color, 0.85); 10 | @define-color shaded_base shade(@theme_base_color, 0.98); 11 | @define-color popup shade(@theme_base_color, 0.98); 12 | 13 | /* ------------------------------ */ 14 | 15 | 16 | /* -- headerbar -- */ 17 | /* ------------------------------ */ 18 | .lookup-word-header { 19 | font-size: larger; 20 | font-weight: bold; 21 | } 22 | 23 | /* -- stack -- */ 24 | /* ------------------------------ */ 25 | stack { 26 | border-bottom-right-radius: 4px; 27 | border-bottom-left-radius: 4px; 28 | } 29 | 30 | 31 | /* -- settings-view -- */ 32 | /* ------------------------------ */ 33 | label#settings-group-label { 34 | font-weight: bold; 35 | opacity: 0.75; 36 | } 37 | 38 | frame#settings-group-frame { 39 | border-radius: 4px; 40 | border-color: rgba(0, 0, 0, 0.3); 41 | background-color: @shaded_dark; 42 | } 43 | 44 | .settings-sub-label { 45 | font-size: 0.9em; 46 | color: gray; 47 | } 48 | 49 | /* -- quickword icon animation -- */ 50 | /* ------------------------------ */ 51 | .quickword-icon-left { 52 | animation: floating 1.5s ease-in-out infinite; 53 | } 54 | 55 | @keyframes float-down { 56 | 25% {-gtk-icon-transform: translateY(0px);} 57 | 75% {-gtk-icon-transform: translateY(-5px);} 58 | 100% {-gtk-icon-transform: translateX(0px);} 59 | } 60 | 61 | .quickword-icon-right { 62 | animation: floating 1.25s ease-in-out infinite; 63 | } 64 | 65 | @keyframes floating { 66 | 25% {-gtk-icon-transform: translateY(0px);} 67 | 75% {-gtk-icon-transform: translateY(3px);} 68 | 100% {-gtk-icon-transform: translateY(0px);} 69 | } 70 | 71 | @keyframes floating-two { 72 | from { 73 | -gtk-icon-transform: translateY(60px); 74 | } 75 | 25% {-gtk-icon-transform: translateY(60px);} 76 | 75% {-gtk-icon-transform: translateY(65px);} 77 | 100% {-gtk-icon-transform: translateY(60px);} 78 | } 79 | 80 | .download-icon-start { 81 | -gtk-icon-transform: translateY(60px); 82 | transition: -gtk-icon-transform 1.25s; 83 | animation: floating-two 1.25s ease-in-out infinite; 84 | 85 | } 86 | 87 | /* -- word-view -- */ 88 | /* ------------------------------ */ 89 | .word-types-left { 90 | border-top-left-radius: 0px; 91 | border-bottom-left-radius: 6px; 92 | border-right-color: rgba(0,0,0,0.05); 93 | } 94 | 95 | .word-types-right { 96 | border-top-right-radius: 0px; 97 | border-bottom-right-radius: 6px; 98 | border-left-color: rgba(0,0,0,0.05); 99 | } 100 | 101 | .word-types { 102 | border-top-color: rgba(0,0,0,0.1); 103 | border-bottom-color: rgba(0,0,0,0); 104 | border-left-color: rgba(0,0,0,0); 105 | border-right-color: rgba(0,0,0,0); 106 | color: alpha(@theme_text_color, 0.6); 107 | } 108 | 109 | .word-types:checked { 110 | font-weight: bold; 111 | color: alpha(@theme_text_color, 1); 112 | } 113 | 114 | .copied-content, .copy-img { 115 | font-size: 0.9em; 116 | color: white; 117 | background-color: rgba(24, 24, 24, 0.75); 118 | border-style: solid; 119 | padding: 4px; 120 | border-radius: 4px; 121 | } 122 | 123 | .word-hover { 124 | color: @accent_color_500; 125 | } 126 | 127 | .word-examples { 128 | font-style: italic; 129 | color: gray; 130 | } 131 | 132 | .word-examples-hover { 133 | font-style: italic; 134 | color: alpha(@accent_color_500, 0.75); 135 | } 136 | 137 | .word-lemmas { 138 | opacity: 0.7; 139 | font-size: 0.9em; 140 | border-style: solid; 141 | border-width: 1px; 142 | padding-left: 4px; 143 | padding-right: 4px; 144 | border-radius: 3px; 145 | } 146 | 147 | .word-lemmas:hover { 148 | opacity: 1.0; 149 | } 150 | 151 | .word-lemmas:focus { 152 | box-shadow: none; 153 | } 154 | 155 | .more-results { 156 | color: gray; 157 | font-size: 0.9em; 158 | } 159 | 160 | /* -- transition effects -- */ 161 | /* ------------------------------ */ 162 | .transition-on:dir(ltr) { 163 | opacity: 0; 164 | transition: all 100ms ease-in-out; 165 | } 166 | 167 | .transition-on:hover { 168 | opacity: 1; 169 | transition: all 250ms ease-in-out; 170 | } 171 | 172 | 173 | /* -- no-word-view -- */ 174 | /* ------------------------------ */ 175 | .no-word-view > entry > image.right { 176 | padding-right: 4px; 177 | } 178 | 179 | .no-word-view > entry { 180 | font-size: large; 181 | } 182 | 183 | scrolledwindow > undershoot.top { 184 | background-blend-mode: normal; 185 | background-clip: border-box; 186 | background-color: rgba(0,0,0,0); 187 | background-image: linear-gradient(@theme_bg_color 0, alpha(@theme_bg_color, 0) 50%); 188 | background-origin: padding-box; 189 | background-position: left top; 190 | background-repeat: repeat; 191 | background-size: auto; 192 | } 193 | 194 | scrolledwindow > undershoot.bottom { 195 | background-blend-mode: normal; 196 | background-clip: border-box; 197 | background-color: rgba(0,0,0,0); 198 | background-image: linear-gradient(alpha(@theme_bg_color, 0) 50%, @theme_bg_color 100%); 199 | background-origin: padding-box; 200 | background-position: left top; 201 | background-repeat: repeat; 202 | background-size: auto; 203 | } 204 | 205 | button#speak:hover { 206 | color: @accent_color_500; 207 | } 208 | -------------------------------------------------------------------------------- /data/com.github.hezral.quickword.appdata.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | com.github.hezral.quickword 5 | CC0 6 | GPL-3.0+ 7 | QuickWord 8 | Quick and easy word lookups 9 | Adi Hezral 10 | 11 |

QuickWord can be used by manual lookup and via a keyboard shortcut to open it on the fly.

12 |

Select a word and hit the ⌘ + Ctrl + D shortcut to get the word lookup.

13 |

Features include:

14 |
    15 |
  • On the fly word lookup
  • 16 |
  • Works offline (first run will require internet to download dictionary data)
  • 17 |
  • Copy definitions and examples to clipboard with just a click
  • 18 |
  • Word definitions
  • 19 |
  • Examples of word sentences
  • 20 |
  • Synonyms
  • 21 |
  • Explore synonyms from each word
  • 22 |
23 |
24 | 25 | 26 | com.github.hezral.quickword 27 | 28 | 29 | https://github.com/hezral/quickword 30 | https://github.com/hezral/quickword/issues 31 | https://github.com/hezral/quickword/issues/labels/bug 32 | 33 | 34 | 35 | Manual word lookup dark theme 36 | https://github.com/hezral/quickword/blob/master/data/screenshot-01.png?raw=true 37 | 38 | 39 | Word lookup results 40 | https://github.com/hezral/quickword/blob/master/data/screenshot-02.png?raw=true 41 | 42 | 43 | Settings 44 | https://github.com/hezral/quickword/blob/master/data/screenshot-03.png?raw=true 45 | 46 | 47 | 48 | 49 | none 50 | none 51 | none 52 | none 53 | none 54 | none 55 | none 56 | none 57 | none 58 | none 59 | none 60 | none 61 | none 62 | none 63 | none 64 | none 65 | none 66 | none 67 | none 68 | none 69 | none 70 | mild 71 | none 72 | none 73 | none 74 | none 75 | none 76 | 77 | 78 | 79 | 80 | 81 |

Add option to always show close button, fix #13 issue

82 |

Fixed and improved appdata info

83 |
84 |
85 |
86 | 87 | 88 | #D9262C 89 | #FFFFFF 90 | 2.00 91 | pk_live_51HUnWtAIOwvlC6vLMv119RvqqsN3PWrr9CpRuQ8Jzn2pX5MPXL9oDHfdRdIzWIKr9TS39PCqQkf4QrtEFxN5Dquf00Ubl3tXzb 92 | 93 | 94 |
95 | -------------------------------------------------------------------------------- /data/com.github.hezral.quickword.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Quickword 3 | Comment=Quick and easy word lookup 4 | Exec=com.github.hezral.quickword 5 | Icon=com.github.hezral.quickword 6 | Terminal=false 7 | Type=Application 8 | Categories=Utilities;Dictionary;Office; 9 | StartupNotify=true 10 | -------------------------------------------------------------------------------- /data/com.github.hezral.quickword.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | If the dark Gtk stylesheet should be used 8 | If the dark Gtk stylesheet should be used 9 | 10 | 11 | true 12 | Whether this is the first time that QuickWord has opened 13 | Used to determine whether or not to perform initial install steps 14 | 15 | 16 | false 17 | Enable/Disable this behaviour 18 | Close on focus out of the QuickWord app window 19 | 20 | 21 | false 22 | Enable/Disable this behaviour 23 | Display QuickWord app window on all workspaces 24 | 25 | 26 | false 27 | Enable/Disable this behaviour 28 | Display QuickWord app window on top of all windows 29 | 30 | 31 | false 32 | Show window close button 33 | Show window close button 34 | 35 | 36 | false 37 | Follow OS Appearance Style 38 | Follow OS Appearance Style 39 | 40 | 41 | false 42 | Always show close button 43 | Always show close button 44 | 45 | 46 | false 47 | Close to exit or run in background 48 | Close to exit or run in background 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /data/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hezral/quickword/d5da6df6f5416852bd33204d14bd450d40ad796b/data/demo.gif -------------------------------------------------------------------------------- /data/icons/128.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /data/icons/16-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/icons/16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /data/icons/96.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /data/icons/com.github.hezral.quickword-coffee.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /data/icons/com.github.hezral.quickword-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /data/icons/com.github.hezral.quickword-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /data/icons/com.github.hezral.quickword-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/icons/prep_icons.py: -------------------------------------------------------------------------------- 1 | import pathlib, os, shutil 2 | from os import path 3 | from subprocess import call 4 | 5 | # Base application info 6 | base_rdnn = 'com.github.hezral' 7 | app_name = 'quickword' 8 | app_id = base_rdnn + '.' + app_name 9 | app_url = 'https://github.com/hezral/' + app_name 10 | saythanks_url = 'https://saythanks.io/to/adihezral%40gmail.com' 11 | 12 | # Setup file paths for data files 13 | prefix = '/usr' 14 | prefix_data = path.join(prefix, 'share') 15 | install_path = path.join(prefix_data, app_id) 16 | icon_path = 'icons/hicolor' 17 | icon_sizes = ['16','24','32','48','64','128'] 18 | 19 | # Setup install data list 20 | 21 | 22 | # Add icon data files to install data list 23 | for size in icon_sizes: 24 | prefix_size = size + 'x' + size 25 | prefix_size2x = size + 'x' + size + '@2' 26 | icon_dir = path.join(prefix_data, icon_path, prefix_size, 'apps') 27 | icon_dir2x = path.join(prefix_data, icon_path, prefix_size2x, 'apps') 28 | icon_file = 'data/icons/' + size + '.svg' 29 | if not os.path.exists('data/icons/' + size): 30 | os.makedirs('data/icons/' + size) 31 | new_icon_file = path.join('data/icons/', size, (app_id + '.svg')) 32 | shutil.copyfile(icon_file, new_icon_file) 33 | file = (icon_dir, [new_icon_file]) 34 | file2x = (icon_dir2x, [new_icon_file]) 35 | print(file) 36 | print(file2x) -------------------------------------------------------------------------------- /data/logo_type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hezral/quickword/d5da6df6f5416852bd33204d14bd450d40ad796b/data/logo_type.png -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | desktop_file = i18n.merge_file( 2 | input: 'com.github.hezral.quickword.desktop.in', 3 | output: 'com.github.hezral.quickword.desktop', 4 | type: 'desktop', 5 | po_dir: '../po', 6 | install: true, 7 | install_dir: join_paths(get_option('datadir'), 'applications') 8 | ) 9 | 10 | desktop_utils = find_program('desktop-file-validate', required: false) 11 | if desktop_utils.found() 12 | test('Validate desktop file', desktop_utils, 13 | args: [desktop_file] 14 | ) 15 | endif 16 | 17 | appstream_file = i18n.merge_file( 18 | input: 'com.github.hezral.quickword.appdata.xml.in', 19 | output: 'com.github.hezral.quickword.appdata.xml', 20 | po_dir: '../po', 21 | install: true, 22 | install_dir: join_paths(get_option('datadir'), 'metainfo') 23 | ) 24 | 25 | appstream_util = find_program('appstream-util', required: false) 26 | if appstream_util.found() 27 | test('Validate appstream file', appstream_util, 28 | args: ['validate', appstream_file] 29 | ) 30 | endif 31 | 32 | install_data('com.github.hezral.quickword.gschema.xml', 33 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 34 | ) 35 | 36 | compile_schemas = find_program('glib-compile-schemas', required: false) 37 | if compile_schemas.found() 38 | test('Validate schema file', compile_schemas, 39 | args: ['--strict', '--dry-run', meson.current_source_dir()] 40 | ) 41 | endif 42 | 43 | install_data('application.css', 44 | install_dir: join_paths(pkgdatadir, project_short_name, 'data') 45 | ) 46 | 47 | # install_data( 48 | # join_paths('icons', '128', meson.project_name() + '.svg'), 49 | # install_dir: join_paths(get_option ('datadir'), 'icons', 'hicolor', 'scalable', 'apps'), 50 | # rename: meson.project_name() + '.svg' 51 | # ) 52 | 53 | install_data( 54 | join_paths('icons', meson.project_name() + '-' + 'symbolic' + '.svg'), 55 | install_dir: join_paths(get_option ('datadir'), 'icons', 'hicolor', 'symbolic', 'apps'), 56 | ) 57 | 58 | other_icon_sources = [ 59 | join_paths('icons', meson.project_name() + '-' + 'left' + '.svg'), 60 | join_paths('icons', meson.project_name() + '-' + 'right' + '.svg'), 61 | join_paths('icons', meson.project_name() + '-' + 'symbolic' + '.svg'), 62 | join_paths('icons', meson.project_name() + '-' + 'coffee' + '.svg'), 63 | ] 64 | iconsdir = join_paths(pkgdatadir, project_short_name, 'data', 'icons') 65 | install_data(other_icon_sources, install_dir: iconsdir) 66 | 67 | icon_sizes = ['16', '24', '32', '48', '64', '128'] 68 | foreach i : icon_sizes 69 | install_data( 70 | join_paths('icons', i + '.svg'), 71 | rename: meson.project_name() + '.svg', 72 | install_dir: join_paths(get_option ('datadir'), 'icons', 'hicolor', i + 'x' + i, 'apps') 73 | ) 74 | install_data( 75 | join_paths('icons', i + '.svg'), 76 | rename: meson.project_name() + '.svg', 77 | install_dir: join_paths(get_option ('datadir'), 'icons', 'hicolor', i + 'x' + i + '@2', 'apps') 78 | ) 79 | endforeach -------------------------------------------------------------------------------- /data/screenshot-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hezral/quickword/d5da6df6f5416852bd33204d14bd450d40ad796b/data/screenshot-01.png -------------------------------------------------------------------------------- /data/screenshot-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hezral/quickword/d5da6df6f5416852bd33204d14bd450d40ad796b/data/screenshot-02.png -------------------------------------------------------------------------------- /data/screenshot-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hezral/quickword/d5da6df6f5416852bd33204d14bd450d40ad796b/data/screenshot-03.png -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | com.github.hezral.quickword (2.0.1) precise; urgency=low 2 | 3 | * Small bug fix and improvement release 4 | 5 | -- Adi Hezral Fri, 12 Nov 2021 00:00:00 +0800 6 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: com.github.hezral.quickword 2 | Section: utils 3 | Priority: optional 4 | Maintainer: Adi Hezral 5 | Build-Depends: debhelper (>= 10), 6 | dh-python, 7 | python3, 8 | meson, 9 | libgranite-dev 10 | X-Python3-Version: >= 3.6.9 11 | Standards-Version: 3.9.3 12 | 13 | Package: com.github.hezral.quickword 14 | Architecture: any 15 | Depends: ${python3:Depends}, 16 | ${misc:Depends}, 17 | python3, 18 | python3-gi, 19 | python3-xlib 20 | python3-nltk, 21 | espeak, 22 | gir1.2-granite-1.0, 23 | libgranite5 | libgranite6 24 | Description: Quick and easy word lookup on the fly and offline 25 | QuickWord can be used by manual lookup and via a keyboard shortcut to open it 26 | on the fly. 27 | . 28 | Features: 29 | • On the fly word lookup 30 | • Works offline (first run will require internet to download dictionary data) 31 | • Copy definitions and examples to clipboard with just a click 32 | • Word definitions 33 | • Examples of word sentences 34 | • Explore synonyms from each word 35 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://dep.debian.net/deps/dep5 2 | Upstream-Name: quickword 3 | Source: https://github.com/hezral/quickword 4 | 5 | Files: * 6 | Copyright: 2020 Adi Hezral 7 | License: GPL-3.0+ 8 | 9 | License: GPL-3.0+ 10 | This program is free software: you can redistribute it and/or modify 11 | it under the terms of the GNU General Public License as published by 12 | the Free Software Foundation, either version 3 of the License, or 13 | (at your option) any later version. 14 | . 15 | This package is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU General Public License for more details. 19 | . 20 | You should have received a copy of the GNU General Public License 21 | along with this program. If not, see . 22 | . 23 | On Debian systems, the complete text of the GNU General 24 | Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". 25 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | # Sample debian/rules that uses debhelper. 4 | # This file was originally written by Joey Hess and Craig Small. 5 | # As a special exception, when this file is copied by dh-make into a 6 | # dh-make output file, you may use that output file without restriction. 7 | # This special exception was added by Craig Small in version 0.37 of dh-make. 8 | 9 | # Uncomment this to turn on verbose mode. 10 | #export DH_VERBOSE=1 11 | 12 | %: 13 | dh $@ --with python3 --buildsystem=pybuild -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('com.github.hezral.quickword', 2 | version: '2.0.1', 3 | meson_version: '>= 0.50.0', 4 | default_options: [ 'warning_level=2', 5 | ], 6 | ) 7 | 8 | i18n = import('i18n') 9 | 10 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 11 | rdnn = meson.project_name().split('.') 12 | project_domain = '.'.join([rdnn[0],rdnn[1],rdnn[2]]) 13 | project_short_name = rdnn[3] 14 | 15 | subdir('data') 16 | subdir('src') 17 | subdir('po') 18 | 19 | meson.add_install_script('build-aux/meson/postinstall.py') 20 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hezral/quickword/d5da6df6f5416852bd33204d14bd450d40ad796b/po/LINGUAS -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/com.github.hezral.quickword.desktop.in 2 | data/com.github.hezral.quickword.appdata.xml.in 3 | data/com.github.hezral.quickword.gschema.xml 4 | 5 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext('quickword', preset: 'glib') 2 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hezral/quickword/d5da6df6f5416852bd33204d14bd450d40ad796b/src/__init__.py -------------------------------------------------------------------------------- /src/active_window_manager.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | 5 | # pylint: disable=unused-import 6 | from contextlib import contextmanager 7 | from typing import Any, Dict, Optional, Tuple, Union # noqa 8 | 9 | from Xlib import X 10 | from Xlib.display import Display 11 | from Xlib.error import XError 12 | from Xlib.xobject.drawable import Window 13 | from Xlib.protocol.rq import Event 14 | 15 | import threading 16 | 17 | import gi 18 | from gi.repository import GLib 19 | 20 | from datetime import datetime 21 | 22 | class ActiveWindowManager(): 23 | # Based on code by Stephan Sokolow 24 | # Source: https://gist.github.com/ssokolow/e7c9aae63fb7973e4d64cff969a78ae8 25 | # Modified by hezral to add _get_window_class_name function 26 | 27 | """python-xlib example which reacts to changing the active window/title. 28 | 29 | Requires: 30 | - Python 31 | - python-xlib 32 | 33 | Tested with Python 2.x because my Kubuntu 14.04 doesn't come with python-xlib 34 | for Python 3.x. 35 | 36 | Design: 37 | ------- 38 | 39 | Any modern window manager that isn't horrendously broken maintains an X11 40 | property on the root window named _NET_ACTIVE_WINDOW. 41 | 42 | Any modern application toolkit presents the window title via a property 43 | named _NET_WM_NAME. 44 | 45 | This listens for changes to both of them and then hides duplicate events 46 | so it only reacts to title changes once. 47 | 48 | Known Bugs: 49 | ----------- 50 | 51 | - Under some circumstances, I observed that the first window creation and last 52 | window deletion on on an empty desktop (ie. not even a taskbar/panel) would 53 | go ignored when using this test setup: 54 | 55 | Xephyr :3 & 56 | DISPLAY=:3 openbox & 57 | DISPLAY=:3 python3 x11_watch_active_window.py 58 | 59 | # ...and then launch one or more of these in other terminals 60 | DISPLAY=:3 leafpad 61 | """ 62 | 63 | stop_thread = False 64 | id_thread = None 65 | callback = None 66 | 67 | def __init__(self, gtk_application=None): 68 | super().__init__() 69 | 70 | self.app = gtk_application 71 | 72 | # Connect to the X server and get the root window 73 | self.disp = Display() 74 | self.root = self.disp.screen().root 75 | 76 | # Prepare the property names we use so they can be fed into X11 APIs 77 | self.NET_ACTIVE_WINDOW = self.disp.intern_atom('_NET_ACTIVE_WINDOW') 78 | self.NET_WM_NAME = self.disp.intern_atom('_NET_WM_NAME') # UTF-8 79 | self.WM_NAME = self.disp.intern_atom('WM_NAME') # Legacy encoding 80 | self.WM_CLASS = self.disp.intern_atom('WM_CLASS') 81 | 82 | self.last_seen = {'xid': None, 'title': None} # type: Dict[str, Any] 83 | 84 | def _run(self, callback): 85 | 86 | self.callback = callback 87 | self.stop_thread = False 88 | 89 | def init_manager(): 90 | # Listen for _NET_ACTIVE_WINDOW changes 91 | self.root.change_attributes(event_mask=X.PropertyChangeMask) 92 | 93 | # Prime last_seen with whatever window was active when we started this 94 | self.get_window_name(self.get_active_window()[0]) 95 | self.handle_change(self.last_seen) 96 | 97 | while True: # next_event() sleeps until we get an event 98 | self.handle_xevent(self.disp.next_event()) 99 | if self.stop_thread: 100 | print(datetime.now(), "active_window_manager stopped") 101 | break 102 | 103 | self.thread = threading.Thread(target=init_manager) 104 | self.thread.daemon = True 105 | self.thread.start() 106 | print(datetime.now(), "active_window_manager started") 107 | 108 | def _stop(self): 109 | self.stop_thread = True 110 | 111 | @contextmanager 112 | def window_obj(self, win_id: Optional[int]) -> Window: 113 | """Simplify dealing with BadWindow (make it either valid or None)""" 114 | window_obj = None 115 | if win_id: 116 | try: 117 | window_obj = self.disp.create_resource_object('window', win_id) 118 | except XError: 119 | pass 120 | yield window_obj 121 | 122 | def get_active_window(self) -> Tuple[Optional[int], bool]: 123 | """Return a (window_obj, focus_has_changed) tuple for the active window.""" 124 | response = self.root.get_full_property(self.NET_ACTIVE_WINDOW, X.AnyPropertyType) 125 | if not response: 126 | return None, False 127 | win_id = response.value[0] 128 | 129 | focus_changed = (win_id != self.last_seen['xid']) 130 | if focus_changed: 131 | with self.window_obj(self.last_seen['xid']) as old_win: 132 | if old_win: 133 | old_win.change_attributes(event_mask=X.NoEventMask) 134 | 135 | self.last_seen['xid'] = win_id 136 | with self.window_obj(win_id) as new_win: 137 | if new_win: 138 | new_win.change_attributes(event_mask=X.PropertyChangeMask) 139 | 140 | return win_id, focus_changed 141 | 142 | def _get_window_name_inner(self, win_obj: Window) -> str: 143 | """Simplify dealing with _NET_WM_NAME (UTF-8) vs. WM_NAME (legacy)""" 144 | for atom in (self.NET_WM_NAME, self.WM_NAME): 145 | try: 146 | window_name = win_obj.get_full_property(atom, 0) 147 | except UnicodeDecodeError: # Apparently a Debian distro package bug 148 | title = "" 149 | else: 150 | if window_name: 151 | win_name = window_name.value # type: Union[str, bytes] 152 | if isinstance(win_name, bytes): 153 | # Apparently COMPOUND_TEXT is so arcane that this is how 154 | # tools like xprop deal with receiving it these days 155 | win_name = win_name.decode('latin1', 'replace') 156 | return win_name 157 | else: 158 | title = "" 159 | 160 | return "{} (XID: {})".format(title, win_obj.id) 161 | 162 | def _get_window_class_name(self, win_obj: Window) -> str: 163 | """SReturn window class name""" 164 | try: 165 | window_name = win_obj.get_full_property(self.WM_CLASS, 0) 166 | except UnicodeDecodeError: # Apparently a Debian distro package bug 167 | title = "" 168 | else: 169 | if window_name: 170 | win_class_name = window_name.value # type: Union[str, bytes] 171 | if isinstance(win_class_name, bytes): 172 | # Apparently COMPOUND_TEXT is so arcane that this is how 173 | # tools like xprop deal with receiving it these days 174 | win_class_name = win_class_name.replace(b'\x00',b' ').decode("utf-8").lower() 175 | return win_class_name 176 | else: 177 | title = "" 178 | 179 | return "{} (XID: {})".format(title, win_obj.id) 180 | 181 | def get_window_name(self, win_id: Optional[int]) -> Tuple[Optional[str], bool]: 182 | """ 183 | Look up the window class name for a given X11 window ID 184 | retrofitted to provide window class name instead of window title 185 | """ 186 | if not win_id: 187 | self.last_seen['title'] = None 188 | return self.last_seen['title'], True 189 | 190 | title_changed = False 191 | with self.window_obj(win_id) as wobj: 192 | if wobj: 193 | try: 194 | win_title = self._get_window_class_name(wobj) 195 | except XError: 196 | pass 197 | else: 198 | title_changed = (win_title != self.last_seen['title']) 199 | self.last_seen['title'] = win_title 200 | 201 | return self.last_seen['title'], title_changed 202 | 203 | def handle_xevent(self, event: Event): 204 | """Handler for X events which ignores anything but focus/title change""" 205 | if event.type != X.PropertyNotify: 206 | return 207 | 208 | changed = False 209 | if event.atom == self.NET_ACTIVE_WINDOW: 210 | if self.get_active_window()[1]: 211 | self.get_window_name(self.last_seen['xid']) # Rely on the side-effects 212 | changed = True 213 | elif event.atom in (self.NET_WM_NAME, self.WM_NAME): 214 | changed = changed or self.get_window_name(self.last_seen['xid'])[1] 215 | 216 | if changed: 217 | self.handle_change(self.last_seen) 218 | 219 | def handle_change(self, new_state: dict): 220 | """Replace this with whatever you want to actually do""" 221 | GLib.idle_add(self.callback, new_state['title']) 222 | -------------------------------------------------------------------------------- /src/clipboard_manager.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import gi 5 | gi.require_version('Gtk', '3.0') 6 | from gi.repository import Gtk, Gdk 7 | 8 | 9 | #------------------CLASS-SEPARATOR------------------# 10 | 11 | 12 | class Clipboard(): 13 | def __init__(self, atom_type): 14 | 15 | # create clipboard 16 | self.clipboard = Gtk.Clipboard.get(atom_type) 17 | 18 | # target type to listen for 19 | self.text_target = Gdk.Atom.intern('text/plain', False) 20 | 21 | 22 | #------------------CLASS-SEPARATOR------------------# 23 | 24 | 25 | class ClipboardListener(Clipboard): 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(atom_type=Gdk.SELECTION_PRIMARY, *args, **kwargs) 28 | 29 | def copy_selected_text(self, clipboard=None, event=None): 30 | if self.clipboard.wait_is_target_available(self.text_target): 31 | content = self.clipboard.wait_for_text() 32 | 33 | # remove multiple lines and only return first line 34 | if "\n" in content: 35 | content = content.split("\n")[0] 36 | 37 | # lowercase the word 38 | content = content.lower() 39 | 40 | # remove spaces in front and back 41 | content = content.strip() 42 | 43 | # remove special characters in front of word 44 | if len(content) > 0: 45 | FirstContainsSpecialChars = any(not c.isalnum() for c in content[0]) 46 | LastContainsSpecialChars = any(not c.isalnum() for c in content[len(content)-1]) 47 | 48 | if FirstContainsSpecialChars: 49 | content = content[1:] 50 | if LastContainsSpecialChars: 51 | content = content[0:-1] 52 | 53 | # don't return empty lines 54 | if len(content) > 0: 55 | valid = True 56 | return content 57 | else: 58 | content = None 59 | valid = False 60 | return content 61 | 62 | 63 | #------------------CLASS-SEPARATOR------------------# 64 | 65 | 66 | class ClipboardPaste(Clipboard): 67 | def __init__(self, *args, **kwargs): 68 | super().__init__(atom_type=Gdk.SELECTION_CLIPBOARD, *args, **kwargs) 69 | 70 | def copy_to_clipboard(self, text_to_copy): 71 | self.clipboard.set_text(text_to_copy, -1) 72 | -------------------------------------------------------------------------------- /src/data_manager.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import os 5 | 6 | 7 | import gi 8 | gi.require_version('Gtk', '3.0') 9 | from gi.repository import GLib, Gtk 10 | 11 | import threading 12 | import time 13 | 14 | from nltk import data, downloader 15 | 16 | DATA_IDS = ('wordnet',) 17 | 18 | 19 | #------------------CLASS-SEPARATOR------------------# 20 | 21 | 22 | class DataManager(): 23 | def __init__(self, application_id="com.github.hezral.quickword", *args, **kwargs): 24 | 25 | self.nltk_data_path = os.path.join(GLib.get_user_data_dir(), application_id, 'nltk_data') 26 | 27 | # setup paths 28 | data.path = [self.nltk_data_path] 29 | downloader.download_dir = self.nltk_data_path 30 | 31 | # check if data dir exist 32 | if not os.path.exists(self.nltk_data_path): 33 | os.makedirs(self.nltk_data_path) 34 | 35 | def update_data(self, callback=None): 36 | # check if any data is stale and update it 37 | stale = 0 38 | for id in DATA_IDS: 39 | if downloader.Downloader().is_stale(id, self.nltk_data_path): 40 | stale += 1 41 | GLib.idle_add(callback, "Updating", id + " has an update") 42 | time.sleep(0.5) 43 | else: 44 | GLib.idle_add(callback, "No Updates", id + " is up-to-date") 45 | if stale > 0: 46 | GLib.idle_add(callback, "Updates Available", "There are " + str(stale) + "data updates available for download") 47 | time.sleep(0.5) 48 | downloader.Downloader().update(quiet=False, prefix=self.nltk_data_path) 49 | time.sleep(0.5) 50 | GLib.idle_add(callback, "Completed", "All data has been updated.") 51 | else: 52 | time.sleep(0.5) 53 | GLib.idle_add(callback, "No Updates", "All data is up-to-date.") 54 | 55 | def download_data(self, callback=None): 56 | # download data if not installed 57 | for id in DATA_IDS: 58 | if not downloader.Downloader().is_installed(id, self.nltk_data_path): 59 | GLib.idle_add(callback, "Downloading...", "Downloading " + id) 60 | time.sleep(0.5) 61 | downloader.Downloader().download(id) 62 | time.sleep(0.5) 63 | GLib.idle_add(callback, "Downloading", id + " data has been downloaded.") 64 | time.sleep(0.5) 65 | GLib.idle_add(callback, "Completed", "All data has been downloaded.") 66 | else: 67 | time.sleep(0.5) 68 | GLib.idle_add(callback, "Downloaded", "All data has already been downloaded.") 69 | 70 | 71 | 72 | def run_func(self, runname=None, callback=None): 73 | if runname == "download": 74 | target = self.download_data 75 | else: 76 | target = self.update_data 77 | thread = threading.Thread(target=target, args=(callback,)) 78 | thread.daemon = True 79 | thread.start() 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import sys, os 5 | 6 | import gi 7 | gi.require_version('Gtk', '3.0') 8 | gi.require_version('Granite', '1.0') 9 | from gi.repository import Gtk, Gio, Gdk, Granite 10 | 11 | from .main_window import QuickWordWindow 12 | from .clipboard_manager import ClipboardListener, ClipboardPaste 13 | from .word_lookup import WordLookup 14 | from .active_window_manager import ActiveWindowManager 15 | from .utils import HelperUtils 16 | 17 | from datetime import datetime 18 | 19 | #------------------CLASS-SEPARATOR------------------# 20 | 21 | class QuickWordApp(Gtk.Application): 22 | 23 | app_id = "com.github.hezral.quickword" 24 | gtk_settings = Gtk.Settings().get_default() 25 | gio_settings = Gio.Settings(schema_id=app_id) 26 | granite_settings = Granite.Settings.get_default() 27 | clipboard_listener = ClipboardListener() 28 | clipboard_paste = ClipboardPaste() 29 | utils = HelperUtils() 30 | window_manager = None 31 | window = None 32 | lookup_word = None 33 | word_data = None 34 | running = False 35 | 36 | def __init__(self, *args, **kwargs): 37 | super().__init__(*args, **kwargs) 38 | 39 | self.props.application_id = self.app_id 40 | self.window_manager = ActiveWindowManager(gtk_application=self) 41 | self.first_run = self.gio_settings.get_value("first-run") 42 | 43 | # initialize word lookup 44 | self._word_lookup = WordLookup(application_id=self.props.application_id) 45 | if self.first_run: 46 | self.generate_data_manager() 47 | else: 48 | self._word_lookup.get_synsets("a") # hack to load wordnet faster maybe 49 | 50 | if self.gio_settings.get_value("theme-optin"): 51 | prefers_color_scheme = self.granite_settings.get_prefers_color_scheme() 52 | self.gtk_settings.set_property("gtk-application-prefer-dark-theme", prefers_color_scheme) 53 | self.granite_settings.connect("notify::prefers-color-scheme", self.on_prefers_color_scheme) 54 | 55 | def do_startup(self): 56 | Gtk.Application.do_startup(self) 57 | 58 | # setup quiting app using Escape, Ctrl+Q 59 | quit_action = Gio.SimpleAction.new("quit", None) 60 | quit_action.connect("activate", self.on_quit_action) 61 | self.add_action(quit_action) 62 | self.set_accels_for_action("app.quit", ["Q", "Escape"]) 63 | 64 | # set CSS provider 65 | provider = Gtk.CssProvider() 66 | provider.load_from_path(os.path.join(os.path.dirname(__file__), "data", "application.css")) 67 | Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 68 | 69 | # prepend custom path for icon theme 70 | icon_theme = Gtk.IconTheme.get_default() 71 | icon_theme.prepend_search_path(os.path.join(os.path.dirname(__file__), "data", "icons")) 72 | 73 | def do_activate(self): 74 | 75 | if self.window is None: 76 | self.window = QuickWordWindow(application=self) 77 | self.window.word_view.clipboard_paste = self.clipboard_paste 78 | self.add_window(self.window) 79 | 80 | self.window.present() 81 | 82 | # get current selected text and lookup 83 | self.on_new_word_selected() 84 | 85 | self.running = True 86 | 87 | # setup listener for new text selection 88 | self.clipboard_listener.clipboard.connect("owner-change", self.on_new_word_selected) 89 | 90 | def generate_data_manager(self): 91 | from .data_manager import DataManager 92 | self._data_manager = DataManager(application_id=self.props.application_id) 93 | return True 94 | 95 | def on_new_word_selected(self, clipboard=None, event=None): 96 | self.on_new_word_lookup(self.clipboard_listener.copy_selected_text()) 97 | 98 | def on_new_word_lookup(self, word): 99 | self.lookup_word = word 100 | word_data = None 101 | if self.first_run: 102 | self.window.on_view_visible() 103 | else: 104 | if word is not None: 105 | word_data = self._word_lookup.get_synsets(word) 106 | 107 | if word_data is not None: 108 | # emit the signal to trigger content update callback 109 | self.window.emit("on-new-word-selected", word_data) 110 | return True 111 | else: 112 | # go back to no-word-view 113 | self.lookup_word = None 114 | self.window.on_manual_lookup(not_found=True) 115 | return False 116 | 117 | def on_quit_action(self, action, param): 118 | if self.window is not None: 119 | self.window.destroy() 120 | 121 | def on_prefers_color_scheme(self, *args): 122 | prefers_color_scheme = self.granite_settings.get_prefers_color_scheme() 123 | self.gtk_settings.set_property("gtk-application-prefer-dark-theme", prefers_color_scheme) 124 | 125 | def main(version): 126 | app = QuickWordApp() 127 | return app.run(sys.argv) -------------------------------------------------------------------------------- /src/main_window.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import gi 5 | gi.require_version('Gtk', '3.0') 6 | gi.require_version('Granite', '1.0') 7 | from gi.repository import GLib, Gtk, Granite, GObject, Gdk 8 | 9 | from .settings_view import SettingsView 10 | from .noword_view import NoWordView 11 | from .word_view import WordView 12 | from .updater_view import UpdaterView 13 | 14 | 15 | class QuickWordWindow(Gtk.ApplicationWindow): 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | 19 | # Add custom signals to detect new word selected 20 | # GObject.signal_new(signal_name, type, flags, return_type, param_types) 21 | # param_types is a list example [GObject.TYPE_PYOBJECT, GObject.TYPE_STRING] 22 | GObject.signal_new("on-new-word-selected", Gtk.ApplicationWindow, GObject.SIGNAL_RUN_LAST, GObject.TYPE_BOOLEAN, [GObject.TYPE_PYOBJECT]) 23 | self.connect("on-new-word-selected", self.on_new_word_selected) 24 | 25 | self.app = self.props.application 26 | self.lookup_word = self.app.lookup_word 27 | 28 | self.updater_view = UpdaterView() 29 | self.noword_view = NoWordView() 30 | self.word_view = WordView() 31 | self.settings_view = SettingsView(app=self.app) 32 | 33 | self.stack = Gtk.Stack() 34 | self.stack.props.transition_type = Gtk.StackTransitionType.CROSSFADE 35 | self.stack.add_named(self.word_view, self.word_view.get_name()) 36 | self.stack.add_named(self.settings_view, self.settings_view.get_name()) 37 | self.stack.add_named(self.noword_view, self.noword_view.get_name()) 38 | self.stack.add_named(self.updater_view, self.updater_view.get_name()) 39 | 40 | self.headerbar = self.generate_headerbar() 41 | 42 | # self.props.resizable = False #set this and window will expand and retract based on child 43 | self.title = "QuickWord" 44 | self.set_keep_above(True) 45 | self.get_style_context().add_class("rounded") 46 | self.set_size_request(-1, -1) #set width to -1 to expand and retract based on content 47 | self.props.window_position = Gtk.WindowPosition.MOUSE 48 | self.set_titlebar(self.headerbar) 49 | self.add(self.stack) 50 | 51 | self.connect("key-press-event", self.on_key_press) 52 | 53 | self.show_all() 54 | self.on_start_settings() 55 | # hide all other views to enable window to expand and retract based on content 56 | self.noword_view.hide() 57 | self.settings_view.hide() 58 | self.updater_view.hide() 59 | 60 | def on_key_press(self, widget, eventkey): 61 | 62 | if not self.noword_view.entry.props.has_focus: 63 | if Gdk.keyval_name(eventkey.keyval).lower() == "right": 64 | self.stack.set_visible_child_name("settings-view") 65 | self.on_view_visible(view="settings-view") 66 | 67 | if Gdk.keyval_name(eventkey.keyval).lower() == "left": 68 | self.on_view_visible(view="word-view") 69 | 70 | if Gdk.keyval_name(eventkey.keyval).lower() == "m": 71 | self.on_manual_lookup() 72 | 73 | if self.stack.get_visible_child_name() == "word-view": 74 | if Gdk.keyval_name(eventkey.keyval).lower() == "n": 75 | self.word_view.stack.set_visible_child_name("noun") 76 | if Gdk.keyval_name(eventkey.keyval).lower() == "a": 77 | self.word_view.stack.set_visible_child_name("adjective") 78 | if Gdk.keyval_name(eventkey.keyval).lower() == "d": 79 | self.word_view.stack.set_visible_child_name("adverb") 80 | if Gdk.keyval_name(eventkey.keyval).lower() == "v": 81 | self.word_view.stack.set_visible_child_name("verb") 82 | 83 | def on_start_settings(self): 84 | self.connect("delete-event", self.on_close_window) 85 | 86 | if self.app.gio_settings.get_value("sticky-mode"): 87 | self.stick() 88 | 89 | if not self.app.gio_settings.get_value("persistent-mode"): 90 | self.word_label.props.margin_left = 10 91 | self.headerbar.set_show_close_button(False) 92 | if self.app.window_manager is not None: 93 | self.app.window_manager._run(callback=self.on_persistent_mode) 94 | else: 95 | self.headerbar.set_show_close_button(True) 96 | self.word_label.props.margin_left = 0 97 | 98 | if self.app.gio_settings.get_value("close-button"): 99 | self.headerbar.set_show_close_button(True) 100 | self.word_label.props.margin_left = 0 101 | 102 | def on_persistent_mode(self, wm_class): 103 | if wm_class is not None: 104 | if self.app.props.application_id.split(".")[-1] not in wm_class: 105 | if self.app.running: 106 | self.on_close_window() 107 | 108 | def on_enter_word_label(self, *args): 109 | self.word_action_revealer.set_reveal_child(False) 110 | if self.stack.get_visible_child_name() == "word-view": 111 | self.edit_img_revealer.set_reveal_child(True) 112 | 113 | def on_leave_word_label(self, *args): 114 | if self.stack.get_visible_child_name() == "settings-view": 115 | pass 116 | if self.stack.get_visible_child_name() != "no-word-view" or self.stack.get_visible_child_name() != "updater-view": 117 | self.word_action_revealer.set_reveal_child(False) 118 | self.edit_img_revealer.set_reveal_child(False) 119 | if self.stack.get_visible_child_name() == "word-view": 120 | self.word_action_revealer.set_reveal_child(True) 121 | self.edit_img_revealer.set_reveal_child(False) 122 | 123 | def on_new_word_selected(self, window, word_data): 124 | self.lookup_word = word_data[0] 125 | self.word_label.props.label = word_data[0] 126 | self.word_view.on_wordlookup(data=word_data) 127 | self.word_view.show_all() 128 | self.stack.set_visible_child(self.word_view) 129 | self.props.resizable = True 130 | self.view_switch.props.active = False 131 | 132 | def on_manual_lookup(self, eventbutton=None, eventbox=None, not_found=False, *args): 133 | self.word_action_revealer.set_reveal_child(False) 134 | 135 | if self.stack.get_visible_child_name() == "word-view": 136 | if not_found: 137 | self.noword_view.message.props.label = "Word not found" 138 | else: 139 | self.noword_view.message.props.label = "Lookup a new word" 140 | 141 | # need to clear entry text since clipboard_listener will pickup the text as it will be selected on focus and cause a loop back to the word-view 142 | self.noword_view.entry.props.text = "" 143 | 144 | self.noword_view.show_all() 145 | self.noword_view.icon_overlay.grab_focus() 146 | self.stack.set_visible_child_name(name="no-word-view") 147 | self.word_label.props.label = "QuickWord" 148 | self.word_view.hide() 149 | self.settings_view.hide() 150 | self.props.resizable = False 151 | # delayed grab focus to avoid hotkey character getting inserted in entry field 152 | GLib.timeout_add(250, self.noword_view.entry.grab_focus) 153 | 154 | def generate_viewswitch(self): 155 | self.view_switch = Granite.ModeSwitch.from_icon_name("com.github.hezral.quickword-symbolic", "preferences-system-symbolic") 156 | self.view_switch.props.valign = Gtk.Align.CENTER 157 | self.view_switch.props.halign = Gtk.Align.END 158 | self.view_switch.props.hexpand = True 159 | self.view_switch.props.margin = 4 160 | self.view_switch.props.name = "view-switch" 161 | self.view_switch.get_children()[1].props.can_focus = False 162 | self.view_switch.connect_after("notify::active", self.on_view_visible) 163 | return self.view_switch 164 | 165 | def generate_headerbar(self): 166 | self.word_label = Gtk.Label("QuickWord") 167 | self.word_label.props.vexpand = True 168 | self.word_label.get_style_context().add_class("lookup-word-header") 169 | 170 | edit_img = Gtk.Image().new_from_icon_name("insert-text-symbolic", Gtk.IconSize.SMALL_TOOLBAR) 171 | edit_img.props.halign = Gtk.Align.START 172 | self.edit_img_revealer = Gtk.Revealer() 173 | self.edit_img_revealer.props.transition_type = Gtk.RevealerTransitionType.CROSSFADE 174 | self.edit_img_revealer.add(edit_img) 175 | 176 | self.pronounciation_label = Gtk.Label("") 177 | self.pronounciation_label.props.name = "pronounciation" 178 | self.pronounciation_label.props.expand = True 179 | self.pronounciation_label.props.can_focus = False 180 | self.pronounciation_label.get_style_context().add_class("pronounciation") 181 | 182 | self.speak_btn = Gtk.Button(image=Gtk.Image().new_from_icon_name("audio-volume-high-symbolic", Gtk.IconSize.SMALL_TOOLBAR)) 183 | self.speak_btn.props.name = "speak" 184 | self.speak_btn.props.expand = True 185 | self.speak_btn.props.can_focus = False 186 | self.speak_btn.get_style_context().add_class("speak") 187 | self.speak_btn.connect("clicked", self.on_speak_word) 188 | 189 | self.word_actions = Gtk.Grid() 190 | self.word_actions.props.name = "word-actions" 191 | self.word_actions.props.column_spacing = 6 192 | self.word_actions.props.expand = False 193 | self.word_actions.attach(self.pronounciation_label, 0, 0, 1, 1) 194 | self.word_actions.attach(self.speak_btn, 1, 0, 1, 1) 195 | 196 | self.word_action_revealer = Gtk.Revealer() 197 | self.word_action_revealer.props.transition_type = Gtk.RevealerTransitionType.CROSSFADE 198 | self.word_action_revealer.add(self.word_actions) 199 | 200 | self.word_box = Gtk.EventBox() 201 | self.word_box.add(self.word_label) 202 | self.word_box.connect("button-press-event", self.on_manual_lookup) 203 | self.word_box.connect("enter-notify-event", self.on_enter_word_label) 204 | self.word_box.connect("leave-notify-event", self.on_leave_word_label) 205 | 206 | self.word_grid = Gtk.Grid() 207 | self.word_grid.props.column_spacing = 6 208 | self.word_grid.props.halign = Gtk.Align.START 209 | self.word_grid.props.valign = Gtk.Align.CENTER 210 | self.word_grid.attach(self.word_box, 0, 0, 1, 1) 211 | self.word_grid.attach(self.word_action_revealer, 1, 0, 1, 1) 212 | self.word_grid.attach(self.edit_img_revealer, 1, 0, 1, 1) 213 | 214 | titlebar_grid = Gtk.Grid() 215 | titlebar_grid.props.hexpand = True 216 | titlebar_grid.attach(self.word_grid, 0, 0, 1, 1) 217 | titlebar_grid.attach(self.generate_viewswitch(), 1, 0, 1, 1) 218 | 219 | headerbar = Gtk.HeaderBar() 220 | headerbar.props.custom_title = titlebar_grid 221 | headerbar.props.decoration_layout = "close:" 222 | return headerbar 223 | 224 | def on_speak_word(self, button): 225 | import subprocess 226 | from shutil import which, Error 227 | try: 228 | word = self.word_label.props.label 229 | subprocess.call(["espeak", word]) 230 | print(word) 231 | except: 232 | pass 233 | 234 | def on_view_visible(self, viewswitch=None, gparam=None, view=None): 235 | 236 | def set_word_view(): 237 | if self.app.lookup_word is None: 238 | self.noword_view.show_all() 239 | self.word_label.props.label = "Quickword" 240 | self.stack.set_visible_child(self.noword_view) 241 | self.word_view.hide() 242 | self.props.resizable = False 243 | else: 244 | self.word_view.show_all() 245 | self.word_label.props.label = self.lookup_word 246 | self.stack.set_visible_child(self.word_view) 247 | self.noword_view.hide() 248 | self.word_action_revealer.set_reveal_child(True) 249 | self.props.resizable = True 250 | 251 | def set_settings_view(): 252 | self.word_action_revealer.set_reveal_child(False) 253 | self.settings_view.show_all() 254 | self.settings_view.on_totalwords() 255 | self.stack.set_visible_child(self.settings_view) 256 | self.word_label.props.label = "Settings" 257 | self.word_view.hide() 258 | self.noword_view.hide() 259 | self.props.resizable = False 260 | 261 | def set_updater_view(): 262 | self.word_action_revealer.set_reveal_child(False) 263 | self.updater_view.show_all() 264 | self.stack.set_visible_child(self.updater_view) 265 | self.word_label.props.label = "Settings" 266 | self.word_view.hide() 267 | self.noword_view.hide() 268 | self.settings_view.hide() 269 | self.props.resizable = False 270 | 271 | if self.app.first_run: 272 | set_updater_view() 273 | 274 | if not self.app.first_run and view is None: 275 | if viewswitch is not None: 276 | if viewswitch.props.active: 277 | set_settings_view() 278 | else: 279 | set_word_view() 280 | self.settings_view.hide() 281 | else: 282 | set_word_view() 283 | 284 | if view is not None: 285 | self.stack.set_visible_child_name(view) 286 | if view == "settings-view": 287 | self.view_switch.props.active = True 288 | else: 289 | self.view_switch.props.active = False 290 | 291 | def on_close_window(self, window=None, event=None): 292 | if self.app.gio_settings.get_value("close-mode"): 293 | if window is None: 294 | self.hide() 295 | else: 296 | window.hide() 297 | return True 298 | else: 299 | self.destroy() -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 2 | moduledir = join_paths(pkgdatadir, 'quickword') 3 | gnome = import('gnome') 4 | 5 | python = import('python') 6 | 7 | conf = configuration_data() 8 | conf.set('PYTHON', python.find_installation('python3').path()) 9 | conf.set('VERSION', meson.project_version()) 10 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 11 | conf.set('pkgdatadir', pkgdatadir) 12 | 13 | configure_file( 14 | input: 'quickword.in', 15 | output: 'com.github.hezral.quickword', 16 | configuration: conf, 17 | install: true, 18 | install_dir: get_option('bindir') 19 | ) 20 | 21 | quickword_sources = [ 22 | '__init__.py', 23 | 'utils.py', 24 | 'main.py', 25 | 'main_window.py', 26 | 'active_window_manager.py', 27 | 'clipboard_manager.py', 28 | 'data_manager.py', 29 | 'noword_view.py', 30 | 'settings_view.py', 31 | 'updater_view.py', 32 | 'word_lookup.py', 33 | 'word_view.py' 34 | ] 35 | 36 | install_data(quickword_sources, install_dir: moduledir) 37 | -------------------------------------------------------------------------------- /src/noword_view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import os 5 | import gi 6 | gi.require_version('Gtk', '3.0') 7 | from gi.repository import Gtk, Pango 8 | 9 | 10 | class NoWordView(Gtk.Grid): 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | 14 | #-- quickword logo --------# 15 | left_icon = Gtk.Image().new_from_icon_name("com.github.hezral.quickword-left", Gtk.IconSize.DIALOG) 16 | right_icon = Gtk.Image().new_from_icon_name("com.github.hezral.quickword-right", Gtk.IconSize.DIALOG) 17 | 18 | left_icon.set_pixel_size(96) 19 | right_icon.set_pixel_size(96) 20 | 21 | right_icon.get_style_context().add_class("quickword-icon-right") 22 | 23 | self.icon_overlay = Gtk.Overlay() 24 | self.icon_overlay.add(left_icon) 25 | self.icon_overlay.add_overlay(right_icon) 26 | self.icon_overlay.props.can_focus = True 27 | self.icon_overlay.props.focus_on_click = True 28 | # self.icon_overlay.grab_focus() 29 | 30 | #-- message header --------# 31 | self.message = Gtk.Label("No word detected") 32 | self.message.props.name = "message" 33 | self.message.props.margin_bottom = 5 34 | self.message.props.hexpand = True 35 | self.message.props.halign = Gtk.Align.CENTER 36 | self.message.props.valign = Gtk.Align.CENTER 37 | self.message.props.max_width_chars = 30 38 | self.message.props.wrap = True 39 | self.message.props.wrap_mode = Pango.WrapMode.WORD 40 | self.message.props.justify = Gtk.Justification.CENTER 41 | self.message.get_style_context().add_class("h3") 42 | 43 | #-- message header --------# 44 | self.sub_message = Gtk.Label("Select a word in any application or document\nor type a word below to get a quick word lookup") 45 | self.sub_message.props.margin_bottom = 10 46 | self.sub_message.props.hexpand = True 47 | self.sub_message.props.halign = Gtk.Align.CENTER 48 | self.sub_message.props.valign = Gtk.Align.CENTER 49 | self.sub_message.props.max_width_chars = 40 50 | self.sub_message.props.wrap = True 51 | self.sub_message.props.wrap_mode = Pango.WrapMode.WORD 52 | self.sub_message.props.justify = Gtk.Justification.CENTER 53 | 54 | #-- word entry --------# 55 | self.entry = Gtk.Entry() 56 | self.entry.set_size_request(-1, 40) 57 | self.entry.props.expand = False 58 | self.entry.props.focus_on_click = True 59 | self.entry.props.placeholder_text = "type a word like 'quick' and press enter" 60 | self.entry.props.xalign = 0.5 61 | self.entry.get_style_context().add_class("entry-word") 62 | self.entry.connect("key-press-event", self.on_entry_start) 63 | self.entry.connect("button-press-event", self.on_entry_start) 64 | self.entry.connect("activate", self.on_entry_activate) 65 | self.entry.connect("icon_press", self.on_backspace) 66 | 67 | 68 | self.props.name = 'no-word-view' 69 | self.get_style_context().add_class(self.props.name) 70 | self.set_size_request(350, -1) 71 | self.props.margin = 20 72 | self.props.margin_left = 20 73 | self.props.margin_right = 20 74 | self.props.expand = True 75 | self.props.row_spacing = 12 76 | self.props.column_spacing = 6 77 | self.props.valign = Gtk.Align.CENTER 78 | self.attach(self.icon_overlay, 0, 1, 1, 1) 79 | self.attach(self.message, 0, 2, 1, 1) 80 | self.attach(self.sub_message, 0, 3, 1, 1) 81 | self.attach(self.entry, 0, 4, 1, 1) 82 | 83 | 84 | def on_entry_start(self, entry, eventkey): 85 | entry.props.secondary_icon_name = "edit-clear-symbolic" 86 | 87 | def on_backspace(self, entry, entry_icon, eventbutton): 88 | entry.props.text = "" 89 | 90 | def on_entry_activate(self, entry): 91 | self.entry.props.secondary_icon_name = None 92 | window = self.get_toplevel() 93 | 94 | if self.entry.props.text == "": 95 | # need to reset text, bug maybe? 96 | self.entry.props.text = "" 97 | self.entry.props.placeholder_text = "need a word here 😀️" 98 | self.icon_overlay.grab_focus() 99 | else: 100 | # callback to WordLookup 101 | lookup = window.props.application.on_new_word_lookup(entry.props.text) 102 | # check if word lookup succeeded or not 103 | if lookup is False: 104 | self.message.props.label = "Word not found" 105 | self.entry.props.text = "" 106 | self.entry.props.placeholder_text = "please type a valid word 🧐️" 107 | self.icon_overlay.grab_focus() 108 | else: 109 | self.hide() 110 | -------------------------------------------------------------------------------- /src/quickword.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | # SPDX-FileCopyrightText: 2021 Adi Hezral 5 | 6 | import os 7 | import sys 8 | import signal 9 | import gettext 10 | 11 | VERSION = '@VERSION@' 12 | pkgdatadir = '@pkgdatadir@' 13 | localedir = '@localedir@' 14 | 15 | sys.path.insert(1, pkgdatadir) 16 | signal.signal(signal.SIGINT, signal.SIG_DFL) 17 | gettext.install('quickword', localedir) 18 | 19 | if __name__ == '__main__': 20 | import gi 21 | 22 | from quickword import main 23 | print("Quickword", VERSION) 24 | sys.exit(main.main(VERSION)) 25 | -------------------------------------------------------------------------------- /src/settings_view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import gi 5 | gi.require_version('Gtk', '3.0') 6 | gi.require_version('Granite', '1.0') 7 | from gi.repository import Gtk, Gio, Gdk, GObject, Pango, Granite 8 | 9 | 10 | class SettingsView(Gtk.Grid): 11 | def __init__(self, app, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | 14 | self.app = app 15 | 16 | theme_switch = SubSettings(type="switch", name="theme-switch", label="Switch between Dark/Light theme", sublabel=None, separator=False) 17 | theme_optin = SubSettings(type="checkbutton", name="theme-optin", label=None, sublabel=None, separator=True, params=("Follow system appearance style",)) 18 | 19 | theme_switch.switch.bind_property("active", self.app.gtk_settings, "gtk-application-prefer-dark-theme", GObject.BindingFlags.SYNC_CREATE) 20 | 21 | self.app.granite_settings.connect("notify::prefers-color-scheme", self.on_appearance_style_change, theme_switch) 22 | theme_switch.switch.connect_after("notify::active", self.on_switch_activated) 23 | theme_optin.checkbutton.connect_after("notify::active", self.on_checkbutton_activated, theme_switch) 24 | 25 | self.app.gio_settings.bind("prefer-dark-style", theme_switch.switch, "active", Gio.SettingsBindFlags.DEFAULT) 26 | self.app.gio_settings.bind("theme-optin", theme_optin.checkbutton, "active", Gio.SettingsBindFlags.DEFAULT) 27 | 28 | close_button = SubSettings(type="switch", name="close-button", label="Show close button", sublabel="Always show close button",separator=True) 29 | close_button.switch.connect_after("notify::active", self.on_switch_activated) 30 | self.app.gio_settings.bind("close-button", close_button.switch, "active", Gio.SettingsBindFlags.DEFAULT) 31 | 32 | persistent_mode = SubSettings(type="switch", name="persistent-mode", label="Persistent mode", sublabel="Stays open and updates as text changes",separator=True) 33 | persistent_mode.switch.connect_after("notify::active", self.on_switch_activated, close_button) 34 | self.app.gio_settings.bind("persistent-mode", persistent_mode.switch, "active", Gio.SettingsBindFlags.DEFAULT) 35 | 36 | close_mode = SubSettings(type="switch", name="close-mode", label="Background mode", sublabel="Close window and run-in-background",separator=True) 37 | close_mode.switch.connect_after("notify::active", self.on_switch_activated) 38 | self.app.gio_settings.bind("close-mode", close_mode.switch, "active", Gio.SettingsBindFlags.DEFAULT) 39 | 40 | sticky_mode = SubSettings(type="switch", name="sticky-mode", label="Sticky mode", sublabel="Window is displayed on all workspaces",separator=False) 41 | sticky_mode.switch.connect_after("notify::active", self.on_switch_activated) 42 | self.app.gio_settings.bind("sticky-mode", sticky_mode.switch, "active", Gio.SettingsBindFlags.DEFAULT) 43 | 44 | display_behaviour = SettingsGroup("Display & Behaviour", (theme_switch, theme_optin, persistent_mode, close_button, close_mode, sticky_mode)) 45 | 46 | buyme_coffee = SubSettings(type="button", name="buy-me-coffee", label="Show Support", sublabel="Thanks for supporting me!", separator=False, params=("Coffee Time", Gtk.Image().new_from_icon_name("com.github.hezral.quickword-coffee", Gtk.IconSize.LARGE_TOOLBAR), )) 47 | buyme_coffee.button.connect("clicked", self.on_button_clicked) 48 | 49 | check_updates = SubSettings(type="button", name="check-updates", label="NA", sublabel=None, separator=True, params=("Check Updates", Gtk.Image().new_from_icon_name("software-update-available", Gtk.IconSize.LARGE_TOOLBAR), )) 50 | check_updates.button.connect("clicked", self.on_button_clicked) 51 | 52 | others = SettingsGroup("Others", (check_updates, buyme_coffee, )) 53 | 54 | self.props.name = "settings-view" 55 | self.get_style_context().add_class(self.props.name) 56 | self.props.expand = True 57 | self.props.margin = 20 58 | self.props.margin_top = 10 59 | self.props.row_spacing = 10 60 | self.props.column_spacing = 6 61 | self.attach(display_behaviour, 0, 0, 1, 1) 62 | self.attach(others, 0, 1, 1, 1) 63 | 64 | def on_button_clicked(self, button, params=None): 65 | name = button.get_name() 66 | if name == "check-updates": 67 | self.on_check_update() 68 | if name == "buy-me-coffee": 69 | Gtk.show_uri_on_window(None, "https://www.buymeacoffee.com/hezral", Gdk.CURRENT_TIME) 70 | 71 | def on_check_update(self): 72 | stack = self.get_parent() 73 | window = stack.get_parent() 74 | 75 | window.updater_view.show_all() 76 | window.stack.set_visible_child_name("updater-view") 77 | window.word_label.props.label = "Updater" 78 | window.settings_view.hide() 79 | 80 | def on_totalwords(self): 81 | stack = self.get_parent() 82 | window = stack.get_parent() 83 | app = window.props.application 84 | others_settinggroup = [child for child in self.get_children() if child.get_name() == "Others"][0] 85 | others_settinggroup_frame = [child for child in others_settinggroup.get_children() if isinstance(child, Gtk.Frame)][0] 86 | check_updates_subsetting = [child for child in others_settinggroup_frame.get_children()[0].get_children() if child.get_name() == "check-updates"][0] 87 | # print(check_updates_subsetting.get_children()) 88 | 89 | if check_updates_subsetting.label_text.props.label == "NA": 90 | check_updates_subsetting.label_text.props.label = "Total words available: " + str(app._word_lookup.get_totalwords()) 91 | 92 | def generate_separator(self): 93 | separator = Gtk.Separator() 94 | separator.props.hexpand = True 95 | separator.props.valign = Gtk.Align.CENTER 96 | return separator 97 | 98 | def on_switch_activated(self, switch, gparam, widget=None): 99 | name = switch.get_name() 100 | main_window = self.get_toplevel() 101 | 102 | if self.is_visible(): 103 | # stack = self.get_parent() 104 | # window = stack.get_parent() 105 | if name == "persistent-mode": 106 | close_button = widget 107 | if switch.get_active(): 108 | self.app.window_manager._stop() 109 | main_window.set_keep_above(True) # manually trigger a window manager event to stop the thread 110 | main_window.headerbar.set_show_close_button(True) 111 | main_window.word_label.props.margin_left = 0 112 | # if self.app.gio_settings.get_value("close-button"): 113 | # close_button.props.sensitive = True 114 | else: 115 | self.app.window_manager._run(callback=main_window.on_persistent_mode) 116 | if not self.app.gio_settings.get_value("close-button"): 117 | main_window.headerbar.set_show_close_button(False) 118 | main_window.word_label.props.margin_left = 10 119 | 120 | main_window.headerbar.hide() 121 | main_window.headerbar.show_all() 122 | 123 | if name == "sticky-mode": 124 | if switch.get_active(): 125 | main_window.stick() 126 | else: 127 | main_window.unstick() 128 | 129 | if name == "close-button": 130 | if switch.get_active(): 131 | main_window.word_label.props.margin_left = 0 132 | main_window.headerbar.set_show_close_button(True) 133 | else: 134 | main_window.word_label.props.margin_left = 10 135 | main_window.headerbar.set_show_close_button(False) 136 | main_window.headerbar.hide() 137 | main_window.headerbar.show_all() 138 | 139 | 140 | 141 | 142 | def on_checkbutton_activated(self, checkbutton, gparam, widget): 143 | name = checkbutton.get_name() 144 | theme_switch = widget 145 | if name == "theme-optin": 146 | if self.app.gio_settings.get_value("theme-optin"): 147 | prefers_color_scheme = self.app.granite_settings.get_prefers_color_scheme() 148 | sensitive = False 149 | else: 150 | prefers_color_scheme = Granite.SettingsColorScheme.NO_PREFERENCE 151 | theme_switch.switch.props.active = self.app.gio_settings.get_value("prefer-dark-style") 152 | sensitive = True 153 | 154 | self.app.gtk_settings.set_property("gtk-application-prefer-dark-theme", prefers_color_scheme) 155 | self.app.granite_settings.connect("notify::prefers-color-scheme", self.app.on_prefers_color_scheme) 156 | 157 | if "DARK" in prefers_color_scheme.value_name: 158 | active = True 159 | else: 160 | active = False 161 | 162 | theme_switch.switch.props.active = active 163 | theme_switch.props.sensitive = sensitive 164 | 165 | def on_appearance_style_change(self, granite_settings, gparam, widget): 166 | theme_switch = widget 167 | if theme_switch.switch.props.active: 168 | theme_switch.switch.props.active = False 169 | else: 170 | theme_switch.switch.props.active = True 171 | 172 | 173 | class SettingsGroup(Gtk.Grid): 174 | def __init__(self, group_label, subsettings_list, *args, **kwargs): 175 | super().__init__(*args, **kwargs) 176 | 177 | grid = Gtk.Grid() 178 | grid.props.margin = 8 179 | grid.props.hexpand = True 180 | grid.props.row_spacing = 8 181 | grid.props.column_spacing = 10 182 | 183 | i = 0 184 | for subsetting in subsettings_list: 185 | grid.attach(subsetting, 0, i, 1, 1) 186 | i += 1 187 | 188 | frame = Gtk.Frame() 189 | frame.props.name = "settings-group-frame" 190 | frame.props.hexpand = True 191 | frame.add(grid) 192 | 193 | label = Gtk.Label(group_label) 194 | label.props.name = "settings-group-label" 195 | label.props.halign = Gtk.Align.START 196 | label.props.margin_left = 4 197 | 198 | self.props.name = group_label 199 | self.props.halign = Gtk.Align.FILL 200 | self.props.hexpand = True 201 | self.props.row_spacing = 4 202 | self.props.can_focus = False 203 | self.attach(label, 0, 0, 1, 1) 204 | self.attach(frame, 0, 1, 1, 1) 205 | 206 | 207 | class SubSettings(Gtk.Grid): 208 | def __init__(self, type=None, name=None, label=None, sublabel=None, separator=True, params=None, utils=None, *args, **kwargs): 209 | super().__init__(*args, **kwargs) 210 | 211 | self.type = type 212 | 213 | # box--- 214 | box = Gtk.VBox() 215 | box.props.spacing = 2 216 | box.props.hexpand = True 217 | 218 | # label--- 219 | if label is not None: 220 | self.label_text = Gtk.Label(label) 221 | self.label_text.props.halign = Gtk.Align.START 222 | box.add(self.label_text) 223 | 224 | # sublabel--- 225 | if sublabel is not None: 226 | self.sublabel_text = Gtk.Label(sublabel) 227 | self.sublabel_text.props.halign = Gtk.Align.START 228 | self.sublabel_text.props.wrap_mode = Pango.WrapMode.WORD 229 | self.sublabel_text.props.max_width_chars = 30 230 | self.sublabel_text.props.justify = Gtk.Justification.LEFT 231 | #self.sublabel_text.props.wrap = True 232 | self.sublabel_text.get_style_context().add_class("settings-sub-label") 233 | box.add(self.sublabel_text) 234 | 235 | if type == "switch": 236 | self.switch = Gtk.Switch() 237 | self.switch.props.name = name 238 | self.switch.props.halign = Gtk.Align.END 239 | self.switch.props.valign = Gtk.Align.CENTER 240 | self.switch.props.hexpand = False 241 | self.attach(self.switch, 1, 0, 1, 2) 242 | 243 | if type == "spinbutton": 244 | self.spinbutton = Gtk.SpinButton().new_with_range(min=params[0], max=params[1], step=params[2]) 245 | self.spinbutton.props.name = name 246 | self.attach(self.spinbutton, 1, 0, 1, 2) 247 | 248 | if type == "button": 249 | if len(params) == 1: 250 | self.button = Gtk.Button(label=params[0]) 251 | else: 252 | self.button = Gtk.Button(label=params[0], image=params[1]) 253 | self.button.props.name = name 254 | self.button.props.hexpand = False 255 | self.button.props.always_show_image = True 256 | self.button.set_size_request(90, -1) 257 | if len(params) >1: 258 | label = [child for child in self.button.get_children()[0].get_child() if isinstance(child, Gtk.Label)][0] 259 | label.props.valign = Gtk.Align.CENTER 260 | self.attach(self.button, 1, 0, 1, 2) 261 | 262 | if type == "checkbutton": 263 | self.checkbutton = Gtk.CheckButton().new_with_label(params[0]) 264 | self.checkbutton.props.name = name 265 | self.attach(self.checkbutton, 0, 0, 1, 2) 266 | 267 | # separator --- 268 | if separator: 269 | row_separator = Gtk.Separator() 270 | row_separator.props.hexpand = True 271 | row_separator.props.valign = Gtk.Align.CENTER 272 | if type == None: 273 | self.attach(row_separator, 0, 0, 1, 1) 274 | else: 275 | self.attach(row_separator, 0, 2, 2, 1) 276 | 277 | # SubSettings construct--- 278 | self.props.name = name 279 | self.props.hexpand = True 280 | if type == None: 281 | self.attach(box, 0, 0, 1, 1) 282 | else: 283 | self.props.row_spacing = 8 284 | self.props.column_spacing = 10 285 | self.attach(box, 0, 0, 1, 2) -------------------------------------------------------------------------------- /src/updater_view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import os 5 | import gi 6 | from gi.repository import GLib 7 | gi.require_version('Gtk', '3.0') 8 | from gi.repository import Gtk, Pango, Gio 9 | 10 | 11 | #------------------CLASS-SEPARATOR------------------# 12 | 13 | 14 | class UpdaterView(Gtk.Grid): 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | #-- quickword logo --------# 19 | download_icon = Gtk.Image().new_from_icon_name("emblem-downloads", Gtk.IconSize.DIALOG) 20 | download_icon.props.halign = Gtk.Align.END 21 | download_icon.props.valign = Gtk.Align.START 22 | download_icon.props.expand = False 23 | download_icon.props.name = "download-icon" 24 | download_icon.get_style_context().add_class("quickword-icon-right") 25 | 26 | left_icon = Gtk.Image().new_from_icon_name("com.github.hezral.quickword-left", Gtk.IconSize.DIALOG) 27 | right_icon = Gtk.Image().new_from_icon_name("com.github.hezral.quickword-right", Gtk.IconSize.DIALOG) 28 | 29 | left_icon.set_pixel_size(96) 30 | right_icon.set_pixel_size(96) 31 | 32 | left_icon.props.expand = False 33 | right_icon.props.expand = False 34 | 35 | icon_overlay = Gtk.Overlay() 36 | icon_overlay.add(left_icon) 37 | icon_overlay.add_overlay(right_icon) 38 | icon_overlay.add_overlay(download_icon) 39 | icon_overlay.props.halign = Gtk.Align.CENTER 40 | icon_overlay.props.can_focus = True 41 | icon_overlay.props.focus_on_click = True 42 | icon_overlay.grab_focus() 43 | 44 | #-- message header --------# 45 | 46 | message = Gtk.Label() 47 | message.props.name = "message" 48 | message.props.margin_bottom = 5 49 | message.props.hexpand = True 50 | message.props.halign = Gtk.Align.CENTER 51 | message.props.valign = Gtk.Align.CENTER 52 | message.props.max_width_chars = 30 53 | message.props.wrap = True 54 | message.props.wrap_mode = Pango.WrapMode.WORD 55 | message.props.justify = Gtk.Justification.CENTER 56 | message.get_style_context().add_class("h2") 57 | 58 | sub_message = Gtk.Label() 59 | sub_message.props.name = "sub-message" 60 | sub_message.props.margin_bottom = 10 61 | sub_message.props.hexpand = True 62 | sub_message.props.halign = Gtk.Align.CENTER 63 | sub_message.props.valign = Gtk.Align.CENTER 64 | sub_message.props.max_width_chars = 50 65 | sub_message.props.wrap = True 66 | sub_message.props.wrap_mode = Pango.WrapMode.WORD 67 | sub_message.props.justify = Gtk.Justification.CENTER 68 | 69 | #-- proceed button --------# 70 | self.proceed_btn = Gtk.Button(label="Proceed") 71 | self.proceed_btn.props.name = "proceed-btn" 72 | self.proceed_btn.get_style_context().add_class("h3") 73 | self.proceed_btn.set_size_request(-1, 32) 74 | self.proceed_btn.connect("clicked", self.on_proceed_update) 75 | 76 | self.proceed_btn_revealer = Gtk.Revealer() 77 | self.proceed_btn_revealer.add(self.proceed_btn) 78 | self.proceed_btn_revealer.set_reveal_child(True) 79 | 80 | #-- start button --------# 81 | self.start_btn = Gtk.Button(label="Start Using Quickword") 82 | self.start_btn.props.name = "start-btn" 83 | self.start_btn.get_style_context().add_class("h3") 84 | self.start_btn.set_size_request(-1, 32) 85 | self.start_btn.connect("clicked", self.on_start) 86 | 87 | self.start_btn_revealer = Gtk.Revealer() 88 | self.start_btn_revealer.add(self.start_btn) 89 | 90 | #-- UpdaterView construct--------# 91 | self.props.name = "updater-view" 92 | self.get_style_context().add_class(self.props.name) 93 | self.set_size_request(350, -1) 94 | self.props.expand = True 95 | self.props.row_spacing = 12 96 | self.props.column_spacing = 6 97 | self.props.margin = 20 98 | self.props.valign = Gtk.Align.END 99 | self.attach(icon_overlay, 0, 1, 1, 1) 100 | self.attach(message, 0, 2, 1, 1) 101 | self.attach(sub_message, 0, 3, 1, 1) 102 | self.attach(self.proceed_btn_revealer, 0, 4, 1, 1) 103 | self.attach(self.start_btn_revealer, 0, 5, 1, 1) 104 | self.connect_after("realize", self.generate_message_str) 105 | 106 | def generate_message_str(self, view): 107 | 108 | stack = self.get_parent() 109 | window = stack.get_parent() 110 | app = window.props.application 111 | 112 | if app.first_run: 113 | message_str ="Hello!" 114 | sub_message_str = "On first run, dictionary download is required" 115 | sub_message_str = sub_message_str + "\n" + "Ensure internet connectivity before proceeding" 116 | else: 117 | message_str = "Check for Updates" 118 | sub_message_str = "Check for any updates to dictionary data" 119 | message = [child for child in self.get_children() if child.props.name == "message"][0] 120 | sub_message = [child for child in self.get_children() if child.props.name == "sub-message"][0] 121 | 122 | message.props.label = message_str 123 | sub_message.props.label = sub_message_str 124 | 125 | def on_start(self, button): 126 | stack = self.get_parent() 127 | window = stack.get_parent() 128 | app = window.props.application 129 | app.on_new_word_selected() 130 | 131 | def on_proceed_update(self, button): 132 | stack = self.get_parent() 133 | window = stack.get_parent() 134 | app = window.props.application 135 | icon_overlay = [child for child in self.get_children() if isinstance(child, Gtk.Overlay)][0] 136 | download_icon = [child for child in icon_overlay.get_children() if child.props.name == "download-icon"][0] 137 | 138 | download_icon.get_style_context().remove_class("quickword-icon-right") 139 | download_icon.get_style_context().add_class("download-icon-start") 140 | 141 | if app.first_run: 142 | app._data_manager.run_func(runname="download", callback=self.on_update_progress) 143 | gio_settings = Gio.Settings(schema_id="com.github.hezral.quickword") 144 | gio_settings.set_boolean("first-run", False) 145 | app.first_run = False 146 | else: 147 | run = app.generate_data_manager() 148 | while run is False: 149 | GLib.idle_add(self.on_update_progress, "One moment..", "Waiting for data manager") 150 | app._data_manager.run_func(runname="update", callback=self.on_update_progress) 151 | 152 | def on_update_progress(self, message_str, sub_message_str): 153 | message = [child for child in self.get_children() if child.props.name == "message"][0] 154 | sub_message = [child for child in self.get_children() if child.props.name == "sub-message"][0] 155 | 156 | if message_str == "Completed" or message_str == "Downloaded" or message_str == "No Updates": 157 | # start_btn = [child for child in self.get_children() if child.props.name == "start-btn"][0] 158 | # self.remove_row(4) 159 | # self.attach(start_btn, 0, 4, 1, 1) 160 | self.proceed_btn_revealer.set_reveal_child(False) 161 | self.start_btn_revealer.set_reveal_child(True) 162 | 163 | else: 164 | # proceed_btn = [child for child in self.get_children() if child.props.name == "proceed-btn"][0] 165 | self.proceed_btn.props.label = "Please wait.." 166 | 167 | message.props.label = message_str 168 | sub_message.props.label = sub_message_str 169 | 170 | 171 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | class HelperUtils: 5 | 6 | @staticmethod 7 | def run_async(func): 8 | ''' 9 | https://github.com/learningequality/ka-lite-gtk/blob/341813092ec7a6665cfbfb890aa293602fb0e92f/kalite_gtk/mainwindow.py 10 | http://code.activestate.com/recipes/576683-simple-threading-decorator/ 11 | run_async(func): 12 | function decorator, intended to make "func" run in a separate thread (asynchronously). 13 | Returns the created Thread object 14 | Example: 15 | @run_async 16 | def task1(): 17 | do_something 18 | @run_async 19 | def task2(): 20 | do_something_too 21 | ''' 22 | from threading import Thread 23 | from functools import wraps 24 | 25 | @wraps(func) 26 | def async_func(*args, **kwargs): 27 | func_hl = Thread(target=func, args=args, kwargs=kwargs) 28 | func_hl.start() 29 | # Never return anything, idle_add will think it should re-run the 30 | # function because it's a non-False value. 31 | return None 32 | 33 | return async_func 34 | 35 | @staticmethod 36 | def get_active_window_wm_class(): 37 | ''' Function to get active window ''' 38 | import Xlib 39 | import Xlib.display 40 | 41 | display = Xlib.display.Display() 42 | root = display.screen().root 43 | 44 | NET_ACTIVE_WINDOW = display.intern_atom('_NET_ACTIVE_WINDOW') 45 | WM_CLASS = display.intern_atom('WM_CLASS') 46 | 47 | root.change_attributes(event_mask=Xlib.X.FocusChangeMask) 48 | try: 49 | window_id = root.get_full_property(NET_ACTIVE_WINDOW, Xlib.X.AnyPropertyType).value[0] 50 | window = display.create_resource_object('window', window_id) 51 | try: 52 | return window.get_full_property(WM_CLASS, 0).value.replace(b'\x00',b' ').decode("utf-8").lower() 53 | except: 54 | return None 55 | except Xlib.error.XError: #simplify dealing with BadWindow 56 | return None -------------------------------------------------------------------------------- /src/word_lookup.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import os 5 | import gi 6 | from gi.repository import GLib 7 | 8 | # nltk imports 9 | from nltk import data 10 | from nltk.corpus import wordnet as wn 11 | 12 | # for ipa output of words by espeak 13 | import subprocess 14 | from shutil import which, Error 15 | 16 | class WordLookup(): 17 | def __init__(self, application_id="com.github.hezral.quickword", *args, **kwargs): 18 | 19 | # setup nltk data path 20 | nltk_data_path = os.path.join(GLib.get_user_data_dir(), application_id, 'nltk_data') 21 | data.path = [nltk_data_path] 22 | 23 | #check if espeak is installed 24 | try: 25 | self.espeak = which("espeak") 26 | except Error as error: 27 | print("espeak not installed") 28 | 29 | def get_synsets(self, word): 30 | 31 | # create list to store data to return 32 | # structure = word, pronounciation, synsets 33 | data_tuple = [] 34 | 35 | # lowercase the word 36 | word = word.lower() 37 | 38 | # remove spaces in front and back 39 | word = word.strip() 40 | 41 | # remove special characters in front of word 42 | FirstContainsSpecialChars = any(not c.isalnum() for c in word[0]) 43 | LastContainsSpecialChars = any(not c.isalnum() for c in word[len(word)-1]) 44 | 45 | if FirstContainsSpecialChars: 46 | word = word[1:] 47 | if LastContainsSpecialChars: 48 | word = word[0:-1] 49 | 50 | # get synsets for word 51 | # wordnet lookup can only contain letters, numbers, spaces, hyphens, periods, slashes, and/or apostrophes. 52 | # check if safe chars and try to get synsets first 53 | containsSafeChar = set(" -./'_") 54 | if any((c in containsSafeChar) for c in word): 55 | 56 | synsets = wn.synsets(word) 57 | # if none returns 58 | if len(synsets) == 0: 59 | word = word.translate({ord(c): " " for c in " -./'_"}).split(" ")[0] #replace safe chars with space and only get for first word 60 | 61 | synsets = wn.synsets(word) 62 | else: 63 | synsets = wn.synsets(word) 64 | 65 | # clean up word for display, remove any special characters 66 | containsSpecialChars = any(not c.isalnum() for c in word) 67 | if containsSpecialChars: 68 | _word = word.translate({ord(c): " " for c in "!@#$%^&*()[]{};:,./<>?\|`~-=_+"}) 69 | data_tuple.append(_word.title()) 70 | else: 71 | data_tuple.append(word.capitalize()) 72 | 73 | # get pronounciation 74 | try: 75 | run_espeak = subprocess.Popen([self.espeak, word, "-q", "--ipa"], stdout=subprocess.PIPE) 76 | stdout, stderr = run_espeak.communicate() 77 | pronounce = stdout.decode("utf-8").split("\n")[0].strip() 78 | except: 79 | pronounce = ["NA"] 80 | # add pronounciation to data list 81 | 82 | data_tuple.append(pronounce) 83 | 84 | # add to data list if there is any synset found 85 | if len(synsets) > 0: 86 | data_tuple.append(synsets) 87 | 88 | return data_tuple 89 | 90 | def get_totalwords(self): 91 | return len(wn._lemma_pos_offset_map) 92 | 93 | 94 | # wl = WordLookup() 95 | 96 | # print(type(wl)) 97 | 98 | # wl.get_synsets("test") 99 | 100 | # the lines is only for debug 101 | # def lookup(clipboard=None, event=None, wd=None): 102 | # content, valid = clipboard_listener.copy_selected_text(clipboard) 103 | # if content and valid: 104 | # results = wd.get_synsets(content) 105 | # #print(results) 106 | # from clipboard import ClipboardListener 107 | # wd = WordLookup() 108 | # clipboard_listener = ClipboardListener() 109 | # clipboard_listener.copy_selected_text() 110 | 111 | # the lines is only for debug 112 | # clipboard_listener.clipboard.connect("owner-change", lookup, wd) 113 | # import gi, signal 114 | # gi.require_version('Gtk', '3.0') 115 | # from gi.repository import Gtk, GLib 116 | # GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit) 117 | # Gtk.main() 118 | -------------------------------------------------------------------------------- /src/word_view.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-3.0-or-later 2 | # SPDX-FileCopyrightText: 2021 Adi Hezral 3 | 4 | import gi 5 | gi.require_version('Gtk', '3.0') 6 | from gi.repository import Gtk, Pango 7 | 8 | class WordView(Gtk.Grid): 9 | def __init__(self, *args, **kwargs): 10 | super().__init__(*args, **kwargs) 11 | 12 | self.clipboard_paste = None 13 | self.lookup_word = None 14 | 15 | self.stack = Gtk.Stack() 16 | self.stack.props.expand = True 17 | self.stack.props.transition_type = Gtk.StackTransitionType.CROSSFADE 18 | 19 | self.stack_switcher = Gtk.StackSwitcher() 20 | self.stack_switcher.props.homogeneous = True 21 | self.stack_switcher.props.stack = self.stack 22 | self.stack_switcher.get_style_context().add_class("subview-switcher") 23 | 24 | self.props.name = 'word-view' 25 | self.get_style_context().add_class(self.props.name) 26 | self.set_size_request(350, -1) 27 | self.props.expand = False 28 | self.attach(self.stack, 0, 0, 2, 1) 29 | self.attach(self.stack_switcher, 0, 1, 2, 1,) 30 | 31 | def on_wordlookup(self, button=None, data=None): 32 | 33 | self.lookup_word = data[0] 34 | synsets = data[2] 35 | 36 | stack = self.get_parent() 37 | window = stack.get_toplevel() 38 | window.pronounciation_label.props.label = "/ " + data[1] + " /" 39 | window.word_action_revealer.set_reveal_child(True) 40 | 41 | # delete all stack children if any 42 | if self.stack.get_children(): 43 | for child in self.stack.get_children(): 44 | self.stack.remove(child) 45 | 46 | # create list for each type of word 47 | noun_data = [] 48 | adjective_data = [] 49 | adverb_data = [] 50 | ver_data = [] 51 | 52 | # add data to each word list 53 | for synset in synsets: 54 | if synset.pos() == "n": 55 | noun_data.append(synset) 56 | if synset.pos() == "a" or synset.pos() == "s": 57 | adjective_data.append(synset) 58 | if synset.pos() == "r": 59 | adverb_data.append(synset) 60 | if synset.pos() == "v": 61 | ver_data.append(synset) 62 | 63 | # add word lists to dict 64 | word_list_dict = {} 65 | word_list_dict["noun"] = noun_data 66 | word_list_dict["adjective"] = adjective_data 67 | word_list_dict["adverb"] = adverb_data 68 | word_list_dict["verb"] = ver_data 69 | 70 | subviews = {} 71 | for type in word_list_dict: 72 | if len(word_list_dict[type]) > 0: 73 | subviews["{0}".format(type)] = WordSubView(name=type, word=self.lookup_word, contents=word_list_dict[type]) 74 | 75 | # add views to stack 76 | for view in subviews: 77 | # subviews[view].show_all() 78 | self.stack.add_titled(subviews[view], view, view) 79 | 80 | # style left and right tabs for stack switcher 81 | stack_count = len(self.stack_switcher.get_children()) 82 | 83 | left_tab = self.stack_switcher.get_children()[0] 84 | left_tab.get_style_context().add_class("word-types-left") 85 | right_tab = self.stack_switcher.get_children()[stack_count-1] 86 | right_tab.get_style_context().add_class("word-types-right") 87 | 88 | for child in self.stack_switcher.get_children(): 89 | child.get_style_context().add_class("word-types") 90 | child.props.can_focus = False 91 | 92 | self.stack.show_all() 93 | 94 | 95 | #------------------CLASS-SEPARATOR------------------# 96 | 97 | 98 | class WordSubView(Gtk.Grid): 99 | def __init__(self, name, word, contents, *args, **kwargs): 100 | super().__init__(*args, **kwargs) 101 | 102 | self.props.name = name 103 | self.props.hexpand = True 104 | self.props.row_spacing = 15 105 | self.props.column_spacing = 0 106 | 107 | scrolled_view = Gtk.ScrolledWindow() 108 | scrolled_view.props.expand = True 109 | # scrolled_view.connect("edge-reached", self.on_edge_reached) 110 | # scrolled_view.connect("edge-overshot", self.on_edge_overshot) 111 | 112 | scrolled_view_grid = Gtk.Grid() 113 | scrolled_view_grid.props.row_spacing = 10 114 | scrolled_view_grid.props.expand = True 115 | scrolled_view_grid.props.margin = 10 116 | scrolled_view_grid.props.margin_left = 20 117 | scrolled_view_grid.props.margin_right = 20 118 | 119 | i = 1 120 | for content in contents: 121 | scrolled_view_grid.attach(WordItems(word, content), 0, i, 1, 1) 122 | i += 1 123 | 124 | scrolled_view.add(scrolled_view_grid) 125 | self.attach(scrolled_view, 0, 1, 1, 1) 126 | 127 | def on_edge_overshot(self, scrolledwindow, position): 128 | # print(position.value_name) 129 | # stack = self.get_parent() 130 | # word_view = stack.get_parent() 131 | # print(word_view) 132 | ... 133 | 134 | def on_edge_reached(self, scrolledwindow, position): 135 | # print(position.value_name) 136 | # stack = self.get_parent() 137 | # word_view = stack.get_parent() 138 | # print(word_view) 139 | ... 140 | # if position.value_name == "GTK_POS_BOTTOM": 141 | # self.count_label.props.label = str(self.more_count) + " more results up.." 142 | # elif position.value_name == "GTK_POS_TOP": 143 | # self.count_label.props.label = str(self.more_count) + " more results below.." 144 | 145 | 146 | #------------------CLASS-SEPARATOR------------------# 147 | 148 | 149 | class WordItems(Gtk.Grid): 150 | def __init__(self, word, contents, multi=False, *args, **kwargs): 151 | super().__init__(*args, **kwargs) 152 | 153 | # synset item 154 | synset = contents 155 | 156 | #-- word definition -------# 157 | definition_str = synset.definition() 158 | 159 | word_definition = Gtk.Label(definition_str) 160 | word_definition.props.wrap = True 161 | word_definition.props.hexpand = True 162 | word_definition.props.wrap_mode = Pango.WrapMode.WORD 163 | word_definition.props.justify = Gtk.Justification.FILL 164 | word_definition.props.halign = Gtk.Align.START 165 | word_definition.props.valign = Gtk.Align.CENTER 166 | word_definition.get_style_context().add_class("word-definition") 167 | 168 | #-- word examples -------# 169 | examples = synset.examples() 170 | 171 | if len(examples) > 0: 172 | examples_str = '"' + examples[0] + '"' 173 | else: 174 | examples_str = "" 175 | 176 | word_examples = Gtk.Label(examples_str) 177 | word_examples.props.wrap = True 178 | word_examples.props.hexpand = True 179 | word_examples.props.wrap_mode = Pango.WrapMode.WORD 180 | word_examples.props.justify = Gtk.Justification.FILL 181 | word_examples.props.halign = Gtk.Align.START 182 | word_examples.props.valign = Gtk.Align.START 183 | word_examples.get_style_context().add_class("word-examples") 184 | 185 | # box for definition and examples 186 | word_box = Gtk.VBox() 187 | word_box.props.hexpand = True 188 | word_box.set_size_request(-1, 24) 189 | word_box.add(word_definition) 190 | if not examples_str == "": 191 | word_definition.props.valign = Gtk.Align.START 192 | word_box.add(word_examples) 193 | 194 | #-- lemmas (similar words) -------# 195 | lemmas = synset.lemma_names() 196 | lemmas.sort(key=len) 197 | 198 | # remove lemma that is same with lookup word 199 | for lemma in lemmas: 200 | if lemma.lower() == word.lower(): 201 | lemmas.remove(lemma) 202 | 203 | # grid to hold lemma till 5th item 204 | lemma_box = Gtk.Grid() 205 | lemma_box.props.column_spacing = 2 206 | lemma_box.props.row_spacing = 2 207 | lemma_box.props.hexpand = True 208 | lemma_box.props.halign = Gtk.Align.START 209 | 210 | # grid to hold lemma for 6th item onwards 211 | lemma_more_box = Gtk.Grid() 212 | lemma_more_box.props.margin_top = 10 213 | lemma_more_box.props.hexpand = False 214 | lemma_more_box.props.halign = Gtk.Align.START 215 | lemma_more_box.props.column_spacing = 2 216 | lemma_more_box.props.row_spacing = 4 217 | # encased into expander 218 | lemma_more_expander = Gtk.Expander() 219 | lemma_more_expander.props.margin_top = 3 220 | lemma_more_expander.add(lemma_more_box) 221 | 222 | # iterate through lemmas list 223 | i = j = k = 0 224 | charslen = 0 225 | charslenmore = 0 226 | if len(lemmas) > 1: 227 | for lemma in lemmas: 228 | # if text contains underscore and maybe other special characters 229 | # use lemma_label for cleaned string 230 | # use lemma_name for original string 231 | containsSpecialChars = any(not c.isalnum() for c in lemma) 232 | if containsSpecialChars: 233 | lemma_name = lemma.translate ({ord(c): " " for c in "!@#$%^&*()[]{};:,./<>?\|`~-=_+"}) 234 | else: 235 | lemma_name = lemma 236 | 237 | charslen = len(lemma) + charslen 238 | if i < 6: 239 | if charslen <= 70: 240 | lemma_box.attach(self.generate_lemma_buttons(lemma_label=lemma_name, lemma_name=lemmas[i]), i, 1, 1, 1) 241 | else: 242 | charslenmore = len(lemma) + charslenmore 243 | if charslenmore <= 70: 244 | lemma_more_box.attach(self.generate_lemma_buttons(lemma_label=lemma_name, lemma_name=lemmas[i]), j, 1, 1, 1) 245 | j += 1 246 | else: 247 | lemma_more_box.attach(self.generate_lemma_buttons(lemma_label=lemma_name, lemma_name=lemmas[i]), k, 2, 1, 1) #need to recalculate i from zero and offset 248 | k += 1 249 | i += 1 250 | 251 | # add margin if lemma_more_box is present 252 | if len(lemma_more_box.get_children()) >= 1: 253 | lemma_box.props.margin_left = 16 254 | 255 | #-- copy action -------# 256 | copy_img = Gtk.Image().new_from_icon_name("edit-copy-symbolic", Gtk.IconSize.SMALL_TOOLBAR) 257 | copy_img.props.no_show_all = True 258 | copy_img.props.hexpand = True 259 | copy_img.props.halign = Gtk.Align.END 260 | copy_img.props.valign = Gtk.Align.END 261 | copy_img.get_style_context().add_class("transition-on") 262 | copy_img.get_style_context().add_class("copy-img") 263 | 264 | # copied notification 265 | copied_img = Gtk.Image().new_from_icon_name("emblem-default", Gtk.IconSize.SMALL_TOOLBAR) 266 | copied_img.props.no_show_all = True 267 | copied_label = Gtk.Label("Copied to clipboard") 268 | copied_label.props.no_show_all = True 269 | 270 | copied_grid = Gtk.Grid() 271 | copied_grid.props.column_spacing = 4 272 | copied_grid.props.halign = Gtk.Align.END 273 | copied_grid.props.valign = Gtk.Align.END 274 | copied_grid.props.hexpand = True 275 | copied_grid.get_style_context().add_class("transition-on") 276 | copied_grid.get_style_context().add_class("copied-content") 277 | copied_grid.attach(copied_img, 0, 1, 1, 1) 278 | copied_grid.attach(copied_label, 1, 1, 1, 1) 279 | 280 | # workaround to avoid weird issue with enter and notify events after adding to the eventbox 281 | # put all in a grid, then put in eventbox. overlay didn't work 282 | content_grid = Gtk.Grid() 283 | content_grid.props.row_spacing = 0 284 | content_grid.props.hexpand = True 285 | content_grid.attach(copy_img, 0, 1, 1, 2) 286 | content_grid.attach(copied_grid, 0, 1, 1, 2) 287 | content_grid.attach(word_box, 0, 1, 1, 1) 288 | 289 | #-- eventbox -------# 290 | content_eventbox = Gtk.EventBox() 291 | content_eventbox.add(content_grid) 292 | 293 | # list items to pass to eventbox events 294 | eventbox_params = (copy_img, copied_label, copied_img, copied_grid, word, word_definition, word_examples, word_box, lemma_box) 295 | 296 | content_eventbox.connect("enter-notify-event", self.on_enter_content_box, eventbox_params) 297 | content_eventbox.connect("leave-notify-event", self.on_leave_content_box, eventbox_params) 298 | content_eventbox.connect("button-press-event", self.on_copy_content_clicked, eventbox_params) 299 | 300 | #-- WordItems construct--------# 301 | # self.props.name = name 302 | self.props.hexpand = True 303 | self.props.row_spacing = 2 304 | self.props.column_spacing = 0 305 | self.attach(content_eventbox, 0, 1, 5, 1) 306 | 307 | if len(lemma_more_box) >= 1: 308 | self.attach(lemma_more_expander, 0, 2, 5, 1) 309 | self.attach(lemma_box, 1, 2, 1, 1) 310 | else: 311 | self.attach(lemma_box, 0, 2, 1, 1) 312 | 313 | def generate_lemma_buttons(self, lemma_label, lemma_name): 314 | button = Gtk.Button(label=lemma_label) 315 | button.props.name = lemma_name 316 | button.props.expand = False 317 | button.get_style_context().add_class("word-lemmas") 318 | button.connect("clicked", self.on_lemma_clicked) 319 | return button 320 | 321 | def on_lemma_clicked(self, button): 322 | wordview = self.get_wordview() 323 | stack = wordview.get_parent() 324 | window = stack.get_parent() 325 | app = window.props.application 326 | app.on_new_word_lookup(button.props.name) 327 | 328 | def generate_separator(self): 329 | separator = Gtk.Separator() 330 | separator.props.hexpand = True 331 | separator.props.valign = Gtk.Align.CENTER 332 | return separator 333 | 334 | def on_enter_content_box(self, eventbox, event, widget_list): 335 | copy_img = widget_list[0] 336 | copied_grid = widget_list[3] 337 | word_box = widget_list[7] 338 | 339 | # show widget and set flags to trigger transition effect, see application.css 340 | copy_img.show() 341 | copy_img.set_state_flags(Gtk.StateFlags.PRELIGHT, True) 342 | 343 | # set flags to ready state for transition effect, see application.css 344 | copied_grid.set_state_flags(Gtk.StateFlags.DIR_LTR, True) 345 | 346 | # add styling for hover effect 347 | word_box.get_children()[0].get_style_context().add_class("word-hover") 348 | try: 349 | word_box.get_children()[1].get_style_context().remove_class("word-examples") 350 | word_box.get_children()[1].get_style_context().add_class("word-examples-hover") 351 | except: 352 | pass 353 | 354 | def on_leave_content_box(self, eventbox, event, widget_list): 355 | copy_img = widget_list[0] 356 | copied_grid = widget_list[3] 357 | word_box = widget_list[7] 358 | 359 | # reset state flagss to ready state for transition effect, see application.css 360 | copy_img.set_state_flags(Gtk.StateFlags.DIR_LTR, True) 361 | 362 | # reset state flags to ready state for transition effect, see application.css 363 | # grid can stay as show state since only toggle widget hide/shows 364 | copied_grid.set_state_flags(Gtk.StateFlags.DIR_LTR, True) 365 | 366 | # remove styling for hover effect 367 | word_box.get_children()[0].get_style_context().remove_class("word-hover") 368 | try: 369 | word_box.get_children()[1].get_style_context().remove_class("word-examples-hover") 370 | word_box.get_children()[1].get_style_context().add_class("word-examples") 371 | except: 372 | pass 373 | 374 | def on_copy_content_clicked(self, eventbox, event, widget_list): 375 | copy_img = widget_list[0] 376 | copied_label = widget_list[1] 377 | copied_img = widget_list[2] 378 | copied_grid = widget_list[3] 379 | word = widget_list[4] 380 | word_definition = widget_list[5] 381 | word_example = widget_list[6] 382 | word_lemmas = widget_list[8] 383 | word_lemmas_list = [] 384 | if len(word_lemmas.get_children()) > 0: 385 | for child in word_lemmas.get_children(): 386 | word_lemmas_list.append(child.props.label) 387 | 388 | # reset state flagss to ready state for transition effect, see application.css 389 | copy_img.set_state_flags(Gtk.StateFlags.DIR_LTR, True) 390 | 391 | # show widgets 392 | copied_label.show() 393 | copied_img.show() 394 | 395 | # set flags to trigger transition effect, see application.css 396 | copied_grid.set_state_flags(Gtk.StateFlags.PRELIGHT, True) 397 | 398 | # callback to copy content to clipboard 399 | clipboard_paste = self.get_wordview().clipboard_paste 400 | to_copy = "Word: " + word + "\n" 401 | to_copy = to_copy + "Pronounciation: " + self.get_wordview().get_toplevel().pronounciation_label.props.label + "\n" 402 | to_copy = to_copy + "Definition: " + word_definition.props.label + "\n" 403 | to_copy = to_copy + "Example: " + word_example.props.label + "\n" 404 | to_copy = to_copy + "Synonyms/Related: " + ", ".join(word_lemmas_list) 405 | clipboard_paste.copy_to_clipboard(to_copy) 406 | 407 | def get_wordview(self): 408 | worditem = self 409 | # if not in scrolled view 410 | if isinstance(worditem.get_parent(), WordSubView): 411 | wordsubview = worditem.get_parent() #WordSubView 412 | stack = wordsubview.get_parent() #GtkStack 413 | wordview = stack.get_parent() #WordView 414 | # in scrolled view 415 | else: 416 | worditemgrid = worditem.get_parent() #GtkGrid 417 | viewport = worditemgrid.get_parent() #GtkViewport 418 | scrolledwindow = viewport.get_parent() #GtkScrolledWindow 419 | wordsubview = scrolledwindow.get_parent() #WordSubView 420 | stack = wordsubview.get_parent() #GtkStack 421 | wordview = stack.get_parent() #WordView 422 | return wordview 423 | --------------------------------------------------------------------------------