Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c28991678b |
@@ -0,0 +1,45 @@
|
||||
# PROTOTYPE — graph encoding & hourly drill-down (throwaway)
|
||||
|
||||
Answers wayfinder ticket **Choose daily graph encoding and hourly drill-down**
|
||||
on map **Fenris TUI polish and hourly history**.
|
||||
Not production code. Do not merge onto main.
|
||||
|
||||
## Question
|
||||
|
||||
Which graph encoding best communicates hourly writes and a day's usage
|
||||
without misleading: daily bars vs one candle per day vs rolling hourly strip,
|
||||
with range -> selected day -> hourly detail -> back navigation.
|
||||
|
||||
## Run
|
||||
|
||||
./run # or: python3 tui_prototype.py
|
||||
|
||||
## Variants (switch with [ and ])
|
||||
|
||||
- **A — Daily bars**: one bar per local display day (bytes written), drill
|
||||
into 24 hourly bars with Enter / click.
|
||||
- **B — Daily candles**: one candle per day derived from hourly values
|
||||
(open = first evidenced hour, close = last, high/low = max/min hourly).
|
||||
Same drill-down.
|
||||
- **C — Rolling hourly strip**: continuous last-72-hours columns with day
|
||||
separators; click an hour for its readout (no day level).
|
||||
|
||||
## Data states encoded (synthetic)
|
||||
|
||||
measured zero (0 B) · gap / no evidence · unallocated usage (measured,
|
||||
attribution unknown) · partial day ("so far") · deliberate-disable pause band ·
|
||||
day before monitoring began.
|
||||
|
||||
Gaps never render as zero. Unallocated is a separate visual segment, never
|
||||
spread into hours.
|
||||
|
||||
## Framework facts verified
|
||||
|
||||
Textual 8.2.8 (installed): no BarChart widget (roadmap-only), Sparkline is
|
||||
non-interactive. Bars/candles here are a custom Static renderable; mouse
|
||||
clicks map via widget-local x. No new dependencies.
|
||||
|
||||
## Screenshots
|
||||
|
||||
screenshots/ holds SVG exports at 80x24 and 140x40 per variant.
|
||||
Regenerate with: python3 capture.py
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
# PROTOTYPE helper — regenerate screenshots/*.svg via headless Textual run.
|
||||
import asyncio
|
||||
from tui_prototype import GraphPrototype
|
||||
|
||||
|
||||
async def shot(app, size, presses, path):
|
||||
async with app.run_test(size=size) as pilot:
|
||||
for p in presses:
|
||||
await pilot.press(p)
|
||||
svg = app.export_screenshot()
|
||||
with open(path, "w") as fh:
|
||||
fh.write(svg)
|
||||
print("wrote", path)
|
||||
|
||||
|
||||
async def main():
|
||||
for w, h in ((80, 24), (140, 40)):
|
||||
for v in "ABC":
|
||||
presses = ["]"] * "ABC".index(v)
|
||||
await shot(GraphPrototype(), (w, h), presses,
|
||||
"screenshots/v%s_%dx%d_range.svg" % (v, w, h))
|
||||
# drill-down detail (variant A) and a mid-range selection (variant B)
|
||||
await shot(GraphPrototype(), (80, 24), ["left", "enter"],
|
||||
"screenshots/vA_80x24_hourly-detail.svg")
|
||||
await shot(GraphPrototype(), (140, 40), ["]", "left", "left"],
|
||||
"screenshots/vB_140x40_selected-gap-day.svg")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# PROTOTYPE launcher
|
||||
exec python3 "$(dirname "$0")/tui_prototype.py" "$@"
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,488 @@
|
||||
#!/usr/bin/env python3
|
||||
# PROTOTYPE — throwaway. Wayfinder ticket "Choose daily graph encoding and
|
||||
# hourly drill-down" on map "Fenris TUI polish and hourly history".
|
||||
# Synthetic data only. Not production code; no tests by design.
|
||||
#
|
||||
# Variants (switch with [ / ]):
|
||||
# A daily bars (day -> Enter -> hourly bars -> Esc back)
|
||||
# B daily candles (OHLC derived from hourly values, same drill-down)
|
||||
# C rolling 72-hour strip with day separators (no day level)
|
||||
#
|
||||
# Encoding contract under test (accepted hourly-history decision):
|
||||
# measured zero = 0 B, distinct from gap (no evidence), distinct from
|
||||
# unallocated usage (measured total, hour attribution unknown).
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hour:
|
||||
gb: float = 0.0 # measured bytes written; 0.0 == measured zero
|
||||
coverage: float = 1.0 # share of hour classified known
|
||||
|
||||
|
||||
@dataclass
|
||||
class Day:
|
||||
hours: list = field(default_factory=list) # list[Hour | None]; None = gap
|
||||
unallocated: float = 0.0 # GB measured, attribution unknown
|
||||
elapsed_h: float = 24.0 # elapsed span (partial < 24)
|
||||
note: str = ""
|
||||
pause: str = "" # deliberate-disable band
|
||||
|
||||
@property
|
||||
def evidenced(self):
|
||||
return [h for h in self.hours if h is not None]
|
||||
|
||||
@property
|
||||
def allocated_gb(self):
|
||||
return sum(h.gb for h in self.evidenced)
|
||||
|
||||
@property
|
||||
def gap_hours(self):
|
||||
return sum(1 for h in self.hours if h is None)
|
||||
|
||||
|
||||
def _profile(mult=1.0, zero=False):
|
||||
shape = [0.3, 0.2, 0.1, 0.1, 0.2, 0.5, 1.4, 2.6, 3.1, 2.2,
|
||||
1.8, 2.0, 2.4, 3.0, 3.4, 2.8, 2.1, 1.9, 2.3, 1.6,
|
||||
1.1, 0.8, 0.5, 0.4]
|
||||
return [Hour(0.0 if zero else round(v * mult, 2), 1.0) for v in shape]
|
||||
|
||||
|
||||
def synth_days(now):
|
||||
"""14 local display days ending today (partial). Every ticket state present."""
|
||||
days = []
|
||||
days.append(Day([None] * 24, note="no evidence — before monitoring began"))
|
||||
days.append(Day(_profile(1.0)))
|
||||
g = _profile(0.9)
|
||||
for i in range(10, 16):
|
||||
g[i] = None
|
||||
days.append(Day(g, note="gap 10:00–16:00 (no evidence, not zero)"))
|
||||
days.append(Day(_profile(0.0), note="measured zero day"))
|
||||
days.append(Day(_profile(1.2), unallocated=8.5,
|
||||
note="8.5 GB unallocated (interval attribution unknown)"))
|
||||
days.append(Day(_profile(4.0), note="heavy write day"))
|
||||
days.append(Day(_profile(0.7),
|
||||
pause="paused 09:00–11:00 (deliberate disable — excluded)"))
|
||||
days.append(Day(_profile(1.1)))
|
||||
days.append(Day(_profile(0.9)))
|
||||
m = _profile(1.0)
|
||||
m[3] = Hour(0.0, 1.0)
|
||||
m[17] = None
|
||||
days.append(Day(m, note="zero hour 03:00, gap hour 17:00"))
|
||||
days.append(Day(_profile(1.3), unallocated=1.9))
|
||||
days.append(Day(_profile(1.0)))
|
||||
days.append(Day(_profile(0.8)))
|
||||
days.append(Day(_profile(1.5)[:14], elapsed_h=14,
|
||||
note="today — 14 h elapsed, so far"))
|
||||
assert len(days) == 14
|
||||
return days
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering (Rich markup into Static)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AMBER = "#e0a458" # default-preset graph colour (approved input)
|
||||
UNALLOC = "#8a7f70" # measured, attribution unknown
|
||||
GAP = "#5f6b73"
|
||||
ZERO = "#9bb0bf"
|
||||
SELECT = "#f2d5a0"
|
||||
|
||||
FILL = "█"
|
||||
EIGHTHS = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
|
||||
def _fmt_gb(v):
|
||||
if v >= 100:
|
||||
return "%d GB" % v
|
||||
return "%s GB" % ("%.1f" % v if v < 10 else "%.0f" % v)
|
||||
|
||||
|
||||
def render_days_bars(days, height, sel):
|
||||
"""Variant A: one bar per day. Unallocated stacked on top, visually separate."""
|
||||
n = len(days)
|
||||
mx = max((d.allocated_gb + d.unallocated) for d in days) or 1.0
|
||||
grid = [[" "] * n for _ in range(height)]
|
||||
style = [[None] * n for _ in range(height)]
|
||||
for i, d in enumerate(days):
|
||||
if not d.evidenced and not d.unallocated:
|
||||
for r in range(height):
|
||||
grid[r][i], style[r][i] = "·", GAP
|
||||
continue
|
||||
rows = []
|
||||
ua_cells = d.unallocated / mx * height
|
||||
for _ in range(int(ua_cells + 1e-9)):
|
||||
rows.append(("▒", UNALLOC))
|
||||
if ua_cells - int(ua_cells) > 0.12:
|
||||
rows.append((EIGHTHS[int((ua_cells % 1) * 8)], UNALLOC))
|
||||
cells = (d.allocated_gb + d.unallocated) / mx * height
|
||||
for _ in range(int(cells + 1e-9)):
|
||||
rows.append((FILL, AMBER))
|
||||
if cells - int(cells) > 0.12:
|
||||
rows.append((EIGHTHS[int((cells % 1) * 8)], AMBER))
|
||||
if not rows:
|
||||
rows = [("·", ZERO)]
|
||||
rr = height - 1
|
||||
for ch, st in rows:
|
||||
if rr < 0:
|
||||
break
|
||||
grid[rr][i], style[rr][i] = ch, st
|
||||
rr -= 1
|
||||
if d.elapsed_h < 24:
|
||||
rr = height - len(rows)
|
||||
if 0 <= rr < height:
|
||||
grid[rr][i], style[rr][i] = "┄", SELECT
|
||||
lines = []
|
||||
for r in range(height):
|
||||
parts = []
|
||||
for i in range(n):
|
||||
ch, st = grid[r][i], style[r][i]
|
||||
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
|
||||
parts.append(" ")
|
||||
lines.append("".join(parts).rstrip())
|
||||
marks = [" "] * (2 * n)
|
||||
marks[2 * sel] = "▼"
|
||||
lines.insert(0, "".join(marks).rstrip())
|
||||
return lines
|
||||
|
||||
|
||||
def render_days_candles(days, height, sel):
|
||||
"""Variant B: one candle per day. open = first evidenced hour, close = last,
|
||||
high/low = max/min hourly GB. Body solid when close >= open, hollow otherwise;
|
||||
partial day renders dashed. 3-wide slot + 1 space."""
|
||||
n = len(days)
|
||||
stats = []
|
||||
for d in days:
|
||||
ev = [h.gb for h in d.evidenced]
|
||||
stats.append(None if not ev else
|
||||
dict(open=ev[0], close=ev[-1], hi=max(ev), lo=min(ev)))
|
||||
mx = max((s["hi"] for s in stats if s), default=1.0) or 1.0
|
||||
|
||||
def row(v):
|
||||
return height - 1 - min(int(v / mx * (height - 1)), height - 1)
|
||||
|
||||
cols = [] # per day: list[height] of 3-char cell strings + styles
|
||||
for i, s in enumerate(stats):
|
||||
d = days[i]
|
||||
if s is None:
|
||||
cols.append([("╎╎╎", GAP)] * height)
|
||||
continue
|
||||
top, bot = row(s["hi"]), row(s["lo"])
|
||||
body_hi, body_lo = row(max(s["open"], s["close"])), row(min(s["open"], s["close"]))
|
||||
solid = s["close"] >= s["open"]
|
||||
partial = d.elapsed_h < 24
|
||||
body_fill = "▒" if partial else (FILL if solid else "░")
|
||||
body_st = AMBER if (solid or partial) else GAP
|
||||
col = []
|
||||
for r in range(height):
|
||||
if body_lo <= r <= body_hi:
|
||||
col.append((body_fill * 3, body_st))
|
||||
elif top <= r <= bot:
|
||||
col.append((" │ ", AMBER if solid else GAP))
|
||||
else:
|
||||
col.append((" ", None))
|
||||
cols.append(col)
|
||||
lines = [(" " * (4 * sel)) + "▼"]
|
||||
for r in range(height):
|
||||
parts = []
|
||||
for i in range(n):
|
||||
cells, st = cols[i][r]
|
||||
parts.append(cells if st is None else "[%s]%s[/]" % (st, cells))
|
||||
parts.append(" ")
|
||||
lines.append("".join(parts).rstrip())
|
||||
return lines
|
||||
|
||||
|
||||
def render_hours_bars(day, height, sel):
|
||||
"""Day detail: one bar per hour. Gaps never zero."""
|
||||
n = len(day.hours)
|
||||
mx = max([h.gb for h in day.evidenced] + [day.unallocated, 1e-9])
|
||||
grid = [[" "] * n for _ in range(height)]
|
||||
style = [[None] * n for _ in range(height)]
|
||||
for i, h in enumerate(day.hours):
|
||||
if h is None:
|
||||
grid[height - 1][i], style[height - 1][i] = "░", GAP
|
||||
continue
|
||||
if h.gb == 0:
|
||||
grid[height - 1][i], style[height - 1][i] = "·", ZERO
|
||||
continue
|
||||
cells = h.gb / mx * height
|
||||
rr = height - 1
|
||||
for _ in range(int(cells + 1e-9)):
|
||||
if rr < 0:
|
||||
break
|
||||
grid[rr][i], style[rr][i] = FILL, AMBER
|
||||
rr -= 1
|
||||
if cells % 1 > 0.12 and rr >= 0:
|
||||
grid[rr][i], style[rr][i] = EIGHTHS[int((cells % 1) * 8)], AMBER
|
||||
lines = []
|
||||
for r in range(height):
|
||||
parts = []
|
||||
for i in range(n):
|
||||
ch, st = grid[r][i], style[r][i]
|
||||
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
|
||||
lines.append("".join(parts).rstrip())
|
||||
marks = [" "] * n
|
||||
marks[sel % n] = "▼"
|
||||
lines.insert(0, "".join(marks).rstrip())
|
||||
return lines
|
||||
|
||||
|
||||
def render_strip(days, height, sel):
|
||||
"""Variant C: rolling hourly strip, last 72 h, day separators."""
|
||||
flat = [] # (day_idx, hour_idx, Hour|None|'future')
|
||||
for di, d in enumerate(days[-4:]):
|
||||
real = len(days) - 4 + di
|
||||
for hi in range(24):
|
||||
h = d.hours[hi] if hi < len(d.hours) else "future"
|
||||
flat.append((real, hi, h))
|
||||
flat = flat[-72:]
|
||||
mx = max([f[2].gb for f in flat if f[2] not in (None, "future")] + [1e-9])
|
||||
cols = []
|
||||
for _, _, h in flat:
|
||||
col = [(" ", None)] * height
|
||||
if h == "future":
|
||||
col[height - 1] = ("·", None)
|
||||
elif h is None:
|
||||
col[height - 1] = ("░", GAP)
|
||||
elif h.gb == 0:
|
||||
col[height - 1] = ("·", ZERO)
|
||||
else:
|
||||
cells = h.gb / mx * height
|
||||
rr = height - 1
|
||||
for _ in range(int(cells + 1e-9)):
|
||||
if rr < 0:
|
||||
break
|
||||
col[rr] = (FILL, AMBER)
|
||||
rr -= 1
|
||||
if cells % 1 > 0.12 and rr >= 0:
|
||||
col[rr] = (EIGHTHS[int((cells % 1) * 8)], AMBER)
|
||||
cols.append(col)
|
||||
lines = []
|
||||
for r in range(height):
|
||||
parts = []
|
||||
prev = None
|
||||
for i, f in enumerate(flat):
|
||||
if prev is not None and f[0] != prev:
|
||||
parts.append("[%s]│[/]" % SELECT)
|
||||
prev = f[0]
|
||||
ch, st = cols[i][r]
|
||||
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
|
||||
lines.append("".join(parts).rstrip())
|
||||
marks = []
|
||||
prev = None
|
||||
for i, f in enumerate(flat):
|
||||
if prev is not None and f[0] != prev:
|
||||
marks.append(" ")
|
||||
prev = f[0]
|
||||
marks.append("▼" if i == sel else " ")
|
||||
lines.insert(0, "".join(marks).rstrip())
|
||||
return lines, flat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VARIANT_NAMES = {"A": "Daily bars", "B": "Daily candles", "C": "Rolling 72 h strip"}
|
||||
GH = 10 # graph rows
|
||||
|
||||
|
||||
class GraphPrototype(App):
|
||||
TITLE = "PROTOTYPE — usage-history graph encoding"
|
||||
CSS = """
|
||||
#col { height: 100%; }
|
||||
#hdr { height: 2; color: $text-muted; }
|
||||
#graph { height: auto; padding: 0 1; }
|
||||
#readout { height: auto; padding: 0 1; }
|
||||
#legend { height: 3; padding: 0 1; color: $text-muted; }
|
||||
#footer { dock: bottom; height: 2; background: $panel; padding: 0 1; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("[", "prev_variant", "variant ←"),
|
||||
Binding("]", "next_variant", "variant →"),
|
||||
Binding("left", "left", "←"),
|
||||
Binding("right", "right", "→"),
|
||||
Binding("enter", "drill", "open"),
|
||||
Binding("escape,backspace", "back", "back"),
|
||||
Binding("q", "quit", "quit"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.now = datetime.now().astimezone()
|
||||
self.days = synth_days(self.now)
|
||||
self.variant = "A"
|
||||
self.view = "days" # days | hours (A/B); strip (C)
|
||||
self.sel_day = len(self.days) - 1
|
||||
self.sel_hour = 13
|
||||
self.sel_strip = 71
|
||||
|
||||
def day_label(self, i):
|
||||
d = (self.now - timedelta(days=len(self.days) - 1 - i)).date()
|
||||
return d, d.strftime("%a %d %b")
|
||||
|
||||
def tz_label(self):
|
||||
off = self.now.strftime("%z")
|
||||
return "Local · UTC%s · %s" % (off[:3] + ":" + off[3:], time.tzname[0])
|
||||
|
||||
def day_readout(self, d, i):
|
||||
_, lab = self.day_label(i)
|
||||
if not d.evidenced and not d.unallocated:
|
||||
return "%s — no evidence (not zero): %s" % (lab, d.note or "")
|
||||
bits = ["%s — %s written" % (lab, _fmt_gb(d.allocated_gb))]
|
||||
span = 24 if d.elapsed_h == 24 else len(d.hours)
|
||||
bits.append("%d/%d h evidenced" % (span - d.gap_hours, span))
|
||||
if d.unallocated:
|
||||
bits.append("+ %s unallocated" % _fmt_gb(d.unallocated))
|
||||
if d.elapsed_h < 24:
|
||||
bits.append("partial · %d h elapsed · so far" % int(d.elapsed_h))
|
||||
cov = sum(h.coverage for h in d.evidenced) / max(len(d.hours), 1)
|
||||
bits.append("coverage %.0f%%" % (100 * cov))
|
||||
txt = " · ".join(bits)
|
||||
if d.note:
|
||||
txt += " — %s" % d.note
|
||||
if d.pause:
|
||||
txt += " — %s" % d.pause
|
||||
return txt
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="col"):
|
||||
yield Static("", id="hdr")
|
||||
yield Static("", id="graph")
|
||||
yield Static("", id="readout")
|
||||
yield Static("", id="legend")
|
||||
yield Static("", id="footer")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.refresh_all()
|
||||
|
||||
def refresh_all(self):
|
||||
self.query_one("#hdr", Static).update(
|
||||
"🐺 Fenris · usage history · %s · writes per hour" % self.tz_label())
|
||||
g = self.query_one("#graph", Static)
|
||||
ro = self.query_one("#readout", Static)
|
||||
if self.view == "hours":
|
||||
d = self.days[self.sel_day]
|
||||
lines = render_hours_bars(d, GH, self.sel_hour)
|
||||
lines.append("".join(("%02d" % i) if i % 3 == 0 else " " for i in range(len(d.hours))))
|
||||
lines.append(" midnight → 23:00 local")
|
||||
g.update("\n".join(lines))
|
||||
h = d.hours[self.sel_hour]
|
||||
if h is None:
|
||||
dtxt = "hour %02d:00 — no evidence (not zero)" % self.sel_hour
|
||||
elif h.gb == 0:
|
||||
dtxt = "hour %02d:00 — 0 B measured" % self.sel_hour
|
||||
else:
|
||||
dtxt = "hour %02d:00 — %s written · coverage %.0f%%" % (
|
||||
self.sel_hour, _fmt_gb(h.gb), 100 * h.coverage)
|
||||
_, lab = self.day_label(self.sel_day)
|
||||
extra = ""
|
||||
if d.unallocated:
|
||||
extra = " · %s unallocated (shown separately, never spread into hours)" % _fmt_gb(d.unallocated)
|
||||
ro.update("%s ▸ %s%s" % (lab, dtxt, extra))
|
||||
elif self.variant in ("A", "B"):
|
||||
lines = (render_days_bars(self.days, GH, self.sel_day) if self.variant == "A"
|
||||
else render_days_candles(self.days, GH, self.sel_day))
|
||||
_, last = self.day_label(len(self.days) - 1)
|
||||
lines.append(" " + " ".join(
|
||||
self.day_label(i)[1].split()[1] for i in range(len(self.days))))
|
||||
g.update("\n".join(lines))
|
||||
ro.update(self.day_readout(self.days[self.sel_day], self.sel_day))
|
||||
else:
|
||||
lines, flat = render_strip(self.days, GH, self.sel_strip)
|
||||
g.update("\n".join(lines))
|
||||
txt = ""
|
||||
if 0 <= self.sel_strip < len(flat):
|
||||
di, hi, h = flat[self.sel_strip]
|
||||
_, lab = self.day_label(di)
|
||||
if h == "future":
|
||||
txt = "future — not counted"
|
||||
elif h is None:
|
||||
txt = "no evidence (not zero)"
|
||||
elif h.gb == 0:
|
||||
txt = "0 B measured"
|
||||
else:
|
||||
txt = "%s written · coverage %.0f%%" % (_fmt_gb(h.gb), 100 * h.coverage)
|
||||
ro.update("%s %02d:00 — %s" % (lab, hi, txt))
|
||||
self.query_one("#legend", Static).update(
|
||||
"legend: [%(a)s]█ allocated[/] [%(u)s]▒ unallocated (attribution unknown)[/] "
|
||||
"[%(g)s]░ gap · no evidence — never zero[/] [%(z)s]· measured 0 B[/] "
|
||||
"[%(s)s]┄ partial day (so far)[/]" % dict(a=AMBER, u=UNALLOC, g=GAP, z=ZERO, s=SELECT))
|
||||
v = "%s: %s" % (self.variant, VARIANT_NAMES[self.variant])
|
||||
mode = "hourly strip" if self.variant == "C" else (
|
||||
"day detail" if self.view == "hours" else "range view")
|
||||
self.query_one("#footer", Static).update(
|
||||
"PROTOTYPE ▸ %s ▸ %s [ / ] variant · ←/→ select · Enter open · Esc back · click" % (v, mode))
|
||||
|
||||
def action_prev_variant(self):
|
||||
order = "ABC"
|
||||
self.variant = order[(order.index(self.variant) - 1) % 3]
|
||||
self.view = "strip" if self.variant == "C" else "days"
|
||||
self.refresh_all()
|
||||
|
||||
def action_next_variant(self):
|
||||
order = "ABC"
|
||||
self.variant = order[(order.index(self.variant) + 1) % 3]
|
||||
self.view = "strip" if self.variant == "C" else "days"
|
||||
self.refresh_all()
|
||||
|
||||
def action_left(self):
|
||||
if self.view == "hours":
|
||||
self.sel_hour = max(0, self.sel_hour - 1)
|
||||
elif self.variant == "C":
|
||||
self.sel_strip = max(0, self.sel_strip - 1)
|
||||
else:
|
||||
self.sel_day = max(0, self.sel_day - 1)
|
||||
self.refresh_all()
|
||||
|
||||
def action_right(self):
|
||||
if self.view == "hours":
|
||||
self.sel_hour = min(23, self.sel_hour + 1)
|
||||
elif self.variant == "C":
|
||||
self.sel_strip = min(71, self.sel_strip + 1)
|
||||
else:
|
||||
self.sel_day = min(len(self.days) - 1, self.sel_day + 1)
|
||||
self.refresh_all()
|
||||
|
||||
def action_drill(self):
|
||||
if self.variant in ("A", "B") and self.view == "days":
|
||||
self.view = "hours"
|
||||
self.sel_hour = 13
|
||||
self.refresh_all()
|
||||
|
||||
def action_back(self):
|
||||
if self.view == "hours":
|
||||
self.view = "days"
|
||||
self.refresh_all()
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
w = event.widget
|
||||
if w is None or w.id != "graph":
|
||||
return
|
||||
x = event.x
|
||||
if self.view == "hours":
|
||||
self.sel_hour = max(0, min(23, x))
|
||||
elif self.variant == "C":
|
||||
self.sel_strip = max(0, min(71, x))
|
||||
else:
|
||||
self.sel_day = max(0, min(len(self.days) - 1, x // 2))
|
||||
self.refresh_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
GraphPrototype().run()
|
||||