Compare commits

29 Commits
v1.0 ... master

Author SHA1 Message Date
09ca3f6199 add another gui variant 2026-01-04 12:31:48 +01:00
eeb18679e6 add option to debug command output 2024-01-01 01:52:19 +01:00
2046adfc79 add update page button to update button states only, delay button update state to let process to finish, overwrite button text if attr description is defined, empty and label class inherit from tile class, process with callback option 2024-01-01 00:11:17 +01:00
bb8709bdf5 fix systemd unit file 2023-12-28 19:10:25 +01:00
212a6ab426 update systemd service unit to wait for env DISPLAY and XAUTHORITY 2023-12-28 19:00:03 +01:00
2622d514c4 fix cast of command which might not be a number 2023-12-28 15:42:01 +01:00
b8d94b271f fix slider config cast 2023-12-24 01:47:57 +01:00
7c3cd04c1b working slider 2023-12-24 00:34:46 +01:00
6d502ea67e fix source sliders 2023-12-23 16:10:20 +01:00
165879eb0d fix color buttons 2023-12-23 15:18:14 +01:00
ed851b0eed add source volume slider, add gui config flags, add gui menu 2023-12-23 14:36:36 +01:00
6abaf515ae fix config 2023-12-21 13:26:55 +01:00
b972354e97 first step for slider class 2023-12-19 21:43:21 +01:00
dec5642e26 fix volume input sinks to handle also mono streams 2023-12-10 04:19:05 +01:00
310d396407 fix static dir 2023-12-09 12:50:16 +01:00
4594b55bf4 change colors 2023-12-09 09:29:32 +01:00
d1581e866f add label class, move empty buton to empty label 2023-10-18 20:32:51 +02:00
c50b5e1ce9 command output 2023-05-29 19:35:55 +02:00
bbda532246 move test button to debug 2023-01-16 13:02:07 +01:00
ead2b06eca cli args for host and port 2023-01-16 12:51:54 +01:00
db0a3b1655 fix app name 2022-11-20 18:24:45 +01:00
180f571018 fix json load of string including an stderr output 2022-11-08 19:43:15 +01:00
707453a513 fix json load of string including an stderr output 2022-11-08 19:39:42 +01:00
21c91b0b9c fix justpy config overwrite 2022-11-06 15:51:47 +01:00
b32a63d9b4 fix config file variable 2022-10-30 16:55:18 +01:00
a9636a4112 fix static directory 2022-10-30 16:45:24 +01:00
18af7036bb comment out xdg ref and profile 2022-10-03 02:24:49 +02:00
310d31e98d fix controldeck entry 2022-10-01 13:35:39 +02:00
6a3995f69a change style to Quasar 2022-10-01 13:16:19 +02:00
11 changed files with 2132 additions and 602 deletions

28
README
View File

@@ -1,7 +1,33 @@
Install
virtualenv ~/.local/venv/controldeck
source ~/.local/venv/controldeck/bin/activate
# web interface
pip install --upgrade nicegui
# gui wrapper
pip install --upgrade pywebview
pip install --upgrade "pywebview[qt]"
# optional for volume buttons
sudo pacman -S libpulse
# OLD
Requirements:
- controldeck
- python package justpy, the framework: pip install justpy --upgrade
- (optionally) for volume buttons: libpulse
- controldeck-gui
- render engine
- GTK: python-gobject, python-cairo, webkit2gtk>=2.22
- QT: qt5-webkit, python-qtpy
- QT5: python-pyqt5-webengine, python-pyqt5
- QT: pyside2, pyside6
local:
./setup.sh

14
TODO Normal file
View File

@@ -0,0 +1,14 @@
in cli mode
- config is loaded in main and application. config load in app needs info about cli args.config for the optionally new path
- pactl json Invalid non-ASCII character: 0xffffffc3
add state-command into the button hover tooltip
update buttons function (not reloading everything)
is updating the borders to active or inactive but does not update the state info in the hovered tooltip
command-alt is not implemented, to run another command if button is in active state, e.g. to kill a command
- when implementing then also add info of command-alt into button hover tooltip

View File

