f1-telemetry/telemetry/gui.py
2026-06-13 18:53:52 +03:00

228 lines
8.8 KiB
Python

import os
import queue
import threading
import tkinter as tk
from tkinter import filedialog, scrolledtext, ttk
from .parser import load_lap
from .report_common import report_path
from . import report_technique, report_setup, report_compare, report_race
from .rename import rename_unprocessed
# project root = parent of the tool\ dir
_PROJECT_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', '..'))
_REPORTS_DIR = os.path.join(_PROJECT_ROOT, 'reports')
def _race_output_path(race_dir: str) -> str:
dirname = os.path.basename(race_dir.rstrip('/\\'))
return os.path.join(_PROJECT_ROOT, 'reports', dirname + '_race.md')
class _App:
def __init__(self, root: tk.Tk) -> None:
self._root = root
root.title('F1 Telemetry Analyzer')
root.resizable(True, True)
self._mode = tk.StringVar(value='technique')
self._lang = tk.StringVar(value='ru')
self._selection: list[str] = []
self._log_queue: queue.Queue[str | None] = queue.Queue()
self._busy = False
self._build_ui()
self._poll_log()
def _build_ui(self) -> None:
pad = {'padx': 8, 'pady': 4}
mode_frame = ttk.LabelFrame(self._root, text='Mode')
mode_frame.pack(fill='x', **pad)
for label, value in [('Technique', 'technique'), ('Setup', 'setup'),
('Compare', 'compare'), ('Race', 'race')]:
ttk.Radiobutton(mode_frame, text=label, variable=self._mode,
value=value).pack(side='left', padx=6, pady=4)
lang_frame = ttk.LabelFrame(self._root, text='Prompt language / Язык промпта')
lang_frame.pack(fill='x', **pad)
ttk.Radiobutton(lang_frame, text='RU', variable=self._lang,
value='ru').pack(side='left', padx=6, pady=4)
ttk.Radiobutton(lang_frame, text='EN', variable=self._lang,
value='en').pack(side='left', padx=6, pady=4)
input_frame = ttk.LabelFrame(self._root, text='Input')
input_frame.pack(fill='x', **pad)
self._path_var = tk.StringVar()
ttk.Entry(input_frame, textvariable=self._path_var,
state='readonly', width=60).pack(side='left', fill='x',
expand=True, padx=(6, 2), pady=4)
ttk.Button(input_frame, text='Browse…',
command=self._browse).pack(side='left', padx=(2, 6), pady=4)
btn_frame = ttk.Frame(self._root)
btn_frame.pack(fill='x', **pad)
self._btn_generate = ttk.Button(btn_frame, text='Generate report',
command=self._on_generate)
self._btn_generate.pack(side='left', padx=(0, 4))
self._btn_rename = ttk.Button(btn_frame, text='Rename unprocessed (add lap #)',
command=self._on_rename)
self._btn_rename.pack(side='left', padx=4)
ttk.Button(btn_frame, text='Open reports folder',
command=self._open_reports).pack(side='left', padx=4)
log_frame = ttk.LabelFrame(self._root, text='Log')
log_frame.pack(fill='both', expand=True, **pad)
self._log = scrolledtext.ScrolledText(log_frame, state='disabled',
wrap='word', height=16)
self._log.pack(fill='both', expand=True, padx=4, pady=4)
def _browse(self) -> None:
mode = self._mode.get()
init = _PROJECT_ROOT
if mode == 'race':
d = filedialog.askdirectory(title='Select race folder', initialdir=init)
if d:
self._selection = [d]
self._path_var.set(d)
elif mode == 'compare':
files = filedialog.askopenfilenames(
title='Select 2+ CSV files', initialdir=init,
filetypes=[('CSV', '*.csv')])
if files:
self._selection = list(files)
self._path_var.set(' | '.join(os.path.basename(f) for f in files))
else:
f = filedialog.askopenfilename(
title='Select CSV file', initialdir=init,
filetypes=[('CSV', '*.csv')])
if f:
self._selection = [f]
self._path_var.set(f)
def _validate_selection(self) -> str | None:
mode = self._mode.get()
sel = self._selection
if not sel:
return 'No file selected'
if mode in ('technique', 'setup') and len(sel) != 1:
return f'{mode} requires exactly 1 file'
if mode == 'compare' and len(sel) < 2:
return 'compare requires 2+ files'
if mode == 'race' and (len(sel) != 1 or not os.path.isdir(sel[0])):
return 'race requires a directory'
return None
def _set_busy(self, busy: bool) -> None:
self._busy = busy
state = 'disabled' if busy else 'normal'
self._btn_generate.configure(state=state)
self._btn_rename.configure(state=state)
def _log_append(self, text: str) -> None:
self._log.configure(state='normal')
self._log.insert('end', text + '\n')
self._log.see('end')
self._log.configure(state='disabled')
def _poll_log(self) -> None:
while True:
try:
msg = self._log_queue.get_nowait()
except queue.Empty:
break
if msg is None:
self._set_busy(False)
else:
self._log_append(msg)
self._root.after(100, self._poll_log)
def _push(self, msg: str) -> None:
self._log_queue.put(msg)
def _on_generate(self) -> None:
err = self._validate_selection()
if err:
self._log_append(f'✗ {err}')
return
mode = self._mode.get()
sel = list(self._selection)
lang = self._lang.get()
self._set_busy(True)
threading.Thread(target=self._worker_generate, args=(mode, sel, lang),
daemon=True).start()
def _worker_generate(self, mode: str, sel: list[str], lang: str) -> None:
try:
if mode == 'technique':
lap = load_lap(sel[0])
out = report_path(sel[0], 'technique')
abs_path, tokens = report_technique.generate(lap, out, lang)
self._push(f'✓ {abs_path} (~{tokens} tokens)')
elif mode == 'setup':
lap = load_lap(sel[0])
out = report_path(sel[0], 'setup')
abs_path, tokens = report_setup.generate(lap, out, lang)
self._push(f'✓ {abs_path} (~{tokens} tokens)')
elif mode == 'compare':
laps = [load_lap(f) for f in sel]
stem_b = os.path.splitext(os.path.basename(sel[1]))[0]
out = report_path(sel[0], 'compare', stem_b)
abs_path, tokens = report_compare.generate(laps, out, lang)
self._push(f'✓ {abs_path} (~{tokens} tokens)')
elif mode == 'race':
race_dir = os.path.abspath(sel[0])
csv_files = sorted(
f for f in os.listdir(race_dir) if f.lower().endswith('.csv')
)
if not csv_files:
self._push(f'✗ No CSV files found in {race_dir}')
return
laps = [load_lap(os.path.join(race_dir, f)) for f in csv_files]
out = _race_output_path(race_dir)
abs_path, tokens = report_race.generate(laps, out, lang)
self._push(f'✓ {abs_path} (~{tokens} tokens)')
except Exception as e:
self._push(f'✗ {e}')
finally:
self._log_queue.put(None)
def _on_rename(self) -> None:
d = filedialog.askdirectory(title='Select folder to rename', initialdir=_PROJECT_ROOT)
if not d:
return
self._set_busy(True)
threading.Thread(target=self._worker_rename, args=(d,), daemon=True).start()
def _worker_rename(self, d: str) -> None:
try:
results = rename_unprocessed([d])
if not results:
self._push('No CSV files found')
for r in results:
if r.status == 'renamed':
self._push(f'renamed: {r.old} -> {r.new}')
elif r.status == 'skipped':
self._push(f'skipped: {r.old} ({r.detail})')
else:
self._push(f'error: {r.old} ({r.detail})')
except Exception as e:
self._push(f'✗ {e}')
finally:
self._log_queue.put(None)
def _open_reports(self) -> None:
if not os.path.isdir(_REPORTS_DIR):
self._log_append(f'Reports folder does not exist yet: {_REPORTS_DIR}')
return
os.startfile(_REPORTS_DIR)
def main() -> None:
root = tk.Tk()
_App(root)
root.mainloop()