diff --git a/README b/README index 123c5e3..0b14b79 100644 --- a/README +++ b/README @@ -1,6 +1,7 @@ Install Requirements: + - python package justpy, the framework: pip install justpy --upgrade - (optionally) for volume buttons: libpulse local: diff --git a/VERSION b/VERSION index fb37ebd..c5536f0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2021.08.24 \ No newline at end of file +2022.10.01 \ No newline at end of file diff --git a/controldeck.py b/controldeck.py index 238fc13..dd1429c 100755 --- a/controldeck.py +++ b/controldeck.py @@ -1,142 +1,97 @@ #!/usr/bin/env python """ -HTML style powered by Tailwind CSS +HTML style powered by Quasar NOTE: currently buttons only updated on page reload -(buttons will be recreated) """ import sys -from os import path, sep, makedirs +from os import getcwd, path, sep, makedirs +import shutil +import shlex from subprocess import Popen, PIPE, STDOUT from configparser import ConfigParser -from re import search, IGNORECASE -from justpy import Div, I, WebPage, SetRoute, parse_html, run_task, justpy -from cairosvg import svg2svg +import re +import json +import time +import argparse +from addict import Dict # also used in justpy +from justpy import ( + Div, + I, + P, + QuasarPage, + QBadge, + QBar, + QBtn, + QBtnGroup, + QBtnToggle, + QCard, + QCardSection, + QDialog, + 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 +) 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" -STATUS_DIV = Div() -STATUS_DIV.classes = " border-2 border-gray-800 bg-gray-900 text-gray-600 h-40 m-2 pt-1 pl-2 mr-16 rounded-lg flex overflow-y-auto" -STATUS_DIV.style = "scrollbar-width: thin;" +CONFIG_FILE = '~/.config/controldeck/controldeck.conf' +CONFIG_FILE_FULL = path.expanduser(CONFIG_FILE) def tohtml(text): return text.replace("\n", "
") -def mouseenter_status(self, msg): - """Div style see STATUS_DIV.classes""" - STATUS_DIV.inner_html = "
" - STATUS_DIV.inner_html += "
command
" - STATUS_DIV.inner_html += f"
{tohtml(self.command.strip())}
" - STATUS_DIV.inner_html += "
command-alt
" - STATUS_DIV.inner_html += f"
{tohtml(self.command_alt.strip())}
" - STATUS_DIV.inner_html += "
state
" - STATUS_DIV.inner_html += f"
{tohtml(self.state.strip())}
" - STATUS_DIV.inner_html += "
state-pattern
" - STATUS_DIV.inner_html += f"
{tohtml(self.state_pattern.strip())}
" - STATUS_DIV.inner_html += "
state-pattern-alt
" - STATUS_DIV.inner_html += f"
{tohtml(self.state_pattern_alt.strip())}
" - STATUS_DIV.inner_html += "
switched
" - STATUS_DIV.inner_html += f"
{str(self.switched)}
" - STATUS_DIV.inner_html += "
" - -def process(args, output=True): +def process(command_line, shell=False, output=True, stdout=PIPE, stderr=STDOUT): 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 - result = Popen(args, stdout=PIPE, stderr=STDOUT, shell=True, start_new_session=True) + # 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) + result = Popen(args, stdout=stdout, stderr=stderr, shell=shell, start_new_session=True) if output: - return result.stdout.read().decode("utf-8").rstrip() + res = result.stdout.read().decode("utf-8").rstrip() + result.kill() # does not help to unblock + # print(res) + return res except Exception as e: print(f"{e} failed!") -def process_shell(command): - print(command) - # string works only with shell - if isinstance(command, (list)): - # e.g.: [['pkill', 'ArdourGUI'], ['systemctl', '--user', 'restart', 'pipewire', 'pipewire-pulse'], ['ardour6', '-n', 'productive-pipewire']] - if isinstance(command[0], (list)): - [process(i, False) for i in command] - else: - # e.g.: ['pkill', 'ArdourGUI'] - process(command, False) - else: - # e.g.: 'pkill ArdourGUI' - process(command, False) - -def volume(name): - result = process(f'pamixer --sink "{name}" --get-volume-human') - if search("The sink doesn't exit", result): - result = "--" - elif search("pamixer: command not found", result) is not None: - n = process(r"pactl list sinks short | awk '{print $2}'").split() - v = process(r"pactl list sinks | grep '^[[:space:]]Volume:' | sed -e 's,.* \([0-9][0-9]*\)%.*,\1,'").split() - if name in n: - result = v[n.index(name)] + '%' - else: - result = '--' - return result - -def volume_decrease(name): - result = process(f'pamixer --sink "{name}" --get-volume-human --decrease 5') - if search("pamixer: command not found", result) is not None: - process(f'pactl set-sink-volume "{name}" -5%') - #process(f'pactl set-sink-volume "{name}" -5db') - result = volume(name) - return result - -def volume_increase(name): - result = process(f'pamixer --sink "{name}" --get-volume-human --increase 5') - if search("pamixer: command not found", result) is not None: - process(f'pactl set-sink-volume "{name}" +5%') - #process(f'pactl set-sink-volume "{name}" +5db') - result = volume(name) - return result - -def volume_mute(name): - result = process(f'pamixer --sink "{name}" --get-volume-human --toggle-mute') - if search("pamixer: command not found", result) is not None: - process(f'pactl set-sink-mute "{name}" toggle') - result = volume(name) - return result - -def source_volume(name): - result = process(f'pamixer --source "{name}" --get-volume-human') - if search("The source doesn't exit", result): - result = "--" - elif search("pamixer: command not found", result) is not None: - n = process(r"pactl list sources short | awk '{print $2}'").split() - v = process(r"pactl list sources | grep '^[[:space:]]Volume:' | sed -e 's,.* \([0-9][0-9]*\)%.*,\1,'").split() - if name in n: - result = v[n.index(name)] + '%' - else: - result = '--' - return result - -def source_volume_decrease(name): - result = process(f'pamixer --source "{name}" --get-volume-human --decrease 5') - if search("pamixer: command not found", result) is not None: - process(f'pactl set-source-volume "{name}" -5%') - #process(f'pactl set-source-volume "{name}" -5db') - result = volume(name) - return result - -def source_volume_increase(name): - result = process(f'pamixer --source "{name}" --get-volume-human --increase 5') - if search("pamixer: command not found", result) is not None: - process(f'pactl set-source-volume "{name}" +5%') - #process(f'pactl set-source-volume "{name}" +5db') - result = volume(name) - return result - -def source_volume_mute(name): - result = process(f'pamixer --source "{name}" --get-volume-human --toggle-mute') - if search("pamixer: command not found", result) is not None: - process(f'pactl set-source-mute "{name}" toggle') - result = volume(name) - return result - def config_load(conf=''): config = ConfigParser(strict=False) if conf: @@ -155,39 +110,7 @@ def config_load(conf=''): #print(config.sections()) return config -def svg_element(image): - svg = '' - parse = False - if path.isfile(path.expanduser(image)): - try: - with open(path.expanduser(image)) as f: - svg = f.read() - except Exception as e: - print(f"{e}") - # 1st try direct parsing - try: # svg with custom tags, as inkscape is using, cannot be interpreted - _svg = parse_html(svg) - parse = True - except Exception as e: - # 2nd try svg2svg parsing - try: # svg with custom tags, as inkscape is using, cannot be interpreted - svg = svg2svg(bytestring=svg.encode('utf-8')).decode('utf-8') - _svg = parse_html(svg) - parse = True - except Exception as e: - print(f"[Error SVG]: {e}") - _svg = None - if parse: - # set width and height to viewBox to update width and height for scaling - w = _svg.width if hasattr(_svg, 'width') else "64" - h = _svg.height if hasattr(_svg, 'height') else "64" - vb = _svg.viewBox if hasattr(_svg, 'viewBox') else '0 0 ' + w + ' ' + h - _svg.viewBox = vb - _svg.width = 64 - _svg.height = 64 - return _svg - -class Button(Div): +class Button(QBtn): """ usage Button(text, text_alt, btype, command, command_alt, @@ -196,221 +119,214 @@ class Button(Div): icon, icon_alt, image, image_alt, a) """ - text = '' - text_normal = '' - text_alt = '' - btype = None - command = '' - command_alt = '' - color_bg = '' - color_fg = '' - icon = '' - icon_alt = '' - image = '' - image_alt = '' - image_element = None - image_alt_element = None - state = '' - state_pattern = '' - state_pattern_alt = '' - state_command = '' - state_command_alt = '' - switched = False - def update_state(self): - self.state = process(self.state_command) - # update switched state - if self.state_pattern != '' and search(self.state_pattern, self.state): - # search is None if search has no match otherwise re.Match object - self.switched = False - elif self.state_pattern_alt != '' and search(self.state_pattern_alt, self.state): - self.switched = True - else: - self.switched = False - # change text / icon - if not self.switched: - self.classes = self.classes.replace("border-green-700", "border-gray-700") - if self.image: - self.components[0] = self.image_element - elif self.icon: - self.inner_html = f"" - else: - self.text = self.text_normal - else: - self.classes = self.classes.replace("border-gray-700", "border-green-700") - if self.image_alt: - self.components[0] = self.image_alt_element - elif self.icon_alt: - self.inner_html = f"" - elif self.text_alt: - self.text = self.text_alt - 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' + self.push = True + # self.padding = 'none' # not working, also not inside init + self.dense = True + + # default **kwargs + self.btype = None # button or empty + self.image = '' # used for files like svg and png + self.command = '' # command to run on click + self.state = '' # output of the state check command + self.state_command = '' # command to check the unclicked state super().__init__(**kwargs) - self.text_normal = str(self.text) - self.on('mouseenter', mouseenter_status) + self.style = "width: 90px;" + self.style += "min-height: 70px;" + if self.btype == 'empty': - # empty is like an invisible spacer with the size of a button - self.classes = "w-20 h-20 m-2 p-1 flex select-none" - self.text = '' - + self.classes = 'invisible' # invisible; not shown but still stakes up space else: - self.classes = "fitin border-2 border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-500 w-20 h-20 m-2 p-1 rounded-lg font-bold flex items-center text-center justify-center select-none" + self.style += "border: 1px solid #455a64;" # #455a64 blue-grey-8 - self.style = f"background-color:{self.color_bg};" if ishexcolor(self.color_bg) else '' - self.style += f"color:{self.color_fg};" if ishexcolor(self.color_fg) else '' + self.style += "width: 90px;" + self.style += "min-height: 77px;" # image + 2 text lines + self.style += "line-height: 1em;" + if self.image and path.exists(self.image): + static_dir = getcwd() + '/static' + shutil.copy2(self.image, static_dir) + basename = path.basename(self.image) + self.icon = f"img:/static/{basename}" + # + # if self.command != '': + QTooltip(a=self, text=self.command.strip(), delay=500) # setting style white-space:pre does not work, see wp.css below def click(self, msg): - self.state = process(self.state_command) - # run command - if not self.switched: - process_shell(self.command) - else: - if self.command_alt != '': - process_shell(self.command_alt) - else: - process_shell(self.command) - # update switched state - #if self.state_pattern == '': - # self.switched = not self.switched self.update_state() + if 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) - self.state = process(self.state_command) - self.image_element = svg_element(self.image) - self.image_alt_element = svg_element(self.image_alt) - if self.image and self.image_element is not None: - self.text = '' - if self.image_alt and not search(self.state_pattern, self.state): - self.add(self.image_alt_element) - else: - self.add(self.image_element) - elif self.icon: - if self.icon_alt and not search(self.state_pattern, self.state): - self.inner_html = f"" - else: - self.inner_html = f"" - else: - if self.text_alt and not search(self.state_pattern, self.state): - # self.text = self.text_alt - # div for fitin logic - self.inner_html = f"
{self.text_alt}
" - elif self.text_normal: # only if string is not empty (for font icons) - # self.text = self.text_normal - # div for fitin logic - self.inner_html = f"
{self.text_normal}
" - self.update_state() + def update_state(self): + if self.state_command != '': + self.state = process(self.state_command, shell=True) -class ButtonsSound(Div): - div = None - btype = None - name = None - description = None - volume = None - decrease_icon = '' - decrease_image = '' - increase_icon = '' - increase_image = '' - mute_icon = '' - mute_icon_alt = '' - mute_image = '' - mute_image_alt = '' - bmute = None +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 or not found? + 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.vtype = 'sink' # sink (loudspeaker) or sink-input (app output) + self.name = '' # pulseaudio sink name + self.description = '' # badge name + super().__init__(**kwargs) - self.classes = "grid-rows-2" - self.div = Div(classes="flex") + self.style = "width:286px;" # three buttons and the two spaces + self.update_state() # get self.pa_state - if self.decrease_image: - tmp = svg_element(self.decrease_image) - if tmp is not None: - bdec = Button(click=self.decrease, a=self.div).add(tmp) - elif self.decrease_icon: - bdec = Button(icon = self.decrease_icon, click=self.decrease, a=self.div) - else: - bdec = Button(inner_html='- 5%', click=self.decrease, a=self.div) + if self.pa_state: + if self.vtype == 'sink': + cmdl_toggle = 'pactl set-sink-mute {name} toggle' + cmdl_value = 'pactl set-sink-volume {name} {value}%' + elif self.vtype == 'sink-input': + cmdl_toggle = 'pactl set-sink-input-mute {name} toggle' + cmdl_value = 'pactl set-sink-input-volume {name} {value}%' + self.description = \ + self.pa_state['properties']['application.process.binary'] +\ + ': ' +\ + 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 + volume_level = float(self.pa_state['volume']['front-left']['value_percent'][:-1]) # remove the % sign - if self.increase_image: - tmp = svg_element(self.increase_image) - if tmp is not None: - binc = Button(click=self.increase, a=self.div).add(tmp) - elif self.increase_icon: - binc = Button(icon=self.increase_icon, - click=self.increase, a=self.div) - else: - binc = Button(inner_html='+ 5%', click=self.increase, a=self.div) + 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' + item = QItem( + a=self, + dense=True, # dense: less spacing: vertical little higher + ) + 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) + 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, + ) + 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;", + ) - if self.mute_image and self.mute_image is not None: - self.bmute = Button(click=self.mute, - image=self.mute_image, - image_alt=self.mute_image_alt, - a=self.div) - else: - self.bmute = Button(text='mute', - icon=self.mute_icon, - icon_alt=self.mute_icon_alt, - click=self.mute, a=self.div) - if self.btype == 'mic': - self.bmute.state = f'{source_volume(self.name)}' - else: - self.bmute.state = f'{volume(self.name)}' + @classmethod + def update_states(cls) -> None: + """ + get pulseaudio state of all sinks and sink-inputs and save it to + class variable data - if self.bmute.state == 'muted': - if self.bmute.image_alt_element: - self.bmute.components[0] = self.bmute.image_alt_element - elif self.mute_icon: - self.bmute.inner_html = f"" + 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['sink-inputs'] = [] else: - self.bmute.text = 'unmute' + cls.data['sinks'] = json.loads(sinks) - self.add(self.div) - if self.btype == 'mic': - self.volume = Div(text=f"{self.description}: {source_volume(self.name)}", - classes="text-gray-600 text-center -mt-2", a=self) - else: - self.volume = Div(text=f"{self.description}: {volume(self.name)}", - classes="text-gray-600 text-center -mt-2", a=self) + sink_inputs = process('pactl -f json list sink-inputs', shell=True) + if 'failure' in sinks: + print("'pactl -f json list sink-inputs' returns", sink_inputs) + cls.data['sink-inputs'] = [] + else: + cls.data['sink-inputs'] = json.loads(sink_inputs) - self.classes += " border-0 ml-2 mr-2" - bdec.classes += " ml-2 mr-1" - binc.classes += " ml-1 mr-1" - self.bmute.classes += " ml-1 mr-2" + 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.vtype == 'sink': + # match pa name with self.name + tmp = list(filter(lambda item: item['name'] == self.name, + Volume.data['sinks'])) + elif self.vtype == '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 {} - async def decrease(self, msg): - if self.btype == 'mic': - self.volume.text = f'{self.description}: {source_volume_decrease(self.name)}' - else: - self.volume.text = f'{self.description}: {volume_decrease(self.name)}' + def is_muted(self): + return self.pa_state['mute'] - async def increase(self, msg): - if self.btype == 'mic': - self.volume.text = f'{self.description}: {source_volume_increase(self.name)}' - else: - self.volume.text = f'{self.description}: {volume_increase(self.name)}' - - async def mute(self, msg): - if self.btype == 'mic': - self.volume.text = f'{self.description}: {source_volume_mute(self.name)}' - self.bmute.state = f'{source_volume(self.name)}' - else: - self.volume.text = f'{self.description}: {volume_mute(self.name)}' - self.bmute.state = f'{volume(self.name)}' - if self.bmute.state == 'muted': - if self.bmute.image_alt_element: - self.bmute.components[0] = self.bmute.image_alt_element - elif self.mute_icon_alt: - self.bmute.inner_html = f"" - else: - self.bmute.text = 'unmute' - else: - if self.bmute.image_element: - self.bmute.components[0] = self.bmute.image_element - elif self.mute_icon: - if self.mute_icon: - self.bmute.inner_html = f"" - else: - self.bmute.text = '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'], vtype='sink-input') async def reload(self, msg): await msg.page.reload() @@ -429,143 +345,567 @@ async def kill_gui(self, msg): await process("pkill controldeck-gui") def ishexcolor(code): - return bool(search(r'^#(?:[0-9a-fA-F]{3}){1,2}$', code)) + return bool(re.search(r'^#(?:[0-9a-fA-F]{3}){1,2}$', code)) -@SetRoute('/') -def application(request): - - wp = WebPage(title=APP_NAME, body_classes="bg-gray-900") - wp.page_type = 'main' - wp.head_html = '' - # can be accessed via msg.page.request - wp.request = request - - menu = Div(classes="fixed bottom-0 right-0 p-1 grid grid-col-1 select-none text-gray-500", a=wp) - I(classes="w-10 h-10 w-1 fa-2x fa-fw fas fa-redo-alt", click=reload, a=menu) - if "gui" in request.query_params: - I(classes="w-10 h-10 w-1 fa-2x fa-fw fas fa-window-close", click=kill_gui, a=menu) - - config = config_load() - volume_dict = {} - button_dict = {} +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 - # volume buttons - iname = search("^([0-9]*.?)(volume|mic)", i, flags=IGNORECASE) + #iname = re.search(r"^([0-9]*:?)([0-9]*\.?)(button|empty)", i, flags=re.IGNORECASE) + iname = re.search( + r"^([0-9a-z]*:)?([0-9]*\.)?(button|empty|sink-inputs|sink|source)", # sink-inputs BEFORE sink + i, flags=re.IGNORECASE) if iname is not None: - id = iname.group(1)[:-1] # remove dot - if iname.group(2) == 'mic': - tmp = [{'type': iname.group(2), 'description': i[iname.end(0)+1:], - 'color-bg': config.get(i, 'color-bg', fallback=''), - 'color-fg': config.get(i, 'color-fg', fallback=''), - 'name': config.get(i, 'name', fallback=None), - 'decrease-icon': config.get('default', 'mic-decrease-icon', fallback=''), - 'decrease-image': config.get('default', 'mic-decrease-image', fallback=''), - 'increase-icon': config.get('default', 'mic-increase-icon', fallback=''), - 'increase-image': config.get('default', 'mic-increase-image', fallback=''), - 'mute-icon': config.get('default', 'mic-mute-icon', fallback=''), - 'mute-icon-alt': config.get('default', 'mic-mute-icon-alt', fallback=''), - 'mute-image': config.get('default', 'mic-mute-image', fallback=''), - 'mute-image-alt': config.get('default', 'mic-mute-image-alt', fallback='')}] - else: - tmp = [{'type': iname.group(2), 'description': i[iname.end(0)+1:], - 'color-bg': config.get(i, 'color-bg', fallback=''), - 'color-fg': config.get(i, 'color-fg', fallback=''), - 'name': config.get(i, 'name', fallback=None), - 'decrease-icon': config.get('default', 'volume-decrease-icon', fallback=''), - 'decrease-image': config.get('default', 'volume-decrease-image', fallback=''), - 'increase-icon': config.get('default', 'volume-increase-icon', fallback=''), - 'increase-image': config.get('default', 'volume-increase-image', fallback=''), - 'mute-icon': config.get('default', 'volume-mute-icon', fallback=''), - 'mute-icon-alt': config.get('default', 'volume-mute-icon-alt', fallback=''), - 'mute-image': config.get('default', 'volume-mute-image', fallback=''), - 'mute-image-alt': config.get('default', 'volume-mute-image-alt', fallback='')}] + 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_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 in ['button', 'empty']: + # button or empty + args = [{'widget-class': 'Button', + 'type': wid_type, + 'text': wid_name, + 'text-alt': config.get(i, 'text-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=''), + 'state': config.get(i, 'state', fallback=''), + 'state-alt': config.get(i, 'state-alt', fallback=''), + 'state-command': config.get(i, 'state-command', fallback=''), + 'state-command-alt': config.get(i, 'state-command-alt', 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 == 'sink': + # 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: - volume_dict[id] += tmp + widget_dict[tab_name][sec_id] += args except KeyError: - volume_dict[id] = tmp - # button or empty - iname = search("^([0-9]*.?)(button|empty)", i, flags=IGNORECASE) - if iname is not None: - id = iname.group(1)[:-1] # remove dot - tmp = [{'type': iname.group(2), 'text': i[iname.end(0)+1:], - 'text-alt': config.get(i, 'text-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=''), - 'state': config.get(i, 'state', fallback=''), - 'state-alt': config.get(i, 'state-alt', fallback=''), - 'state-command': config.get(i, 'state-command', fallback=''), - 'state-command-alt': config.get(i, 'state-command-alt', 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='')}] - try: - button_dict[id] += tmp - except KeyError: - button_dict[id] = tmp - var_prefix = "_div" - for i in volume_dict: - var = var_prefix+i - for j in volume_dict[i]: - if var not in vars(): - vars()[var] = Div(classes="flex flex-wrap", a=wp) - color_bg = f"background-color:{j['color-bg']};" if ishexcolor(j['color-bg']) else '' - color_fg = f"color:{j['color-fg']};" if ishexcolor(j['color-fg']) else '' - ButtonsSound(name=j['name'], description=j['description'], btype=j['type'], - color_bg=j['color-bg'], color_fg=j['color-fg'], - decrease_icon=j['decrease-icon'], decrease_image=j['decrease-image'], - increase_icon=j['increase-icon'], increase_image=j['increase-image'], - mute_icon=j['mute-icon'], mute_image=j['mute-image'], - mute_icon_alt=j['mute-icon-alt'], mute_image_alt=j['mute-image-alt'], - a=eval(var)) - for i in button_dict: - var = var_prefix+i - for j in button_dict[i]: - if var not in vars(): - vars()[var] = Div(classes="flex flex-wrap", a=wp) - Button(text=j['text'], text_alt=j['text-alt'], btype=j['type'], - 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'], - state_command=j['state-command'], - state_command_alt=j['state-command-alt'], - icon=j['icon'], icon_alt=j['icon-alt'], - image=j['image'], image_alt=j['image-alt'], a=eval(var)) + widget_dict[tab_name][sec_id] = args + return widget_dict - # fit text of button - javascript_string = """ - function fitin() { - var fitindiv = document.querySelectorAll(".fitin"); - var i; - for (i = 0; i < fitindiv.length; i++) { - // inner div needed to see if inner div is larger in height to reduce the font-size - fitindivdiv = fitindiv[i].firstElementChild; - while( fitindivdiv.clientHeight > fitindiv[i].clientHeight ) { - fitindivdiv.style.fontSize = (parseInt(window.getComputedStyle(fitindivdiv).fontSize) - 1) + "px"; - } - } - }; - fitin(); +@SetRoute('/') +async def application(request): """ - async def page_ready(self, msg): - run_task(self.run_javascript(javascript_string, request_id='fitin_logic', send=False)) - wp.on('page_ready', page_ready) + 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; + } + """ + + # 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) + + def toggle_edit_config(self, msg): + self.dialog.value = True + if path.exists(CONFIG_FILE_FULL): + self.dialog_label.text = CONFIG_FILE + with open(CONFIG_FILE_FULL, 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 path.exists(CONFIG_FILE_FULL): + with open(CONFIG_FILE_FULL, 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', + 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') + + # 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' + btn_toogle_dark = ToggleDarkModeBtn( + label='', icon='settings_brightness', dense=True, flat=True, a=toolbar, + #click=dark_light_mode_toggle + ) + QTooltip(a=btn_toogle_dark, text='Toggle dark/light mode') + 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) + 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') + 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]) + if j['widget-class'] == 'Button': + Button(text=j['text'], text_alt=j['text-alt'], + btype=j['type'], + 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'], + state_command=j['state-command'], state_command_alt=j['state-command-alt'], + icon=j['icon'], icon_alt=j['icon-alt'], + image=j['image'], image_alt=j['image-alt'], + a=eval(var)) + elif j['widget-class'] == 'Volume': + Volume(name=j['name'], description=j['description'], + vtype=j['type'], + a=eval(var)) + elif j['widget-class'] == 'VolumeGroup': + VolumeGroup(vtype=j['type'], a=eval(var)) + + # Test + 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) + + # tmp = process('pactl -f json list sink-inputs', stderr=None) + # print('') + # #print(tmp) + # #print(len(tmp)) + # tmp = json.loads(tmp) + # print(tmp) + # print(len(tmp)) + # if tmp: + # print('mute:', tmp[0]['mute']) + # print('volume:', tmp[0]['volume']['front-left']['value_percent'][:-1]) + # print('application.process.binary:', tmp[0]['properties']['application.process.binary']) + # print('application.process.id:', tmp[0]['properties']['application.process.id']) + # if 'application.icon_name' in tmp[0]['properties']: + # print('application.icon_name:', tmp[0]['properties']['application.icon_name']) + # if 'media.icon_name' in tmp[0]['properties']: + # print('media.icon_name:', tmp[0]['properties']['media.icon_name']) + # print('media.name:', tmp[0]['properties']['media.name']) + # print('') + + #tmp2 = process('pactl list sink-inputs | grep media.name', shell=True, stderr=None) + #print(tmp2) + # remove ' name.media = ' and remove quotation marks at front and back + # print([i[1:-1] for i in re.sub('[ \t]*media.name = ', '', tmp2, re.S).split('\n')]) + + # own pactl list sink-inputs parser (pactl 16.1, Compiled with libpulse 16.1.0) + tmp2 = process("pactl list sink-inputs", shell=True, stderr=None) + #print(tmp2) + tmp3 = [] + for i in tmp2.split('Sink Input #'): # split block + if i: + tmp4 = {} + for j,k in enumerate(i.split('\n')): # split on every line + if j == 0: # fist entry + tmp4['id'] = k # is the id + tmp4['properties'] = {} # initialize + if k: # others are attributes + if 'Driver: ' in k: + tmp4['driver'] = re.sub('[ \t]*Driver: ', '', k) + elif 'Mute: ' in k: + tmp5 = re.sub('[ \t]*Mute: ', '', k) + tmp5 = tmp5.replace('no', 'false') + tmp5 = tmp5.replace('yes', 'true') + tmp4['mute'] = tmp5 + elif 'Volume: ' in k: + tmp4['volume'] = {} + for l in re.sub('[ \t]*Volume: ', '', k).split(','): + tmp5 = l.split(': ') + tmp6 = tmp5[1].split(' / ') + tmp4['volume'][tmp5[0].strip()] = { + 'value': tmp6[0], 'value_percent': tmp6[1], 'db': tmp6[2]} + # Properties + elif 'application.name = ' in k: + # remove '\t\tapplication.name = ' and remove quotation marks at front and back + tmp4['properties']['application.name'] = re.sub('[ \t]*application.name = ', '', k)[1:-1] + elif 'application.process.id = ' in k: + # remove '\t\tapplication.process.id = ' and remove quotation marks at front and back + tmp4['properties']['application.process.id'] = re.sub('[ \t]*application.process.id = ', '', k)[1:-1] + elif 'application.process.binary = ' in k: + # remove '\t\tapplication.process.binary = ' and remove quotation marks at front and back + tmp4['properties']['application.process.binary'] = re.sub('[ \t]*application.process.binary = ', '', k)[1:-1] + elif 'media.icon_name = ' in k: + # remove '\t\tmedia.icon_name = ' and remove quotation marks at front and back + tmp4['properties']['media.icon_name'] = re.sub('[ \t]*media.icon_name = ', '', k)[1:-1] # only pipewire? + elif 'application.icon_name = ' in k: + # remove '\t\tapplication.icon_name = ' and remove quotation marks at front and back + tmp4['properties']['application.icon_name'] = re.sub('[ \t]*application.icon_name = ', '', k)[1:-1] # only pulseaudio? (seen on wsl) + elif 'media.name = ' in k: + # remove '\t\tmedia.name = ' and remove quotation marks at front and back + tmp4['properties']['media.name'] = re.sub('[ \t]*media.name = ', '', k)[1:-1] + elif 'node.name = ' in k: + # remove '\t\tnode.name = ' and remove quotation marks at front and back + tmp4['properties']['node.name'] = re.sub('[ \t]*node.name = ', '', k)[1:-1] + tmp3.append(tmp4) + import xdg.IconTheme + #print(tmp3) + #icon_name = '' + #icon_name = i['properties']['application.icon_name'] if 'application.icon_name' in i['properties'] else icon_name + #icon_name = i['properties']['media.icon_name'] if 'media.icon_name' in i['properties'] else icon_name + #icon = 'img:' + xdg.IconTheme.getIconPath(icon_name, size=None, extensions=['svg', 'png']), + + # 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) + #if status: + # wp.add(STATUS_DIV) return wp -def main(): - justpy(host="0.0.0.0") +@SetRoute('/hello') +def hello_function(): + wp = WebPage() + wp.add(P(text='Hello there!', classes='text-5xl m-2')) + return wp + +def main(args): + config = config_load(args.config) + host = config.get('default', 'host', fallback='0.0.0.0') + port = config.get('default', 'port', fallback='8000') + justpy(host=host, port=port, static_directory="./static") + +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('-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) + + return 0 if __name__ == '__main__': - sys.exit(main()) + sys.exit(cli()) diff --git a/controldeck_gui.py b/controldeck_gui.py index 67fb531..6114801 100755 --- a/controldeck_gui.py +++ b/controldeck_gui.py @@ -14,36 +14,19 @@ def thread_function(name): # print("Thread %s: finishing", name) # p = process("xdotool search --name 'ControlDeck'") # intersection of ControlDeck window name and empty classname - p = process("comm -12 <(xdotool search --name 'ControlDeck' | sort) <(xdotool search --classname '^$' | sort)") + p = process("comm -12 <(xdotool search --name 'ControlDeck' | sort) <(xdotool search --classname '^$' | sort)", shell=True) if p: # print(p) # process("xdotool search --name 'ControlDeck' set_window --class 'controldeck'", output=False) # process("xdotool search --name 'ControlDeck' set_window --classname 'controldeck' --class 'ControlDeck' windowunmap windowmap", output=False) # will find to many wrong ids - process(f"xdotool set_window --classname 'controldeck' --class 'ControlDeck' {p} windowunmap {p} windowmap {p}", output=False) + process(f"xdotool set_window --classname 'controldeck' --class 'ControlDeck' {p} windowunmap {p} windowmap {p}", shell=True, output=False) time.sleep(0.1) def main(args, pid=-1): - #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") - - if args.start and controldeck_process == "": - process("controldeck &", 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) - config = config_load(conf=args.config) - url = config.get('gui', 'url', fallback='http://0.0.0.0:8000') + "/?gui&pid=" + str(pid) + 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 = int(config.get('gui', 'width', fallback=800)) except ValueError as e: @@ -81,6 +64,28 @@ def main(args, pid=-1): minimized = config.get('gui', 'minimized', fallback='False').title() == 'True' on_top = config.get('gui', 'always_on_top', fallback='False').title() == 'True' + #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) + + if args.start and controldeck_process == "": + cmd = "controldeck" + 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) + create_window("ControlDeck", url=url, html=None, diff --git a/justpy.env b/justpy.env new file mode 100644 index 0000000..a10cdac --- /dev/null +++ b/justpy.env @@ -0,0 +1,2 @@ +STATIC_DIRECTORY = ./static +NO_INTERNET = True diff --git a/setup.cfg b/setup.cfg index fc04662..b7aef03 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,7 +6,6 @@ version = file: VERSION install_requires = justpy pywebview - cairosvg py_modules = controldeck controldeck_gui