WIP
This commit is contained in:
751
src/calendar/widgets/WeekGrid.py
Normal file
751
src/calendar/widgets/WeekGrid.py
Normal file
@@ -0,0 +1,751 @@
|
||||
"""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
|
||||
|
||||
# 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, dim outside work hours
|
||||
if is_current_time_row:
|
||||
secondary_color = self._get_theme_color("secondary")
|
||||
time_style = Style(color=secondary_color, bold=True)
|
||||
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)
|
||||
segments.append(Segment(cell_text, cell_style))
|
||||
|
||||
return Strip(segments)
|
||||
|
||||
def _render_event_cell(
|
||||
self, day_col: DayColumn, row_index: int, col_idx: int
|
||||
) -> 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
|
||||
|
||||
if not events_at_row:
|
||||
# Empty cell
|
||||
if is_cursor:
|
||||
return ">" + " " * (day_col_width - 1), Style(reverse=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
|
||||
|
||||
# Scroll to show work day start initially
|
||||
if self._body:
|
||||
work_start_row = config.work_day_start_hour() * rows_per_hour
|
||||
# If current time is before work day start, scroll to work day start
|
||||
# Otherwise scroll to show current time
|
||||
scroll_target = min(work_start_row, current_row)
|
||||
self._body.scroll_to(y=scroll_target, 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))
|
||||
Reference in New Issue
Block a user