initial commit
This commit is contained in:
commit
f0bec148c3
59
.gitignore
vendored
Normal file
59
.gitignore
vendored
Normal file
@ -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
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
2
MANIFEST.in
Normal file
2
MANIFEST.in
Normal file
@ -0,0 +1,2 @@
|
||||
include README.rst
|
||||
include LICENSE
|
214
README.rst
Normal file
214
README.rst
Normal file
@ -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 <b>bold</b>!')
|
||||
|
||||
If you need to escape a string to avoid it being mistaken for markup, use the
|
||||
``Rofi.escape()`` class method::
|
||||
|
||||
msg = Rofi.escape('Format: <firstname> <lastname>')
|
||||
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
|
917
rofi.py
Normal file
917
rofi.py
Normal file
@ -0,0 +1,917 @@
|
||||
#
|
||||
#
|
||||
# The MIT License
|
||||
#
|
||||
# Copyright (c) 2016, 2017 Blair Bonnett <blair.bonnett@gmail.com>
|
||||
#
|
||||
# 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("<b>{0:s}</b>: {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 = '<span color="#FF0000" font_weight="bold">{0:s}</span>\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)
|
48
setup.py
Normal file
48
setup.py
Normal file
@ -0,0 +1,48 @@
|
||||
#
|
||||
# python-rofi
|
||||
#
|
||||
# The MIT License
|
||||
#
|
||||
# Copyright (c) 2017 Blair Bonnett <blair.bonnett@gmail.com>
|
||||
#
|
||||
# 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"],
|
||||
)
|
23
tests/test.py
Executable file
23
tests/test.py
Executable file
@ -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()
|
Loading…
Reference in New Issue
Block a user