@@ -1 +1 @@
2021.08.24
2024.01.18

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import sys
import os
import argparse
from tkinter import Tk, messagebox
from webview import create_window, start
import webview
from controldeck import config_load, process
import threading
import time
@@ -14,20 +14,73 @@ def thread_function(name):
# print("Thread %s: finishing", name)
# p = process("xdotool search --name 'ControlDeck'")
# intersection of ControlDeck window name and empty classname
p = process("comm -12 <(xdotool search --name 'ControlDeck' | sort) <(xdotool search --classname '^$' | sort)")
p = process("comm -12 <(xdotool search --name 'ControlDeck' | sort) <(xdotool search --classname '^$' | sort)", shell=True)
if p:
# print(p)
# process("xdotool search --name 'ControlDeck' set_window --class 'controldeck'", output=False)
# process("xdotool search --name 'ControlDeck' set_window --classname 'controldeck' --class 'ControlDeck' windowunmap windowmap", output=False) # will find to many wrong ids
process(f"xdotool set_window --classname 'controldeck' --class 'ControlDeck' {p} windowunmap {p} windowmap {p}", output=False)
process(f"xdotool set_window --classname 'controldeck' --class 'ControlDeck' {p} windowunmap {p} windowmap {p}", shell=True, output=False)
time.sleep(0.1)
def main(args, pid=-1):
config = config_load(conf=args.config)
host = config.get('default', 'host', fallback='0.0.0.0')
port = config.get('default', 'port', fallback='8000')
url = f"http://{host}:{port}/?gui&pid={str(pid)}"
try:
width = config.getint('gui', 'width', fallback=800)
except ValueError as e:
width = 800
print(f"Error width: {e}. fallback to: {width}")
try:
height = config.getint('gui', 'height', fallback=600)
except ValueError as e:
width = 600
print(f"Error height: {e}. fallback to {width}")
try:
x = config.getint('gui', 'x', fallback='')
except ValueError as e:
x = None
print(f"Error x: {e}. fallback to {x}")
try:
y = config.getint('gui', 'y', fallback='')
except ValueError as e:
y = None
print(f"Error y: {e}. fallback to: {y}")
resizable = config.get('gui', 'resizable', fallback='True').title() == 'True'
fullscreen = config.get('gui', 'fullscreen', fallback='False').title() == 'True'
try:
min_width = config.getint('gui', 'min_width', fallback=200)
except ValueError as e:
min_width = 200
print(f"Error min_width: {e}. fallback to: {min_width}")
try:
min_height = config.getint('gui', 'min_height', fallback=100)
except ValueError as e:
min_height = 100
print(f"Error min_height: {e}. fallback to: {min_height}")
min_size = (min_width, min_height)
frameless = config.get('gui', 'frameless', fallback='False').title() == 'True'
minimized = config.get('gui', 'minimized', fallback='False').title() == 'True'
maximized = config.get('gui', 'maximized', fallback='False').title() == 'True'
on_top = config.get('gui', 'always_on_top', fallback='False').title() == 'True'
confirm_close = config.get('gui', 'confirm_close', fallback='False').title() == 'True'
transparent = config.get('gui', 'transparent', fallback='True').title() == 'True'
gui_type = config.get('gui', 'gui_type', fallback=None)
gui_type = gui_type if gui_type != "" else None
menu = config.get('gui', 'menu', fallback='True').title() == 'True'
if args.debug:
print(f"config file [default]: {config.items('default')}")
print(f"config file [gui]: {config.items('gui')}")
#controldeck_process = process("ps --no-headers -C controldeck")
controldeck_process = process("ps --no-headers -C controldeck || ps aux | grep -e 'python.*controldeck.py' | grep -v grep")
controldeck_process = process("ps --no-headers -C controldeck || ps aux | grep -e 'python.*controldeck.py' | grep -v grep", shell=True, output=True)
if args.start and controldeck_process == "":
process("controldeck &", output=False)
cmd = "controldeck"
cmd += " --config={args.config}" if args.config else ""
print(cmd)
process(cmd, shell=True, output=False)
elif controldeck_process == "":
# cli output
@@ -42,46 +95,8 @@ def main(args, pid=-1):
sys.exit(2)
config = config_load(conf=args.config)
url = config.get('gui', 'url', fallback='http://0.0.0.0:8000') + "/?gui&pid=" + str(pid)
try:
width = int(config.get('gui', 'width', fallback=800))
except ValueError as e:
print(f"{e}")
width = 800
try:
height = int(config.get('gui', 'height', fallback=600))
except ValueError as e:
print(f"{e}")
width = 600
try:
x = int(config.get('gui', 'x', fallback=''))
except ValueError as e:
print(f"{e}")
x = None
try:
y = int(config.get('gui', 'y', fallback=''))
except ValueError as e:
print(f"{e}")
y = None
resizable = config.get('gui', 'resizable', fallback='True').title() == 'True'
fullscreen = config.get('gui', 'fullscreen', fallback='False').title() == 'True'
try:
min_width = int(config.get('gui', 'min_width', fallback=200))
except ValueError as e:
print(f"{e}")
min_width = 200
try:
min_height = int(config.get('gui', 'min_height', fallback=100))
except ValueError as e:
print(f"{e}")
min_width = 100
min_size = (min_width, min_height)
frameless = config.get('gui', 'frameless', fallback='False').title() == 'True'
minimized = config.get('gui', 'minimized', fallback='False').title() == 'True'
on_top = config.get('gui', 'always_on_top', fallback='False').title() == 'True'
create_window("ControlDeck",
window = webview.create_window(
title="ControlDeck",
url=url,
html=None,
js_api=None,
@@ -89,21 +104,92 @@ def main(args, pid=-1):
height=height,
x=x,
y=y,
screen=None,
resizable=resizable,
fullscreen=fullscreen,
min_size=min_size,
hidden=False,
frameless=frameless,
easy_drag=True,
focus=True,
minimized=minimized,
maximized=maximized,
on_top=on_top,
confirm_close=False,
confirm_close=confirm_close,
background_color='#000000',
transparent=True,
text_select=False)
transparent=transparent, # TODO: bug in qt; menu bar is transparent
text_select=False,
zoomable=False, # zoom via js
draggable=False,
vibrancy=False,
localization=None,
)
x = threading.Thread(target=thread_function, args=(1,))
x.start()
start()
def menu_reload():
window = webview.active_window()
if window:
url = window.get_current_url()
window.load_url(url)
print(window.get_current_url())
print(window)
print(dir(window))
def menu_zoomin():
window = webview.active_window()
if window:
zoom = window.evaluate_js('document.documentElement.style.zoom')
if zoom == "":
zoom = 1.0
else:
zoom = float(zoom)
zoom += 0.1
print(f"zoom-in: {zoom}")
window.evaluate_js(f'document.documentElement.style.zoom = {zoom}')
print(f"set document.documentElement.style.zoom = {zoom}")
def menu_zoomout():
window = webview.active_window()
if window:
zoom = window.evaluate_js('document.documentElement.style.zoom')
if zoom == "":
zoom = 1.0
else:
zoom = float(zoom)
zoom -= 0.1
print(f"zoom-out: {zoom}")
window.evaluate_js(f'document.documentElement.style.zoom = {zoom}')
print(f"set document.documentElement.style.zoom = {zoom}")
def menu_zoomreset():
window = webview.active_window()
if window:
zoom = 1.0
print(f"zoom-reset: {zoom}")
window.evaluate_js(f'document.documentElement.style.zoom = {zoom}')
print(f"set document.documentElement.style.zoom = {zoom}")
menu_items = []
if menu:
menu_items = [webview.menu.Menu(
'Main', [
webview.menu.MenuAction('Reload', menu_reload),
webview.menu.MenuAction('zoom +', menu_zoomin),
webview.menu.MenuAction('zoom -', menu_zoomout),
webview.menu.MenuAction('zoom reset', menu_zoomreset),
]
)]
# TODO: zoom reset on reload (both from menu and within justpy)
# TODO: add zoom in config
# TODO: move zoom logic to justpy but then it is fix for all,
# maybe better a zoom argument in url address
def win_func(window):
print(window.get_current_url())
webview.start(
func=win_func,
args=window,
gui=gui_type, # TODO: bug in qt; any menu action is always the last action
debug=args.debug,
menu=menu_items,
)
def cli():
parser = argparse.ArgumentParser(

442
controldeck_nicegui.py Normal file
View File

@@ -0,0 +1,442 @@
#!/usr/bin/env python
"""
https://nicegui.io/
"""
import sys
import os
import argparse
from configparser import ConfigParser
import re
from datetime import datetime
import subprocess
import shlex
import shutil
from nicegui import ui, app
# parameters defined
APP_NAME = "ControlDeck"
DEBUG = False
# parameters derived
CONFIG_DIR = os.path.join(os.path.expanduser("~"), '.config', APP_NAME.lower())
CONFIG_FILE_NAME = APP_NAME.lower() + '.conf'
CONFIG_FILE = os.path.join(CONFIG_DIR, CONFIG_FILE_NAME)
CACHE_DIR = os.path.join(os.path.expanduser('~'), '.cache', APP_NAME.lower())
STATIC_DIR = os.path.join(CACHE_DIR, 'static')
def config(conf=''):
cfg = ConfigParser(strict=False)
# fist check if file is given
if conf:
config_file = conf
else:
# check if config file is located at the script's location
config_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), CONFIG_FILE_NAME) # realpath; resolve symlink
if not os.path.exists(config_file):
# if not, use the file inside .config
os.makedirs(CONFIG_DIR, exist_ok=True)
config_file = CONFIG_FILE
try:
cfg.read(os.path.expanduser(config_file))
except Exception as e:
print(f"{e}")
#print(cfg.sections())
return cfg
def widget(cfg) -> dict:
"""scan for widgets to add from the config
{
tab-name: {
section-id: [
{widget-args}
]
}
}
known widgets: empty, label, button, slider, sink-inputs, sink, source
"""
widget_dict = {}
for i in cfg.sections():
iname = None
iname = re.search(
r"^([0-9a-z]*:)?([0-9]*\.)?(empty|label|button|slider|sink-inputs|sink|source)", # sink-inputs BEFORE sink!
i, flags=re.IGNORECASE)
if iname is not None:
tab_name = iname.group(1)[:-1].lower() if iname.group(1) is not None else '' # remove collon, id is '' if nothing is given
sec_id = iname.group(2)[:-1] if iname.group(2) is not None else '' # remove dot, id is '' if nothing is given
wid_type = iname.group(3).lower()
wid_name = i[iname.end(0)+1:] # rest; after last group, can have all chars including . and :
# check if tab is in dict else insert placeholder
if tab_name not in widget_dict:
widget_dict.update({tab_name: {}})
# check if section is in tab else insert placeholder
if sec_id not in widget_dict[tab_name]:
widget_dict[tab_name].update({sec_id: []})
widget_dict[tab_name][sec_id] += [{
'type': wid_type,
'text': wid_name}]
widget_dict[tab_name][sec_id][-1].update(
cfg.items(i))
return widget_dict
def widget_str(wgt) -> str:
"printable text of the structure"
# text = f'widgets: {wgt}'
text = ''
text += 'widgets:\n\n'
for tab, secs in wgt.items():
text += f'- tab: "{tab}"\n'
for sec, items in secs.items():
text += f' - sec: "{sec}"\n'
# text += f' - items: {items}\n'
for item in items:
text += f' - `{item}`\n'
return text
# output good for short / very fast processes, this will block until done
# callback good for long processes
def process(
command_line, shell=False, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, output=True, callback=None):
try:
# with shell=True args can be a string
# detached process https://stackoverflow.com/a/65900355/992129 start_new_session
# https://docs.python.org/3/library/subprocess.html#popen-constructor
# reading the output blocks also the process -> for buttons use output=False
# maybe https://stackoverflow.com/questions/375427/a-non-blocking-read-on-a-subprocess-pipe-in-python
# print(command_line)
if shell:
# shell mode for 'easy' program strings with pipes
# e.g. DISPLAY=:0 wmctrl -xl | grep emacs.Emacs && DISPLAY=:0 wmctrl -xa emacs.Emacs || DISPLAY=:0 emacs &
args = command_line
else:
args = shlex.split(command_line)
# print(args)
popen_args = (args, )
popen_kwargs = dict(
stdout=stdout,
stderr=stderr,
shell=shell,
start_new_session=True,
)
if callback is not None:
def run_in_thread(callback, popen_args, popen_kwargs):
proc = subprocess.Popen(*popen_args, **popen_kwargs)
proc.wait()
callback()
thread = threading.Thread(
target=run_in_thread,
args=(callback, popen_args, popen_kwargs))
thread.start()
else:
# proc = subprocess.Popen(args, stdout=stdout, stderr=stderr, shell=shell, start_new_session=True)
proc = subprocess.Popen(*popen_args, **popen_kwargs)
if output:
res = proc.stdout.read().decode("utf-8").rstrip()
proc.kill() # does not help to unblock
# print(res)
return res
except Exception as e:
print(f"process '{e}' failed!")
#
# CLI
#
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawTextHelpFormatter, # preserve formatting
prefix_chars='-',
add_help=False, # custom help text
)
parser.add_argument('-c', '--config', nargs='?', type=str, default='',
help="Specify a path to a custom config file (default: ~/.config/controldeck/controldeck.conf)")
parser.add_argument('--host', type=str, default='',
help="Specify the host to use (overwrites the value inside the config file, fallbacks to 127.0.0.1)")
parser.add_argument('--port', type=str, default='',
help="Specify the port to use (overwrites the value inside the config file, fallbacks to 8000)")
parser.add_argument('-v', '--verbose', action="store_true", help="Verbose output")
parser.add_argument('-D', '--debug', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('-h', '--help', action='store_true', # action help auto exits
help='Show this help message and exit')
args = parser.parse_args()
if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR, exist_ok=True)
if args.debug:
DEBUG = True
print('[DEBUG] args:', args)
print('[DEBUG] __file__:', __file__)
print('[DEBUG] cwd:', os.getcwd())
print('[DEBUG] CONFIG_DIR:', CONFIG_DIR, "exists", os.path.exists(CONFIG_DIR))
print('[DEBUG] CACHE_DIR:', CACHE_DIR, "exists", os.path.exists(CACHE_DIR))
print('[DEBUG] STATIC_DIR:', STATIC_DIR, "exists", os.path.exists(STATIC_DIR))
#import starlette.routing
#mounts = [i for i in app.routes if type(i) == starlette.routing.Mount]
#mounts = [{'path': i.path, 'name': i.name, 'directory': i.app.directory} for i in mounts]
#print(f"[DEBUG] app mounts: {mounts}")
cfg = config(args.config)
host = args.host if args.host else cfg.get('default', 'host', fallback='127.0.0.1')
port = args.port if args.port else cfg.get('default', 'port', fallback='8080')
if args.debug:
print('[DEBUG] host:', host)
print('[DEBUG] port:', port)
if args.help:
parser.print_help()
exit(0)
wgt = widget(cfg)
#
# NiceGUI
#
import yaml
app.add_static_files('/static', STATIC_DIR)
def empty(**kwargs):
# with ui.element('div') as d:
with ui.card().tight() as d:
d.classes('bg-grey-9') # TODO: remove bg color
d.style("width: 90px;min-height: 80px;line-height: 1em;")
# text = yaml.dump_all([kwargs])
text = "\n".join([ll.rstrip() for ll in yaml.dump_all([kwargs]).splitlines() if ll.strip()])
ui.tooltip(f"{text}").style('white-space: pre-wrap')
return d
def unknown(**kwargs):
with empty(**kwargs) as d:
d.classes(replace='bg-red-10')
ui.label(kwargs['text'])
return d
def label(**kwargs):
with empty(**kwargs) as d:
d.classes(replace='bg-blue-grey-10')
d.classes('q-pa-sm text-blue-grey-5 text-bold text-center q-btn--push')
# q-btn--push = border-radius: 7px;
ui.label(kwargs['text'])
return d
class ToggleButton(ui.button):
def __init__(self, *args, **kwargs) -> None:
# super().__init__(*args, **kwargs)
super().__init__()
self._state = False
self.on('click', self.toggle)
self.command = kwargs.get('command', None)
self.command_alt = kwargs.get('command-alt', None)
self.state_alt = kwargs.get('state-alt', None)
self.state_command = kwargs.get('state-command', '')
self.state = ''
if self.state_command:
try:
self.state = process(self.state_command, shell=True, output=True)
except Exception as e:
print(e)
if self.state == self.state_alt:
self._state = True
def toggle(self) -> None:
"""Toggle the button state."""
if self._state == False:
# button not yet toggled
if DEBUG:
print(f"[btn] command: {self.command}")
output = process(self.command, shell=True, output=False) # output=True freezes until finished
elif self.command_alt is not None:
if DEBUG:
print(f"[btn] command-alt: {self.command_alt}")
output = process(self.command_alt, shell=True, output=False) # output=True freezes until finished
self._state = not self._state
if self.state_alt is not None: # state_alt not command_alt b/c state for visual feedback
self.update()
def update(self) -> None:
# self.props(f'color={"green" if self._state else "red"}')
self.style(f'border: 1px solid {"#0277bd" if self._state else "#455a64"}')
super().update()
def button(**kwargs):
text = kwargs['description'] if 'description' in kwargs else kwargs['text']
icon = kwargs.get('icon', '')
image = kwargs.get('image', '')
if image and os.path.exists(image):
# copy image files into the static folder
basename = os.path.basename(image)
# e.g. media-playback-stop.svg
staticfile = os.path.join(STATIC_DIR, basename)
# e.g. <user-home>/.cache/controldeck/static/media-playback-stop.svg
if not os.path.exists(staticfile):
shutil.copy2(image, staticfile)
if DEBUG:
print(f'[DEBUG.btn.{text}] copy {image} to {staticfile}')
icon = f"img:/static/{basename}"
# e.g. img:/static/media-playback-stop.svg
# <q-icon name="img:data:image/svg+xml;charset=utf8,<svg xmlns='http://www.w3.org/2000/svg' height='140' width='500'><ellipse cx='200' cy='80' rx='100' ry='50' style='fill:yellow;stroke:purple;stroke-width:2' /></svg>" />
# <q-btn icon="img:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" ... />
if DEBUG:
print(f'[DEBUG.btn.{text}] icon: {icon}')
# with ui.button() as d:
with ToggleButton(**kwargs) as d:
d.props('dense')
d.classes('bg-grey-10')
# d.style("width: 90px;min-height: 77px;border: 1px solid var(--c-blue-grey-8);")
d.style("width: 90px;min-height: 80px;line-height: 1em;border: 1px solid #455a64;")
if 'color-bg' in kwargs and kwargs['color-bg']:
d.style(f"background-color: {kwargs['color-bg']};")
if 'color-fg' in kwargs and kwargs['color-fg']:
d.style(f"color: {kwargs['color-fg']} !important;")
tttext = "\n".join([ll.rstrip() for ll in yaml.dump_all([kwargs]).splitlines() if ll.strip()])
tttext += f"\nstate: {d.state}"
ui.tooltip(f"{tttext}").style('white-space: pre-wrap')
with d:
with ui.element('div').classes('w-full'):
ui.icon(icon)
ui.label(text)
return d
def slider(**kwargs):
"""command can have a {value} placeholder to insert slider value
"""
min = kwargs.get('min', '0')
min = float(min) if min else 0
max = kwargs.get('max', '100')
max = float(max) if max else 100
step = kwargs.get('step', '1')
step = float(step) if step else 1
icon = kwargs.get('icon', 'tune')
command = kwargs.get('command', '')
state_command = kwargs.get('state-command', '')
value = min
if state_command:
try:
value = float(process(state_command, shell=True))
except Exception as e:
print(e)
def action(*args, **kwargs):
# e.g. args[0] = GenericEventArguments(
# sender=<nicegui.elements.slider.Slider object at 0x7c3f8e865b50>,
# client=<nicegui.client.Client object at 0x7c3f8fcaac10>,
# args=0.95)
value = args[0].args
if DEBUG:
print("[sld] command:", command.format(value=value))
process(command.format(value=value), shell=True, output=False)
with ui.element('div') as d:
d.style("width: 302px;min-height: 80px;") # 3*90=270 + 2*16=32 = 302
text = "\n".join([ll.rstrip() for ll in yaml.dump_all([kwargs]).splitlines() if ll.strip()])
ui.tooltip(f"{text}").style('white-space: pre-wrap')
with ui.row().classes('text-ml'):
ui.icon(icon).classes('text-2xl')
text = kwargs['description'] if 'description' in kwargs else kwargs['text']
ui.label(text).style("padding-top:1px;")
v = ui.label().style("padding-top:1px;")
s = ui.slider(min=min, max=max, step=step, value=value).props('markers')
v.bind_text_from(s, 'value')
s.props('color=blue-9')#.props('label-always')
s.style("padding-left:16px;padding-right:16px;")
# s.style("width: 200px;padding-top:30px;")
#s.on_value_change(action)
s.on('update:model-value', lambda e: action(e),
throttle=1.0, leading_events=False) # update every second (only last value of every second)
return d
def volume(**kwargs):
# sinks (loudspeaker); icon: volume_up, volume_mute (or volume_off)
# source (microphone), icon: mic and mic_none (or mic_off)
# sink-input (app output)
# TODO: disable / mute button function on icon
icon = ''
if kwargs['type'] == 'sink':
icon = 'volume_up'
elif kwargs['type'] == 'source':
icon = 'mic'
elif kwargs['type'] == 'sink-input':
icon = 'volume_up'
kwargs.update({'min': 0, 'max': 100, 'step': 5, 'icon': icon})
with slider(**kwargs) as d:
pass
return d
def volume_group(**kwargs):
ds = []
kwargs.update({'wtype': 'sink-input'})
# for i in Volume.data['sink-inputs']:
for i in []:
with volume(**kwargs) as d:
pass
ds.append(d)
return ds
def reload() -> None:
global cfg, wgt
cfg = config(args.config)
wgt = widget(cfg)
ui.navigate.reload()
@ui.page('/')
def index(tab:str=''):
"""uses wgt"""
# tabs bar
with ui.header().classes(replace='row items-center no-wrap') as header:
ui.button(on_click=lambda: left_drawer.toggle(), icon='menu').props('flat color=white')
with ui.tabs(on_change=lambda evt: ui.run_javascript(f"window.history.pushState('', '', '/?tab={evt.value}')")) as tabs:
ui.tab(name='[all]', label='', icon='brightness_auto').tooltip('[all]')
# for tabi in sorted(wgt.keys()):
for tabi in wgt.keys():
opts = {'name': tabi, 'label': tabi}
if tabi == '':
opts.update({'name': '[]', 'label':'', 'icon': 'radio_button_unchecked'})
ui.tab(**opts).tooltip(opts['name'])
# tabs.style('overflow-x:auto;')
header.style('overflow-x:auto;')
header.classes('bg-blue-grey-10')
# footer
with ui.footer(value=False) as footer:
ui.label('Footer')
footer.classes('bg-blue-grey-10')
# left side drawer
with ui.left_drawer().classes('bg-blue-grey-10') as left_drawer:
ui.label('Side menu')
left_drawer_label = ui.label()
ui.timer(1.0, lambda: left_drawer_label.set_text(f'{datetime.now():%X}'))
left_drawer_button = ui.button('reload', on_click=lambda: reload())
# content for tabs
with ui.tab_panels(tabs, value=tab).classes('w-full'):
for tabi, secs in wgt.items():
if tabi == '':
tabi = '[]'
# ui.markdown(tabi)
with ui.tab_panel(tabi):
for seci, items in secs.items():
with ui.row():
for item in items:
if item['type'] == 'empty':
empty(**item)
if item['type'] == 'label':
label(**item)
elif item['type'] == 'button':
button(**item)
elif item['type'] == 'slider':
slider(**item)
elif item['type'] in ['sink', 'source']:
volume(**item)
elif item['type'] in ['sink-inputs']:
volume_group(**item)
else:
unknown(**item)
# button to toggle footer
with ui.page_sticky(position='bottom-right', x_offset=20, y_offset=20):
ui.button(on_click=footer.toggle, icon='contact_support').props('fab').classes('bg-blue-grey-10')
@ui.page('/test')
def test():
"""test"""
ui.markdown(widget_str(wgt)).classes('w-full')#.style('overflow-x:auto;')
ui.add_head_html("""<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">""")
ui.run(host=host, port=int(port), title=APP_NAME, dark=True)

