diff --git a/README.md b/README.md index 3090f07..70b34bc 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,17 @@ or view a result directory: tbview path/to/events/dir ``` +## Controls (in viewer) + +- **Switch tag**: Up/Down, PgUp/PgDn, Home/End, Tab/Shift-Tab, or `[` / `]` +- **Quick-select tag 1-9**: number keys `1`-`9` +- **Toggle smoothing**: `s` +- **Toggle X axis**: `m` +- **Set xlim (steps)**: `x` then type `start:end` (ESC cancels) +- **Set ylim**: `y` then type `min:max` (ESC cancels) +- **Back to selection**: `q` +- **Quit**: Ctrl+C + ## Acknowledgement This project is still in progress, and some features may not be complete. diff --git a/tbview/dashing_lib/widgets.py b/tbview/dashing_lib/widgets.py index 34111cb..c4475c1 100644 --- a/tbview/dashing_lib/widgets.py +++ b/tbview/dashing_lib/widgets.py @@ -36,6 +36,7 @@ def __init__(self, options, current=0, color=0, *args, **kw): super().__init__('', color, *args, **kw) self._current = current self._options = options + self._scroll_offset = 0 @property def current(self): @@ -43,6 +44,18 @@ def current(self): @current.setter def current(self, c): + try: + c = int(c) + except Exception: + c = 0 + if not self._options: + self._current = 0 + self._scroll_offset = 0 + return + if c < 0: + c = 0 + if c >= len(self._options): + c = len(self._options) - 1 self._current = c @property @@ -52,6 +65,19 @@ def options(self): @options.setter def options(self, options): self._options = options + # Keep selection and scroll offset in a valid range. + if not self._options: + self._current = 0 + self._scroll_offset = 0 + else: + if self._current < 0: + self._current = 0 + if self._current >= len(self._options): + self._current = len(self._options) - 1 + if self._scroll_offset < 0: + self._scroll_offset = 0 + if self._scroll_offset >= len(self._options): + self._scroll_offset = max(0, len(self._options) - 1) def _apply_options_to_text(self, tbox:TBox): t = tbox.t @@ -65,10 +91,24 @@ def _display(self, tbox, parent): # Render options without wrapping to avoid breaking ANSI sequences tbox = self._draw_borders_and_title(tbox) t = tbox.t + # Ensure current selection is visible within the viewport. + viewport_h = max(0, tbox.h) + if self._options and viewport_h > 0: + if self._current < self._scroll_offset: + self._scroll_offset = self._current + elif self._current >= self._scroll_offset + viewport_h: + self._scroll_offset = self._current - viewport_h + 1 + max_offset = max(0, len(self._options) - viewport_h) + if self._scroll_offset > max_offset: + self._scroll_offset = max_offset + dx = 0 - for i, opt in enumerate(self._options): + start = self._scroll_offset + end = len(self._options) + for i in range(start, end): if dx >= tbox.h: break + opt = self._options[i] visible_text = opt[:tbox.w] styled = (t.on_white if i == self._current else t.white)(visible_text) print( diff --git a/tbview/viewer.py b/tbview/viewer.py index 79c7db9..2210b7d 100644 --- a/tbview/viewer.py +++ b/tbview/viewer.py @@ -44,7 +44,19 @@ def __init__(self, event_path, event_tag) -> None: self.ui = RatioHSplit( PlotextTile(self.plot, title='Plot', border_color=15), RatioVSplit( - Text(" 1.Press arrow keys to locate coordinates.\n\n 2.Use number 1-9 or to select tag.\n\n 3.Press 'q' to go back to selection.\n\n 4.Ctrl+C to quit.\n\n 5.Press 's' to toggle smoothing (0/10/50/100/200).\n\n 6.Press 'm' to toggle X axis (step/rel/abs).\n\n 7.Press 'x' to set xlim in steps (start:end), ESC to cancel.\n\n 8.Press 'y' to set ylim (min:max), ESC to cancel.", color=15, title=' Tips', border_color=15), + Text( + " 1.Use Up/Down, PgUp/PgDn, Home/End, Tab/Shift-Tab, or [ / ] to switch tags.\n\n" + " 2.Use number 1-9 to quick-select tags 1-9.\n\n" + " 3.Press 'q' to go back to selection.\n\n" + " 4.Ctrl+C to quit.\n\n" + " 5.Press 's' to toggle smoothing (0/10/50/100/200).\n\n" + " 6.Press 'm' to toggle X axis (step/rel/abs).\n\n" + " 7.Press 'x' to set xlim in steps (start:end), ESC to cancel.\n\n" + " 8.Press 'y' to set ylim (min:max), ESC to cancel.", + color=15, + title=' Tips', + border_color=15 + ), self.tag_selector, self.logger, ratios=(2, 4, 2), @@ -73,6 +85,31 @@ def __init__(self, event_path, event_tag) -> None: self._quit_and_reselect = False self.scan_events(initial=True) + def _tag_count(self): + return len(self.tag_selector.options or []) + + def _select_tag_index(self, idx): + n = self._tag_count() + if n <= 0: + self.tag_selector.current = 0 + return + if idx < 0: + idx = 0 + if idx >= n: + idx = n - 1 + self.tag_selector.current = idx + + def _move_tag_selection(self, delta, wrap=False): + n = self._tag_count() + if n <= 0: + self.tag_selector.current = 0 + return + cur = int(getattr(self.tag_selector, 'current', 0) or 0) + nxt = cur + int(delta) + if wrap: + nxt %= n + self._select_tag_index(nxt) + def scan_events(self, initial=False): import os, time @@ -176,12 +213,32 @@ def handle_input(self, key): return if key.is_sequence: - pass + name = getattr(key, 'name', '') + if name in ('KEY_UP',): + self._move_tag_selection(-1, wrap=False) + elif name in ('KEY_DOWN',): + self._move_tag_selection(+1, wrap=False) + elif name in ('KEY_PGUP', 'KEY_PAGEUP'): + self._move_tag_selection(-10, wrap=False) + elif name in ('KEY_PGDOWN', 'KEY_PAGEDOWN'): + self._move_tag_selection(+10, wrap=False) + elif name in ('KEY_HOME',): + self._select_tag_index(0) + elif name in ('KEY_END',): + self._select_tag_index(self._tag_count() - 1) + elif name in ('KEY_TAB',): + self._move_tag_selection(+1, wrap=True) + elif name in ('KEY_BTAB',): + self._move_tag_selection(-1, wrap=True) else: if key.isdigit(): digit = int(key) if digit > 0 and digit <= len(self.tag_selector.options): self.tag_selector.current = digit - 1 + elif str(key) == '[': + self._move_tag_selection(-1, wrap=True) + elif str(key) == ']': + self._move_tag_selection(+1, wrap=True) elif str(key).lower() == 's': self.smoothing_index = (self.smoothing_index + 1) % len(self.smoothing_levels) self.smoothing_window = self.smoothing_levels[self.smoothing_index] @@ -206,6 +263,22 @@ def handle_input(self, key): def log(self, msg, level=''): self.logger.append(self.term.white(f'{level} {msg}')) + def _truncate_label(self, label, max_width): + """Truncate label to fit max_width, showing first N and last N characters.""" + if len(label) <= max_width: + return label + # Reserve space for "..." + ellipsis_len = 3 + # Calculate how many characters we can show on each side + # We want to show equal amounts on both sides + available_chars = max_width - ellipsis_len + if available_chars < 2: + # Too narrow, just truncate + return label[:max_width] + n = available_chars // 2 + # Show first n and last n characters with "..." in between + return label[:n] + "..." + label[-n:] + def plot(self, tbox): import time t0 = time.perf_counter() @@ -312,6 +385,10 @@ def plot(self, tbox): extra_parts.append(speed_str) if extra_parts: plot_label = f"{plot_label} (" + ", ".join(extra_parts) + ")" + # Truncate label if it doesn't fit on screen + # Estimate available width: leave ~30% for plot, rest for legend + max_label_width = max(20, int(tbox.w * 0.7)) + plot_label = self._truncate_label(plot_label, max_label_width) plt.plot(x_vals, values, label=plot_label, color=color) except Exception: plt.plot(x_vals, values, color=color)