"""Functions / classes used to create greatday's TUI."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Final, Sequence
from potoroo import TaggedRepo
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.widgets import Footer, Header, Input, Static
from .tag import GreatTag
from .todo import GreatTodo
from .types import SavedQueryGroup, SavedQueryGroupMap
# HACK: Used to fix action parameter parenthesis bug (see PR:textual#562).
_FAKE_RIGHT_PAREN: Final = "]]]"
# default saved query group
_DEFAULT_QUERY_GROUP: SavedQueryGroup = {
"queries": {"all": ""},
"default": "all",
}
# number of seconds in-between full TUI refreshes
_REFRESH_INTERVAL: Final = 60
[docs]@dataclass
class StatsGroup:
"""Todo query stats group.
Attributes:
all_stats: Stats on all todos matching this query.
done_stats: Stats on all completed todos matching this query.
open_stats: Stats on all un-completed (i.e. not done) todos matching
this query.
"""
all_stats: Stats
done_stats: Stats
open_stats: Stats
[docs] @classmethod
def from_todos(cls, todos: Sequence[GreatTodo] | None) -> StatsGroup:
"""Constructs a StatsGroup from an iterable of todos (or None)."""
if todos is not None:
all_todos = todos
else:
all_todos = []
all_count = len(all_todos)
all_points = sum(
int(todo.metadata.get("p" if todo.done else "xp", "0"))
for todo in all_todos
)
done_todos = [todo for todo in all_todos if todo.done]
done_count = len(done_todos)
done_points = sum(
int(todo.metadata.get("p", "0")) for todo in done_todos
)
open_todos = [todo for todo in all_todos if not todo.done]
open_count = len(open_todos)
open_points = sum(
int(todo.metadata.get("xp", "0")) for todo in open_todos
)
all_stats = Stats(count=all_count, points=all_points)
done_stats = Stats(count=done_count, points=done_points)
open_stats = Stats(count=open_count, points=open_points)
return cls(
all_stats=all_stats, done_stats=done_stats, open_stats=open_stats
)
[docs]@dataclass
class Stats:
"""Todo query stats.
Attributes:
count: number of todos matched by this query.
points: Sum of points (i.e. 'p') or expected points (i.e. 'xp') for
todos matching this query.
"""
count: int
points: int
[docs]@dataclass
class Context:
"""Mutable TUI Context.
Used to preserve state after closing the GreatApp instance, running vim,
and then opening a new GreatApp instance.
Attributes:
query: The active todo query string.
group_name: The name of the saved query group to use. This controls
which queries are bound to digits (i.e. 0-9) and shown in the stats
panel.
edit_todos: After closing the TUI, should we open up vim to edit
matching todos?
"""
query: str
group_name: str
edit_todos: bool = False
[docs]class GreatApp(App[str]):
"""Textual TUI Application Class."""
CSS = """
Screen {
layout: grid;
grid-size: 10;
grid-gutter: 2;
padding: 2;
}
#stats {
height: 100%;
column-span: 3;
row-span: 9;
}
#main {
height: 100%;
column-span: 7;
row-span: 9;
}
#command {
column-span: 10;
row-span: 1;
}
"""
BINDINGS = [
Binding(
"escape",
"change_mode('normal')",
description="Normal Mode",
priority=True,
),
Binding(
"enter", "submit", description="Submit", priority=True, show=False
),
Binding(
"!",
"change_query_group(0)",
description="Change to 1st query group.",
show=False,
),
Binding(
"@",
"change_query_group(1)",
description="Change to 2nd query group.",
show=False,
),
Binding(
"#",
"change_query_group(2)",
description="Change to 3rd query group.",
show=False,
),
Binding(
"$",
"change_query_group(3)",
description="Change to 4th query group.",
show=False,
),
Binding(
"%",
"change_query_group(4)",
description="Change to 5th query group.",
show=False,
),
Binding(
"^",
"change_query_group(5)",
description="Change to 6th query group.",
show=False,
),
Binding(
"&",
"change_query_group(6)",
description="Change to 7th query group.",
show=False,
),
Binding(
"*",
"change_query_group(7)",
description="Change to 8th query group.",
show=False,
),
Binding(
"(",
"change_query_group(8)",
description="Change to 9th query group.",
show=False,
),
Binding(
")",
"change_query_group(9)",
description="Change to 10th query group.",
show=False,
),
]
def __init__(
self,
*,
repo: TaggedRepo[str, GreatTodo, GreatTag],
ctx: Context,
saved_query_group_map: SavedQueryGroupMap,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.repo = repo
self.ctx = ctx
self.saved_query_group_map = saved_query_group_map
self.query_widget = Input(
name="query", id="query", value=self.ctx.query
)
self.command_widget = Input(name="command", id="command")
self.todo_widget = Static(
Panel(
_todo_lines_from_query(self.repo, self.ctx.query),
title="Todos",
),
name="todo",
id="todo",
)
self.stats_widget = StatsWidget(
self.repo, self.ctx, self.saved_query_group_map, id="stats"
)
[docs] def compose(self) -> ComposeResult:
"""Yields great widgets."""
yield GreatHeader()
yield self.stats_widget
yield Vertical(self.query_widget, self.todo_widget, id="main")
yield self.command_widget
yield GreatFooter()
[docs] async def on_load(self) -> None:
"""Configure key bindings."""
await self.bind_saved_queries(self.ctx.group_name)
self.bind("a", "add_todo", description="Add Todo")
self.bind("e", "edit", description="Edit Todos")
self.bind("i", "change_mode('insert')", description="Insert Mode")
self.bind("I", "clear_and_insert", description="Clear and Insert")
self.bind("r", "refresh", description="Refresh")
self.bind("q", "quit", description="Quit")
[docs] async def bind_saved_queries(self, group_name: str) -> None:
"""Binds saved queries in `group_name` to digits (i.e. 0-9)."""
for i, (name, query) in enumerate(
self.saved_query_group_map.get(group_name, _DEFAULT_QUERY_GROUP)[
"queries"
].items()
):
if i > 9:
break
query = query.replace(")", _FAKE_RIGHT_PAREN)
description = f"{name.upper()} Query"
self.bind(
str(i),
f"new_query('{query}')",
description=description,
show=False,
)
[docs] async def on_mount(self) -> None:
"""Configure layout."""
# do a full refresh of this widget every _REFRESH_INTERVAL seconds
self.set_interval(_REFRESH_INTERVAL, self.action_refresh)
[docs] async def action_add_todo(self) -> None:
"""Action to add a new todo to the inbox."""
self.command_widget.focus()
[docs] async def action_change_mode(self, mode: str) -> None:
"""Action to toggle to/from insert mode and other modes."""
self.query_widget.refresh()
if mode == "insert":
self.query_widget.focus()
elif mode == "normal":
self.stats_widget.focus()
else:
raise AssertionError(f"Bad mode: {mode!r}")
[docs] async def action_change_query_group(self, group_idx: int) -> None:
"""Changes the saved query group that is being used."""
# abort early if no query group change is required
group_name = list(self.saved_query_group_map.keys())[group_idx]
if self.ctx.group_name == group_name:
return
self.ctx.group_name = group_name
self.stats_widget.do_full_refresh = True
await self.bind_saved_queries(group_name)
query = get_default_query(self.saved_query_group_map, group_name)
await self.action_new_query(query)
[docs] async def action_clear_and_insert(self) -> None:
"""Clears input bar and enters Insert mode."""
self.query_widget.value = ""
await self.action_change_mode("insert")
[docs] async def action_edit(self) -> None:
"""Edits todos which match the current todo query."""
self.ctx.edit_todos = True
await self.action_quit()
[docs] async def action_new_query(self, query: str) -> None:
"""Execute a new todo query."""
query = query.replace(_FAKE_RIGHT_PAREN, ")")
self.query_widget.value = query
await self.action_submit()
[docs] async def action_refresh(self) -> None:
"""Full refresh of TUI (e.g. stats + main panel will reload)."""
# refresh stats panel
self.stats_widget.do_full_refresh = True
self.stats_widget.refresh()
# refresh main panel
text = _todo_lines_from_query(self.repo, self.ctx.query)
self.todo_widget.update(Panel(text, title="Todo List"))
[docs] async def action_submit(self) -> None:
"""Executes the current todo query shown in the input bar."""
todo_line = self.command_widget.value
self.command_widget.value = ""
if todo_line != "":
todo = GreatTodo.from_line(todo_line).unwrap()
self.repo.add(todo)
self.stats_widget.do_full_refresh = True
self.ctx.query = self.query_widget.value
self.stats_widget.refresh()
text = _todo_lines_from_query(self.repo, self.ctx.query)
self.todo_widget.update(Panel(text, title="Todo List"))
await self.action_change_mode("normal")
def _todo_lines_from_query(
repo: TaggedRepo[str, GreatTodo, GreatTag], query: str
) -> str:
tag = GreatTag.from_query(query)
todos = repo.get_by_tag(tag).unwrap()
result = ""
for todo in sorted(todos):
result += todo.to_line() + "\n"
return result
# TODO(bugyi): Convert to a method of a new SavedQueryGroupManager type.
[docs]def get_default_query(
saved_query_group_map: SavedQueryGroupMap, group_name: str
) -> str:
"""Returns the name of the configured default query for a query group.
Helper function that hides the details of parsing the SavedQueryGroup data
structure.
Args:
saved_query_group_map: Contains the query group we are interested in.
group_name: Name of the query group we are interested in.
"""
query_group = saved_query_group_map.get(group_name, _DEFAULT_QUERY_GROUP)
default_key = query_group["default"]
queries = query_group["queries"] if query_group["queries"] else {"all": ""}
if default_key not in queries:
default_key = list(queries.keys())[0]
query = queries[default_key]
return query