Compare commits
8 Commits
6d502ea67e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ca3f6199 | |||
| eeb18679e6 | |||
| 2046adfc79 | |||
| bb8709bdf5 | |||
| 212a6ab426 | |||
| 2622d514c4 | |||
| b8d94b271f | |||
| 7c3cd04c1b |
31
README
31
README
@@ -1,8 +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:
|
||||
- python package justpy, the framework: pip install justpy --upgrade
|
||||
- (optionally) for volume buttons: libpulse
|
||||
- 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
14
TODO
Normal 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
|
||||
389
controldeck.py
389
controldeck.py
@@ -5,23 +5,27 @@ HTML style powered by Quasar
|
||||
NOTE: currently buttons only updated on page reload
|
||||
|
||||
Icon string
|
||||
https://quasar.dev/vue-components/icon#webfont-usagehttps://quasar.dev/vue-components/icon#webfont-usage
|
||||
- unicode
|
||||
- https://quasar.dev/vue-components/icon#webfont-usagehttps://quasar.dev/vue-components/icon#webfont-usage
|
||||
for example:
|
||||
without prefix uses material-icons https://fonts.google.com/icons?icon.set=Material+Icons
|
||||
"fas fa-" uses fontawesome-v5 https://fontawesome.com/icons
|
||||
- 💡
|
||||
- without prefix uses material-icons https://fonts.google.com/icons?icon.set=Material+Icons
|
||||
- "fas fa-" uses fontawesome-v5 https://fontawesome.com/v5/search?m=free
|
||||
"""
|
||||
|
||||
import sys
|
||||
from os import getcwd, path, sep, makedirs
|
||||
import os
|
||||
import shutil
|
||||
import shlex
|
||||
from subprocess import Popen, PIPE, STDOUT
|
||||
import subprocess
|
||||
from configparser import ConfigParser
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import argparse
|
||||
import textwrap
|
||||
import threading
|
||||
from addict import Dict # also used in justpy
|
||||
|
||||
APP_NAME = "ControlDeck"
|
||||
@@ -30,11 +34,11 @@ COLOR_PRIME_TEXT = "blue-grey-7"
|
||||
COLOR_SELECT = "light-blue-9"
|
||||
DEBUG = False
|
||||
|
||||
CONFIG_DIR = path.join(path.expanduser("~"), '.config', APP_NAME.lower())
|
||||
CONFIG_DIR = os.path.join(os.path.expanduser("~"), '.config', APP_NAME.lower())
|
||||
CONFIG_FILE_NAME = APP_NAME.lower() + '.conf'
|
||||
CONFIG_FILE = path.join(CONFIG_DIR, CONFIG_FILE_NAME)
|
||||
CACHE_DIR = path.join(path.expanduser('~'), '.cache', APP_NAME.lower())
|
||||
STATIC_DIR = path.join(CACHE_DIR, 'static')
|
||||
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')
|
||||
|
||||
# justpy config overwrite
|
||||
# NEEDS to be done BEFORE loading justpy but AFTER jpcore.justpy_config.JpConfig
|
||||
@@ -90,7 +94,11 @@ from justpy import (
|
||||
def tohtml(text):
|
||||
return text.replace("\n", "<br>")
|
||||
|
||||
def process(command_line, shell=False, output=True, stdout=PIPE, stderr=STDOUT):
|
||||
# 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
|
||||
@@ -105,14 +113,32 @@ def process(command_line, shell=False, output=True, stdout=PIPE, stderr=STDOUT):
|
||||
else:
|
||||
args = shlex.split(command_line)
|
||||
# print(args)
|
||||
result = Popen(args, stdout=stdout, stderr=stderr, shell=shell, start_new_session=True)
|
||||
if output:
|
||||
res = result.stdout.read().decode("utf-8").rstrip()
|
||||
result.kill() # does not help to unblock
|
||||
# print(res)
|
||||
return res
|
||||
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"{e} failed!")
|
||||
print(f"process '{e}' failed!")
|
||||
|
||||
def config_load(conf=''):
|
||||
config = ConfigParser(strict=False)
|
||||
@@ -121,20 +147,40 @@ def config_load(conf=''):
|
||||
config_file = conf
|
||||
else:
|
||||
# check if config file is located at the script's location
|
||||
config_file = path.join(path.dirname(path.realpath(__file__)), CONFIG_FILE_NAME) # realpath; resolve symlink
|
||||
if not path.exists(config_file):
|
||||
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
|
||||
makedirs(CONFIG_DIR, exist_ok=True)
|
||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||
config_file = CONFIG_FILE
|
||||
try:
|
||||
config.read(path.expanduser(config_file))
|
||||
config.read(os.path.expanduser(config_file))
|
||||
except Exception as e:
|
||||
print(f"{e}")
|
||||
#print(config.sections())
|
||||
return config
|
||||
|
||||
class Tile(QDiv):
|
||||
"""
|
||||
for empty spots and labels
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.style = "width: 90px;"
|
||||
self.style += "min-height: 70px;"
|
||||
|
||||
class Empty(Tile):
|
||||
"""
|
||||
empty slot, for horizontal arrangement, text is ignored, no bg color etc.
|
||||
|
||||
Args:
|
||||
**kwargs:
|
||||
- wtype: 'empty' (any string atm)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# TODO: colors definable in config
|
||||
class Label(QDiv):
|
||||
class Label(Tile):
|
||||
"""
|
||||
Args:
|
||||
**kwargs:
|
||||
@@ -143,16 +189,13 @@ class Label(QDiv):
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.style = "width: 90px;"
|
||||
self.style += "min-height: 70px;"
|
||||
if self.wtype != 'empty':
|
||||
self.classes = f"q-pa-sm text-blue-grey-5 text-bold text-center bg-blue-grey-10"
|
||||
# bg-light-blue-10
|
||||
#self.style += "font-size: 14px;"
|
||||
self.style += "line-height: 1em;"
|
||||
#self.style += "border-radius: 7px;"
|
||||
self.classes += " q-btn--push" # border-radius: 7px;
|
||||
self.text = self.text.upper()
|
||||
self.classes = f"q-pa-sm text-blue-grey-5 text-bold text-center bg-blue-grey-10"
|
||||
# bg-light-blue-10
|
||||
#self.style += "font-size: 14px;"
|
||||
self.style += "line-height: 1em;"
|
||||
#self.style += "border-radius: 7px;"
|
||||
self.classes += " q-btn--push" # border-radius: 7px;
|
||||
self.text = self.text.upper()
|
||||
#print()
|
||||
#print(self)
|
||||
|
||||
@@ -161,25 +204,31 @@ class Button(QBtn):
|
||||
"""
|
||||
Args:
|
||||
**kwargs:
|
||||
- text: button text in normal state (unpressed)
|
||||
- text_alt: button text in active state (pressed)
|
||||
- text: button id text
|
||||
- description: button text in normal state, if not set fallback to `text`
|
||||
- description_alt: button text in active state [not in use yet]
|
||||
- wtype: 'button' (any string atm) for a button
|
||||
- command: command to execute on click
|
||||
- command_alt: command to execute on click in active state
|
||||
- command_alt: if defined command to execute on click in active state
|
||||
otherwise using `command`
|
||||
- command_output: bool to grab command output or not
|
||||
- color_bg: background color
|
||||
- color_fg: foreground color
|
||||
- state_pattern: string defining the normal state (unpressed) [NEDDED?]
|
||||
- state_pattern: string defining the normal state [NEDDED?]
|
||||
- state_pattern_alt: string defining the alternative state
|
||||
(active, pressed)
|
||||
- state_command: command to execute to compare with state_pattern*
|
||||
- icon: icon in normal state (unpressed)
|
||||
- icon: icon in normal state
|
||||
- icon_alt: icon in active state
|
||||
- image: image in normal state, absolute path
|
||||
- image_alt: image in active state, absolute path
|
||||
|
||||
_alt is for and being in the alternative / active / pressed state,
|
||||
without _alt is for and being in the normal / unpressed state.
|
||||
|
||||
Usage:
|
||||
Button(text, text_alt, wtype, command, command_alt,
|
||||
color_bg=, color_fg=, state_pattern, state_pattern_alt,
|
||||
state_command,
|
||||
icon, icon_alt, image, image_alt,
|
||||
color_bg, color_fg, state_pattern, state_pattern_alt,
|
||||
state_command, icon, icon_alt, image, image_alt,
|
||||
a)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
@@ -194,15 +243,18 @@ class Button(QBtn):
|
||||
|
||||
# default **kwargs
|
||||
self.wtype = None # button or empty
|
||||
self.description = '' # button text
|
||||
self.image = '' # used for files like svg and png
|
||||
# e.g. /usr/share/icons/breeze-dark/actions/24/media-playback-stop.svg
|
||||
self.command = '' # command to run on click
|
||||
self.command_output = False
|
||||
self.state = '' # output of the state check command
|
||||
self.state_command = '' # command to check the unclicked state
|
||||
self.color_bg = kwargs.pop('color_bg', '')
|
||||
self.color_fg = kwargs.pop('color_fg', '')
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.text = self.description if self.description else self.text
|
||||
self.style = "width: 90px;"
|
||||
self.style += "min-height: 77px;" # image + 2 text lines
|
||||
self.style += "border: 1px solid var(--c-blue-grey-8);" # #455a64 blue-grey-8
|
||||
@@ -212,60 +264,109 @@ class Button(QBtn):
|
||||
if self.color_fg:
|
||||
self.style += f"color: {self.color_fg} !important;"
|
||||
|
||||
# if DEBUG:
|
||||
# print(f'[DEBUG] button: {self.text}; image: {self.image}; exists: {path.exists(self.image)}')
|
||||
if self.image and path.exists(self.image):
|
||||
if self.image and os.path.exists(self.image):
|
||||
# copy image files into the static folder
|
||||
basename = path.basename(self.image)
|
||||
basename = os.path.basename(self.image)
|
||||
# e.g. media-playback-stop.svg
|
||||
staticfile = path.join(STATIC_DIR, basename)
|
||||
staticfile = os.path.join(STATIC_DIR, basename)
|
||||
# e.g. <user-home>/.cache/controldeck/static/media-playback-stop.svg
|
||||
if not path.exists(staticfile):
|
||||
if not os.path.exists(staticfile):
|
||||
shutil.copy2(self.image, staticfile)
|
||||
if DEBUG:
|
||||
print(f'[DEBUG] copy {self.image} to {staticfile}')
|
||||
print(f'[DEBUG.btn.{self.text}] copy {self.image} to {staticfile}')
|
||||
self.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] button: {self.text}; icon: {staticfile}; exists: {path.exists(self.image)}')
|
||||
# print(f'[DEBUG] button: {self.text}; icon: {self.icon}')
|
||||
if DEBUG:
|
||||
print(f'[DEBUG.btn.{self.text}] icon: {self.icon}')
|
||||
|
||||
if self.command != '':
|
||||
self.update_state()
|
||||
tt = f"command: {self.command.strip()}"
|
||||
if self.state_command:
|
||||
tt += f"\nstate: {self.state}"
|
||||
QTooltip(a=self, text=tt, delay=500) # setting style white-space:pre does not work, see wp.css below
|
||||
def click(self, msg):
|
||||
if self.command != '':
|
||||
self.update_state()
|
||||
if DEBUG:
|
||||
print(f"[btn] command: {self.command}")
|
||||
process(self.command, shell=True, output=False) # output=True freezes controldeck until process finished (until e.g. an emacs button is closed)
|
||||
self.on('click', click)
|
||||
# setting style white-space:pre does not work, see wp.css below
|
||||
self.tooltip = QTooltip(a=self, delay=500)
|
||||
self.update_tooltip()
|
||||
self.on('click', self.click)
|
||||
|
||||
async def click(self, msg):
|
||||
if self.command != '':
|
||||
if DEBUG:
|
||||
print(f"[DEBUG.btn.{self.text}] command: {self.command}")
|
||||
# output=True freezes controldeck until process finished (until e.g. an emacs button is closed)
|
||||
outputting = False
|
||||
if DEBUG and self.command_output:
|
||||
outputting = True
|
||||
|
||||
output = process(self.command, shell=True, output=outputting)
|
||||
|
||||
# def upd():
|
||||
# time.sleep(2)
|
||||
# print('foo')
|
||||
# self.text = ''
|
||||
# self.update_state()
|
||||
# self.update_tooltip()
|
||||
# self.update()
|
||||
# #self.wp.update()
|
||||
# #wp.update()
|
||||
# #await msg.page.update()
|
||||
# print('baz')
|
||||
# process(self.command, shell=True, output=True, callback=upd)
|
||||
|
||||
if DEBUG:
|
||||
print(f"[DEBUG.btn.{self.text}] output: {output}")
|
||||
# TODO: command and state matching: command is running async
|
||||
# and not 'finished' for state update. wait is also not
|
||||
# 'possible' bc/ it could be long running process
|
||||
time.sleep(1)
|
||||
self.update_state()
|
||||
self.update_tooltip()
|
||||
else:
|
||||
return True
|
||||
|
||||
def update_tooltip(self):
|
||||
if '\n' in self.command.strip():
|
||||
ttt = f"command:\n{textwrap.indent(self.command.strip(), ' ')}"
|
||||
else:
|
||||
ttt = f"command: {self.command.strip()}"
|
||||
if self.state_command:
|
||||
if '\n' in self.state:
|
||||
ttt += f"\nstate:\n{textwrap.indent(self.state, ' ')}"
|
||||
else:
|
||||
ttt += f"\nstate: {self.state}"
|
||||
self.tooltip.text = ttt
|
||||
|
||||
def is_state_alt(self):
|
||||
return self.state == self.state_pattern_alt
|
||||
# return repr(self.state) == repr(self.state_pattern_alt).replace(r'\\', '\\')
|
||||
return repr(self.state) == repr(self.state_pattern_alt)
|
||||
|
||||
def update_state(self):
|
||||
if self.state_command != '':
|
||||
self.state = process(self.state_command, shell=True)
|
||||
if DEBUG:
|
||||
print("[btn] update btn state")
|
||||
print(f"[btn] text: {self.text}")
|
||||
print(f"[btn] state (before click): {self.state}")
|
||||
print(f"[btn] state_command: {self.state_command}")
|
||||
print(f"[btn] state_pattern: {self.state_pattern}")
|
||||
print(f"[btn] state_pattern_alt: {self.state_pattern_alt}")
|
||||
print(f"[btn] is_state_alt: {self.is_state_alt()}")
|
||||
print(f"[DEBUG.btn.{self.text}] updated btn state")
|
||||
print(f"[DEBUG.btn.{self.text}] state_command: {self.state_command}")
|
||||
print(f"[DEBUG.btn.{self.text}] state: {repr(self.state)} # state_pattern: {repr(self.state_pattern)} # state_pattern_alt: {repr(self.state_pattern_alt)}")
|
||||
print(f"[DEBUG.btn.{self.text}] is_state_alt: {self.is_state_alt()}")
|
||||
# TODO: update state instead of appending
|
||||
if self.is_state_alt():
|
||||
# self.style += "border: 1px solid green;"
|
||||
# self.style += "border-bottom: 1px solid green;"
|
||||
self.style += "border: 1px solid var(--c-light-blue-9);"
|
||||
# self.style += "border-bottom: 1px solid var(--c-light-blue);"
|
||||
else:
|
||||
self.style += "border: 1px solid var(--c-blue-grey-8);"
|
||||
else:
|
||||
return True
|
||||
|
||||
# can be used to update all buttons on event
|
||||
# is like a full reload, and the page is blocked
|
||||
# def react(self, data):
|
||||
# # print(f"self {self}")
|
||||
# # print(f"data {data}")
|
||||
# self.update_state()
|
||||
# if hasattr(self, 'tooltip'):
|
||||
# self.update_tooltip()
|
||||
# # print(f"react done")
|
||||
|
||||
class Slider(Div):
|
||||
def __init__(self, **kwargs):
|
||||
@@ -277,8 +378,17 @@ class Slider(Div):
|
||||
self.wtype = 'slider' # slider (atm the only option)
|
||||
self.name = '' # id name
|
||||
self.description = '' # badge name
|
||||
self.command = '' # command to run on click
|
||||
self.state_command = '' # command to check the current state
|
||||
|
||||
self.min = kwargs.pop('min', '0')
|
||||
self.min = float(self.min) if self.min else 0
|
||||
self.max = kwargs.pop('max', '100')
|
||||
self.max = float(self.max) if self.max else 100
|
||||
self.step = kwargs.pop('step', '1')
|
||||
self.step = float(self.step) if self.step else 1
|
||||
super().__init__(**kwargs)
|
||||
self.icon = self.icon if self.icon else 'tune'
|
||||
self.style = "width:286px;" # three buttons and the two spaces
|
||||
|
||||
self.cmdl_toggle = '' # cmd for the button left
|
||||
@@ -286,8 +396,14 @@ class Slider(Div):
|
||||
|
||||
# local vars
|
||||
badge_name = self.description if self.description else self.name
|
||||
value = 0
|
||||
value = self.min
|
||||
if self.state_command:
|
||||
try:
|
||||
value = float(process(self.state_command, shell=True))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
# 1st row; badge
|
||||
badge = QBadge(
|
||||
text=badge_name,
|
||||
outline=True,
|
||||
@@ -300,24 +416,40 @@ class Slider(Div):
|
||||
a=badge, text=badge_name, delay=500, anchor='center left',
|
||||
offset=[0, 14], transition_show='jump-right', transition_hide='jump-left')
|
||||
tt.self='center left'
|
||||
|
||||
# 2nd row; icon and slider
|
||||
item = QItem(
|
||||
a=self,
|
||||
dense=True, # dense: less spacing: vertical little higher
|
||||
)
|
||||
|
||||
# left icon
|
||||
item_section = QItemSection(
|
||||
side=True, # side=True unstreched btn
|
||||
a=item,
|
||||
)
|
||||
QIcon(
|
||||
name=self.icon,
|
||||
color=COLOR_PRIME_TEXT,
|
||||
left=True,
|
||||
a=item_section,
|
||||
)
|
||||
|
||||
# right slider
|
||||
item_section2 = QItemSection(a=item)
|
||||
def handle_slider(widget_self, msg):
|
||||
# process(cmdl_value.format(name=self.name,value=msg.value), shell=True, output=False)
|
||||
pass
|
||||
if '{value}' in self.command:
|
||||
if DEBUG:
|
||||
print("[sld] command:", self.command.format(value=msg.value))
|
||||
process(self.command.format(value=msg.value), shell=True, output=False)
|
||||
else:
|
||||
if DEBUG:
|
||||
print("[sld] command:", self.command)
|
||||
self.slider = QSlider(
|
||||
value=value,
|
||||
min=0,
|
||||
max=100,
|
||||
step=5,
|
||||
min=self.min,
|
||||
max=self.max,
|
||||
step=self.step,
|
||||
label=True,
|
||||
a=item_section2,
|
||||
input=handle_slider,
|
||||
@@ -327,7 +459,7 @@ class Slider(Div):
|
||||
|
||||
# TODO: toggle?
|
||||
def is_toggled(self):
|
||||
self.toggled = not self.toggled
|
||||
# self.toggled = not self.toggled
|
||||
return self.toggled
|
||||
|
||||
class Volume(Div):
|
||||
@@ -382,6 +514,7 @@ class Volume(Div):
|
||||
volume_level = float(self.pa_state['volume']['mono']['value_percent'][:-1]) # remove the % sign
|
||||
# TODO: ? indicator if stream is stereo or mono ?
|
||||
|
||||
# 1st row; badge
|
||||
badge = QBadge(
|
||||
text=badge_name,
|
||||
outline=True,
|
||||
@@ -394,16 +527,19 @@ class Volume(Div):
|
||||
a=badge, text=badge_name, delay=500, anchor='center left',
|
||||
offset=[0, 14], transition_show='jump-right', transition_hide='jump-left')
|
||||
tt.self='center left'
|
||||
|
||||
# 2nd row; icon and slider
|
||||
item = QItem(
|
||||
a=self,
|
||||
dense=True, # dense: less spacing: vertical little higher
|
||||
)
|
||||
|
||||
# left icon
|
||||
item_section = QItemSection(
|
||||
side=True, # side=True unstreched btn,
|
||||
#avatar=True, # more spacing than side
|
||||
a=item,
|
||||
)
|
||||
# QIcon(name="volume_up", a=item_section)
|
||||
def handle_btn(widget_self, msg):
|
||||
# not checking the current state
|
||||
process(cmdl_toggle.format(name=self.name), shell=True, output=False)
|
||||
@@ -424,6 +560,8 @@ class Volume(Div):
|
||||
color=COLOR_PRIME_TEXT,
|
||||
click=handle_btn,
|
||||
)
|
||||
|
||||
# right slider
|
||||
item_section2 = QItemSection(a=item)
|
||||
def handle_slider(widget_self, msg):
|
||||
process(cmdl_value.format(name=self.name,value=msg.value), shell=True, output=False)
|
||||
@@ -519,6 +657,11 @@ class VolumeGroup():
|
||||
for i in Volume.data['sink-inputs']:
|
||||
Volume(a=a, name=i['index'], wtype='sink-input')
|
||||
|
||||
async def update(self, msg):
|
||||
for i,j in Button.instances.items():
|
||||
if type(j) == Button:
|
||||
j.update_state()
|
||||
|
||||
async def reload(self, msg):
|
||||
await msg.page.reload()
|
||||
|
||||
@@ -558,7 +701,7 @@ def widget_load(config) -> dict:
|
||||
if iname is not None:
|
||||
tab_name = iname.group(1)[:-1] 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)
|
||||
wid_type = iname.group(3).lower()
|
||||
wid_name = i[iname.end(0)+1:] # rest; after last group, can have all chars including . and :
|
||||
# print('group ', iname.group(0))
|
||||
# print('tab_id ', tab_name)
|
||||
@@ -568,7 +711,7 @@ def widget_load(config) -> dict:
|
||||
# print('')
|
||||
if wid_type == 'empty':
|
||||
# TODO: empty using label class, like an alias?
|
||||
args = [{'widget-class': 'Empty',
|
||||
args = [{'widget-class': Empty,
|
||||
'type': wid_type}]
|
||||
elif wid_type == 'label':
|
||||
args = [{'widget-class': Label,
|
||||
@@ -579,11 +722,13 @@ def widget_load(config) -> dict:
|
||||
args = [{'widget-class': Button,
|
||||
'type': wid_type,
|
||||
'text': wid_name,
|
||||
'text-alt': config.get(i, 'text-alt', fallback=''),
|
||||
'description': config.get(i, 'description', fallback=''),
|
||||
'description-alt': config.get(i, 'description-alt', fallback=''),
|
||||
'color-bg': config.get(i, 'color-bg', fallback=''),
|
||||
'color-fg': config.get(i, 'color-fg', fallback=''),
|
||||
'command': config.get(i, 'command', fallback=''),
|
||||
'command-alt': config.get(i, 'command-alt', fallback=''),
|
||||
'command-output': config.get(i, 'command-output', fallback='False').title() == 'True',
|
||||
'state': config.get(i, 'state', fallback=''),
|
||||
'state-alt': config.get(i, 'state-alt', fallback=''),
|
||||
'state-command': config.get(i, 'state-command', fallback=''),
|
||||
@@ -597,6 +742,12 @@ def widget_load(config) -> dict:
|
||||
'type': wid_type,
|
||||
'name': wid_name,
|
||||
'description': config.get(i, 'description', fallback=''),
|
||||
'icon': config.get(i, 'icon', fallback=''),
|
||||
'command': config.get(i, 'command', fallback=''),
|
||||
'state-command': config.get(i, 'state-command', fallback=''),
|
||||
'min': config.get(i, 'min', fallback=''),
|
||||
'max': config.get(i, 'max', fallback=''),
|
||||
'step': config.get(i, 'step', fallback=''),
|
||||
}]
|
||||
elif wid_type == 'sink':
|
||||
# volume sliders
|
||||
@@ -791,9 +942,10 @@ async def application(request):
|
||||
|
||||
QSpace(a=toolbar)
|
||||
|
||||
# BUTTON edit config
|
||||
def toggle_edit_config(self, msg):
|
||||
self.dialog.value = True
|
||||
if path.exists(CONFIG_FILE):
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
self.dialog_label.text = CONFIG_FILE
|
||||
with open(CONFIG_FILE, encoding='utf-8') as file:
|
||||
self.dialog_input.value = file.read()
|
||||
@@ -815,7 +967,7 @@ async def application(request):
|
||||
QSpace(a=edit_dialog_bar)
|
||||
QSeparator(vertical=True,spaced=True,a=edit_dialog_bar)
|
||||
def edit_dialog_save(self, msg):
|
||||
if path.exists(CONFIG_FILE):
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
with open(CONFIG_FILE, mode='w', encoding='utf-8') as file:
|
||||
file.write(self.dialog_input.value)
|
||||
self.dialog_input.remove_class('changed')
|
||||
@@ -921,7 +1073,7 @@ async def application(request):
|
||||
edit_config = QBtn(
|
||||
dense=True,
|
||||
flat=True,
|
||||
icon='edit',
|
||||
icon='edit', # not working: edit_note, app_registration
|
||||
a=toolbar,
|
||||
click=toggle_edit_config,
|
||||
dialog=edit_dialog,
|
||||
@@ -930,22 +1082,34 @@ async def application(request):
|
||||
)
|
||||
QTooltip(a=edit_config, text='Config')
|
||||
|
||||
# BUTTON dark mode toggle
|
||||
# async def dark_light_mode_toggle(self, msg):
|
||||
# if self.icon == 'dark_mode':
|
||||
# self.icon = 'light_mode'
|
||||
# elif self.icon == 'light_mode':
|
||||
# self.icon = 'dark_mode'
|
||||
# icon='contrast' not working
|
||||
btn_toogle_dark = ToggleDarkModeBtn(
|
||||
label='', icon='settings_brightness', dense=True, flat=True, a=toolbar,
|
||||
label='', icon='brightness_medium', dense=True, flat=True, a=toolbar,
|
||||
#click=dark_light_mode_toggle
|
||||
)
|
||||
QTooltip(a=btn_toogle_dark, text='Toggle dark/light mode')
|
||||
|
||||
# BUTTON fullscreen
|
||||
async def toggle_screen(self, msg):
|
||||
await msg.page.run_javascript('Quasar.AppFullscreen.toggle()')
|
||||
btn_fullscreen = QBtn(dense=True, flat=True, icon='crop_square', a=toolbar, click=toggle_screen)
|
||||
btn_fullscreen = QBtn(dense=True, flat=True, icon='fullscreen', a=toolbar, click=toggle_screen)
|
||||
QTooltip(a=btn_fullscreen, text='Toggle fullscreen')
|
||||
btn_reload = QBtn(dense=True, flat=True, icon="redo", click=reload, a=toolbar)
|
||||
QTooltip(a=btn_reload, text='Reload')
|
||||
|
||||
# BUTTON update
|
||||
btn_update = QBtn(dense=True, flat=True, icon="update", click=update, a=toolbar)
|
||||
QTooltip(a=btn_update, text='Update buttons')
|
||||
|
||||
# BUTTON reload
|
||||
btn_reload = QBtn(dense=True, flat=True, icon="refresh", click=reload, a=toolbar)
|
||||
QTooltip(a=btn_reload, text='Reload config')
|
||||
|
||||
# BUTTON close
|
||||
if "gui" in request.query_params:
|
||||
btn_close = QBtn(dense=True, flat=True, icon="close", click=kill_gui, a=toolbar)
|
||||
QTooltip(a=btn_close, text='Close')
|
||||
@@ -970,10 +1134,10 @@ async def application(request):
|
||||
classes="row q-pa-sm q-gutter-sm",
|
||||
a=tab_panel[tab_name])
|
||||
# TODO: empty using label class, like an alias?
|
||||
if j['widget-class'] == 'Empty':
|
||||
Label(text='',
|
||||
wtype=j['type'],
|
||||
a=eval(var))
|
||||
if j['widget-class'] == Empty:
|
||||
j['widget-class'](
|
||||
wtype=j['type'],
|
||||
a=eval(var))
|
||||
if j['widget-class'] == Label:
|
||||
j['widget-class'](
|
||||
text=j['text'],
|
||||
@@ -981,9 +1145,12 @@ async def application(request):
|
||||
a=eval(var))
|
||||
if j['widget-class'] == Button:
|
||||
j['widget-class'](
|
||||
text=j['text'], text_alt=j['text-alt'],
|
||||
text=j['text'],
|
||||
wtype=j['type'],
|
||||
description=j['description'],
|
||||
description_alt=j['description-alt'],
|
||||
command=j['command'], command_alt=j['command-alt'],
|
||||
command_output=j['command-output'],
|
||||
color_bg=j['color-bg'], color_fg=j['color-fg'],
|
||||
state_pattern=j['state'], state_pattern_alt=j['state-alt'],
|
||||
state_command=j['state-command'],
|
||||
@@ -994,6 +1161,9 @@ async def application(request):
|
||||
j['widget-class'](
|
||||
name=j['name'], description=j['description'],
|
||||
wtype=j['type'],
|
||||
icon=j['icon'],
|
||||
command=j['command'], state_command=j['state-command'],
|
||||
min=j['min'], max=j['max'], step=j['step'],
|
||||
a=eval(var))
|
||||
elif j['widget-class'] == Volume:
|
||||
j['widget-class'](
|
||||
@@ -1042,9 +1212,30 @@ def hello_function():
|
||||
wp.add(P(text='Hello there!', classes='text-5xl m-2'))
|
||||
return wp
|
||||
|
||||
# import asyncio
|
||||
# async def clock():
|
||||
# i = 0
|
||||
# while True:
|
||||
# i += 1
|
||||
# print(f"update clock # {i}")
|
||||
# for i,j in Button.instances.items():
|
||||
# if type(j) == Button:
|
||||
# pass
|
||||
# #print(j)
|
||||
# run_task(j.update_state_async())
|
||||
# #await j.update_state()
|
||||
# #j.update_state()
|
||||
# # clock_div.text = time.strftime("%a, %d %b %Y, %H:%M:%S", time.localtime())
|
||||
# run_task(wp.update())
|
||||
# await asyncio.sleep(20)
|
||||
|
||||
# async def clock_init():
|
||||
# print("start update clock")
|
||||
# run_task(clock())
|
||||
|
||||
def main(args, host, port):
|
||||
if not path.exists(STATIC_DIR):
|
||||
makedirs(STATIC_DIR, exist_ok=True)
|
||||
if not os.path.exists(STATIC_DIR):
|
||||
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||
justpy(host=host, port=port, start_server=True)
|
||||
# this process will run as main loop
|
||||
|
||||
@@ -1072,10 +1263,10 @@ def cli():
|
||||
DEBUG = True
|
||||
print('[DEBUG] args:', args)
|
||||
print('[DEBUG] __file__:', __file__)
|
||||
print('[DEBUG] cwd:', getcwd())
|
||||
print('[DEBUG] CONFIG_DIR:', CONFIG_DIR, "exists", path.exists(CONFIG_DIR))
|
||||
print('[DEBUG] CACHE_DIR:', CACHE_DIR, "exists", path.exists(CACHE_DIR))
|
||||
print('[DEBUG] STATIC_DIR:', STATIC_DIR, "exists", path.exists(STATIC_DIR))
|
||||
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]
|
||||
|
||||
442
controldeck_nicegui.py
Normal file
442
controldeck_nicegui.py
Normal 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
217
controldeck_nicegui_gui.py
Executable 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())
|
||||
@@ -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 -D
|
||||
Restart=on-failure
|
||||
RestartSec=4
|
||||
StandardOutput=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
#WantedBy=default.target
|
||||
WantedBy=graphical-session.target
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
# 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 (Font Awesome), e.g. fas fa-play
|
||||
# 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
|
||||
@@ -52,6 +52,13 @@
|
||||
# : 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
|
||||
|
||||
Reference in New Issue
Block a user