commit f0bec148c38ecb7431ab034c12cd2c6020ceeeab Author: ksyasuda Date: Fri Oct 21 23:23:23 2022 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b733dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Vim. +*.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b58208e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Blair Bonnett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a5021c6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.rst +include LICENSE diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..32c92db --- /dev/null +++ b/README.rst @@ -0,0 +1,214 @@ +=========== +python-rofi +=========== + +A Python module to make simple GUIs using Rofi. + + +What is Rofi? +============= + +Rofi_ is a popup window switcher with minimal dependencies. Its basic operation +is to display a list of options and let the user pick one. The following +screenshot is shamelessly hotlinked from the Rofi website (which you should +probably visit if you want actual details about Rofi!) and shows it being used +by the teiler_ screenshot application. + +.. image:: https://davedavenport.github.io/rofi/images/rofi/dmenu-replacement.png + :alt: A screenshot of the teiler application using Rofi. + +.. _Rofi: https://davedavenport.github.io/rofi/ + +.. _teiler: https://carnager.github.io/teiler/ + + +What is this module? +==================== + +It simplifies making simple GUIs using Rofi. It provides a class with a number +of methods for various GUI actions (show messages, pick one of these options, +enter some text / a number). These are translated to the appropriate Rofi +command line options, and then the standard subprocess_ module is used to run +Rofi. Any output is then processed and returned to you to do whatever you like +with. + +.. _subprocess: https://docs.python.org/3/library/subprocess.html + + +Examples +-------- + +Data entry +~~~~~~~~~~ + +The simplest example is to create a Rofi instance and prompt the user to enter +a piece of text:: + + from rofi import Rofi + r = Rofi() + name = r.text_entry('What is your name? ') + +There are also entry methods for integers, floating-point numbers, and decimal +numbers:: + + age = r.integer_entry('How old are you? ') + height = r.float_entry('How tall are you? ') + price = r.decimal_entry('How much are you willing to spend? ') + +All of these return the corresponding Python type. Dates and times can also be +requested:: + + dob = r.date_entry('What is your date of birth? ') + start = r.time_entry('When do you start work? ') + reminder = r.datetime_entry('When do you want to be alerted? ') + +Again, these return the corresponding Python type. By default, they expect the +user to enter something in the appropriate format for the current locale. You +can override this by providing a list of format specifiers to any of these +functions. The available specifiers are detailed in the Python documentation +for the datetime_ module. For example:: + + start = r.time_entry('When do you start work? ', formats=['%H:%M']) + +All of these entry methods are specialisations of the ``generic_entry()`` +method. You can use this to create your own entry types. All you need to do is +create a validator function which takes the text entered by the user, and +returns either the Python object or an error message. For example, to enforce a +minimum length on an entered piece of text:: + + validator = lambda s: (s, None) if len(s) > 6 else (None, "Too short!") + r.generic_entry('Enter a 7-character or longer string: ', validator) + +Note that all of these methods return ``None`` if the dialog is cancelled. + +.. _datetime: https://docs.python.org/3/library/datetime.html + +Errors +~~~~~~ + +To show an error message to the user:: + + r.error('I cannot let you do that.') + r.exit_with_error('I cannot let you do that.') + +The latter shows the error message and then exits. + +Selections +~~~~~~~~~~ + +To give the user a list of things to select from, and return the index of the +option they chose:: + + options = ['Red', 'Green', 'Blue', 'White', 'Silver', 'Black', 'Other'] + index, key = r.select('What colour car do you drive?', options) + +The returned ``key`` value tells you what key the user pressed. For Enter, the +value is 0, while -1 indicates they cancelled the dialog. You can also specify +custom key bindings:: + + index, key = r.select('What colour car do you drive?', options, key5=('Alt+n', "I don't drive")) + +In this case, the returned ``key`` will be 5 if they press Alt+n. + +Status +~~~~~~ + +To display a status message to the user:: + + r.status("I'm working on that...") + +This is the only non-blocking method (all the others wait for the user to +finish before returning control to your script). To close the status message:: + + r.close() + +Calling a display or entry method will also close any status message currently +displayed. + +Messages +~~~~~~~~ + +Any of the entry methods and the select method have an optional argument +``message``. This is a string which is displayed below the prompt. The string +can contain Pango_ markup:: + + r.text_entry('What are your goals for this year? ', message='Be bold!') + +If you need to escape a string to avoid it being mistaken for markup, use the +``Rofi.escape()`` class method:: + + msg = Rofi.escape('Format: ') + r.text_entry('Enter your name: ', message=msg) + +.. _Pango: https://developer.gnome.org/pango/stable/PangoMarkupFormat.html + +Customisation +~~~~~~~~~~~~~ + +There are a number of options available to customise the display. These can be +set in the initialiser to apply to every dialog displayed, or you can pass them +to any of the display methods to change just that dialog. See the Rofi +documentation for full details of these parameters. + +* ``lines``: The maximum number of lines to show before scrolling. + +* ``fixed_lines``: Keep a fixed number of lines visible. + +* ``width``: If positive but not more than 100, this is the percentage of the + screen's width the window takes up. If greater than 100, it is the width in + pixels. If negative, it estimates the width required for the corresponding + number of characters, i.e., -30 would set the width so approximately 30 + characters per row would show. + +* ``fullscreen``: If True, use the full height and width of the screen. + +* ``location``: The position of the window on the screen. + +* You can also pass in arbitrary arguments to rofi through the ``rofi_args`` + parameter. These have to be passed in as a list of strings, with every + argument in a seperate string. For example, to make a selection case + insensitive:: + + r = Rofi() + r.select('Choose one', ['option 1', 'option 2', 'option 3'], + rofi_args=['-i']) + + or, to choose a different style for an instance of ``Rofi``:: + + r = Rofi(rofi_args=['-theme', 'path/to/theme.rasi']) + r.status('Stuff is happening, please wait...') + + + + +Requirements +============ + +You need to have the ``rofi`` executable available on the system path (i.e., +install Rofi!). Everything else that python-rofi needs is provided by the +Python standard libraries. + + +What Python versions are supported? +=================================== + +It *should* work with any version of Python from 2.7 onwards. It may work with +older versions, though no specific support for them will be added. It is +developed on Python 2.7 and Python 3.6 -- the latest versions of the Python 2 +and 3 branches respectively. + + +What license does it use? +========================= + +The MIT license, the same as Rofi itself. + + +Bug reports +=========== + +The project is developed on GitHub_. Please file any bug reports or feature +requests on the Issues_ page there. + +.. _GitHub: https://github.com/bcbnz/python-rofi +.. _Issues: https://github.com/bcbnz/python-rofi/issues diff --git a/rofi.py b/rofi.py new file mode 100644 index 0000000..86feb01 --- /dev/null +++ b/rofi.py @@ -0,0 +1,917 @@ +# +# +# The MIT License +# +# Copyright (c) 2016, 2017 Blair Bonnett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +import atexit +import signal +import subprocess +import time +from datetime import datetime +from decimal import Decimal, InvalidOperation + +# Python < 3.2 doesn't provide a context manager interface for Popen. +# Let's make our own wrapper if needed. +if hasattr(subprocess.Popen, "__exit__"): + Popen = subprocess.Popen +else: + + class ContextManagedPopen(subprocess.Popen): + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + if self.stdout: + self.stdout.close() + if self.stderr: + self.stderr.close() + if self.stdin: + self.stdin.close() + self.wait() + + Popen = ContextManagedPopen + + +class Rofi(object): + """Class to facilitate making simple GUIs with Rofi. + + Rofi is a popup window system with minimal dependencies (xlib and pango). + It was designed as a window switcher. Its basic operation is to display a + list of options and let the user pick one. + + This class provides a set of methods to make simple GUIs with Rofi. It does + this by using the subprocess module to call Rofi externally. Many of the + methods are blocking. + + Some strings can contain Pango markup for additional formatting (those that + can are noted as such in the docstrings). Any text in these strings *must* + be escaped before calling Rofi. The class method Rofi.escape() performs + this escaping for you. Make sure you call this on the text prior to adding + Pango markup, otherwise the markup will be escaped and displayed to the + user. See https://developer.gnome.org/pango/stable/PangoMarkupFormat.html + for available markup. + + """ + + def __init__( + self, + lines=None, + fixed_lines=None, + width=None, + fullscreen=None, + location=None, + exit_hotkeys=("Alt+F4", "Control+q"), + rofi_args=None, + theme_str=None, + config_file=None, + ): + """ + Parameters + ---------- + exit_hotkeys: tuple of strings + Hotkeys to use to exit the application. These will be automatically + set and handled in any method which takes hotkey arguments. If one + of these hotkeys is pressed, a SystemExit will be raised to perform + the exit. + + The following parameters set default values for various layout options, + and can be overwritten in any display method. A value of None means + use the system default, which may be set by a configuration file or + fall back to the compile-time default. See the Rofi documentation for + full details on what the values mean. + + lines: positive integer + The maximum number of lines to show before scrolling. + fixed_lines: positive integer + Keep a fixed number of lines visible. + width: real + If positive but not more than 100, this is the percentage of the + screen's width the window takes up. If greater than 100, it is the + width in pixels. If negative, it estimates the width required for + the corresponding number of characters, i.e., -30 would set the + width so ~30 characters per row would show. + fullscreen: boolean + If True, use the full height and width of the screen. + location: integer + The position of the window on the screen. + rofi_args: list + A list of other arguments to pass in to every call to rofi. These get appended + after any other arguments + + """ + # The Popen class returned for any non-blocking windows. + self._process = None + + # Save parameters. + self.theme_str = theme_str + self.config_file = config_file + self.lines = lines + self.fixed_lines = fixed_lines + self.width = width + self.fullscreen = fullscreen + self.location = location + self.exit_hotkeys = exit_hotkeys + self.rofi_args = rofi_args or [] + + # Don't want a window left on the screen if we exit unexpectedly + # (e.g., an unhandled exception). + atexit.register(self.close) + + @classmethod + def escape(self, string): + """Escape a string for Pango markup. + + Parameters + ---------- + string: + A piece of text to escape. + + Returns + ------- + The text, safe for use in with Pango markup. + + """ + # Escape ampersands first, then other entities. Since argument is a + # dictionary, we can't guarantee order of translations and so doing it + # in one go would risk the ampersands in other translations being + # escaped again. + return string.translate({38: "&"}).translate( + {34: """, 39: "'", 60: "<", 62: ">"} + ) + + def close(self): + """Close any open window. + + Note that this only works with non-blocking methods. + + """ + if self._process: + # Be nice first. + self._process.send_signal(signal.SIGINT) + + # If it doesn't close itself promptly, be brutal. + # Python 3.2+ added the timeout option to wait() and the + # corresponding TimeoutExpired exception. If they exist, use them. + if hasattr(subprocess, "TimeoutExpired"): + try: + self._process.wait(timeout=1) + except subprocess.TimeoutExpired: + self._process.send_signal(signal.SIGKILL) + + # Otherwise, roll our own polling loop. + else: + # Give it 1s, checking every 10ms. + count = 0 + while count < 100: + if self._process.poll() is not None: + break + time.sleep(0.01) + + # Still hasn't quit. + if self._process.poll() is None: + self._process.send_signal(signal.SIGKILL) + + # Clean up. + self._process = None + + def _run_blocking(self, args, input=None): + """Internal API: run a blocking command with subprocess. + + This closes any open non-blocking dialog before running the command. + + Parameters + ---------- + args: Popen constructor arguments + Command to run. + input: string + Value to feed to the stdin of the process. + + Returns + ------- + (returncode, stdout) + The exit code (integer) and stdout value (string) from the process. + + """ + # Close any existing dialog. + if self._process: + self.close() + + # Make sure we grab stdout as text (not bytes). + kwargs = {} + kwargs["stdout"] = subprocess.PIPE + kwargs["universal_newlines"] = True + + # Use the run() method if available (Python 3.5+). + if hasattr(subprocess, "run"): + result = subprocess.run(args, input=input, **kwargs) + return result.returncode, result.stdout + + # Have to do our own. If we need to feed stdin, we must open a pipe. + if input is not None: + kwargs["stdin"] = subprocess.PIPE + + # Start the process. + with Popen(args, **kwargs) as proc: + # Talk to it (no timeout). This will wait until termination. + stdout, stderr = proc.communicate(input) + + # Find out the return code. + returncode = proc.poll() + + # Done. + return returncode, stdout + + def _run_nonblocking(self, args, input=None): + """Internal API: run a non-blocking command with subprocess. + + This closes any open non-blocking dialog before running the command. + + Parameters + ---------- + args: Popen constructor arguments + Command to run. + input: string + Value to feed to the stdin of the process. + + """ + # Close any existing dialog. + if self._process: + self.close() + + # Start the new one. + self._process = subprocess.Popen(args, stdout=subprocess.PIPE) + + def _common_args(self, allow_fullscreen=True, **kwargs): + args = [] + + if self.theme_str: + theme_str = kwargs.get("theme", self.theme_str) + args.extend(["-theme-str", str(theme_str)]) + + # config file + if self.config_file: + from pathlib import Path + + config_file = Path(kwargs.get("config_file", self.config_file)) + if ( + not config_file.expanduser().exists() + or config_file.expanduser().is_dir() + ): + raise ValueError( + f"config_file {str(config_file)} is not a valid file or does not exist" + ) + args.extend(["-config", str(config_file.expanduser())]) + + # Number of lines. + lines = kwargs.get("lines", self.lines) + if lines: + args.extend(["-lines", str(lines)]) + fixed_lines = kwargs.get("fixed_lines", self.fixed_lines) + if fixed_lines: + args.extend(["-fixed-num-lines", str(fixed_lines)]) + + # Width. + width = kwargs.get("width", self.width) + if width is not None: + args.extend(["-width", str(width)]) + + # Fullscreen mode? + fullscreen = kwargs.get("fullscreen", self.fullscreen) + if allow_fullscreen and fullscreen: + args.append("-fullscreen") + + # Location on screen. + location = kwargs.get("location", self.location) + if location is not None: + args.extend(["-location", str(location)]) + + # Any other arguments + args.extend(self.rofi_args) + + # Done. + return args + + def error(self, message, rofi_args=None, **kwargs): + """Show an error window. + + This method blocks until the user presses a key. + + Fullscreen mode is not supported for error windows, and if specified + will be ignored. + + Parameters + ---------- + message: string + Error message to show. + + """ + rofi_args = rofi_args or [] + # Generate arguments list. + args = ["rofi", "-e", message] + args.extend(self._common_args(allow_fullscreen=False, **kwargs)) + args.extend(rofi_args) + + # Close any existing window and show the error. + self._run_blocking(args) + + def status(self, message, rofi_args=None, **kwargs): + """Show a status message. + + This method is non-blocking, and intended to give a status update to + the user while something is happening in the background. + + To close the window, either call the close() method or use any of the + display methods to replace it with a different window. + + Fullscreen mode is not supported for status messages and if specified + will be ignored. + + Parameters + ---------- + message: string + Progress message to show. + + """ + rofi_args = rofi_args or [] + # Generate arguments list. + args = ["rofi", "-e", message] + args.extend(self._common_args(allow_fullscreen=False, **kwargs)) + args.extend(rofi_args) + + # Update the status. + self._run_nonblocking(args) + + def select( + self, prompt, options, rofi_args=None, message="", select=None, **kwargs + ): + """Show a list of options and return user selection. + + This method blocks until the user makes their choice. + + Parameters + ---------- + prompt: string + The prompt telling the user what they are selecting. + options: list of strings + The options they can choose from. Any newline characters are + replaced with spaces. + message: string, optional + Message to show between the prompt and the options. This can + contain Pango markup, and any text content should be escaped. + select: integer, optional + Set which option is initially selected. + keyN: tuple (string, string); optional + Custom key bindings where N is one or greater. The first entry in + the tuple should be a string defining the key, e.g., "Alt+x" or + "Delete". Note that letter keys should be lowercase ie.e., Alt+a + not Alt+A. + + The second entry should be a short string stating the action the + key will take. This is displayed to the user at the top of the + dialog. If None or an empty string, it is not displayed (but the + binding is still set). + + By default, key1 through key9 are set to ("Alt+1", None) through + ("Alt+9", None) respectively. + + Returns + ------- + tuple (index, key) + The index of the option the user selected, or -1 if they cancelled + the dialog. + Key indicates which key was pressed, with 0 being 'OK' (generally + Enter), -1 being 'Cancel' (generally escape), and N being custom + key N. + + """ + rofi_args = rofi_args or [] + # Replace newlines and turn the options into a single string. + optionstr = "\n".join(option.replace("\n", " ") for option in options) + + # Set up arguments. + args = ["rofi", "-dmenu", "-p", prompt, "-format", "i"] + if select is not None: + args.extend(["-selected-row", str(select)]) + + # Key bindings to display. + display_bindings = [] + + # Configure the key bindings. + user_keys = set() + for k, v in kwargs.items(): + # See if the keyword name matches the needed format. + if not k.startswith("key"): + continue + try: + keynum = int(k[3:]) + except ValueError: + continue + + # Add it to the set. + key, action = v + user_keys.add(keynum) + args.extend(["-kb-custom-{0:s}".format(k[3:]), key]) + if action: + display_bindings.append("{0:s}: {1:s}".format(key, action)) + + # And the global exit bindings. + exit_keys = set() + next_key = 10 + for key in self.exit_hotkeys: + while next_key in user_keys: + next_key += 1 + exit_keys.add(next_key) + args.extend(["-kb-custom-{0:d}".format(next_key), key]) + next_key += 1 + + # Add any displayed key bindings to the message. + message = message or "" + if display_bindings: + message += "\n" + " ".join(display_bindings) + message = message.strip() + + # If we have a message, add it to the arguments. + if message: + args.extend(["-mesg", message]) + + # Add in common arguments. + args.extend(self._common_args(**kwargs)) + args.extend(rofi_args) + + # Run the dialog. + returncode, stdout = self._run_blocking(args, input=optionstr) + + # Figure out which option was selected. + stdout = stdout.strip() + index = int(stdout) if stdout else -1 + + # And map the return code to a key. + if returncode == 0: + key = 0 + elif returncode == 1: + key = -1 + elif returncode > 9: + key = returncode - 9 + if key in exit_keys: + raise SystemExit() + else: + self.exit_with_error( + "Unexpected rofi returncode {0:d}.".format(results.returncode) + ) + + # And return. + return index, key + + def generic_entry( + self, prompt, validator=None, message=None, rofi_args=None, **kwargs + ): + """A generic entry box. + + Parameters + ---------- + prompt: string + Text prompt for the entry. + validator: function, optional + A function to validate and convert the value entered by the user. + It should take one parameter, the string that the user entered, and + return a tuple (value, error). The value should be the users entry + converted to the appropriate Python type, or None if the entry was + invalid. The error message should be a string telling the user what + was wrong, or None if the entry was valid. The prompt will be + re-displayed to the user (along with the error message) until they + enter a valid value. If no validator is given, the text that the + user entered is returned as-is. + message: string + Optional message to display under the entry. + + Returns + ------- + The value returned by the validator, or None if the dialog was + cancelled. + + Examples + -------- + Enforce a minimum entry length: + >>> r = Rofi() + >>> validator = lambda s: (s, None) if len(s) > 6 else (None, "Too short") + >>> r.generic_entry('Enter a 7-character or longer string: ', validator) + + """ + error = "" + rofi_args = rofi_args or [] + + # Keep going until we get something valid. + while True: + args = ["rofi", "-dmenu", "-p", prompt, "-format", "s"] + + # Add any error to the given message. + msg = message or "" + if error: + msg = '{0:s}\n{1:s}'.format( + error, msg + ) + msg = msg.rstrip("\n") + + # If there is actually a message to show. + if msg: + args.extend(["-mesg", msg]) + + # Add in common arguments. + args.extend(self._common_args(**kwargs)) + args.extend(rofi_args) + + # Run it. + returncode, stdout = self._run_blocking(args, input="") + + # Was the dialog cancelled? + if returncode != 0: + return None + + # Get rid of the trailing newline and check its validity. + text = stdout.rstrip("\n") + if validator: + value, error = validator(text) + if not error: + return value + else: + return text + + def text_entry( + self, + prompt, + message=None, + allow_blank=False, + strip=True, + rofi_args=None, + **kwargs, + ): + """Prompt the user to enter a piece of text. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + allow_blank: Boolean + Whether to allow blank entries. + strip: Boolean + Whether to strip leading and trailing whitespace from the entered + value. + + Returns + ------- + string, or None if the dialog was cancelled. + + """ + + def text_validator(text): + if strip: + text = text.strip() + if not allow_blank: + if not text: + return None, "A value is required." + + return text, None + + return self.generic_entry(prompt, text_validator, message, rofi_args, **kwargs) + + def integer_entry( + self, prompt, message=None, min=None, max=None, rofi_args=None, **kwargs + ): + """Prompt the user to enter an integer. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + min, max: integer, optional + Minimum and maximum values to allow. If None, no limit is imposed. + + Returns + ------- + integer, or None if the dialog is cancelled. + + """ + # Sanity check. + if (min is not None) and (max is not None) and not (max > min): + raise ValueError("Maximum limit has to be more than the minimum limit.") + + def integer_validator(text): + error = None + + # Attempt to convert to integer. + try: + value = int(text) + except ValueError: + return None, "Please enter an integer value." + + # Check its within limits. + if (min is not None) and (value < min): + return None, "The minimum allowable value is {0:d}.".format(min) + if (max is not None) and (value > max): + return None, "The maximum allowable value is {0:d}.".format(max) + + return value, None + + return self.generic_entry( + prompt, integer_validator, message, rofi_args, **kwargs + ) + + def float_entry( + self, prompt, message=None, min=None, max=None, rofi_args=None, **kwargs + ): + """Prompt the user to enter a floating point number. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + min, max: float, optional + Minimum and maximum values to allow. If None, no limit is imposed. + + Returns + ------- + float, or None if the dialog is cancelled. + + """ + # Sanity check. + if (min is not None) and (max is not None) and not (max > min): + raise ValueError("Maximum limit has to be more than the minimum limit.") + + def float_validator(text): + error = None + + # Attempt to convert to float. + try: + value = float(text) + except ValueError: + return None, "Please enter a floating point value." + + # Check its within limits. + if (min is not None) and (value < min): + return None, "The minimum allowable value is {0}.".format(min) + if (max is not None) and (value > max): + return None, "The maximum allowable value is {0}.".format(max) + + return value, None + + return self.generic_entry(prompt, float_validator, message, rofi_args, **kwargs) + + def decimal_entry( + self, prompt, message=None, min=None, max=None, rofi_args=None, **kwargs + ): + """Prompt the user to enter a decimal number. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + min, max: Decimal, optional + Minimum and maximum values to allow. If None, no limit is imposed. + + Returns + ------- + Decimal, or None if the dialog is cancelled. + + """ + # Sanity check. + if (min is not None) and (max is not None) and not (max > min): + raise ValueError("Maximum limit has to be more than the minimum limit.") + + def decimal_validator(text): + error = None + + # Attempt to convert to decimal. + try: + value = Decimal(text) + except InvalidOperation: + return None, "Please enter a decimal value." + + # Check its within limits. + if (min is not None) and (value < min): + return None, "The minimum allowable value is {0}.".format(min) + if (max is not None) and (value > max): + return None, "The maximum allowable value is {0}.".format(max) + + return value, None + + return self.generic_entry( + prompt, decimal_validator, message, rofi_args, **kwargs + ) + + def date_entry( + self, + prompt, + message=None, + formats=["%x", "%d/%m/%Y"], + show_example=False, + rofi_args=None, + **kwargs, + ): + """Prompt the user to enter a date. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + formats: list of strings, optional + The formats that the user can enter dates in. These should be + format strings as accepted by the datetime.datetime.strptime() + function from the standard library. They are tried in order, and + the first that returns a date object without error is selected. + Note that the '%x' in the default list is the current locale's date + representation. + show_example: Boolean + If True, today's date in the first format given is appended to the + message. + + Returns + ------- + datetime.date, or None if the dialog is cancelled. + + """ + + def date_validator(text): + # Try them in order. + for format in formats: + try: + dt = datetime.strptime(text, format) + except ValueError: + continue + else: + # This one worked; good enough for us. + return (dt.date(), None) + + # None of the formats worked. + return (None, "Please enter a valid date.") + + # Add an example to the message? + if show_example: + message = message or "" + message += "Today's date in the correct format: " + datetime.now().strftime( + formats[0] + ) + + return self.generic_entry(prompt, date_validator, message, rofi_args, **kwargs) + + def time_entry( + self, + prompt, + message=None, + formats=["%X", "%H:%M", "%I:%M", "%H.%M", "%I.%M"], + show_example=False, + rofi_args=None, + **kwargs, + ): + """Prompt the user to enter a time. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + formats: list of strings, optional + The formats that the user can enter times in. These should be + format strings as accepted by the datetime.datetime.strptime() + function from the standard library. They are tried in order, and + the first that returns a time object without error is selected. + Note that the '%X' in the default list is the current locale's time + representation. + show_example: Boolean + If True, the current time in the first format given is appended to + the message. + + Returns + ------- + datetime.time, or None if the dialog is cancelled. + + """ + + def time_validator(text): + # Try them in order. + for format in formats: + try: + dt = datetime.strptime(text, format) + except ValueError: + continue + else: + # This one worked; good enough for us. + return (dt.time(), None) + + # None of the formats worked. + return (None, "Please enter a valid time.") + + # Add an example to the message? + if show_example: + message = message or "" + message += "Current time in the correct format: " + datetime.now().strftime( + formats[0] + ) + + return self.generic_entry( + prompt, time_validator, message, rofi_args=None, **kwargs + ) + + def datetime_entry( + self, + prompt, + message=None, + formats=["%x %X"], + show_example=False, + rofi_args=None, + **kwargs, + ): + """Prompt the user to enter a date and time. + + Parameters + ---------- + prompt: string + Prompt to display to the user. + message: string, optional + Message to display under the entry line. + formats: list of strings, optional + The formats that the user can enter the date and time in. These + should be format strings as accepted by the + datetime.datetime.strptime() function from the standard library. + They are tried in order, and the first that returns a datetime + object without error is selected. Note that the '%x %X' in the + default list is the current locale's date and time representation. + show_example: Boolean + If True, the current date and time in the first format given is appended to + the message. + + Returns + ------- + datetime.datetime, or None if the dialog is cancelled. + + """ + + def datetime_validator(text): + # Try them in order. + for format in formats: + try: + dt = datetime.strptime(text, format) + except ValueError: + continue + else: + # This one worked; good enough for us. + return (dt, None) + + # None of the formats worked. + return (None, "Please enter a valid date and time.") + + # Add an example to the message? + if show_example: + message = message or "" + message += ( + "Current date and time in the correct format: " + + datetime.now().strftime(formats[0]) + ) + + return self.generic_entry( + prompt, datetime_validator, message, rofi_args, **kwargs + ) + + def exit_with_error(self, error, **kwargs): + """Report an error and exit. + + This raises a SystemExit exception to ask the interpreter to quit. + + Parameters + ---------- + error: string + The error to report before quitting. + + """ + self.error(error, **kwargs) + raise SystemExit(error) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3c6e79c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..148407f --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +# +# python-rofi +# +# The MIT License +# +# Copyright (c) 2017 Blair Bonnett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from setuptools import setup + +with open("README.rst", "r") as f: + long_description = f.read() + +setup( + name="python-rofi", + description="Create simple GUIs using the Rofi application", + long_description=long_description, + version="1.1.0", + author="Blair Bonnett", + author_email="blair.bonnett@gmail.com", + license="MIT", + url="https://github.com/bcbnz/python-rofi", + zip_safe=True, + classifiers=[ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Development Status :: 5 - Production/Stable", + ], + py_modules=["rofi"], +) diff --git a/tests/test.py b/tests/test.py new file mode 100755 index 0000000..6cb26cb --- /dev/null +++ b/tests/test.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +from rofi import Rofi + + +def test_basic(): + # rofi = Rofi() + # rofi = Rofi( + # config_file="~/.config/rofi/aniwrapper.rasi", + # theme_str="window { width: 25%; }", + # ) + rofi = Rofi( + config_file="~/.config/rofi/aniwrapper-dracula.rasi", + theme_str="window { width: 25%; }", + ) + # rofi = Rofi(config_file="~/.config/rofi/aniwrapper-dracula.rasi") + name = rofi.text_entry("What is your name?") + print("Hello, {}!".format(name)) + assert name and name != "", "Name is empty" + + +if __name__ == "__main__": + test_basic()