├── setup.cfg ├── MANIFEST.in ├── .gitignore ├── LICENSE ├── setup.py ├── README.rst └── rofi.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # Vim. 59 | *.swp 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Blair Bonnett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # python-rofi 3 | # 4 | # The MIT License 5 | # 6 | # Copyright (c) 2017 Blair Bonnett 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | # 26 | 27 | from setuptools import setup 28 | 29 | with open('README.rst', 'r') as f: 30 | long_description = f.read() 31 | 32 | setup( 33 | name='python-rofi', 34 | description='Create simple GUIs using the Rofi application', 35 | long_description=long_description, 36 | version='1.0.1', 37 | author='Blair Bonnett', 38 | author_email='blair.bonnett@gmail.com', 39 | license='MIT', 40 | url='https://github.com/bcbnz/python-rofi', 41 | zip_safe=True, 42 | classifiers=[ 43 | 'License :: OSI Approved :: MIT License', 44 | 'Programming Language :: Python', 45 | 'Development Status :: 5 - Production/Stable', 46 | ], 47 | py_modules=['rofi'], 48 | ) 49 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | python-rofi 3 | =========== 4 | 5 | A Python module to make simple GUIs using Rofi. 6 | 7 | 8 | What is Rofi? 9 | ============= 10 | 11 | Rofi_ is a popup window switcher with minimal dependencies. Its basic operation 12 | is to display a list of options and let the user pick one. The following 13 | screenshot is shamelessly hotlinked from the Rofi website (which you should 14 | probably visit if you want actual details about Rofi!) and shows it being used 15 | by the teiler_ screenshot application. 16 | 17 | .. image:: https://davedavenport.github.io/rofi/images/rofi/dmenu-replacement.png 18 | :alt: A screenshot of the teiler application using Rofi. 19 | 20 | .. _Rofi: https://davedavenport.github.io/rofi/ 21 | 22 | .. _teiler: https://carnager.github.io/teiler/ 23 | 24 | 25 | What is this module? 26 | ==================== 27 | 28 | It simplifies making simple GUIs using Rofi. It provides a class with a number 29 | of methods for various GUI actions (show messages, pick one of these options, 30 | enter some text / a number). These are translated to the appropriate Rofi 31 | command line options, and then the standard subprocess_ module is used to run 32 | Rofi. Any output is then processed and returned to you to do whatever you like 33 | with. 34 | 35 | .. _subprocess: https://docs.python.org/3/library/subprocess.html 36 | 37 | 38 | Examples 39 | -------- 40 | 41 | Data entry 42 | ~~~~~~~~~~ 43 | 44 | The simplest example is to create a Rofi instance and prompt the user to enter 45 | a piece of text:: 46 | 47 | from rofi import Rofi 48 | r = Rofi() 49 | name = r.text_entry('What is your name? ') 50 | 51 | There are also entry methods for integers, floating-point numbers, and decimal 52 | numbers:: 53 | 54 | age = r.integer_entry('How old are you? ') 55 | height = r.float_entry('How tall are you? ') 56 | price = r.decimal_entry('How much are you willing to spend? ') 57 | 58 | All of these return the corresponding Python type. Dates and times can also be 59 | requested:: 60 | 61 | dob = r.date_entry('What is your date of birth? ') 62 | start = r.time_entry('When do you start work? ') 63 | reminder = r.datetime_entry('When do you want to be alerted? ') 64 | 65 | Again, these return the corresponding Python type. By default, they expect the 66 | user to enter something in the appropriate format for the current locale. You 67 | can override this by providing a list of format specifiers to any of these 68 | functions. The available specifiers are detailed in the Python documentation 69 | for the datetime_ module. For example:: 70 | 71 | start = r.time_entry('When do you start work? ', formats=['%H:%M']) 72 | 73 | All of these entry methods are specialisations of the ``generic_entry()`` 74 | method. You can use this to create your own entry types. All you need to do is 75 | create a validator function which takes the text entered by the user, and 76 | returns either the Python object or an error message. For example, to enforce a 77 | minimum length on an entered piece of text:: 78 | 79 | validator = lambda s: (s, None) if len(s) > 6 else (None, "Too short!") 80 | r.generic_entry('Enter a 7-character or longer string: ', validator) 81 | 82 | Note that all of these methods return ``None`` if the dialog is cancelled. 83 | 84 | .. _datetime: https://docs.python.org/3/library/datetime.html 85 | 86 | Errors 87 | ~~~~~~ 88 | 89 | To show an error message to the user:: 90 | 91 | r.error('I cannot let you do that.') 92 | r.exit_with_error('I cannot let you do that.') 93 | 94 | The latter shows the error message and then exits. 95 | 96 | Selections 97 | ~~~~~~~~~~ 98 | 99 | To give the user a list of things to select from, and return the index of the 100 | option they chose:: 101 | 102 | options = ['Red', 'Green', 'Blue', 'White', 'Silver', 'Black', 'Other'] 103 | index, key = r.select('What colour car do you drive?', options) 104 | 105 | The returned ``key`` value tells you what key the user pressed. For Enter, the 106 | value is 0, while -1 indicates they cancelled the dialog. You can also specify 107 | custom key bindings:: 108 | 109 | index, key = r.select('What colour car do you drive?', options, key5=('Alt+n', "I don't drive")) 110 | 111 | In this case, the returned ``key`` will be 5 if they press Alt+n. 112 | 113 | Status 114 | ~~~~~~ 115 | 116 | To display a status message to the user:: 117 | 118 | r.status("I'm working on that...") 119 | 120 | This is the only non-blocking method (all the others wait for the user to 121 | finish before returning control to your script). To close the status message:: 122 | 123 | r.close() 124 | 125 | Calling a display or entry method will also close any status message currently 126 | displayed. 127 | 128 | Messages 129 | ~~~~~~~~ 130 | 131 | Any of the entry methods and the select method have an optional argument 132 | ``message``. This is a string which is displayed below the prompt. The string 133 | can contain Pango_ markup:: 134 | 135 | r.text_entry('What are your goals for this year? ', message='Be bold!') 136 | 137 | If you need to escape a string to avoid it being mistaken for markup, use the 138 | ``Rofi.escape()`` class method:: 139 | 140 | msg = Rofi.escape('Format: ') 141 | r.text_entry('Enter your name: ', message=msg) 142 | 143 | .. _Pango: https://developer.gnome.org/pango/stable/PangoMarkupFormat.html 144 | 145 | Customisation 146 | ~~~~~~~~~~~~~ 147 | 148 | There are a number of options available to customise the display. These can be 149 | set in the initialiser to apply to every dialog displayed, or you can pass them 150 | to any of the display methods to change just that dialog. See the Rofi 151 | documentation for full details of these parameters. 152 | 153 | * ``lines``: The maximum number of lines to show before scrolling. 154 | 155 | * ``fixed_lines``: Keep a fixed number of lines visible. 156 | 157 | * ``width``: If positive but not more than 100, this is the percentage of the 158 | screen's width the window takes up. If greater than 100, it is the width in 159 | pixels. If negative, it estimates the width required for the corresponding 160 | number of characters, i.e., -30 would set the width so approximately 30 161 | characters per row would show. 162 | 163 | * ``fullscreen``: If True, use the full height and width of the screen. 164 | 165 | * ``location``: The position of the window on the screen. 166 | 167 | * You can also pass in arbitrary arguments to rofi through the ``rofi_args`` 168 | parameter. These have to be passed in as a list of strings, with every 169 | argument in a seperate string. For example, to make a selection case 170 | insensitive:: 171 | 172 | r = Rofi() 173 | r.select('Choose one', ['option 1', 'option 2', 'option 3'], 174 | rofi_args=['-i']) 175 | 176 | or, to choose a different style for an instance of ``Rofi``:: 177 | 178 | r = Rofi(rofi_args=['-theme', 'path/to/theme.rasi']) 179 | r.status('Stuff is happening, please wait...') 180 | 181 | 182 | 183 | 184 | Requirements 185 | ============ 186 | 187 | You need to have the ``rofi`` executable available on the system path (i.e., 188 | install Rofi!). Everything else that python-rofi needs is provided by the 189 | Python standard libraries. 190 | 191 | 192 | What Python versions are supported? 193 | =================================== 194 | 195 | It *should* work with any version of Python from 2.7 onwards. It may work with 196 | older versions, though no specific support for them will be added. It is 197 | developed on Python 2.7 and Python 3.6 -- the latest versions of the Python 2 198 | and 3 branches respectively. 199 | 200 | 201 | What license does it use? 202 | ========================= 203 | 204 | The MIT license, the same as Rofi itself. 205 | 206 | 207 | Bug reports 208 | =========== 209 | 210 | The project is developed on GitHub_. Please file any bug reports or feature 211 | requests on the Issues_ page there. 212 | 213 | .. _GitHub: https://github.com/bcbnz/python-rofi 214 | .. _Issues: https://github.com/bcbnz/python-rofi/issues 215 | -------------------------------------------------------------------------------- /rofi.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # The MIT License 4 | # 5 | # Copyright (c) 2016, 2017 Blair Bonnett 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | # 25 | 26 | import atexit 27 | from datetime import datetime 28 | from decimal import Decimal, InvalidOperation 29 | import signal 30 | import subprocess 31 | import time 32 | 33 | 34 | # Python < 3.2 doesn't provide a context manager interface for Popen. 35 | # Let's make our own wrapper if needed. 36 | if hasattr(subprocess.Popen, '__exit__'): 37 | Popen = subprocess.Popen 38 | else: 39 | class ContextManagedPopen(subprocess.Popen): 40 | def __enter__(self): 41 | return self 42 | 43 | def __exit__(self, type, value, traceback): 44 | if self.stdout: 45 | self.stdout.close() 46 | if self.stderr: 47 | self.stderr.close() 48 | if self.stdin: 49 | self.stdin.close() 50 | self.wait() 51 | Popen = ContextManagedPopen 52 | 53 | 54 | class Rofi(object): 55 | """Class to facilitate making simple GUIs with Rofi. 56 | 57 | Rofi is a popup window system with minimal dependencies (xlib and pango). 58 | It was designed as a window switcher. Its basic operation is to display a 59 | list of options and let the user pick one. 60 | 61 | This class provides a set of methods to make simple GUIs with Rofi. It does 62 | this by using the subprocess module to call Rofi externally. Many of the 63 | methods are blocking. 64 | 65 | Some strings can contain Pango markup for additional formatting (those that 66 | can are noted as such in the docstrings). Any text in these strings *must* 67 | be escaped before calling Rofi. The class method Rofi.escape() performs 68 | this escaping for you. Make sure you call this on the text prior to adding 69 | Pango markup, otherwise the markup will be escaped and displayed to the 70 | user. See https://developer.gnome.org/pango/stable/PangoMarkupFormat.html 71 | for available markup. 72 | 73 | """ 74 | def __init__(self, lines=None, fixed_lines=None, width=None, 75 | fullscreen=None, location=None, 76 | exit_hotkeys=('Alt+F4', 'Control+q'), rofi_args=None): 77 | """ 78 | Parameters 79 | ---------- 80 | exit_hotkeys: tuple of strings 81 | Hotkeys to use to exit the application. These will be automatically 82 | set and handled in any method which takes hotkey arguments. If one 83 | of these hotkeys is pressed, a SystemExit will be raised to perform 84 | the exit. 85 | 86 | The following parameters set default values for various layout options, 87 | and can be overwritten in any display method. A value of None means 88 | use the system default, which may be set by a configuration file or 89 | fall back to the compile-time default. See the Rofi documentation for 90 | full details on what the values mean. 91 | 92 | lines: positive integer 93 | The maximum number of lines to show before scrolling. 94 | fixed_lines: positive integer 95 | Keep a fixed number of lines visible. 96 | width: real 97 | If positive but not more than 100, this is the percentage of the 98 | screen's width the window takes up. If greater than 100, it is the 99 | width in pixels. If negative, it estimates the width required for 100 | the corresponding number of characters, i.e., -30 would set the 101 | width so ~30 characters per row would show. 102 | fullscreen: boolean 103 | If True, use the full height and width of the screen. 104 | location: integer 105 | The position of the window on the screen. 106 | rofi_args: list 107 | A list of other arguments to pass in to every call to rofi. These get appended 108 | after any other arguments 109 | 110 | """ 111 | # The Popen class returned for any non-blocking windows. 112 | self._process = None 113 | 114 | # Save parameters. 115 | self.lines = lines 116 | self.fixed_lines = fixed_lines 117 | self.width = width 118 | self.fullscreen = fullscreen 119 | self.location = location 120 | self.exit_hotkeys = exit_hotkeys 121 | self.rofi_args = rofi_args or [] 122 | 123 | # Don't want a window left on the screen if we exit unexpectedly 124 | # (e.g., an unhandled exception). 125 | atexit.register(self.close) 126 | 127 | 128 | @classmethod 129 | def escape(self, string): 130 | """Escape a string for Pango markup. 131 | 132 | Parameters 133 | ---------- 134 | string: 135 | A piece of text to escape. 136 | 137 | Returns 138 | ------- 139 | The text, safe for use in with Pango markup. 140 | 141 | """ 142 | # Escape ampersands first, then other entities. Since argument is a 143 | # dictionary, we can't guarantee order of translations and so doing it 144 | # in one go would risk the ampersands in other translations being 145 | # escaped again. 146 | return string.translate( 147 | {38: '&'} 148 | ).translate({ 149 | 34: '"', 150 | 39: ''', 151 | 60: '<', 152 | 62: '>' 153 | }) 154 | 155 | 156 | 157 | def close(self): 158 | """Close any open window. 159 | 160 | Note that this only works with non-blocking methods. 161 | 162 | """ 163 | if self._process: 164 | # Be nice first. 165 | self._process.send_signal(signal.SIGINT) 166 | 167 | # If it doesn't close itself promptly, be brutal. 168 | # Python 3.2+ added the timeout option to wait() and the 169 | # corresponding TimeoutExpired exception. If they exist, use them. 170 | if hasattr(subprocess, 'TimeoutExpired'): 171 | try: 172 | self._process.wait(timeout=1) 173 | except subprocess.TimeoutExpired: 174 | self._process.send_signal(signal.SIGKILL) 175 | 176 | # Otherwise, roll our own polling loop. 177 | else: 178 | # Give it 1s, checking every 10ms. 179 | count = 0 180 | while count < 100: 181 | if self._process.poll() is not None: 182 | break 183 | time.sleep(0.01) 184 | 185 | # Still hasn't quit. 186 | if self._process.poll() is None: 187 | self._process.send_signal(signal.SIGKILL) 188 | 189 | # Clean up. 190 | self._process = None 191 | 192 | 193 | def _run_blocking(self, args, input=None): 194 | """Internal API: run a blocking command with subprocess. 195 | 196 | This closes any open non-blocking dialog before running the command. 197 | 198 | Parameters 199 | ---------- 200 | args: Popen constructor arguments 201 | Command to run. 202 | input: string 203 | Value to feed to the stdin of the process. 204 | 205 | Returns 206 | ------- 207 | (returncode, stdout) 208 | The exit code (integer) and stdout value (string) from the process. 209 | 210 | """ 211 | # Close any existing dialog. 212 | if self._process: 213 | self.close() 214 | 215 | # Make sure we grab stdout as text (not bytes). 216 | kwargs = {} 217 | kwargs['stdout'] = subprocess.PIPE 218 | kwargs['universal_newlines'] = True 219 | 220 | # Use the run() method if available (Python 3.5+). 221 | if hasattr(subprocess, 'run'): 222 | result = subprocess.run(args, input=input, **kwargs) 223 | return result.returncode, result.stdout 224 | 225 | # Have to do our own. If we need to feed stdin, we must open a pipe. 226 | if input is not None: 227 | kwargs['stdin'] = subprocess.PIPE 228 | 229 | # Start the process. 230 | with Popen(args, **kwargs) as proc: 231 | # Talk to it (no timeout). This will wait until termination. 232 | stdout, stderr = proc.communicate(input) 233 | 234 | # Find out the return code. 235 | returncode = proc.poll() 236 | 237 | # Done. 238 | return returncode, stdout 239 | 240 | 241 | def _run_nonblocking(self, args, input=None): 242 | """Internal API: run a non-blocking command with subprocess. 243 | 244 | This closes any open non-blocking dialog before running the command. 245 | 246 | Parameters 247 | ---------- 248 | args: Popen constructor arguments 249 | Command to run. 250 | input: string 251 | Value to feed to the stdin of the process. 252 | 253 | """ 254 | # Close any existing dialog. 255 | if self._process: 256 | self.close() 257 | 258 | # Start the new one. 259 | self._process = subprocess.Popen(args, stdout=subprocess.PIPE) 260 | 261 | 262 | def _common_args(self, allow_fullscreen=True, **kwargs): 263 | args = [] 264 | 265 | # Number of lines. 266 | lines = kwargs.get('lines', self.lines) 267 | if lines: 268 | args.extend(['-lines', str(lines)]) 269 | fixed_lines = kwargs.get('fixed_lines', self.fixed_lines) 270 | if fixed_lines: 271 | args.extend(['-fixed-num-lines', str(fixed_lines)]) 272 | 273 | # Width. 274 | width = kwargs.get('width', self.width) 275 | if width is not None: 276 | args.extend(['-width', str(width)]) 277 | 278 | # Fullscreen mode? 279 | fullscreen = kwargs.get('fullscreen', self.fullscreen) 280 | if allow_fullscreen and fullscreen: 281 | args.append('-fullscreen') 282 | 283 | # Location on screen. 284 | location = kwargs.get('location', self.location) 285 | if location is not None: 286 | args.extend(['-location', str(location)]) 287 | 288 | # Any other arguments 289 | args.extend(self.rofi_args) 290 | 291 | # Done. 292 | return args 293 | 294 | 295 | def error(self, message, rofi_args=None, **kwargs): 296 | """Show an error window. 297 | 298 | This method blocks until the user presses a key. 299 | 300 | Fullscreen mode is not supported for error windows, and if specified 301 | will be ignored. 302 | 303 | Parameters 304 | ---------- 305 | message: string 306 | Error message to show. 307 | 308 | """ 309 | rofi_args = rofi_args or [] 310 | # Generate arguments list. 311 | args = ['rofi', '-e', message] 312 | args.extend(self._common_args(allow_fullscreen=False, **kwargs)) 313 | args.extend(rofi_args) 314 | 315 | # Close any existing window and show the error. 316 | self._run_blocking(args) 317 | 318 | 319 | def status(self, message, rofi_args=None, **kwargs): 320 | """Show a status message. 321 | 322 | This method is non-blocking, and intended to give a status update to 323 | the user while something is happening in the background. 324 | 325 | To close the window, either call the close() method or use any of the 326 | display methods to replace it with a different window. 327 | 328 | Fullscreen mode is not supported for status messages and if specified 329 | will be ignored. 330 | 331 | Parameters 332 | ---------- 333 | message: string 334 | Progress message to show. 335 | 336 | """ 337 | rofi_args = rofi_args or [] 338 | # Generate arguments list. 339 | args = ['rofi', '-e', message] 340 | args.extend(self._common_args(allow_fullscreen=False, **kwargs)) 341 | args.extend(rofi_args) 342 | 343 | # Update the status. 344 | self._run_nonblocking(args) 345 | 346 | 347 | def select(self, prompt, options, rofi_args=None, message="", select=None, **kwargs): 348 | """Show a list of options and return user selection. 349 | 350 | This method blocks until the user makes their choice. 351 | 352 | Parameters 353 | ---------- 354 | prompt: string 355 | The prompt telling the user what they are selecting. 356 | options: list of strings 357 | The options they can choose from. Any newline characters are 358 | replaced with spaces. 359 | message: string, optional 360 | Message to show between the prompt and the options. This can 361 | contain Pango markup, and any text content should be escaped. 362 | select: integer, optional 363 | Set which option is initially selected. 364 | keyN: tuple (string, string); optional 365 | Custom key bindings where N is one or greater. The first entry in 366 | the tuple should be a string defining the key, e.g., "Alt+x" or 367 | "Delete". Note that letter keys should be lowercase ie.e., Alt+a 368 | not Alt+A. 369 | 370 | The second entry should be a short string stating the action the 371 | key will take. This is displayed to the user at the top of the 372 | dialog. If None or an empty string, it is not displayed (but the 373 | binding is still set). 374 | 375 | By default, key1 through key9 are set to ("Alt+1", None) through 376 | ("Alt+9", None) respectively. 377 | 378 | Returns 379 | ------- 380 | tuple (index, key) 381 | The index of the option the user selected, or -1 if they cancelled 382 | the dialog. 383 | Key indicates which key was pressed, with 0 being 'OK' (generally 384 | Enter), -1 being 'Cancel' (generally escape), and N being custom 385 | key N. 386 | 387 | """ 388 | rofi_args = rofi_args or [] 389 | # Replace newlines and turn the options into a single string. 390 | optionstr = '\n'.join(option.replace('\n', ' ') for option in options) 391 | 392 | # Set up arguments. 393 | args = ['rofi', '-dmenu', '-p', prompt, '-format', 'i'] 394 | if select is not None: 395 | args.extend(['-selected-row', str(select)]) 396 | 397 | # Key bindings to display. 398 | display_bindings = [] 399 | 400 | # Configure the key bindings. 401 | user_keys = set() 402 | for k, v in kwargs.items(): 403 | # See if the keyword name matches the needed format. 404 | if not k.startswith('key'): 405 | continue 406 | try: 407 | keynum = int(k[3:]) 408 | except ValueError: 409 | continue 410 | 411 | # Add it to the set. 412 | key, action = v 413 | user_keys.add(keynum) 414 | args.extend(['-kb-custom-{0:s}'.format(k[3:]), key]) 415 | if action: 416 | display_bindings.append("{0:s}: {1:s}".format(key, action)) 417 | 418 | # And the global exit bindings. 419 | exit_keys = set() 420 | next_key = 10 421 | for key in self.exit_hotkeys: 422 | while next_key in user_keys: 423 | next_key += 1 424 | exit_keys.add(next_key) 425 | args.extend(['-kb-custom-{0:d}'.format(next_key), key]) 426 | next_key += 1 427 | 428 | # Add any displayed key bindings to the message. 429 | message = message or "" 430 | if display_bindings: 431 | message += "\n" + " ".join(display_bindings) 432 | message = message.strip() 433 | 434 | # If we have a message, add it to the arguments. 435 | if message: 436 | args.extend(['-mesg', message]) 437 | 438 | # Add in common arguments. 439 | args.extend(self._common_args(**kwargs)) 440 | args.extend(rofi_args) 441 | 442 | # Run the dialog. 443 | returncode, stdout = self._run_blocking(args, input=optionstr) 444 | 445 | # Figure out which option was selected. 446 | stdout = stdout.strip() 447 | index = int(stdout) if stdout else -1 448 | 449 | # And map the return code to a key. 450 | if returncode == 0: 451 | key = 0 452 | elif returncode == 1: 453 | key = -1 454 | elif returncode > 9: 455 | key = returncode - 9 456 | if key in exit_keys: 457 | raise SystemExit() 458 | else: 459 | self.exit_with_error("Unexpected rofi returncode {0:d}.".format(results.returncode)) 460 | 461 | # And return. 462 | return index, key 463 | 464 | 465 | def generic_entry(self, prompt, validator=None, message=None, rofi_args=None, **kwargs): 466 | """A generic entry box. 467 | 468 | Parameters 469 | ---------- 470 | prompt: string 471 | Text prompt for the entry. 472 | validator: function, optional 473 | A function to validate and convert the value entered by the user. 474 | It should take one parameter, the string that the user entered, and 475 | return a tuple (value, error). The value should be the users entry 476 | converted to the appropriate Python type, or None if the entry was 477 | invalid. The error message should be a string telling the user what 478 | was wrong, or None if the entry was valid. The prompt will be 479 | re-displayed to the user (along with the error message) until they 480 | enter a valid value. If no validator is given, the text that the 481 | user entered is returned as-is. 482 | message: string 483 | Optional message to display under the entry. 484 | 485 | Returns 486 | ------- 487 | The value returned by the validator, or None if the dialog was 488 | cancelled. 489 | 490 | Examples 491 | -------- 492 | Enforce a minimum entry length: 493 | >>> r = Rofi() 494 | >>> validator = lambda s: (s, None) if len(s) > 6 else (None, "Too short") 495 | >>> r.generic_entry('Enter a 7-character or longer string: ', validator) 496 | 497 | """ 498 | error = "" 499 | rofi_args = rofi_args or [] 500 | 501 | # Keep going until we get something valid. 502 | while True: 503 | args = ['rofi', '-dmenu', '-p', prompt, '-format', 's'] 504 | 505 | # Add any error to the given message. 506 | msg = message or "" 507 | if error: 508 | msg = '{0:s}\n{1:s}'.format(error, msg) 509 | msg = msg.rstrip('\n') 510 | 511 | # If there is actually a message to show. 512 | if msg: 513 | args.extend(['-mesg', msg]) 514 | 515 | # Add in common arguments. 516 | args.extend(self._common_args(**kwargs)) 517 | args.extend(rofi_args) 518 | 519 | # Run it. 520 | returncode, stdout = self._run_blocking(args, input="") 521 | 522 | # Was the dialog cancelled? 523 | if returncode == 1: 524 | return None 525 | 526 | # Get rid of the trailing newline and check its validity. 527 | text = stdout.rstrip('\n') 528 | if validator: 529 | value, error = validator(text) 530 | if not error: 531 | return value 532 | else: 533 | return text 534 | 535 | 536 | def text_entry(self, prompt, message=None, allow_blank=False, strip=True, 537 | rofi_args=None, **kwargs): 538 | """Prompt the user to enter a piece of text. 539 | 540 | Parameters 541 | ---------- 542 | prompt: string 543 | Prompt to display to the user. 544 | message: string, optional 545 | Message to display under the entry line. 546 | allow_blank: Boolean 547 | Whether to allow blank entries. 548 | strip: Boolean 549 | Whether to strip leading and trailing whitespace from the entered 550 | value. 551 | 552 | Returns 553 | ------- 554 | string, or None if the dialog was cancelled. 555 | 556 | """ 557 | def text_validator(text): 558 | if strip: 559 | text = text.strip() 560 | if not allow_blank: 561 | if not text: 562 | return None, "A value is required." 563 | 564 | return text, None 565 | 566 | return self.generic_entry(prompt, text_validator, message, rofi_args, **kwargs) 567 | 568 | 569 | def integer_entry(self, prompt, message=None, min=None, max=None, rofi_args=None, **kwargs): 570 | """Prompt the user to enter an integer. 571 | 572 | Parameters 573 | ---------- 574 | prompt: string 575 | Prompt to display to the user. 576 | message: string, optional 577 | Message to display under the entry line. 578 | min, max: integer, optional 579 | Minimum and maximum values to allow. If None, no limit is imposed. 580 | 581 | Returns 582 | ------- 583 | integer, or None if the dialog is cancelled. 584 | 585 | """ 586 | # Sanity check. 587 | if (min is not None) and (max is not None) and not (max > min): 588 | raise ValueError("Maximum limit has to be more than the minimum limit.") 589 | 590 | def integer_validator(text): 591 | error = None 592 | 593 | # Attempt to convert to integer. 594 | try: 595 | value = int(text) 596 | except ValueError: 597 | return None, "Please enter an integer value." 598 | 599 | # Check its within limits. 600 | if (min is not None) and (value < min): 601 | return None, "The minimum allowable value is {0:d}.".format(min) 602 | if (max is not None) and (value > max): 603 | return None, "The maximum allowable value is {0:d}.".format(max) 604 | 605 | return value, None 606 | 607 | return self.generic_entry(prompt, integer_validator, message, rofi_args, **kwargs) 608 | 609 | 610 | def float_entry(self, prompt, message=None, min=None, max=None, rofi_args=None, **kwargs): 611 | """Prompt the user to enter a floating point number. 612 | 613 | Parameters 614 | ---------- 615 | prompt: string 616 | Prompt to display to the user. 617 | message: string, optional 618 | Message to display under the entry line. 619 | min, max: float, optional 620 | Minimum and maximum values to allow. If None, no limit is imposed. 621 | 622 | Returns 623 | ------- 624 | float, or None if the dialog is cancelled. 625 | 626 | """ 627 | # Sanity check. 628 | if (min is not None) and (max is not None) and not (max > min): 629 | raise ValueError("Maximum limit has to be more than the minimum limit.") 630 | 631 | def float_validator(text): 632 | error = None 633 | 634 | # Attempt to convert to float. 635 | try: 636 | value = float(text) 637 | except ValueError: 638 | return None, "Please enter a floating point value." 639 | 640 | # Check its within limits. 641 | if (min is not None) and (value < min): 642 | return None, "The minimum allowable value is {0}.".format(min) 643 | if (max is not None) and (value > max): 644 | return None, "The maximum allowable value is {0}.".format(max) 645 | 646 | return value, None 647 | 648 | return self.generic_entry(prompt, float_validator, message, rofi_args, **kwargs) 649 | 650 | 651 | def decimal_entry(self, prompt, message=None, min=None, max=None, rofi_args=None, **kwargs): 652 | """Prompt the user to enter a decimal number. 653 | 654 | Parameters 655 | ---------- 656 | prompt: string 657 | Prompt to display to the user. 658 | message: string, optional 659 | Message to display under the entry line. 660 | min, max: Decimal, optional 661 | Minimum and maximum values to allow. If None, no limit is imposed. 662 | 663 | Returns 664 | ------- 665 | Decimal, or None if the dialog is cancelled. 666 | 667 | """ 668 | # Sanity check. 669 | if (min is not None) and (max is not None) and not (max > min): 670 | raise ValueError("Maximum limit has to be more than the minimum limit.") 671 | 672 | def decimal_validator(text): 673 | error = None 674 | 675 | # Attempt to convert to decimal. 676 | try: 677 | value = Decimal(text) 678 | except InvalidOperation: 679 | return None, "Please enter a decimal value." 680 | 681 | # Check its within limits. 682 | if (min is not None) and (value < min): 683 | return None, "The minimum allowable value is {0}.".format(min) 684 | if (max is not None) and (value > max): 685 | return None, "The maximum allowable value is {0}.".format(max) 686 | 687 | return value, None 688 | 689 | return self.generic_entry(prompt, decimal_validator, message, rofi_args, **kwargs) 690 | 691 | 692 | def date_entry(self, prompt, message=None, formats=['%x', '%d/%m/%Y'], 693 | show_example=False, rofi_args=None, **kwargs): 694 | """Prompt the user to enter a date. 695 | 696 | Parameters 697 | ---------- 698 | prompt: string 699 | Prompt to display to the user. 700 | message: string, optional 701 | Message to display under the entry line. 702 | formats: list of strings, optional 703 | The formats that the user can enter dates in. These should be 704 | format strings as accepted by the datetime.datetime.strptime() 705 | function from the standard library. They are tried in order, and 706 | the first that returns a date object without error is selected. 707 | Note that the '%x' in the default list is the current locale's date 708 | representation. 709 | show_example: Boolean 710 | If True, today's date in the first format given is appended to the 711 | message. 712 | 713 | Returns 714 | ------- 715 | datetime.date, or None if the dialog is cancelled. 716 | 717 | """ 718 | def date_validator(text): 719 | # Try them in order. 720 | for format in formats: 721 | try: 722 | dt = datetime.strptime(text, format) 723 | except ValueError: 724 | continue 725 | else: 726 | # This one worked; good enough for us. 727 | return (dt.date(), None) 728 | 729 | # None of the formats worked. 730 | return (None, 'Please enter a valid date.') 731 | 732 | # Add an example to the message? 733 | if show_example: 734 | message = message or "" 735 | message += "Today's date in the correct format: " + datetime.now().strftime(formats[0]) 736 | 737 | return self.generic_entry(prompt, date_validator, message, rofi_args, **kwargs) 738 | 739 | 740 | def time_entry(self, prompt, message=None, formats=['%X', '%H:%M', '%I:%M', '%H.%M', 741 | '%I.%M'], show_example=False, rofi_args=None, **kwargs): 742 | """Prompt the user to enter a time. 743 | 744 | Parameters 745 | ---------- 746 | prompt: string 747 | Prompt to display to the user. 748 | message: string, optional 749 | Message to display under the entry line. 750 | formats: list of strings, optional 751 | The formats that the user can enter times in. These should be 752 | format strings as accepted by the datetime.datetime.strptime() 753 | function from the standard library. They are tried in order, and 754 | the first that returns a time object without error is selected. 755 | Note that the '%X' in the default list is the current locale's time 756 | representation. 757 | show_example: Boolean 758 | If True, the current time in the first format given is appended to 759 | the message. 760 | 761 | Returns 762 | ------- 763 | datetime.time, or None if the dialog is cancelled. 764 | 765 | """ 766 | def time_validator(text): 767 | # Try them in order. 768 | for format in formats: 769 | try: 770 | dt = datetime.strptime(text, format) 771 | except ValueError: 772 | continue 773 | else: 774 | # This one worked; good enough for us. 775 | return (dt.time(), None) 776 | 777 | # None of the formats worked. 778 | return (None, 'Please enter a valid time.') 779 | 780 | # Add an example to the message? 781 | if show_example: 782 | message = message or "" 783 | message += "Current time in the correct format: " + datetime.now().strftime(formats[0]) 784 | 785 | return self.generic_entry(prompt, time_validator, message, rofi_args=None, **kwargs) 786 | 787 | 788 | def datetime_entry(self, prompt, message=None, formats=['%x %X'], show_example=False, 789 | rofi_args=None, **kwargs): 790 | """Prompt the user to enter a date and time. 791 | 792 | Parameters 793 | ---------- 794 | prompt: string 795 | Prompt to display to the user. 796 | message: string, optional 797 | Message to display under the entry line. 798 | formats: list of strings, optional 799 | The formats that the user can enter the date and time in. These 800 | should be format strings as accepted by the 801 | datetime.datetime.strptime() function from the standard library. 802 | They are tried in order, and the first that returns a datetime 803 | object without error is selected. Note that the '%x %X' in the 804 | default list is the current locale's date and time representation. 805 | show_example: Boolean 806 | If True, the current date and time in the first format given is appended to 807 | the message. 808 | 809 | Returns 810 | ------- 811 | datetime.datetime, or None if the dialog is cancelled. 812 | 813 | """ 814 | def datetime_validator(text): 815 | # Try them in order. 816 | for format in formats: 817 | try: 818 | dt = datetime.strptime(text, format) 819 | except ValueError: 820 | continue 821 | else: 822 | # This one worked; good enough for us. 823 | return (dt, None) 824 | 825 | # None of the formats worked. 826 | return (None, 'Please enter a valid date and time.') 827 | 828 | # Add an example to the message? 829 | if show_example: 830 | message = message or "" 831 | message += "Current date and time in the correct format: " + datetime.now().strftime(formats[0]) 832 | 833 | return self.generic_entry(prompt, datetime_validator, message, rofi_args, **kwargs) 834 | 835 | 836 | def exit_with_error(self, error, **kwargs): 837 | """Report an error and exit. 838 | 839 | This raises a SystemExit exception to ask the interpreter to quit. 840 | 841 | Parameters 842 | ---------- 843 | error: string 844 | The error to report before quitting. 845 | 846 | """ 847 | self.error(error, **kwargs) 848 | raise SystemExit(error) 849 | --------------------------------------------------------------------------------