shrinkray 25.12.27.3__py3-none-any.whl → 25.12.29.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.
- shrinkray/passes/bytes.py +0 -125
- shrinkray/passes/clangdelta.py +0 -24
- shrinkray/passes/genericlanguages.py +0 -20
- shrinkray/passes/json.py +0 -8
- shrinkray/passes/patching.py +0 -63
- shrinkray/passes/sequences.py +0 -25
- shrinkray/reducer.py +0 -50
- shrinkray/state.py +43 -51
- shrinkray/subprocess/__init__.py +0 -4
- shrinkray/subprocess/protocol.py +10 -12
- shrinkray/subprocess/worker.py +59 -21
- shrinkray/tui.py +605 -32
- {shrinkray-25.12.27.3.dist-info → shrinkray-25.12.29.0.dist-info}/METADATA +10 -2
- shrinkray-25.12.29.0.dist-info/RECORD +33 -0
- shrinkray/display.py +0 -75
- shrinkray-25.12.27.3.dist-info/RECORD +0 -34
- {shrinkray-25.12.27.3.dist-info → shrinkray-25.12.29.0.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.27.3.dist-info → shrinkray-25.12.29.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.27.3.dist-info → shrinkray-25.12.29.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.27.3.dist-info → shrinkray-25.12.29.0.dist-info}/top_level.txt +0 -0
shrinkray/tui.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Textual-based TUI for Shrink Ray."""
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
import os
|
|
4
5
|
import time
|
|
5
6
|
import traceback
|
|
6
7
|
from collections.abc import AsyncGenerator
|
|
7
8
|
from contextlib import aclosing
|
|
8
9
|
from datetime import timedelta
|
|
9
|
-
from typing import Literal, Protocol
|
|
10
|
+
from typing import Literal, Protocol, cast
|
|
10
11
|
|
|
11
12
|
import humanize
|
|
12
13
|
from rich.text import Text
|
|
@@ -17,7 +18,9 @@ from textual.reactive import reactive
|
|
|
17
18
|
from textual.screen import ModalScreen
|
|
18
19
|
from textual.theme import Theme
|
|
19
20
|
from textual.widgets import DataTable, Footer, Header, Label, Static
|
|
21
|
+
from textual_plotext import PlotextPlot
|
|
20
22
|
|
|
23
|
+
from shrinkray.formatting import try_decode
|
|
21
24
|
from shrinkray.subprocess.client import SubprocessClient
|
|
22
25
|
from shrinkray.subprocess.protocol import (
|
|
23
26
|
PassStatsData,
|
|
@@ -223,6 +226,219 @@ class StatsDisplay(Static):
|
|
|
223
226
|
return "\n".join(lines)
|
|
224
227
|
|
|
225
228
|
|
|
229
|
+
def _format_time_label(seconds: float) -> str:
|
|
230
|
+
"""Format a time value for axis labels."""
|
|
231
|
+
if seconds < 60:
|
|
232
|
+
return f"{int(seconds)}s"
|
|
233
|
+
elif seconds < 3600:
|
|
234
|
+
minutes = int(seconds / 60)
|
|
235
|
+
return f"{minutes}m"
|
|
236
|
+
else:
|
|
237
|
+
hours = int(seconds / 3600)
|
|
238
|
+
return f"{hours}h"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _get_time_axis_bounds(current_time: float) -> tuple[float, list[float], list[str]]:
|
|
242
|
+
"""Get stable x-axis bounds and labeled positions.
|
|
243
|
+
|
|
244
|
+
Returns (max_time, positions, labels) where:
|
|
245
|
+
- max_time: the stable right boundary of the axis
|
|
246
|
+
- positions: numeric positions where axis labels should appear (e.g., [0, 60, 120])
|
|
247
|
+
- labels: formatted strings for each position (e.g., ["0s", "1m", "2m"])
|
|
248
|
+
|
|
249
|
+
The axis only rescales when current_time exceeds the current boundary.
|
|
250
|
+
"""
|
|
251
|
+
if current_time <= 0:
|
|
252
|
+
ticks = [0.0, 10.0, 20.0, 30.0]
|
|
253
|
+
labels = [_format_time_label(t) for t in ticks]
|
|
254
|
+
return (30.0, ticks, labels)
|
|
255
|
+
|
|
256
|
+
# For the first 10 minutes, expand one minute at a time with 1-minute ticks
|
|
257
|
+
if current_time < 600:
|
|
258
|
+
# Round up to next minute
|
|
259
|
+
minutes = int(current_time / 60) + 1
|
|
260
|
+
max_time = float(minutes * 60)
|
|
261
|
+
interval = 60.0
|
|
262
|
+
else:
|
|
263
|
+
# After 10 minutes, use larger boundaries
|
|
264
|
+
# (boundary, tick_interval) - axis extends to boundary, ticks at interval
|
|
265
|
+
boundaries = [
|
|
266
|
+
(1800, 300), # 30m with 5m ticks
|
|
267
|
+
(3600, 600), # 1h with 10m ticks
|
|
268
|
+
(7200, 1200), # 2h with 20m ticks
|
|
269
|
+
(14400, 1800), # 4h with 30m ticks
|
|
270
|
+
(28800, 3600), # 8h with 1h ticks
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
# Find the first boundary that exceeds current_time
|
|
274
|
+
max_time = 1800.0
|
|
275
|
+
interval = 300.0
|
|
276
|
+
for boundary, tick_interval in boundaries:
|
|
277
|
+
if current_time < boundary:
|
|
278
|
+
max_time = float(boundary)
|
|
279
|
+
interval = float(tick_interval)
|
|
280
|
+
break
|
|
281
|
+
else:
|
|
282
|
+
# Beyond 8h: extend in 4h increments with 1h ticks
|
|
283
|
+
hours = int(current_time / 14400) + 1
|
|
284
|
+
max_time = float(hours * 14400)
|
|
285
|
+
interval = 3600.0
|
|
286
|
+
|
|
287
|
+
# Generate ticks from 0 to max_time
|
|
288
|
+
ticks = []
|
|
289
|
+
t = 0.0
|
|
290
|
+
while t <= max_time:
|
|
291
|
+
ticks.append(t)
|
|
292
|
+
t += interval
|
|
293
|
+
|
|
294
|
+
labels = [_format_time_label(t) for t in ticks]
|
|
295
|
+
return (max_time, ticks, labels)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _get_percentage_axis_bounds(
|
|
299
|
+
min_pct: float, max_pct: float
|
|
300
|
+
) -> tuple[float, list[float], list[str]]:
|
|
301
|
+
"""Get stable y-axis bounds for percentage values on log scale.
|
|
302
|
+
|
|
303
|
+
Returns (min_pct_bound, positions, labels) where:
|
|
304
|
+
- min_pct_bound: the stable lower boundary of the axis
|
|
305
|
+
- positions: log10 positions where axis labels should appear
|
|
306
|
+
- labels: formatted percentage strings for each position
|
|
307
|
+
|
|
308
|
+
The axis only rescales when min_pct gets close to the current lower boundary.
|
|
309
|
+
"""
|
|
310
|
+
# Standard percentage boundaries (log scale friendly)
|
|
311
|
+
# Extended to handle very small reductions (below 0.01%)
|
|
312
|
+
boundaries = [
|
|
313
|
+
100,
|
|
314
|
+
50,
|
|
315
|
+
20,
|
|
316
|
+
10,
|
|
317
|
+
5,
|
|
318
|
+
2,
|
|
319
|
+
1,
|
|
320
|
+
0.5,
|
|
321
|
+
0.2,
|
|
322
|
+
0.1,
|
|
323
|
+
0.05,
|
|
324
|
+
0.02,
|
|
325
|
+
0.01,
|
|
326
|
+
0.005,
|
|
327
|
+
0.002,
|
|
328
|
+
0.001,
|
|
329
|
+
0.0005,
|
|
330
|
+
0.0002,
|
|
331
|
+
0.0001,
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# Find the appropriate lower bound - use the first boundary below min_pct * 0.5
|
|
335
|
+
# This gives us some room before we need to rescale
|
|
336
|
+
lower_bound = boundaries[-1] # Default to smallest boundary
|
|
337
|
+
for b in boundaries:
|
|
338
|
+
if b < min_pct * 0.5:
|
|
339
|
+
lower_bound = b
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
# Find which percentage values to show as ticks (between lower_bound and 100%)
|
|
343
|
+
# Since boundaries always includes 100 and lower_bound <= 100, this is never empty
|
|
344
|
+
tick_pcts = [p for p in boundaries if p >= lower_bound and p <= 100]
|
|
345
|
+
|
|
346
|
+
# Convert to log scale
|
|
347
|
+
ticks = [math.log10(max(0.0001, p)) for p in tick_pcts]
|
|
348
|
+
|
|
349
|
+
# Format labels
|
|
350
|
+
labels = []
|
|
351
|
+
for p in tick_pcts:
|
|
352
|
+
if p >= 1:
|
|
353
|
+
labels.append(f"{p:.0f}%")
|
|
354
|
+
else:
|
|
355
|
+
labels.append(f"{p}%")
|
|
356
|
+
|
|
357
|
+
return (lower_bound, ticks, labels)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class SizeGraph(PlotextPlot):
|
|
361
|
+
"""Widget to display test case size over time on a log scale."""
|
|
362
|
+
|
|
363
|
+
_size_history: list[tuple[float, int]]
|
|
364
|
+
_original_size: int
|
|
365
|
+
_current_runtime: float
|
|
366
|
+
|
|
367
|
+
def __init__(
|
|
368
|
+
self,
|
|
369
|
+
name: str | None = None,
|
|
370
|
+
id: str | None = None,
|
|
371
|
+
classes: str | None = None,
|
|
372
|
+
disabled: bool = False,
|
|
373
|
+
) -> None:
|
|
374
|
+
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
375
|
+
self._size_history = []
|
|
376
|
+
self._original_size = 0
|
|
377
|
+
self._current_runtime = 0.0
|
|
378
|
+
|
|
379
|
+
def update_graph(
|
|
380
|
+
self,
|
|
381
|
+
new_entries: list[tuple[float, int]],
|
|
382
|
+
original_size: int,
|
|
383
|
+
current_runtime: float,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Update the graph with new data."""
|
|
386
|
+
if new_entries:
|
|
387
|
+
self._size_history.extend(new_entries)
|
|
388
|
+
if original_size > 0:
|
|
389
|
+
self._original_size = original_size
|
|
390
|
+
self._current_runtime = current_runtime
|
|
391
|
+
self._setup_plot()
|
|
392
|
+
self.refresh()
|
|
393
|
+
|
|
394
|
+
def on_mount(self) -> None:
|
|
395
|
+
"""Set up the plot on mount."""
|
|
396
|
+
self._setup_plot()
|
|
397
|
+
|
|
398
|
+
def on_resize(self) -> None:
|
|
399
|
+
"""Redraw when resized."""
|
|
400
|
+
self._setup_plot()
|
|
401
|
+
|
|
402
|
+
def _setup_plot(self) -> None:
|
|
403
|
+
"""Configure and draw the plot."""
|
|
404
|
+
plt = self.plt
|
|
405
|
+
plt.clear_figure()
|
|
406
|
+
plt.theme("dark")
|
|
407
|
+
|
|
408
|
+
if len(self._size_history) < 2 or self._original_size == 0:
|
|
409
|
+
plt.xlabel("Time")
|
|
410
|
+
plt.ylabel("% of original")
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
times = [t for t, _ in self._size_history]
|
|
414
|
+
sizes = [s for _, s in self._size_history]
|
|
415
|
+
|
|
416
|
+
# Calculate percentages of original size
|
|
417
|
+
percentages = [(s / self._original_size) * 100 for s in sizes]
|
|
418
|
+
|
|
419
|
+
# Use log scale for y-axis (percentages)
|
|
420
|
+
log_percentages = [math.log10(max(0.01, p)) for p in percentages]
|
|
421
|
+
|
|
422
|
+
plt.plot(times, log_percentages, marker="braille")
|
|
423
|
+
|
|
424
|
+
# Get stable x-axis bounds
|
|
425
|
+
max_time, x_ticks, x_labels = _get_time_axis_bounds(self._current_runtime)
|
|
426
|
+
plt.xticks(x_ticks, x_labels)
|
|
427
|
+
plt.xlim(0, max_time)
|
|
428
|
+
|
|
429
|
+
# Get stable y-axis bounds
|
|
430
|
+
min_pct = min(percentages)
|
|
431
|
+
lower_bound, y_ticks, y_labels = _get_percentage_axis_bounds(min_pct, 100)
|
|
432
|
+
plt.yticks(y_ticks, y_labels)
|
|
433
|
+
plt.ylim(math.log10(max(0.01, lower_bound)), math.log10(100))
|
|
434
|
+
|
|
435
|
+
plt.xlabel("Time")
|
|
436
|
+
plt.ylabel("% of original")
|
|
437
|
+
|
|
438
|
+
# Build to apply the plot
|
|
439
|
+
_ = plt.build()
|
|
440
|
+
|
|
441
|
+
|
|
226
442
|
class ContentPreview(Static):
|
|
227
443
|
"""Widget to display the current test case content preview."""
|
|
228
444
|
|
|
@@ -313,21 +529,37 @@ class OutputPreview(Static):
|
|
|
313
529
|
|
|
314
530
|
output_content = reactive("")
|
|
315
531
|
active_test_id: reactive[int | None] = reactive(None)
|
|
532
|
+
last_return_code: reactive[int | None] = reactive(None)
|
|
316
533
|
_last_update_time: float = 0.0
|
|
317
|
-
|
|
534
|
+
# Pending updates that haven't been applied yet (due to throttling)
|
|
535
|
+
_pending_content: str = ""
|
|
536
|
+
_pending_test_id: int | None = None
|
|
537
|
+
_pending_return_code: int | None = None
|
|
538
|
+
# Track if we've ever seen any output (once true, never show "No test output yet...")
|
|
539
|
+
_has_seen_output: bool = False
|
|
318
540
|
|
|
319
|
-
def update_output(
|
|
320
|
-
|
|
541
|
+
def update_output(
|
|
542
|
+
self, content: str, test_id: int | None, return_code: int | None = None
|
|
543
|
+
) -> None:
|
|
544
|
+
# Only update pending content if there's actual content to show
|
|
545
|
+
# This prevents switching to empty output when we have previous output
|
|
546
|
+
if content:
|
|
547
|
+
self._pending_content = content
|
|
548
|
+
self._has_seen_output = True
|
|
549
|
+
self._pending_test_id = test_id
|
|
550
|
+
self._pending_return_code = return_code
|
|
551
|
+
|
|
552
|
+
# Throttle display updates to every 200ms
|
|
321
553
|
now = time.time()
|
|
322
554
|
if now - self._last_update_time < 0.2:
|
|
323
555
|
return
|
|
324
556
|
|
|
325
557
|
self._last_update_time = now
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
self.
|
|
558
|
+
# Only update output_content if we have new content
|
|
559
|
+
if self._pending_content:
|
|
560
|
+
self.output_content = self._pending_content
|
|
561
|
+
self.active_test_id = self._pending_test_id
|
|
562
|
+
self.last_return_code = self._pending_return_code
|
|
331
563
|
self.refresh(layout=True)
|
|
332
564
|
|
|
333
565
|
def _get_available_lines(self) -> int:
|
|
@@ -345,11 +577,15 @@ class OutputPreview(Static):
|
|
|
345
577
|
return 30
|
|
346
578
|
|
|
347
579
|
def render(self) -> str:
|
|
348
|
-
# Header line
|
|
349
|
-
|
|
580
|
+
# Header line - use return_code to determine if test is running
|
|
581
|
+
# (return_code is None means still running, has value means completed)
|
|
582
|
+
if self.active_test_id is not None and self.last_return_code is None:
|
|
350
583
|
header = f"[green]Test #{self.active_test_id} running...[/green]"
|
|
351
|
-
elif self.
|
|
352
|
-
header = f"[dim]Test #{self.
|
|
584
|
+
elif self.active_test_id is not None:
|
|
585
|
+
header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
|
|
586
|
+
elif self._has_seen_output or self.output_content:
|
|
587
|
+
# Have seen output before - show without header
|
|
588
|
+
header = ""
|
|
353
589
|
else:
|
|
354
590
|
header = "[dim]No test output yet...[/dim]"
|
|
355
591
|
|
|
@@ -359,14 +595,17 @@ class OutputPreview(Static):
|
|
|
359
595
|
available_lines = self._get_available_lines()
|
|
360
596
|
lines = self.output_content.split("\n")
|
|
361
597
|
|
|
598
|
+
# Build prefix (header + newline, or empty if no header)
|
|
599
|
+
prefix = f"{header}\n" if header else ""
|
|
600
|
+
|
|
362
601
|
# Show tail of output (most recent lines)
|
|
363
602
|
if len(lines) <= available_lines:
|
|
364
|
-
return f"{
|
|
603
|
+
return f"{prefix}{self.output_content}"
|
|
365
604
|
|
|
366
605
|
# Truncate from the beginning
|
|
367
606
|
truncated_lines = lines[-(available_lines):]
|
|
368
607
|
skipped = len(lines) - available_lines
|
|
369
|
-
return f"{
|
|
608
|
+
return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
|
|
370
609
|
|
|
371
610
|
|
|
372
611
|
class HelpScreen(ModalScreen[None]):
|
|
@@ -424,6 +663,169 @@ class HelpScreen(ModalScreen[None]):
|
|
|
424
663
|
yield Static("[dim]Press any key to close[/dim]")
|
|
425
664
|
|
|
426
665
|
|
|
666
|
+
class ExpandedBoxModal(ModalScreen[None]):
|
|
667
|
+
"""Modal screen showing an expanded view of a content box."""
|
|
668
|
+
|
|
669
|
+
CSS = """
|
|
670
|
+
ExpandedBoxModal {
|
|
671
|
+
align: center middle;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
ExpandedBoxModal > Vertical {
|
|
675
|
+
width: 95%;
|
|
676
|
+
height: 90%;
|
|
677
|
+
background: $panel;
|
|
678
|
+
border: thick $primary;
|
|
679
|
+
padding: 0 1 1 1;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
ExpandedBoxModal #expanded-title {
|
|
683
|
+
text-align: center;
|
|
684
|
+
text-style: bold;
|
|
685
|
+
height: auto;
|
|
686
|
+
width: 100%;
|
|
687
|
+
border-bottom: solid $primary;
|
|
688
|
+
padding: 0;
|
|
689
|
+
margin-bottom: 1;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
ExpandedBoxModal VerticalScroll {
|
|
693
|
+
width: 100%;
|
|
694
|
+
height: 1fr;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
ExpandedBoxModal #expanded-content {
|
|
698
|
+
width: 100%;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
ExpandedBoxModal #expanded-graph {
|
|
702
|
+
width: 100%;
|
|
703
|
+
height: 1fr;
|
|
704
|
+
}
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
BINDINGS = [
|
|
708
|
+
("escape,enter,q", "dismiss", "Close"),
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
def __init__(
|
|
712
|
+
self, title: str, content_widget_id: str, file_path: str | None = None
|
|
713
|
+
) -> None:
|
|
714
|
+
super().__init__()
|
|
715
|
+
self._title = title
|
|
716
|
+
self._content_widget_id = content_widget_id
|
|
717
|
+
self._file_path = file_path
|
|
718
|
+
|
|
719
|
+
def _read_file(self, file_path: str) -> str:
|
|
720
|
+
"""Read file content, decoding as text if possible."""
|
|
721
|
+
with open(file_path, "rb") as f:
|
|
722
|
+
raw_content = f.read()
|
|
723
|
+
# Try to decode as text, fall back to hex display if binary
|
|
724
|
+
encoding, text = try_decode(raw_content)
|
|
725
|
+
if encoding is not None:
|
|
726
|
+
return text
|
|
727
|
+
return "[Binary content - hex display]\n\n" + raw_content.hex()
|
|
728
|
+
|
|
729
|
+
def compose(self) -> ComposeResult:
|
|
730
|
+
with Vertical():
|
|
731
|
+
yield Label(self._title, id="expanded-title")
|
|
732
|
+
if self._content_widget_id == "graph-container":
|
|
733
|
+
# For graph, create a new SizeGraph widget
|
|
734
|
+
yield SizeGraph(id="expanded-graph")
|
|
735
|
+
else:
|
|
736
|
+
# For other content, use a scrollable static
|
|
737
|
+
with VerticalScroll():
|
|
738
|
+
yield Static("", id="expanded-content")
|
|
739
|
+
|
|
740
|
+
def _get_graph_content(self, app: "ShrinkRayApp") -> None:
|
|
741
|
+
"""Copy graph data from main graph to expanded graph."""
|
|
742
|
+
main_graphs = list(app.query("#size-graph").results(SizeGraph))
|
|
743
|
+
expanded_graphs = list(self.query("#expanded-graph").results(SizeGraph))
|
|
744
|
+
if not main_graphs or not expanded_graphs:
|
|
745
|
+
return
|
|
746
|
+
main_graph = main_graphs[0]
|
|
747
|
+
expanded_graph = expanded_graphs[0]
|
|
748
|
+
expanded_graph._size_history = main_graph._size_history.copy()
|
|
749
|
+
expanded_graph._original_size = main_graph._original_size
|
|
750
|
+
expanded_graph._current_runtime = main_graph._current_runtime
|
|
751
|
+
expanded_graph._setup_plot()
|
|
752
|
+
|
|
753
|
+
def _get_stats_content(self, app: "ShrinkRayApp") -> str:
|
|
754
|
+
"""Get stats content from the stats display widget."""
|
|
755
|
+
stats_displays = list(app.query("#stats-display").results(StatsDisplay))
|
|
756
|
+
if not stats_displays:
|
|
757
|
+
return "Statistics not available"
|
|
758
|
+
return stats_displays[0].render()
|
|
759
|
+
|
|
760
|
+
def _get_file_content(self, app: "ShrinkRayApp") -> str:
|
|
761
|
+
"""Get content from file or preview widget."""
|
|
762
|
+
if self._file_path:
|
|
763
|
+
return self._read_file(self._file_path)
|
|
764
|
+
content_previews = list(app.query("#content-preview").results(ContentPreview))
|
|
765
|
+
if not content_previews:
|
|
766
|
+
return "Content preview not available"
|
|
767
|
+
return content_previews[0].preview_content
|
|
768
|
+
|
|
769
|
+
def _get_output_content(self, app: "ShrinkRayApp") -> str:
|
|
770
|
+
"""Get output content from the output preview widget."""
|
|
771
|
+
output_previews = list(app.query("#output-preview").results(OutputPreview))
|
|
772
|
+
if not output_previews:
|
|
773
|
+
return "Output not available"
|
|
774
|
+
output_preview = output_previews[0]
|
|
775
|
+
|
|
776
|
+
# Use pending values (most recent) rather than throttled values
|
|
777
|
+
raw_content = output_preview._pending_content or output_preview.output_content
|
|
778
|
+
test_id = (
|
|
779
|
+
output_preview._pending_test_id
|
|
780
|
+
if output_preview._pending_test_id is not None
|
|
781
|
+
else output_preview.active_test_id
|
|
782
|
+
)
|
|
783
|
+
return_code = (
|
|
784
|
+
output_preview._pending_return_code
|
|
785
|
+
if output_preview._pending_return_code is not None
|
|
786
|
+
else output_preview.last_return_code
|
|
787
|
+
)
|
|
788
|
+
has_seen_output = output_preview._has_seen_output
|
|
789
|
+
|
|
790
|
+
# Build header - return_code is None means test is still running
|
|
791
|
+
if test_id is not None and return_code is None:
|
|
792
|
+
header = f"[green]Test #{test_id} running...[/green]\n\n"
|
|
793
|
+
elif test_id is not None:
|
|
794
|
+
header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
|
|
795
|
+
else:
|
|
796
|
+
header = ""
|
|
797
|
+
|
|
798
|
+
if raw_content:
|
|
799
|
+
return header + raw_content
|
|
800
|
+
elif has_seen_output or test_id is not None:
|
|
801
|
+
# We've seen output before - show header only (no "No test output" message)
|
|
802
|
+
return header.rstrip("\n") if header else ""
|
|
803
|
+
else:
|
|
804
|
+
return "[dim]No test output yet...[/dim]"
|
|
805
|
+
|
|
806
|
+
def on_mount(self) -> None:
|
|
807
|
+
"""Populate content from the source widget."""
|
|
808
|
+
# Cast is safe because this modal is only used within ShrinkRayApp
|
|
809
|
+
app = cast("ShrinkRayApp", self.app)
|
|
810
|
+
|
|
811
|
+
if self._content_widget_id == "graph-container":
|
|
812
|
+
self._get_graph_content(app)
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
# For non-graph content, populate the static
|
|
816
|
+
# compose() always creates the #expanded-content widget for non-graph modals
|
|
817
|
+
if self._content_widget_id == "stats-container":
|
|
818
|
+
content = self._get_stats_content(app)
|
|
819
|
+
elif self._content_widget_id == "content-container":
|
|
820
|
+
content = self._get_file_content(app)
|
|
821
|
+
elif self._content_widget_id == "output-container":
|
|
822
|
+
content = self._get_output_content(app)
|
|
823
|
+
else:
|
|
824
|
+
content = ""
|
|
825
|
+
|
|
826
|
+
self.query_one("#expanded-content", Static).update(content)
|
|
827
|
+
|
|
828
|
+
|
|
427
829
|
class PassStatsScreen(ModalScreen[None]):
|
|
428
830
|
"""Modal screen showing pass statistics in a table."""
|
|
429
831
|
|
|
@@ -647,16 +1049,42 @@ class ShrinkRayApp(App[None]):
|
|
|
647
1049
|
height: 100%;
|
|
648
1050
|
}
|
|
649
1051
|
|
|
1052
|
+
#status-label {
|
|
1053
|
+
text-style: bold;
|
|
1054
|
+
margin: 0 1;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
#stats-area {
|
|
1058
|
+
height: 1fr;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
650
1061
|
#stats-container {
|
|
651
|
-
|
|
652
|
-
|
|
1062
|
+
border: solid $primary;
|
|
1063
|
+
margin: 0;
|
|
1064
|
+
padding: 1;
|
|
1065
|
+
width: 1fr;
|
|
1066
|
+
height: 100%;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
#stats-container:focus {
|
|
1070
|
+
border: thick $primary;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
#graph-container {
|
|
1074
|
+
border: solid $primary;
|
|
1075
|
+
margin: 0;
|
|
653
1076
|
padding: 1;
|
|
654
|
-
|
|
1077
|
+
width: 1fr;
|
|
1078
|
+
height: 100%;
|
|
655
1079
|
}
|
|
656
1080
|
|
|
657
|
-
#
|
|
658
|
-
|
|
659
|
-
|
|
1081
|
+
#graph-container:focus {
|
|
1082
|
+
border: thick $primary;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
#size-graph {
|
|
1086
|
+
width: 100%;
|
|
1087
|
+
height: 100%;
|
|
660
1088
|
}
|
|
661
1089
|
|
|
662
1090
|
#content-area {
|
|
@@ -664,27 +1092,27 @@ class ShrinkRayApp(App[None]):
|
|
|
664
1092
|
}
|
|
665
1093
|
|
|
666
1094
|
#content-container {
|
|
667
|
-
border: solid
|
|
668
|
-
margin:
|
|
1095
|
+
border: solid $primary;
|
|
1096
|
+
margin: 0;
|
|
669
1097
|
padding: 1;
|
|
670
1098
|
width: 1fr;
|
|
671
1099
|
height: 100%;
|
|
672
1100
|
}
|
|
673
1101
|
|
|
674
|
-
#content-container:
|
|
675
|
-
border:
|
|
1102
|
+
#content-container:focus {
|
|
1103
|
+
border: thick $primary;
|
|
676
1104
|
}
|
|
677
1105
|
|
|
678
1106
|
#output-container {
|
|
679
|
-
border: solid
|
|
680
|
-
margin:
|
|
1107
|
+
border: solid $primary;
|
|
1108
|
+
margin: 0;
|
|
681
1109
|
padding: 1;
|
|
682
1110
|
width: 1fr;
|
|
683
1111
|
height: 100%;
|
|
684
1112
|
}
|
|
685
1113
|
|
|
686
|
-
#output-container:
|
|
687
|
-
border:
|
|
1114
|
+
#output-container:focus {
|
|
1115
|
+
border: thick $primary;
|
|
688
1116
|
}
|
|
689
1117
|
"""
|
|
690
1118
|
|
|
@@ -693,6 +1121,11 @@ class ShrinkRayApp(App[None]):
|
|
|
693
1121
|
("p", "show_pass_stats", "Pass Stats"),
|
|
694
1122
|
("c", "skip_current_pass", "Skip Pass"),
|
|
695
1123
|
("h", "show_help", "Help"),
|
|
1124
|
+
("up", "focus_up", "Focus Up"),
|
|
1125
|
+
("down", "focus_down", "Focus Down"),
|
|
1126
|
+
("left", "focus_left", "Focus Left"),
|
|
1127
|
+
("right", "focus_right", "Focus Right"),
|
|
1128
|
+
("enter", "expand_box", "Expand"),
|
|
696
1129
|
]
|
|
697
1130
|
|
|
698
1131
|
ENABLE_COMMAND_PALETTE = False
|
|
@@ -737,6 +1170,14 @@ class ShrinkRayApp(App[None]):
|
|
|
737
1170
|
self._current_pass_name: str = ""
|
|
738
1171
|
self._disabled_passes: list[str] = []
|
|
739
1172
|
|
|
1173
|
+
# Box IDs in navigation order: [top-left, top-right, bottom-left, bottom-right]
|
|
1174
|
+
_BOX_IDS = [
|
|
1175
|
+
"stats-container",
|
|
1176
|
+
"graph-container",
|
|
1177
|
+
"content-container",
|
|
1178
|
+
"output-container",
|
|
1179
|
+
]
|
|
1180
|
+
|
|
740
1181
|
def compose(self) -> ComposeResult:
|
|
741
1182
|
yield Header()
|
|
742
1183
|
with Vertical(id="main-container"):
|
|
@@ -745,14 +1186,23 @@ class ShrinkRayApp(App[None]):
|
|
|
745
1186
|
id="status-label",
|
|
746
1187
|
markup=False,
|
|
747
1188
|
)
|
|
748
|
-
with
|
|
749
|
-
|
|
1189
|
+
with Horizontal(id="stats-area"):
|
|
1190
|
+
with VerticalScroll(id="stats-container") as stats_scroll:
|
|
1191
|
+
stats_scroll.border_title = "Statistics"
|
|
1192
|
+
stats_scroll.can_focus = True
|
|
1193
|
+
yield StatsDisplay(id="stats-display")
|
|
1194
|
+
with Vertical(id="graph-container") as graph_container:
|
|
1195
|
+
graph_container.border_title = "Size Over Time"
|
|
1196
|
+
graph_container.can_focus = True
|
|
1197
|
+
yield SizeGraph(id="size-graph")
|
|
750
1198
|
with Horizontal(id="content-area"):
|
|
751
1199
|
with VerticalScroll(id="content-container") as content_scroll:
|
|
752
1200
|
content_scroll.border_title = "Recent Reductions"
|
|
1201
|
+
content_scroll.can_focus = True
|
|
753
1202
|
yield ContentPreview(id="content-preview")
|
|
754
1203
|
with VerticalScroll(id="output-container") as output_scroll:
|
|
755
1204
|
output_scroll.border_title = "Test Output"
|
|
1205
|
+
output_scroll.can_focus = True
|
|
756
1206
|
yield OutputPreview(id="output-preview")
|
|
757
1207
|
yield Footer()
|
|
758
1208
|
|
|
@@ -772,8 +1222,81 @@ class ShrinkRayApp(App[None]):
|
|
|
772
1222
|
|
|
773
1223
|
self.title = "Shrink Ray"
|
|
774
1224
|
self.sub_title = self._file_path
|
|
1225
|
+
|
|
1226
|
+
# Set initial focus to first box
|
|
1227
|
+
self.query_one("#stats-container").focus()
|
|
1228
|
+
|
|
775
1229
|
self.run_reduction()
|
|
776
1230
|
|
|
1231
|
+
def _get_focused_box_index(self) -> int:
|
|
1232
|
+
"""Get the index of the currently focused box, or 0 if none."""
|
|
1233
|
+
for i, box_id in enumerate(self._BOX_IDS):
|
|
1234
|
+
boxes = list(self.query(f"#{box_id}"))
|
|
1235
|
+
if boxes and boxes[0].has_focus:
|
|
1236
|
+
return i
|
|
1237
|
+
return 0
|
|
1238
|
+
|
|
1239
|
+
def _focus_box(self, index: int) -> None:
|
|
1240
|
+
"""Focus the box at the given index (with wrapping)."""
|
|
1241
|
+
index = index % len(self._BOX_IDS)
|
|
1242
|
+
box_id = self._BOX_IDS[index]
|
|
1243
|
+
self.query_one(f"#{box_id}").focus()
|
|
1244
|
+
|
|
1245
|
+
def action_focus_up(self) -> None:
|
|
1246
|
+
"""Move focus to the box above."""
|
|
1247
|
+
current = self._get_focused_box_index()
|
|
1248
|
+
# Grid is 2x2: top row is 0,1; bottom row is 2,3
|
|
1249
|
+
# Moving up: 2->0, 3->1, 0->2, 1->3 (wraps)
|
|
1250
|
+
if current >= 2:
|
|
1251
|
+
self._focus_box(current - 2)
|
|
1252
|
+
else:
|
|
1253
|
+
self._focus_box(current + 2)
|
|
1254
|
+
|
|
1255
|
+
def action_focus_down(self) -> None:
|
|
1256
|
+
"""Move focus to the box below."""
|
|
1257
|
+
current = self._get_focused_box_index()
|
|
1258
|
+
# Moving down: 0->2, 1->3, 2->0, 3->1 (wraps)
|
|
1259
|
+
if current < 2:
|
|
1260
|
+
self._focus_box(current + 2)
|
|
1261
|
+
else:
|
|
1262
|
+
self._focus_box(current - 2)
|
|
1263
|
+
|
|
1264
|
+
def action_focus_left(self) -> None:
|
|
1265
|
+
"""Move focus to the box on the left."""
|
|
1266
|
+
current = self._get_focused_box_index()
|
|
1267
|
+
# Moving left within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
|
|
1268
|
+
if current % 2 == 0:
|
|
1269
|
+
self._focus_box(current + 1)
|
|
1270
|
+
else:
|
|
1271
|
+
self._focus_box(current - 1)
|
|
1272
|
+
|
|
1273
|
+
def action_focus_right(self) -> None:
|
|
1274
|
+
"""Move focus to the box on the right."""
|
|
1275
|
+
current = self._get_focused_box_index()
|
|
1276
|
+
# Moving right within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
|
|
1277
|
+
if current % 2 == 0:
|
|
1278
|
+
self._focus_box(current + 1)
|
|
1279
|
+
else:
|
|
1280
|
+
self._focus_box(current - 1)
|
|
1281
|
+
|
|
1282
|
+
def action_expand_box(self) -> None:
|
|
1283
|
+
"""Expand the currently focused box to a modal."""
|
|
1284
|
+
current = self._get_focused_box_index()
|
|
1285
|
+
box_id = self._BOX_IDS[current]
|
|
1286
|
+
|
|
1287
|
+
# Get the title from the container's border_title
|
|
1288
|
+
titles = {
|
|
1289
|
+
"stats-container": "Statistics",
|
|
1290
|
+
"graph-container": "Size Over Time",
|
|
1291
|
+
"content-container": "Current Test Case",
|
|
1292
|
+
"output-container": "Test Output",
|
|
1293
|
+
}
|
|
1294
|
+
title = titles.get(box_id, "Details")
|
|
1295
|
+
|
|
1296
|
+
# Pass file_path for content-container to enable full file reading
|
|
1297
|
+
file_path = self._file_path if box_id == "content-container" else None
|
|
1298
|
+
self.push_screen(ExpandedBoxModal(title, box_id, file_path=file_path))
|
|
1299
|
+
|
|
777
1300
|
@work(exclusive=True)
|
|
778
1301
|
async def run_reduction(self) -> None:
|
|
779
1302
|
"""Start the reduction subprocess and monitor progress."""
|
|
@@ -812,6 +1335,7 @@ class ShrinkRayApp(App[None]):
|
|
|
812
1335
|
stats_display = self.query_one("#stats-display", StatsDisplay)
|
|
813
1336
|
content_preview = self.query_one("#content-preview", ContentPreview)
|
|
814
1337
|
output_preview = self.query_one("#output-preview", OutputPreview)
|
|
1338
|
+
size_graph = self.query_one("#size-graph", SizeGraph)
|
|
815
1339
|
|
|
816
1340
|
async with aclosing(self._client.get_progress_updates()) as updates:
|
|
817
1341
|
async for update in updates:
|
|
@@ -820,8 +1344,22 @@ class ShrinkRayApp(App[None]):
|
|
|
820
1344
|
update.content_preview, update.hex_mode
|
|
821
1345
|
)
|
|
822
1346
|
output_preview.update_output(
|
|
823
|
-
update.test_output_preview,
|
|
1347
|
+
update.test_output_preview,
|
|
1348
|
+
update.active_test_id,
|
|
1349
|
+
update.last_test_return_code,
|
|
824
1350
|
)
|
|
1351
|
+
size_graph.update_graph(
|
|
1352
|
+
update.new_size_history,
|
|
1353
|
+
update.original_size,
|
|
1354
|
+
update.runtime,
|
|
1355
|
+
)
|
|
1356
|
+
# Also update expanded modals if they exist
|
|
1357
|
+
self._update_expanded_graph(
|
|
1358
|
+
update.new_size_history,
|
|
1359
|
+
update.original_size,
|
|
1360
|
+
update.runtime,
|
|
1361
|
+
)
|
|
1362
|
+
self._update_expanded_stats()
|
|
825
1363
|
self._latest_pass_stats = update.pass_stats
|
|
826
1364
|
self._current_pass_name = update.current_pass_name
|
|
827
1365
|
self._disabled_passes = update.disabled_passes
|
|
@@ -870,6 +1408,41 @@ class ShrinkRayApp(App[None]):
|
|
|
870
1408
|
except Exception:
|
|
871
1409
|
pass # Widget not yet mounted
|
|
872
1410
|
|
|
1411
|
+
def _update_expanded_graph(
|
|
1412
|
+
self,
|
|
1413
|
+
new_entries: list[tuple[float, int]],
|
|
1414
|
+
original_size: int,
|
|
1415
|
+
current_runtime: float,
|
|
1416
|
+
) -> None:
|
|
1417
|
+
"""Update the expanded graph if it exists in a modal screen."""
|
|
1418
|
+
# Check if there's an ExpandedBoxModal for the graph on the screen stack
|
|
1419
|
+
for screen in self.screen_stack:
|
|
1420
|
+
if isinstance(screen, ExpandedBoxModal):
|
|
1421
|
+
if screen._content_widget_id == "graph-container":
|
|
1422
|
+
expanded_graphs = list(
|
|
1423
|
+
screen.query("#expanded-graph").results(SizeGraph)
|
|
1424
|
+
)
|
|
1425
|
+
if expanded_graphs:
|
|
1426
|
+
expanded_graphs[0].update_graph(
|
|
1427
|
+
new_entries, original_size, current_runtime
|
|
1428
|
+
)
|
|
1429
|
+
break
|
|
1430
|
+
|
|
1431
|
+
def _update_expanded_stats(self) -> None:
|
|
1432
|
+
"""Update the expanded stats if it exists in a modal screen."""
|
|
1433
|
+
for screen in self.screen_stack:
|
|
1434
|
+
if isinstance(screen, ExpandedBoxModal):
|
|
1435
|
+
if screen._content_widget_id == "stats-container":
|
|
1436
|
+
stats_displays = list(
|
|
1437
|
+
self.query("#stats-display").results(StatsDisplay)
|
|
1438
|
+
)
|
|
1439
|
+
expanded_contents = list(
|
|
1440
|
+
screen.query("#expanded-content").results(Static)
|
|
1441
|
+
)
|
|
1442
|
+
if stats_displays and expanded_contents:
|
|
1443
|
+
expanded_contents[0].update(stats_displays[0].render())
|
|
1444
|
+
break
|
|
1445
|
+
|
|
873
1446
|
async def action_quit(self) -> None:
|
|
874
1447
|
"""Quit the application with graceful cancellation."""
|
|
875
1448
|
if self._client and not self._completed:
|