diff --git a/controldeck.py b/controldeck.py
index 0895ad6..24510ed 100755
--- a/controldeck.py
+++ b/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", "
")
-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. /.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
#
#
- # 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)
+ output = False
+ if DEBUG and self.command_output:
+ output = True
+
+ process(self.command, shell=True, output=output)
+
+ # 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):
@@ -556,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()
@@ -595,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)
@@ -605,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,
@@ -616,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=''),
@@ -834,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()
@@ -858,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')
@@ -964,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,
@@ -973,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')
@@ -1013,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'],
@@ -1024,8 +1145,10 @@ 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'],
color_bg=j['color-bg'], color_fg=j['color-fg'],
state_pattern=j['state'], state_pattern_alt=j['state-alt'],
@@ -1088,9 +1211,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
@@ -1118,10 +1262,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]