laboard 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- laboard/__init__.py +1 -0
- laboard/__main__.py +5 -0
- laboard/app.py +528 -0
- laboard/assets/fonts/Figtree-Black.ttf +0 -0
- laboard/assets/fonts/Figtree-Regular.ttf +0 -0
- laboard/assets/fonts/Inter-latin-var.woff2 +0 -0
- laboard/assets/logo/laboard_splash.svg +2 -0
- laboard/charts.py +631 -0
- laboard/labels.py +189 -0
- laboard/modelinfo.py +218 -0
- laboard/netronview.py +107 -0
- laboard/newproject.py +287 -0
- laboard/pages/__init__.py +0 -0
- laboard/pages/data.py +352 -0
- laboard/pages/home.py +242 -0
- laboard/pages/hyperparameter.py +154 -0
- laboard/pages/model.py +78 -0
- laboard/pages/result.py +707 -0
- laboard/pages/setting.py +272 -0
- laboard/project.py +1127 -0
- laboard/readers.py +156 -0
- laboard/remote.py +343 -0
- laboard/sidebar.py +242 -0
- laboard/store.py +38 -0
- laboard/theme.py +111 -0
- laboard/tomledit.py +262 -0
- laboard/torchgraph.py +102 -0
- laboard/widgets.py +1128 -0
- laboard-1.0.0.dist-info/METADATA +400 -0
- laboard-1.0.0.dist-info/RECORD +34 -0
- laboard-1.0.0.dist-info/WHEEL +5 -0
- laboard-1.0.0.dist-info/entry_points.txt +2 -0
- laboard-1.0.0.dist-info/licenses/LICENSE +8 -0
- laboard-1.0.0.dist-info/top_level.txt +1 -0
laboard/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
laboard/__main__.py
ADDED
laboard/app.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
from PyQt6 import sip
|
|
7
|
+
from PyQt6.QtCore import QPoint, QPointF, QRectF, QSettings, Qt, QTimer
|
|
8
|
+
from PyQt6.QtGui import QColor, QPainter, QPen
|
|
9
|
+
from PyQt6.QtWidgets import QApplication, QFrame, QGraphicsDropShadowEffect, QMainWindow, QSizePolicy, QWidget
|
|
10
|
+
|
|
11
|
+
from . import netronview, project, sidebar, store, theme
|
|
12
|
+
from .pages import data, home, hyperparameter, model, result, setting
|
|
13
|
+
from .theme import T
|
|
14
|
+
from .widgets import (Box, Button, Chevron, Collapsing, Dot, HLine, InfoIcon, Label, Logo, Overlay, Pill, Scroll, Segmented, SideHandle,
|
|
15
|
+
SplashLogo, clear_layout, hbox, mono_span, vbox, wrap)
|
|
16
|
+
|
|
17
|
+
TABS = [("home", "Home"), ("data", "Data"), ("model", "Model"), ("hyperparameter", "Hyperparameter"), ("result", "Result"), ("setting", "Setting")]
|
|
18
|
+
PAGES = dict(home=home, data=data, model=model, hyperparameter=hyperparameter, result=result, setting=setting)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WindowButton(QWidget):
|
|
22
|
+
def __init__(self, kind, on_click):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.kind, self.on_click = kind, on_click
|
|
25
|
+
self.setFixedSize(24, 24)
|
|
26
|
+
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
27
|
+
|
|
28
|
+
def mouseReleaseEvent(self, e):
|
|
29
|
+
self.on_click()
|
|
30
|
+
|
|
31
|
+
def paintEvent(self, e):
|
|
32
|
+
p = QPainter(self)
|
|
33
|
+
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
34
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
35
|
+
p.setBrush(QColor("#2a2b28"))
|
|
36
|
+
p.drawEllipse(QRectF(0, 0, 24, 24))
|
|
37
|
+
p.setPen(QPen(QColor("#e8ebe6"), 1.5))
|
|
38
|
+
p.setBrush(Qt.BrushStyle.NoBrush)
|
|
39
|
+
o = QPointF(7, 7)
|
|
40
|
+
if self.kind == "min":
|
|
41
|
+
p.drawLine(o + QPointF(1, 5), o + QPointF(9, 5))
|
|
42
|
+
elif self.kind == "max":
|
|
43
|
+
p.drawRect(QRectF(o + QPointF(1.5, 1.5), o + QPointF(8.5, 8.5)))
|
|
44
|
+
else:
|
|
45
|
+
p.drawLine(o + QPointF(2, 2), o + QPointF(8, 8))
|
|
46
|
+
p.drawLine(o + QPointF(8, 2), o + QPointF(2, 8))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TitleBar(QFrame):
|
|
50
|
+
def __init__(self, win):
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.win = win
|
|
53
|
+
self.setFixedHeight(40)
|
|
54
|
+
self.setStyleSheet("TitleBar{background:#0e0f0c;border-bottom:1px solid #2a2b28;}")
|
|
55
|
+
left = hbox(Logo(22), 8, Label("Laboard", 13, 600, "#e8ebe6"), "stretch", align=Qt.AlignmentFlag.AlignVCenter)
|
|
56
|
+
title = f"{store.PROJECT['name']} — {store.PROJECT['toml']}" if store.PROJECT else "No project loaded"
|
|
57
|
+
mid = Label(title, 13, 400, "#868685", align=Qt.AlignmentFlag.AlignCenter, select=False)
|
|
58
|
+
btns = hbox("stretch", WindowButton("min", win.showMinimized), WindowButton("max", self.toggle_max),
|
|
59
|
+
WindowButton("close", win.close), spacing=8)
|
|
60
|
+
self.setLayout(hbox((left, 1), (mid, 2), (btns, 1), margins=(16, 0, 10, 0), spacing=12))
|
|
61
|
+
|
|
62
|
+
def toggle_max(self):
|
|
63
|
+
self.win.showNormal() if self.win.isMaximized() else self.win.showMaximized()
|
|
64
|
+
|
|
65
|
+
def mousePressEvent(self, e):
|
|
66
|
+
if e.button() == Qt.MouseButton.LeftButton:
|
|
67
|
+
self.win.windowHandle().startSystemMove()
|
|
68
|
+
|
|
69
|
+
def mouseDoubleClickEvent(self, e):
|
|
70
|
+
self.toggle_max()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _saved_rows():
|
|
74
|
+
try:
|
|
75
|
+
n = int(QSettings("Laboard", "Laboard").value("table_rows", store.TABLE_ROWS_DEFAULT))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
n = store.TABLE_ROWS_DEFAULT
|
|
78
|
+
return n if n in store.TABLE_ROWS else store.TABLE_ROWS_DEFAULT
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
CENTER_MIN = 620
|
|
82
|
+
|
|
83
|
+
LOG_FILTERS = [("all", "All"), ("local", "Local"), ("remote", "Remote")]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def in_filter(c, kind):
|
|
87
|
+
return kind == "all" or c[7] in (kind, "both")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Dialog(Overlay):
|
|
91
|
+
def __init__(self, win, title, text, button="Cancel", body=None, actions=None):
|
|
92
|
+
super().__init__(win)
|
|
93
|
+
card = Box(bg="surf", radius=24)
|
|
94
|
+
card.setFixedWidth(520)
|
|
95
|
+
card.setGraphicsEffect(QGraphicsDropShadowEffect(blurRadius=64, offset=QPointF(0, 24), color=QColor(14, 15, 12, 89)))
|
|
96
|
+
icon = Box(bg="surf2", radius=22, layout=hbox(InfoIcon(22), align=Qt.AlignmentFlag.AlignCenter))
|
|
97
|
+
icon.setFixedSize(44, 44)
|
|
98
|
+
words = vbox(Label(title, 22, 600, spacing=-0.4, lh=28), 6, Label(text, 14, 400, "body", wrap=True),
|
|
99
|
+
*((16, body) if body is not None else ()))
|
|
100
|
+
buttons = hbox("stretch", spacing=8, margins=(28, 24, 28, 28))
|
|
101
|
+
for text_, variant, on_click in actions or [(button, "tertiary", None)]:
|
|
102
|
+
buttons.addWidget(Button(text_, variant, on_click=on_click or self.close))
|
|
103
|
+
card.setLayout(vbox(hbox(vbox(icon, "stretch"), 16, (words, 1), margins=(28, 28, 28, 0)), buttons))
|
|
104
|
+
self.setLayout(vbox("stretch", hbox("stretch", card, "stretch"), "stretch"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CommitPicker(QFrame):
|
|
108
|
+
def __init__(self, app, anchor):
|
|
109
|
+
super().__init__(None, Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint | Qt.WindowType.NoDropShadowWindowHint)
|
|
110
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
111
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
112
|
+
self.app, self.anchor = app, anchor
|
|
113
|
+
P = store.PROJECT
|
|
114
|
+
panel = Box(bg="surf", radius=16, border=f"1px solid {T.line}")
|
|
115
|
+
shadow = QGraphicsDropShadowEffect(blurRadius=40, offset=QPointF(0, 12), color=QColor(14, 15, 12, 60))
|
|
116
|
+
panel.setGraphicsEffect(shadow)
|
|
117
|
+
lay = vbox(margins=(8, 8, 8, 8))
|
|
118
|
+
title = hbox(Label("Git log", 14, 600), "stretch", margins=(10, 8, 10, 4), align=Qt.AlignmentFlag.AlignVCenter)
|
|
119
|
+
if P.get("upstream"):
|
|
120
|
+
title.addWidget(Button("Fetch", compact=True, on_click=self.fetch))
|
|
121
|
+
sync = (f"{mono_span(P['branch_name'])} ⇄ {mono_span(P['upstream'])} · {P['ahead']} ahead · {P['behind']} behind"
|
|
122
|
+
f" · fetched {P['fetched']}")
|
|
123
|
+
else:
|
|
124
|
+
sync = f"{mono_span(P.get('branch_name', ''))} has no upstream: every commit is local"
|
|
125
|
+
lay.addLayout(title)
|
|
126
|
+
note = Label(sync, 12, 400, "body", rich=True, wrap=True)
|
|
127
|
+
note.setContentsMargins(10, 0, 10, 8)
|
|
128
|
+
lay.addWidget(note)
|
|
129
|
+
self.seg = None
|
|
130
|
+
if P.get("upstream"):
|
|
131
|
+
self.seg = Segmented(LOG_FILTERS, app.s.log_filter, self.set_filter, expand=True, pad=(5, 0))
|
|
132
|
+
lay.addLayout(hbox(self.seg, margins=(10, 0, 10, 4)))
|
|
133
|
+
self.rows = vbox()
|
|
134
|
+
self.sc = Scroll(wrap(vbox(self.rows, "stretch")))
|
|
135
|
+
lay.addWidget(self.sc)
|
|
136
|
+
back = Label("<u>Back to latest</u>", 13, 600, rich=True, select=False)
|
|
137
|
+
back.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
138
|
+
back.mousePressEvent = lambda e: self.choose(None)
|
|
139
|
+
lay.addWidget(HLine())
|
|
140
|
+
lay.addLayout(hbox(Label("Older snapshots are read-only", 12, 400, "mute"), "stretch", back, margins=(10, 10, 10, 4)))
|
|
141
|
+
panel.setLayout(lay)
|
|
142
|
+
panel.setFixedWidth(460)
|
|
143
|
+
self.setLayout(vbox(panel, margins=(24, 8, 24, 40)))
|
|
144
|
+
self.populate()
|
|
145
|
+
|
|
146
|
+
def populate(self):
|
|
147
|
+
clear_layout(self.rows)
|
|
148
|
+
last_day = ""
|
|
149
|
+
cur, head = self.app.view_index(), store.PROJECT.get("head")
|
|
150
|
+
shown = [(i, c) for i, c in enumerate(store.COMMITS) if in_filter(c, self.app.s.log_filter)]
|
|
151
|
+
for i, c in shown:
|
|
152
|
+
day, t = c[4].split(", ")
|
|
153
|
+
if day != last_day:
|
|
154
|
+
d = Label(day.upper(), 11, 600, "mute", spacing=0.4, lh=28)
|
|
155
|
+
d.setContentsMargins(10, 10, 10, 4)
|
|
156
|
+
d.setAlignment(Qt.AlignmentFlag.AlignBottom)
|
|
157
|
+
self.rows.addWidget(d)
|
|
158
|
+
last_day = day
|
|
159
|
+
on = i == cur
|
|
160
|
+
meta = Label(f"{mono_span(c[0], 'body')} · {c[2]} · {t}", 12, 400, "mute", rich=True, lh=16)
|
|
161
|
+
text = vbox(Label(c[1], 13, 600, "selfg" if on else "ink", elide=True, lh=20), meta)
|
|
162
|
+
dot = Dot("ink" if on else "surf", 10, ring="ink")
|
|
163
|
+
row = hbox(vbox(5, dot, "stretch"), 12, (text, 1), spacing=6, margins=(10, 8, 10, 8))
|
|
164
|
+
badge = home.where_badge(c)
|
|
165
|
+
if badge:
|
|
166
|
+
row.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
|
|
167
|
+
if c[0] == head:
|
|
168
|
+
row.addWidget(Pill("HEAD", "tab", "tabfg", size=11, pad=(1, 8)), 0, Qt.AlignmentFlag.AlignTop)
|
|
169
|
+
self.rows.addWidget(Box(bg="sel" if on else None, radius=10, hover=None if on else "surf2", layout=row,
|
|
170
|
+
on_click=lambda h=c[0]: self.choose(None if h == head else h)))
|
|
171
|
+
if not shown:
|
|
172
|
+
empty = Label("No commits here.", 13, 400, "mute")
|
|
173
|
+
empty.setContentsMargins(10, 10, 10, 10)
|
|
174
|
+
self.rows.addWidget(empty)
|
|
175
|
+
self.rows.invalidate()
|
|
176
|
+
self.sc.setFixedHeight(min(480, self.sc.widget().layout().sizeHint().height() + 4))
|
|
177
|
+
self.adjustSize()
|
|
178
|
+
pos = self.anchor.mapToGlobal(QPoint(self.anchor.width(), self.anchor.height()))
|
|
179
|
+
self.move(pos.x() - self.width() + 24, pos.y())
|
|
180
|
+
|
|
181
|
+
def set_filter(self, kind):
|
|
182
|
+
self.app.s.log_filter = kind
|
|
183
|
+
if self.seg is not None:
|
|
184
|
+
self.seg.select(kind)
|
|
185
|
+
self.populate()
|
|
186
|
+
|
|
187
|
+
def fetch(self):
|
|
188
|
+
self.close()
|
|
189
|
+
QTimer.singleShot(0, self.app.fetch_remote)
|
|
190
|
+
|
|
191
|
+
def choose(self, h):
|
|
192
|
+
self.close()
|
|
193
|
+
QTimer.singleShot(0, lambda: self.app.show_commit(h))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _first(rows, keep=lambda r: True):
|
|
197
|
+
return next((r[0] for r in rows if keep(r)), "")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class MainWindow(QMainWindow):
|
|
201
|
+
def __init__(self, start_tab="home", theme_name="light", frameless=False):
|
|
202
|
+
super().__init__()
|
|
203
|
+
self.frameless = frameless
|
|
204
|
+
theme.set_theme(theme_name)
|
|
205
|
+
if frameless:
|
|
206
|
+
self.setWindowFlag(Qt.WindowType.FramelessWindowHint)
|
|
207
|
+
self.resize(1920, 1080)
|
|
208
|
+
self.setMinimumHeight(760)
|
|
209
|
+
self._status_msg = None
|
|
210
|
+
self.dialog = None
|
|
211
|
+
self.toml_text = None
|
|
212
|
+
self.side_widths = {}
|
|
213
|
+
self.handles, self.page, self.center_min, self.own_centre = [], None, CENTER_MIN, None
|
|
214
|
+
self.top_min = 0
|
|
215
|
+
self._left_page = False
|
|
216
|
+
netronview.attach(self)
|
|
217
|
+
self.reset_state(start_tab)
|
|
218
|
+
self.render()
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def view_state():
|
|
222
|
+
return dict(
|
|
223
|
+
hist_filter="all", open_commit=_first(store.COMMITS) or None, smooth=0.6, split_filter="all",
|
|
224
|
+
view_mode="raw", zoom=None, pending_toml=None, pt_view="inferred", log_filter="all",
|
|
225
|
+
toml_view="fields",
|
|
226
|
+
setting_scroll=0,
|
|
227
|
+
table_page={},
|
|
228
|
+
table_open=set(),
|
|
229
|
+
table_view={},
|
|
230
|
+
run_page={},
|
|
231
|
+
run_open=set(),
|
|
232
|
+
hp_query="",
|
|
233
|
+
chart_sel={},
|
|
234
|
+
chart_size={},
|
|
235
|
+
open_dirs=dict(model=set(), hyperparameter=set(), result=set()))
|
|
236
|
+
|
|
237
|
+
def reset_state(self, tab="home"):
|
|
238
|
+
folder = next(iter(store.DATA_FOLDERS), "")
|
|
239
|
+
prefs = getattr(self, "s", None)
|
|
240
|
+
self.s = SimpleNamespace(
|
|
241
|
+
table_rows=prefs.table_rows if prefs else _saved_rows(),
|
|
242
|
+
tab=tab, folder=folder, view_commit=None, infer_source=next(iter(store.INFERENCE), None),
|
|
243
|
+
task=store.TASKS[0] if store.TASKS else None,
|
|
244
|
+
sel=dict(home=store.PROJECT.get("hot_file", ""), data=store.DATA_FOLDERS[folder]["files"][0] if folder else "",
|
|
245
|
+
model=_first(store.MODEL_FILES), hyperparameter=_first(store.RUNS), result=_first(store.RESULT_FILES),
|
|
246
|
+
setting=_first(store.RECENT_PROJECTS)),
|
|
247
|
+
**self.view_state())
|
|
248
|
+
self.task = store.DASH.get("task", "")
|
|
249
|
+
self.setWindowTitle(f"Laboard — {store.PROJECT['name']} — {store.PROJECT['toml']}" if store.PROJECT else "Laboard")
|
|
250
|
+
|
|
251
|
+
def load_project(self, path, commit=None, text=None):
|
|
252
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
253
|
+
try:
|
|
254
|
+
notes = project.load(path, commit, text)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
self._status_msg = f"Could not load {path}{' at ' + commit if commit else ''}: {e}"
|
|
257
|
+
self.render()
|
|
258
|
+
return
|
|
259
|
+
finally:
|
|
260
|
+
QApplication.restoreOverrideCursor()
|
|
261
|
+
same = store.PROJECT["toml"] == getattr(self, "_toml", None)
|
|
262
|
+
old = self.s
|
|
263
|
+
self.reset_state(self.s.tab)
|
|
264
|
+
if same:
|
|
265
|
+
for k in self.view_state().keys() - {"open_commit"}:
|
|
266
|
+
setattr(self.s, k, getattr(old, k))
|
|
267
|
+
if old.infer_source in store.INFERENCE:
|
|
268
|
+
self.s.infer_source = old.infer_source
|
|
269
|
+
if old.task in store.TASKS:
|
|
270
|
+
self.s.task = old.task
|
|
271
|
+
valid = dict(model=store.MODEL_INFO, hyperparameter=store.RUN_DETAILS, result=store.RESULT_PATHS)
|
|
272
|
+
for tab, keys in valid.items():
|
|
273
|
+
if old.sel[tab] in keys:
|
|
274
|
+
self.s.sel[tab] = old.sel[tab]
|
|
275
|
+
if old.folder in store.DATA_FOLDERS and old.sel["data"] in store.DATA_FOLDERS[old.folder]["files"]:
|
|
276
|
+
self.s.folder, self.s.sel["data"] = old.folder, old.sel["data"]
|
|
277
|
+
self._toml = store.PROJECT["toml"]
|
|
278
|
+
self.toml_text = text
|
|
279
|
+
self.s.view_commit = commit
|
|
280
|
+
head = f"Showing {commit} · read-only snapshot" if commit else f"Loaded {path}"
|
|
281
|
+
self._status_msg = " · ".join([head, *notes])
|
|
282
|
+
self.render()
|
|
283
|
+
|
|
284
|
+
def show_dialog(self, title, text, body=None, actions=None):
|
|
285
|
+
self.dialog = Dialog(self, title, text, body=body, actions=actions)
|
|
286
|
+
self.dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
287
|
+
self.dialog.open()
|
|
288
|
+
return self.dialog
|
|
289
|
+
|
|
290
|
+
def show_no_project(self):
|
|
291
|
+
self.show_dialog("Load a project file", "No project is loaded yet. Go to Setting, open Load project file "
|
|
292
|
+
"and click Browse to load a laboard.toml.", actions=[("Close", "tertiary", None)])
|
|
293
|
+
|
|
294
|
+
def show_commit(self, commit):
|
|
295
|
+
self.load_project(store.PROJECT["toml"], commit, self.toml_text)
|
|
296
|
+
|
|
297
|
+
def side_scale(self):
|
|
298
|
+
return SideHandle.DESIGN_WIDTH
|
|
299
|
+
|
|
300
|
+
def side_room(self):
|
|
301
|
+
return self.width() - 24 - 12 * sum(not h.closed for h in self.handles) - 1
|
|
302
|
+
|
|
303
|
+
def drag_side(self, handle, w, start):
|
|
304
|
+
scale, room = self.side_scale(), self.side_room()
|
|
305
|
+
w = max(0, min(w, handle.max_width(), room + 1 - self.center_min))
|
|
306
|
+
w = 0 if w < SideHandle.SNAP else w
|
|
307
|
+
excess = w + sum(start[h] for h in self.handles if h is not handle) + self.center_min - 1 - room
|
|
308
|
+
for h in self.handles:
|
|
309
|
+
if h is not handle:
|
|
310
|
+
other = start[h] - min(max(excess, 0), start[h])
|
|
311
|
+
other = 0 if other < SideHandle.SNAP else other
|
|
312
|
+
excess -= start[h] - other
|
|
313
|
+
self.side_widths[h.key] = other / scale
|
|
314
|
+
self.side_widths[handle.key] = w / scale
|
|
315
|
+
self.fit_sides()
|
|
316
|
+
|
|
317
|
+
def fit_sides(self):
|
|
318
|
+
centre = self.own_centre or max(self.center_min, self.top_min - 24)
|
|
319
|
+
free = self.width() - 24 - centre
|
|
320
|
+
fold = getattr(self.page, "fold_left", 0)
|
|
321
|
+
order = self.handles[::-1] if fold else self.handles
|
|
322
|
+
for h in order:
|
|
323
|
+
want = 0 if h.closed else min(round(h.share() * self.side_scale()), h.max_width())
|
|
324
|
+
w = max(0, min(want, free - 12))
|
|
325
|
+
least = fold if fold and h is self.handles[0] else SideHandle.SNAP
|
|
326
|
+
w = 0 if w < min(want, least) else w
|
|
327
|
+
show = not h.closed and free >= 12 and (w > 0 or want == 0)
|
|
328
|
+
h.apply(w if show else 0)
|
|
329
|
+
h.setVisible(show)
|
|
330
|
+
free -= (12 + w) if show else 0
|
|
331
|
+
if self.page is not None:
|
|
332
|
+
self.page.setMinimumWidth(max(1, min(self.center_min, free + centre)))
|
|
333
|
+
|
|
334
|
+
def show_right(self, on):
|
|
335
|
+
h = self.handles[-1]
|
|
336
|
+
if on and h.share() == 0:
|
|
337
|
+
self.side_widths[h.key] = self.side_widths.pop(h.key + ":last", h.default)
|
|
338
|
+
elif not on and h.share() > 0:
|
|
339
|
+
self.side_widths[h.key + ":last"] = h.share()
|
|
340
|
+
self.side_widths[h.key] = 0
|
|
341
|
+
h.closed = not on
|
|
342
|
+
self.fit_sides()
|
|
343
|
+
|
|
344
|
+
def resizeEvent(self, e):
|
|
345
|
+
super().resizeEvent(e)
|
|
346
|
+
self.fit_sides()
|
|
347
|
+
|
|
348
|
+
def set(self, **kw):
|
|
349
|
+
if "theme" in kw:
|
|
350
|
+
theme.set_theme(kw.pop("theme"))
|
|
351
|
+
if "table_rows" in kw:
|
|
352
|
+
QSettings("Laboard", "Laboard").setValue("table_rows", kw["table_rows"])
|
|
353
|
+
if kw.get("tab", self.s.tab) != self.s.tab:
|
|
354
|
+
vars(self.s).update(self.view_state())
|
|
355
|
+
self.side_widths.clear()
|
|
356
|
+
self._left_page = True
|
|
357
|
+
for k, v in kw.items():
|
|
358
|
+
setattr(self.s, k, v)
|
|
359
|
+
QTimer.singleShot(0, self.render)
|
|
360
|
+
|
|
361
|
+
def toggle_dir(self, tab, path):
|
|
362
|
+
dirs = self.s.open_dirs[tab]
|
|
363
|
+
self.set(open_dirs={**self.s.open_dirs, tab: dirs - {path} if path in dirs else dirs | {path}})
|
|
364
|
+
|
|
365
|
+
def pick(self, tab, name):
|
|
366
|
+
old, self.s.sel = self.s.sel, {**self.s.sel, tab: name}
|
|
367
|
+
page = PAGES.get(tab)
|
|
368
|
+
if tab == self.s.tab and hasattr(page, "select") and page.select(self, name):
|
|
369
|
+
if not self.file_list.mark(name):
|
|
370
|
+
self.file_list.populate(self.file_list.search.text())
|
|
371
|
+
return
|
|
372
|
+
self.s.sel = old
|
|
373
|
+
self.set(sel={**self.s.sel, tab: name})
|
|
374
|
+
|
|
375
|
+
def view_index(self):
|
|
376
|
+
hashes = [c[0] for c in store.COMMITS]
|
|
377
|
+
for h in (self.s.view_commit, store.PROJECT.get("head")):
|
|
378
|
+
if h in hashes:
|
|
379
|
+
return hashes.index(h)
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
def fetch_remote(self):
|
|
383
|
+
P = store.PROJECT
|
|
384
|
+
remote = P["upstream"].split("/", 1)[0]
|
|
385
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
386
|
+
try:
|
|
387
|
+
r = subprocess.run(["git", "fetch", "--quiet", remote], cwd=P["path"], capture_output=True, text=True, timeout=120)
|
|
388
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
389
|
+
r = SimpleNamespace(returncode=1, stderr=str(e))
|
|
390
|
+
finally:
|
|
391
|
+
QApplication.restoreOverrideCursor()
|
|
392
|
+
if r.returncode:
|
|
393
|
+
self.status(f"git fetch {remote} failed: {r.stderr.strip()[:160]}")
|
|
394
|
+
return
|
|
395
|
+
self.load_project(P["toml"], self.s.view_commit, self.toml_text)
|
|
396
|
+
|
|
397
|
+
def status(self, msg):
|
|
398
|
+
self._status_msg = msg
|
|
399
|
+
self.status_label.setText(msg)
|
|
400
|
+
self.status_label.setToolTip(msg)
|
|
401
|
+
|
|
402
|
+
def render(self):
|
|
403
|
+
netronview.park(self)
|
|
404
|
+
old = self.centralWidget()
|
|
405
|
+
root = QWidget()
|
|
406
|
+
root.setObjectName("root")
|
|
407
|
+
root.setStyleSheet(f"QWidget#root{{background:{T.bg};}} QToolTip{{color:{T.ink};background:{T.surf};border:1px solid {T.line};}}")
|
|
408
|
+
lay = vbox()
|
|
409
|
+
if self.frameless:
|
|
410
|
+
lay.addWidget(TitleBar(self))
|
|
411
|
+
lay.addWidget(self.top_bar())
|
|
412
|
+
body = hbox(margins=(12, 12, 12, 12))
|
|
413
|
+
side = sidebar.build(self)
|
|
414
|
+
page = PAGES[self.s.tab].build(self)
|
|
415
|
+
page, right = page if isinstance(page, tuple) else (page, None)
|
|
416
|
+
page.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
417
|
+
self.own_centre = getattr(page, "centre_min", None)
|
|
418
|
+
self.page, self.center_min = page, self.own_centre or max(CENTER_MIN, page.minimumSizeHint().width())
|
|
419
|
+
self.handles = [SideHandle(side, self, f"left:{self.s.tab}")]
|
|
420
|
+
body.addWidget(side)
|
|
421
|
+
body.addWidget(self.handles[0])
|
|
422
|
+
body.addWidget(page, 1)
|
|
423
|
+
if right is not None:
|
|
424
|
+
self.handles.append(SideHandle(right, self, self.s.tab, right=True))
|
|
425
|
+
body.addWidget(self.handles[1])
|
|
426
|
+
body.addWidget(right)
|
|
427
|
+
old_x = self.body_scroll.horizontalScrollBar().value() if old is not None and not self._left_page else 0
|
|
428
|
+
self._left_page = False
|
|
429
|
+
self.body_scroll = Scroll(wrap(body), horizontal=True)
|
|
430
|
+
if right is not None and hasattr(page, "right_on"):
|
|
431
|
+
self.show_right(page.right_on)
|
|
432
|
+
self.fit_sides()
|
|
433
|
+
self.body_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
434
|
+
QTimer.singleShot(0, lambda: self.body_scroll.horizontalScrollBar().setValue(old_x))
|
|
435
|
+
lay.addWidget(self.body_scroll, 1)
|
|
436
|
+
lay.addWidget(self.status_bar())
|
|
437
|
+
root.setLayout(lay)
|
|
438
|
+
self.setCentralWidget(root)
|
|
439
|
+
if old is not None:
|
|
440
|
+
old.deleteLater()
|
|
441
|
+
if self.dialog is not None and not sip.isdeleted(self.dialog) and self.dialog.isVisible():
|
|
442
|
+
self.dialog.raise_()
|
|
443
|
+
|
|
444
|
+
def top_bar(self):
|
|
445
|
+
bar = Box(bg="surf", radius=0)
|
|
446
|
+
bar.setFixedHeight(68)
|
|
447
|
+
brand = SplashLogo()
|
|
448
|
+
tabs = Segmented(TABS, self.s.tab, lambda k: self.set(tab=k), size=14, pad=(8, 22), outer=4, gap=4,
|
|
449
|
+
on=("tab", "tabfg"), off=("transparent", "ink"))
|
|
450
|
+
if store.COMMITS:
|
|
451
|
+
P = store.PROJECT
|
|
452
|
+
i = self.view_index()
|
|
453
|
+
latest = self.s.view_commit is None
|
|
454
|
+
c = store.COMMITS[i]
|
|
455
|
+
if latest:
|
|
456
|
+
sync = f" · ↑{P['ahead']} ↓{P['behind']} vs {P['upstream']}" if P.get("upstream") else ""
|
|
457
|
+
state = f"{P['dirty']}{sync}"
|
|
458
|
+
else:
|
|
459
|
+
state = "remote commit · read-only snapshot" if c[7] == "remote" else "read-only snapshot"
|
|
460
|
+
chip_text = (f"<b>{P['branch_name'] if latest else 'detached'}</b> <span style='color:{T.mute}'>@</span>{mono_span(c[0])} "
|
|
461
|
+
f"<span style='color:{T.body}'>· {state}</span>")
|
|
462
|
+
when = f"<b>{('Latest · ' if latest else '') + c[4]}</b> {mono_span('· ' + c[0], 'body', 12)}"
|
|
463
|
+
else:
|
|
464
|
+
chip_text = "not a git repository" if store.PROJECT else "No project loaded"
|
|
465
|
+
when = "—"
|
|
466
|
+
chip = Box(bg="surf2", radius=17, layout=hbox(Label(chip_text, 13, 500, rich=True), margins=(14, 7, 14, 7)))
|
|
467
|
+
picker_text = vbox(Label("Show project as of", 11, 400, "mute", lh=14), Label(when, 13, 600, rich=True, lh=18))
|
|
468
|
+
picker = Box(bg="surf", radius=12, border=f"1px solid {T.ink}", hover="surf2" if store.COMMITS else None,
|
|
469
|
+
layout=hbox((picker_text, 1), 12, Chevron(), margins=(16, 6, 12, 6), align=Qt.AlignmentFlag.AlignVCenter))
|
|
470
|
+
picker.setMinimumWidth(300)
|
|
471
|
+
if store.COMMITS:
|
|
472
|
+
picker.clicked.connect(lambda: CommitPicker(self, picker).show())
|
|
473
|
+
right = Collapsing(chip, picker)
|
|
474
|
+
row = hbox(tabs, 20, (right, 1), align=Qt.AlignmentFlag.AlignVCenter)
|
|
475
|
+
bar.setLayout(hbox(brand, 28, (row, 1), margins=(24, 0, 20, 0)))
|
|
476
|
+
self.top_min = bar.minimumSizeHint().width()
|
|
477
|
+
return bar
|
|
478
|
+
|
|
479
|
+
def status_bar(self):
|
|
480
|
+
bar = Box(bg="surf", radius=0)
|
|
481
|
+
bar.setFixedHeight(30)
|
|
482
|
+
bar.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
|
483
|
+
lay = hbox(spacing=0, margins=(18, 0, 18, 0))
|
|
484
|
+
if store.STATUS["watching"]:
|
|
485
|
+
lay.addWidget(Dot(theme.POSITIVE, 7))
|
|
486
|
+
lay.addSpacing(6)
|
|
487
|
+
lay.addWidget(Label(store.STATUS["watching"], 12, 400, "body"))
|
|
488
|
+
for t in store.STATUS["left"]:
|
|
489
|
+
lay.addSpacing(20)
|
|
490
|
+
lay.addWidget(Label(t, 12, 400, "body"))
|
|
491
|
+
lay.addSpacing(20)
|
|
492
|
+
self.status_label = Label(self._status_msg or "", 12, 400, "body", elide=True, align=Qt.AlignmentFlag.AlignRight)
|
|
493
|
+
self.status_label.setToolTip(self._status_msg or "")
|
|
494
|
+
lay.addWidget(self.status_label, 1)
|
|
495
|
+
for t in store.STATUS["right"]:
|
|
496
|
+
lay.addSpacing(20)
|
|
497
|
+
lay.addWidget(Label(t, 12, 400, "body"))
|
|
498
|
+
bar.setLayout(lay)
|
|
499
|
+
return bar
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def main(argv=None):
|
|
503
|
+
ap = argparse.ArgumentParser(prog="laboard", description="Laboard — ML experiment tracker")
|
|
504
|
+
ap.add_argument("--tab", choices=[t for t, _ in TABS], help="start tab (default: home, or setting without --project)")
|
|
505
|
+
ap.add_argument("--theme", default="light", choices=["light", "dark"])
|
|
506
|
+
ap.add_argument("--frameless", action="store_true", help="draw the dark custom title bar instead of the WM's")
|
|
507
|
+
ap.add_argument("--screenshot", metavar="PNG", help="render once, save a screenshot and exit")
|
|
508
|
+
ap.add_argument("--project", metavar="TOML", help="load a laboard .toml project at start")
|
|
509
|
+
args, qt_args = ap.parse_known_args(argv)
|
|
510
|
+
netronview.prepare()
|
|
511
|
+
qapp = QApplication([sys.argv[0], *qt_args])
|
|
512
|
+
qapp.setApplicationName("Laboard")
|
|
513
|
+
qapp.setDesktopFileName("laboard")
|
|
514
|
+
qapp.setWindowIcon(theme.logo_icon())
|
|
515
|
+
theme.load_fonts()
|
|
516
|
+
qapp.setFont(theme.font(14))
|
|
517
|
+
win = MainWindow(args.tab or ("home" if args.project else "setting"), args.theme, args.frameless)
|
|
518
|
+
if args.project:
|
|
519
|
+
win.load_project(args.project)
|
|
520
|
+
win.show()
|
|
521
|
+
if not args.project and not args.screenshot:
|
|
522
|
+
win.show_no_project()
|
|
523
|
+
if args.screenshot:
|
|
524
|
+
def shot():
|
|
525
|
+
win.grab().save(args.screenshot)
|
|
526
|
+
qapp.quit()
|
|
527
|
+
QTimer.singleShot(400, shot)
|
|
528
|
+
return qapp.exec()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<svg viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Icons_Splash" overflow="hidden" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpmM2MxOWE0NS02ZTg2LTRlNDYtYWM3Yy0yOTE4ZGVmNjZkMzQAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaDb7jYWFPU11/jlrvE4MlO0AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo4Nzk2OTVkZC0zOGIyLTRmNjQtYTZjZC1mYTdhMzE2OTg1YTNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoL+QpG+J9Lg04v+KZh7/gZAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggAj95YhTVyBtI4kyyKkFRNFOWZe6+QhbojeRzngovWeGkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaJ6rIqDOsSt4ngx9+pZZDvAAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBwg9yU9t7smiiniTk3pD6J8TEHP6f7ihRKACdA759rI2RuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBjKZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmYzYzE5YTQ1LTZlODYtNGU0Ni1hYzdjLTI5MThkZWY2NmQzNC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjZlOWExMmQwLTE3NzEtNGNkNi1hYmVhLTlmOWEyN2I2N2ExNnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCACP3liFNXIG0jiTLIqQVE0U5Zl7r5CFuiN5HOeCi9Z4aJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggdFnxLnq6rYpOL5E/fsOwWEnSvT6w7MOyCFXx0S4icJCiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggTIARLxTmjPjbuA1UEpgLgfsRtwJMeBWpeMautzaR9YJ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQOZGkPXOOnmm+kXRdlWQbl9rATmLRKhZ12eA18JU9OBHBjxCBEHoRkaCg5YesVA2UPKV9wy1SjaqVDOqBURNvFE=</c2pa:manifest></metadata>
|
|
2
|
+
<path d="M82.65 62.63C80.17 61.33 70.46 58.06 62.3 54.89 60.3 54.1 58.51 52.64 58.16 50.5 57.75 48.02 59.47 46.27 61.3 44.67 66.3 40.25 71.24 39.07 73.24 37.07 77.2125 33.2332 77.3225 26.9025 73.4858 22.93 69.649 18.9575 63.3183 18.8474 59.3458 22.6842 59.2624 22.7647 59.1805 22.8467 59.1 22.93 58.1 23.93 56.9 25.93 55.26 28.02 49.59 35.13 48.17 35.89 44.15 35.89L44.15 35.89C41.7964 35.83 39.7347 34.2968 39 32.06 36.71 25.64 34 17.76 32.88 15.67 30.6874 11.7322 25.7178 10.3174 21.78 12.51 17.8422 14.7026 16.4274 19.6722 18.62 23.61 20.776 26.7864 23.2341 29.7469 25.96 32.45 28.0047 34.5079 28.9123 37.4364 28.39 40.29 27.5252 44.5751 23.5738 47.5224 19.22 47.13 18.22 47.05 17.46 47 17.02 47 13.6179 47 10.86 49.7579 10.86 53.16 10.86 56.5621 13.6179 59.32 17.02 59.32 17.96 59.32 20.4 59.11 23.44 58.73 25.9367 58.3929 28.3642 59.7118 29.44 61.99L29.44 61.99C30.44 64.12 29.44 66.22 28.44 68.36 27.7815 69.7507 27.0159 71.088 26.15 72.36 24.951 74.6747 25.8555 77.5231 28.1702 78.722 30.3388 79.8453 33.0062 79.1288 34.32 77.07 35.08 75.75 35.79 71.68 38.59 66.07 38.9423 65.2001 39.6002 64.4889 40.44 64.07 42.154 63.3446 44.1316 64.1461 44.8569 65.8602 44.9134 65.9936 44.9612 66.1305 45 66.27 45.32 67.35 45.62 68.27 45.82 68.74 46.8906 70.866 49.482 71.7216 51.608 70.6509 53.4484 69.7242 54.3753 67.6243 53.82 65.64 53.621 65.1922 53.3869 64.7609 53.12 64.35 52.4471 63.3355 52.6645 61.9744 53.62 61.22 54.5652 60.5916 55.8232 60.7216 56.62 61.53 63.78 67.69 72.35 75.65 75 77.06 78.9264 79.2721 83.9026 77.8825 86.1147 73.9561 88.3269 70.0298 86.9372 65.0536 83.0109 62.8414 82.8792 62.7672 82.7456 62.6967 82.61 62.63Z" fill="#9fe870"></path><circle cx="47.84" cy="20" r="4" fill="#9fe870"></circle><circle cx="14.63" cy="35.21" r="5.21" fill="#9fe870"></circle><circle cx="16.84" cy="68" r="2" fill="#9fe870"></circle><circle cx="43.34" cy="80.5" r="1.5" fill="#9fe870"></circle><circle cx="60.34" cy="82.5" r="3.5" fill="#9fe870"></circle><circle cx="75.84" cy="51" r="2" fill="#9fe870"></circle></svg>
|