498 lines
20 KiB
Python
498 lines
20 KiB
Python
"""PySimpleGUI-based desktop GUI for DslrDirector.
|
|
|
|
This file provides a simpler, single-file GUI implementation using PySimpleGUI
|
|
instead of Tkinter. It mirrors the core functionality: Animations tab (home,
|
|
countdown, sharing), Communication tab for BLE controls (scan/connect/disconnect),
|
|
and a Logs tab with a refresh button.
|
|
|
|
It reuses the project's existing persistence helpers (load_animation, save_animation,
|
|
load_comm, save_comm) and imports the optional BleComm module if available.
|
|
|
|
Logo handling: use Pillow (if available) to apply the same un-matte/defringe and
|
|
30%-scale pipeline. On Windows the code will compose the processed image onto the
|
|
panel background color and save as data/ata_logo_out.png so that the displayed
|
|
image has no halo issues.
|
|
|
|
Run with: python src/gui_psg.py (from project root, venv recommended)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import threading
|
|
import time
|
|
|
|
import PySimpleGUI as sg
|
|
# Some PySimpleGUI builds (private PyPI message) may present an unexpected API.
|
|
# Verify the module exposes the expected elements; otherwise fall back to the
|
|
# existing Tkinter GUI so the user still gets a working UI and gets guidance.
|
|
try:
|
|
if not hasattr(sg, 'Text') or not hasattr(sg, 'Window'):
|
|
raise ImportError('PySimpleGUI API appears incompatible')
|
|
except Exception as _psg_err:
|
|
msg = (
|
|
"PySimpleGUI import succeeded but the installed package does not expose the\n"
|
|
"expected API (Text/Window). This can happen with a private or mismatched\n"
|
|
"PySimpleGUI build.\n\n"
|
|
"Recommended fix:\n"
|
|
" python -m pip uninstall PySimpleGUI\n"
|
|
" python -m pip cache purge\n"
|
|
" python -m pip install --upgrade --extra-index-url https://PySimpleGUI.net/install PySimpleGUI\n\n"
|
|
"Falling back to the Tkinter GUI (src/gui.py) for now...\n"
|
|
)
|
|
try:
|
|
# write to log if available
|
|
with open(os.path.join(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')), 'data', 'gui_start.log'), 'a', encoding='utf-8') as lf:
|
|
lf.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} PSG: {msg}\n")
|
|
except Exception:
|
|
pass
|
|
print(msg)
|
|
try:
|
|
# Ensure repo root is on sys.path so 'src' package imports work when running as a script
|
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
if repo_root not in sys.path:
|
|
sys.path.insert(0, repo_root)
|
|
# Launch existing Tk GUI as a fallback
|
|
from src.gui import DSLRGui
|
|
app = DSLRGui()
|
|
app.mainloop()
|
|
# clean exit
|
|
raise SystemExit(0)
|
|
except SystemExit:
|
|
raise
|
|
except Exception as e:
|
|
# if fallback also fails, log and re-raise
|
|
try:
|
|
with open(os.path.join(repo_root, 'data', 'gui_start.log'), 'a', encoding='utf-8') as lf:
|
|
lf.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} PSG fallback error: {e}\n")
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
# reuse helpers from existing module
|
|
try:
|
|
import src.app as appmod
|
|
import importlib
|
|
BleComm = importlib.import_module('src.BleComm')
|
|
except Exception:
|
|
appmod = None
|
|
BleComm = None
|
|
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
DATA_DIR = os.path.join(REPO_ROOT, 'data')
|
|
CONFIG_PATH = os.path.join(DATA_DIR, 'config.json')
|
|
COMM_PATH = os.path.join(DATA_DIR, 'comm.json')
|
|
GUI_LOG = os.path.join(REPO_ROOT, 'data', 'gui_start.log')
|
|
|
|
|
|
def _write_gui_log(msg: str):
|
|
try:
|
|
with open(GUI_LOG, 'a', encoding='utf-8') as f:
|
|
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# reuse persistence helpers if available in appmod by importing functions from gui.py
|
|
try:
|
|
from src.gui import load_animation, save_animation, load_comm, save_comm
|
|
except Exception:
|
|
# fallback implementations (simple read/write)
|
|
def load_animation():
|
|
try:
|
|
if not os.path.exists(CONFIG_PATH):
|
|
return {}
|
|
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
|
data = json.load(f) or {}
|
|
return data.get('animation-events', {})
|
|
except Exception:
|
|
return {}
|
|
|
|
def save_animation(ae: dict):
|
|
try:
|
|
base = {}
|
|
if os.path.exists(CONFIG_PATH):
|
|
try:
|
|
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
|
base = json.load(f) or {}
|
|
except Exception:
|
|
base = {}
|
|
base['animation-events'] = ae
|
|
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
|
json.dump(base, f, indent=2)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def load_comm():
|
|
try:
|
|
if not os.path.exists(COMM_PATH):
|
|
return {}
|
|
with open(COMM_PATH, 'r', encoding='utf-8') as f:
|
|
return json.load(f) or {}
|
|
except Exception:
|
|
return {}
|
|
|
|
def save_comm(cfg: dict):
|
|
try:
|
|
with open(COMM_PATH, 'w', encoding='utf-8') as f:
|
|
json.dump(cfg, f, indent=2)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# Image processing pipeline (Pillow optional)
|
|
PIL_AVAILABLE = False
|
|
try:
|
|
from PIL import Image
|
|
PIL_AVAILABLE = True
|
|
except Exception:
|
|
Image = None
|
|
|
|
|
|
def process_logo_for_psg(path: str, panel_hex='#252535'):
|
|
"""Open path, un-matte/defringe, resize to 30%, and return a filepath to a PNG.
|
|
|
|
On Windows compose onto panel background and save to DATA_DIR/ata_logo_out.png
|
|
and return that path. If Pillow not available, return original path.
|
|
"""
|
|
if not path or not os.path.exists(path):
|
|
return None
|
|
if not PIL_AVAILABLE:
|
|
return path
|
|
try:
|
|
img = Image.open(path).convert('RGBA')
|
|
# crop transparent border
|
|
try:
|
|
alpha = img.getchannel('A')
|
|
bbox = alpha.getbbox()
|
|
if bbox:
|
|
img = img.crop(bbox)
|
|
except Exception:
|
|
pass
|
|
# (simple) un-matte: sample border matte and reverse for semi-alpha
|
|
try:
|
|
alpha = img.getchannel('A')
|
|
w, h = img.size
|
|
px = img.load()
|
|
matte_samples = []
|
|
for x in range(w):
|
|
if alpha.getpixel((x, 0)) == 0:
|
|
matte_samples.append(px[x, 0][:3])
|
|
if alpha.getpixel((x, h - 1)) == 0:
|
|
matte_samples.append(px[x, h - 1][:3])
|
|
for y in range(h):
|
|
if alpha.getpixel((0, y)) == 0:
|
|
matte_samples.append(px[0, y][:3])
|
|
if alpha.getpixel((w - 1, y)) == 0:
|
|
matte_samples.append(px[w - 1, y][:3])
|
|
if matte_samples:
|
|
mr = int(sum(s[0] for s in matte_samples) / len(matte_samples))
|
|
mg = int(sum(s[1] for s in matte_samples) / len(matte_samples))
|
|
mb = int(sum(s[2] for s in matte_samples) / len(matte_samples))
|
|
matte = (mr, mg, mb)
|
|
new = Image.new('RGBA', img.size)
|
|
for yy in range(h):
|
|
for xx in range(w):
|
|
r, g, b, a = img.getpixel((xx, yy))
|
|
if a == 0 or a == 255:
|
|
new.putpixel((xx, yy), (r, g, b, a))
|
|
else:
|
|
af = a / 255.0
|
|
rr = int(round((r - (1 - af) * matte[0]) / max(af, 1e-6)))
|
|
gg = int(round((g - (1 - af) * matte[1]) / max(af, 1e-6)))
|
|
bb = int(round((b - (1 - af) * matte[2]) / max(af, 1e-6)))
|
|
rr = max(0, min(255, rr))
|
|
gg = max(0, min(255, gg))
|
|
bb = max(0, min(255, bb))
|
|
new.putpixel((xx, yy), (rr, gg, bb, a))
|
|
img = new
|
|
except Exception:
|
|
pass
|
|
# conservative defringe
|
|
try:
|
|
alpha_cut = 32
|
|
color_tol = 30
|
|
w2, h2 = img.size
|
|
px2 = img.load()
|
|
matte_rgb = None
|
|
if hasattr(img, 'info') and 'background' in img.info:
|
|
matte_rgb = img.info['background']
|
|
if matte_rgb is None and len(matte_samples) > 0:
|
|
matte_rgb = matte
|
|
if matte_rgb is None:
|
|
matte_rgb = (30, 30, 43)
|
|
removed = 0
|
|
for yy in range(h2):
|
|
for xx in range(w2):
|
|
r, g, b, a = px2[xx, yy]
|
|
if 0 < a <= alpha_cut:
|
|
dr = r - matte_rgb[0]
|
|
dg = g - matte_rgb[1]
|
|
db = b - matte_rgb[2]
|
|
dist = (dr * dr + dg * dg + db * db) ** 0.5
|
|
if dist <= color_tol:
|
|
px2[xx, yy] = (0, 0, 0, 0)
|
|
removed += 1
|
|
except Exception:
|
|
pass
|
|
# resize to 30%
|
|
sw, sh = img.size
|
|
new_w = max(1, int(sw * 0.3))
|
|
new_h = max(1, int(sh * 0.3))
|
|
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
|
|
# On Windows, compose to opaque background and save
|
|
out_path = os.path.join(DATA_DIR, 'ata_logo_out.png')
|
|
try:
|
|
if os.name == 'nt':
|
|
def hex_to_rgb(h):
|
|
h = h.lstrip('#')
|
|
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
|
try:
|
|
panel_rgb = hex_to_rgb(panel_hex)
|
|
except Exception:
|
|
panel_rgb = (37, 37, 53)
|
|
bg = Image.new('RGBA', img_resized.size, panel_rgb + (255,))
|
|
composed = Image.alpha_composite(bg, img_resized)
|
|
composed.save(out_path, format='PNG')
|
|
_write_gui_log(f'PSG Logo: wrote composed PNG to {out_path}')
|
|
return out_path
|
|
else:
|
|
img_resized.save(out_path, format='PNG')
|
|
_write_gui_log(f'PSG Logo: wrote PNG to {out_path}')
|
|
return out_path
|
|
except Exception:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# Cosmetic theme/colors for a modern dark look
|
|
def _apply_psg_theme():
|
|
# define core palette
|
|
bg = '#12121a'
|
|
panel = '#1e1e2b'
|
|
fg = '#e6e6f0'
|
|
accent = '#4fb0c6'
|
|
|
|
# Add a named theme mapping to PySimpleGUI
|
|
try:
|
|
sg.LOOK_AND_FEEL_TABLE['DslrDark'] = {
|
|
'BACKGROUND': bg,
|
|
'TEXT': fg,
|
|
'INPUT': '#262636',
|
|
'TEXT_INPUT': fg,
|
|
'SCROLL': '#2b2b36',
|
|
'BUTTON': accent,
|
|
'PROGRESS': ('#01826B', '#D0D0D0'),
|
|
'BORDER': 1,
|
|
'SLIDER_DEPTH': 0,
|
|
'PROGRESS_DEPTH': 0
|
|
}
|
|
sg.theme('DslrDark')
|
|
sg.set_options(element_padding=(6, 6), button_color=(fg, panel), font=('Segoe UI', 11))
|
|
except Exception:
|
|
try:
|
|
sg.theme('Dark')
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
_apply_psg_theme()
|
|
|
|
|
|
# Build the PySimpleGUI layout
|
|
def build_layout():
|
|
anim = load_animation()
|
|
comm = load_comm()
|
|
# Anim tab layout
|
|
anim_layout = [
|
|
[sg.Text('Home')],
|
|
[sg.Radio('Animation', 'home_mode', default=(anim.get('home-state', 'animation') == 'animation'), key='-HOME_ANIM-'),
|
|
sg.Radio('Solid', 'home_mode', default=(anim.get('home-state') == 'solid'), key='-HOME_SOLID-')],
|
|
[sg.Text('Anim ID'), sg.Input(anim.get('home-anim', '0'), key='-HOME_ID-', size=(8,1))],
|
|
[sg.Text('Color'), sg.Input(anim.get('home-color', '#000000'), key='-HOME_COLOR-', size=(12,1)), sg.Button('Pick', key='-HOME_PICK-')],
|
|
[sg.HorizontalSeparator()],
|
|
[sg.Text('Countdown')],
|
|
[sg.Radio('Animation', 'count_mode', default=(anim.get('countdown-state', 'animation') == 'animation'), key='-COUNT_ANIM-'),
|
|
sg.Radio('Solid', 'count_mode', default=(anim.get('countdown-state') == 'solid'), key='-COUNT_SOLID-')],
|
|
[sg.Text('Anim ID'), sg.Input(anim.get('countdown-anim', '0'), key='-COUNT_ID-', size=(8,1))],
|
|
[sg.Text('Color'), sg.Input(anim.get('countdown-color', '#ffffff'), key='-COUNT_COLOR-', size=(12,1)), sg.Button('Pick', key='-COUNT_PICK-')],
|
|
[sg.HorizontalSeparator()],
|
|
[sg.Text('Sharing')],
|
|
[sg.Radio('Animation', 'share_mode', default=(anim.get('sharing-state', 'animation') == 'animation'), key='-SHARE_ANIM-'),
|
|
sg.Radio('Solid', 'share_mode', default=(anim.get('sharing-state') == 'solid'), key='-SHARE_SOLID-')],
|
|
[sg.Text('Anim ID'), sg.Input(anim.get('sharing-anim', '0'), key='-SHARE_ID-', size=(8,1))],
|
|
[sg.Text('Color'), sg.Input(anim.get('sharing-color', '#0000ff'), key='-SHARE_COLOR-', size=(12,1)), sg.Button('Pick', key='-SHARE_PICK-')],
|
|
[sg.Button('Save Animations', key='-SAVE_ANIM-')]
|
|
]
|
|
|
|
# Comm (BLE) layout
|
|
comm_name = comm.get('ble', {}).get('device_name', '') if isinstance(comm, dict) else ''
|
|
comm_addr = comm.get('ble', {}).get('address', '') if isinstance(comm, dict) else ''
|
|
comm_filter = comm.get('ble', {}).get('filter_name', '') if isinstance(comm, dict) else ''
|
|
comm_layout = [
|
|
[sg.Text('Device Name'), sg.Input(comm_name, key='-COMM_NAME-', size=(30,1))],
|
|
[sg.Text('Address/ID'), sg.Input(comm_addr, key='-COMM_ADDR-', size=(30,1))],
|
|
[sg.Text('Filter'), sg.Input(comm_filter, key='-COMM_FILTER-', size=(30,1))],
|
|
[sg.Button('Scan', key='-SCAN-', button_color=('white', '#2f313a'), size=(10,1)),
|
|
sg.Button('Connect', key='-CONNECT-', button_color=('white', '#4fb0c6'), size=(10,1)),
|
|
sg.Button('Disconnect', key='-DISCONNECT-', button_color=('white', '#a64b4b'), size=(10,1))],
|
|
[sg.Listbox(values=[], size=(60,6), key='-SCAN_LIST-')]
|
|
]
|
|
|
|
logs_layout = [
|
|
[sg.Multiline('', size=(80, 20), key='-LOG_TEXT-')],
|
|
[sg.Button('Refresh Logs', key='-REFRESH_LOGS-', button_color=('white', '#4fb0c6'))]
|
|
]
|
|
|
|
tab_group = [[sg.Tab('Animations', anim_layout), sg.Tab('Communication', comm_layout), sg.Tab('Logs', logs_layout)]]
|
|
|
|
# load logo (attempt processed path)
|
|
logo_candidates = [
|
|
os.path.join(REPO_ROOT, 'static', 'images', 'ata_logo.png'),
|
|
os.path.join(os.getcwd(), 'static', 'images', 'ata_logo.png'),
|
|
os.path.join(DATA_DIR, 'ata_logo.png')
|
|
]
|
|
logo_path = None
|
|
for p in logo_candidates:
|
|
if p and os.path.exists(p):
|
|
logo_path = p
|
|
break
|
|
if logo_path:
|
|
processed = process_logo_for_psg(logo_path)
|
|
else:
|
|
processed = None
|
|
|
|
# Header: left logo, centered title/subtitle
|
|
if processed and os.path.exists(processed):
|
|
logo_el = sg.Image(processed, pad=(6, 6))
|
|
else:
|
|
logo_el = sg.Text('', size=(12, 1))
|
|
|
|
title_col = [[sg.Text('DSLR Director', font=('Segoe UI', 22, 'bold'), justification='center')],
|
|
[sg.Text('Control animations and BLE from your desktop', font=('Segoe UI', 10), text_color='#cbd6dc', justification='center')]]
|
|
|
|
header_frame = [
|
|
sg.Column([[logo_el]], vertical_alignment='center', element_justification='left', pad=(8, 8)),
|
|
sg.VerticalSeparator(),
|
|
sg.Column(title_col, vertical_alignment='center', element_justification='center', expand_x=True, pad=(8, 8))
|
|
]
|
|
|
|
# Slightly larger tab group with padding
|
|
layout = [
|
|
header_frame,
|
|
[sg.HorizontalSeparator()],
|
|
[sg.TabGroup(tab_group, key='-TABS-', expand_x=True, expand_y=True, pad=(6, 8))],
|
|
[sg.Button('Exit', button_color=('white', '#2f313a'))]
|
|
]
|
|
|
|
return layout
|
|
|
|
|
|
def run():
|
|
layout = build_layout()
|
|
window = sg.Window('DslrDirector - PySimpleGUI', layout, finalize=True)
|
|
|
|
# Background: attempt to start BleComm if present
|
|
try:
|
|
if BleComm is not None and hasattr(BleComm, 'start'):
|
|
BleComm.start()
|
|
except Exception:
|
|
pass
|
|
|
|
while True:
|
|
event, values = window.read(timeout=200)
|
|
if event == sg.WIN_CLOSED or event == 'Exit':
|
|
break
|
|
if event == '-SAVE_ANIM-':
|
|
ae = {}
|
|
ae['home-state'] = 'animation' if values.get('-HOME_ANIM-') else 'solid'
|
|
ae['home-color'] = values.get('-HOME_COLOR-', '#000000')
|
|
try:
|
|
ae['home-anim'] = int(values.get('-HOME_ID-', '0'))
|
|
except Exception:
|
|
ae['home-anim'] = 0
|
|
ae['countdown-state'] = 'animation' if values.get('-COUNT_ANIM-') else 'solid'
|
|
ae['countdown-color'] = values.get('-COUNT_COLOR-', '#ffffff')
|
|
try:
|
|
ae['countdown-anim'] = int(values.get('-COUNT_ID-', '0'))
|
|
except Exception:
|
|
ae['countdown-anim'] = 0
|
|
ae['sharing-state'] = 'animation' if values.get('-SHARE_ANIM-') else 'solid'
|
|
ae['sharing-color'] = values.get('-SHARE_COLOR-', '#0000ff')
|
|
try:
|
|
ae['sharing-anim'] = int(values.get('-SHARE_ID-', '0'))
|
|
except Exception:
|
|
ae['sharing-anim'] = 0
|
|
ok = save_animation(ae)
|
|
if ok:
|
|
sg.popup('Saved', 'Animations saved to config.json')
|
|
else:
|
|
sg.popup('Error', 'Failed to save animations')
|
|
|
|
if event == '-SCAN-':
|
|
window['-SCAN_LIST-'].update([])
|
|
def _scan():
|
|
try:
|
|
res = BleComm.scan(values.get('-COMM_FILTER-', '') if BleComm else [])
|
|
items = []
|
|
for d in res:
|
|
name = d.get('name') or ''
|
|
addr = d.get('address') or d.get('id') or ''
|
|
items.append(f"{name} | {addr}")
|
|
window['-SCAN_LIST-'].update(items)
|
|
except Exception as e:
|
|
window['-SCAN_LIST-'].update([f"Scan failed: {e}"])
|
|
threading.Thread(target=_scan, daemon=True).start()
|
|
|
|
if event == '-CONNECT-':
|
|
addr = values.get('-COMM_ADDR-', '').strip()
|
|
name = values.get('-COMM_NAME-', '').strip()
|
|
base = load_comm()
|
|
base['ble'] = base.get('ble', {})
|
|
if addr:
|
|
base['ble']['address'] = addr
|
|
if name:
|
|
base['ble']['device_name'] = name
|
|
base['ble']['filter_name'] = values.get('-COMM_FILTER-', '')
|
|
save_comm(base)
|
|
def _do():
|
|
try:
|
|
res = BleComm.connect(addr or name)
|
|
sg.popup('Connect', f'Connect result: {res}')
|
|
except Exception as e:
|
|
sg.popup('Connect failed', str(e))
|
|
threading.Thread(target=_do, daemon=True).start()
|
|
|
|
if event == '-DISCONNECT-':
|
|
def _do():
|
|
try:
|
|
res = BleComm.disconnect()
|
|
sg.popup('Disconnect', f'Disconnect result: {res}')
|
|
except Exception as e:
|
|
sg.popup('Disconnect failed', str(e))
|
|
threading.Thread(target=_do, daemon=True).start()
|
|
|
|
if event == '-REFRESH_LOGS-':
|
|
content = ''
|
|
if appmod is not None and hasattr(appmod, 'tail_log'):
|
|
content = appmod.tail_log(200)
|
|
else:
|
|
lf = os.path.join(REPO_ROOT, 'dslrbooth_triggers.log')
|
|
if os.path.exists(lf):
|
|
with open(lf, 'r', encoding='utf-8', errors='replace') as f:
|
|
content = ''.join(f.readlines()[-200:])
|
|
window['-LOG_TEXT-'].update(content)
|
|
|
|
window.close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
_write_gui_log('PSG GUI: starting')
|
|
except Exception:
|
|
pass
|
|
run()
|