#!/usr/bin/env python
"""
HTML style powered by Quasar
NOTE: currently buttons only updated on page reload
Icon string
- 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/v5/search?m=free
"""
import sys
import os
import shutil
import shlex
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"
COLOR_PRIME = "blue-grey-8" # "blue-grey-7" "blue-grey-8" 'light-blue-9'
COLOR_PRIME_TEXT = "blue-grey-7"
COLOR_SELECT = "light-blue-9"
DEBUG = False
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')
# justpy config overwrite
# NEEDS to be done BEFORE loading justpy but AFTER jpcore.justpy_config.JpConfig
# jpcore.justpy_config.JpConfig loads defaults into jpcore.jpconfig
# justpy creates app mounts
from jpcore.justpy_config import JpConfig
import jpcore.jpconfig
jpcore.jpconfig.STATIC_DIRECTORY = STATIC_DIR
from justpy import (
app,
Div,
I,
P,
QuasarPage,
QBadge,
QBar,
QBtn,
QBtnGroup,
QBtnToggle,
QCard,
QCardSection,
QDialog,
QDiv,
QEditor,
QHeader,
QIcon,
QInput,
QItem,
QItemLabel,
QItemSection,
QLayout,
QNotify,
QPage,
QPageContainer,
QSeparator,
QSlider,
QSpace,
QTab,
QTabs,
QTabPanel,
QTabPanels,
QToolbar,
QTooltip,
SetRoute,
ToggleDarkModeBtn,
WebPage,
parse_html,
run_task,
justpy
)
def tohtml(text):
return text.replace("\n", "
")
# 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!")
def config_load(conf=''):
config = 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:
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(Tile):
"""
Args:
**kwargs:
- wtype: if 'empty' then: empty slot, for horizontal arrangement, text is
ignored, no bg color etc.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
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)
# TODO: distinguish between non-command and comand inactive stage?
class Button(QBtn):
"""
Args:
**kwargs:
- 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: 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 [NEDDED?]
- state_pattern_alt: string defining the alternative state
- state_command: command to execute to compare with state_pattern*
- 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,
a)
"""
def __init__(self, **kwargs):
self.stack = True
# self.outline = True # is set via style, see below
# self.color = "blue-grey-8" # is overwritten via text_color
self.text_color = COLOR_PRIME_TEXT
self.size = 'md' # button / font size
self.push = True
# self.padding = 'none' # not working, also not inside init
self.dense = True
# 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
self.style += "line-height: 1em;"
if self.color_bg:
self.style += f"background-color: {self.color_bg};"
if self.color_fg:
self.style += f"color: {self.color_fg} !important;"
if self.image and os.path.exists(self.image):
# copy image files into the static folder
basename = os.path.basename(self.image)
# e.g. media-playback-stop.svg
staticfile = os.path.join(STATIC_DIR, basename)
# e.g. /.cache/controldeck/static/media-playback-stop.svg
if not os.path.exists(staticfile):
shutil.copy2(self.image, staticfile)
if DEBUG:
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.btn.{self.text}] icon: {self.icon}')
if self.command != '':
self.update_state()
# 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 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(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):
# instance vars
self.slider = None # for handle methods to access slider
self.toggled = False
# default **kwargs
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
self.cmdl_value = '' # cmd for the slider value
# local vars
badge_name = self.description if self.description else self.name
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,
color=COLOR_PRIME_TEXT,
style="max-width:286px;", # 3*90 + 2*8
classes="ellipsis",
a=self,
)
tt = QTooltip(
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):
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=self.min,
max=self.max,
step=self.step,
label=True,
a=item_section2,
input=handle_slider,
color=COLOR_SELECT,
style="opacity: 0.6 !important;" if self.is_toggled() else "opacity: unset !important;",
)
# TODO: toggle?
def is_toggled(self):
# self.toggled = not self.toggled
return self.toggled
class Volume(Div):
# class variables
data = {} # pulseaudio info for all sinks
icon_muted = 'volume_mute' # default icon for muted state, 'volume_off' better for disabled?
icon_unmuted = 'volume_up' # default icon for unmuted state
last_update = 0 # used for updates. init set to zero so the 1st diff is large to go into update at startup
def __init__(self, **kwargs):
# instance vars
self.slider = None # for handle methods to access slider
# default **kwargs
self.wtype = 'sink' # sink (loudspeaker), source (microphone) or sink-input (app output)
self.name = '' # pulseaudio sink or source name
self.description = '' # badge name
super().__init__(**kwargs)
self.style = "width:286px;" # three buttons and the two spaces
self.update_state() # get self.pa_state
if self.pa_state:
if self.wtype == 'sink':
cmdl_toggle = 'pactl set-sink-mute {name} toggle'
cmdl_value = 'pactl set-sink-volume {name} {value}%'
elif self.wtype == 'source':
cmdl_toggle = 'pactl set-source-mute {name} toggle'
cmdl_value = 'pactl set-source-volume {name} {value}%'
self.icon_muted = 'mic_none' # default icon for muted state, 'mic_off' better for disabled?
self.icon_unmuted = 'mic' # default icon for unmuted state
elif self.wtype == 'sink-input':
cmdl_toggle = 'pactl set-sink-input-mute {name} toggle'
cmdl_value = 'pactl set-sink-input-volume {name} {value}%'
app_name = self.pa_state['properties']['application.process.binary'] if 'application.process.binary' in self.pa_state['properties'] else ''
if app_name == '':
app_name = self.pa_state['properties']['application.name'] if 'application.name' in self.pa_state['properties'] else ''
if app_name == '':
app_name = self.pa_state['properties']['node.name'] if 'node.name' in self.pa_state['properties'] else ''
self.description = \
app_name +\
': ' +\
self.pa_state['properties']['media.name']
# local vars
badge_name = self.description if self.description else self.name
volume_level = 0
if self.pa_state: # might be empty {} if it is not found
# pulseaudio 2^16 (65536) volume levels
if 'front-left' in self.pa_state['volume']:
volume_level = float(self.pa_state['volume']['front-left']['value_percent'][:-1]) # remove the % sign
elif 'mono' in self.pa_state['volume']:
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,
color=COLOR_PRIME_TEXT,
style="max-width:286px;", # 3*90 + 2*8
classes="ellipsis",
a=self,
)
tt = QTooltip(
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,
)
def handle_btn(widget_self, msg):
# not checking the current state
process(cmdl_toggle.format(name=self.name), shell=True, output=False)
if widget_self.icon == self.icon_unmuted: # switch to mute
widget_self.icon = self.icon_muted
self.slider.style = "opacity: 0.6 !important;"
elif widget_self.icon == self.icon_muted: # switch to unmute
widget_self.icon = self.icon_unmuted
self.slider.style = "opacity: unset !important;"
# toggle disable state
# this wont allow to change the volume in a mute state
# self.slider.disable = not self.slider.disable
QBtn(
icon=self.icon_muted if self.is_muted() else self.icon_unmuted,
dense=True,
flat=True,
a=item_section,
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)
self.slider = QSlider(
value=volume_level,
min=0,
max=100,
step=5,
label=True,
a=item_section2,
input=handle_slider,
color=COLOR_SELECT,
# track_color=COLOR_PRIME, # not working, see .q-slider__track-container
# markers=True,
# marker_labels=True, # not working?
# track_size="10px", # not working, see cs .q-slider__track-container
# style = "height: 4px;"
style="opacity: 0.6 !important;" if self.is_muted() else "opacity: unset !important;",
)
@classmethod
def update_states(cls) -> None:
"""
get pulseaudio state of all sinks and sink-inputs and save it to
class variable data
creates and updates
Volume.data['sinks']
Volume.data['sink-inputs']
both might be empty lists but available
"""
t = time.time()
dt = t - cls.last_update
if dt > 1.0: # update only if at least a second passed since last update
cls.last_update = t
# wsl not running pulse daemon: Connection failure: Connection refused
sinks = process('pactl -f json list sinks', shell=True)
if 'failure' in sinks:
print("'pactl -f json list sinks' returns: '", sinks, "'")
# fill (initialize) key 'sinks' and 'sink-inputs' to empty list so not enter KeyError
cls.data['sinks'] = []
cls.data['sources'] = []
cls.data['sink-inputs'] = []
else:
cls.data['sinks'] = json.loads(sinks)
# only do additionals if sink was working
sources = process('pactl -f json list sources', shell=True, stderr=None)
if 'failure' in sources:
print("'pactl -f json list sources' returns: '", sources, "'")
cls.data['sources'] = []
else:
cls.data['sources'] = json.loads(sources)
sink_inputs = process('pactl -f json list sink-inputs', shell=True, stderr=None) # stderr might have e.g.: Invalid non-ASCII character: 0xffffffc3
if 'failure' in sink_inputs:
print("'pactl -f json list sink-inputs' returns: '", sink_inputs, "'")
cls.data['sink-inputs'] = []
else:
cls.data['sink-inputs'] = json.loads(sink_inputs)
def update_state(self) -> None:
"fills self.pa_state, therefore access info via self.pa_state"
self.update_states()
tmp = []
# filter for the given pa name, empty list if not found
if self.wtype == 'sink':
# match pa name with self.name
tmp = list(filter(lambda item: item['name'] == self.name,
Volume.data['sinks']))
elif self.wtype == 'source':
# match pa name with self.name
tmp = list(filter(lambda item: item['name'] == self.name,
Volume.data['sources']))
elif self.wtype == 'sink-input':
# match pa index with self.name
try: # for int casting
tmp = list(filter(lambda item: item['index'] == int(self.name),
Volume.data['sink-inputs']))
except:
pass
self.pa_state = tmp[0] if tmp else {}
def is_muted(self):
return self.pa_state['mute']
class VolumeGroup():
def __init__(self, **kwargs):
a = kwargs.pop('a', None) # add Volume widgets to the specified component
Volume.update_states()
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()
async def reload_all_instances(self, msg):
"Reload all browser tabs that the page is rendered on"
for page in WebPage.instances.values():
if page.page_type == 'main':
await page.reload()
async def kill_gui(self, msg):
if 'pid' in msg.page.request.query_params:
pid = msg.page.request.query_params.get('pid')
await process(f"kill {pid}")
else:
await process("pkill controldeck-gui")
def ishexcolor(code):
return bool(re.search(r'^#(?:[0-9a-fA-F]{3}){1,2}$', code))
def widget_load(config) -> dict:
"""scan for widgets to add (adding below) in the config
{
tab-name: {
section-id: [
{widget-args}
]
}
}
"""
widget_dict = {}
for i in config.sections():
iname = None
#iname = re.search(r"^([0-9]*:?)([0-9]*\.?)(button|empty)", i, flags=re.IGNORECASE)
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] 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 :
# print('group ', iname.group(0))
# print('tab_id ', tab_name)
# print('sec_id ', sec_id)
# print('wid_type', wid_type)
# print('wid_name', wid_name)
# print('')
if wid_type == 'empty':
# TODO: empty using label class, like an alias?
args = [{'widget-class': Empty,
'type': wid_type}]
elif wid_type == 'label':
args = [{'widget-class': Label,
'type': wid_type,
'text': wid_name}]
elif wid_type == 'button':
# button or empty
args = [{'widget-class': Button,
'type': wid_type,
'text': wid_name,
'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=''),
'icon': config.get(i, 'icon', fallback=''),
'icon-alt': config.get(i, 'icon-alt', fallback=''),
'image': config.get(i, 'image', fallback=''),
'image-alt': config.get(i, 'image-alt', fallback='')}]
elif wid_type == 'slider':
# sliders
args = [{'widget-class': Slider,
'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
args = [{'widget-class': Volume,
'type': wid_type,
'name': wid_name,
'description': config.get(i, 'description', fallback=''),
}]
elif wid_type == 'source':
# volume sliders
args = [{'widget-class': Volume,
'type': wid_type,
'name': wid_name,
'description': config.get(i, 'description', fallback=''),
}]
elif wid_type == 'sink-inputs':
# multiple volume sliders
args = [{'widget-class': VolumeGroup,
'type': wid_type,
}]
if tab_name not in widget_dict:
widget_dict.update({tab_name: {}})
# try:
# widget_dict[sec_id] += args
# except KeyError:
# widget_dict[sec_id] = args
try:
widget_dict[tab_name][sec_id] += args
except KeyError:
widget_dict[tab_name][sec_id] = args
return widget_dict
@SetRoute('/')
async def application(request):
"""
Components:
[QLayout]
+-[QHeader]
+-[QBar]
+-[QBtnToggle] - tabs
+-[QSpace]
+-[QBtn] - edit
+-[ToggleDarkModeBtn]
+-[QBtn] - fullscreen
+-[QBtn] - reload
+-([QBtn]) - close
+-[QPageContainer]
+-[QPage]
+-[QTabPanel]
+-[QTabPanel]
+-[QTabPanel]
+-[QTabPanel]
+-...
"""
wp = QuasarPage(
title=APP_NAME,
dark=True,
classes="blue-grey-10",
)
# can be accessed via msg.page.request
wp.request = request
tab_choice = request.query_params.get('tab', '[all]') # if tab is not specified default to [all]
wp.page_type = 'main'
wp.head_html = """
"""
wp.css = """
.q-icon {
height: unset; /* overwrite 1em so unused icons in buttons do not use space */
}
.q-btn img.q-icon { /* for images icons not font icons */
padding-bottom: 0.1em; /* some space to the text below */
font-size: 2.5em !important; /* increase image size (1.715em) */
}
.q-btn i.q-icon { /* for font icons not image icons */
padding-bottom: 0.1em; /* some space to the text below */
}
.q-btn-group > .q-btn-item:not(:last-child) {
border-right: unset !important;
}
.q-btn-group {
border-radius: 7px;
}
/* bc/ track_size is not working */
.q-slider__track-container {
height: 4px;
margin-top: -2px;
}
/* bc/ track_color is not working */
.q-slider__track-container {
background: #455a64 !important; /* blue-grey-8 */
}
.q-tooltip {
white-space: pre;
}
/* COLORS */
:root {
--c-blue-grey-8: #455a64; /* blue-grey-8 */
--c-light-blue-9: #0277bd; /* light-blue-9 */
}
.border-c-blue-grey-8 {
border: 1px solid var(--c-blue-grey-8) !important;
}
"""
# quasar version installed v1.9.14
# v1 bc/ v2 uses vue v3 components but justpy only has vue v2 components
# https://github.com/justpy-org/justpy/issues/460
# wp.head_html += ''
# script_html = """
#
# """
# will be added at the top of body
# wp.body_html = script_html
# scan for widgets to add (adding below) in the config
config = config_load()
widget_dict = widget_load(config)
tab_names = ['[all]'] + list(widget_dict.keys()) # an all tab + all defined tab in the config
layout = QLayout(view="lHh lpr lFf",
#container=True,
a=wp, name='layout')
header = QHeader(elevated=True, a=layout)
#toolbar = QToolbar(a=header) # height 50
toolbar = QBar(
a=header,
classes='bg-'+COLOR_PRIME,
) # height 32, dense 24 'bg-light-blue-10'
# toolbar_tabs = QTabs(
# a=toolbar, outside_arrows=True, mobile_arrows=True,
# style='width:200px',
# )
# QTab(a=toolbar_tabs, label="test1")
# QTab(a=toolbar_tabs, label="test2")
# QTab(a=toolbar_tabs, label="test3")
# QTab(a=toolbar_tabs, label="test4")
# QTab(a=toolbar_tabs, label="test5")
# QTab(a=toolbar_tabs, label="test6")
async def tab_button_change(self, msg):
#print(msg)
#print(msg['target'])
#print(self.value)
# tab_panel is defined below
if self.value == '[all]':
for tab_name in tab_panel.keys():
if tab_panel[tab_name].has_class('hidden'):
tab_panel[tab_name].remove_class('hidden')
else:
for tab_name in tab_panel.keys():
if not tab_panel[tab_name].has_class('hidden'):
tab_panel[tab_name].set_class('hidden')
if self.value in tab_panel:
tab_panel[self.value].remove_class('hidden')
# change the address field in the browser using pushState (to be able to go 'back') (other would be replaceState)
await msg.page.run_javascript(f"window.history.pushState('', '', '/?tab={self.value}')")
tab_btns = QBtnToggle( # 'deep-purple-9'
toggle_color=COLOR_SELECT, dense=False, flat=False, push=False,
glossy=False, a=toolbar, input=tab_button_change, value=tab_choice,
classes='q-ma-md', unelevated=True)
tab_btns.remove_class('q-ma-md')
tab_btns.style = 'height:100%;' # buttons full height
tab_btns.style += 'width:calc(100vw - 24px - 100.5333px);' # full width minus padding and 4 btns at the right end
tab_btns.style += 'overflow-x:auto;' # scroll content
for tab in tab_names:
label = tab.capitalize()
if tab == '[all]':
tab_btns.options.append({
'label': '',
'value': tab,
#'icon': 'fiber_smart_record',
#'icon': 'device_hub',
'icon': 'brightness_auto',
#'icon': 'looks',
})
elif tab == '':
tab_btns.options.append({
'label': '',
'value': tab,
'icon': 'radio_button_unchecked',
})
else:
tab_btns.options.append({
'label': label,
'value': tab,
})
QSpace(a=toolbar)
# BUTTON edit config
def toggle_edit_config(self, msg):
self.dialog.value = True
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()
def edit_dialog_after(self, msg):
self.dialog_input.remove_class('changed')
edit_dialog = QDialog(
maximized=True,
transition_show='slide-down',
transition_hide='slide-up',
a=toolbar,
name="edit-dialog",
after=edit_dialog_after,
)
edit_dialog_card = QCard(
a=edit_dialog,
)
edit_dialog_bar = QBar(a=edit_dialog_card, classes='bg-'+COLOR_PRIME)
edit_dialog_label = QItemLabel(a=edit_dialog_bar) # text filled by handle toggle_edit_config
QSpace(a=edit_dialog_bar)
QSeparator(vertical=True,spaced=True,a=edit_dialog_bar)
def edit_dialog_save(self, msg):
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')
edit_dialog_btn_save = QBtn(
a=edit_dialog_bar,
dense=True,
flat=True,
icon='save',
click=edit_dialog_save)
QTooltip(a=edit_dialog_btn_save, text='Save')
QSeparator(vertical=True,spaced=True,a=edit_dialog_bar)
edit_dialog_btn_close = QBtn(
a=edit_dialog_bar,
dense=True,
flat=True,
icon='close',
v_close_popup=True)
QTooltip(a=edit_dialog_btn_close, text='Close')
edit_dialog_card_section = QCardSection(
a=edit_dialog_card,
#classes='q-pt-none',
)
edit_dialog_input = QInput(
#filled=True,
type='textarea',
style='font-family:monospace,monospace;height:calc(100vh - 64px);', # 32 bar + 16 padding-top + 16 padding-bottom, two components deeper set height 100% see wp.css below
a=edit_dialog_card_section,
#value='', # filled by handle toggle_edit_config
spellcheck=False,
#wrap='off', # not working
#bg_color="blue-grey-9", # COLOR_PRIME
#input_class='text-'+COLOR_PRIME,
input_class='text-blue-grey-4',
outlined=True,
input_style='resize:none;white-space:pre;padding:6px;',
color='transparent', # on focus change border color, default is prime (blue)
)
def edit_dialog_change(self, msg):
#print(type(self.classes))
# self.set_class('changed') # hangs
# self.set_classes('changed') # hangs
#if self.classes:
# return True # return non None to not update the widget
self.classes = 'changed'
# self.error=True # red border
# return None to update the widget
#edit_dialog_input.on('change', edit_dialog_change) # hits after losing focus
edit_dialog_input.on('input', edit_dialog_change) # hits during editing
wp.css += """
.q-dialog .q-field__control {
height: 100%;
padding: unset;
}
/* changed coloring */
.q-field--dark.changed .q-field__control::before {
border-color: #9653f799;
}
.q-field--dark.changed .q-field__control:hover::before {
border-color: #9653f7 !important;
}
"""
edit_dialog_btn_save.dialog_input = edit_dialog_input
edit_dialog.dialog_input = edit_dialog_input
# edit_dialog_editor = QEditor(
# a=edit_dialog_card_section,
# definitions={
# 'save': {
# 'tip': 'Save your work',
# 'icon': 'save',
# 'label': 'Save',
# },
# 'upload': {
# 'tip': 'Upload to cloud',
# 'icon': 'cloud_upload',
# 'label': 'Upload',
# },
# },
# toolbar=[['undo', 'redo', 'print', 'fullscreen', 'viewsource'],['upload', 'save']],
# )
# edit_dialog_html = """
#
#
#
#
#
#
#
#
# """
# edit_dialog = parse_html(edit_dialog_html, a=toolbar)
edit_config = QBtn(
dense=True,
flat=True,
icon='edit', # not working: edit_note, app_registration
a=toolbar,
click=toggle_edit_config,
dialog=edit_dialog,
dialog_input=edit_dialog_input,
dialog_label=edit_dialog_label,
)
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='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='fullscreen', a=toolbar, click=toggle_screen)
QTooltip(a=btn_fullscreen, text='Toggle fullscreen')
# 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')
page_container = QPageContainer(a=layout)
page = QPage(a=page_container, name="page-tabs")
#tab_panels = QTabPanels(v_model='tab', animated=True, a=layout) # panels not working
tab_panel = {i:QTabPanel(name=i, classes="q-pa-none", a=page) for i in tab_names}
msg = Dict()
msg.page = wp
await tab_button_change(tab_btns, msg) # update visibility of tab panels regarding the request
# add widgets; naming like _div_[tab_name][sec_id]
for tab_name in widget_dict:
for i in widget_dict[tab_name]:
var = "_div_"+tab_name+i # tab_name: chars or sting-letters, i (sec_id) '' or a string-number
for j in widget_dict[tab_name][i]:
if var not in vars():
vars()[var] = Div(
name=var,
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:
j['widget-class'](
wtype=j['type'],
a=eval(var))
if j['widget-class'] == Label:
j['widget-class'](
text=j['text'],
wtype=j['type'],
a=eval(var))
if j['widget-class'] == Button:
j['widget-class'](
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'],
icon=j['icon'], icon_alt=j['icon-alt'],
image=j['image'], image_alt=j['image-alt'],
a=eval(var))
elif j['widget-class'] == Slider:
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'](
name=j['name'], description=j['description'],
wtype=j['type'],
a=eval(var))
elif j['widget-class'] == VolumeGroup:
j['widget-class'](wtype=j['type'], a=eval(var))
# TODO: change reference wp.components to ...
if not wp.components:
# config not found or empty, therefore insert an empty div to not get an error
Div(text="add elements in controldeck.conf", classes="flex flex-wrap", a=wp)
status = config.get('default', 'status', fallback='False').title() == 'True'
#if status:
# wp.add(STATUS_DIV)
# TODO: Test
if DEBUG:
test_row = Div(classes="row q-pa-sm q-gutter-sm", a=tab_panel['[all]'])
# button with active status led
test_btn = Button(
a=test_row,
text='foo',
)
Div(
a=test_btn,
style='position: absolute;top: 2px;right: 2px;width: 5px;background-color: #00ff00;height: 5px;border-radius: 2.5px;'
)
# notify, position ['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right']
test_btn.qnotify = QNotify(message='Test nofify', position='top', a=wp)
def test_btn_click(self, msg):
self.qnotify.notify = True
self.qnotify.caption = 'test caption'
test_btn.on('click', test_btn_click)
def test_btn_after(self, msg):
self.qnotify.notify = False
test_btn.on('after', test_btn_after)
return wp
@SetRoute('/hello')
def hello_function():
wp = WebPage()
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 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
def cli():
global DEBUG
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 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}")
config = config_load(args.config)
host = args.host if args.host else config.get('default', 'host', fallback='127.0.0.1')
port = args.port if args.port else config.get('default', 'port', fallback='8000')
if args.debug:
print('[DEBUG] host:', host)
print('[DEBUG] port:', port)
if args.help:
parser.print_help()
exit(0)
main(args, host, port)
return 0
if __name__ == '__main__':
sys.exit(cli())