Skip to content

Commit 70d37da

Browse files
committed
feat: add colorblind-accessible mode for progress bars
Add alternative visual encoding using distinct characters (=, X, ·, ?) so progress bar status can be distinguished without color perception. - New --colorblind CLI flag for watch command - 'a' key toggles accessible mode at runtime - Always-on legend in accessible mode shows character meanings - AccessibilityConfig frozen dataclass for clean configuration Closes #46
1 parent 684359e commit 70d37da

File tree

6 files changed

+341
-15
lines changed

6 files changed

+341
-15
lines changed

snakesee/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def watch(
6363
weighting_strategy: Literal["index", "time"] = "index",
6464
half_life_logs: int = 10,
6565
half_life_days: float = 7.0,
66+
colorblind: bool = False,
6667
) -> None:
6768
"""
6869
Watch a Snakemake workflow in real-time with a TUI dashboard.
@@ -88,6 +89,8 @@ def watch(
8889
half_life_days: Half-life in days for time-based weighting.
8990
After this many days, a run's weight is halved. Default: 7.0.
9091
Only used when weighting_strategy="time".
92+
colorblind: Use colorblind-accessible mode with distinct characters in
93+
progress bars. Can also be toggled with 'a' key in TUI.
9194
"""
9295
try:
9396
workflow_dir = _validate_workflow_dir(workflow_dir)
@@ -115,6 +118,9 @@ def watch(
115118
profile_path = profile or find_profile(workflow_dir)
116119

117120
from snakesee.tui import WorkflowMonitorTUI
121+
from snakesee.tui.accessibility import ACCESSIBLE_CONFIG
122+
123+
accessibility_config = ACCESSIBLE_CONFIG if colorblind else None
118124

119125
tui = WorkflowMonitorTUI(
120126
workflow_dir=workflow_dir,
@@ -125,6 +131,7 @@ def watch(
125131
weighting_strategy=weighting_strategy,
126132
half_life_logs=half_life_logs,
127133
half_life_days=half_life_days,
134+
accessibility_config=accessibility_config,
128135
)
129136
tui.run()
130137

snakesee/tui/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"""
2929

3030
# Re-export public API for backward compatibility
31+
from snakesee.tui.accessibility import ACCESSIBLE_CONFIG
32+
from snakesee.tui.accessibility import DEFAULT_CONFIG
33+
from snakesee.tui.accessibility import AccessibilityConfig
3134
from snakesee.tui.monitor import DEFAULT_REFRESH_RATE
3235
from snakesee.tui.monitor import FG_BLUE
3336
from snakesee.tui.monitor import FG_GREEN
@@ -37,6 +40,9 @@
3740
from snakesee.tui.monitor import WorkflowMonitorTUI
3841

3942
__all__ = [
43+
"ACCESSIBLE_CONFIG",
44+
"AccessibilityConfig",
45+
"DEFAULT_CONFIG",
4046
"DEFAULT_REFRESH_RATE",
4147
"FG_BLUE",
4248
"FG_GREEN",

snakesee/tui/accessibility.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Accessibility configuration for colorblind-friendly rendering.
2+
3+
Provides alternative visual encodings so that progress bar status
4+
can be distinguished without relying on color perception alone.
5+
"""
6+
7+
from dataclasses import dataclass
8+
9+
10+
@dataclass(frozen=True, slots=True)
11+
class BarStyle:
12+
"""Character and label for a single progress bar segment.
13+
14+
Attributes:
15+
char: The character used to fill the segment.
16+
label: Human-readable label for the legend.
17+
"""
18+
19+
char: str
20+
label: str
21+
22+
23+
@dataclass(frozen=True, slots=True)
24+
class AccessibilityConfig:
25+
"""Visual encoding configuration for progress bar rendering.
26+
27+
Controls which characters are used for each segment of the progress bar
28+
and whether the legend is always displayed.
29+
30+
Attributes:
31+
succeeded: Style for completed/succeeded jobs.
32+
failed: Style for failed jobs.
33+
remaining: Style for remaining/pending jobs.
34+
incomplete: Style for incomplete jobs (workflow interrupted).
35+
show_legend: If True, always show the legend (not just on failure).
36+
"""
37+
38+
succeeded: BarStyle
39+
failed: BarStyle
40+
remaining: BarStyle
41+
incomplete: BarStyle
42+
show_legend: bool
43+
44+
45+
DEFAULT_CONFIG = AccessibilityConfig(
46+
succeeded=BarStyle(char="\u2588", label="succeeded"),
47+
failed=BarStyle(char="\u2588", label="failed"),
48+
remaining=BarStyle(char="\u2591", label="remaining"),
49+
incomplete=BarStyle(char="\u2591", label="incomplete"),
50+
show_legend=False,
51+
)
52+
53+
ACCESSIBLE_CONFIG = AccessibilityConfig(
54+
succeeded=BarStyle(char="=", label="succeeded"),
55+
failed=BarStyle(char="X", label="failed"),
56+
remaining=BarStyle(char="\u00b7", label="remaining"),
57+
incomplete=BarStyle(char="?", label="incomplete"),
58+
show_legend=True,
59+
)

snakesee/tui/monitor.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
from snakesee.state.clock import get_clock
5454
from snakesee.state.paths import WorkflowPaths
5555
from snakesee.state.workflow_state import WorkflowState
56+
from snakesee.tui.accessibility import ACCESSIBLE_CONFIG
57+
from snakesee.tui.accessibility import DEFAULT_CONFIG
58+
from snakesee.tui.accessibility import AccessibilityConfig
5659
from snakesee.validation import EventAccumulator
5760
from snakesee.validation import ValidationLogger
5861
from snakesee.validation import compare_states
@@ -145,6 +148,7 @@ class WorkflowMonitorTUI:
145148
p: Pause/resume auto-refresh
146149
e: Toggle time estimation
147150
w: Toggle wildcard conditioning (estimate per sample/batch)
151+
a: Toggle colorblind-accessible mode
148152
r: Force refresh
149153
Ctrl+r: Hard refresh (reload historical data)
150154
@@ -192,6 +196,7 @@ def __init__(
192196
weighting_strategy: WeightingStrategy = "index",
193197
half_life_logs: int = 10,
194198
half_life_days: float = 7.0,
199+
accessibility_config: AccessibilityConfig | None = None,
195200
) -> None:
196201
"""
197202
Initialize the TUI.
@@ -205,6 +210,8 @@ def __init__(
205210
weighting_strategy: Strategy for weighting historical data ("index" or "time").
206211
half_life_logs: Half-life in run count for index-based weighting.
207212
half_life_days: Half-life in days for time-based weighting.
213+
accessibility_config: Visual encoding config for colorblind accessibility.
214+
Defaults to DEFAULT_CONFIG (standard block characters).
208215
"""
209216
self.workflow_dir = workflow_dir
210217
self.refresh_rate = refresh_rate
@@ -213,6 +220,7 @@ def __init__(
213220
self.weighting_strategy = weighting_strategy
214221
self.half_life_logs = half_life_logs
215222
self.half_life_days = half_life_days
223+
self._accessibility_config = accessibility_config or DEFAULT_CONFIG
216224
self.console = Console()
217225
self._running = True
218226
self._estimator: TimeEstimator | None = None
@@ -1019,7 +1027,7 @@ def _handle_key(self, key: str) -> bool:
10191027
return False
10201028

10211029
def _handle_toggle_key(self, key: str) -> bool:
1022-
"""Handle toggle keys (?, p, e, w, r, Ctrl+r). Returns True if key was handled."""
1030+
"""Handle toggle keys (?, p, e, w, a, r, Ctrl+r). Returns True if handled."""
10231031
if key == "?":
10241032
self._show_help = True
10251033
self._force_refresh = True
@@ -1042,6 +1050,14 @@ def _handle_toggle_key(self, key: str) -> bool:
10421050
if key.lower() == "r":
10431051
self._force_refresh = True
10441052
return True
1053+
if key.lower() == "a":
1054+
# Toggle colorblind-accessible mode
1055+
if self._accessibility_config == DEFAULT_CONFIG:
1056+
self._accessibility_config = ACCESSIBLE_CONFIG
1057+
else:
1058+
self._accessibility_config = DEFAULT_CONFIG
1059+
self._force_refresh = True
1060+
return True
10451061
if key == "\x12": # Ctrl+r - hard refresh
10461062
self._init_estimator()
10471063
self._force_refresh = True
@@ -1559,6 +1575,7 @@ def _make_help_panel(self) -> Panel:
15591575
help_text.add_row("p", "Pause/resume auto-refresh")
15601576
help_text.add_row("e", "Toggle time estimation")
15611577
help_text.add_row("w", "Toggle wildcard conditioning")
1578+
help_text.add_row("a", "Toggle colorblind-accessible mode")
15621579
help_text.add_row("r", "Force refresh")
15631580
help_text.add_row("Ctrl+r", "Hard refresh (reload historical data)")
15641581
help_text.add_row("", "")
@@ -1648,20 +1665,28 @@ def _make_progress_bar(self, progress: WorkflowProgress, width: int = 40) -> Tex
16481665
total = max(1, progress.total_jobs)
16491666
succeeded = progress.completed_jobs
16501667
failed = progress.failed_jobs
1668+
config = self._accessibility_config
16511669

16521670
# Calculate widths for each segment
16531671
succeeded_width = int((succeeded / total) * width)
16541672
failed_width = int((failed / total) * width)
1655-
remaining_width = width - succeeded_width - failed_width
1673+
unfinished = max(0, progress.total_jobs - progress.completed_jobs - progress.failed_jobs)
1674+
incomplete = (
1675+
min(len(progress.incomplete_jobs_list), unfinished)
1676+
if progress.status == WorkflowStatus.INCOMPLETE
1677+
else 0
1678+
)
1679+
incomplete_width = int((incomplete / total) * width)
1680+
remaining_width = width - succeeded_width - failed_width - incomplete_width
16561681

16571682
# Build the bar with colored segments
16581683
bar = Text()
1659-
bar.append("█" * succeeded_width, style="green")
1660-
bar.append("█" * failed_width, style="red")
1661-
if progress.status == WorkflowStatus.INCOMPLETE:
1662-
bar.append("░" * remaining_width, style="yellow") # Incomplete = yellow
1663-
else:
1664-
bar.append("░" * remaining_width, style="dim")
1684+
bar.append(config.succeeded.char * succeeded_width, style="green")
1685+
bar.append(config.failed.char * failed_width, style="red")
1686+
if incomplete_width > 0:
1687+
bar.append(config.incomplete.char * incomplete_width, style="yellow")
1688+
if remaining_width > 0:
1689+
bar.append(config.remaining.char * remaining_width, style="dim")
16651690

16661691
return bar
16671692

@@ -1719,14 +1744,35 @@ def _make_progress_panel(
17191744

17201745
eta_text = Text.from_markup(" ".join(eta_parts)) if eta_parts else Text("")
17211746

1722-
# Legend for the progress bar when there are failures
1747+
# Legend for the progress bar
1748+
config = self._accessibility_config
17231749
legend = Text()
1724-
if progress.failed_jobs > 0:
1750+
show_legend = progress.failed_jobs > 0 or config.show_legend
1751+
if show_legend:
17251752
legend.append(" (", style="dim")
1726-
legend.append("█", style="green")
1727-
legend.append(f"={progress.completed_jobs} succeeded ", style="dim")
1728-
legend.append("█", style="red")
1729-
legend.append(f"={progress.failed_jobs} failed", style="dim")
1753+
legend.append(config.succeeded.char, style="green")
1754+
legend.append(f"={progress.completed_jobs} {config.succeeded.label}", style="dim")
1755+
if progress.failed_jobs > 0:
1756+
legend.append(" ", style="dim")
1757+
legend.append(config.failed.char, style="red")
1758+
legend.append(f"={progress.failed_jobs} {config.failed.label}", style="dim")
1759+
unfinished = max(
1760+
0, progress.total_jobs - progress.completed_jobs - progress.failed_jobs
1761+
)
1762+
incomplete = (
1763+
min(len(progress.incomplete_jobs_list), unfinished)
1764+
if progress.status == WorkflowStatus.INCOMPLETE
1765+
else 0
1766+
)
1767+
remaining = unfinished - incomplete
1768+
if incomplete > 0:
1769+
legend.append(" ", style="dim")
1770+
legend.append(config.incomplete.char, style="yellow")
1771+
legend.append(f"={incomplete} {config.incomplete.label}", style="dim")
1772+
if remaining > 0:
1773+
legend.append(" ", style="dim")
1774+
legend.append(config.remaining.char, style="dim")
1775+
legend.append(f"={remaining} {config.remaining.label}", style="dim")
17301776
legend.append(")", style="dim")
17311777

17321778
# Border color based on status (use FG colors for normal states)
@@ -1740,7 +1786,7 @@ def _make_progress_panel(
17401786
border_style = border_colors.get(progress.status, FG_BLUE)
17411787

17421788
# Combine progress line with legend if present
1743-
if progress.failed_jobs > 0:
1789+
if show_legend:
17441790
full_progress = Text()
17451791
full_progress.append(progress_line)
17461792
full_progress.append(legend)

tests/test_cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,28 @@ def test_watch_calls_tui(self, snakemake_dir: Path, tmp_path: Path) -> None:
6363
weighting_strategy="index",
6464
half_life_logs=10,
6565
half_life_days=7.0,
66+
accessibility_config=None,
67+
)
68+
mock_instance.run.assert_called_once()
69+
70+
def test_watch_calls_tui_in_accessible_mode(self, snakemake_dir: Path, tmp_path: Path) -> None:
71+
"""Test that watch enables accessible rendering when requested."""
72+
from snakesee.tui.accessibility import ACCESSIBLE_CONFIG
73+
74+
with patch("snakesee.tui.WorkflowMonitorTUI") as mock_tui:
75+
mock_instance = mock_tui.return_value
76+
watch(tmp_path, refresh=2.0, no_estimate=True, colorblind=True)
77+
78+
mock_tui.assert_called_once_with(
79+
workflow_dir=tmp_path,
80+
refresh_rate=2.0,
81+
use_estimation=False,
82+
profile_path=None,
83+
use_wildcard_conditioning=True,
84+
weighting_strategy="index",
85+
half_life_logs=10,
86+
half_life_days=7.0,
87+
accessibility_config=ACCESSIBLE_CONFIG,
6688
)
6789
mock_instance.run.assert_called_once()
6890

0 commit comments

Comments
 (0)