217
controldeck_nicegui_gui.py Executable file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python
import sys
import os
import argparse
from tkinter import Tk, messagebox
import webview
from controldeck_nicegui import config, process
import threading
import time
def thread_function(name):
# print("Thread %s: starting", name)
for i in range(10):
# print("Thread %s: finishing", name)
# p = process("xdotool search --name 'ControlDeck'")
# intersection of ControlDeck window name and empty classname
p = process("comm -12 <(xdotool search --name 'ControlDeck' | sort) <(xdotool search --classname '^$' | sort)", shell=True)
if p:
# print(p)
# process("xdotool search --name 'ControlDeck' set_window --class 'controldeck'", output=False)
# process("xdotool search --name 'ControlDeck' set_window --classname 'controldeck' --class 'ControlDeck' windowunmap windowmap", output=False) # will find to many wrong ids
process(f"xdotool set_window --classname 'controldeck' --class 'ControlDeck' {p} windowunmap {p} windowmap {p}", shell=True, output=False)
time.sleep(0.1)
def main(args, pid=-1):
config = config_load(conf=args.config)
host = config.get('default', 'host', fallback='0.0.0.0')
port = config.get('default', 'port', fallback='8000')
url = f"http://{host}:{port}/?gui&pid={str(pid)}"
try:
width = config.getint('gui', 'width', fallback=800)
except ValueError as e:
width = 800
print(f"Error width: {e}. fallback to: {width}")
try:
height = config.getint('gui', 'height', fallback=600)
except ValueError as e:
width = 600
print(f"Error height: {e}. fallback to {width}")
try:
x = config.getint('gui', 'x', fallback='')
except ValueError as e:
x = None
print(f"Error x: {e}. fallback to {x}")
try:
y = config.getint('gui', 'y', fallback='')
except ValueError as e:
y = None
print(f"Error y: {e}. fallback to: {y}")
resizable = config.get('gui', 'resizable', fallback='True').title() == 'True'
fullscreen = config.get('gui', 'fullscreen', fallback='False').title() == 'True'
try:
min_width = config.getint('gui', 'min_width', fallback=200)
except ValueError as e:
min_width = 200
print(f"Error min_width: {e}. fallback to: {min_width}")
try:
min_height = config.getint('gui', 'min_height', fallback=100)
except ValueError as e:
min_height = 100
print(f"Error min_height: {e}. fallback to: {min_height}")
min_size = (min_width, min_height)
frameless = config.get('gui', 'frameless', fallback='False').title() == 'True'
minimized = config.get('gui', 'minimized', fallback='False').title() == 'True'
maximized = config.get('gui', 'maximized', fallback='False').title() == 'True'
on_top = config.get('gui', 'always_on_top', fallback='False').title() == 'True'
confirm_close = config.get('gui', 'confirm_close', fallback='False').title() == 'True'
transparent = config.get('gui', 'transparent', fallback='True').title() == 'True'
gui_type = config.get('gui', 'gui_type', fallback=None)
gui_type = gui_type if gui_type != "" else None
menu = config.get('gui', 'menu', fallback='True').title() == 'True'
if args.debug:
print(f"config file [default]: {config.items('default')}")
print(f"config file [gui]: {config.items('gui')}")
#controldeck_process = process("ps --no-headers -C controldeck")
#controldeck_process = process("ps --no-headers -C controldeck || ps aux | grep -e 'python.*controldeck.py' | grep -v grep", shell=True, output=True)
controldeck_process = process("ps --no-headers -C controldeck || ps aux | grep -e 'python.*controldeck_nicegui.py' | grep -v grep", shell=True, output=True)
if args.start and controldeck_process == "":
#cmd = "controldeck"
cmd = "controldeck_gui"
cmd += " --config={args.config}" if args.config else ""
print(cmd)
process(cmd, shell=True, output=False)
elif controldeck_process == "":
# cli output
print("controldeck is not running!")
# gui output
# Tkinter must have a root window. If you don't create one, one will be created for you. If you don't want this root window, create it and then hide it:
root = Tk()
root.withdraw()
messagebox.showinfo("ControlDeck", "controldeck is not running!")
# Other option would be to use the root window to display the information (Label, Button)
sys.exit(2)
window = webview.create_window(
title="ControlDeck",
url=url,
html=None,
js_api=None,
width=width,
height=height,
x=x,
y=y,
screen=None,
resizable=resizable,
fullscreen=fullscreen,
min_size=min_size,
hidden=False,
frameless=frameless,
easy_drag=True,
focus=True,
minimized=minimized,
maximized=maximized,
on_top=on_top,
confirm_close=confirm_close,
background_color='#000000',
transparent=transparent, # TODO: bug in qt; menu bar is transparent
text_select=False,
zoomable=False, # zoom via js
draggable=False,
vibrancy=False,
localization=None,
)
x = threading.Thread(target=thread_function, args=(1,))
x.start()
def menu_reload():
window = webview.active_window()
if window:
url = window.get_current_url()
window.load_url(url)
print(window.get_current_url())
print(window)
print(dir(window))
def menu_zoomin():
window = webview.active_window()
if window:
zoom = window.evaluate_js('document.documentElement.style.zoom')
if zoom == "":
zoom = 1.0
else:
zoom = float(zoom)
zoom += 0.1
print(f"zoom-in: {zoom}")
window.evaluate_js(f'document.documentElement.style.zoom = {zoom}')
print(f"set document.documentElement.style.zoom = {zoom}")
def menu_zoomout():
window = webview.active_window()
if window:
zoom = window.evaluate_js('document.documentElement.style.zoom')
if zoom == "":
zoom = 1.0
else:
zoom = float(zoom)
zoom -= 0.1
print(f"zoom-out: {zoom}")
window.evaluate_js(f'document.documentElement.style.zoom = {zoom}')
print(f"set document.documentElement.style.zoom = {zoom}")
def menu_zoomreset():
window = webview.active_window()
if window:
zoom = 1.0
print(f"zoom-reset: {zoom}")
window.evaluate_js(f'document.documentElement.style.zoom = {zoom}')
print(f"set document.documentElement.style.zoom = {zoom}")
menu_items = []
if menu:
menu_items = [webview.menu.Menu(
'Main', [
webview.menu.MenuAction('Reload', menu_reload),
webview.menu.MenuAction('zoom +', menu_zoomin),
webview.menu.MenuAction('zoom -', menu_zoomout),
webview.menu.MenuAction('zoom reset', menu_zoomreset),
]
)]
# TODO: zoom reset on reload (both from menu and within justpy)
# TODO: add zoom in config
# TODO: move zoom logic to justpy but then it is fix for all,
# maybe better a zoom argument in url address
def win_func(window):
print(window.get_current_url())
webview.start(
func=win_func,
args=window,
gui=gui_type, # TODO: bug in qt; any menu action is always the last action
debug=args.debug,
menu=menu_items,
)
def cli():
parser = argparse.ArgumentParser(
description=__doc__, prefix_chars='-',
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument('-c', '--config', nargs='?', type=str, default='',
help="Specify a path to a custom config file (default: ~/.config/controldeck/controldeck.conf)")
parser.add_argument('-s', '--start', action="store_true",
help="Start also controldeck program")
parser.add_argument('-v', '--verbose', action="store_true", help="Verbose output")
parser.add_argument('-D', '--debug', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args()
if args.debug:
print(args)
main(args, pid=os.getpid())
return 0
if __name__ == '__main__':
sys.exit(cli())

View File

@@ -1,16 +1,22 @@
[Unit]
Description=ControlDeck
ConditionFileIsExecutable=/usr/bin/controldeck
After=network.target
After=systemd-user-sessions.service getty@tty1.service plymouth-quit.service systemd-logind.service network.target
PartOf=graphical-session.target
[Service]
Environment=PYTHONUNBUFFERED=1
# add a pause to assure /etc/X11/xinit/xinitrc.d/50-systemd-user.sh to run
# this will add DISPLAY and XAUTHORITY env
# ExecStartPre=/bin/sleep 5
ExecStartPre=/bin/sh -c '(while test ! -v DISPLAY -o ! -v XAUTHORITY; do echo "wait for DISPLAY and XAUTHORITY"; sleep 2; done; echo "DISPLAY and XAUTHORITY found");'
# allowed time for the start
TimeoutStartSec=30
ExecStartPre=/bin/sh -c 'source /etc/profile'
ExecStart=/usr/bin/controldeck
ExecStart=/usr/bin/controldeck -D
Restart=on-failure
RestartSec=4
StandardOutput=journal
[Install]
WantedBy=default.target
#WantedBy=default.target
WantedBy=graphical-session.target

View File

@@ -6,8 +6,8 @@ After=network.target
[Service]
Environment=PYTHONUNBUFFERED=1
TimeoutStartSec=30
ExecStartPre=/bin/sh -c 'source /etc/profile'
ExecStart=%h/.local/bin/controldeck
#ExecStartPre=/bin/sh -c 'source /etc/profile'
ExecStart=%h/.local/bin/controldeck -D
Restart=on-failure
RestartSec=4
StandardOutput=journal

View File

@@ -1,59 +1,73 @@
# Examples:
#
# [N.volume.NAME]
# name = sink_name
# color-fg = hex color code
# color-bg = hex color code
#
# [TAB:N.empty.NAME]
# : TAB optional tab name to specify tab group
# : N. optional number to specify group/row
# : NAME id, name of the button
# : name sink name, see name with either:
# : NAME id of the empty spot
#
# [TAB:N.label.NAME]
# : TAB optional tab name to specify tab group
# : N. optional number to specify group/row
# : NAME id, name of the label
#
# [TAB:N.sink.NAME]
# : TAB optional tab name to specify tab group
# : N. optional number to specify group/row
# : NAME sink id name, see name with either:
# pactl list sinks short
# pamixer --list-sinks
# : color-bg background color
# : color-bg forground color
# description = text for the sink
#
# [N.button.NAME]
# text-alt = name
# color-fg = hex color code
# color-bg = hex color code
# command = shell command
# second command
# ...
# command-alt = shell command ...
# state = normal state
# state-command = shell command ...
# icon = Font Awesome
# icon-alt = Font Awesom
# image = path to svg file
# image-alt = path to svg file
# [TAB:N.source.NAME]
# : TAB optional tab name to specify tab group
# : N. optional number to specify group/row
# : NAME source id name, see name with either:
# pactl list sources short
# pamixer --list-sources
# description = text for the source
#
# [TAB:N.sink-inputs]
# : TAB optional tab name to specify tab group
# : N. optional number to specify group/row
#
# [TAB:N.button.NAME]
# : TAB optional tab name to specify tab group
# : N. optional group/row specification
# : NAME id, name of the button
# : text-alt optional alternative button text
# : color-bg background color
# : color-bg forground color
# : command command(s) to run
# : command-alt optional back-switch command(s) to run
# : state string to define the normal state
# : state-command command to get the state
# : icon use icon instead of NAME (Font Awesome), e.g.: fas fa-play
# : icon-alt optional alternative icon
# : image absolute path to svg file
# : image-alt optional alternative image
#
# [N.empty.NAME]
#
# : N. optional number to specify group/row
# text-alt = optional alternative burron text
# color-fg = foreground color as hex color code, e.g. #aa5500
# color-bg = background color as hex color code, e.g. #0055aa
# command = command(s) to run, seperated by new lines: shell command
# second command ...
# command-alt = optinal back-switch command(s) to run: shell command ...
# state-alt = string to define the alternative state (pressed)
# state-command = command to get the state: shell command ...
# icon = add icon in front of NAME, e.g. fas fa-play
# icon-alt = optional alternative icon
# image = absolte path to image file (svg, png)
# image-alt = optional alternative absolue path to image file
# [TAB:N.slider.NAME]
# : TAB optional tab name to specify tab group
# : N. optional group/row specification
# : NAME id, name of the button
# description = text for the slider
# icon = add icon in front of slider, e.g. tune
# min = minimum int value, e.g. 0
# max = maximum int value, e.g. 100
# step = step size, e.g. 1
# state-command = command to get the state: shell command
# command = command to run to get the value, using {value} in the command to
# interpolate the value: shell command
[default]
host = 0.0.0.0
port = 8000
# status = False
# volume-decrease-icon = fas fa-volume-down
# volume-increase-icon = fas fa-volume-up
# volume-mute-icon = fas fa-volume-off
# volume-mute-icon-alt = fas fa-volume-mute
# volume-mute-icon-alt =
# volume-decrease-image =
# volume-increase-image =
# volume-mute-image =
@@ -68,7 +82,6 @@
# mic-mute-image-alt =
[gui]
url = http://0.0.0.0:8000
width = 800
height = 600
# x and y specifying the window coordinate (empty = centered)
@@ -80,7 +93,13 @@ min_width = 200
min_height = 100
frameless = False
minimized = False
maximized = False
always_on_top = False
confirm_close = False
transparent = True
# gui_type: qt, gtk, cef, mshtml, edgechromium. or set env PYWEBVIEW_GUI
gui_type =
menu = True
[4.button.Test]
command = notify-send -a foo baz

View File

@@ -6,12 +6,11 @@ version = file: VERSION
install_requires =
justpy
pywebview
cairosvg
py_modules =
controldeck
controldeck_gui
[options.entry_points]
console_scripts =
controldeck = controldeck:main
controldeck = controldeck:cli
controldeck-gui = controldeck_gui:cli