787 lines
27 KiB
Python
787 lines
27 KiB
Python
"""Week view grid widget for Calendar TUI.
|
|
|
|
Displays a week of calendar events in a grid layout where:
|
|
- Columns represent days (5 or 7)
|
|
- Rows represent time slots (30 minutes per row)
|
|
- Events span multiple rows proportionally to their duration
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import date, datetime, timedelta
|
|
from typing import List, Optional, Tuple
|
|
|
|
from rich.style import Style
|
|
from rich.segment import Segment
|
|
from textual.binding import Binding
|
|
from textual.containers import Vertical
|
|
from textual.geometry import Size
|
|
from textual.message import Message
|
|
from textual.reactive import reactive
|
|
from textual.scroll_view import ScrollView
|
|
from textual.strip import Strip
|
|
from textual.widget import Widget
|
|
|
|
from src.calendar.backend import Event
|
|
from src.calendar import config
|
|
|
|
|
|
# Column widths
|
|
TIME_COLUMN_WIDTH = 6 # "HH:MM "
|
|
MIN_DAY_COLUMN_WIDTH = 10 # Minimum width for each day column
|
|
DEFAULT_DAY_COLUMN_WIDTH = 20 # Default/preferred width for each day column
|
|
|
|
|
|
def get_rows_per_hour() -> int:
|
|
"""Get rows per hour from config."""
|
|
return 60 // config.minutes_per_row()
|
|
|
|
|
|
def get_total_rows() -> int:
|
|
"""Get total rows for 24 hours."""
|
|
return 24 * get_rows_per_hour()
|
|
|
|
|
|
def get_week_start_for_date(target_date: date) -> date:
|
|
"""Get the week start date for a given date based on config.
|
|
|
|
Config uses: 0=Sunday, 1=Monday, ..., 6=Saturday
|
|
Python weekday() uses: 0=Monday, ..., 6=Sunday
|
|
"""
|
|
week_start_cfg = config.week_start_day() # 0=Sunday, 1=Monday, etc.
|
|
python_weekday = target_date.weekday() # 0=Monday, 6=Sunday
|
|
|
|
# Convert config week start to python weekday
|
|
# Sunday(0) -> 6, Monday(1) -> 0, Tuesday(2) -> 1, etc.
|
|
python_week_start = (week_start_cfg - 1) % 7
|
|
|
|
# Calculate days since week start
|
|
days_since_week_start = (python_weekday - python_week_start) % 7
|
|
|
|
return target_date - timedelta(days=days_since_week_start)
|
|
|
|
|
|
def get_day_column_for_date(target_date: date, week_start: date) -> int:
|
|
"""Get the column index for a date within its week.
|
|
|
|
Returns the number of days since week_start.
|
|
"""
|
|
return (target_date - week_start).days
|
|
|
|
|
|
@dataclass
|
|
class DayColumn:
|
|
"""Events and layout for a single day column."""
|
|
|
|
day: date
|
|
events: List[Event] = field(default_factory=list)
|
|
# 2D grid: row -> list of events at that row
|
|
grid: List[List[Event]] = field(default_factory=list)
|
|
|
|
def __post_init__(self):
|
|
# Initialize grid with rows for 24 hours
|
|
self.grid = [[] for _ in range(get_total_rows())]
|
|
|
|
def layout_events(self) -> None:
|
|
"""Layout events handling overlaps."""
|
|
total_rows = get_total_rows()
|
|
minutes_per_row = config.minutes_per_row()
|
|
|
|
# Clear the grid
|
|
self.grid = [[] for _ in range(total_rows)]
|
|
|
|
# Sort events by start time, then by duration (longer first)
|
|
sorted_events = sorted(
|
|
self.events, key=lambda e: (e.start, -(e.end - e.start).total_seconds())
|
|
)
|
|
|
|
for event in sorted_events:
|
|
if event.all_day:
|
|
continue # Handle all-day events separately
|
|
|
|
start_row, end_row = event.get_row_span(minutes_per_row)
|
|
|
|
# Clamp to valid range
|
|
start_row = max(0, min(start_row, total_rows - 1))
|
|
end_row = max(start_row + 1, min(end_row, total_rows))
|
|
|
|
# Add event to each row it spans
|
|
for row in range(start_row, end_row):
|
|
if event not in self.grid[row]:
|
|
self.grid[row].append(event)
|
|
|
|
|
|
class WeekGridHeader(Widget):
|
|
"""Fixed header widget showing day names."""
|
|
|
|
DEFAULT_CSS = """
|
|
WeekGridHeader {
|
|
height: 1;
|
|
background: $surface;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
days: List[date],
|
|
cursor_col: int = 0,
|
|
include_weekends: bool = True,
|
|
name: Optional[str] = None,
|
|
id: Optional[str] = None,
|
|
) -> None:
|
|
super().__init__(name=name, id=id)
|
|
self._days = days
|
|
self._cursor_col = cursor_col
|
|
self._include_weekends = include_weekends
|
|
|
|
def update_days(self, days: List[date], cursor_col: int) -> None:
|
|
"""Update the displayed days."""
|
|
self._days = days
|
|
self._cursor_col = cursor_col
|
|
self.refresh()
|
|
|
|
def set_include_weekends(self, include_weekends: bool) -> None:
|
|
"""Update the include_weekends setting."""
|
|
self._include_weekends = include_weekends
|
|
self.refresh()
|
|
|
|
@property
|
|
def num_days(self) -> int:
|
|
return 7 if self._include_weekends else 5
|
|
|
|
def _get_day_column_width(self) -> int:
|
|
"""Calculate day column width based on available space."""
|
|
available_width = self.size.width - TIME_COLUMN_WIDTH
|
|
if available_width <= 0 or self.num_days == 0:
|
|
return DEFAULT_DAY_COLUMN_WIDTH
|
|
width_per_day = available_width // self.num_days
|
|
return max(MIN_DAY_COLUMN_WIDTH, width_per_day)
|
|
|
|
def _get_theme_color(self, color_name: str) -> str:
|
|
"""Get a color from the current theme."""
|
|
try:
|
|
theme = self.app.current_theme
|
|
color = getattr(theme, color_name, None)
|
|
if color:
|
|
return str(color)
|
|
except Exception:
|
|
pass
|
|
# Fallback colors
|
|
fallbacks = {
|
|
"secondary": "#81A1C1",
|
|
"primary": "#88C0D0",
|
|
"foreground": "#D8DEE9",
|
|
"surface": "#3B4252",
|
|
}
|
|
return fallbacks.get(color_name, "white")
|
|
|
|
def render_line(self, y: int) -> Strip:
|
|
"""Render the header row."""
|
|
day_col_width = self._get_day_column_width()
|
|
|
|
if y != 0:
|
|
return Strip.blank(TIME_COLUMN_WIDTH + (day_col_width * self.num_days))
|
|
|
|
segments = []
|
|
|
|
# Time column spacer
|
|
segments.append(Segment(" " * TIME_COLUMN_WIDTH))
|
|
|
|
# Get theme colors
|
|
secondary_color = self._get_theme_color("secondary")
|
|
|
|
# Day headers
|
|
today = date.today()
|
|
for i, day in enumerate(self._days):
|
|
day_name = day.strftime("%a %m/%d")
|
|
|
|
# Style based on selection and today
|
|
if i == self._cursor_col:
|
|
style = Style(bold=True, reverse=True)
|
|
elif day == today:
|
|
# Highlight today with theme secondary color
|
|
style = Style(bold=True, color="white", bgcolor=secondary_color)
|
|
elif day.weekday() >= 5: # Weekend
|
|
style = Style(color="bright_black")
|
|
else:
|
|
style = Style()
|
|
|
|
# Center the day name in the column
|
|
header = day_name.center(day_col_width)
|
|
segments.append(Segment(header, style))
|
|
|
|
return Strip(segments)
|
|
|
|
|
|
class WeekGridBody(ScrollView):
|
|
"""Scrollable body of the week grid showing time slots and events."""
|
|
|
|
# Reactive attributes
|
|
cursor_row: reactive[int] = reactive(0)
|
|
cursor_col: reactive[int] = reactive(0)
|
|
|
|
# Messages
|
|
class CursorMoved(Message):
|
|
"""Cursor position changed."""
|
|
|
|
def __init__(self, row: int, col: int) -> None:
|
|
super().__init__()
|
|
self.row = row
|
|
self.col = col
|
|
|
|
def __init__(
|
|
self,
|
|
include_weekends: bool = True,
|
|
name: Optional[str] = None,
|
|
id: Optional[str] = None,
|
|
classes: Optional[str] = None,
|
|
) -> None:
|
|
super().__init__(name=name, id=id, classes=classes)
|
|
self._days: List[DayColumn] = []
|
|
self._include_weekends = include_weekends
|
|
self._work_day_start = config.work_day_start_hour()
|
|
self._work_day_end = config.work_day_end_hour()
|
|
|
|
def _get_theme_color(self, color_name: str) -> str:
|
|
"""Get a color from the current theme."""
|
|
try:
|
|
theme = self.app.current_theme
|
|
color = getattr(theme, color_name, None)
|
|
if color:
|
|
return str(color)
|
|
except Exception:
|
|
pass
|
|
# Fallback colors
|
|
fallbacks = {
|
|
"secondary": "#81A1C1",
|
|
"primary": "#88C0D0",
|
|
"accent": "#B48EAD",
|
|
"foreground": "#D8DEE9",
|
|
"surface": "#3B4252",
|
|
"warning": "#EBCB8B",
|
|
"error": "#BF616A",
|
|
}
|
|
return fallbacks.get(color_name, "white")
|
|
|
|
@property
|
|
def num_days(self) -> int:
|
|
return 7 if self._include_weekends else 5
|
|
|
|
def _get_day_column_width(self) -> int:
|
|
"""Calculate day column width based on available space."""
|
|
available_width = self.size.width - TIME_COLUMN_WIDTH
|
|
if available_width <= 0 or self.num_days == 0:
|
|
return DEFAULT_DAY_COLUMN_WIDTH
|
|
width_per_day = available_width // self.num_days
|
|
return max(MIN_DAY_COLUMN_WIDTH, width_per_day)
|
|
|
|
@property
|
|
def content_width(self) -> int:
|
|
return TIME_COLUMN_WIDTH + (self._get_day_column_width() * self.num_days)
|
|
|
|
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
|
return get_total_rows()
|
|
|
|
def on_mount(self) -> None:
|
|
"""Set up virtual size for scrolling."""
|
|
self._update_virtual_size()
|
|
|
|
def _update_virtual_size(self) -> None:
|
|
"""Update the virtual size based on content dimensions."""
|
|
self.virtual_size = Size(self.content_width, get_total_rows())
|
|
|
|
def set_days(self, days: List[DayColumn]) -> None:
|
|
"""Set the day columns to display."""
|
|
self._days = days
|
|
self._update_virtual_size()
|
|
self.refresh()
|
|
|
|
def set_include_weekends(self, include_weekends: bool) -> None:
|
|
"""Update the include_weekends setting."""
|
|
self._include_weekends = include_weekends
|
|
self._update_virtual_size()
|
|
self.refresh()
|
|
|
|
def watch_cursor_row(self, old: int, new: int) -> None:
|
|
"""Handle cursor row changes."""
|
|
total_rows = get_total_rows()
|
|
# Clamp cursor row
|
|
if new < 0:
|
|
self.cursor_row = 0
|
|
elif new >= total_rows:
|
|
self.cursor_row = total_rows - 1
|
|
else:
|
|
# Scroll to keep cursor visible with a 2-row margin from viewport edges
|
|
self._scroll_to_keep_cursor_visible(new)
|
|
self.post_message(self.CursorMoved(new, self.cursor_col))
|
|
self.refresh()
|
|
|
|
def _scroll_to_keep_cursor_visible(self, cursor_row: int) -> None:
|
|
"""Scroll viewport only when cursor gets within 2 rows of the edge."""
|
|
margin = 2 # Number of rows to keep between cursor and viewport edge
|
|
|
|
scroll_y = int(self.scroll_offset.y)
|
|
viewport_height = self.size.height
|
|
|
|
# Calculate visible range
|
|
visible_top = scroll_y
|
|
visible_bottom = scroll_y + viewport_height - 1
|
|
|
|
# Check if cursor is too close to the top edge
|
|
if cursor_row < visible_top + margin:
|
|
# Scroll up to keep margin above cursor
|
|
new_scroll_y = max(0, cursor_row - margin)
|
|
self.scroll_to(y=new_scroll_y, animate=False)
|
|
# Check if cursor is too close to the bottom edge
|
|
elif cursor_row > visible_bottom - margin:
|
|
# Scroll down to keep margin below cursor
|
|
new_scroll_y = cursor_row - viewport_height + margin + 1
|
|
self.scroll_to(y=new_scroll_y, animate=False)
|
|
|
|
def watch_cursor_col(self, old: int, new: int) -> None:
|
|
"""Handle cursor column changes."""
|
|
self.post_message(self.CursorMoved(self.cursor_row, new))
|
|
self.refresh()
|
|
|
|
def render_line(self, y: int) -> Strip:
|
|
"""Render a single line of the grid."""
|
|
scroll_y = int(self.scroll_offset.y)
|
|
row_index = y + scroll_y
|
|
|
|
total_rows = get_total_rows()
|
|
if row_index < 0 or row_index >= total_rows:
|
|
return Strip.blank(self.content_width)
|
|
|
|
return self._render_time_row(row_index)
|
|
|
|
def _render_time_row(self, row_index: int) -> Strip:
|
|
"""Render a time row with events."""
|
|
rows_per_hour = get_rows_per_hour()
|
|
minutes_per_row = config.minutes_per_row()
|
|
segments = []
|
|
|
|
# Check if this is the current time row
|
|
now = datetime.now()
|
|
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
|
is_current_time_row = row_index == current_row
|
|
|
|
# Check if cursor is on this row
|
|
is_cursor_row = row_index == self.cursor_row
|
|
|
|
# Time label (only show on the hour)
|
|
if row_index % rows_per_hour == 0:
|
|
hour = row_index // rows_per_hour
|
|
time_str = f"{hour:02d}:00 "
|
|
else:
|
|
time_str = " " # Blank for half-hour
|
|
|
|
# Style time label - highlight current time or cursor, dim outside work hours
|
|
if is_cursor_row:
|
|
# Highlight the hour label when cursor is on this row
|
|
primary_color = self._get_theme_color("primary")
|
|
time_style = Style(color=primary_color, bold=True, reverse=True)
|
|
elif is_current_time_row:
|
|
error_color = self._get_theme_color("error")
|
|
# Add subtle background to current time row for better visibility
|
|
surface_color = self._get_theme_color("surface")
|
|
time_style = Style(color=error_color, bold=True, bgcolor=surface_color)
|
|
elif (
|
|
row_index < self._work_day_start * rows_per_hour
|
|
or row_index >= self._work_day_end * rows_per_hour
|
|
):
|
|
time_style = Style(color="bright_black")
|
|
else:
|
|
primary_color = self._get_theme_color("primary")
|
|
time_style = Style(color=primary_color)
|
|
|
|
segments.append(Segment(time_str, time_style))
|
|
|
|
# Event cells for each day
|
|
for col_idx, day_col in enumerate(self._days):
|
|
cell_text, cell_style = self._render_event_cell(
|
|
day_col, row_index, col_idx, is_current_time_row
|
|
)
|
|
segments.append(Segment(cell_text, cell_style))
|
|
|
|
return Strip(segments)
|
|
|
|
def _render_event_cell(
|
|
self,
|
|
day_col: DayColumn,
|
|
row_index: int,
|
|
col_idx: int,
|
|
is_current_time_row: bool = False,
|
|
) -> Tuple[str, Style]:
|
|
"""Render a single cell for a day/time slot."""
|
|
events_at_row = day_col.grid[row_index] if row_index < len(day_col.grid) else []
|
|
rows_per_hour = get_rows_per_hour()
|
|
minutes_per_row = config.minutes_per_row()
|
|
day_col_width = self._get_day_column_width()
|
|
|
|
is_cursor = col_idx == self.cursor_col and row_index == self.cursor_row
|
|
|
|
# Get colors for current time line
|
|
error_color = self._get_theme_color("error") if is_current_time_row else None
|
|
|
|
if not events_at_row:
|
|
# Empty cell
|
|
if is_cursor:
|
|
return ">" + " " * (day_col_width - 1), Style(reverse=True)
|
|
elif is_current_time_row:
|
|
# Current time indicator line
|
|
return "─" * day_col_width, Style(color=error_color, bold=True)
|
|
else:
|
|
# Grid line style
|
|
if row_index % rows_per_hour == 0:
|
|
return "-" * day_col_width, Style(color="bright_black")
|
|
else:
|
|
return " " * day_col_width, Style()
|
|
|
|
# Get the event to display (first one if multiple)
|
|
event = events_at_row[0]
|
|
|
|
# Determine if this is the start row for this event
|
|
start_row, _ = event.get_row_span(minutes_per_row)
|
|
is_start = row_index == max(0, start_row)
|
|
|
|
# Build cell text
|
|
if is_start:
|
|
# Show event title with time
|
|
time_str = event.start.strftime("%H:%M")
|
|
title = event.title[: day_col_width - 7] # Leave room for time
|
|
cell_text = f"{time_str} {title}"
|
|
else:
|
|
# Continuation of event
|
|
cell_text = "│ " + event.title[: day_col_width - 3]
|
|
|
|
# Pad/truncate to column width
|
|
cell_text = cell_text[:day_col_width].ljust(day_col_width)
|
|
|
|
# Style based on event and cursor
|
|
if is_cursor:
|
|
style = Style(bold=True, reverse=True)
|
|
elif len(events_at_row) > 1:
|
|
# Overlapping events - use warning color
|
|
warning_color = self._get_theme_color("warning")
|
|
style = Style(bgcolor=warning_color, color="black")
|
|
else:
|
|
# Normal event - use primary color
|
|
primary_color = self._get_theme_color("primary")
|
|
style = Style(bgcolor=primary_color, color="black")
|
|
|
|
return cell_text, style
|
|
|
|
def get_event_at_cursor(self) -> Optional[Event]:
|
|
"""Get the event at the current cursor position."""
|
|
if self.cursor_col < 0 or self.cursor_col >= len(self._days):
|
|
return None
|
|
|
|
day_col = self._days[self.cursor_col]
|
|
if self.cursor_row < 0 or self.cursor_row >= len(day_col.grid):
|
|
return None
|
|
|
|
events = day_col.grid[self.cursor_row]
|
|
return events[0] if events else None
|
|
|
|
|
|
class WeekGrid(Vertical):
|
|
"""Week view calendar grid widget with fixed header."""
|
|
|
|
DEFAULT_CSS = """
|
|
WeekGrid {
|
|
height: 1fr;
|
|
}
|
|
|
|
WeekGridHeader {
|
|
height: 1;
|
|
}
|
|
|
|
WeekGridBody {
|
|
height: 1fr;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("j", "cursor_down", "Down", show=False),
|
|
Binding("k", "cursor_up", "Up", show=False),
|
|
Binding("h", "cursor_left", "Left", show=False),
|
|
Binding("l", "cursor_right", "Right", show=False),
|
|
Binding("H", "prev_week", "Prev Week", show=True),
|
|
Binding("L", "next_week", "Next Week", show=True),
|
|
Binding("g", "goto_today", "Today", show=True),
|
|
Binding("enter", "select_event", "View", show=True),
|
|
]
|
|
|
|
# Reactive attributes
|
|
week_start: reactive[date] = reactive(date.today)
|
|
include_weekends: reactive[bool] = reactive(True)
|
|
|
|
# Messages
|
|
class EventSelected(Message):
|
|
"""Event was selected."""
|
|
|
|
def __init__(self, event: Event) -> None:
|
|
super().__init__()
|
|
self.event = event
|
|
|
|
class WeekChanged(Message):
|
|
"""Week was changed via navigation."""
|
|
|
|
def __init__(self, week_start: date) -> None:
|
|
super().__init__()
|
|
self.week_start = week_start
|
|
|
|
def __init__(
|
|
self,
|
|
week_start: Optional[date] = None,
|
|
include_weekends: bool = True,
|
|
name: Optional[str] = None,
|
|
id: Optional[str] = None,
|
|
classes: Optional[str] = None,
|
|
) -> None:
|
|
super().__init__(name=name, id=id, classes=classes)
|
|
|
|
# Initialize state BEFORE setting reactive attributes
|
|
self._days: List[DayColumn] = []
|
|
self._events_by_date: dict[date, List[Event]] = {}
|
|
self._header: Optional[WeekGridHeader] = None
|
|
self._body: Optional[WeekGridBody] = None
|
|
|
|
# Set week start based on config.week_start_day() if not provided
|
|
if week_start is None:
|
|
today = date.today()
|
|
week_start = get_week_start_for_date(today)
|
|
|
|
self.include_weekends = include_weekends
|
|
self.week_start = week_start
|
|
|
|
@property
|
|
def num_days(self) -> int:
|
|
return 7 if self.include_weekends else 5
|
|
|
|
@property
|
|
def cursor_row(self) -> int:
|
|
"""Get current cursor row."""
|
|
if self._body:
|
|
return self._body.cursor_row
|
|
return 0
|
|
|
|
@cursor_row.setter
|
|
def cursor_row(self, value: int) -> None:
|
|
"""Set cursor row."""
|
|
if self._body:
|
|
self._body.cursor_row = value
|
|
|
|
@property
|
|
def cursor_col(self) -> int:
|
|
"""Get current cursor column."""
|
|
if self._body:
|
|
return self._body.cursor_col
|
|
return 0
|
|
|
|
@cursor_col.setter
|
|
def cursor_col(self, value: int) -> None:
|
|
"""Set cursor column."""
|
|
if self._body:
|
|
self._body.cursor_col = value
|
|
if self._header:
|
|
self._header.update_days([d.day for d in self._days], value)
|
|
|
|
def compose(self):
|
|
"""Compose the widget."""
|
|
days = [d.day for d in self._days] if self._days else []
|
|
self._header = WeekGridHeader(
|
|
days=days,
|
|
cursor_col=0,
|
|
include_weekends=self.include_weekends,
|
|
)
|
|
self._body = WeekGridBody(
|
|
include_weekends=self.include_weekends,
|
|
)
|
|
yield self._header
|
|
yield self._body
|
|
|
|
def on_mount(self) -> None:
|
|
"""Initialize on mount - set cursor to current day/time."""
|
|
self._init_week()
|
|
self.goto_now()
|
|
|
|
def _init_week(self) -> None:
|
|
"""Initialize the week's day columns."""
|
|
self._days = []
|
|
# Always iterate through 7 days from week_start
|
|
for i in range(7):
|
|
day = self.week_start + timedelta(days=i)
|
|
# Skip weekend days (Saturday=5, Sunday=6) when not including weekends
|
|
if not self.include_weekends and day.weekday() >= 5:
|
|
continue
|
|
col = DayColumn(day=day)
|
|
if day in self._events_by_date:
|
|
col.events = self._events_by_date[day]
|
|
col.layout_events()
|
|
self._days.append(col)
|
|
|
|
# Update child widgets
|
|
if self._header:
|
|
self._header.update_days(
|
|
[d.day for d in self._days], self._body.cursor_col if self._body else 0
|
|
)
|
|
if self._body:
|
|
self._body.set_days(self._days)
|
|
|
|
def set_events(self, events_by_date: dict[date, List[Event]]) -> None:
|
|
"""Set the events to display."""
|
|
self._events_by_date = events_by_date
|
|
self._init_week()
|
|
self.refresh()
|
|
|
|
def goto_now(self) -> None:
|
|
"""Set cursor to current day and time, scroll to work day start."""
|
|
today = date.today()
|
|
now = datetime.now()
|
|
rows_per_hour = get_rows_per_hour()
|
|
minutes_per_row = config.minutes_per_row()
|
|
|
|
# Set week to contain today using configurable week start day
|
|
week_start_date = get_week_start_for_date(today)
|
|
if self.week_start != week_start_date:
|
|
self.week_start = week_start_date
|
|
|
|
# Set cursor column to today (relative to week start)
|
|
col = get_day_column_for_date(today, self.week_start)
|
|
if not self.include_weekends and col >= 5:
|
|
col = 4 # Last weekday if weekend
|
|
self.cursor_col = col
|
|
|
|
# Set cursor row to current time
|
|
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
|
self.cursor_row = current_row
|
|
|
|
# Always scroll to work day start initially (e.g., 7am)
|
|
if self._body:
|
|
work_start_row = config.work_day_start_hour() * rows_per_hour
|
|
self._body.scroll_to(y=work_start_row, animate=False)
|
|
|
|
def watch_week_start(self, old: date, new: date) -> None:
|
|
"""Handle week_start changes."""
|
|
self._init_week()
|
|
self.post_message(self.WeekChanged(new))
|
|
self.refresh()
|
|
|
|
def watch_include_weekends(self, old: bool, new: bool) -> None:
|
|
"""Handle include_weekends changes."""
|
|
if self._header:
|
|
self._header.set_include_weekends(new)
|
|
if self._body:
|
|
self._body.set_include_weekends(new)
|
|
self._init_week()
|
|
self.refresh()
|
|
|
|
def on_week_grid_body_cursor_moved(self, message: WeekGridBody.CursorMoved) -> None:
|
|
"""Handle cursor moves in body - update header."""
|
|
if self._header:
|
|
self._header.update_days([d.day for d in self._days], message.col)
|
|
|
|
def get_event_at_cursor(self) -> Optional[Event]:
|
|
"""Get the event at the current cursor position."""
|
|
if self._body:
|
|
return self._body.get_event_at_cursor()
|
|
return None
|
|
|
|
def get_cursor_date(self) -> date:
|
|
"""Get the date at the current cursor column."""
|
|
if self._days and 0 <= self.cursor_col < len(self._days):
|
|
return self._days[self.cursor_col].day
|
|
return date.today()
|
|
|
|
def get_cursor_time(self):
|
|
"""Get the time at the current cursor row.
|
|
|
|
Returns:
|
|
A time object for the cursor row position.
|
|
"""
|
|
from datetime import time as time_type
|
|
|
|
minutes_per_row = config.minutes_per_row()
|
|
total_minutes = self.cursor_row * minutes_per_row
|
|
hour = total_minutes // 60
|
|
minute = total_minutes % 60
|
|
# Clamp to valid range
|
|
hour = max(0, min(23, hour))
|
|
minute = max(0, min(59, minute))
|
|
return time_type(hour, minute)
|
|
|
|
# Actions
|
|
def action_cursor_down(self) -> None:
|
|
"""Move cursor down."""
|
|
if self._body:
|
|
self._body.cursor_row += 1
|
|
|
|
def action_cursor_up(self) -> None:
|
|
"""Move cursor up."""
|
|
if self._body:
|
|
self._body.cursor_row -= 1
|
|
|
|
def action_cursor_left(self) -> None:
|
|
"""Move cursor left (wraps to previous week)."""
|
|
if self._body:
|
|
if self._body.cursor_col <= 0:
|
|
# Wrap to previous week
|
|
self._body.cursor_col = self.num_days - 1
|
|
self.action_prev_week()
|
|
else:
|
|
self._body.cursor_col -= 1
|
|
if self._header:
|
|
self._header.update_days(
|
|
[d.day for d in self._days], self._body.cursor_col
|
|
)
|
|
|
|
def action_cursor_right(self) -> None:
|
|
"""Move cursor right (wraps to next week)."""
|
|
if self._body:
|
|
if self._body.cursor_col >= self.num_days - 1:
|
|
# Wrap to next week
|
|
self._body.cursor_col = 0
|
|
self.action_next_week()
|
|
else:
|
|
self._body.cursor_col += 1
|
|
if self._header:
|
|
self._header.update_days(
|
|
[d.day for d in self._days], self._body.cursor_col
|
|
)
|
|
|
|
def action_prev_week(self) -> None:
|
|
"""Navigate to previous week."""
|
|
self.week_start = self.week_start - timedelta(weeks=1)
|
|
|
|
def action_next_week(self) -> None:
|
|
"""Navigate to next week."""
|
|
self.week_start = self.week_start + timedelta(weeks=1)
|
|
|
|
def action_goto_today(self) -> None:
|
|
"""Navigate to current week and today's column/time."""
|
|
self.goto_now()
|
|
|
|
def action_select_event(self) -> None:
|
|
"""Select the event at cursor."""
|
|
event = self.get_event_at_cursor()
|
|
if event:
|
|
self.post_message(self.EventSelected(event))
|
|
|
|
def goto_date(self, target_date: date) -> None:
|
|
"""Navigate to a specific date.
|
|
|
|
Sets the week to contain the target date and places cursor on that day.
|
|
"""
|
|
# Get the week start for the target date
|
|
week_start_date = get_week_start_for_date(target_date)
|
|
|
|
if self.week_start != week_start_date:
|
|
self.week_start = week_start_date
|
|
|
|
# Set cursor column to the target date
|
|
col = get_day_column_for_date(target_date, self.week_start)
|
|
if not self.include_weekends and col >= 5:
|
|
col = 4 # Last weekday if weekend
|
|
self.cursor_col = col
|