Browse activity dates from the keyboard (#92)
Add keyboard-driven date navigation to the TUI: - [ and ] keys select previous/next day, adjusting the visible range - g opens a date entry modal for direct date navigation - t returns to today's live view from any historical browsing - Background refresh preserves the browsed selection - Local-day widget shows data for the selected date - Updated help screen and action legend with new bindings - DatePickerScreen modal with input validation - 24 headless tests covering navigation, date entry, browse stability, local-day evidence, and constrained widths Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
co-authored by
CommandCodeBot
parent
e12f4a574c
commit
5df8a12339
+174
-10
@@ -26,7 +26,7 @@ from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
from .projection import (
|
||||
ConfidenceState,
|
||||
@@ -63,7 +63,7 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
||||
_ACTION_LEGEND = (
|
||||
"p Pause · " + _RESUME_HINT + "\n"
|
||||
"c Collect now · t Today · m Motion · d Disclosures · ? Help"
|
||||
"c Collect now · t Today · [ ] Day · g Date · m Motion · d Disclosures · ? Help"
|
||||
)
|
||||
|
||||
def _format_remaining(seconds: float) -> str:
|
||||
@@ -191,10 +191,11 @@ class DailyBarGraph(Widget):
|
||||
self,
|
||||
day_data: List[Dict[str, Any]],
|
||||
on_drill: Any = None,
|
||||
selected_date: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update graph with day data. on_drill(day) called on drill entry."""
|
||||
selected_day = None
|
||||
if 0 <= self.selected_index < len(self._day_data):
|
||||
selected_day = selected_date
|
||||
if selected_day is None and 0 <= self.selected_index < len(self._day_data):
|
||||
selected_day = self._day_data[self.selected_index].get("day")
|
||||
self._all_day_data = day_data
|
||||
self._on_drill = on_drill
|
||||
@@ -699,6 +700,42 @@ class DailyBarGraph(Widget):
|
||||
self._refresh_hourly()
|
||||
event.stop()
|
||||
|
||||
def navigate_to_date(self, date_str: str) -> bool:
|
||||
"""Navigate to a specific date, adjusting range if needed.
|
||||
|
||||
Returns True if the selection changed.
|
||||
"""
|
||||
idx = next(
|
||||
(i for i, d in enumerate(self._all_day_data)
|
||||
if d.get("day") == date_str),
|
||||
None,
|
||||
)
|
||||
if idx is None:
|
||||
return False
|
||||
|
||||
old_day = None
|
||||
if 0 <= self.selected_index < len(self._day_data):
|
||||
old_day = self._day_data[self.selected_index].get("day")
|
||||
|
||||
# Count actual data days (not gaps) after the target
|
||||
n_data_after = sum(
|
||||
1 for d in self._all_day_data[idx:]
|
||||
if d.get("sample_count", 0) > 0 or d.get("total_bytes", 0) > 0
|
||||
)
|
||||
if n_data_after > self.range_days:
|
||||
self.range_days = max(n_data_after, min(_RANGE_OPTIONS))
|
||||
|
||||
self._trim_to_range()
|
||||
if not self._day_data:
|
||||
return False
|
||||
self.selected_index = next(
|
||||
(i for i, d in enumerate(self._day_data)
|
||||
if d.get("day") == date_str),
|
||||
idx,
|
||||
)
|
||||
new_day = self._day_data[self.selected_index].get("day")
|
||||
return new_day != old_day
|
||||
|
||||
def on_click(self, event: Any) -> None:
|
||||
render = self.query_one("#bar-render")
|
||||
offset_x = event.x - render.region.x
|
||||
@@ -1333,6 +1370,7 @@ class HelpScreen(ModalScreen[None]):
|
||||
+ _ACTION_LEGEND + "\n"
|
||||
"q Quit the dashboard — monitoring continues\n"
|
||||
"Tab Focus the graph · ← → Select · 1-4 Change range\n"
|
||||
"[ Previous day · ] Next day · g Go to date\n"
|
||||
"Enter Show hours · Esc Return to days\n"
|
||||
"w Toggle W/R on live graph\n"
|
||||
"Theme changed via preferences file\n\n"
|
||||
@@ -1344,6 +1382,68 @@ class HelpScreen(ModalScreen[None]):
|
||||
self.dismiss()
|
||||
|
||||
|
||||
class DatePickerScreen(ModalScreen[str | None]):
|
||||
"""Modal screen for entering a date to browse."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
DatePickerScreen {
|
||||
align: center middle;
|
||||
}
|
||||
#date-picker-content {
|
||||
width: 40;
|
||||
height: auto;
|
||||
border: heavy $accent;
|
||||
padding: 1 2;
|
||||
}
|
||||
#date-input {
|
||||
width: 100%;
|
||||
}
|
||||
#date-error {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $error;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Container(
|
||||
Static("[bold]Go to date[/bold]\nEnter a local date (YYYY-MM-DD):", id="date-picker-header"),
|
||||
Input(placeholder="YYYY-MM-DD", id="date-input"),
|
||||
Static("", id="date-error"),
|
||||
Static("[dim]Enter Select · Esc Cancel[/dim]", id="date-picker-footer"),
|
||||
id="date-picker-content",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#date-input").focus()
|
||||
|
||||
def on_input_submitted(self, event: Any) -> None:
|
||||
value = event.value.strip()
|
||||
if not value:
|
||||
self.query_one("#date-error").update("[red]Please enter a date[/red]")
|
||||
return
|
||||
try:
|
||||
parsed = datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
self.query_one("#date-error").update(
|
||||
"[red]Invalid date format. Use YYYY-MM-DD.[/red]"
|
||||
)
|
||||
return
|
||||
if parsed > datetime.now().date():
|
||||
self.query_one("#date-error").update(
|
||||
"[red]Cannot browse future dates[/red]"
|
||||
)
|
||||
return
|
||||
self.dismiss(value)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main TUI App
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1451,6 +1551,9 @@ class FenrisTuiApp(App):
|
||||
Binding("c", "collect", "Collect now", show=False),
|
||||
Binding("d", "disclose", "Disclosures", show=False),
|
||||
Binding("t", "today", "Today", show=False),
|
||||
Binding("left_square_bracket", "prev_day", "Previous day", show=False),
|
||||
Binding("right_square_bracket", "next_day", "Next day", show=False),
|
||||
Binding("g", "go_to_date", "Go to date", show=False),
|
||||
Binding("m", "toggle_motion", "Motion", show=False),
|
||||
Binding("w", "toggle_measure", "W/R", show=False),
|
||||
Binding("?", "help", "Help", show=False),
|
||||
@@ -1485,6 +1588,9 @@ class FenrisTuiApp(App):
|
||||
# Track constrained state for resize handling (issue #81)
|
||||
self._is_constrained_mode = False
|
||||
|
||||
# Track the date the user is browsing (None = live/today view)
|
||||
self._browse_date: Optional[str] = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="dashboard-scroll"):
|
||||
with Container(id="main-grid"):
|
||||
@@ -1675,7 +1781,10 @@ class FenrisTuiApp(App):
|
||||
hour_data = _query_hourly_graph_data(
|
||||
conn, saved_drill_day, self._clock_now,
|
||||
)
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
graph.set_data(
|
||||
day_data, on_drill=self._on_graph_drill,
|
||||
selected_date=self._browse_date,
|
||||
)
|
||||
graph.view_mode = "hourly"
|
||||
graph.drill_day = saved_drill_day
|
||||
graph.set_hour_data(hour_data)
|
||||
@@ -1687,7 +1796,10 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
graph._refresh_hourly()
|
||||
else:
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
graph.set_data(
|
||||
day_data, on_drill=self._on_graph_drill,
|
||||
selected_date=self._browse_date,
|
||||
)
|
||||
|
||||
# Update constrained summary if in constrained mode (issue #81)
|
||||
if self._is_constrained_mode:
|
||||
@@ -1758,12 +1870,18 @@ class FenrisTuiApp(App):
|
||||
main_grid.refresh(layout=True)
|
||||
|
||||
def _render_local_day(self, conn: sqlite3.Connection) -> None:
|
||||
"""Render the local-day activity totals widget (issue #90)."""
|
||||
"""Render the local-day activity totals widget (issue #90, #92)."""
|
||||
try:
|
||||
from .tz_util import detect_system_tz
|
||||
from .local_day import query_current_local_day
|
||||
from .local_day import (
|
||||
query_current_local_day,
|
||||
query_local_day_summary,
|
||||
)
|
||||
tz_name = detect_system_tz()
|
||||
local = query_current_local_day(conn, self._clock_now, tz_name)
|
||||
if self._browse_date is not None:
|
||||
local = query_local_day_summary(conn, self._browse_date)
|
||||
else:
|
||||
local = query_current_local_day(conn, self._clock_now, tz_name)
|
||||
except Exception:
|
||||
local = None
|
||||
|
||||
@@ -1870,12 +1988,58 @@ class FenrisTuiApp(App):
|
||||
|
||||
def action_today(self) -> None:
|
||||
"""Return to today's live view from any historical browsing."""
|
||||
# Reset the daily graph to today's data
|
||||
self._browse_date = None
|
||||
graph = self.query_one("#usage-history")
|
||||
if graph.view_mode == "hourly":
|
||||
graph._exit_drill()
|
||||
# Reset selection to today (last day in the data)
|
||||
if graph._day_data:
|
||||
graph.selected_index = len(graph._day_data) - 1
|
||||
self._refresh()
|
||||
|
||||
def _browse_day(self, offset: int) -> None:
|
||||
"""Move selection by *offset* days from the current position."""
|
||||
graph = self.query_one("#usage-history")
|
||||
if not graph._day_data:
|
||||
return
|
||||
# Find the currently selected day's date string
|
||||
current_day = graph._day_data[graph.selected_index].get("day")
|
||||
if current_day is None:
|
||||
return
|
||||
# Compute target date
|
||||
current_date = datetime.strptime(current_day, "%Y-%m-%d").date()
|
||||
target_date = current_date + timedelta(days=offset)
|
||||
target_str = target_date.strftime("%Y-%m-%d")
|
||||
# Navigate to target, adjusting range if needed
|
||||
if graph.navigate_to_date(target_str):
|
||||
self._browse_date = target_str
|
||||
if graph.view_mode == "hourly":
|
||||
graph._exit_drill()
|
||||
self._refresh()
|
||||
|
||||
def action_prev_day(self) -> None:
|
||||
"""Select the previous (older) day."""
|
||||
self._browse_day(-1)
|
||||
|
||||
def action_next_day(self) -> None:
|
||||
"""Select the next (newer) day."""
|
||||
self._browse_day(1)
|
||||
|
||||
def _go_to_date_callback(self, date_str: str | None) -> None:
|
||||
"""Handle date picker result."""
|
||||
if date_str is None:
|
||||
return
|
||||
graph = self.query_one("#usage-history")
|
||||
if graph.view_mode == "hourly":
|
||||
graph._exit_drill()
|
||||
self._browse_date = date_str
|
||||
graph.navigate_to_date(date_str)
|
||||
self._refresh()
|
||||
|
||||
def action_go_to_date(self) -> None:
|
||||
"""Open the date entry screen."""
|
||||
self.push_screen(DatePickerScreen(), callback=self._go_to_date_callback)
|
||||
|
||||
def action_toggle_motion(self) -> None:
|
||||
"""Toggle reduced motion preference (issue #80)."""
|
||||
self._reduced_motion = not self._reduced_motion
|
||||
|
||||
Reference in New Issue
Block a user