From 9bda0245319a214049ced1613e9525ac67438b6f Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Tue, 2 Jun 2026 14:53:38 +0200 Subject: [PATCH 01/27] Removed some dead code from the repo. --- poetry.lock | 16 ++- pyproject.toml | 2 + src/org/color.py | 65 ---------- src/org/commands/agenda/layout.py | 6 +- src/org/commands/interactive_common.py | 16 --- src/org/commands/stats/all.py | 143 --------------------- src/org/commands/tasks/common.py | 11 -- src/org/commands/tasks/list/command.py | 14 -- src/org/commands/tasks/query.py | 8 -- tests/commands/tasks/test_tasks_list.py | 2 - tests/commands/test_tasks_query_command.py | 2 - tests/conftest.py | 26 ---- 12 files changed, 17 insertions(+), 294 deletions(-) diff --git a/poetry.lock b/poetry.lock index b90ec9f..e386207 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "annotated-doc" @@ -892,7 +892,19 @@ files = [ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "vulture" +version = "2.16" +description = "Find dead code" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee"}, + {file = "vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717"}, +] + [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "bbadb8e532df0da51683a02aca1202ef717cde43671876a0a51827330a9ba09a" +content-hash = "e18e5231f4497bc416395d18cace6e19454545cbcf462e8dfbe18b435759dabd" diff --git a/pyproject.toml b/pyproject.toml index 1422327..7370638 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ mypy = "^1.18.1" taskipy = "^1.14.1" pyright = "^1.1.408" types-pyyaml = "^6.0.12.20260408" +vulture = "^2.16" [tool.poetry.scripts] org = "org.cli:main" @@ -39,6 +40,7 @@ lint-fix = "ruff check --fix src/ tests/" format-check = "ruff format -q --check src/ tests/" format = "ruff format src/ tests/" type = "mypy src/org tests" +dead-code = "vulture src/org tests" check = "task format-check && task lint && task type && task test" check-cov = "task format-check && task lint && task type && task test-cov" diff --git a/src/org/color.py b/src/org/color.py index f521ec8..a03a251 100644 --- a/src/org/color.py +++ b/src/org/color.py @@ -63,19 +63,6 @@ def bright_white(text: str, enabled: bool) -> str: return colorize(text, "bold white", enabled) -def white(text: str, enabled: bool) -> str: - """Apply white color to text (default, usually no-op). - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "white", enabled) - - def dim_white(text: str, enabled: bool) -> str: """Apply dim white color to text. @@ -102,58 +89,6 @@ def magenta(text: str, enabled: bool) -> str: return colorize(text, "magenta", enabled) -def green(text: str, enabled: bool) -> str: - """Apply green color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "green", enabled) - - -def bright_green(text: str, enabled: bool) -> str: - """Apply bright green color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold green", enabled) - - -def bright_red(text: str, enabled: bool) -> str: - """Apply bright red color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold red", enabled) - - -def bright_yellow(text: str, enabled: bool) -> str: - """Apply bright yellow color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ - return colorize(text, "bold yellow", enabled) - - def bright_blue(text: str, enabled: bool) -> str: """Apply bright blue color to text. diff --git a/src/org/commands/agenda/layout.py b/src/org/commands/agenda/layout.py index 3fe490c..5b4c2db 100644 --- a/src/org/commands/agenda/layout.py +++ b/src/org/commands/agenda/layout.py @@ -113,7 +113,6 @@ class DayRowModel: day: date rows: list[AgendaRow] - selectable_row_indexes: list[int] @dataclass(frozen=True) @@ -695,10 +694,7 @@ def build_view_day_model( continue rows.append(AgendaRow(kind="spacer", day=day)) rows.extend(section_rows) - selectable = [ - idx for idx, row in enumerate(rows) if row.kind == "task" and row.node is not None - ] - return DayRowModel(day=day, rows=rows, selectable_row_indexes=selectable) + return DayRowModel(day=day, rows=rows) def _build_agenda_table_with_widths( diff --git a/src/org/commands/interactive_common.py b/src/org/commands/interactive_common.py index 997696a..d44a780 100644 --- a/src/org/commands/interactive_common.py +++ b/src/org/commands/interactive_common.py @@ -18,12 +18,9 @@ from rich.live import Live from rich.markup import escape from rich.panel import Panel -from rich.syntax import Syntax from rich.table import Table from rich.text import Text -from org.output_format import DEFAULT_OUTPUT_THEME - if TYPE_CHECKING: from collections.abc import Callable @@ -918,16 +915,3 @@ def detail_org_block(node: Heading) -> str: filename = node.document.filename or "unknown" node_text = node.render().rstrip() return f"# {filename}\n{node_text}" if node_text else f"# {filename}" - - -def open_task_detail_in_pager(console: Console, node: Heading, *, color_enabled: bool) -> None: - """Open task detail in pager with Org syntax highlighting.""" - detail = Syntax( - detail_org_block(node), - "org", - theme=DEFAULT_OUTPUT_THEME, - line_numbers=False, - word_wrap=True, - ) - with console.pager(styles=color_enabled): - console.print(detail) diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 75399ea..f7cc32b 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -28,24 +28,18 @@ TagBlockConfig, TaskLineConfig, TimelineFormatConfig, - TopTasksSectionConfig, - apply_indent, build_console, format_group_block, - format_groups_section, format_tag_block, format_task_line, - format_top_tasks_section, lines_to_text, processing_status, - section_header_lines, setup_output, ) from org.validation import validate_stats_arguments if TYPE_CHECKING: - from collections.abc import Callable from datetime import datetime from org_parser.document import Heading @@ -120,20 +114,6 @@ class _TagsDisplayConfig: color_enabled: bool -@dataclass(frozen=True) -class _TagsSectionConfig: - """Configuration for rendering a single tags section.""" - - max_relations: int - plot_width: int - date_from: datetime | None - date_until: datetime | None - global_timerange: TimeRange - max_items: int - exclude_set: set[str] - color_enabled: bool - - @dataclass(frozen=True) class _StatsAllDisplayConfig: """Configuration shared across stats all layout sections.""" @@ -162,129 +142,6 @@ class _StatsAllSectionBuildConfig: use_summary_based_default: bool -def format_tags_section( - category_name: str, - tags: dict[str, Tag], - config: _TagsSectionConfig, - order_fn: Callable[[tuple[str, Tag]], int], - indent: str = "", -) -> str: - """Return formatted output for a single tags section.""" - if config.max_items == 0: - return "" - - cleaned = clean(config.exclude_set, tags) - sorted_items = sorted(cleaned.items(), key=order_fn)[0 : config.max_items] - - lines = section_header_lines(category_name.upper(), config.color_enabled) - - if not sorted_items: - lines.append(" No results") - return lines_to_text(apply_indent(lines, indent)) - - for idx, (name, tag) in enumerate(sorted_items): - if idx > 0: - lines.append("") - lines.extend( - format_tag_block( - name, - tag, - TagBlockConfig( - max_relations=config.max_relations, - exclude_set=config.exclude_set, - date_from=config.date_from, - date_until=config.date_until, - global_timerange=config.global_timerange, - timeline=TimelineFormatConfig( - color_enabled=config.color_enabled, - indent=" ", - plot_width=config.plot_width, - ), - name_indent=" ", - stats_indent=" ", - ), - ), - ) - - return lines_to_text(apply_indent(lines, indent)) - - -def format_stats_all_output( - result: AnalysisResult, - nodes: list[Heading], - args: StatsAllArgs, - display_config: _StatsAllDisplayConfig, - plot_width: int, -) -> str: - """Return formatted output for the stats all command.""" - - def order_by_total(item: tuple[str, Tag]) -> int: - """Sort by total count (descending).""" - return -item[1].total_tasks - - category_name = CATEGORY_NAMES[args.use] - task_limit = args.max_results if args.max_results is not None else 10 - - return "".join( - section - for section in ( - format_tasks_summary( - result, - SummaryDisplayConfig( - date_from=display_config.date_from, - date_until=display_config.date_until, - done_states=display_config.done_states, - todo_states=display_config.todo_states, - color_enabled=display_config.color_enabled, - ), - plot_width, - ), - format_top_tasks_section( - nodes, - TopTasksSectionConfig( - max_results=task_limit, - color_enabled=display_config.color_enabled, - done_states=display_config.done_states, - todo_states=display_config.todo_states, - line_width=plot_width, - indent="", - ), - ), - format_tags_section( - category_name, - result.tags, - _TagsSectionConfig( - max_relations=args.max_relations, - plot_width=plot_width, - date_from=display_config.date_from, - date_until=display_config.date_until, - global_timerange=result.timerange, - max_items=args.max_tags, - exclude_set=display_config.exclude_set, - color_enabled=display_config.color_enabled, - ), - order_by_total, - indent=" ", - ), - format_groups_section( - result.tag_groups, - display_config.exclude_set, - ( - args.min_group_size, - plot_width, - display_config.date_from, - display_config.date_until, - result.timerange, - display_config.color_enabled, - ), - args.max_groups, - indent=" ", - ), - ) - if section - ) - - def _line_count(text: str) -> int: """Return visual line count for preformatted text blocks.""" normalized = text.strip("\n") diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index 6efa5b5..fecad48 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -96,17 +96,6 @@ def iter_descendants(heading: Heading) -> list[Heading]: return descendants -def apply_subtree_level(heading: Heading, new_level: int) -> None: - """Apply heading level and shift descendants by the same delta.""" - level_delta = new_level - heading.level - if level_delta == 0: - return - - heading.level = new_level - for descendant in iter_descendants(heading): - descendant.level += level_delta - - def normalize_selector(value: str | None, option_name: str) -> str | None: """Normalize optional selector value and reject blank strings.""" if value is None: diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index 2d93341..ba62559 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -14,7 +14,6 @@ from org import config as config_module from org.cli_common import load_and_process_data from org.commands.interactive_common import interactive_help_command_text, interactive_loop -from org.commands.search_common import filter_nodes_by_search from org.output_format import ( DEFAULT_OUTPUT_THEME, OutputFormat, @@ -117,8 +116,6 @@ class _TasksListSessionData: class TasksListOutputFormatter(Protocol): """Formatter interface for the tasks list command.""" - include_filenames: bool - def prepare(self, data: TasksListRenderInput) -> PreparedOutput: """Prepare tasks list output for rendering.""" ... @@ -169,8 +166,6 @@ def _prepare_detailed_task_list(nodes: list[Heading], out_theme: str) -> Prepare class OrgTasksListOutputFormatter: """Org output formatter for tasks list command.""" - include_filenames = True - def prepare(self, data: TasksListRenderInput) -> PreparedOutput: """Prepare tasks list output in org or short-list form.""" if not data.nodes: @@ -204,8 +199,6 @@ def prepare(self, data: TasksListRenderInput) -> PreparedOutput: class PandocTasksListOutputFormatter: """Pandoc-based output formatter for tasks list command.""" - include_filenames = False - def __init__(self, output_format: str, pandoc_args: str | None) -> None: """Initialize formatter options for pandoc-based task rendering.""" self.output_format = output_format @@ -235,8 +228,6 @@ def prepare(self, data: TasksListRenderInput) -> PreparedOutput: class JsonTasksListOutputFormatter: """JSON output formatter for tasks list command.""" - include_filenames = False - def prepare(self, data: TasksListRenderInput) -> PreparedOutput: """Prepare tasks list output as JSON.""" payload = _json_output_payload(list(data.nodes)) @@ -354,11 +345,6 @@ def _run_tasks_list_static( print_prepared_output(console, prepared_output) -def _filter_nodes_by_search(nodes: list[Heading], search_text: str) -> list[Heading]: - """Filter nodes by case-insensitive substring match over one node's own text.""" - return filter_nodes_by_search(nodes, search_text) - - def _run_tasks_list_interactive( console: Console, args: ListArgs, diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index 960ffef..e21c2dd 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -49,8 +49,6 @@ class QueryOutputFormatter(Protocol): """Formatter interface for the query command.""" - include_filenames: bool - def prepare( self, values: list[object], @@ -83,8 +81,6 @@ def _format_org_block(value: object) -> str: class OrgQueryOutputFormatter: """Org output formatter for query command.""" - include_filenames = True - def prepare( self, values: list[object], @@ -145,8 +141,6 @@ def _prepare_org_values(self, values: list[object], out_theme: str) -> PreparedO class PandocQueryOutputFormatter: """Pandoc-based output formatter for query command.""" - include_filenames = False - def __init__(self, output_format: str, pandoc_args: str | None) -> None: """Initialize formatter options for pandoc-based rendering.""" self.output_format = output_format @@ -178,8 +172,6 @@ def prepare( class JsonQueryOutputFormatter: """JSON output formatter for query command.""" - include_filenames = False - def prepare( self, values: list[object], diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 0dd4d88..c0cc46b 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -301,8 +301,6 @@ def test_run_tasks_list_markdown_pandoc_error_is_usage_error( """Markdown formatter failures should be surfaced as CLI usage errors.""" class _FailingFormatter: - include_filenames = False - def prepare(self, data: object) -> object: del data raise OutputFormatError("pandoc missing") diff --git a/tests/commands/test_tasks_query_command.py b/tests/commands/test_tasks_query_command.py index 90f81a9..8a18e96 100644 --- a/tests/commands/test_tasks_query_command.py +++ b/tests/commands/test_tasks_query_command.py @@ -295,8 +295,6 @@ def test_run_query_markdown_pandoc_error_is_usage_error(monkeypatch: pytest.Monk """Markdown formatter failures should be surfaced as CLI usage errors.""" class _FailingFormatter: - include_filenames = False - def prepare( self, values: list[object], diff --git a/tests/conftest.py b/tests/conftest.py index 248f3b7..dc0aa18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,37 +4,11 @@ import org_parser -from org.analyze import Frequency - if TYPE_CHECKING: from org_parser.document import Heading -def freq_dict_from_ints(d: dict[str, int]) -> dict[str, Frequency]: - """Convert dict[str, int] to dict[str, Frequency] for testing. - - Args: - d: Dictionary mapping strings to integers - - Returns: - Dictionary mapping strings to Frequency objects - """ - return {k: Frequency(v) for k, v in d.items()} - - -def freq_dict_to_ints(d: dict[str, Frequency]) -> dict[str, int]: - """Convert dict[str, Frequency] to dict[str, int] for assertions. - - Args: - d: Dictionary mapping strings to Frequency objects - - Returns: - Dictionary mapping strings to integers - """ - return {k: v.total for k, v in d.items()} - - def node_from_org( org_text: str, todo_states: list[str] | None = None, From ec07e77cf3eba40eb86bb5ee0f8886a7f9aa5d18 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Tue, 2 Jun 2026 20:06:42 +0200 Subject: [PATCH 02/27] Refactored the stats command placement --- VIBES.org | 13 ++++++--- src/org/cli.py | 43 +++--------------------------- src/org/cli_common.py | 10 +++++++ src/org/commands/stats/__init__.py | 5 ++++ src/org/commands/stats/command.py | 41 ++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 43 deletions(-) create mode 100644 src/org/commands/stats/command.py diff --git a/VIBES.org b/VIBES.org index 9db7c11..db3df59 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7341,9 +7341,13 @@ Please don't leave unused arguments in functions, if an argument is no longer ne The timestamp advancement should only be used if the new state is a completed state. In such a case, the state should be updated to the first TODO state defined for the document, a logbook repeat should be added to the task (`State {node.todo} to {new_state}`) and the repeaters should be advanced according to its rules. ** Codebase improvements :Cleanup: -*** Extract the =stats= command wrapper into a separate module +*** DONE Extract the =stats= command wrapper into a separate module +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-02 Tue 20:06] +:END: +Organize it the same as the other commands (most recentry modified: agenda, board and tasks list). + *** Refactor the task CRUD utils into a separate module re-used by all the commands -*** Refactor config handling so that no global state is mutated *** Avoid using =getattr= and =setattr= like the plague Refactor all places that currently use either of these functions so that the concrete type is known and concrete field is accessed. Introduce linter rules that make sure these function cannot be used. @@ -7351,8 +7355,6 @@ Introduce linter rules that make sure these function cannot be used. *** Use Textual for better TUI organization - Selection for prompts - Sparkline for freequency plots -*** Config handling is too generic -Split config handling to be per-command and have the config file reflect that. *** REWORKED Interactive loop refactor :LOGBOOK: - State "REWORKED" from "TODO" [2026-05-26 Tue 21:27] @@ -7762,6 +7764,9 @@ Shows long term plan including completeness of tasks that have children and gann - Set workflow states, etc automatically. - Lint/Format files **** Check for file errors and log them +*** Config handling is too generic +Split config handling to be per-command and have the config file reflect that. +*** Refactor config handling so that no global state is mutated ** Dashboard command - Reads a dashboard config and shows a visualization on a server. - Tabbed views for the agenda, board, backlog and stats diff --git a/src/org/cli.py b/src/org/cli.py index db47daa..21af731 100644 --- a/src/org/cli.py +++ b/src/org/cli.py @@ -7,11 +7,9 @@ import typer from org import config, logging_config -from org.commands import agenda +from org.cli_common import DEFAULT_VERBOSE, resolve_verbose +from org.commands import agenda, stats from org.commands import board as board_command -from org.commands.stats import all as stats_all -from org.commands.stats import groups, tags -from org.commands.stats import summary as stats_summary from org.commands.tasks import command as tasks_command @@ -19,19 +17,6 @@ help="Analyze Emacs Org-mode archive files for task statistics.", no_args_is_help=True, ) -stats_app = typer.Typer( - help="Analyze Org-mode archive files for task statistics.", - no_args_is_help=True, -) - - -DEFAULT_VERBOSE: dict[str, bool] = {"value": False} - - -def _resolve_verbose(verbose: bool | None) -> bool: - if verbose is None: - return DEFAULT_VERBOSE["value"] - return verbose @app.callback() @@ -46,32 +31,12 @@ def main_callback( """Global CLI options.""" if verbose is None and not DEFAULT_VERBOSE["value"]: return - logging_config.configure_logging(_resolve_verbose(verbose)) - - -@stats_app.callback() -def stats_callback( - verbose: bool | None = typer.Option( - None, - "--verbose", - "-v", - help="Enable verbose logging output", - ), -) -> None: - """Global stats CLI options.""" - if verbose is None and not DEFAULT_VERBOSE["value"]: - return - logging_config.configure_logging(_resolve_verbose(verbose)) - + logging_config.configure_logging(resolve_verbose(verbose)) -stats_all.register(stats_app) -groups.register(stats_app) -stats_summary.register(stats_app) -tags.register(stats_app) agenda.register(app) board_command.register(app) -app.add_typer(stats_app, name="stats") +stats.register(app) tasks_command.register(app) diff --git a/src/org/cli_common.py b/src/org/cli_common.py index 26201e3..437bfd6 100644 --- a/src/org/cli_common.py +++ b/src/org/cli_common.py @@ -44,10 +44,20 @@ MAP: dict[str, str] = {} +DEFAULT_VERBOSE: dict[str, bool] = {"value": False} + logger = logging.getLogger("org") TAGS: set[str] = set() + +def resolve_verbose(verbose: bool | None) -> bool: + """Resolve effective verbose setting from CLI flag and config defaults.""" + if verbose is None: + return DEFAULT_VERBOSE["value"] + return verbose + + HEADING = { "the", "to", diff --git a/src/org/commands/stats/__init__.py b/src/org/commands/stats/__init__.py index 541aa33..26b5c2e 100644 --- a/src/org/commands/stats/__init__.py +++ b/src/org/commands/stats/__init__.py @@ -1 +1,6 @@ """Stats command package.""" + +from .command import register + + +__all__ = ["register"] diff --git a/src/org/commands/stats/command.py b/src/org/commands/stats/command.py new file mode 100644 index 0000000..117af6e --- /dev/null +++ b/src/org/commands/stats/command.py @@ -0,0 +1,41 @@ +"""Stats command wiring.""" + +from __future__ import annotations + +import typer + +from org import logging_config +from org.cli_common import DEFAULT_VERBOSE, resolve_verbose +from org.commands.stats import all as stats_all +from org.commands.stats import groups, tags +from org.commands.stats import summary as stats_summary + + +stats_app = typer.Typer( + help="Analyze Org-mode archive files for task statistics.", + no_args_is_help=True, +) + + +@stats_app.callback() +def stats_callback( + verbose: bool | None = typer.Option( + None, + "--verbose", + "-v", + help="Enable verbose logging output", + ), +) -> None: + """Global stats CLI options.""" + if verbose is None and not DEFAULT_VERBOSE["value"]: + return + logging_config.configure_logging(resolve_verbose(verbose)) + + +def register(app: typer.Typer) -> None: + """Register stats commands on the root CLI app.""" + stats_all.register(stats_app) + groups.register(stats_app) + stats_summary.register(stats_app) + tags.register(stats_app) + app.add_typer(stats_app, name="stats") From faf4319ed03c7615efa9c0c683a5ffad719ccafc Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Fri, 5 Jun 2026 11:06:13 +0200 Subject: [PATCH 03/27] Add textual migration plan --- VIBES.org | 7 +- plans/textual-migration.md | 451 +++++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 plans/textual-migration.md diff --git a/VIBES.org b/VIBES.org index db3df59..4fb2986 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7352,7 +7352,12 @@ Organize it the same as the other commands (most recentry modified: agenda, boar Refactor all places that currently use either of these functions so that the concrete type is known and concrete field is accessed. Introduce linter rules that make sure these function cannot be used. *** Avoid monkey patching in tests -*** Use Textual for better TUI organization +*** TODO Textual for app organization & input handling +Refine the plan in @plans/textual-migration.md to better reflect the current state of the codebase. +Focus on just the TUI organization & input handling aspects, we'll handle widgets etc later. + +Implement the plan from @plans/textual-migration.md +*** Use Textual widgets for better TUI organization - Selection for prompts - Sparkline for freequency plots *** REWORKED Interactive loop refactor diff --git a/plans/textual-migration.md b/plans/textual-migration.md new file mode 100644 index 0000000..2b4dce6 --- /dev/null +++ b/plans/textual-migration.md @@ -0,0 +1,451 @@ +# Textual Migration Plan + +## Recommendation + +Use a phased rewrite to Textual. + +1. Introduce a shared Textual runtime for interactive commands within the existing command structure. +2. Rewrite `tasks list` first. +3. Rewrite `board`. +4. Rewrite `agenda`. +5. Rewrite `tasks capture` fullscreen placeholder flow last. +6. Remove all obsolete non-Textual interactive code and tests. + +That order matches complexity and risk: + +- `tasks list` is the simplest and best for proving the shared runtime. +- `board` adds more layout complexity. +- `agenda` adds time-driven refresh and richer row semantics. +- `tasks capture` is currently its own separate input/fullscreen implementation. + +This is not a compatibility migration. The goal is to end with one interactive implementation model: Textual. + +This rewrite should preserve the existing CLI organization and command nesting. Non-interactive commands should remain available exactly where they are today, including `tasks list` remaining a subcommand of `tasks`. + +For each command, the existing CLI surface should be preserved unless there is an explicit decision to change it. That includes: + +- command/subcommand names +- CLI switches and arguments +- help text behavior +- config default application and config-driven behavior +- logging and argument handling conventions where they are part of the current command flow + +It is acceptable for Textual to become the new app entry point, but that entry point must support the full CLI rather than only the interactive screens. The Textual-based app structure should still expose and run non-interactive commands within the same overall command tree. + +## Current Interaction Patterns + +Shared patterns in the current code: + +- `tasks list`, `board`, and `agenda` already share one fullscreen loop and most input plumbing in `src/org/commands/interactive_common.py`. +- Manual fullscreen loop using Rich `Live(screen=True)`. +- Manual input mode management with `termios` / `tty`. +- Shared key decoding, help modal rendering, mouse toggles, paste handling, prompt editing, and external-action suspension in `src/org/commands/interactive_common.py`. +- In-memory session objects per command with: + - selection state + - scroll state + - search/filter state + - status message + - help-modal state + - active prompt state + - external-action runner callback +- External blocking actions suspend the screen: + - editor + - capture + - archive-like file mutations +- `tasks capture` is the outlier: + - separate fullscreen `Live(screen=True)` flow + - separate `termios` / `tty` handling + - separate placeholder input loop + +This is exactly the kind of code Textual should replace fully rather than wrap. + +## Textual Architecture + +Do not build separate bespoke apps or split interactive and non-interactive behavior into different top-level applications. Rewrite the existing command implementation in place, using Textual as the application foundation while still supporting the full command tree from the same app entry point. + +Recommended structure: + +- keep the current CLI and command nesting unchanged +- allow Textual to become the primary app entry point +- ensure the Textual-based app entry point supports both interactive and non-interactive commands +- keep interactive and non-interactive command entrypoints logically in their current command areas even if some wiring moves to the new app entry point +- preserve existing command switches, help text, and config handling for each command +- introduce shared Textual runtime/helpers alongside the existing command code rather than in a separate package +- keep `tasks list` under `src/org/commands/tasks/list/` +- keep `tasks capture` under `src/org/commands/tasks/` +- keep `board` under `src/org/commands/board/` +- keep `agenda` under `src/org/commands/agenda/` + +Recommended architectural rules: + +- Keep domain logic outside Textual widgets. +- Textual should own: + - rendering + - focus + - key bindings + - mouse events + - resize handling + - modal workflows + - timers +- Existing command logic should continue to own: + - task mutation + - filtering/search semantics + - reload/preserve-selection logic + - archive/refile/capture/edit behavior + +That means: + +- current session dataclasses and mutation/reload helpers remain useful where they still reflect real behavior +- current manual loop/input code is a removal target, not a parallel fallback to keep +- `src/org/commands/interactive_common.py` should not survive as a second interactive runtime once the rewrite is complete + +## Shared TUI Design + +Recommended shared application concepts across all interactive commands: + +- shared app-level action dispatch +- shared external-action suspension/resume +- shared modal lifecycle +- shared command/session mounting within the existing command modules +- command-owned state for selection, search, status, and reload preservation + +The app is being rewritten, not reorganized into a new CLI shape. Textual may become the application entry point, but it must support the existing command tree rather than replacing it with an interactive-only application. + +For prompt interactions, do not preserve the current footer-editing mechanics as infrastructure. +Use Textual-native interaction: + +- small modal input dialog for: + - search + - state selection + - planning timestamp edits + - refile + - clock duration + - capture template selection +- optional inline search only if it materially improves flow + +Recommendation: + +- Preserve useful prompt semantics where they still make sense. +- Replace manual cursor editing, raw input handling, and footer-specific prompt mechanics with Textual-native prompt flows. +- Do not keep the old prompt implementation in parallel just for compatibility. + +## External Action Strategy + +Current behavior: + +- suspend live screen +- launch editor / capture flow +- resume screen +- refresh state + +In Textual, recommend: + +- centralize this as an app-level helper +- temporarily suspend the app UI before launching blocking terminal interactions +- resume the app and refresh the screen state afterward + +For external editor specifically: + +- keep command session state alive +- preserve selection/search/scroll where possible +- show a short status before suspension if useful + +## Tasks List + +Current behavior: + +- vertical selection +- search prompt with live filtering +- state change +- tag edit +- planning edits +- capture prompt +- archive +- external editor +- help modal + +Textual recommendation: + +- Main screen should be the first full rewrite onto the shared Textual runtime while staying under the existing `tasks list` command path. +- Search, help, and prompts should use Textual-native interaction rather than the shared manual input loop. +- Existing task mutation and reload logic should be reused only where it still fits the new runtime. +- Good first migration target because: + - single-axis navigation + - no time-based updates + - simplest state model + +Migration notes: + +- reuse current filtering and mutation functions where still appropriate +- remove its old manual interactive path once the Textual rewrite is complete +- remove tests that only assert the old interactive loop/input behavior +- add Textual-based interaction tests for the rewritten command + +## Board + +Current behavior: + +- multi-column board +- horizontal and vertical movement +- variable-height cards/panels +- search filtering +- capture +- archive +- external editor +- state move across columns +- priority changes + +Textual recommendation: + +- Main widget: board container with one widget per column +- Each column should own a vertical list of cards +- App-level state tracks: + - selected column + - selected row within column +- Avoid trying to fake the current Rich panel grid directly +- Better model: + - custom board widget + - focus model separate from raw rendered layout + +Migration notes: + +- rewrite onto the same shared Textual runtime as `tasks list`, without changing the command/module nesting +- likely needs a custom widget rather than stock Textual table/list widgets +- good candidate for virtualization later if columns get large +- Textual should simplify resize behavior a lot here +- remove the old manual interactive board path once complete +- remove tests that only make sense for the deleted manual implementation +- add Textual-based interaction tests + +Recommendation: + +- preserve keyboard model: + - arrows for movement + - shifted arrows for state/priority actions +- consider making focused card visually stronger than today + +## Agenda + +Current behavior: + +- mixed row types: + - task rows + - hour markers + - now marker + - relative sections +- time-driven refresh for now marker +- date-range paging/scrolling +- task-only actions depending on selected row kind +- capture on timetable-like rows +- refile +- clock duration prompt +- state changes +- planning shifts +- search + +Textual recommendation: + +- Main widget: custom agenda list/tree-like widget, not plain table +- Model rows explicitly by type +- Make selection row-aware: + - some rows actionable + - some rows navigational only +- Use a timer for now-marker refresh + - `set_interval(60, ...)` or minute-aware scheduling +- Do not keep timeout-based polling to drive refresh once the Textual version exists +- This is the biggest input/runtime benefit Textual brings here + +Migration notes: + +- keep current row-building/domain logic initially +- move row actions into command handlers that inspect selected row type +- remove the old timeout/event-loop path once the Textual rewrite is complete +- replace old agenda-specific interactive tests with Textual-based tests + +Recommendation: + +- agenda should be the only screen with timer-driven UI refresh +- this screen most benefits from the migration + +## Tasks Capture + +Current behavior: + +- fullscreen template preview +- placeholder-by-placeholder editing +- inline footer input +- help modal +- custom text input handling + +Textual recommendation: + +- Treat this as a dedicated form/editor screen inside the shared Textual runtime while keeping it under the existing `tasks capture` command. +- Replace its bespoke `Live(screen=True)` and `termios` / `tty` input loop entirely. +- Use Textual-native input handling rather than preserving raw key-reading code. +- Layout: + - preview pane/body + - active field status + - input widget +- Instead of raw placeholder stepping logic in the footer: + - use focused input field + preview updates + - optionally one screen per field or a single form screen with next/prev controls + +Migration notes: + +- likely simpler after Textual rewrite than today +- but it is sufficiently distinct that it should come after the other commands +- once rewritten, remove the old capture fullscreen/input implementation and its obsolete tests + +Recommendation: + +- redesign slightly instead of preserving the exact current interaction +- Textual form flow will likely be better than emulating the manual footer editor + +## What Gets Replaced + +Likely mostly replaced: + +- `interactive_common.py` input-mode and terminal-control sections +- shared manual fullscreen event loop +- custom key/input byte decoding +- mouse reporting toggles +- bracketed-paste handling +- help modal rendering helpers tied to the manual runtime +- footer prompt editing mechanics +- the bespoke capture `Live`/raw-input loop + +Likely partially retained: + +- selection-preservation helpers +- search/filter/mutation helpers +- command-specific session refresh logic + +Likely retained as domain logic: + +- task mutation functions +- archive/refile/edit/capture integration +- data loading/reload functions +- current render-independent computations + +Likely retained as CLI structure: + +- one unified application entry point +- current Typer command tree +- current command nesting and subcommand paths +- current non-interactive command availability +- current command switches and arguments +- current command help text behavior +- current command config handling behavior + +## Testing Plan + +A Textual rewrite changes test style significantly. + +Current tests are heavy on: + +- pure renderable output +- manual input/runtime helpers +- direct key dispatch against the old event-loop model +- prompt state mutations tied to the old footer-editing flow + +Textual plan should add: + +- app-level interaction tests using Textual pilot +- screen-specific behavior tests: + - navigation + - modal open/close + - prompt submit/cancel + - state refresh after mutations +- keep domain/unit tests around current mutation/filter helpers when those helpers still exist and still represent production behavior + +Recommended split: + +- preserve pure unit tests only for domain/state/data logic that still exists in production +- remove tests that no longer make sense once the old manual runtime is deleted +- add integration-style Textual tests for UI behavior +- avoid carrying old architecture-specific tests forward once the corresponding production code is gone + +## Dependency and Tooling Impact + +Would entail: + +- add `textual` dependency +- likely add test helpers for Textual pilot-based testing +- validate compatibility with current Python target and strict typing setup + +Likely no need to replace: + +- Typer +- Rich for non-interactive rendering +- current CLI command structure +- current command nesting such as `tasks list` +- current command option/argument surface +- current help text and config-default behavior + +## Migration Phases + +### Phase 1 + +- Add Textual dependency +- Create shared Textual runtime architecture inside the existing command layout +- Make the Textual-based app entry point support both interactive and non-interactive commands +- Preserve per-command switches, help text, and config handling while rewiring execution onto the new app foundation +- Define app-level external-action suspend/resume API +- Define shared command mounting and input/action dispatch model + +### Phase 2 + +- Rewrite `tasks list` in Textual +- Keep non-interactive `tasks list` unchanged +- Remove the old interactive implementation for `tasks list` +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 3 + +- Rewrite `board` in Textual +- Remove the old interactive implementation for `board` +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 4 + +- Rewrite `agenda` in Textual +- Add timer-based now-marker refresh +- Remove the old interactive implementation for `agenda` +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 5 + +- Rewrite `tasks capture` in Textual +- Replace manual placeholder loop with Textual form/editor screen +- Remove the old capture fullscreen/input implementation +- Remove tests that only cover the deleted implementation +- Add Textual-based tests + +### Phase 6 + +- Remove obsolete terminal/input plumbing +- Remove remaining Rich `Live` fullscreen helpers no longer used +- Remove obsolete interactive helper code that no longer corresponds to production behavior +- Ensure there are no remaining non-Textual interactive runtime remnants + +## Recommendations + +1. Rewrite all interactive TUI code in Textual; do not keep the old manual runtime in parallel. +2. Do not start with `agenda`. +3. Do not try to preserve the footer-prompt implementation exactly. +4. Use modal prompts and app-managed timers. +5. Keep the existing CLI organization and command nesting intact, including `tasks list` remaining a subcommand of `tasks`. +6. Remove tests that no longer make sense after the rewrite, and replace them with Textual-based tests. +7. Treat `tasks capture` as a separate UX redesign, not just a port. +8. Keep business logic outside Textual widgets. +9. If Textual becomes the app entry point, it must support non-interactive commands too, not just interactive screens. +10. Preserve each command's switches, help text, and config handling unless there is an explicit decision to change them. + +## Open Questions + +1. Do you want the Textual TUI to preserve existing key bindings almost exactly, or is some UX cleanup acceptable? +2. Do you want `tasks list` to remain a single-pane list, or should migration be used to add a detail pane? +3. Should `tasks capture` stay placeholder-step-based, or can it become a more form-like editor? From d559df8b07bcfd989a3ff9a7c4262db2b91a7a5e Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Fri, 5 Jun 2026 11:34:26 +0200 Subject: [PATCH 04/27] Convert tasks list to Textual --- VIBES.org | 37 +- poetry.lock | 98 +++++- pyproject.toml | 1 + src/org/commands/runtime.py | 133 +++++++ src/org/commands/styles/help_modal.tcss | 18 + src/org/commands/styles/prompt_modal.tcss | 26 ++ src/org/commands/tasks/list/app.py | 366 ++++++++++++++++++++ src/org/commands/tasks/list/command.py | 32 +- src/org/commands/tasks/list/events.py | 290 ---------------- src/org/commands/tasks/list/layout.py | 120 ------- src/org/commands/tasks/list/styles/app.tcss | 12 + tests/commands/tasks/test_tasks_list.py | 317 ++--------------- tests/commands/tasks/test_tasks_list_app.py | 183 ++++++++++ 13 files changed, 905 insertions(+), 728 deletions(-) create mode 100644 src/org/commands/runtime.py create mode 100644 src/org/commands/styles/help_modal.tcss create mode 100644 src/org/commands/styles/prompt_modal.tcss create mode 100644 src/org/commands/tasks/list/app.py delete mode 100644 src/org/commands/tasks/list/layout.py create mode 100644 src/org/commands/tasks/list/styles/app.tcss create mode 100644 tests/commands/tasks/test_tasks_list_app.py diff --git a/VIBES.org b/VIBES.org index 4fb2986..f09fa30 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7340,7 +7340,7 @@ Please don't leave unused arguments in functions, if an argument is no longer ne *** Mark as completed should respect repeaters The timestamp advancement should only be used if the new state is a completed state. In such a case, the state should be updated to the first TODO state defined for the document, a logbook repeat should be added to the task (`State {node.todo} to {new_state}`) and the repeaters should be advanced according to its rules. -** Codebase improvements :Cleanup: +** TODO Codebase improvements :Cleanup: *** DONE Extract the =stats= command wrapper into a separate module :LOGBOOK: - State "DONE" from "TODO" [2026-06-02 Tue 20:06] @@ -7357,6 +7357,41 @@ Refine the plan in @plans/textual-migration.md to better reflect the current sta Focus on just the TUI organization & input handling aspects, we'll handle widgets etc later. Implement the plan from @plans/textual-migration.md +**** DONE Broken input/modals +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:14] +:END: +The help modal (key binding: ?) doesn't show the bindings, just a blank screen. The input prompt doesn't display the text that's being written, nor any message such as "Search term" or similar. + +**** DONE Broken modal lifecycle +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:14] +:END: +Modals that can be cancelled with the Esc key, also cause the whole app to exit. These should only close the modal. The exception should be the key-bindings modal - when any button is pressed, it should close the modal and apply that binding to the underlying screen. The modal should be updated to have a clear title (same as the other input modals) that says "Key bindings". The final line that says "Press any key to return" should be removed. + +**** TODO src/org/commands/tasks/list/command.py +:PROPERTIES: +:context: +:END: + +In file src/org/commands/tasks/list/command.py around line 375, near the ~def _run_tasks_list_interactive(~ + +#+begin_example ++ del console ++ run_tasks_list_app(args, data) + +#+end_example + +It's better to refactor the function call sites not to pass the =console= there. + +**** DONE Put CSS into a separate file for better organization +:PROPERTIES: +:capture_time: [2026-06-05 Fri 11:13] +:context: [[file:~/projects/software/org-cli/VIBES.org::*src/org/commands/tasks/list/command.py][src/org/commands/tasks/list/command.py]] +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:20] +:END: *** Use Textual widgets for better TUI organization - Selection for prompts - Sparkline for freequency plots diff --git a/poetry.lock b/poetry.lock index e386207..e827e90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "annotated-doc" @@ -244,6 +244,27 @@ files = [ {file = "librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862"}, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +description = "Links recognition library with FULL unicode support." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e"}, + {file = "linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b"}, +] + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx_book_theme"] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -257,6 +278,7 @@ files = [ ] [package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} mdurl = ">=0.1,<1.0" [package.extras] @@ -268,6 +290,26 @@ profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d"}, + {file = "mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"}, +] + +[package.dependencies] +markdown-it-py = ">=2.0.0,<5.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout"] + [[package]] name = "mdurl" version = "0.1.2" @@ -436,6 +478,18 @@ optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] +[[package]] +name = "platformdirs" +version = "4.10.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -721,6 +775,29 @@ mslex = {version = ">=1.1.0,<2.0.0", markers = "sys_platform == \"win32\""} psutil = ">=5.7.2,<7" tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and python_version < \"4.0\""} +[[package]] +name = "textual" +version = "8.2.7" +description = "Modern Text User Interface framework" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73"}, + {file = "textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105"}, +] + +[package.dependencies] +markdown-it-py = {version = ">=2.1.0", extras = ["linkify"]} +mdit-py-plugins = "*" +platformdirs = ">=3.6.0,<5" +pygments = ">=2.19.2,<3.0.0" +rich = ">=14.2.0" +typing-extensions = ">=4.4.0,<5.0.0" + +[package.extras] +syntax = ["tree-sitter (>=0.25.0) ; python_version >= \"3.10\"", "tree-sitter-bash (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-css (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-go (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-html (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-java (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-javascript (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-json (>=0.24.0) ; python_version >= \"3.10\"", "tree-sitter-markdown (>=0.3.0) ; python_version >= \"3.10\"", "tree-sitter-python (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-regex (>=0.24.0) ; python_version >= \"3.10\"", "tree-sitter-rust (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-sql (>=0.3.11) ; python_version >= \"3.10\"", "tree-sitter-toml (>=0.6.0) ; python_version >= \"3.10\"", "tree-sitter-xml (>=0.7.0) ; python_version >= \"3.10\"", "tree-sitter-yaml (>=0.6.0) ; python_version >= \"3.10\""] + [[package]] name = "tomli" version = "2.4.0" @@ -886,12 +963,27 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +description = "Micro subset of unicode data files for linkify-it-py projects." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c"}, + {file = "uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811"}, +] + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "vulture" version = "2.16" @@ -907,4 +999,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "e18e5231f4497bc416395d18cace6e19454545cbcf462e8dfbe18b435759dabd" +content-hash = "9f743b6b30bbc98607d4f8229be0f42fc7d88a1a07b6df68c7ab8baf3a5b7544" diff --git a/pyproject.toml b/pyproject.toml index 7370638..9ec493d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ typer = "^0.24.1" rich = "^14.3.3" parsy = "^2.2" pyyaml = "^6.0.3" +textual = "^8.2.7" [tool.poetry.group.dev.dependencies] pytest = "^9.0.2" diff --git a/src/org/commands/runtime.py b/src/org/commands/runtime.py new file mode 100644 index 0000000..3f51c8c --- /dev/null +++ b/src/org/commands/runtime.py @@ -0,0 +1,133 @@ +"""Shared Textual runtime helpers for interactive command UIs.""" + +from __future__ import annotations + +from contextlib import nullcontext +from typing import TYPE_CHECKING, cast + +from rich.text import Text +from textual import events as textual_events +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Input, Static + +from org.commands.interactive_common import ( + InteractiveHelpEntry, + render_interactive_help_panel_text, +) + + +if TYPE_CHECKING: + from collections.abc import Callable + from contextlib import AbstractContextManager + + +def _help_body_text(entries: list[InteractiveHelpEntry]) -> Text: + """Build help body text without duplicating the modal title.""" + panel_text = render_interactive_help_panel_text(entries) + body_lines = panel_text.splitlines()[1:] + return Text.from_markup("\n".join(body_lines)) + + +class HelpModalScreen(ModalScreen[None]): + """Generic modal screen that renders command key bindings.""" + + CSS_PATH = "styles/help_modal.tcss" + + def __init__(self, entries: list[InteractiveHelpEntry], *, color_enabled: bool) -> None: + """Store key binding data for modal rendering.""" + super().__init__() + self._entries = entries + self._color_enabled = color_enabled + self._ready_to_close = False + + def compose(self) -> ComposeResult: + """Render the help modal contents.""" + title_text = Text("Key bindings", style="bold") + help_text = _help_body_text(self._entries) + yield Container( + Static(title_text, id="help-title"), + Static(help_text, id="help-content"), + id="help-dialog", + ) + + def on_mount(self) -> None: + """Arm key-to-close after the opening key event finishes.""" + self.call_after_refresh(self._enable_close) + + def _enable_close(self) -> None: + """Allow subsequent key presses to close the help modal.""" + self._ready_to_close = True + + async def on_key(self, event: textual_events.Key) -> None: + """Close and forward the next key press to the underlying app.""" + event.stop() + if not self._ready_to_close: + return + forwarded_event = textual_events.Key(event.key, event.character) + self.dismiss(None) + self.app.call_after_refresh(lambda: self.app.post_message(forwarded_event)) + + +class PromptModalScreen(ModalScreen[str | None]): + """Generic modal screen for single-value text prompts.""" + + CSS_PATH = "styles/prompt_modal.tcss" + + def __init__( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + ) -> None: + """Store modal prompt configuration.""" + super().__init__() + self._label = label + self._initial_value = initial_value + self._on_change = on_change + + def compose(self) -> ComposeResult: + """Render the prompt label and input field.""" + yield Container( + Static(Text(self._label, style="bold"), id="prompt-label"), + Static(Text("Enter submits. Esc cancels.", style="dim"), id="prompt-instructions"), + Input(value=self._initial_value, id="prompt-input"), + id="prompt-dialog", + ) + + def on_mount(self) -> None: + """Focus the prompt input after the modal is mounted.""" + self.query_one(Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + """Forward value changes to optional preview callback.""" + if self._on_change is not None: + self._on_change(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Return submitted input value to caller.""" + self.dismiss(event.value) + + def on_key(self, event: textual_events.Key) -> None: + """Consume escape so prompt cancel doesn't fall through to app bindings.""" + if event.key != "escape": + return + event.stop() + self.dismiss(None) + + +class CommandApp(App[None]): + """Shared helpers for Textual-backed interactive command apps.""" + + def suspend_for_external(self, callback: Callable[[], None]) -> None: + """Suspend the app when possible around one blocking callback.""" + suspend = getattr(self, "suspend", None) + context_manager: AbstractContextManager[object] + if callable(suspend): + context_manager = cast("AbstractContextManager[object]", suspend()) + else: + context_manager = nullcontext() + with context_manager: + callback() diff --git a/src/org/commands/styles/help_modal.tcss b/src/org/commands/styles/help_modal.tcss new file mode 100644 index 0000000..6f2986f --- /dev/null +++ b/src/org/commands/styles/help_modal.tcss @@ -0,0 +1,18 @@ +HelpModalScreen { + align: center middle; + background: $surface 60%; +} + +#help-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 80%; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#help-content { + width: 1fr; +} diff --git a/src/org/commands/styles/prompt_modal.tcss b/src/org/commands/styles/prompt_modal.tcss new file mode 100644 index 0000000..44109d5 --- /dev/null +++ b/src/org/commands/styles/prompt_modal.tcss @@ -0,0 +1,26 @@ +PromptModalScreen { + align: center middle; + background: $surface 60%; +} + +#prompt-dialog { + width: 80%; + max-width: 100; + height: auto; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#prompt-label { + margin-bottom: 1; +} + +#prompt-instructions { + margin-bottom: 1; +} + +#prompt-input { + width: 1fr; + min-width: 20; +} diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py new file mode 100644 index 0000000..fc89156 --- /dev/null +++ b/src/org/commands/tasks/list/app.py @@ -0,0 +1,366 @@ +"""Textual app for the interactive tasks list command.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.console import Group +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.widgets import Static + +from org.commands import runtime +from org.commands.tasks.common import ( + PlanningTimestampField, + capture_template_prompt_label, + configured_capture_template_names, + planning_prompt_label, + resolve_capture_template_selection, + resolve_todo_state_selection, + state_selection_prompt_label, + tags_prompt_label, +) +from org.tui import TaskLineConfig, format_task_line + +from . import events +from .events import TASKS_LIST_HELP_ENTRIES, create_tasks_list_session + + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import ClassVar + + from org_parser.document import Heading + from textual.app import ComposeResult + from textual.events import MouseScrollDown, MouseScrollUp, Resize + + from .command import ListArgs, _TasksListSessionData + + +_HIGHLIGHT_ROW_STYLE = "on grey23" +_HELP_FOOTER_TEXT = "Type ? for help" + + +class TasksListApp(runtime.CommandApp): + """Textual app that backs interactive `tasks list`.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", show=False), + Binding("down", "move_down", show=False), + Binding("n", "move_down", show=False), + Binding("p", "move_up", show=False), + Binding("shift+up", "increase_priority", show=False), + Binding("shift+down", "decrease_priority", show=False), + Binding("enter", "edit_selected", show=False), + Binding("slash", "prompt_search", show=False), + Binding("x", "clear_search", show=False), + Binding("a", "prompt_capture", show=False), + Binding("dollar_sign", "archive_selected", show=False), + Binding("t", "prompt_state", show=False), + Binding("g", "prompt_tags", show=False), + Binding("s", "prompt_scheduled", show=False), + Binding("d", "prompt_deadline", show=False), + Binding("c", "prompt_closed", show=False), + Binding("question_mark", "show_help", show=False), + Binding("q", "quit_app", show=False), + Binding("escape", "quit_app", show=False), + ] + + def __init__(self, args: ListArgs, data: _TasksListSessionData) -> None: + """Build one tasks list app from CLI args and loaded session data.""" + super().__init__() + self.session = create_tasks_list_session(args, data) + + def compose(self) -> ComposeResult: + """Build the main app layout.""" + yield Vertical( + Static(id="tasks-body"), + Static(id="tasks-footer"), + Static(id="tasks-status"), + ) + + def on_mount(self) -> None: + """Render initial state after mount.""" + self._refresh_view() + + def on_resize(self, _event: Resize) -> None: + """Refresh viewport layout after terminal resize.""" + self._refresh_view() + + def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: + """Match legacy wheel-up navigation behavior.""" + events.move_selection(self.session, -1) + self._refresh_view() + + def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: + """Match legacy wheel-down navigation behavior.""" + events.move_selection(self.session, 1) + self._refresh_view() + + def _body_widget(self) -> Static: + return self.query_one("#tasks-body", Static) + + def _footer_widget(self) -> Static: + return self.query_one("#tasks-footer", Static) + + def _status_widget(self) -> Static: + return self.query_one("#tasks-status", Static) + + def _viewport_height(self) -> int: + body_height = self._body_widget().size.height + if body_height <= 0: + return max(5, self.size.height - 2) + return max(5, body_height) + + def _sync_scroll(self, viewport_height: int) -> None: + max_offset = max(0, len(self.session.visible_nodes) - viewport_height) + self.session.scroll_offset = min(max(self.session.scroll_offset, 0), max_offset) + if not self.session.visible_nodes: + return + if self.session.selected_index < self.session.scroll_offset: + self.session.scroll_offset = self.session.selected_index + elif self.session.selected_index >= self.session.scroll_offset + viewport_height: + self.session.scroll_offset = self.session.selected_index - viewport_height + 1 + self.session.scroll_offset = min(max(self.session.scroll_offset, 0), max_offset) + + def _row_text(self, node: Heading, *, line_width: int) -> Text: + line = format_task_line( + node, + TaskLineConfig( + color_enabled=self.session.color_enabled, + done_states=self.session.done_states, + todo_states=self.session.todo_states, + line_width=line_width, + ), + ) + if self.session.color_enabled: + return Text.from_markup(line) + return Text(line) + + def _body_renderable(self) -> Group: + viewport_height = self._viewport_height() + events.ensure_selection_bounds(self.session) + self._sync_scroll(viewport_height) + window = self.session.visible_nodes[ + self.session.scroll_offset : self.session.scroll_offset + viewport_height + ] + rows: list[Text] = [] + line_width = max(20, self.size.width - 1) + for index, node in enumerate(window, start=self.session.scroll_offset): + row = self._row_text(node, line_width=line_width) + if index == self.session.selected_index: + row.stylize(_HIGHLIGHT_ROW_STYLE) + rows.append(row) + rows.extend(Text("") for _ in range(viewport_height - len(window))) + return Group(*rows) + + def _footer_renderable(self) -> Text: + selected_row = self.session.selected_index + 1 if self.session.visible_nodes else 0 + total_rows = len(self.session.visible_nodes) + search_text = self.session.search_text or "-" + footer_style = "dim" if self.session.color_enabled else "" + return Text( + f"Rows {selected_row}/{total_rows} | Search: {search_text} | {_HELP_FOOTER_TEXT}", + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ) + + def _status_renderable(self) -> Text: + footer_style = "dim" if self.session.color_enabled else "" + return Text( + self.session.status_message or "", + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ) + + def _refresh_view(self) -> None: + self._body_widget().update(self._body_renderable()) + self._footer_widget().update(self._footer_renderable()) + self._status_widget().update(self._status_renderable()) + + def _open_prompt( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen( + runtime.PromptModalScreen(label, initial_value=initial_value, on_change=on_change), + callback=_complete, + ) + + def _run_external(self, callback: Callable[[], None]) -> None: + self.suspend_for_external(callback) + self._refresh_view() + + def action_move_up(self) -> None: + """Move the selection one row upward.""" + events.move_selection(self.session, -1) + self._refresh_view() + + def action_move_down(self) -> None: + """Move the selection one row downward.""" + events.move_selection(self.session, 1) + self._refresh_view() + + def action_increase_priority(self) -> None: + """Raise the selected task priority one step.""" + events.apply_priority_shift(self.session, increase=True) + self._refresh_view() + + def action_decrease_priority(self) -> None: + """Lower the selected task priority one step.""" + events.apply_priority_shift(self.session, increase=False) + self._refresh_view() + + def action_clear_search(self) -> None: + """Clear the active search filter.""" + events.clear_search(self.session) + self._refresh_view() + + def action_edit_selected(self) -> None: + """Open the selected task in the external editor.""" + self._run_external(lambda: events.edit_selected_task_in_external_editor(self.session)) + + def action_archive_selected(self) -> None: + """Archive the selected task subtree.""" + events.archive_selected_task(self.session) + self._refresh_view() + + def action_quit_app(self) -> None: + """Exit the interactive tasks list app.""" + self.exit() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen( + runtime.HelpModalScreen( + TASKS_LIST_HELP_ENTRIES, + color_enabled=self.session.color_enabled, + ), + ) + + def action_prompt_search(self) -> None: + """Open the search prompt with live filtering preview.""" + previous_text = self.session.search_text + + def _preview(value: str) -> None: + events.apply_search_text(self.session, value.strip()) + self._refresh_view() + + self._open_prompt( + "Search text (blank clears)", + initial_value=self.session.search_text, + on_change=_preview, + on_submit=lambda value: events.apply_search_text(self.session, value.strip()), + on_cancel=lambda: self._cancel_search(previous_text), + ) + + def _cancel_search(self, previous_text: str) -> None: + events.apply_search_text(self.session, previous_text) + self.session.status_message = "Search cancelled" + + def action_prompt_capture(self) -> None: + """Prompt for a capture template and create a task.""" + template_names = configured_capture_template_names() + if not template_names: + self.session.status_message = "No capture templates configured" + self._refresh_view() + return + + def _submit(value: str) -> None: + stripped = value.strip() + if not stripped: + self.session.status_message = "Capture cancelled" + return + template_name = resolve_capture_template_selection(stripped, template_names) + if template_name is None: + self.session.status_message = "Invalid capture template shortcut" + return + self._run_external(lambda: events.apply_capture_task(self.session, template_name)) + + self._open_prompt( + capture_template_prompt_label(template_names), + on_submit=_submit, + on_cancel=lambda: self._set_status("Capture cancelled"), + ) + + def action_prompt_state(self) -> None: + """Prompt for a TODO state transition.""" + status_message = events.can_activate_state_prompt(self.session) + if status_message is not None: + self.session.status_message = status_message + self._refresh_view() + return + states = events.state_choices_for_selected_node(self.session) + + def _submit(value: str) -> None: + stripped = value.strip() + if not stripped: + self.session.status_message = "State change cancelled" + return + selected_state = resolve_todo_state_selection(stripped, states) + if selected_state is None: + self.session.status_message = "Invalid TODO state selection" + return + events.apply_state_change_with_value(self.session, selected_state) + + self._open_prompt( + state_selection_prompt_label(states), + on_submit=_submit, + on_cancel=lambda: self._set_status("State change cancelled"), + ) + + def action_prompt_tags(self) -> None: + """Prompt for replacement tag CSV on the selected task.""" + self._open_prompt( + tags_prompt_label(), + on_submit=lambda value: events.apply_tags_edit(self.session, value), + on_cancel=lambda: self._set_status("Tags edit cancelled"), + ) + + def _set_status(self, status_message: str) -> None: + self.session.status_message = status_message + + def _prompt_planning(self, field: PlanningTimestampField) -> None: + self._open_prompt( + planning_prompt_label(field), + on_submit=lambda value: events.apply_planning_timestamp_edit( + self.session, + field=field, + raw_timestamp=value, + ), + on_cancel=lambda: self._set_status("Planning timestamp edit cancelled"), + ) + + def action_prompt_scheduled(self) -> None: + """Prompt for a scheduled timestamp value.""" + self._prompt_planning("scheduled") + + def action_prompt_deadline(self) -> None: + """Prompt for a deadline timestamp value.""" + self._prompt_planning("deadline") + + def action_prompt_closed(self) -> None: + """Prompt for a closed timestamp value.""" + self._prompt_planning("closed") + + +def run_tasks_list_app(args: ListArgs, data: _TasksListSessionData) -> None: + """Run the Textual-backed interactive tasks list app.""" + TasksListApp(args, data).run() diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index ba62559..071c5bf 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -13,7 +13,7 @@ from org import config as config_module from org.cli_common import load_and_process_data -from org.commands.interactive_common import interactive_help_command_text, interactive_loop +from org.commands.interactive_common import interactive_help_command_text from org.output_format import ( DEFAULT_OUTPUT_THEME, OutputFormat, @@ -37,12 +37,11 @@ setup_output, ) -from . import events, layout +from . import events +from .app import run_tasks_list_app if TYPE_CHECKING: - from collections.abc import Callable - from org_parser.document import Heading from rich.console import Console @@ -346,32 +345,15 @@ def _run_tasks_list_static( def _run_tasks_list_interactive( - console: Console, args: ListArgs, data: _TasksListSessionData, ) -> None: - """Run interactive tasks-list UI session.""" + """Run interactive tasks-list UI session via Textual.""" if not data.nodes: + console = build_console(data.color_enabled, args.width) console.print("No results", markup=False) return - - session = events.create_tasks_list_session(args, data) - - run_external: list[Callable[[Callable[[], None]], None]] = [events.passthrough_run_external] - - def _bind_run_external(callback: Callable[[Callable[[], None]], None]) -> None: - run_external[0] = callback - session.run_external = callback - - session.run_external = run_external[0] - - interactive_loop( - console=console, - render=lambda: layout.interactive_tasks_list_renderable(console, session), - on_event=lambda event: events.handle_interactive_event(session, event, run_external[0]), - bind_run_external=_bind_run_external, - timeout_seconds=None, - ) + run_tasks_list_app(args, data) def _is_cli_option_present(argv: list[str], option: str) -> bool: @@ -410,7 +392,7 @@ def run_tasks_list(args: ListArgs) -> None: ) if not _effective_noninteractive_mode(args): - _run_tasks_list_interactive(console, args, session_data) + _run_tasks_list_interactive(args, session_data) return _run_tasks_list_static(console, args, session_data) diff --git a/src/org/commands/tasks/list/events.py b/src/org/commands/tasks/list/events.py index 36ac904..51670cd 100644 --- a/src/org/commands/tasks/list/events.py +++ b/src/org/commands/tasks/list/events.py @@ -12,18 +12,10 @@ from org.commands.archive import archive_heading_subtree_and_save from org.commands.editor import edit_heading_subtree_in_external_editor from org.commands.interactive_common import ( - FooterPromptState, HeadingLocator, - InputEvent, - InteractiveEvent, InteractiveHelpEntry, - InteractivePromptState, - TimeoutEvent, advance_timestamp_by_repeater, append_repeat_transition, - apply_help_modal_key, - create_interactive_prompt_state, - handle_active_prompt_event, heading_locator, local_now, resolve_heading_locator, @@ -33,25 +25,15 @@ from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( PlanningTimestampField, - PromptActionConfig, - capture_template_prompt_config, - configured_capture_template_names, planning_field_label, - planning_prompt_config, replace_heading_tags_from_csv, replace_planning_timestamp_from_raw, - resolve_capture_template_selection, - resolve_todo_state_selection, save_document, - state_selection_prompt_config, - tags_prompt_config, todo_states_for_heading, ) if TYPE_CHECKING: - from collections.abc import Callable - from org_parser.document import Document, Heading from .command import ListArgs, _TasksListSessionData @@ -74,10 +56,6 @@ class TasksListSession: scroll_offset: int status_message: str search_text: str - search_prompt_previous_text: str | None = None - show_help_modal: bool = False - active_prompt: InteractivePromptState | None = None - run_external: Callable[[Callable[[], None]], None] | None = None TASKS_LIST_HELP_ENTRIES = [ @@ -427,274 +405,6 @@ def create_tasks_list_session(args: ListArgs, data: _TasksListSessionData) -> Ta scroll_offset=0, status_message="", search_text="", - show_help_modal=False, - active_prompt=None, - run_external=None, ) ensure_selection_bounds(session) return session - - -def _activate_prompt( - session: TasksListSession, - config: PromptActionConfig, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> None: - """Attach one active footer prompt to the tasks list session.""" - session.active_prompt = create_interactive_prompt_state( - config, - submit_value=submit_value, - preview_value=preview_value, - cancel=cancel, - ) - - -def _activate_capture_prompt(session: TasksListSession) -> None: - """Activate capture-template prompt when templates are configured.""" - template_names = configured_capture_template_names() - if not template_names: - session.status_message = "No capture templates configured" - return - - config = capture_template_prompt_config() - - def _submit(value: str) -> bool: - value = value.strip() - template_name = resolve_capture_template_selection(value, template_names) - if template_name is None and not value: - session.status_message = config.cancel_status - return False - if template_name is None: - session.status_message = config.invalid_status - return True - _run_external(session, lambda: apply_capture_task(session, template_name)) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_search_prompt(session: TasksListSession) -> None: - """Activate search prompt and preserve current search text for cancellation.""" - session.search_prompt_previous_text = session.search_text - config = PromptActionConfig( - prompt=FooterPromptState(label="Search text (blank clears)"), - cancel_status="Search cancelled", - invalid_status="Invalid search input", - ) - - def _submit(value: str) -> bool: - session.search_prompt_previous_text = None - apply_search_text(session, value.strip()) - return False - - def _preview(value: str) -> None: - apply_search_text(session, value.strip()) - - def _cancel() -> None: - previous_text = session.search_prompt_previous_text or "" - session.search_prompt_previous_text = None - apply_search_text(session, previous_text) - session.status_message = config.cancel_status - - _activate_prompt( - session, - config, - submit_value=_submit, - preview_value=_preview, - cancel=_cancel, - ) - - -def _activate_tags_prompt(session: TasksListSession) -> None: - """Activate tags editing prompt for the selected task.""" - if selected_node(session) is None: - session.status_message = "Action available only on task rows" - return - - config = tags_prompt_config() - - def _submit(value: str) -> bool: - apply_tags_edit(session, value) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_planning_prompt(session: TasksListSession, field: PlanningTimestampField) -> None: - """Activate one planning timestamp prompt for the selected task.""" - if selected_node(session) is None: - session.status_message = "Action available only on task rows" - return - - config = planning_prompt_config(field) - - def _submit(value: str) -> bool: - apply_planning_timestamp_edit(session, field=field, raw_timestamp=value) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_state_selection_prompt(session: TasksListSession, states: list[str]) -> None: - """Activate TODO-state selection prompt for the selected task.""" - config = state_selection_prompt_config(states) - - def _submit(value: str) -> bool: - value = value.strip() - selected_state = resolve_todo_state_selection(value, states) - if selected_state is None and not value: - session.status_message = config.cancel_status - return False - if selected_state is None: - session.status_message = config.invalid_status - return True - apply_state_change_with_value(session, selected_state) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _handle_capture_prompt_activation(session: TasksListSession) -> None: - _activate_capture_prompt(session) - - -def _handle_search_prompt_activation(session: TasksListSession) -> None: - _activate_search_prompt(session) - - -def _handle_state_prompt_activation(session: TasksListSession) -> None: - status_message = can_activate_state_prompt(session) - if status_message is not None: - session.status_message = status_message - return - _activate_state_selection_prompt(session, state_choices_for_selected_node(session)) - - -def _handle_tags_prompt_activation(session: TasksListSession) -> None: - _activate_tags_prompt(session) - - -def _handle_scheduled_prompt_activation(session: TasksListSession) -> None: - _activate_planning_prompt(session, "scheduled") - - -def _handle_deadline_prompt_activation(session: TasksListSession) -> None: - _activate_planning_prompt(session, "deadline") - - -def _handle_closed_prompt_activation(session: TasksListSession) -> None: - _activate_planning_prompt(session, "closed") - - -def _handle_help_modal_event(session: TasksListSession, event: InteractiveEvent) -> bool: - if not session.show_help_modal: - return False - if not isinstance(event, TimeoutEvent): - session.show_help_modal = False - return True - - -def _handle_navigation_key(session: TasksListSession, key: str) -> bool: - handler = { - "n": lambda: move_selection(session, 1), - "DOWN": lambda: move_selection(session, 1), - "WHEEL-DOWN": lambda: move_selection(session, 1), - "p": lambda: move_selection(session, -1), - "UP": lambda: move_selection(session, -1), - "WHEEL-UP": lambda: move_selection(session, -1), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_mutation_key( - session: TasksListSession, - key: str, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "ENTER": lambda: run_external(lambda: edit_selected_task_in_external_editor(session)), - "$": lambda: archive_selected_task(session), - "x": lambda: clear_search(session), - "S-UP": lambda: apply_priority_shift(session, increase=True), - "S-DOWN": lambda: apply_priority_shift(session, increase=False), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_prompt_activation_key( - session: TasksListSession, - key: str, - _run_external_callback: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "a": _handle_capture_prompt_activation, - "/": _handle_search_prompt_activation, - "t": _handle_state_prompt_activation, - "g": _handle_tags_prompt_activation, - "s": _handle_scheduled_prompt_activation, - "d": _handle_deadline_prompt_activation, - "c": _handle_closed_prompt_activation, - }.get(key) - if handler is None: - return False - handler(session) - return True - - -def _handle_keypress_event( - session: TasksListSession, - key: str, - run_external: Callable[[Callable[[], None]], None] | None = None, -) -> bool: - effective_run_external = passthrough_run_external if run_external is None else run_external - consumed, next_help_modal = apply_help_modal_key(key, show_help_modal=session.show_help_modal) - session.show_help_modal = next_help_modal - if consumed: - return True - if key in {"q", "ESC"}: - return False - if _handle_navigation_key(session, key): - return True - if _handle_mutation_key(session, key, effective_run_external): - return True - if _handle_prompt_activation_key(session, key, effective_run_external): - return True - if key and key != "IGNORE": - session.status_message = f"Unsupported key: {key}" - return True - - -def passthrough_run_external(callback: Callable[[], None]) -> None: - """Run an external callback immediately.""" - callback() - - -def _run_external(session: TasksListSession, callback: Callable[[], None]) -> None: - runner = session.run_external or passthrough_run_external - runner(callback) - - -def handle_interactive_event( - session: TasksListSession, - event: InteractiveEvent, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - """Handle one tasks-list interactive event.""" - if _handle_help_modal_event(session, event): - return True - if session.active_prompt is not None: - return handle_active_prompt_event(session, event) - if isinstance(event, TimeoutEvent): - return True - if isinstance(event, InputEvent): - return True - return _handle_keypress_event(session, event.key, run_external) diff --git a/src/org/commands/tasks/list/layout.py b/src/org/commands/tasks/list/layout.py deleted file mode 100644 index a543417..0000000 --- a/src/org/commands/tasks/list/layout.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tasks list interactive and static layout helpers.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from rich.console import Console, Group -from rich.rule import Rule -from rich.table import Table -from rich.text import Text - -from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, - build_footer_prompt_text, - render_interactive_help_modal, -) -from org.tui import TaskLineConfig, format_task_line - -from .events import TASKS_LIST_HELP_ENTRIES, TasksListSession, ensure_selection_bounds - - -if TYPE_CHECKING: - from org_parser.document import Heading - - -_HIGHLIGHT_ROW_STYLE = "on grey23" - - -def build_task_row_text( - node: Heading, - session: TasksListSession, - *, - line_width: int, -) -> Text: - """Build one interactive row using the static task line format.""" - line = format_task_line( - node, - TaskLineConfig( - color_enabled=session.color_enabled, - done_states=session.done_states, - todo_states=session.todo_states, - line_width=line_width, - ), - ) - if session.color_enabled: - return Text.from_markup(line) - return Text(line) - - -def _sync_scroll(session: TasksListSession, viewport_height: int) -> None: - """Keep selected row inside the current viewport window.""" - max_offset = max(0, len(session.visible_nodes) - viewport_height) - session.scroll_offset = min(max(session.scroll_offset, 0), max_offset) - - if not session.visible_nodes: - return - - if session.selected_index < session.scroll_offset: - session.scroll_offset = session.selected_index - elif session.selected_index >= session.scroll_offset + viewport_height: - session.scroll_offset = session.selected_index - viewport_height + 1 - - session.scroll_offset = min(max(session.scroll_offset, 0), max_offset) - - -def interactive_tasks_list_renderable(console: Console, session: TasksListSession) -> Group: - """Build scrollable interactive tasks list renderable.""" - if session.show_help_modal: - return Group( - render_interactive_help_modal( - TASKS_LIST_HELP_ENTRIES, - color_enabled=session.color_enabled, - ), - ) - - viewport_height = max(5, console.size.height - 3) - ensure_selection_bounds(session) - _sync_scroll(session, viewport_height) - - window = session.visible_nodes[session.scroll_offset : session.scroll_offset + viewport_height] - viewport_table = Table.grid(expand=True) - viewport_table.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - for index, node in enumerate(window, start=session.scroll_offset): - row_style = _HIGHLIGHT_ROW_STYLE if index == session.selected_index else "" - viewport_table.add_row( - build_task_row_text(node, session, line_width=console.size.width), - style=row_style, - ) - - for _ in range(viewport_height - len(window)): - viewport_table.add_row(Text("")) - - selected_row = session.selected_index + 1 if session.visible_nodes else 0 - total_rows = len(session.visible_nodes) - search_text = session.search_text or "-" - row_text = f"Rows {selected_row}/{total_rows} | Search: {search_text}" - prompt_line = None - active_prompt = session.active_prompt - if active_prompt is not None: - prompt_line = build_footer_prompt_text(active_prompt.prompt) - status = session.status_message or "" - footer_style = "dim" if session.color_enabled else "" - - footer_line = Table.grid(expand=True) - footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - footer_line.add_row( - Text(row_text, style=footer_style, no_wrap=True, overflow="ellipsis"), - Text( - INTERACTIVE_HELP_FOOTER_HINT, - style=footer_style, - no_wrap=True, - overflow="ellipsis", - ), - ) - - status_text = Text(status, style=footer_style, no_wrap=True, overflow="ellipsis") - if prompt_line is None: - return Group(viewport_table, Rule(style=footer_style), footer_line, status_text) - return Group(viewport_table, Rule(style=footer_style), footer_line, prompt_line, status_text) diff --git a/src/org/commands/tasks/list/styles/app.tcss b/src/org/commands/tasks/list/styles/app.tcss new file mode 100644 index 0000000..1e3f41e --- /dev/null +++ b/src/org/commands/tasks/list/styles/app.tcss @@ -0,0 +1,12 @@ +Screen { + layout: vertical; +} + +#tasks-body { + height: 1fr; +} + +#tasks-footer, +#tasks-status { + height: auto; +} diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index c0cc46b..61c3bad 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -7,7 +7,7 @@ import sys from contextlib import contextmanager from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING import click import pytest @@ -17,16 +17,11 @@ from org import config as config_module from org.commands import archive as archive_command from org.commands import editor as editor_command -from org.commands.interactive_common import ( - KeypressEvent, - handle_active_prompt_event, - heading_locator, -) +from org.commands.interactive_common import heading_locator from org.commands.search_common import filter_nodes_by_search from org.commands.tasks import capture as capture_command from org.commands.tasks.list import command as tasks_list from org.commands.tasks.list import events as tasks_list_events -from org.commands.tasks.list import layout as tasks_list_layout from org.histogram import visual_len from org.output_format import OutputFormat, OutputFormatError from tests.conftest import node_from_org @@ -37,7 +32,6 @@ from pathlib import Path from org_parser.document import Document, Heading - from rich.live import Live FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures") @@ -622,7 +616,6 @@ def test_run_tasks_list_uses_interactive_mode_when_tty_and_not_blocked( called = {"interactive": False} def _fake_interactive( - _console: Console, _args: tasks_list.ListArgs, data: object, ) -> None: @@ -753,108 +746,10 @@ def test_filter_nodes_by_search_handles_non_string_property_values() -> None: assert [item.title_text for item in matches] == ["Parent"] -def test_handle_interactive_key_mouse_wheel_moves_selection() -> None: - """Mouse wheel key tokens should move interactive selection.""" - nodes = node_from_org("* TODO A\n* TODO B\n") - session = _make_session(nodes) - assert tasks_list_events._handle_keypress_event(session, "WHEEL-DOWN") is True - assert session.selected_index == 1 - assert tasks_list_events._handle_keypress_event(session, "WHEEL-UP") is True - assert session.selected_index == 0 - - -def test_interactive_rows_counter_tracks_selected_row_while_scrolling() -> None: - """Rows counter should follow selected row index in both directions.""" - nodes = node_from_org("* TODO A\n* TODO B\n* TODO C\n") - session = _make_session(nodes) - session.selected_index = 1 - total_rows = len(session.visible_nodes) - - down_capture = StringIO() - down_console = Console( - file=down_capture, - force_terminal=False, - width=80, - height=8, - no_color=True, - ) - down_console.print(tasks_list_layout.interactive_tasks_list_renderable(down_console, session)) - assert f"Rows 2/{total_rows}" in down_capture.getvalue() - assert "Type ? for help" in down_capture.getvalue() - - session.selected_index = 0 - up_capture = StringIO() - up_console = Console(file=up_capture, force_terminal=False, width=80, height=8, no_color=True) - up_console.print(tasks_list_layout.interactive_tasks_list_renderable(up_console, session)) - assert f"Rows 1/{total_rows}" in up_capture.getvalue() - assert "Type ? for help" in up_capture.getvalue() - - -def test_handle_interactive_key_question_toggles_help_modal() -> None: - """Question mark should open help modal and next key should close it.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - - assert tasks_list_events._handle_keypress_event(session, "?") is True - assert session.show_help_modal is True - - assert tasks_list_events._handle_keypress_event(session, "ENTER") is True - assert session.show_help_modal is False - - -def test_interactive_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel in task list.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - session.show_help_modal = True - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=100, height=12, no_color=True) - - console.print(tasks_list_layout.interactive_tasks_list_renderable(console, session)) - output = buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - -def test_interactive_task_row_reuses_static_line_format_with_level_prefix_and_tag_alignment() -> ( - None -): - """Interactive rows should keep static format including stars and right-aligned tags.""" - nodes = node_from_org("* TODO Task title :alpha:beta:\n") - session = _make_session(nodes) - - row = tasks_list_layout.build_task_row_text(nodes[0], session, line_width=80) - - assert "* TODO" in row.plain - assert row.plain.rstrip().endswith(":alpha:beta:") - assert visual_len(row.plain) == 80 - - -def test_interactive_task_row_parses_color_markup_without_literal_tags() -> None: - """Interactive colored row should render markup instead of showing markup literals.""" - nodes = node_from_org("* DONE Task title\n") - session = _make_session(nodes, color_enabled=True) - - row = tasks_list_layout.build_task_row_text(nodes[0], session, line_width=80) - - assert "[dim white]" not in row.plain - assert "[/]" not in row.plain - - -def test_handle_interactive_key_unsupported_sets_status_and_continues() -> None: - """Unsupported key should set status message without exiting interactive mode.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - key = "UNSUPPORTED-ESC:1b5b3939397e" - assert tasks_list_events._handle_keypress_event(session, key) is True - assert session.status_message == f"Unsupported key: {key}" - - -def test_handle_interactive_key_enter_edits_selected_task( +def test_edit_selected_task_in_external_editor_sets_no_changes_status( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Enter should trigger external-editor task editing on selected row.""" + """Editing the selected task should report when nothing changed.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) @@ -863,11 +758,11 @@ def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: monkeypatch.setattr(tasks_list_events, "edit_heading_subtree_in_external_editor", _fake_edit) - assert tasks_list_events._handle_keypress_event(session, "ENTER") is True + tasks_list_events.edit_selected_task_in_external_editor(session) assert session.status_message == "No changes." -def test_handle_interactive_key_enter_reloads_using_selected_node_identity_after_changed_edit( +def test_edit_selected_task_in_external_editor_reloads_using_selected_node_identity( monkeypatch: pytest.MonkeyPatch, ) -> None: """Changed edit should reload using the originally selected session node identity.""" @@ -892,15 +787,15 @@ def _fake_reload( monkeypatch.setattr(tasks_list_events, "edit_heading_subtree_in_external_editor", _fake_edit) monkeypatch.setattr(tasks_list_events, "reload_session_nodes", _fake_reload) - assert tasks_list_events._handle_keypress_event(session, "ENTER") is True + tasks_list_events.edit_selected_task_in_external_editor(session) assert reloaded_identity == heading_locator(source_node) assert session.status_message == "Task updated" -def test_handle_interactive_key_dollar_archives_selected_task( +def test_archive_selected_task_archives_selected_task( monkeypatch: pytest.MonkeyPatch, ) -> None: - """$ should archive selected task using shared archive helper.""" + """Archiving should use the shared archive helper and refresh status.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) @@ -932,14 +827,14 @@ def _fake_archive( lambda _session, _identity: True, ) - assert tasks_list_events._handle_keypress_event(session, "$") is True + tasks_list_events.archive_selected_task(session) assert session.status_message == "Task archived" -def test_handle_interactive_key_a_captures_task_and_reloads( +def test_apply_capture_task_captures_task_and_reloads( monkeypatch: pytest.MonkeyPatch, ) -> None: - """a should prompt for template then capture and reload list session.""" + """Capturing should reload the list session with the captured task identity.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) captured_node = node_from_org("* TODO Captured\n")[0] @@ -962,7 +857,7 @@ def test_handle_interactive_key_a_captures_task_and_reloads( def _fake_reload( current_session: tasks_list_events.TasksListSession, - preserve_identity: tuple[str, str, str, int | None] | None, + preserve_identity: object, ) -> bool: reload_args["session"] = current_session reload_args["identity"] = preserve_identity @@ -970,198 +865,42 @@ def _fake_reload( monkeypatch.setattr(tasks_list_events, "reload_session_nodes", _fake_reload) - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False + tasks_list_events.apply_capture_task(session, "quick") assert reload_args["session"] is session assert reload_args["identity"] == heading_locator(captured_node) assert session.status_message == "Task captured" -def test_handle_interactive_key_a_capture_blank_input_cancels( +def test_apply_capture_task_reports_keyboard_interrupt_as_cancelled( monkeypatch: pytest.MonkeyPatch, ) -> None: - """a should close prompt with cancel status on blank template input.""" + """Capture should report keyboard interruption as cancellation.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert session.status_message == "Capture cancelled" + def _raise_interrupt(_args: object) -> object: + raise KeyboardInterrupt -def test_handle_interactive_key_a_capture_invalid_shortcut_keeps_prompt_open( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should keep capture prompt open for invalid template shortcuts.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "99" - keep_open = session.active_prompt.submit_callback() + monkeypatch.setattr(tasks_list_events, "capture_task", _raise_interrupt) - assert keep_open is True - assert session.status_message == "Invalid capture template shortcut" + tasks_list_events.apply_capture_task(session, "quick") + assert session.status_message == "Capture cancelled" -def test_handle_interactive_key_a_without_templates_reports_error( +def test_apply_capture_task_reports_bad_parameter_error( monkeypatch: pytest.MonkeyPatch, ) -> None: - """a should report a clear status when no capture templates are configured.""" - nodes = node_from_org("* TODO A\n") - session = _make_session(nodes) - monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {}) - - assert tasks_list_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is None - assert session.status_message == "No capture templates configured" - - -def test_prompt_submit_capture_stops_and_restarts_live(monkeypatch: pytest.MonkeyPatch) -> None: - """Prompt submission should pause live rendering for capture action.""" + """Capture should surface `BadParameter` errors in session status.""" nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - tasks_list_events._handle_keypress_event(session, "a") - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - captured_node = node_from_org("* TODO Captured\n")[0] - monkeypatch.setattr( - tasks_list_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr( - tasks_list_events, - "reload_session_nodes", - lambda _session, _identity: True, - ) - events: list[str] = [] + def _raise_bad_parameter(_args: object) -> object: + raise typer.BadParameter("Invalid capture template shortcut") - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) + monkeypatch.setattr(tasks_list_events, "capture_task", _raise_bad_parameter) - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - controller = cast("Any", _InputControllerStub()) - - def _run_prompt_external(callback: object) -> None: - controller.suspend_live(live) - try: - cast("Any", callback)() - finally: - controller.resume_live(live) - - session.run_external = _run_prompt_external - - consumed = handle_active_prompt_event(session, KeypressEvent("ENTER")) - live.update( - tasks_list_layout.interactive_tasks_list_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert events == ["input-stop", "stop", "start", "input-start", "update"] - - -def test_search_prompt_live_updates_and_escape_reverts_search() -> None: - """Search prompt should live-update results and ESC should restore prior filter.""" - nodes = node_from_org("* TODO Alpha\n* TODO Beta\n") - session = _make_session(nodes) - assert tasks_list_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - consumed = handle_active_prompt_event(session, KeypressEvent("b")) - live.update( - tasks_list_layout.interactive_tasks_list_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert [node.title_text for node in session.visible_nodes] == ["Beta"] - assert session.search_text == "b" - assert session.status_message == "1 matches" - - consumed = handle_active_prompt_event(session, KeypressEvent("ESC")) - live.update( - tasks_list_layout.interactive_tasks_list_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert [node.title_text for node in session.visible_nodes] == ["Alpha", "Beta"] - assert session.search_text == "" - assert session.status_message == "Search cancelled" - assert events == ["update", "update"] + tasks_list_events.apply_capture_task(session, "quick") + assert session.status_message == "Invalid capture template shortcut" def test_apply_state_change_appends_repeat_transition(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py new file mode 100644 index 0000000..5c1e764 --- /dev/null +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -0,0 +1,183 @@ +"""App tests for the tasks list command.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast + +from textual.widgets import Input, Static + +from org.commands.tasks.list import command as tasks_list +from org.commands.tasks.list.app import TasksListApp +from tests.commands.tasks.test_tasks_list import make_list_args +from tests.conftest import node_from_org + + +if TYPE_CHECKING: + import pytest + from org_parser.document import Heading + + +def _make_session_data(nodes: list[Heading]) -> tasks_list._TasksListSessionData: + return tasks_list._TasksListSessionData( + nodes=nodes, + todo_states=["TODO"], + done_states=["DONE"], + color_enabled=False, + ) + + +def test_tasks_list_app_moves_selection_with_arrow_keys() -> None: + """Arrow-key navigation should update the selected task row.""" + + async def _run() -> None: + nodes = node_from_org("* TODO A\n* TODO B\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + await pilot.press("down") + await pilot.pause() + assert app.session.selected_index == 1 + + await pilot.press("up") + await pilot.pause() + assert app.session.selected_index == 0 + + asyncio.run(_run()) + + +def test_tasks_list_app_search_prompt_filters_results_live() -> None: + """Search prompt should update visible tasks through Textual input flow.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\nBody\n* TODO Beta\nOther\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + await pilot.press("slash") + await pilot.pause() + + prompt_input = app.screen.query_one(Input) + assert prompt_input.value == "" + + await pilot.press("a", "l", "p", "h", "a") + await pilot.pause() + assert app.session.search_text == "alpha" + assert [node.title_text for node in app.session.visible_nodes] == ["Alpha"] + + await pilot.press("enter") + await pilot.pause() + assert app.session.search_text == "alpha" + assert [node.title_text for node in app.session.visible_nodes] == ["Alpha"] + + asyncio.run(_run()) + + +def test_tasks_list_app_help_modal_shows_key_bindings() -> None: + """Help modal should stay open and show key binding text.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + await pilot.press("question_mark") + await pilot.pause() + + help_title = app.screen.query_one("#help-title", Static) + help_content = app.screen.query_one("#help-content", Static) + title_rendered = cast("Any", help_title).render() + rendered = cast("Any", help_content).render() + title_text = getattr(title_rendered, "plain", str(title_rendered)) + plain_text = getattr(rendered, "plain", str(rendered)) + assert title_text == "Key bindings" + assert "Key bindings:" not in plain_text + assert "Esc/q" in plain_text + + asyncio.run(_run()) + + +def test_tasks_list_app_search_prompt_shows_label() -> None: + """Search prompt should display its label when opened.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + await pilot.press("slash") + await pilot.pause() + + prompt_label = app.screen.query_one("#prompt-label", Static) + rendered = cast("Any", prompt_label).render() + plain_text = getattr(rendered, "plain", str(rendered)) + assert "Search text" in plain_text + + asyncio.run(_run()) + + +def test_tasks_list_app_escape_cancels_prompt_without_exiting() -> None: + """Escape should dismiss the prompt modal without triggering app quit.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n* TODO Beta\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + await pilot.press("slash") + await pilot.pause() + assert app.screen.query_one("#prompt-label", Static) is not None + + await pilot.press("escape") + await pilot.pause() + + assert app.is_running + assert app.session.status_message == "Search cancelled" + assert app.screen.query_one("#tasks-body", Static) is not None + + asyncio.run(_run()) + + +def test_tasks_list_app_help_modal_applies_key_to_underlying_screen() -> None: + """Help modal should close and forward the pressed key to the app.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n* TODO Beta\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + await pilot.press("question_mark") + await pilot.pause() + assert app.screen.query_one("#help-content", Static) is not None + + await pilot.press("down") + await pilot.pause() + + assert app.session.selected_index == 1 + assert app.screen.query_one("#tasks-body", Static) is not None + + asyncio.run(_run()) + + +def test_run_tasks_list_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Interactive tasks list path should delegate to the Textual app runner.""" + nodes = node_from_org("* TODO Alpha\n") + data = _make_session_data(nodes) + called = {"value": False} + + def _fake_run( + args: tasks_list.ListArgs, + session_data: tasks_list._TasksListSessionData, + ) -> None: + called["value"] = True + assert args.files == [] + assert session_data.nodes == nodes + + monkeypatch.setattr("org.commands.tasks.list.command.run_tasks_list_app", _fake_run) + + tasks_list._run_tasks_list_interactive( + make_list_args([]), + data, + ) + + assert called["value"] is True From 36b4fd1630e3b4075e8097a646ae4912de626264 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Fri, 5 Jun 2026 13:22:28 +0200 Subject: [PATCH 05/27] Converted tasks capture to Textual --- VIBES.org | 54 +- src/org/commands/tasks/capture.py | 835 ------------------ src/org/commands/tasks/capture/__init__.py | 47 + src/org/commands/tasks/capture/app.py | 216 +++++ src/org/commands/tasks/capture/command.py | 130 +++ src/org/commands/tasks/capture/domain.py | 424 +++++++++ .../commands/tasks/capture/styles/app.tcss | 63 ++ .../tasks/capture/styles/selection.tcss | 15 + tests/cli/test_cli_runner.py | 94 +- tests/cli/test_cli_smoke.py | 16 +- .../commands/tasks/test_tasks_capture_app.py | 125 +++ tests/commands/test_tasks_capture_command.py | 830 +++-------------- tests/fixtures/empty-config.yaml | 2 + 13 files changed, 1261 insertions(+), 1590 deletions(-) delete mode 100644 src/org/commands/tasks/capture.py create mode 100644 src/org/commands/tasks/capture/__init__.py create mode 100644 src/org/commands/tasks/capture/app.py create mode 100644 src/org/commands/tasks/capture/command.py create mode 100644 src/org/commands/tasks/capture/domain.py create mode 100644 src/org/commands/tasks/capture/styles/app.tcss create mode 100644 src/org/commands/tasks/capture/styles/selection.tcss create mode 100644 tests/commands/tasks/test_tasks_capture_app.py create mode 100644 tests/fixtures/empty-config.yaml diff --git a/VIBES.org b/VIBES.org index f09fa30..ec8c261 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7369,10 +7369,13 @@ The help modal (key binding: ?) doesn't show the bindings, just a blank screen. :END: Modals that can be cancelled with the Esc key, also cause the whole app to exit. These should only close the modal. The exception should be the key-bindings modal - when any button is pressed, it should close the modal and apply that binding to the underlying screen. The modal should be updated to have a clear title (same as the other input modals) that says "Key bindings". The final line that says "Press any key to return" should be removed. -**** TODO src/org/commands/tasks/list/command.py +**** DONE src/org/commands/tasks/list/command.py :PROPERTIES: :context: :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 11:35] +:END: In file src/org/commands/tasks/list/command.py around line 375, near the ~def _run_tasks_list_interactive(~ @@ -7392,6 +7395,55 @@ It's better to refactor the function call sites not to pass the =console= there. :LOGBOOK: - State "DONE" from "TODO" [2026-06-05 Fri 11:20] :END: +**** DONE Tasks capture preview should be on the left of the form. +:PROPERTIES: +:CAPTURE_TIME: <2026-06-05 Fri 12:03> +:ID: Vibes-490 +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 13:17] +:END: + +Instead of using two columns, please move the task preview panel to the left of the screen and the form to the right. +The inputs should support newlines (enter key). The save/cancel buttons should be center-aligned underr the form, but on the same line. + +Ideally, the form itself should be part of the preview (as-in, the task preview is displayed as the main panel, with input boxes in place of the placeholders.) + +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-05 Fri 13:22] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/tests/commands/tasks/test_tasks_capture_app.py::capture.TasksCaptureArgs( + template_name="quick", + config=".org-cli.yaml", + file=None, + parent=None, + set_values=None, + ), + "quick",]] +:END: + +Filename: /home/k/projects/software/org-cli/tests/commands/tasks/test_tasks_capture_app.py + +#+begin_example + capture.TasksCaptureArgs( + template_name="quick", + config=".org-cli.yaml", + file=None, + parent=None, + set_values=None, + ), + "quick", +#+end_example + +Please don't rely on the .org-cli.yaml file present in the repository in tests. Substritute a temporary file if the configuration is needed. +**** TODO The test suite is a lot slower after the Textual rewrite +:PROPERTIES: +:capture_time: [2026-06-05 Fri 13:19] +:context: [[file:~/projects/software/org-cli/VIBES.org::*Code review comment][Code review comment]] +:END: +It used to be under 8 seconds. Please identify the slow tests and update them to run faster. *** Use Textual widgets for better TUI organization - Selection for prompts - Sparkline for freequency plots diff --git a/src/org/commands/tasks/capture.py b/src/org/commands/tasks/capture.py deleted file mode 100644 index b87527d..0000000 --- a/src/org/commands/tasks/capture.py +++ /dev/null @@ -1,835 +0,0 @@ -"""Tasks capture command for creating tasks from named templates.""" - -from __future__ import annotations - -import logging -import re -import sys -import termios -import tty -from dataclasses import dataclass -from datetime import datetime -from uuid import uuid4 - -import typer -from org_parser.document import Document, Heading -from rich.console import Console, Group, RenderableType -from rich.layout import Layout -from rich.live import Live -from rich.rule import Rule -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text -from typer.rich_utils import _get_rich_console - -from org import config as config_module -from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, - InteractiveHelpEntry, - apply_help_modal_key, - interactive_help_command_text, - read_input_event, - render_interactive_help_modal, - set_bracketed_paste, -) -from org.commands.tasks.common import load_document, resolve_parent_heading, save_document -from org.output_format import DEFAULT_OUTPUT_THEME -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) - - -_PLACEHOLDER_RE = re.compile(r"{{\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*}}") -_ORG_WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") -logger = logging.getLogger("org") - - -_CAPTURE_HELP_ENTRIES = [ - InteractiveHelpEntry( - "Enter", - "Confirm current placeholder value and move to the next unresolved field.", - ), - InteractiveHelpEntry( - "Left/Right", - "Move the input cursor inside the current placeholder value.", - ), - InteractiveHelpEntry("Home/End", "Jump the cursor to the start or end of the input value."), - InteractiveHelpEntry( - "Backspace/Delete", - "Delete one character left/right of cursor in the current input.", - ), - InteractiveHelpEntry( - "Type text / Paste", - "Insert typed or pasted text at cursor position in the current placeholder value.", - ), - InteractiveHelpEntry( - "Esc or Ctrl-C", - "Cancel capture without applying the currently active interactive input flow.", - ), -] - - -@dataclass -class TasksCaptureArgs: - """Arguments for the tasks capture command.""" - - template_name: str | None - config: str - file: str | None - parent: str | None - set_values: list[str] | None - - -@dataclass(frozen=True) -class TasksCaptureResult: - """Result metadata for one successful capture operation.""" - - template_name: str - heading: Heading - document: Document - interactive_used: bool = False - - -def _template_names(templates: dict[str, dict[str, str]]) -> list[str]: - """Return stable template name ordering for prompts and errors.""" - return sorted(templates) - - -def _require_templates(templates: dict[str, dict[str, str]]) -> None: - """Require at least one capture template before running capture.""" - if templates: - return - raise typer.BadParameter("No capture templates configured") - - -def _valid_template_names_text(template_names: list[str]) -> str: - """Return comma-separated template names for error messages.""" - return ", ".join(template_names) - - -def _select_template_name(template_names: list[str]) -> str: - """Prompt user for template selection by numeric index.""" - typer.echo("Select capture template:") - for index, name in enumerate(template_names, start=1): - typer.echo(f"{index}) {name}") - - selected_text = typer.prompt("Template number").strip() - if not selected_text: - raise typer.BadParameter("Template selection cannot be empty") - if not selected_text.isdigit(): - raise typer.BadParameter("Template selection must be a number") - - selected_index = int(selected_text) - if selected_index < 1 or selected_index > len(template_names): - raise typer.BadParameter( - f"Unknown template number {selected_index}. " - f"Valid template numbers: 1-{len(template_names)}", - ) - return template_names[selected_index - 1] - - -def _resolve_template_name( - template_name: str | None, - templates: dict[str, dict[str, str]], -) -> tuple[str, bool]: - """Resolve template name from CLI argument or interactive selection.""" - template_names = _template_names(templates) - if template_name is None: - return (_select_template_name(template_names), True) - - if template_name in templates: - return (template_name, False) - - valid_names = _valid_template_names_text(template_names) - raise typer.BadParameter( - f"Unknown capture template '{template_name}'. Valid templates: {valid_names}", - ) - - -def _template_placeholders(template_content: str) -> list[str]: - """Return unique placeholders in first-appearance order.""" - placeholders = [match.group(1) for match in _PLACEHOLDER_RE.finditer(template_content)] - return list(dict.fromkeys(placeholders)) - - -@dataclass(frozen=True) -class _TemplatePreview: - """Rendered capture template preview with placeholder spans.""" - - text: str - placeholder_spans: dict[str, list[tuple[int, int]]] - - -@dataclass(frozen=True) -class _FooterState: - """Footer display state for interactive capture editor.""" - - current_placeholder: str | None - current_input_value: str - cursor_position: int - current_field_index: int | None - total_fields: int - show_help_modal: bool = False - - -@dataclass(frozen=True) -class _ActiveField: - """Descriptor for one actively edited placeholder field.""" - - placeholder: str - field_index: int - total_fields: int - - -def _placeholder_token(placeholder: str) -> str: - """Return canonical placeholder token for unresolved values.""" - return f"{{{{{placeholder}}}}}" - - -def _render_template_preview(template_content: str, values: dict[str, str]) -> _TemplatePreview: - """Render template preview text and track placeholder output spans.""" - chunks: list[str] = [] - placeholder_spans: dict[str, list[tuple[int, int]]] = {} - output_index = 0 - cursor = 0 - - for match in _PLACEHOLDER_RE.finditer(template_content): - leading_text = template_content[cursor : match.start()] - chunks.append(leading_text) - output_index += len(leading_text) - - placeholder = match.group(1) - replacement = values.get(placeholder, _placeholder_token(placeholder)) - replacement_start = output_index - replacement_end = replacement_start + len(replacement) - chunks.append(replacement) - output_index = replacement_end - placeholder_spans.setdefault(placeholder, []).append((replacement_start, replacement_end)) - - cursor = match.end() - - trailing_text = template_content[cursor:] - chunks.append(trailing_text) - return _TemplatePreview( - text="".join(chunks), - placeholder_spans=placeholder_spans, - ) - - -def _build_template_body( - template_content: str, - values: dict[str, str], - current_placeholder: str | None, - current_input_value: str, -) -> Text: - """Build syntax-highlighted template body with active placeholder emphasis.""" - preview_values = dict(values) - if current_placeholder is not None and current_input_value: - preview_values[current_placeholder] = current_input_value - - preview = _render_template_preview(template_content, preview_values) - syntax = Syntax( - preview.text, - "org", - theme=DEFAULT_OUTPUT_THEME, - line_numbers=False, - word_wrap=True, - ) - highlighted = syntax.highlight(preview.text) - if current_placeholder is not None: - for start, end in preview.placeholder_spans.get(current_placeholder, []): - if end > start: - highlighted.stylize("bold black on bright_yellow", start, end) - return highlighted - - -def _build_footer_prompt( - footer_state: _FooterState, -) -> Text: - """Build interactive footer prompt with visible cursor position.""" - if footer_state.current_placeholder is None: - return Text( - "All placeholders resolved", - style="bold", - no_wrap=True, - overflow="ellipsis", - ) - - input_value_length = len(footer_state.current_input_value) - clamped_cursor = max(0, min(footer_state.cursor_position, input_value_length)) - footer = Text( - f"Value for '{footer_state.current_placeholder}': ", - style="bold", - ) - footer.append(footer_state.current_input_value[:clamped_cursor]) - if clamped_cursor < len(footer_state.current_input_value): - footer.append(footer_state.current_input_value[clamped_cursor], style="reverse") - footer.append(footer_state.current_input_value[clamped_cursor + 1 :]) - else: - footer.append(" ", style="reverse") - return footer - - -def _value_progress_marker(footer_state: _FooterState) -> str: - """Build footer field progress marker text.""" - if footer_state.total_fields < 1: - return "Value 0/0" - if footer_state.current_field_index is None: - return f"Value {footer_state.total_fields}/{footer_state.total_fields}" - return f"Value {footer_state.current_field_index}/{footer_state.total_fields}" - - -def _build_footer_status_line(footer_state: _FooterState) -> Table: - """Build footer status line with marker and key bindings.""" - status_line = Table.grid(expand=True) - status_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - status_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - status_line.add_row( - Text(_value_progress_marker(footer_state), style="dim", no_wrap=True, overflow="ellipsis"), - Text(INTERACTIVE_HELP_FOOTER_HINT, style="dim", no_wrap=True, overflow="ellipsis"), - ) - return status_line - - -def _count_wrapped_prompt_lines( - footer_prompt: Text, - *, - console_width: int, -) -> int: - """Count wrapped footer prompt lines for the current terminal width.""" - wrap_width = max(1, console_width) - measure_console = Console(width=wrap_width, force_terminal=True) - wrapped_lines = footer_prompt.wrap( - measure_console, - width=wrap_width, - overflow="fold", - no_wrap=False, - ) - return max(1, len(wrapped_lines)) - - -def _resolve_terminal_width() -> int: - """Resolve interactive terminal width from Typer's Rich console.""" - return max(1, _get_rich_console(stderr=False).size.width) - - -def _build_fullscreen_capture_renderable( - template_content: str, - values: dict[str, str], - footer_state: _FooterState, - console_width: int, -) -> RenderableType: - """Build full-screen capture renderable with template body and footer prompt.""" - if footer_state.show_help_modal: - return render_interactive_help_modal( - _CAPTURE_HELP_ENTRIES, - color_enabled=True, - ) - - footer_prompt = _build_footer_prompt(footer_state) - footer_prompt_height = _count_wrapped_prompt_lines(footer_prompt, console_width=console_width) - footer_height = max(2, 1 + footer_prompt_height) - layout = Layout(name="capture") - layout.split_column( - Layout(name="body", ratio=1), - Layout(name="separator", size=1), - Layout(name="footer", size=footer_height), - ) - layout["body"].update( - _build_template_body( - template_content, - values, - footer_state.current_placeholder, - footer_state.current_input_value, - ), - ) - layout["separator"].update(Rule(style="dim")) - layout["footer"].update( - Group( - _build_footer_status_line(footer_state), - footer_prompt, - ), - ) - return layout - - -def _supports_live_template_prompt() -> bool: - """Return whether interactive terminal supports live capture preview.""" - return sys.stdin.isatty() and sys.stdout.isatty() - - -def _prompt_unresolved_placeholders( - placeholders: list[str], - values: dict[str, str], -) -> dict[str, str]: - """Prompt unresolved placeholders using standard CLI prompt fallback.""" - resolved_values = dict(values) - for placeholder in placeholders: - resolved_values[placeholder] = typer.prompt( - f"Value for '{placeholder}'", - default="", - show_default=False, - ) - return resolved_values - - -def _apply_input_event( - value: str, - cursor_position: int, - event_name: str, - event_text: str, -) -> tuple[str, int, bool]: - """Apply one input event to editable text and cursor state.""" - if event_name == "ENTER": - return (value, cursor_position, True) - - cursor_targets = { - "LEFT": max(0, cursor_position - 1), - "RIGHT": min(len(value), cursor_position + 1), - "HOME": 0, - "END": len(value), - } - target_cursor = cursor_targets.get(event_name) - if target_cursor is not None: - return (value, target_cursor, False) - - if event_name == "BACKSPACE" and cursor_position > 0: - return ( - f"{value[: cursor_position - 1]}{value[cursor_position:]}", - cursor_position - 1, - False, - ) - if event_name == "DELETE" and cursor_position < len(value): - return (f"{value[:cursor_position]}{value[cursor_position + 1 :]}", cursor_position, False) - if event_name == "TEXT": - return ( - f"{value[:cursor_position]}{event_text}{value[cursor_position:]}", - cursor_position + len(event_text), - False, - ) - return (value, cursor_position, False) - - -def _read_live_placeholder_value( - live: Live, - template_content: str, - resolved_values: dict[str, str], - active_field: _ActiveField, -) -> str: - """Read one placeholder value from raw keyboard events in live mode.""" - current_value = "" - cursor_position = 0 - show_help_modal = False - fd = sys.stdin.fileno() - previous_mode = termios.tcgetattr(fd) - try: - tty.setcbreak(fd) - while True: - live.update( - _build_fullscreen_capture_renderable( - template_content, - resolved_values, - _FooterState( - current_placeholder=active_field.placeholder, - current_input_value=current_value, - cursor_position=cursor_position, - current_field_index=active_field.field_index, - total_fields=active_field.total_fields, - show_help_modal=show_help_modal, - ), - console_width=live.console.size.width, - ), - refresh=True, - ) - event = read_input_event(ctrl_p_as_paste=True) - if event is None: - continue - event_name, event_text = event - help_key = event_text if event_name == "TEXT" else event_name - consumed, show_help_modal = apply_help_modal_key( - help_key, - show_help_modal=show_help_modal, - ) - if consumed: - continue - if event_name == "ESC": - raise KeyboardInterrupt - current_value, cursor_position, done = _apply_input_event( - current_value, - cursor_position, - event_name, - event_text, - ) - if done: - return current_value - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, previous_mode) - - -def _prompt_with_live_preview( - template_content: str, - placeholders: list[str], - values: dict[str, str], -) -> dict[str, str]: - """Prompt unresolved placeholders in a full-screen interactive editor.""" - resolved_values = dict(values) - total_fields = len(placeholders) - try: - fd = sys.stdin.fileno() - termios.tcgetattr(fd) - except OSError, ValueError, termios.error: - return _prompt_unresolved_placeholders(placeholders, resolved_values) - - with Live( - _build_fullscreen_capture_renderable( - template_content, - resolved_values, - _FooterState( - current_placeholder=placeholders[0], - current_input_value="", - cursor_position=0, - current_field_index=1, - total_fields=total_fields, - show_help_modal=False, - ), - console_width=_resolve_terminal_width(), - ), - screen=True, - refresh_per_second=12, - auto_refresh=False, - transient=False, - ) as live: - set_bracketed_paste(True) - try: - for field_index, placeholder in enumerate(placeholders, start=1): - resolved_values[placeholder] = _read_live_placeholder_value( - live, - template_content, - resolved_values, - _ActiveField( - placeholder=placeholder, - field_index=field_index, - total_fields=total_fields, - ), - ) - - live.update( - _build_fullscreen_capture_renderable( - template_content, - resolved_values, - _FooterState( - current_placeholder=None, - current_input_value="", - cursor_position=0, - current_field_index=None, - total_fields=total_fields, - show_help_modal=False, - ), - console_width=live.console.size.width, - ), - refresh=True, - ) - finally: - set_bracketed_paste(False) - - return resolved_values - - -def _static_placeholder_values(document: Document) -> dict[str, str]: - """Build values for static non-interactive placeholders.""" - now = datetime.now().astimezone().replace(microsecond=0) - day_name = _ORG_WEEKDAY_ABBR[now.weekday()] - today_org = f"<{now:%Y-%m-%d} {day_name}>" - now_org = f"<{now:%Y-%m-%d} {day_name} {now:%H:%M}>" - next_id = str(len(list(document)) + 1) - return { - "uuid": str(uuid4()), - "today": today_org, - "now": now_org, - "id": next_id, - } - - -def _parent_placeholder_values(parent_heading: Heading) -> dict[str, str]: - """Build parent-based placeholders for one resolved parent heading.""" - - def _to_string(value: object) -> str: - if value is None: - return "" - return str(value) - - return { - "parent_category": _to_string(parent_heading.category), - "parent_title": _to_string(parent_heading.title_text), - "parent_todo": _to_string(parent_heading.todo), - "parent_id": _to_string(parent_heading.id), - } - - -def _build_static_placeholder_values( - document: Document, - parent_heading: Heading | None, -) -> dict[str, str]: - """Build static placeholder map, optionally including parent placeholders.""" - static_values = _static_placeholder_values(document) - if parent_heading is not None: - static_values.update(_parent_placeholder_values(parent_heading)) - return static_values - - -def _parse_set_values(set_values: list[str] | None) -> dict[str, str]: - """Parse repeated --set KEY=VALUE options into one map.""" - if set_values is None: - return {} - - parsed: dict[str, str] = {} - for raw_value in set_values: - if "=" not in raw_value: - raise typer.BadParameter(f"--set must be KEY=VALUE, got {raw_value!r}") - key, value = raw_value.split("=", 1) - normalized_key = key.strip() - if not normalized_key: - raise typer.BadParameter("--set key cannot be empty") - if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]*", normalized_key): - raise typer.BadParameter( - f"--set key {normalized_key!r} is not a valid template parameter name", - ) - parsed[normalized_key] = value - return parsed - - -def _document_placeholder_values(document: Document) -> dict[str, str]: - """Return explicit document-backed placeholder values.""" - - def _to_optional_string(value: object) -> str | None: - if value is None: - return None - text = str(value) - if not text: - return None - return text - - values = { - "document_category": _to_optional_string(document.category), - "document_filename": _to_optional_string(document.filename), - "document_title": _to_optional_string(document.title), - "document_author": _to_optional_string(document.author), - "document_description": _to_optional_string(document.description), - } - return {key: value for key, value in values.items() if value is not None} - - -def _resolve_placeholder_values( - template_content: str, - placeholders: list[str], - set_values: dict[str, str], - static_values: dict[str, str], - document_values: dict[str, str], -) -> tuple[dict[str, str], bool]: - """Resolve placeholder values from static values, --set, and prompts.""" - values: dict[str, str] = {} - unresolved_placeholders: list[str] = [] - for placeholder in placeholders: - if placeholder in set_values: - values[placeholder] = set_values[placeholder] - continue - if placeholder in static_values: - values[placeholder] = static_values[placeholder] - continue - if placeholder in document_values: - values[placeholder] = document_values[placeholder] - continue - if placeholder in values: - continue - unresolved_placeholders.append(placeholder) - - if _supports_live_template_prompt() and unresolved_placeholders: - return (_prompt_with_live_preview(template_content, unresolved_placeholders, values), True) - - for placeholder in unresolved_placeholders: - values[placeholder] = typer.prompt( - f"Value for '{placeholder}'", - default="", - show_default=False, - ) - return (values, bool(unresolved_placeholders)) - - -def _render_capture_content( - template_content: str, - set_values: list[str] | None, - static_values: dict[str, str], - document: Document, -) -> tuple[str, bool]: - """Render capture template content by replacing handlebars placeholders.""" - placeholders = _template_placeholders(template_content) - provided_values = _parse_set_values(set_values) - unknown_names = sorted(set(provided_values) - set(placeholders)) - if unknown_names: - unknown_list = ", ".join(unknown_names) - logger.warning("Ignoring unknown capture --set parameter(s): %s", unknown_list) - - values, prompted = _resolve_placeholder_values( - template_content, - placeholders, - provided_values, - static_values, - _document_placeholder_values(document), - ) - - def _replace(match: re.Match[str]) -> str: - return values[match.group(1)] - - rendered = _PLACEHOLDER_RE.sub(_replace, template_content) - if rendered.endswith("\n"): - return (rendered, prompted) - return (f"{rendered}\n", prompted) - - -def _validate_rendered_heading(rendered_content: str) -> Heading: - """Parse rendered capture template as a single heading.""" - try: - return Heading.from_source(rendered_content) - except ValueError as err: - raise typer.BadParameter(f"Invalid rendered capture heading: {err}") from err - - -def _resolve_parent_from_selector(document: Document, parent_selector: str) -> Heading: - """Resolve one parent heading from selector expression against one target document.""" - normalized_selector = parent_selector.strip() - if not normalized_selector: - raise typer.BadParameter("Capture template parent selector cannot be empty") - - query_text = f".[] | select({normalized_selector})" - - try: - compiled_query = compile_query_text(query_text) - except QueryParseError as err: - raise typer.BadParameter(f"Invalid parent selector: {err}") from err - - try: - results = compiled_query(Stream([document]), EvalContext({})) - except QueryRuntimeError as err: - raise typer.BadParameter(f"Parent selector failed: {err}") from err - - matches_by_identity: dict[int, Heading] = {} - for result in results: - if not isinstance(result, Heading): - raise typer.BadParameter("Parent selector must match task headings") - matches_by_identity[id(result)] = result - - matches = list(matches_by_identity.values()) - if not matches: - raise typer.BadParameter("Parent selector did not match any heading") - if len(matches) > 1: - raise typer.BadParameter("Parent selector matched multiple headings") - return matches[0] - - -def _attach_heading(document: Document, parent_heading: Heading | None, heading: Heading) -> None: - """Attach heading at root or under one resolved parent.""" - if parent_heading is None: - document.children.append(heading) - return - parent_heading.children.append(heading) - - -def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: - """Capture one task from templates and return created heading metadata.""" - templates = config_module.CONFIG_CAPTURE_TEMPLATES - _require_templates(templates) - - template_key, template_prompted = _resolve_template_name(args.template_name, templates) - template = templates[template_key] - target_file = template["file"] if args.file is None else args.file - template_parent_selector = template.get("parent") - document = load_document(target_file) - - parent_heading = None - if args.parent is not None: - parent_heading = resolve_parent_heading(document, args.parent) - elif template_parent_selector is not None: - parent_heading = _resolve_parent_from_selector(document, template_parent_selector) - - static_values = _build_static_placeholder_values(document, parent_heading) - rendered_content, placeholder_prompted = _render_capture_content( - template["content"], - args.set_values, - static_values, - document, - ) - heading = _validate_rendered_heading(rendered_content) - - _attach_heading(document, parent_heading, heading) - save_document(document) - - return TasksCaptureResult( - template_name=template_key, - heading=heading, - document=document, - interactive_used=template_prompted or placeholder_prompted, - ) - - -def run_tasks_capture(args: TasksCaptureArgs) -> None: - """Run tasks capture command using configured templates.""" - result = capture_task(args) - if not result.interactive_used: - typer.echo(result.heading.id or result.heading.title_text) - - -def register(app: typer.Typer) -> None: - """Register the tasks capture command.""" - - @app.command( - "capture", - help=interactive_help_command_text( - "Create a task from a configured capture template.", - _CAPTURE_HELP_ENTRIES, - ), - ) - def capture( - template_name: str | None = typer.Argument( - None, - metavar="TEMPLATE_NAME", - help="Capture template name. If omitted, interactive selection is shown", - ), - config: str = typer.Option( - ".org-cli.yaml", - "--config", - metavar="FILE", - help="Config file name to load from current directory", - ), - file: str | None = typer.Option( - None, - "--file", - metavar="FILE", - help="Override template target file path", - ), - parent: str | None = typer.Option( - None, - "--parent", - metavar="ID_OR_TITLE", - help="Override template parent by heading ID or title", - ), - set_values: list[str] | None = typer.Option( # noqa: B008 - None, - "--set", - metavar="KEY=VALUE", - help="Set template parameter values without prompting", - ), - ) -> None: - """Create a task from a configured capture template.""" - args = TasksCaptureArgs( - template_name=template_name, - config=config, - file=file, - parent=parent, - set_values=set_values, - ) - config_module.log_command_arguments(args, "tasks capture") - run_tasks_capture(args) diff --git a/src/org/commands/tasks/capture/__init__.py b/src/org/commands/tasks/capture/__init__.py new file mode 100644 index 0000000..fe1070d --- /dev/null +++ b/src/org/commands/tasks/capture/__init__.py @@ -0,0 +1,47 @@ +"""Tasks capture command package.""" + +from .app import run_capture_form_app, run_template_selection_app +from .command import capture_task, register, run_tasks_capture +from .domain import ( + CapturePlan, + TasksCaptureArgs, + TasksCaptureResult, + _build_static_placeholder_values, + _document_placeholder_values, + _parent_placeholder_values, + _parse_set_values, + _render_capture_content_with_values, + _render_template_preview, + _resolve_parent_from_selector, + _static_placeholder_values, + _template_names, + _template_placeholders, + _validate_rendered_heading, + prepare_capture_plan, + resolve_interactive_capture_template_selection, +) + + +__all__ = [ + "CapturePlan", + "TasksCaptureArgs", + "TasksCaptureResult", + "_build_static_placeholder_values", + "_document_placeholder_values", + "_parent_placeholder_values", + "_parse_set_values", + "_render_capture_content_with_values", + "_render_template_preview", + "_resolve_parent_from_selector", + "_static_placeholder_values", + "_template_names", + "_template_placeholders", + "_validate_rendered_heading", + "capture_task", + "prepare_capture_plan", + "register", + "resolve_interactive_capture_template_selection", + "run_capture_form_app", + "run_tasks_capture", + "run_template_selection_app", +] diff --git a/src/org/commands/tasks/capture/app.py b/src/org/commands/tasks/capture/app.py new file mode 100644 index 0000000..62b5750 --- /dev/null +++ b/src/org/commands/tasks/capture/app.py @@ -0,0 +1,216 @@ +"""Interactive Textual apps for tasks capture.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.console import Group +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import Button, Static, TextArea + +from org.commands import runtime + +from .domain import ( + _CAPTURE_HELP_ENTRIES, + CapturePlan, + _build_template_body, + resolve_interactive_capture_template_selection, +) + + +if TYPE_CHECKING: + from typing import ClassVar + + from textual import events as textual_events + from textual.app import ComposeResult + + +class _TemplateSelectionApp(runtime.CommandApp): + """Small app for selecting a capture template interactively.""" + + CSS_PATH = "styles/selection.tcss" + + def __init__(self, template_names: list[str]) -> None: + super().__init__() + self._template_names = template_names + self.selected_template_name: str | None = None + self._status_message = "" + + def compose(self) -> ComposeResult: + yield Container( + Static(Text("Capture template", style="bold"), id="selection-title"), + Static(id="selection-status"), + id="selection-root", + ) + + def on_mount(self) -> None: + self._prompt_template_selection() + + def _refresh_status(self) -> None: + self.query_one("#selection-status", Static).update(Text(self._status_message)) + + def _prompt_template_selection(self) -> None: + options_text = ", ".join( + f"{idx}:{name}" for idx, name in enumerate(self._template_names, start=1) + ) + label = f"Capture template name or number [{options_text}]" + + def _submit(value: str) -> None: + selected = resolve_interactive_capture_template_selection(value, self._template_names) + if selected is None: + self._status_message = "Invalid capture template selection" + self._refresh_status() + self._prompt_template_selection() + return + self.selected_template_name = selected + self.exit() + + self.push_screen( + runtime.PromptModalScreen(label), + callback=lambda value: _submit(value) if value is not None else self.exit(), + ) + + +class CaptureApp(runtime.CommandApp): + """Interactive multi-field capture form with preview.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("ctrl+s", "save_capture", show=False, priority=True), + Binding("escape", "cancel_capture", show=False, priority=True), + Binding("question_mark", "show_help", show=False, priority=True), + ] + + def __init__(self, plan: CapturePlan) -> None: + """Store prepared capture plan and mutable field values.""" + super().__init__() + self.plan = plan + self.field_values = { + placeholder: plan.values.get(placeholder, "") for placeholder in plan.placeholders + } + self.result_values: dict[str, str] | None = None + self.status_message = "" + + def compose(self) -> ComposeResult: + """Build the interactive capture form and preview layout.""" + yield Vertical( + Static(Text(f"Template: {self.plan.template_name}", style="bold"), id="capture-title"), + Horizontal( + Vertical( + Static(Text("Preview", style="bold"), id="preview-title"), + Static(id="capture-preview"), + id="capture-preview-panel", + ), + Vertical( + Static(Text("Fields", style="bold"), id="fields-title"), + Vertical(id="capture-fields"), + Horizontal( + Button("Save", id="capture-save"), + Button("Cancel", id="capture-cancel"), + id="capture-buttons", + ), + id="capture-form", + ), + id="capture-body", + ), + Static(id="capture-status"), + ) + + def on_mount(self) -> None: + """Mount unresolved placeholder inputs and focus the first field.""" + fields = self.query_one("#capture-fields", Vertical) + for placeholder in self.plan.unresolved_placeholders: + fields.mount( + Static( + Text(f"Value for '{placeholder}'", style="bold"), + classes="field-label", + ), + ) + fields.mount( + TextArea( + self.field_values[placeholder], + id=f"field-{placeholder}", + classes="field-input", + soft_wrap=True, + compact=True, + ), + ) + if self.plan.unresolved_placeholders: + self.query_one(f"#field-{self.plan.unresolved_placeholders[0]}", TextArea).focus() + self._refresh_view() + + def _preview_values(self) -> dict[str, str]: + return {**self.plan.values, **self.field_values} + + def _refresh_view(self) -> None: + preview = _build_template_body( + self.plan.template_content, + self._preview_values(), + None, + "", + ) + self.query_one("#capture-preview", Static).update(Group(preview)) + self.query_one("#capture-status", Static).update(Text(self.status_message, style="dim")) + + def on_text_area_changed(self, event: TextArea.Changed) -> None: + """Keep preview in sync with edited field values.""" + widget_id = event.text_area.id or "" + if not widget_id.startswith("field-"): + return + placeholder = widget_id.removeprefix("field-") + self.field_values[placeholder] = event.text_area.text + self._refresh_view() + + def on_key(self, event: textual_events.Key) -> None: + """Handle global capture keys even while inputs are focused.""" + key = event.key + character = event.character or "" + handler = None + if key in {"question_mark", "?"} or character == "?": + handler = self.action_show_help + elif key == "ctrl+s": + handler = self.action_save_capture + elif key == "escape": + handler = self.action_cancel_capture + if handler is None: + return + event.stop() + handler() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle save/cancel button presses.""" + if event.button.id == "capture-save": + self.action_save_capture() + elif event.button.id == "capture-cancel": + self.action_cancel_capture() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen(runtime.HelpModalScreen(_CAPTURE_HELP_ENTRIES, color_enabled=True)) + + def action_cancel_capture(self) -> None: + """Cancel interactive capture without saving.""" + self.result_values = None + self.exit() + + def action_save_capture(self) -> None: + """Save the current field values and exit the form.""" + self.result_values = self._preview_values() + self.exit() + + +def run_template_selection_app(template_names: list[str]) -> str | None: + """Run the template selection app and return the selected template name.""" + app = _TemplateSelectionApp(template_names) + app.run() + return app.selected_template_name + + +def run_capture_form_app(plan: CapturePlan) -> dict[str, str] | None: + """Run the capture form app and return resolved field values.""" + app = CaptureApp(plan) + app.run() + return app.result_values diff --git a/src/org/commands/tasks/capture/command.py b/src/org/commands/tasks/capture/command.py new file mode 100644 index 0000000..424865f --- /dev/null +++ b/src/org/commands/tasks/capture/command.py @@ -0,0 +1,130 @@ +"""CLI wiring for tasks capture.""" + +from __future__ import annotations + +import sys +from dataclasses import replace + +import typer + +from org import config as config_module +from org.commands.interactive_common import interactive_help_command_text + +from .app import run_capture_form_app, run_template_selection_app +from .domain import ( + _CAPTURE_HELP_ENTRIES, + TasksCaptureArgs, + TasksCaptureResult, + _missing_placeholder_error, + _missing_template_name_error, + _require_templates, + _template_names, + _valid_template_names_text, + finalize_capture_plan, + prepare_capture_plan, +) + + +def _is_interactive_terminal() -> bool: + """Return whether capture can launch interactive terminal apps.""" + return sys.stdin.isatty() and sys.stdout.isatty() + + +def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: + """Capture one task from templates and return created heading metadata.""" + templates = config_module.CONFIG_CAPTURE_TEMPLATES + _require_templates(templates) + + interactive_used = False + resolved_args = args + if args.template_name is None: + if not _is_interactive_terminal(): + raise _missing_template_name_error() + selected_name = run_template_selection_app(_template_names(templates)) + if selected_name is None: + raise KeyboardInterrupt + resolved_args = replace(args, template_name=selected_name) + interactive_used = True + + if resolved_args.template_name is None: + raise _missing_template_name_error() + if resolved_args.template_name not in templates: + valid_names = _valid_template_names_text(_template_names(templates)) + raise typer.BadParameter( + "Unknown capture template " + f"'{resolved_args.template_name}'. Valid templates: {valid_names}", + ) + + plan = prepare_capture_plan(resolved_args, resolved_args.template_name) + if plan.unresolved_placeholders: + if not _is_interactive_terminal(): + raise _missing_placeholder_error(plan.unresolved_placeholders) + values = run_capture_form_app(plan) + if values is None: + raise KeyboardInterrupt + return finalize_capture_plan(plan, values, interactive_used=True) + + return finalize_capture_plan(plan, plan.values, interactive_used=interactive_used) + + +def run_tasks_capture(args: TasksCaptureArgs) -> None: + """Run tasks capture command using configured templates.""" + result = capture_task(args) + if not result.interactive_used: + typer.echo(result.heading.id or result.heading.title_text) + + +def register(app: typer.Typer) -> None: + """Register the tasks capture command.""" + + @app.command( + "capture", + help=interactive_help_command_text( + "Create a task from a configured capture template.", + _CAPTURE_HELP_ENTRIES, + ), + ) + def capture( + template_name: str | None = typer.Argument( + None, + metavar="TEMPLATE_NAME", + help="Capture template name. If omitted, interactive selection is shown", + ), + config: str = typer.Option( + ".org-cli.yaml", + "--config", + metavar="FILE", + help="Config file name to load from current directory", + ), + file: str | None = typer.Option( + None, + "--file", + metavar="FILE", + help="Override template target file path", + ), + parent: str | None = typer.Option( + None, + "--parent", + metavar="ID_OR_TITLE", + help="Override template parent by heading ID or title", + ), + set_values: list[str] | None = typer.Option( # noqa: B008 + None, + "--set", + metavar="KEY=VALUE", + help="Set template parameter values without prompting", + ), + ) -> None: + """Create a task from a configured capture template.""" + args = TasksCaptureArgs( + template_name=template_name, + config=config, + file=file, + parent=parent, + set_values=set_values, + ) + config_module.log_command_arguments(args, "tasks capture") + run_tasks_capture(args) + + +__all__ = ["TasksCaptureArgs", "TasksCaptureResult", "register", "run_tasks_capture"] diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py new file mode 100644 index 0000000..4501e62 --- /dev/null +++ b/src/org/commands/tasks/capture/domain.py @@ -0,0 +1,424 @@ +"""Domain logic for tasks capture.""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import uuid4 + +import typer +from org_parser.document import Document, Heading +from rich.syntax import Syntax + +from org import config as config_module +from org.commands.interactive_common import InteractiveHelpEntry +from org.commands.tasks.common import load_document, resolve_parent_heading, save_document +from org.output_format import DEFAULT_OUTPUT_THEME +from org.query_language import ( + EvalContext, + QueryParseError, + QueryRuntimeError, + Stream, + compile_query_text, +) + + +if TYPE_CHECKING: + from rich.text import Text + + +_PLACEHOLDER_RE = re.compile(r"{{\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*}}") +_ORG_WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +logger = logging.getLogger("org") + + +_CAPTURE_HELP_ENTRIES = [ + InteractiveHelpEntry("Tab / Shift-Tab", "Move focus between form fields and controls."), + InteractiveHelpEntry("Type text", "Edit the focused field and update the preview live."), + InteractiveHelpEntry("Ctrl-S", "Save the rendered capture entry."), + InteractiveHelpEntry("Esc", "Cancel capture and return to the shell."), +] + + +@dataclass +class TasksCaptureArgs: + """Arguments for the tasks capture command.""" + + template_name: str | None + config: str + file: str | None + parent: str | None + set_values: list[str] | None + + +@dataclass(frozen=True) +class TasksCaptureResult: + """Result metadata for one successful capture operation.""" + + template_name: str + heading: Heading + document: Document + interactive_used: bool = False + + +@dataclass(frozen=True) +class CapturePlan: + """Prepared capture plan with unresolved placeholder metadata.""" + + args: TasksCaptureArgs + template_name: str + template_content: str + document: Document + parent_heading: Heading | None + placeholders: list[str] + values: dict[str, str] + unresolved_placeholders: list[str] + + +@dataclass(frozen=True) +class _TemplatePreview: + """Rendered capture template preview with placeholder spans.""" + + text: str + placeholder_spans: dict[str, list[tuple[int, int]]] + + +def _template_names(templates: dict[str, dict[str, str]]) -> list[str]: + """Return stable template name ordering for prompts and errors.""" + return sorted(templates) + + +def _require_templates(templates: dict[str, dict[str, str]]) -> None: + """Require at least one capture template before running capture.""" + if templates: + return + raise typer.BadParameter("No capture templates configured") + + +def _valid_template_names_text(template_names: list[str]) -> str: + """Return comma-separated template names for error messages.""" + return ", ".join(template_names) + + +def resolve_interactive_capture_template_selection( + selection: str, + template_names: list[str], +) -> str | None: + """Resolve capture template from interactive selection input.""" + normalized = selection.strip() + if not normalized: + return None + if normalized in template_names: + return normalized + if not normalized.isdigit(): + return None + index = int(normalized) - 1 + if 0 <= index < len(template_names): + return template_names[index] + return None + + +def _template_placeholders(template_content: str) -> list[str]: + """Return unique placeholders in first-appearance order.""" + placeholders = [match.group(1) for match in _PLACEHOLDER_RE.finditer(template_content)] + return list(dict.fromkeys(placeholders)) + + +def _placeholder_token(placeholder: str) -> str: + """Return canonical placeholder token for unresolved values.""" + return f"{{{{{placeholder}}}}}" + + +def _render_template_preview(template_content: str, values: dict[str, str]) -> _TemplatePreview: + """Render template preview text and track placeholder output spans.""" + chunks: list[str] = [] + placeholder_spans: dict[str, list[tuple[int, int]]] = {} + output_index = 0 + cursor = 0 + + for match in _PLACEHOLDER_RE.finditer(template_content): + leading_text = template_content[cursor : match.start()] + chunks.append(leading_text) + output_index += len(leading_text) + + placeholder = match.group(1) + replacement = values.get(placeholder, _placeholder_token(placeholder)) + replacement_start = output_index + replacement_end = replacement_start + len(replacement) + chunks.append(replacement) + output_index = replacement_end + placeholder_spans.setdefault(placeholder, []).append((replacement_start, replacement_end)) + + cursor = match.end() + + trailing_text = template_content[cursor:] + chunks.append(trailing_text) + return _TemplatePreview(text="".join(chunks), placeholder_spans=placeholder_spans) + + +def _build_template_body( + template_content: str, + values: dict[str, str], + current_placeholder: str | None, + current_input_value: str, +) -> Text: + """Build syntax-highlighted template body with active placeholder emphasis.""" + preview_values = dict(values) + if current_placeholder is not None and current_input_value: + preview_values[current_placeholder] = current_input_value + + preview = _render_template_preview(template_content, preview_values) + syntax = Syntax( + preview.text, + "org", + theme=DEFAULT_OUTPUT_THEME, + line_numbers=False, + word_wrap=True, + ) + highlighted = syntax.highlight(preview.text) + if current_placeholder is not None: + for start, end in preview.placeholder_spans.get(current_placeholder, []): + if end > start: + highlighted.stylize("bold black on bright_yellow", start, end) + return highlighted + + +def _static_placeholder_values(document: Document) -> dict[str, str]: + """Build values for static non-interactive placeholders.""" + now = datetime.now().astimezone().replace(microsecond=0) + day_name = _ORG_WEEKDAY_ABBR[now.weekday()] + today_org = f"<{now:%Y-%m-%d} {day_name}>" + now_org = f"<{now:%Y-%m-%d} {day_name} {now:%H:%M}>" + next_id = str(len(list(document)) + 1) + return { + "uuid": str(uuid4()), + "today": today_org, + "now": now_org, + "id": next_id, + } + + +def _parent_placeholder_values(parent_heading: Heading) -> dict[str, str]: + """Build parent-based placeholders for one resolved parent heading.""" + + def _to_string(value: object) -> str: + if value is None: + return "" + return str(value) + + return { + "parent_category": _to_string(parent_heading.category), + "parent_title": _to_string(parent_heading.title_text), + "parent_todo": _to_string(parent_heading.todo), + "parent_id": _to_string(parent_heading.id), + } + + +def _build_static_placeholder_values( + document: Document, + parent_heading: Heading | None, +) -> dict[str, str]: + """Build static placeholder map, optionally including parent placeholders.""" + static_values = _static_placeholder_values(document) + if parent_heading is not None: + static_values.update(_parent_placeholder_values(parent_heading)) + return static_values + + +def _parse_set_values(set_values: list[str] | None) -> dict[str, str]: + """Parse repeated --set KEY=VALUE options into one map.""" + if set_values is None: + return {} + + parsed: dict[str, str] = {} + for raw_value in set_values: + if "=" not in raw_value: + raise typer.BadParameter(f"--set must be KEY=VALUE, got {raw_value!r}") + key, value = raw_value.split("=", 1) + normalized_key = key.strip() + if not normalized_key: + raise typer.BadParameter("--set key cannot be empty") + if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]*", normalized_key): + raise typer.BadParameter( + f"--set key {normalized_key!r} is not a valid template parameter name", + ) + parsed[normalized_key] = value + return parsed + + +def _document_placeholder_values(document: Document) -> dict[str, str]: + """Return explicit document-backed placeholder values.""" + + def _to_optional_string(value: object) -> str | None: + if value is None: + return None + text = str(value) + if not text: + return None + return text + + values = { + "document_category": _to_optional_string(document.category), + "document_filename": _to_optional_string(document.filename), + "document_title": _to_optional_string(document.title), + "document_author": _to_optional_string(document.author), + "document_description": _to_optional_string(document.description), + } + return {key: value for key, value in values.items() if value is not None} + + +def resolve_initial_placeholder_values( + placeholders: list[str], + set_values: dict[str, str], + static_values: dict[str, str], + document_values: dict[str, str], +) -> tuple[dict[str, str], list[str]]: + """Resolve initial placeholder values without prompting.""" + values: dict[str, str] = {} + unresolved_placeholders: list[str] = [] + for placeholder in placeholders: + if placeholder in set_values: + values[placeholder] = set_values[placeholder] + continue + if placeholder in static_values: + values[placeholder] = static_values[placeholder] + continue + if placeholder in document_values: + values[placeholder] = document_values[placeholder] + continue + if placeholder in values: + continue + unresolved_placeholders.append(placeholder) + return values, unresolved_placeholders + + +def _render_capture_content_with_values(template_content: str, values: dict[str, str]) -> str: + """Render capture content from fully resolved placeholder values.""" + + def _replace(match: re.Match[str]) -> str: + return values[match.group(1)] + + rendered = _PLACEHOLDER_RE.sub(_replace, template_content) + if rendered.endswith("\n"): + return rendered + return f"{rendered}\n" + + +def _validate_rendered_heading(rendered_content: str) -> Heading: + """Parse rendered capture template as a single heading.""" + try: + return Heading.from_source(rendered_content) + except ValueError as err: + raise typer.BadParameter(f"Invalid rendered capture heading: {err}") from err + + +def _resolve_parent_from_selector(document: Document, parent_selector: str) -> Heading: + """Resolve one parent heading from selector expression against one target document.""" + normalized_selector = parent_selector.strip() + if not normalized_selector: + raise typer.BadParameter("Capture template parent selector cannot be empty") + + query_text = f".[] | select({normalized_selector})" + + try: + compiled_query = compile_query_text(query_text) + except QueryParseError as err: + raise typer.BadParameter(f"Invalid parent selector: {err}") from err + + try: + results = compiled_query(Stream([document]), EvalContext({})) + except QueryRuntimeError as err: + raise typer.BadParameter(f"Parent selector failed: {err}") from err + + matches_by_identity: dict[int, Heading] = {} + for result in results: + if not isinstance(result, Heading): + raise typer.BadParameter("Parent selector must match task headings") + matches_by_identity[id(result)] = result + + matches = list(matches_by_identity.values()) + if not matches: + raise typer.BadParameter("Parent selector did not match any heading") + if len(matches) > 1: + raise typer.BadParameter("Parent selector matched multiple headings") + return matches[0] + + +def _attach_heading(document: Document, parent_heading: Heading | None, heading: Heading) -> None: + """Attach heading at root or under one resolved parent.""" + if parent_heading is None: + document.children.append(heading) + return + parent_heading.children.append(heading) + + +def prepare_capture_plan(args: TasksCaptureArgs, template_name: str) -> CapturePlan: + """Prepare capture plan from resolved template and current document state.""" + templates = config_module.CONFIG_CAPTURE_TEMPLATES + template = templates[template_name] + target_file = template["file"] if args.file is None else args.file + template_parent_selector = template.get("parent") + document = load_document(target_file) + + parent_heading = None + if args.parent is not None: + parent_heading = resolve_parent_heading(document, args.parent) + elif template_parent_selector is not None: + parent_heading = _resolve_parent_from_selector(document, template_parent_selector) + + placeholders = _template_placeholders(template["content"]) + set_values = _parse_set_values(args.set_values) + unknown_names = sorted(set(set_values) - set(placeholders)) + if unknown_names: + unknown_list = ", ".join(unknown_names) + logger.warning("Ignoring unknown capture --set parameter(s): %s", unknown_list) + + static_values = _build_static_placeholder_values(document, parent_heading) + document_values = _document_placeholder_values(document) + values, unresolved_placeholders = resolve_initial_placeholder_values( + placeholders, + set_values, + static_values, + document_values, + ) + return CapturePlan( + args=args, + template_name=template_name, + template_content=template["content"], + document=document, + parent_heading=parent_heading, + placeholders=placeholders, + values=values, + unresolved_placeholders=unresolved_placeholders, + ) + + +def _missing_template_name_error() -> typer.BadParameter: + return typer.BadParameter("Capture template name is required in non-interactive mode") + + +def _missing_placeholder_error(unresolved_placeholders: list[str]) -> typer.BadParameter: + names = ", ".join(unresolved_placeholders) + return typer.BadParameter(f"Missing placeholder values for: {names}") + + +def finalize_capture_plan( + plan: CapturePlan, + values: dict[str, str], + *, + interactive_used: bool, +) -> TasksCaptureResult: + """Render and save the prepared capture plan.""" + rendered_content = _render_capture_content_with_values(plan.template_content, values) + heading = _validate_rendered_heading(rendered_content) + _attach_heading(plan.document, plan.parent_heading, heading) + save_document(plan.document) + return TasksCaptureResult( + template_name=plan.template_name, + heading=heading, + document=plan.document, + interactive_used=interactive_used, + ) diff --git a/src/org/commands/tasks/capture/styles/app.tcss b/src/org/commands/tasks/capture/styles/app.tcss new file mode 100644 index 0000000..461c130 --- /dev/null +++ b/src/org/commands/tasks/capture/styles/app.tcss @@ -0,0 +1,63 @@ +Screen { + layout: vertical; +} + +#capture-title { + margin-bottom: 1; +} + +#capture-body { + height: 1fr; +} + +#capture-preview-panel { + width: 1fr; + padding-right: 1; +} + +#preview-title { + margin-bottom: 1; +} + +#capture-form { + width: 40%; + min-width: 32; + height: 1fr; +} + +#capture-fields { + height: 1fr; + overflow-y: auto; +} + +.field-label { + margin-top: 1; +} + +.field-input { + height: auto; + min-height: 3; + max-height: 8; +} + +#capture-preview { + height: 1fr; + border: round $primary; + padding: 1; +} + +#capture-buttons { + margin-top: 1; + width: 100%; + align-horizontal: center; + height: auto; +} + +#capture-buttons Button { + width: 16; +} + +#capture-status { + height: auto; + margin-top: 1; +} diff --git a/src/org/commands/tasks/capture/styles/selection.tcss b/src/org/commands/tasks/capture/styles/selection.tcss new file mode 100644 index 0000000..f8422f0 --- /dev/null +++ b/src/org/commands/tasks/capture/styles/selection.tcss @@ -0,0 +1,15 @@ +Screen { + align: center middle; +} + +#selection-root { + width: 60%; + max-width: 80; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#selection-title { + margin-bottom: 1; +} diff --git a/tests/cli/test_cli_runner.py b/tests/cli/test_cli_runner.py index 4ca3183..3d24ea0 100644 --- a/tests/cli/test_cli_runner.py +++ b/tests/cli/test_cli_runner.py @@ -152,7 +152,7 @@ def test_cli_help_lists_specific_key_bindings_for_interactive_commands() -> None (["board", "--help"], ["Esc/q", "S-Left/S-Right", "S-Up/S-Down"]), (["agenda", "--help"], ["Esc/q", "f/b, Left/Right", "r"]), (["tasks", "list", "--help"], ["Esc/q", "/", "s / d / c"]), - (["tasks", "capture", "--help"], ["Enter", "Left/Right", "Esc or Ctrl-C"]), + (["tasks", "capture", "--help"], ["Tab / Shift-Tab", "Ctrl-S", "Esc"]), ] for argv, snippets in expectations: @@ -827,7 +827,7 @@ def test_cli_runner_capture_direct_template_path(tmp_path: Path) -> None: }, ) - result = runner.invoke(app, ["tasks", "capture", "quick"], input="Write docs\n") + result = runner.invoke(app, ["tasks", "capture", "quick", "--set", "title=Write docs"]) assert result.exit_code == 0 updated = target.read_text(encoding="utf-8") @@ -837,19 +837,24 @@ def test_cli_runner_capture_direct_template_path(tmp_path: Path) -> None: config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) -def test_cli_runner_capture_live_preview_updates_between_prompts( +def test_cli_runner_capture_uses_interactive_form_runner( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """CLI capture should render live template preview for interactive placeholders.""" + """CLI capture should route interactive placeholder capture through the app runner.""" runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) - live_preview_support_path = "org.commands.tasks.capture._supports_live_template_prompt" + support_path = "org.commands.tasks.capture.command._is_interactive_terminal" + form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" try: - monkeypatch.setattr(live_preview_support_path, lambda: True) + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr( + form_runner_path, + lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, + ) config.CONFIG_CAPTURE_TEMPLATES.clear() config.CONFIG_CAPTURE_TEMPLATES.update( { @@ -860,33 +865,10 @@ def test_cli_runner_capture_live_preview_updates_between_prompts( }, ) - result = runner.invoke( - app, - ["tasks", "capture", "quick"], - input="Write docs\nJane\n", - color=True, - ) + result = runner.invoke(app, ["tasks", "capture", "quick"], color=True) assert result.exit_code == 0 - combined_output = clean_combined_output(result) - assert "Value for 'title': Write docs" in combined_output - assert "Value for 'owner': Jane" in combined_output assert "* TODO Write docs @Jane" in target.read_text(encoding="utf-8") - - result_fullscreen = runner.invoke( - app, - ["tasks", "capture", "quick"], - input="Another task\nSam\n", - color=True, - env={"TERM": "xterm-256color"}, - ) - - assert result_fullscreen.exit_code == 0 - combined_fullscreen_output = clean_combined_output(result_fullscreen) - assert "Value for 'title':" in combined_fullscreen_output - assert "Value for 'owner':" in combined_fullscreen_output - updated_text = target.read_text(encoding="utf-8") - assert "* TODO Another task @Sam" in updated_text finally: config.CONFIG_CAPTURE_TEMPLATES.clear() config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) @@ -914,8 +896,15 @@ def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: result = runner.invoke( app, - ["tasks", "capture", "quick", "--file", str(override_target)], - input="Write docs\n", + [ + "tasks", + "capture", + "quick", + "--file", + str(override_target), + "--set", + "title=Write docs", + ], ) assert result.exit_code == 0 @@ -950,8 +939,7 @@ def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: result = runner.invoke( app, - ["tasks", "capture", "child", "--parent", "two"], - input="From cli\n", + ["tasks", "capture", "child", "--parent", "two", "--set", "title=From cli"], ) assert result.exit_code == 0 @@ -987,8 +975,7 @@ def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> Non result = runner.invoke( app, - ["tasks", "capture", "child", "--parent", "Two"], - input="By title\n", + ["tasks", "capture", "child", "--parent", "Two", "--set", "title=By title"], ) assert result.exit_code == 0 @@ -1083,14 +1070,21 @@ def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) -def test_cli_runner_capture_accepts_empty_placeholder_value(tmp_path: Path) -> None: - """CLI capture should accept empty interactive placeholder input.""" +def test_cli_runner_capture_accepts_empty_placeholder_value( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Interactive capture app runner should accept empty placeholder values.""" runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + support_path = "org.commands.tasks.capture.command._is_interactive_terminal" + form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" try: + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr(form_runner_path, lambda plan: {**plan.values, "title": ""}) config.CONFIG_CAPTURE_TEMPLATES.clear() config.CONFIG_CAPTURE_TEMPLATES.update( { @@ -1101,7 +1095,7 @@ def test_cli_runner_capture_accepts_empty_placeholder_value(tmp_path: Path) -> N }, ) - result = runner.invoke(app, ["tasks", "capture", "quick"], input="\n") + result = runner.invoke(app, ["tasks", "capture", "quick"]) assert result.exit_code == 0 updated = target.read_text(encoding="utf-8") @@ -1112,14 +1106,26 @@ def test_cli_runner_capture_accepts_empty_placeholder_value(tmp_path: Path) -> N config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) -def test_cli_runner_capture_interactive_numeric_selection(tmp_path: Path) -> None: - """CLI should select capture template with numeric prompt.""" +def test_cli_runner_capture_interactive_numeric_selection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Interactive capture should use the selection app when template name is omitted.""" runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + support_path = "org.commands.tasks.capture.command._is_interactive_terminal" + selection_path = "org.commands.tasks.capture.command.run_template_selection_app" + form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" try: + monkeypatch.setattr(support_path, lambda: True) + monkeypatch.setattr(selection_path, lambda _names: "beta") + monkeypatch.setattr( + form_runner_path, + lambda plan: {**plan.values, "title": "Captured task"}, + ) config.CONFIG_CAPTURE_TEMPLATES.clear() config.CONFIG_CAPTURE_TEMPLATES.update( { @@ -1128,13 +1134,9 @@ def test_cli_runner_capture_interactive_numeric_selection(tmp_path: Path) -> Non }, ) - result = runner.invoke(app, ["tasks", "capture"], input="2\nCaptured task\n") + result = runner.invoke(app, ["tasks", "capture"]) assert result.exit_code == 0 - combined_output = clean_combined_output(result) - assert "Select capture template:" in combined_output - assert "1) alpha" in combined_output - assert "2) beta" in combined_output updated = target.read_text(encoding="utf-8") assert "* TODO Captured task" in updated finally: diff --git a/tests/cli/test_cli_smoke.py b/tests/cli/test_cli_smoke.py index 63e5d78..bb00187 100644 --- a/tests/cli/test_cli_smoke.py +++ b/tests/cli/test_cli_smoke.py @@ -18,9 +18,20 @@ def test_cli_summary_smoke() -> None: """Ensure summary command runs via python -m org.""" fixture_path = os.path.join(FIXTURES_DIR, "single_task.org") + config_path = os.path.join(FIXTURES_DIR, "empty-config.yaml") result = subprocess.run( - [sys.executable, "-m", "org", "stats", "all", "--no-color", fixture_path], + [ + sys.executable, + "-m", + "org", + "stats", + "all", + "--config", + config_path, + "--no-color", + fixture_path, + ], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -34,6 +45,7 @@ def test_cli_summary_multiple_files_smoke() -> None: """Ensure multiple files are processed via CLI.""" fixture1 = os.path.join(FIXTURES_DIR, "simple.org") fixture2 = os.path.join(FIXTURES_DIR, "single_task.org") + config_path = os.path.join(FIXTURES_DIR, "empty-config.yaml") result = subprocess.run( [ @@ -43,6 +55,8 @@ def test_cli_summary_multiple_files_smoke() -> None: "--verbose", "stats", "all", + "--config", + config_path, "--no-color", fixture1, fixture2, diff --git a/tests/commands/tasks/test_tasks_capture_app.py b/tests/commands/tasks/test_tasks_capture_app.py new file mode 100644 index 0000000..5db5054 --- /dev/null +++ b/tests/commands/tasks/test_tasks_capture_app.py @@ -0,0 +1,125 @@ +"""App tests for tasks capture.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast + +from textual.widgets import Static, TextArea + +from org import config as config_module +from org.commands.tasks import capture +from org.commands.tasks.capture.app import CaptureApp + + +if TYPE_CHECKING: + from pathlib import Path + + +def _make_plan(tmp_path: Path) -> capture.CapturePlan: + target = tmp_path / "tasks.org" + config_path = tmp_path / "config.yaml" + target.write_text("* TODO Existing\n", encoding="utf-8") + config_path.write_text("", encoding="utf-8") + previous = { + "quick": { + "file": str(target), + "content": "* TODO {{title}} @{{owner}}", + }, + } + original = dict(config_module.CONFIG_CAPTURE_TEMPLATES) + config_module.CONFIG_CAPTURE_TEMPLATES.clear() + config_module.CONFIG_CAPTURE_TEMPLATES.update(previous) + try: + return capture.prepare_capture_plan( + capture.TasksCaptureArgs( + template_name="quick", + config=str(config_path), + file=None, + parent=None, + set_values=None, + ), + "quick", + ) + finally: + config_module.CONFIG_CAPTURE_TEMPLATES.clear() + config_module.CONFIG_CAPTURE_TEMPLATES.update(original) + + +def test_capture_app_preview_updates_from_field_input(tmp_path: Path) -> None: + """Editing a field should update the rendered preview.""" + + async def _run() -> None: + app = CaptureApp(_make_plan(tmp_path)) + async with app.run_test() as pilot: + title_input = app.screen.query_one("#field-title", TextArea) + owner_input = app.screen.query_one("#field-owner", TextArea) + assert title_input.text == "" + assert owner_input.text == "" + + await pilot.press("W", "r", "i", "t", "e") + await pilot.pause() + owner_input.focus() + await pilot.pause() + await pilot.press("J", "a", "n", "e") + await pilot.pause() + + assert app.field_values["title"] == "Write" + assert app.field_values["owner"] == "Jane" + assert app._preview_values()["title"] == "Write" + assert app._preview_values()["owner"] == "Jane" + + asyncio.run(_run()) + + +def test_capture_app_textarea_keeps_newlines(tmp_path: Path) -> None: + """Multiline field edits should be preserved in preview values.""" + + async def _run() -> None: + app = CaptureApp(_make_plan(tmp_path)) + async with app.run_test() as pilot: + await pilot.press("L", "i", "n", "e", "1", "enter", "L", "i", "n", "e", "2") + await pilot.pause() + + assert app.field_values["title"] == "Line1\nLine2" + assert app._preview_values()["title"] == "Line1\nLine2" + + asyncio.run(_run()) + + +def test_capture_app_help_modal_shows_key_bindings(tmp_path: Path) -> None: + """Question mark should open the shared help modal.""" + + async def _run() -> None: + app = CaptureApp(_make_plan(tmp_path)) + async with app.run_test() as pilot: + app.action_show_help() + await pilot.pause() + title_widget = app.screen_stack[-1].query_one("#help-title", Static) + rendered = cast("Any", title_widget).render() + assert getattr(rendered, "plain", str(rendered)) == "Key bindings" + + asyncio.run(_run()) + + +def test_capture_app_save_collects_form_values(tmp_path: Path) -> None: + """Saving the form should return the current field values.""" + + async def _run() -> None: + app = CaptureApp(_make_plan(tmp_path)) + async with app.run_test() as pilot: + owner_input = app.screen.query_one("#field-owner", TextArea) + await pilot.press("W", "o", "r", "k") + await pilot.pause() + owner_input.focus() + await pilot.pause() + await pilot.press("S", "a", "m") + await pilot.pause() + await pilot.press("ctrl+s") + await pilot.pause() + + assert app.result_values is not None + assert app.result_values["title"] == "Work" + assert app.result_values["owner"] == "Sam" + + asyncio.run(_run()) diff --git a/tests/commands/test_tasks_capture_command.py b/tests/commands/test_tasks_capture_command.py index 5bbdd4c..b660a4e 100644 --- a/tests/commands/test_tasks_capture_command.py +++ b/tests/commands/test_tasks_capture_command.py @@ -3,22 +3,18 @@ from __future__ import annotations import re -from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING import org_parser import pytest import typer -from rich.console import Console from org import config -from org.commands import interactive_common from org.commands.tasks import capture if TYPE_CHECKING: from pathlib import Path - from typing import Self def _make_capture_args( @@ -57,7 +53,7 @@ def test_run_capture_errors_when_no_templates_configured() -> None: previous = _set_capture_templates({}) try: with pytest.raises(typer.BadParameter, match="No capture templates configured"): - capture.run_tasks_capture(_make_capture_args(template_name=None)) + capture.run_tasks_capture(_make_capture_args(template_name="quick")) finally: _restore_capture_templates(previous) @@ -72,149 +68,161 @@ def test_run_capture_errors_for_unknown_template_name() -> None: ) try: with pytest.raises(typer.BadParameter, match="Valid templates: alpha, beta"): - capture.run_tasks_capture(_make_capture_args(template_name="missing")) + capture.capture_task(_make_capture_args(template_name="missing")) finally: _restore_capture_templates(previous) -def test_run_capture_selects_template_by_numeric_prompt( +def test_capture_task_errors_when_template_omitted_in_noninteractive_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-interactive capture should require an explicit template name.""" + previous = _set_capture_templates( + {"quick": {"file": "tasks.org", "content": "* TODO Static"}}, + ) + try: + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: False, + ) + with pytest.raises(typer.BadParameter, match="template name is required"): + capture.capture_task(_make_capture_args(template_name=None)) + finally: + _restore_capture_templates(previous) + + +def test_capture_task_errors_when_placeholders_missing_in_noninteractive_mode( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Capture should resolve template selection from numeric interactive input.""" + """Non-interactive capture should fail on unresolved placeholders.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "alpha": {"file": str(target), "content": "* TODO From alpha"}, - "beta": {"file": str(target), "content": "* TODO From beta"}, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}}, ) try: - monkeypatch.setattr("org.commands.tasks.capture.typer.prompt", lambda _msg: "2") - capture.run_tasks_capture(_make_capture_args(template_name=None)) + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: False, + ) + with pytest.raises( + typer.BadParameter, + match="Missing placeholder values for: title, owner", + ): + capture.capture_task(_make_capture_args(template_name="quick")) finally: _restore_capture_templates(previous) - updated = target.read_text(encoding="utf-8") - assert "From beta" in updated - assert "From alpha" not in updated - -def test_run_capture_prompts_non_static_placeholder_once( +def test_capture_task_uses_interactive_template_selection_and_form( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Repeated non-static placeholders should be prompted once and reused.""" + """Interactive capture should use the selection app and form app helpers.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( { - "quick": { - "file": str(target), - "content": "* TODO {{title}} then {{title}}", - }, + "alpha": {"file": str(target), "content": "* TODO Alpha"}, + "beta": {"file": str(target), "content": "* TODO {{title}} @{{owner}}"}, }, ) - - calls: list[str] = [] - - def _prompt(message: str, **_kwargs: object) -> str: - calls.append(message) - return "Write docs" - try: - monkeypatch.setattr("org.commands.tasks.capture.typer.prompt", _prompt) - capture.run_tasks_capture(_make_capture_args(template_name="quick")) + monkeypatch.setattr( + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_template_selection_app", + lambda _names: "beta", + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, + ) + + result = capture.capture_task(_make_capture_args(template_name=None)) finally: _restore_capture_templates(previous) - updated = target.read_text(encoding="utf-8") - assert "* TODO Write docs then Write docs" in updated - assert calls == ["Value for 'title'"] + assert result.template_name == "beta" + assert result.interactive_used is True + assert "* TODO Write docs @Jane" in target.read_text(encoding="utf-8") -def test_capture_task_returns_created_heading_metadata( +def test_capture_task_uses_form_app_for_unresolved_placeholders( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Reusable capture API should return metadata for the created heading.""" + """Interactive capture should use the form app when placeholders are unresolved.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}", - }, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, ) - try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Write docs", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "From form"}, ) + result = capture.capture_task(_make_capture_args(template_name="quick")) finally: _restore_capture_templates(previous) - assert result.template_name == "quick" - assert result.document.filename == str(target) - assert result.heading.title_text == "Write docs" assert result.interactive_used is True - assert "* TODO Write docs" in target.read_text(encoding="utf-8") + assert result.heading.title_text == "From form" -def test_run_capture_non_interactive_prints_created_task_id( - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - """Non-interactive capture should print new task ID when available.""" +def test_capture_task_returns_created_heading_metadata_noninteractive(tmp_path: Path) -> None: + """Non-interactive capture should return created heading metadata.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}\n:PROPERTIES:\n:ID: task-42\n:END:", - }, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, ) - try: - capture.run_tasks_capture(_make_capture_args("quick", set_values=["title=From set"])) + result = capture.capture_task( + _make_capture_args(template_name="quick", set_values=["title=Write docs"]), + ) finally: _restore_capture_templates(previous) - assert capsys.readouterr().out.strip() == "task-42" + assert result.template_name == "quick" + assert result.document.filename == str(target) + assert result.heading.title_text == "Write docs" + assert result.interactive_used is False -def test_run_capture_non_interactive_prints_created_task_title_when_id_missing( +def test_run_tasks_capture_noninteractive_prints_created_task_id( tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - """Non-interactive capture should print title when task ID is absent.""" + """Non-interactive capture should print new task ID when available.""" target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( { "quick": { "file": str(target), - "content": "* TODO {{title}}", + "content": "* TODO {{title}}\n:PROPERTIES:\n:ID: task-42\n:END:", }, }, ) - try: capture.run_tasks_capture(_make_capture_args("quick", set_values=["title=From set"])) finally: _restore_capture_templates(previous) - assert capsys.readouterr().out.strip() == "From set" + assert capsys.readouterr().out.strip() == "task-42" -def test_run_capture_interactive_does_not_print_created_identifier( +def test_run_tasks_capture_interactive_does_not_print_identifier( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -223,18 +231,16 @@ def test_run_capture_interactive_does_not_print_created_identifier( target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}}", - }, - }, + {"quick": {"file": str(target), "content": "* TODO {{title}}"}}, ) - try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Write docs", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Write docs"}, ) capture.run_tasks_capture(_make_capture_args(template_name="quick")) finally: @@ -251,406 +257,45 @@ def test_render_template_preview_tracks_placeholder_spans() -> None: ) assert preview.text == "* TODO Write docs [Write docs] {{owner}}" - title_spans = preview.placeholder_spans["title"] - owner_spans = preview.placeholder_spans["owner"] - - assert title_spans == [(7, 17), (19, 29)] - assert owner_spans == [(31, 40)] + assert preview.placeholder_spans["title"] == [(7, 17), (19, 29)] + assert preview.placeholder_spans["owner"] == [(31, 40)] -def test_resolve_placeholder_values_uses_live_preview_when_supported( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Interactive placeholders should route through live preview when available.""" - calls: list[tuple[str, list[str], dict[str, str]]] = [] - - def _fake_live_prompt( - template_content: str, - placeholders: list[str], - values: dict[str, str], - ) -> dict[str, str]: - calls.append((template_content, placeholders, values)) - return { - **values, - "title": "From live preview", - } - - monkeypatch.setattr("org.commands.tasks.capture._supports_live_template_prompt", lambda: True) - monkeypatch.setattr("org.commands.tasks.capture._prompt_with_live_preview", _fake_live_prompt) - - result, prompted = capture._resolve_placeholder_values( - "* TODO {{title}}", - ["title"], - set_values={}, - static_values={}, - document_values={}, - ) - - assert result == {"title": "From live preview"} - assert prompted is True - assert calls == [("* TODO {{title}}", ["title"], {})] - - -def test_prompt_with_live_preview_updates_current_field_sequence( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Live prompt should update preview in field order and finalize after input.""" - - class _FakeLive: - def __init__(self, initial_renderable: object, **kwargs: object) -> None: - self.initial_renderable = initial_renderable - self.updates: list[object] = [] - self.kwargs = kwargs - - class _FakeSize: - width = 80 - - class _FakeConsole: - size = _FakeSize() - - self.console = _FakeConsole() - - def __enter__(self) -> Self: - return self - - def __exit__( - self, - _exc_type: type[BaseException] | None, - _exc: BaseException | None, - _tb: object, - ) -> None: - return None - - def update(self, renderable: object, *, refresh: bool) -> None: - assert refresh - self.updates.append(renderable) - - fake_live: _FakeLive | None = None - - def _live_factory(initial_renderable: object, **kwargs: object) -> _FakeLive: - nonlocal fake_live - fake_live = _FakeLive(initial_renderable, **kwargs) - return fake_live - - def _renderable( - _template_content: str, - values: dict[str, str], - footer_state: capture._FooterState, - console_width: int, - ) -> tuple[dict[str, str], capture._FooterState, int]: - return ( - dict(values), - footer_state, - console_width, - ) - - entered_values = iter(["Write docs", "Jane"]) - - def _read_value( - _live: _FakeLive, - _template_content: str, - _resolved_values: dict[str, str], - _active_field: capture._ActiveField, - ) -> str: - return next(entered_values) - - live_path = "org.commands.tasks.capture.Live" - render_path = "org.commands.tasks.capture._build_fullscreen_capture_renderable" - read_value_path = "org.commands.tasks.capture._read_live_placeholder_value" - width_path = "org.commands.tasks.capture._resolve_terminal_width" - fileno_path = "org.commands.tasks.capture.sys.stdin.fileno" - tcgetattr_path = "org.commands.tasks.capture.termios.tcgetattr" - - monkeypatch.setattr(live_path, _live_factory) - monkeypatch.setattr(render_path, _renderable) - monkeypatch.setattr(read_value_path, _read_value) - monkeypatch.setattr(width_path, lambda: 80) - monkeypatch.setattr(fileno_path, lambda: 0) - monkeypatch.setattr(tcgetattr_path, lambda _fd: [0, 0, 0, 0, 0, 0]) - - resolved = capture._prompt_with_live_preview( - "* TODO {{title}} @{{owner}}", - ["title", "owner"], - {}, - ) - - assert resolved == {"title": "Write docs", "owner": "Jane"} - assert fake_live is not None - assert fake_live.initial_renderable == ( - {}, - capture._FooterState( - current_placeholder="title", - current_input_value="", - cursor_position=0, - current_field_index=1, - total_fields=2, - ), - 80, - ) - assert fake_live.kwargs["screen"] is True - assert fake_live.kwargs["refresh_per_second"] == 12 - assert fake_live.kwargs["auto_refresh"] is False - assert fake_live.kwargs["transient"] is False - assert fake_live.updates == [ - ( - {"title": "Write docs", "owner": "Jane"}, - capture._FooterState( - current_placeholder=None, - current_input_value="", - cursor_position=0, - current_field_index=None, - total_fields=2, - ), - 80, - ), - ] - - -def test_read_live_placeholder_value_keeps_raw_mode_for_full_input( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Live placeholder input should keep raw mode enabled while reading all events.""" - - class _FakeSize: - width = 80 - - class _FakeConsole: - size = _FakeSize() - - class _FakeLive: - def __init__(self) -> None: - self.console = _FakeConsole() - self.updates: list[object] = [] - - def update(self, renderable: object, *, refresh: bool) -> None: - assert refresh - self.updates.append(renderable) - - fake_live = _FakeLive() - events = iter( - [ - ("TEXT", "Pasted text"), - ("ENTER", ""), - ], - ) - setcbreak_calls: list[int] = [] - restore_calls: list[tuple[int, int, list[int]]] = [] - - def _setcbreak(fd: int) -> None: - setcbreak_calls.append(fd) - - monkeypatch.setattr("org.commands.tasks.capture.sys.stdin.fileno", lambda: 7) - monkeypatch.setattr( - "org.commands.tasks.capture.termios.tcgetattr", - lambda _fd: [1, 2, 3, 4, 5, 6], - ) - monkeypatch.setattr("org.commands.tasks.capture.tty.setcbreak", _setcbreak) - monkeypatch.setattr( - "org.commands.tasks.capture.termios.tcsetattr", - lambda fd, when, mode: restore_calls.append((fd, when, mode)), - ) - monkeypatch.setattr( - "org.commands.tasks.capture.read_input_event", - lambda **_kwargs: next(events), - ) - - value = capture._read_live_placeholder_value( - cast("Any", fake_live), - "* TODO {{title}}", - {}, - capture._ActiveField(placeholder="title", field_index=1, total_fields=1), - ) - - assert value == "Pasted text" - assert setcbreak_calls == [7] - assert len(restore_calls) == 1 - assert restore_calls[0][0] == 7 - - -def test_build_footer_prompt_includes_field_index_marker() -> None: - """Footer prompt should render editable value input only.""" - footer = capture._build_footer_prompt( - capture._FooterState( - current_placeholder="owner", - current_input_value="Jane", - cursor_position=4, - current_field_index=2, - total_fields=5, - ), - ) - assert footer.plain.startswith("Value for 'owner': Jane") - - -def test_build_footer_status_line_displays_marker_and_controls() -> None: - """Footer status line should show left marker and right controls.""" - footer_state = capture._FooterState( - current_placeholder="owner", - current_input_value="Jane", - cursor_position=4, - current_field_index=2, - total_fields=5, - ) - status_line = capture._build_footer_status_line(footer_state) - assert status_line.columns[1].justify == "right" - assert capture._value_progress_marker(footer_state) == "Value 2/5" - - -def test_capture_help_modal_key_toggle_helper() -> None: - """Question mark should open help modal and any key should close it.""" - consumed, show = interactive_common.apply_help_modal_key("?", show_help_modal=False) - assert consumed is True - assert show is True - - consumed_close, show_close = interactive_common.apply_help_modal_key( - "ENTER", - show_help_modal=True, - ) - assert consumed_close is True - assert show_close is False - - -def test_build_fullscreen_capture_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel in capture fullscreen view.""" - renderable = capture._build_fullscreen_capture_renderable( - "* TODO {{title}}", - {}, - capture._FooterState( - current_placeholder="title", - current_input_value="", - cursor_position=0, - current_field_index=1, - total_fields=1, - show_help_modal=True, - ), - console_width=100, - ) - output_buffer = StringIO() - console = Console(file=output_buffer, force_terminal=False) - console.print(renderable) - output = output_buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - -def test_count_wrapped_prompt_lines_expands_with_long_input() -> None: - """Long prompt values should wrap to multiple footer lines.""" - prompt = capture._build_footer_prompt( - capture._FooterState( - current_placeholder="title", - current_input_value="x" * 120, - cursor_position=120, - current_field_index=1, - total_fields=2, - ), - ) - wrapped_lines = capture._count_wrapped_prompt_lines(prompt, console_width=40) - assert wrapped_lines > 1 - - -def test_apply_input_event_supports_cursor_keys_and_backspace() -> None: - """Input editing should support insertion, cursor motion, and deletion.""" - value = "" - cursor = 0 - - events = [ - ("TEXT", "a"), - ("TEXT", "c"), - ("LEFT", ""), - ("TEXT", "b"), - ("LEFT", ""), - ("DELETE", ""), - ("RIGHT", ""), - ("BACKSPACE", ""), - ("TEXT", "b"), - ("ENTER", ""), - ] - - done = False - for event_name, event_text in events: - value, cursor, done = capture._apply_input_event(value, cursor, event_name, event_text) - - assert done - assert value == "ab" - assert cursor == 2 - - -def test_extract_bracketed_paste_text_decodes_payload() -> None: - """Bracketed paste payload should decode to inserted prompt text.""" - payload = b"\x1b[200~Line one\nLine two\x1b[201~" - assert interactive_common.extract_bracketed_paste_text(payload) == "Line one\nLine two" - - -def test_read_input_event_maps_bracketed_paste_to_text(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste input should be surfaced as TEXT event.""" - monkeypatch.setattr("org.commands.interactive_common.sys.stdin.fileno", lambda: 0) - monkeypatch.setattr("org.commands.interactive_common.os.read", lambda _fd, _n: b"\x1b") - monkeypatch.setattr( - "org.commands.interactive_common.read_escape_sequence", - lambda _fd: b"\x1b[200~Paste value", - ) - monkeypatch.setattr( - "org.commands.interactive_common.read_bracketed_paste_payload", - lambda _fd, initial_payload: initial_payload + b"\x1b[201~", - ) - - assert interactive_common.read_input_event(ctrl_p_as_paste=True) == ("TEXT", "Paste value") - - -def test_set_bracketed_paste_writes_terminal_sequences(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste toggles should emit proper terminal sequences.""" - - class _FakeStdout: - def __init__(self) -> None: - self.writes: list[str] = [] - - def isatty(self) -> bool: - return True - - def write(self, value: str) -> int: - self.writes.append(value) - return len(value) - - def flush(self) -> None: - return None - - fake_stdout = _FakeStdout() - monkeypatch.setattr("org.commands.interactive_common.sys.stdout", fake_stdout) - - interactive_common.set_bracketed_paste(True) - interactive_common.set_bracketed_paste(False) - - assert fake_stdout.writes == ["\x1b[?2004h", "\x1b[?2004l"] - - -def test_run_capture_rejects_invalid_render_without_file_write( +def test_prepare_capture_plan_uses_document_and_parent_placeholders( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - """Invalid rendered heading should fail before file mutation.""" + """Prepared capture plan should resolve document and parent placeholder values.""" target = tmp_path / "tasks.org" - original = "* TODO Existing\n" - target.write_text(original, encoding="utf-8") + target.write_text( + "#+TITLE: Project File\n" + "#+AUTHOR: Jane Doe\n" + "#+DESCRIPTION: Sprint tasks\n" + "#+CATEGORY: Work\n" + "* TODO Parent\n:PROPERTIES:\n:ID: parent-1\n:CATEGORY: Projects\n:END:\n", + encoding="utf-8", + ) previous = _set_capture_templates( { - "broken": { + "child": { "file": str(target), - "content": "not a heading {{title}}", + "parent": '.id == "parent-1"', + "content": ( + "** TODO doc={{document_title}} cat={{document_category}} " + "parent={{parent_title}} parent_id={{parent_id}}" + ), }, }, ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Anything", - ) - with pytest.raises(typer.BadParameter, match="Invalid rendered capture heading"): - capture.run_tasks_capture(_make_capture_args(template_name="broken")) + plan = capture.prepare_capture_plan(_make_capture_args("child"), "child") finally: _restore_capture_templates(previous) - assert target.read_text(encoding="utf-8") == original + assert plan.values["document_title"] == "Project File" + assert plan.values["document_category"] == "Work" + assert plan.values["parent_title"] == "Parent" + assert plan.values["parent_id"] == "parent-1" + assert plan.unresolved_placeholders == [] def test_run_capture_inserts_under_parent_query( @@ -672,11 +317,14 @@ def test_run_capture_inserts_under_parent_query( }, }, ) - try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Child task", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "Child task"}, ) capture.run_tasks_capture(_make_capture_args(template_name="child")) finally: @@ -707,8 +355,12 @@ def test_run_capture_cli_file_override_takes_precedence( ) try: monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "From override", + "org.commands.tasks.capture.command._is_interactive_terminal", + lambda: True, + ) + monkeypatch.setattr( + "org.commands.tasks.capture.command.run_capture_form_app", + lambda plan: {**plan.values, "title": "From override"}, ) capture.run_tasks_capture(_make_capture_args("quick", file=str(override_target))) finally: @@ -718,81 +370,6 @@ def test_run_capture_cli_file_override_takes_precedence( assert "From override" in override_target.read_text(encoding="utf-8") -def test_run_capture_cli_parent_override_takes_precedence( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should use CLI --parent selector over template parent selector.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", - encoding="utf-8", - ) - - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "content": "** TODO {{title}}", - "parent": '.id == "one"', - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: "Override child", - ) - capture.run_tasks_capture(_make_capture_args("child", parent="two")) - finally: - _restore_capture_templates(previous) - - root = org_parser.loads(target.read_text(encoding="utf-8")) - nodes = list(root) - titles = [node.title_text.strip() for node in nodes] - assert titles == ["One", "Two", "Override child"] - - -def test_run_capture_set_values_bypass_prompt(tmp_path: Path) -> None: - """Capture should use --set values without prompting.""" - target = tmp_path / "tasks.org" - target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO {{title}} ({{title}})", - }, - }, - ) - try: - capture.run_tasks_capture(_make_capture_args("quick", set_values=["title=From set"])) - finally: - _restore_capture_templates(previous) - - assert "* TODO From set (From set)" in target.read_text(encoding="utf-8") - - -def test_run_capture_set_values_ignores_unknown_parameter(tmp_path: Path) -> None: - """Capture should ignore --set keys absent from template placeholders.""" - target = tmp_path / "tasks.org" - target.write_text("* TODO Existing\n", encoding="utf-8") - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": "* TODO Static", - }, - }, - ) - try: - capture.run_tasks_capture(_make_capture_args("quick", set_values=["foo=bar"])) - finally: - _restore_capture_templates(previous) - - assert "* TODO Static" in target.read_text(encoding="utf-8") - - def test_run_capture_set_values_reject_invalid_format(tmp_path: Path) -> None: """Capture should reject --set entries that are not KEY=VALUE.""" target = tmp_path / "tasks.org" @@ -855,164 +432,3 @@ def test_run_capture_static_id_placeholder_uses_next_heading_number(tmp_path: Pa _restore_capture_templates(previous) assert ":ID: 3" in target.read_text(encoding="utf-8") - - -def test_run_capture_document_fields_are_available_for_templating( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should render explicit document metadata placeholders without prompting.""" - target = tmp_path / "tasks.org" - target.write_text( - "#+TITLE: Project File\n" - "#+AUTHOR: Jane Doe\n" - "#+DESCRIPTION: Sprint tasks\n" - "#+CATEGORY: Work\n" - "* TODO Existing\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "quick": { - "file": str(target), - "content": ( - "* TODO " - "cat={{document_category}} file={{document_filename}} " - "title={{document_title}} author={{document_author}} " - "desc={{document_description}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("quick")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "cat=Work" in updated - assert f"file={target}" in updated - assert "title=Project File" in updated - assert "author=Jane Doe" in updated - assert "desc=Sprint tasks" in updated - - -def test_run_capture_parent_fields_are_available_with_template_parent( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should expose parent_ placeholders when template parent is configured.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO Project Parent\n:PROPERTIES:\n:ID: project-1\n:CATEGORY: Projects\n:END:\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "parent": '.id == "project-1"', - "content": ( - "** TODO parent_cat={{parent_category}} " - "parent_title={{parent_title}} parent_todo={{parent_todo}} " - "parent_id={{parent_id}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("child")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "parent_cat=Projects" in updated - assert "parent_title=Project Parent" in updated - assert "parent_todo=TODO" in updated - assert "parent_id=project-1" in updated - - -def test_run_capture_parent_fields_are_available_with_cli_parent_override( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture should expose parent_ placeholders with CLI --parent selector.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" - "* TODO Two\n:PROPERTIES:\n:ID: two\n:CATEGORY: Focus\n:END:\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "content": ( - "** TODO parent_cat={{parent_category}} " - "parent_title={{parent_title}} parent_todo={{parent_todo}} " - "parent_id={{parent_id}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("child", parent="two")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "parent_cat=Focus" in updated - assert "parent_title=Two" in updated - assert "parent_todo=TODO" in updated - assert "parent_id=two" in updated - - -def test_run_capture_cli_parent_override_accepts_title( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Capture CLI --parent should resolve exact parent title as override.""" - target = tmp_path / "tasks.org" - target.write_text( - "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" - "* TODO Two\n:PROPERTIES:\n:ID: two\n:CATEGORY: Focus\n:END:\n", - encoding="utf-8", - ) - previous = _set_capture_templates( - { - "child": { - "file": str(target), - "content": ( - "** TODO parent_cat={{parent_category}} " - "parent_title={{parent_title}} parent_todo={{parent_todo}} " - "parent_id={{parent_id}}" - ), - }, - }, - ) - try: - monkeypatch.setattr( - "org.commands.tasks.capture.typer.prompt", - lambda _msg, **_kwargs: (_ for _ in ()).throw(AssertionError("prompt not expected")), - ) - capture.run_tasks_capture(_make_capture_args("child", parent="Two")) - finally: - _restore_capture_templates(previous) - - updated = target.read_text(encoding="utf-8") - assert "parent_cat=Focus" in updated - assert "parent_title=Two" in updated - assert "parent_todo=TODO" in updated - assert "parent_id=two" in updated diff --git a/tests/fixtures/empty-config.yaml b/tests/fixtures/empty-config.yaml new file mode 100644 index 0000000..479207d --- /dev/null +++ b/tests/fixtures/empty-config.yaml @@ -0,0 +1,2 @@ + +{} From ef4aa58b2eefacbb279a09e5ea392b420a543f95 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Fri, 5 Jun 2026 15:49:20 +0200 Subject: [PATCH 06/27] Sped up the test suite a bit. --- VIBES.org | 5 +- .../commands/tasks/test_tasks_capture_app.py | 105 +++++++----------- tests/commands/tasks/test_tasks_list_app.py | 19 ++-- 3 files changed, 56 insertions(+), 73 deletions(-) diff --git a/VIBES.org b/VIBES.org index ec8c261..cda315c 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7438,11 +7438,14 @@ Filename: /home/k/projects/software/org-cli/tests/commands/tasks/test_tasks_capt #+end_example Please don't rely on the .org-cli.yaml file present in the repository in tests. Substritute a temporary file if the configuration is needed. -**** TODO The test suite is a lot slower after the Textual rewrite +**** DONE The test suite is a lot slower after the Textual rewrite :PROPERTIES: :capture_time: [2026-06-05 Fri 13:19] :context: [[file:~/projects/software/org-cli/VIBES.org::*Code review comment][Code review comment]] :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-06 Sat 16:04] +:END: It used to be under 8 seconds. Please identify the slow tests and update them to run faster. *** Use Textual widgets for better TUI organization - Selection for prompts diff --git a/tests/commands/tasks/test_tasks_capture_app.py b/tests/commands/tasks/test_tasks_capture_app.py index 5db5054..be19c25 100644 --- a/tests/commands/tasks/test_tasks_capture_app.py +++ b/tests/commands/tasks/test_tasks_capture_app.py @@ -7,62 +7,48 @@ from textual.widgets import Static, TextArea -from org import config as config_module from org.commands.tasks import capture from org.commands.tasks.capture.app import CaptureApp if TYPE_CHECKING: - from pathlib import Path - - -def _make_plan(tmp_path: Path) -> capture.CapturePlan: - target = tmp_path / "tasks.org" - config_path = tmp_path / "config.yaml" - target.write_text("* TODO Existing\n", encoding="utf-8") - config_path.write_text("", encoding="utf-8") - previous = { - "quick": { - "file": str(target), - "content": "* TODO {{title}} @{{owner}}", - }, - } - original = dict(config_module.CONFIG_CAPTURE_TEMPLATES) - config_module.CONFIG_CAPTURE_TEMPLATES.clear() - config_module.CONFIG_CAPTURE_TEMPLATES.update(previous) - try: - return capture.prepare_capture_plan( - capture.TasksCaptureArgs( - template_name="quick", - config=str(config_path), - file=None, - parent=None, - set_values=None, - ), - "quick", - ) - finally: - config_module.CONFIG_CAPTURE_TEMPLATES.clear() - config_module.CONFIG_CAPTURE_TEMPLATES.update(original) - - -def test_capture_app_preview_updates_from_field_input(tmp_path: Path) -> None: + from org_parser.document import Document, Heading + + +def _make_plan() -> capture.CapturePlan: + return capture.CapturePlan( + args=capture.TasksCaptureArgs( + template_name="quick", + config="unused.yaml", + file=None, + parent=None, + set_values=None, + ), + template_name="quick", + template_content="* TODO {{title}} @{{owner}}", + document=cast("Document", object()), + parent_heading=cast("Heading | None", None), + placeholders=["title", "owner"], + values={}, + unresolved_placeholders=["title", "owner"], + ) + + +def test_capture_app_preview_updates_from_field_input() -> None: """Editing a field should update the rendered preview.""" async def _run() -> None: - app = CaptureApp(_make_plan(tmp_path)) - async with app.run_test() as pilot: + app = CaptureApp(_make_plan()) + async with app.run_test(): title_input = app.screen.query_one("#field-title", TextArea) owner_input = app.screen.query_one("#field-owner", TextArea) assert title_input.text == "" assert owner_input.text == "" - await pilot.press("W", "r", "i", "t", "e") - await pilot.pause() - owner_input.focus() - await pilot.pause() - await pilot.press("J", "a", "n", "e") - await pilot.pause() + title_input.load_text("Write") + app.on_text_area_changed(TextArea.Changed(title_input)) + owner_input.load_text("Jane") + app.on_text_area_changed(TextArea.Changed(owner_input)) assert app.field_values["title"] == "Write" assert app.field_values["owner"] == "Jane" @@ -72,14 +58,15 @@ async def _run() -> None: asyncio.run(_run()) -def test_capture_app_textarea_keeps_newlines(tmp_path: Path) -> None: +def test_capture_app_textarea_keeps_newlines() -> None: """Multiline field edits should be preserved in preview values.""" async def _run() -> None: - app = CaptureApp(_make_plan(tmp_path)) - async with app.run_test() as pilot: - await pilot.press("L", "i", "n", "e", "1", "enter", "L", "i", "n", "e", "2") - await pilot.pause() + app = CaptureApp(_make_plan()) + async with app.run_test(): + title_input = app.screen.query_one("#field-title", TextArea) + title_input.load_text("Line1\nLine2") + app.on_text_area_changed(TextArea.Changed(title_input)) assert app.field_values["title"] == "Line1\nLine2" assert app._preview_values()["title"] == "Line1\nLine2" @@ -87,11 +74,11 @@ async def _run() -> None: asyncio.run(_run()) -def test_capture_app_help_modal_shows_key_bindings(tmp_path: Path) -> None: +def test_capture_app_help_modal_shows_key_bindings() -> None: """Question mark should open the shared help modal.""" async def _run() -> None: - app = CaptureApp(_make_plan(tmp_path)) + app = CaptureApp(_make_plan()) async with app.run_test() as pilot: app.action_show_help() await pilot.pause() @@ -102,21 +89,15 @@ async def _run() -> None: asyncio.run(_run()) -def test_capture_app_save_collects_form_values(tmp_path: Path) -> None: +def test_capture_app_save_collects_form_values() -> None: """Saving the form should return the current field values.""" async def _run() -> None: - app = CaptureApp(_make_plan(tmp_path)) - async with app.run_test() as pilot: - owner_input = app.screen.query_one("#field-owner", TextArea) - await pilot.press("W", "o", "r", "k") - await pilot.pause() - owner_input.focus() - await pilot.pause() - await pilot.press("S", "a", "m") - await pilot.pause() - await pilot.press("ctrl+s") - await pilot.pause() + app = CaptureApp(_make_plan()) + async with app.run_test(): + app.field_values["title"] = "Work" + app.field_values["owner"] = "Sam" + app.action_save_capture() assert app.result_values is not None assert app.result_values["title"] == "Work" diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py index 5c1e764..89b0cf0 100644 --- a/tests/commands/tasks/test_tasks_list_app.py +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -36,11 +36,9 @@ async def _run() -> None: async with app.run_test() as pilot: await pilot.press("down") - await pilot.pause() assert app.session.selected_index == 1 await pilot.press("up") - await pilot.pause() assert app.session.selected_index == 0 asyncio.run(_run()) @@ -54,18 +52,19 @@ async def _run() -> None: app = TasksListApp(make_list_args([]), _make_session_data(nodes)) async with app.run_test() as pilot: - await pilot.press("slash") + app.action_prompt_search() await pilot.pause() prompt_input = app.screen.query_one(Input) assert prompt_input.value == "" - await pilot.press("a", "l", "p", "h", "a") - await pilot.pause() + prompt_input.value = "alpha" + prompt_screen = cast("Any", app.screen_stack[-1]) + prompt_screen.on_input_changed(Input.Changed(prompt_input, "alpha")) assert app.session.search_text == "alpha" assert [node.title_text for node in app.session.visible_nodes] == ["Alpha"] - await pilot.press("enter") + app.screen_stack[-1].dismiss("alpha") await pilot.pause() assert app.session.search_text == "alpha" assert [node.title_text for node in app.session.visible_nodes] == ["Alpha"] @@ -81,7 +80,7 @@ async def _run() -> None: app = TasksListApp(make_list_args([]), _make_session_data(nodes)) async with app.run_test() as pilot: - await pilot.press("question_mark") + app.action_show_help() await pilot.pause() help_title = app.screen.query_one("#help-title", Static) @@ -105,7 +104,7 @@ async def _run() -> None: app = TasksListApp(make_list_args([]), _make_session_data(nodes)) async with app.run_test() as pilot: - await pilot.press("slash") + app.action_prompt_search() await pilot.pause() prompt_label = app.screen.query_one("#prompt-label", Static) @@ -124,7 +123,7 @@ async def _run() -> None: app = TasksListApp(make_list_args([]), _make_session_data(nodes)) async with app.run_test() as pilot: - await pilot.press("slash") + app.action_prompt_search() await pilot.pause() assert app.screen.query_one("#prompt-label", Static) is not None @@ -146,7 +145,7 @@ async def _run() -> None: app = TasksListApp(make_list_args([]), _make_session_data(nodes)) async with app.run_test() as pilot: - await pilot.press("question_mark") + app.action_show_help() await pilot.pause() assert app.screen.query_one("#help-content", Static) is not None From b874807803ed57d9739f0fa86959e16a7cb0246b Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sat, 6 Jun 2026 20:02:08 +0200 Subject: [PATCH 07/27] Converted the agenda command to textual. --- VIBES.org | 18 + src/org/color.py | 12 +- src/org/commands/agenda/app.py | 438 ++++++++++++++ src/org/commands/agenda/command.py | 24 +- src/org/commands/agenda/events.py | 346 ++--------- src/org/commands/agenda/layout.py | 249 +++++--- src/org/commands/agenda/styles/app.tcss | 18 + src/org/commands/runtime.py | 12 +- tests/color/test_color.py | 11 +- tests/commands/test_agenda_app.py | 275 +++++++++ tests/commands/test_agenda_command.py | 744 +----------------------- 11 files changed, 981 insertions(+), 1166 deletions(-) create mode 100644 src/org/commands/agenda/app.py create mode 100644 src/org/commands/agenda/styles/app.tcss create mode 100644 tests/commands/test_agenda_app.py diff --git a/VIBES.org b/VIBES.org index cda315c..e84af2f 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7447,6 +7447,24 @@ Please don't rely on the .org-cli.yaml file present in the repository in tests. - State "DONE" from "TODO" [2026-06-06 Sat 16:04] :END: It used to be under 8 seconds. Please identify the slow tests and update them to run faster. +**** DONE src/org/commands/agenda/layout.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-06 Sat 19:56] +:END: + +In file src/org/commands/agenda/layout.py around line 624, near the ~def _selected_row_location(session: AgendaSession) -> tuple[int, int] | None:~ + +#+begin_example ++def selected_row_location(session: AgendaSession) -> tuple[int, int] | None: ++ """Return the selected interactive row location for one agenda session.""" ++ return _selected_row_location(session) + +#+end_example + +This wrapper should be removed, prefer renaming functions and adjusting callsites. *** Use Textual widgets for better TUI organization - Selection for prompts - Sparkline for freequency plots diff --git a/src/org/color.py b/src/org/color.py index a03a251..dca5678 100644 --- a/src/org/color.py +++ b/src/org/color.py @@ -122,12 +122,18 @@ def get_state_color( if not enabled: return "" + normalized_state = state.strip().upper() + + if normalized_state == "CANCELLED": + return "bold red" + + if normalized_state == "SUSPENDED": + return "bold yellow" + if state in done_states: - if state == "CANCELLED": - return "bold red" return "bold green" if state in todo_states or state == "" or state.lower() == "null": - return "dim white" + return "bold bright_black" return "bold yellow" diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py new file mode 100644 index 0000000..381b95d --- /dev/null +++ b/src/org/commands/agenda/app.py @@ -0,0 +1,438 @@ +"""Textual app for the interactive agenda command.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.widgets import Static + +from org.cli_common import resolve_input_paths +from org.commands import runtime +from org.commands.tasks.common import ( + capture_template_prompt_label, + clock_duration_prompt_label, + configured_capture_template_names, + refile_prompt_label, + resolve_capture_template_selection, + resolve_todo_state_selection, + state_selection_prompt_label, +) + +from . import events, layout +from .events import AgendaSession, create_agenda_session + + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import ClassVar + + from org_parser.document import Heading + from textual.app import ComposeResult + from textual.events import MouseScrollDown, MouseScrollUp, Resize + + from .command import AgendaArgs + from .views import AgendaViewContext + + +_HELP_FOOTER_TEXT = "Type ? for help" + + +class AgendaRowWidget(Static): + """One reusable rendered line in the agenda viewport.""" + + def set_row(self, row_text: Text) -> None: + """Update the widget content for one viewport row.""" + self.update(row_text) + + +class AgendaViewport(Vertical): + """Virtualized agenda viewport backed by a reusable row widget pool.""" + + def ensure_row_pool(self, count: int) -> None: + """Grow or shrink the rendered row pool to match the viewport height.""" + widgets = self.row_widgets() + while len(widgets) < count: + self.mount(AgendaRowWidget(classes="agenda-row")) + widgets = self.row_widgets() + while len(widgets) > count: + widgets[-1].remove() + widgets = self.row_widgets() + + def row_widgets(self) -> list[AgendaRowWidget]: + """Return row widgets in display order.""" + return cast("list[AgendaRowWidget]", list(self.query(AgendaRowWidget))) + + +class AgendaApp(runtime.CommandApp): + """Textual app that backs interactive `agenda`.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", show=False), + Binding("down", "move_down", show=False), + Binding("n", "move_down", show=False), + Binding("p", "move_up", show=False), + Binding("left", "page_backward", show=False), + Binding("right", "page_forward", show=False), + Binding("f", "page_forward", show=False), + Binding("b", "page_backward", show=False), + Binding("enter", "edit_selected", show=False), + Binding("slash", "prompt_search", show=False), + Binding("x", "clear_search", show=False), + Binding("a", "prompt_capture", show=False), + Binding("dollar_sign", "archive_selected", show=False), + Binding("t", "prompt_state", show=False), + Binding("r", "prompt_refile", show=False), + Binding("c", "prompt_clock", show=False), + Binding("shift+left", "shift_date_backward", show=False), + Binding("shift+right", "shift_date_forward", show=False), + Binding("shift+up", "shift_time_backward", show=False), + Binding("shift+down", "shift_time_forward", show=False), + Binding("question_mark", "show_help", show=False), + Binding("q", "quit_app", show=False), + Binding("escape", "quit_app", show=False), + ] + + def __init__(self, session: AgendaSession) -> None: + """Store the prepared agenda session.""" + super().__init__() + self.session = session + + def compose(self) -> ComposeResult: + """Build the main agenda screen layout.""" + yield Vertical( + Static(id="agenda-header"), + AgendaViewport(id="agenda-body"), + Static(id="agenda-footer"), + Static(id="agenda-status"), + ) + + def on_mount(self) -> None: + """Render initial state and arm the minute-refresh timer.""" + self.set_interval(1.0, self._refresh_for_clock_tick) + self._refresh_view() + + def on_resize(self, _event: Resize) -> None: + """Refresh viewport layout after terminal resize.""" + self._refresh_view() + + def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: + """Match legacy wheel-up navigation behavior.""" + events.move_selection(self.session, -1) + self._refresh_view() + + def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: + """Match legacy wheel-down navigation behavior.""" + events.move_selection(self.session, 1) + self._refresh_view() + + def _header_widget(self) -> Static: + return self.query_one("#agenda-header", Static) + + def _body_widget(self) -> AgendaViewport: + return self.query_one("#agenda-body", AgendaViewport) + + def _footer_widget(self) -> Static: + return self.query_one("#agenda-footer", Static) + + def _status_widget(self) -> Static: + return self.query_one("#agenda-status", Static) + + def _viewport_height(self) -> int: + body_height = self._body_widget().size.height + if body_height <= 0: + return max(3, self.size.height - 3) + return max(3, body_height) + + def _line_width(self) -> int: + body_width = self._body_widget().size.width + if body_width <= 0: + return max(40, self.size.width) + return max(40, body_width) + + def _refresh_header(self) -> None: + sticky = layout.sticky_day(self.session) + self._header_widget().update( + Text( + sticky.strftime("%A %Y-%m-%d"), + style="bold" if self.session.render.color_enabled else "", + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_footer(self, viewport_height: int) -> None: + total_rows = max(len(self.session.interactive_rows), 1) + end_line = min( + self.session.scroll_offset + viewport_height, + len(self.session.interactive_rows), + ) + search_text = self.session.search_text or "-" + footer_style = "dim" if self.session.render.color_enabled else "" + self._footer_widget().update( + Text( + f"Lines {end_line}/{total_rows} | Search: {search_text} | {_HELP_FOOTER_TEXT}", + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_status(self) -> None: + footer_style = "dim" if self.session.render.color_enabled else "" + self._status_widget().update( + Text( + self.session.status_message or "", + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_rows(self, viewport_height: int) -> None: + body = self._body_widget() + body.ensure_row_pool(viewport_height) + layout.sync_scroll_offset(self.session, viewport_height) + selected_location = layout.selected_row_location(self.session) + line_width = self._line_width() + window = self.session.interactive_rows[ + self.session.scroll_offset : self.session.scroll_offset + viewport_height + ] + + for widget, row in zip(body.row_widgets(), window, strict=False): + widget.set_row( + layout.render_viewport_row_text( + row, + self.session.render, + self.session.column_widths, + line_width=line_width, + highlighted=row.location == selected_location, + ), + ) + + for widget in body.row_widgets()[len(window) :]: + widget.set_row(Text("")) + + def _refresh_view(self) -> None: + viewport_height = self._viewport_height() + self._refresh_header() + self._refresh_rows(viewport_height) + self._refresh_footer(viewport_height) + self._refresh_status() + + def _refresh_for_clock_tick(self) -> None: + if events.refresh_session_if_minute_changed(self.session): + self._refresh_view() + + def _set_status(self, status_message: str) -> None: + self.session.status_message = status_message + + def _run_external(self, callback: Callable[[], None]) -> None: + self.suspend_for_external(callback) + self._refresh_view() + + def _open_prompt( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen( + runtime.PromptModalScreen(label, initial_value=initial_value, on_change=on_change), + callback=_complete, + ) + + def action_move_up(self) -> None: + """Move the selection one row upward.""" + events.move_selection(self.session, -1) + self._refresh_view() + + def action_move_down(self) -> None: + """Move the selection one row downward.""" + events.move_selection(self.session, 1) + self._refresh_view() + + def action_page_backward(self) -> None: + """Move the visible agenda window backward by the current span.""" + events.set_start_date_relative(self.session, day_delta=-self.session.days) + self._refresh_view() + + def action_page_forward(self) -> None: + """Move the visible agenda window forward by the current span.""" + events.set_start_date_relative(self.session, day_delta=self.session.days) + self._refresh_view() + + def action_edit_selected(self) -> None: + """Open the selected task in the external editor.""" + self._run_external(lambda: events.edit_selected_task_in_external_editor(self.session)) + + def action_archive_selected(self) -> None: + """Archive the selected task subtree.""" + events.archive_selected_task(self.session) + self._refresh_view() + + def action_clear_search(self) -> None: + """Clear the active search filter.""" + events.clear_search(self.session) + self._refresh_view() + + def action_shift_date_backward(self) -> None: + """Shift the selected planning date backward by one day.""" + events.apply_shift_date(self.session, day_delta=-1) + self._refresh_view() + + def action_shift_date_forward(self) -> None: + """Shift the selected planning date forward by one day.""" + events.apply_shift_date(self.session, day_delta=1) + self._refresh_view() + + def action_shift_time_backward(self) -> None: + """Shift the selected timed planning row backward by one hour.""" + events.apply_shift_time(self.session, hour_delta=-1) + self._refresh_view() + + def action_shift_time_forward(self) -> None: + """Shift the selected timed planning row forward by one hour.""" + events.apply_shift_time(self.session, hour_delta=1) + self._refresh_view() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen( + runtime.HelpModalScreen( + layout.AGENDA_HELP_ENTRIES, + color_enabled=self.session.render.color_enabled, + ), + ) + + def action_prompt_search(self) -> None: + """Open the search prompt with live filtering preview.""" + previous_text = self.session.search_text + self._search_cancel_text = previous_text + + def _preview(value: str) -> None: + events.apply_search_text(self.session, value.strip()) + self._refresh_view() + + self._open_prompt( + "Search text (blank clears)", + initial_value=previous_text, + on_change=_preview, + on_submit=lambda value: events.apply_search_text(self.session, value.strip()), + on_cancel=lambda: self._cancel_search(previous_text), + ) + + def _cancel_search(self, previous_text: str) -> None: + events.apply_search_text(self.session, previous_text) + self.session.status_message = "Search cancelled" + + def action_prompt_capture(self) -> None: + """Prompt for a capture template and create a task.""" + status_message = events.can_activate_agenda_capture_prompt(self.session) + if status_message is not None: + self._set_status(status_message) + self._refresh_view() + return + + template_names = configured_capture_template_names() + + def _submit(value: str) -> None: + stripped = value.strip() + if not stripped: + self._set_status("Capture cancelled") + return + template_name = resolve_capture_template_selection(stripped, template_names) + if template_name is None: + self._set_status("Invalid capture template shortcut") + return + self._run_external(lambda: events.apply_capture_task(self.session, template_name)) + + self._open_prompt( + capture_template_prompt_label(template_names), + on_submit=_submit, + on_cancel=lambda: self._set_status("Capture cancelled"), + ) + + def action_prompt_state(self) -> None: + """Prompt for a TODO state transition.""" + status_message = events.can_activate_agenda_state_prompt(self.session) + if status_message is not None: + self._set_status(status_message) + self._refresh_view() + return + states = events.state_choices_for_selected_row(self.session) + + def _submit(value: str) -> None: + stripped = value.strip() + if not stripped: + self._set_status("State change cancelled") + return + selected_state = resolve_todo_state_selection(stripped, states) + if selected_state is None: + self._set_status("Invalid TODO state selection") + return + events.apply_state_change_with_value(self.session, selected_state) + + self._open_prompt( + state_selection_prompt_label(states), + on_submit=_submit, + on_cancel=lambda: self._set_status("State change cancelled"), + ) + + def action_prompt_refile(self) -> None: + """Prompt for a destination file and refile the selected task.""" + if events.selected_task_row(self.session) is None: + self._set_status("Action available only on task rows") + self._refresh_view() + return + + self._open_prompt( + refile_prompt_label(resolve_input_paths(self.session.args.files)), + on_submit=lambda value: events.apply_refile_with_value(self.session, value.strip()), + on_cancel=lambda: self._set_status("Refile cancelled"), + ) + + def action_prompt_clock(self) -> None: + """Prompt for a clock duration and append the clock entry.""" + if events.selected_task_row(self.session) is None: + self._set_status("Action available only on task rows") + self._refresh_view() + return + + self._open_prompt( + clock_duration_prompt_label(), + on_submit=lambda value: events.apply_clock_entry_with_value( + self.session, + value.strip(), + ), + on_cancel=lambda: self._set_status("Clock action cancelled"), + ) + + def action_quit_app(self) -> None: + """Exit the interactive agenda app.""" + self.exit() + + +def run_agenda_app( + args: AgendaArgs, + nodes: list[Heading], + render: layout.RenderContext, + view_ctx: AgendaViewContext, +) -> None: + """Run the Textual-backed interactive agenda app.""" + session = create_agenda_session(args, nodes, render, view_ctx) + AgendaApp(session).run() diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 039e572..047666f 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -13,13 +13,12 @@ from org.commands.interactive_common import interactive_help_command_text, local_now from org.tui import build_console, processing_status, setup_output -from . import events, layout +from . import layout +from .app import run_agenda_app from .views import resolve_view_context logger = logging.getLogger("org") -_HIGHLIGHT_ROW_STYLE = "on grey23" -_INTERACTIVE_INPUT_TIMEOUT_SECONDS = 1.0 @dataclass @@ -97,18 +96,15 @@ def run_agenda(args: AgendaArgs) -> None: return if sys.stdin.isatty() and sys.stdout.isatty(): - events.run_agenda_interactive( - console, - events.create_agenda_session( - args, - nodes, - layout.RenderContext( - color_enabled=color_enabled, - done_states=done_states, - todo_states=todo_states, - ), - view_ctx, + run_agenda_app( + args, + nodes, + layout.RenderContext( + color_enabled=color_enabled, + done_states=done_states, + todo_states=todo_states, ), + view_ctx, ) return diff --git a/src/org/commands/agenda/events.py b/src/org/commands/agenda/events.py index 728e2d3..e55ab37 100644 --- a/src/org/commands/agenda/events.py +++ b/src/org/commands/agenda/events.py @@ -15,19 +15,10 @@ from org.commands.archive import archive_heading_subtree_and_save from org.commands.editor import edit_heading_subtree_in_external_editor from org.commands.interactive_common import ( - FooterPromptState, HeadingLocator, - InputEvent, - InteractiveEvent, - InteractivePromptState, - TimeoutEvent, advance_timestamp_by_repeater, append_repeat_transition, - apply_help_modal_key, - create_interactive_prompt_state, - handle_active_prompt_event, heading_locator, - interactive_loop, local_now, resolve_heading_locator, set_timestamp_fields, @@ -35,20 +26,13 @@ from org.commands.search_common import filter_nodes_by_search from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( - PromptActionConfig, - capture_template_prompt_config, - clock_duration_prompt_config, configured_capture_template_names, duration_to_org_text, iter_descendants, load_document, parse_clock_duration, - refile_prompt_config, - resolve_capture_template_selection, resolve_refile_destination_input, - resolve_todo_state_selection, save_document, - state_selection_prompt_config, todo_states_for_heading, ) @@ -58,12 +42,11 @@ DayRowModel, RenderContext, ViewportRow, - _has_specific_time, - _interactive_artifacts, - _resolve_agenda_start_date, - _selected_row_location, + build_interactive_artifacts, build_view_day_model, - interactive_agenda_renderable, + has_specific_time, + resolve_agenda_start_date, + selected_row_location, ) @@ -71,14 +54,12 @@ from collections.abc import Callable from org_parser.document import Document, Heading - from rich.console import Console from .command import AgendaArgs from .views import AgendaViewContext logger = logging.getLogger("org") -_INTERACTIVE_INPUT_TIMEOUT_SECONDS = 1.0 @dataclass @@ -101,9 +82,6 @@ class AgendaSession: status_message: str search_text: str view_ctx: AgendaViewContext - search_prompt_previous_text: str | None = None - show_help_modal: bool = False - active_prompt: InteractivePromptState | None = None run_external: Callable[[Callable[[], None]], None] | None = None @@ -127,9 +105,11 @@ def refresh_session( day_models.append(day_model) session.day_models = day_models - session.row_locations, session.interactive_rows, session.column_widths = _interactive_artifacts( - day_models, - session.render, + session.row_locations, session.interactive_rows, session.column_widths = ( + build_interactive_artifacts( + day_models, + session.render, + ) ) if not session.row_locations: session.selected_row_index = 0 @@ -159,7 +139,7 @@ def refresh_visible_nodes( def selected_task_row(session: AgendaSession) -> AgendaRow | None: """Return currently selected task row when selected row is a task.""" - location = _selected_row_location(session) + location = selected_row_location(session) if location is None: return None day_index, row_index = location @@ -169,7 +149,7 @@ def selected_task_row(session: AgendaSession) -> AgendaRow | None: return row -def _refresh_session_if_minute_changed(session: AgendaSession) -> bool: +def refresh_session_if_minute_changed(session: AgendaSession) -> bool: """Refresh agenda rows when local wall-clock minute changes.""" current_now = local_now().replace(second=0, microsecond=0) session_now = session.now.replace(second=0, microsecond=0) @@ -194,7 +174,7 @@ def _paths_refer_to_same_file(source_path: str, destination_path: str) -> bool: return source_resolved == destination_resolved -def _move_selection(session: AgendaSession, step: int) -> None: +def move_selection(session: AgendaSession, step: int) -> None: """Move highlighted row selection forward/backward with wraparound.""" if not session.row_locations: return @@ -228,7 +208,8 @@ def _reload_session_nodes(session: AgendaSession) -> None: session.nodes = filter_nodes_by_search(nodes, session.search_text) -def _edit_selected_task_in_external_editor(session: AgendaSession) -> None: +def edit_selected_task_in_external_editor(session: AgendaSession) -> None: + """Open the selected agenda task in the external editor workflow.""" row = selected_task_row(session) if row is None or row.node is None: session.status_message = "Action available only on task rows" @@ -248,7 +229,8 @@ def _edit_selected_task_in_external_editor(session: AgendaSession) -> None: session.status_message = "Task updated" -def _archive_selected_task(session: AgendaSession) -> None: +def archive_selected_task(session: AgendaSession) -> None: + """Archive the currently selected agenda task subtree.""" row = selected_task_row(session) if row is None or row.node is None: session.status_message = "Action available only on task rows" @@ -319,7 +301,7 @@ def shift_planning_time_for_row( field_name = "deadline" if timestamp is None: return None, "Time shifting is available only for timed scheduled/deadline rows" - if not _has_specific_time(timestamp): + if not has_specific_time(timestamp): return None, "Selected planning timestamp has no specific hour" before = str(timestamp) _shift_timestamp_by_hours(timestamp, hour_delta) @@ -392,7 +374,8 @@ def apply_shift_date(session: AgendaSession, *, day_delta: int) -> None: session.status_message = status -def _apply_shift_time(session: AgendaSession, *, hour_delta: int) -> None: +def apply_shift_time(session: AgendaSession, *, hour_delta: int) -> None: + """Shift the selected timed planning row by the requested hour delta.""" row = selected_task_row(session) if row is None or row.node is None: session.status_message = "Action available only on task rows" @@ -496,7 +479,8 @@ def apply_clock_entry_with_value(session: AgendaSession, duration_input: str) -> session.status_message = f"Added clock entry ({duration_text})" -def _clear_search(session: AgendaSession) -> None: +def clear_search(session: AgendaSession) -> None: + """Clear the active agenda search filter while preserving selection when possible.""" if not session.search_text: session.status_message = "Search already clear" return @@ -511,7 +495,7 @@ def _clear_search(session: AgendaSession) -> None: def selected_row(session: AgendaSession) -> AgendaRow | None: """Return the currently selected agenda row.""" - location = _selected_row_location(session) + location = selected_row_location(session) if location is None: return None day_index, row_index = location @@ -542,7 +526,8 @@ def _timetable_schedule_for_selected_row(session: AgendaSession) -> Timestamp | return Timestamp.from_source(f"<{row.day:%Y-%m-%d} {day_name} {hour:02d}:{minute:02d}>") -def _apply_capture_task(session: AgendaSession, template_name: str) -> None: +def apply_capture_task(session: AgendaSession, template_name: str) -> None: + """Capture a task from one template and schedule it for the selected timetable row.""" scheduled = _timetable_schedule_for_selected_row(session) if scheduled is None: session.status_message = "Capture is available only on timetable time rows" @@ -574,7 +559,8 @@ def _apply_capture_task(session: AgendaSession, template_name: str) -> None: session.status_message = f"Task captured and scheduled for {scheduled}" -def _state_choices_for_selected_row(session: AgendaSession) -> list[str]: +def state_choices_for_selected_row(session: AgendaSession) -> list[str]: + """Return valid TODO state choices for the selected task row.""" row = selected_task_row(session) if row is None or row.node is None: return [] @@ -585,12 +571,18 @@ def _can_activate_agenda_state_prompt(session: AgendaSession) -> str | None: row = selected_task_row(session) if row is None or row.node is None: return "Action available only on task rows" - if not _state_choices_for_selected_row(session): + if not state_choices_for_selected_row(session): return "No TODO states defined" return None -def _can_activate_agenda_capture_prompt(session: AgendaSession) -> str | None: +def can_activate_agenda_state_prompt(session: AgendaSession) -> str | None: + """Return status text when the state prompt cannot be opened.""" + return _can_activate_agenda_state_prompt(session) + + +def can_activate_agenda_capture_prompt(session: AgendaSession) -> str | None: + """Return status text when the capture prompt cannot be opened.""" if _timetable_schedule_for_selected_row(session) is None: return "Capture is available only on timetable time rows" if not configured_capture_template_names(): @@ -598,7 +590,8 @@ def _can_activate_agenda_capture_prompt(session: AgendaSession) -> str | None: return None -def _apply_search_text(session: AgendaSession, search_text: str) -> None: +def apply_search_text(session: AgendaSession, search_text: str) -> None: + """Apply agenda search text and refresh the visible day models.""" selected_row = selected_task_row(session) preserve_identity = None if selected_row is not None and selected_row.node is not None: @@ -622,7 +615,7 @@ def create_agenda_session( all_nodes=list(nodes), nodes=nodes, render=render, - start_date=_resolve_agenda_start_date(args.date), + start_date=resolve_agenda_start_date(args.date), days=args.days, now=local_now(), day_models=[], @@ -634,242 +627,17 @@ def create_agenda_session( status_message="", search_text="", view_ctx=view_ctx, - show_help_modal=False, - active_prompt=None, ) refresh_session(session, None) return session -def _set_start_date_relative(session: AgendaSession, *, day_delta: int) -> None: +def set_start_date_relative(session: AgendaSession, *, day_delta: int) -> None: + """Move the agenda start date relative to the current visible span.""" session.start_date += timedelta(days=day_delta) refresh_session(session, None) -def _activate_prompt( - session: AgendaSession, - config: PromptActionConfig, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> None: - session.active_prompt = create_interactive_prompt_state( - config, - submit_value=submit_value, - preview_value=preview_value, - cancel=cancel, - ) - - -def _activate_capture_prompt(session: AgendaSession) -> None: - status_message = _can_activate_agenda_capture_prompt(session) - if status_message is not None: - session.status_message = status_message - return - template_names = configured_capture_template_names() - config = capture_template_prompt_config() - - def _submit(value: str) -> bool: - value = value.strip() - template_name = resolve_capture_template_selection(value, template_names) - if template_name is None and not value: - session.status_message = config.cancel_status - return False - if template_name is None: - session.status_message = config.invalid_status - return True - _run_external(session, lambda: _apply_capture_task(session, template_name)) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_search_prompt(session: AgendaSession) -> None: - session.search_prompt_previous_text = session.search_text - config = PromptActionConfig( - prompt=FooterPromptState(label="Search text (blank clears)"), - cancel_status="Search cancelled", - invalid_status="Invalid search input", - ) - - def _submit(value: str) -> bool: - session.search_prompt_previous_text = None - _apply_search_text(session, value.strip()) - return False - - def _preview(value: str) -> None: - _apply_search_text(session, value.strip()) - - def _cancel() -> None: - previous_text = session.search_prompt_previous_text or "" - session.search_prompt_previous_text = None - _apply_search_text(session, previous_text) - session.status_message = config.cancel_status - - _activate_prompt(session, config, submit_value=_submit, preview_value=_preview, cancel=_cancel) - - -def _activate_state_selection_prompt(session: AgendaSession, states: list[str]) -> None: - config = state_selection_prompt_config(states) - - def _submit(value: str) -> bool: - value = value.strip() - selected_state = resolve_todo_state_selection(value, states) - if selected_state is None and not value: - session.status_message = config.cancel_status - return False - if selected_state is None: - session.status_message = config.invalid_status - return True - apply_state_change_with_value(session, selected_state) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_value_prompt( - session: AgendaSession, - config: PromptActionConfig, - apply_value: Callable[[str], None], -) -> None: - def _submit(value: str) -> bool: - value = value.strip() - if not value: - session.status_message = config.cancel_status - return False - apply_value(value) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _handle_capture_prompt_activation(session: AgendaSession) -> None: - _activate_capture_prompt(session) - - -def _handle_search_prompt_activation(session: AgendaSession) -> None: - _activate_search_prompt(session) - - -def _handle_state_prompt_activation(session: AgendaSession) -> None: - status_message = _can_activate_agenda_state_prompt(session) - if status_message is not None: - session.status_message = status_message - return - _activate_state_selection_prompt(session, _state_choices_for_selected_row(session)) - - -def _handle_refile_prompt_activation(session: AgendaSession) -> None: - if selected_task_row(session) is None: - session.status_message = "Action available only on task rows" - return - _activate_value_prompt( - session, - refile_prompt_config(resolve_input_paths(session.args.files)), - lambda value: apply_refile_with_value(session, value), - ) - - -def _handle_clock_prompt_activation(session: AgendaSession) -> None: - if selected_task_row(session) is None: - session.status_message = "Action available only on task rows" - return - _activate_value_prompt( - session, - clock_duration_prompt_config(), - lambda value: apply_clock_entry_with_value(session, value), - ) - - -def _handle_help_modal_event(session: AgendaSession, event: InteractiveEvent) -> bool: - if not session.show_help_modal: - return False - if not isinstance(event, TimeoutEvent): - session.show_help_modal = False - return True - - -def _handle_navigation_key(session: AgendaSession, key: str) -> bool: - handler = { - "n": lambda: _move_selection(session, 1), - "DOWN": lambda: _move_selection(session, 1), - "WHEEL-DOWN": lambda: _move_selection(session, 1), - "p": lambda: _move_selection(session, -1), - "UP": lambda: _move_selection(session, -1), - "WHEEL-UP": lambda: _move_selection(session, -1), - "f": lambda: _set_start_date_relative(session, day_delta=session.days), - "RIGHT": lambda: _set_start_date_relative(session, day_delta=session.days), - "b": lambda: _set_start_date_relative(session, day_delta=-session.days), - "LEFT": lambda: _set_start_date_relative(session, day_delta=-session.days), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_mutation_key( - session: AgendaSession, - key: str, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "ENTER": lambda: run_external(lambda: _edit_selected_task_in_external_editor(session)), - "$": lambda: _archive_selected_task(session), - "x": lambda: _clear_search(session), - "S-LEFT": lambda: apply_shift_date(session, day_delta=-1), - "S-RIGHT": lambda: apply_shift_date(session, day_delta=1), - "S-UP": lambda: _apply_shift_time(session, hour_delta=-1), - "S-DOWN": lambda: _apply_shift_time(session, hour_delta=1), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_prompt_activation_key( - session: AgendaSession, - key: str, - _run_external_callback: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "a": _handle_capture_prompt_activation, - "/": _handle_search_prompt_activation, - "t": _handle_state_prompt_activation, - "r": _handle_refile_prompt_activation, - "c": _handle_clock_prompt_activation, - }.get(key) - if handler is None: - return False - handler(session) - return True - - -def _handle_keypress_event( - session: AgendaSession, - key: str, - run_external: Callable[[Callable[[], None]], None] | None = None, -) -> bool: - effective_run_external = passthrough_run_external if run_external is None else run_external - consumed, next_help_modal = apply_help_modal_key(key, show_help_modal=session.show_help_modal) - session.show_help_modal = next_help_modal - if consumed: - return True - if key in {"q", "ESC"}: - return False - if _handle_navigation_key(session, key): - return True - if _handle_mutation_key(session, key, effective_run_external): - return True - if _handle_prompt_activation_key(session, key, effective_run_external): - return True - if key and key != "IGNORE": - session.status_message = f"Unsupported key: {key}" - return True - - def passthrough_run_external(callback: Callable[[], None]) -> None: """Run an external callback immediately.""" callback() @@ -878,39 +646,3 @@ def passthrough_run_external(callback: Callable[[], None]) -> None: def _run_external(session: AgendaSession, callback: Callable[[], None]) -> None: runner = session.run_external or passthrough_run_external runner(callback) - - -def handle_interactive_event( - session: AgendaSession, - event: InteractiveEvent, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - """Handle one agenda interactive event.""" - if _handle_help_modal_event(session, event): - return True - if session.active_prompt is not None: - return handle_active_prompt_event(session, event) - if isinstance(event, TimeoutEvent): - _refresh_session_if_minute_changed(session) - return True - if isinstance(event, InputEvent): - return True - return _handle_keypress_event(session, event.key, run_external) - - -def run_agenda_interactive(console: Console, session: AgendaSession) -> None: - """Run the interactive agenda loop.""" - run_external: list[Callable[[Callable[[], None]], None]] = [passthrough_run_external] - - def _bind_run_external(callback: Callable[[Callable[[], None]], None]) -> None: - run_external[0] = callback - session.run_external = callback - - session.run_external = run_external[0] - interactive_loop( - console=console, - render=lambda: interactive_agenda_renderable(console, session), - on_event=lambda event: handle_interactive_event(session, event, run_external[0]), - bind_run_external=_bind_run_external, - timeout_seconds=_INTERACTIVE_INPUT_TIMEOUT_SECONDS, - ) diff --git a/src/org/commands/agenda/layout.py b/src/org/commands/agenda/layout.py index 5b4c2db..70bcca2 100644 --- a/src/org/commands/agenda/layout.py +++ b/src/org/commands/agenda/layout.py @@ -9,17 +9,12 @@ import org_parser.time as org_time import typer from rich import box -from rich.console import Console, Group -from rich.rule import Rule from rich.table import Table from rich.text import Text from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, InteractiveHelpEntry, - build_footer_prompt_text, local_now, - render_interactive_help_modal, shift_datetimes_by_unit, ) from org.query_language import EvalContext, QueryRuntimeError, Stream @@ -34,6 +29,7 @@ if TYPE_CHECKING: from org_parser.document import Heading + from rich.console import Console from .command import AgendaArgs from .events import AgendaSession @@ -187,7 +183,7 @@ class ViewportRow: location: tuple[int, int] | None -def _has_specific_time(timestamp: Timestamp) -> bool: +def has_specific_time(timestamp: Timestamp) -> bool: """Return whether an org timestamp includes an explicit start time.""" return timestamp.start_hour is not None @@ -197,7 +193,7 @@ def _is_active_planning_timestamp(timestamp: Timestamp | None) -> bool: return bool(timestamp is not None and timestamp.is_active) -def _resolve_agenda_start_date(raw_date: str | None) -> date: +def resolve_agenda_start_date(raw_date: str | None) -> date: """Resolve agenda start date from --date or today's date.""" if raw_date is None: return local_now().date() @@ -242,7 +238,7 @@ def _plain_section_row_details(node: Heading, day: date) -> tuple[str, str]: scheduled_source = "scheduled_untimed" if scheduled_day < day: scheduled_source = "overdue_scheduled" - elif _has_specific_time(node.scheduled): + elif has_specific_time(node.scheduled): scheduled_source = "scheduled" return _relative_day_text(scheduled_day, day), scheduled_source return "", "scheduled" @@ -253,12 +249,12 @@ def _has_selected_untimed_planning(node: Heading) -> bool: has_untimed_deadline = bool( node.deadline is not None and _is_active_planning_timestamp(node.deadline) - and not _has_specific_time(node.deadline), + and not has_specific_time(node.deadline), ) has_untimed_scheduled = bool( node.scheduled is not None and _is_active_planning_timestamp(node.scheduled) - and not _has_specific_time(node.scheduled), + and not has_specific_time(node.scheduled), ) return has_untimed_deadline or has_untimed_scheduled @@ -421,7 +417,7 @@ def _collect_repeat_timed_entries( for repeat in repeats: if repeat.timestamp.start.date() != day: continue - if _has_specific_time(repeat.timestamp): + if has_specific_time(repeat.timestamp): timed.append( _TimedEntry( node=node, @@ -449,7 +445,7 @@ def _collect_scheduled_entries( ): return timed, untimed - if _has_specific_time(node.scheduled): + if has_specific_time(node.scheduled): timed.append(_TimedEntry(node=node, when=node.scheduled.start, kind="scheduled")) else: untimed.append(node) @@ -471,7 +467,7 @@ def _collect_deadline_entries( if not _planning_matches_day(node.deadline, day, future_repeats=future_repeats): return timed, untimed - if _has_specific_time(node.deadline): + if has_specific_time(node.deadline): timed.append(_TimedEntry(node=node, when=node.deadline.start, kind="deadline")) else: untimed.append(node) @@ -487,7 +483,135 @@ def _merge_row_style(base_style: str, *, highlighted: bool) -> str: return _HIGHLIGHT_ROW_STYLE -def _selected_row_location(session: AgendaSession) -> tuple[int, int] | None: +def _cell_padding(cell: Text, width: int, *, justify: str = "left") -> Text: + """Return one padded text cell clipped to the requested display width.""" + padded = cell.copy() + padded.no_wrap = True + padded.overflow = "ellipsis" + padded.truncate(max(width, 0), overflow="ellipsis") + remaining = max(width - padded.cell_len, 0) + if remaining == 0: + return padded + spacing = Text(" " * remaining) + if justify == "right": + spacing.append_text(padded) + return spacing + padded.append(" " * remaining) + return padded + + +def viewport_task_width(widths: AgendaColumnWidths, line_width: int) -> int: + """Return the available task-column width for one interactive viewport row.""" + reserved_width = widths.category + widths.time + widths.tags + 3 + return max(10, line_width - reserved_width) + + +def _render_viewport_task_text( + agenda_row: AgendaRow, + render: RenderContext, + widths: AgendaColumnWidths, + *, + line: Text, + line_width: int, +) -> None: + """Render one interactive task row into the provided line.""" + node = agenda_row.node + if node is None: + return + task_width = viewport_task_width(widths, line_width) + line.append_text(_cell_padding(_category_text(node), widths.category)) + line.append(" ") + line.append_text(_cell_padding(Text(agenda_row.time_text), widths.time)) + line.append(" ") + line.append_text( + _cell_padding( + _heading_text( + node, + render=render, + prefix=agenda_row.prefix, + state_override=agenda_row.state_override, + ), + task_width, + ), + ) + line.append(" ") + line.append_text( + _cell_padding( + _tags_text(node, render.color_enabled), + widths.tags, + justify="right", + ), + ) + + +def _render_viewport_non_task_text( + agenda_row: AgendaRow, + render: RenderContext, + widths: AgendaColumnWidths, + *, + line: Text, +) -> None: + """Render one non-task interactive row into the provided line.""" + if agenda_row.kind == "day_header": + line.append(agenda_row.section_label, style="bold" if render.color_enabled else "") + return + if agenda_row.kind == "section": + if render.color_enabled and "[" in agenda_row.section_label: + line.append_text(Text.from_markup(agenda_row.section_label)) + else: + line.append(agenda_row.section_label, style="bold" if render.color_enabled else "") + return + if agenda_row.kind == "hour_marker": + line.append(" " * (widths.category + 1)) + line.append_text(_cell_padding(Text(agenda_row.time_text), widths.time)) + line.append(" ") + line.append("---------------", style="dim") + return + if agenda_row.kind == "now_marker": + line.append(" " * (widths.category + 1)) + line.append_text(_cell_padding(Text(agenda_row.time_text), widths.time)) + line.append(" ") + line.append( + "------ NOW ------", + style="bold yellow" if render.color_enabled else "", + ) + + +def render_viewport_row_text( + row: ViewportRow, + render: RenderContext, + widths: AgendaColumnWidths, + *, + line_width: int, + highlighted: bool, +) -> Text: + """Render one interactive viewport row as a single-line Textual-friendly text object.""" + if row.kind == "spacer": + return Text("") + + agenda_row = row.agenda_row + if agenda_row is None: + return Text("") + + line = Text(no_wrap=True, overflow="ellipsis", style=agenda_row.style or "") + + if agenda_row.kind == "task": + _render_viewport_task_text( + agenda_row, + render, + widths, + line=line, + line_width=line_width, + ) + else: + _render_viewport_non_task_text(agenda_row, render, widths, line=line) + + if highlighted: + line.stylize(_HIGHLIGHT_ROW_STYLE) + return line + + +def selected_row_location(session: AgendaSession) -> tuple[int, int] | None: """Return selected day/row indexes or None when no rows exist.""" if not session.row_locations: return None @@ -794,7 +918,7 @@ def _render_section_row( return 1 -def _interactive_artifacts( +def build_interactive_artifacts( day_models: list[DayRowModel], render: RenderContext, ) -> tuple[list[tuple[int, int]], list[ViewportRow], AgendaColumnWidths]: @@ -921,79 +1045,6 @@ def _render_viewport_row( ) -def interactive_agenda_renderable(console: Console, session: AgendaSession) -> Group: - """Build scrollable interactive agenda renderable with fixed footer controls.""" - if session.show_help_modal: - return Group( - render_interactive_help_modal( - AGENDA_HELP_ENTRIES, - color_enabled=session.render.color_enabled, - ), - ) - rows = session.interactive_rows - widths = session.column_widths - viewport_height = max(3, console.size.height - 5) - selected_row = _selected_viewport_row_index(rows, _selected_row_location(session)) - max_offset = max(0, len(rows) - viewport_height) - session.scroll_offset = min(max(session.scroll_offset, 0), max_offset) - session.scroll_offset = _adjust_scroll_offset( - session.scroll_offset, - selected_row, - viewport_height, - max_offset, - ) - window = rows[session.scroll_offset : session.scroll_offset + viewport_height] - selected_location = _selected_row_location(session) - viewport_table = _build_interactive_viewport_table_with_widths(widths) - for row in window: - _render_viewport_row(viewport_table, row, session, selected_location) - for _ in range(viewport_height - len(window)): - viewport_table.add_row(Text(""), Text(""), Text(""), Text("")) - sticky_day = _resolve_sticky_day(session, rows) - end_line = min(session.scroll_offset + viewport_height, len(rows)) - total_lines = max(len(rows), 1) - search_text = session.search_text or "-" - scroll_text = f"Lines {end_line}/{total_lines} | Search: {search_text}" - prompt_line = None - if session.active_prompt is not None: - prompt_line = build_footer_prompt_text(session.active_prompt.prompt) - status = session.status_message or "" - footer_style = "dim" if session.render.color_enabled else "" - footer_line = Table.grid(expand=True) - footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - footer_line.add_row( - Text(scroll_text, style=footer_style, no_wrap=True, overflow="ellipsis"), - Text(INTERACTIVE_HELP_FOOTER_HINT, style=footer_style, no_wrap=True, overflow="ellipsis"), - ) - status_text = Text(status, style=footer_style, no_wrap=True, overflow="ellipsis") - sticky_header = Text( - _day_header_label(sticky_day), - style="bold" if session.render.color_enabled else "", - no_wrap=True, - overflow="ellipsis", - ) - header_rule = Rule(style=footer_style) - if prompt_line is None: - return Group( - sticky_header, - header_rule, - viewport_table, - Rule(style=footer_style), - footer_line, - status_text, - ) - return Group( - sticky_header, - header_rule, - viewport_table, - Rule(style=footer_style), - footer_line, - prompt_line, - status_text, - ) - - def _adjust_scroll_offset( scroll_offset: int, selected_row: int | None, @@ -1013,7 +1064,7 @@ def _adjust_scroll_offset( def _resolve_sticky_day(session: AgendaSession, rows: list[ViewportRow]) -> date: """Resolve the sticky interactive day header from selection or visible rows.""" - selected_location = _selected_row_location(session) + selected_location = selected_row_location(session) if selected_location is not None: return session.day_models[selected_location[0]].day if rows: @@ -1021,13 +1072,31 @@ def _resolve_sticky_day(session: AgendaSession, rows: list[ViewportRow]) -> date return session.start_date +def sticky_day(session: AgendaSession) -> date: + """Return the current sticky day for the interactive agenda viewport.""" + return _resolve_sticky_day(session, session.interactive_rows) + + +def sync_scroll_offset(session: AgendaSession, viewport_height: int) -> None: + """Clamp and adjust scroll offset to keep the current selection visible.""" + rows = session.interactive_rows + selected_row = _selected_viewport_row_index(rows, selected_row_location(session)) + max_offset = max(0, len(rows) - viewport_height) + session.scroll_offset = _adjust_scroll_offset( + session.scroll_offset, + selected_row, + viewport_height, + max_offset, + ) + + def render_agenda( console: Console, render_input: AgendaRenderInput, view_ctx: AgendaViewContext, ) -> None: """Render agenda output table using view section specs.""" - start_date = _resolve_agenda_start_date(render_input.args.date) + start_date = resolve_agenda_start_date(render_input.args.date) rendered_tables: list[Table] = [] total_rows = 0 day_models: list[DayRowModel] = [] diff --git a/src/org/commands/agenda/styles/app.tcss b/src/org/commands/agenda/styles/app.tcss new file mode 100644 index 0000000..ada5825 --- /dev/null +++ b/src/org/commands/agenda/styles/app.tcss @@ -0,0 +1,18 @@ +Screen { + layout: vertical; +} + +#agenda-header, +#agenda-footer, +#agenda-status { + height: auto; +} + +#agenda-body { + height: 1fr; + overflow: hidden; +} + +.agenda-row { + height: 1; +} diff --git a/src/org/commands/runtime.py b/src/org/commands/runtime.py index 3f51c8c..dba3c74 100644 --- a/src/org/commands/runtime.py +++ b/src/org/commands/runtime.py @@ -2,12 +2,11 @@ from __future__ import annotations -from contextlib import nullcontext from typing import TYPE_CHECKING, cast from rich.text import Text from textual import events as textual_events -from textual.app import App, ComposeResult +from textual.app import App, ComposeResult, SuspendNotSupported from textual.containers import Container from textual.screen import ModalScreen from textual.widgets import Input, Static @@ -124,10 +123,11 @@ class CommandApp(App[None]): def suspend_for_external(self, callback: Callable[[], None]) -> None: """Suspend the app when possible around one blocking callback.""" suspend = getattr(self, "suspend", None) - context_manager: AbstractContextManager[object] if callable(suspend): - context_manager = cast("AbstractContextManager[object]", suspend()) + try: + with cast("AbstractContextManager[object]", suspend()): + callback() + except SuspendNotSupported: + callback() else: - context_manager = nullcontext() - with context_manager: callback() diff --git a/tests/color/test_color.py b/tests/color/test_color.py index 8bfc68d..4aba40f 100644 --- a/tests/color/test_color.py +++ b/tests/color/test_color.py @@ -47,15 +47,20 @@ def test_get_state_color_done_and_cancelled() -> None: def test_get_state_color_todo_and_unknown() -> None: - """TODO/empty states should be dim white, unknown should be yellow.""" + """TODO and empty states should be bold gray, and unknown states should be yellow.""" done_states = ["DONE"] todo_states = ["TODO"] - assert color.get_state_color("TODO", done_states, todo_states, True) == "dim white" - assert color.get_state_color("", done_states, todo_states, True) == "dim white" + assert color.get_state_color("TODO", done_states, todo_states, True) == "bold bright_black" + assert color.get_state_color("", done_states, todo_states, True) == "bold bright_black" assert color.get_state_color("BLOCKED", done_states, todo_states, True) == "bold yellow" +def test_get_state_color_suspended_is_yellow() -> None: + """SUSPENDED should use the shared yellow state style.""" + assert color.get_state_color("SUSPENDED", ["DONE"], ["TODO"], True) == "bold yellow" + + def test_get_state_color_disabled_returns_empty() -> None: """When colors are disabled, no color should be returned.""" assert color.get_state_color("DONE", ["DONE"], ["TODO"], False) == "" diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py new file mode 100644 index 0000000..f09c407 --- /dev/null +++ b/tests/commands/test_agenda_app.py @@ -0,0 +1,275 @@ +"""App tests for the agenda command.""" + +from __future__ import annotations + +import asyncio +import os +import sys +from datetime import datetime +from typing import TYPE_CHECKING, Any, cast + +import org_parser +from textual.widgets import Input, Static + +from org import config as config_module +from org.commands.agenda import command as agenda_command +from org.commands.agenda import events as agenda_events +from org.commands.agenda import layout as agenda_layout +from org.commands.agenda.app import AgendaApp, AgendaViewport +from org.commands.agenda.views import ( + AgendaViewContext, + _compile_view_section_specs, + _fallback_agenda_view, +) +from org.commands.tasks import capture as capture_command +from tests.commands.test_agenda_command import _make_args + + +if TYPE_CHECKING: + import pytest + from org_parser.document import Heading + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") + + +def _make_app( + args: agenda_command.AgendaArgs, + nodes: list[Heading], + *, + color_enabled: bool = False, +) -> AgendaApp: + view = _fallback_agenda_view() + view_ctx = AgendaViewContext(section_specs=_compile_view_section_specs(view), name=view.name) + render = agenda_layout.RenderContext( + color_enabled=color_enabled, + done_states=["DONE"], + todo_states=["TODO"], + ) + return AgendaApp(agenda_events.create_agenda_session(args, nodes, render, view_ctx)) + + +def _body_lines(app: AgendaApp) -> list[str]: + lines: list[str] = [] + for widget in app._body_widget().row_widgets(): + rendered = cast("Any", widget.render()) + lines.append(getattr(rendered, "plain", str(rendered))) + return lines + + +def test_agenda_app_moves_selection_with_arrow_keys() -> None: + """Arrow-key navigation should update the selected agenda row.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test() as pilot: + await pilot.press("down") + assert app.session.selected_row_index == 1 + + await pilot.press("up") + assert app.session.selected_row_index == 0 + + asyncio.run(_run()) + + +def test_agenda_app_search_prompt_filters_results_live_and_escape_restores() -> None: + """Search modal should live-filter rows and escape should restore the previous search.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test() as pilot: + app.action_prompt_search() + await pilot.pause() + + prompt_input = app.screen.query_one(Input) + prompt_input.value = "focus" + prompt_screen = cast("Any", app.screen_stack[-1]) + prompt_screen.on_input_changed(Input.Changed(prompt_input, "focus")) + + assert app.session.search_text == "focus" + visible_titles = [ + row.node.title_text.strip() + for day_model in app.session.day_models + for row in day_model.rows + if row.kind == "task" and row.node is not None + ] + assert visible_titles == ["Timed agenda task"] + + await pilot.press("escape") + await pilot.pause() + + assert app.is_running + assert app.session.search_text == "" + assert app.session.status_message == "Search cancelled" + + asyncio.run(_run()) + + +def test_agenda_app_help_modal_forwards_key_to_app() -> None: + """Help modal should close and forward the pressed key to the agenda app.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test() as pilot: + app.action_show_help() + await pilot.pause() + + assert app.screen.query_one("#help-content", Static) is not None + await pilot.press("down") + await pilot.pause() + + assert app.session.selected_row_index == 1 + assert app.screen.query_one("#agenda-body", AgendaViewport) is not None + + asyncio.run(_run()) + + +def test_agenda_app_pages_date_window_with_right_key() -> None: + """Right-arrow paging should move the agenda start date by the current span.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15", days=2) + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test() as pilot: + await pilot.press("right") + await pilot.pause() + + header = cast("Any", app.query_one("#agenda-header", Static).render()) + assert getattr(header, "plain", str(header)) == "Friday 2025-01-17" + + asyncio.run(_run()) + + +def test_agenda_app_timer_refresh_updates_now_marker(monkeypatch: pytest.MonkeyPatch) -> None: + """Minute refresh should update the rendered NOW marker in the viewport.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + clock = {"value": datetime(2025, 1, 15, 10, 0)} + monkeypatch.setattr(agenda_command, "local_now", lambda: clock["value"]) + monkeypatch.setattr(agenda_events, "local_now", lambda: clock["value"]) + monkeypatch.setattr(agenda_layout, "local_now", lambda: clock["value"]) + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test(): + assert any("10:00" in line and "NOW" in line for line in _body_lines(app)) + clock["value"] = datetime(2025, 1, 15, 10, 1) + app._refresh_for_clock_tick() + assert any("10:01" in line and "NOW" in line for line in _body_lines(app)) + + asyncio.run(_run()) + + +def test_agenda_app_enter_on_non_task_row_reports_status() -> None: + """Enter on a non-task row should keep the app running and set a status message.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + app.session.selected_row_index = next( + index + for index, (day_index, row_index) in enumerate(app.session.row_locations) + if app.session.day_models[day_index].rows[row_index].kind == "hour_marker" + ) + + async with app.run_test(): + app.action_edit_selected() + assert app.session.status_message == "Action available only on task rows" + assert app.is_running + + asyncio.run(_run()) + + +def test_agenda_app_capture_from_timed_row_schedules_task( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Capture on a timed agenda row should schedule the created task for that row time.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) + monkeypatch.setattr( + config_module, + "CONFIG_CAPTURE_TEMPLATES", + {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, + ) + + def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: + return capture_command.TasksCaptureResult( + template_name="quick", + heading=captured_node, + document=captured_node.document, + ) + + monkeypatch.setattr(agenda_events, "capture_task", _fake_capture) + monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) + + app = _make_app(args, list(org_parser.load(fixture_path))) + monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) + app.session.selected_row_index = next( + index + for index, (day_index, row_index) in enumerate(app.session.row_locations) + if ( + app.session.day_models[day_index].rows[row_index].kind == "task" + and ":" in app.session.day_models[day_index].rows[row_index].time_text + ) + ) + selected_row = agenda_events.selected_row(app.session) + assert selected_row is not None + expected_timestamp = f"<2025-01-15 Wed {selected_row.time_text}>" + + async with app.run_test() as pilot: + app.action_prompt_capture() + await pilot.pause() + app.screen_stack[-1].dismiss("1") + await pilot.pause() + + assert str(captured_node.scheduled) == expected_timestamp + assert ( + app.session.status_message + == f"Task captured and scheduled for {expected_timestamp}" + ) + + asyncio.run(_run()) + + +def test_run_agenda_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Interactive agenda path should delegate to the Textual app runner.""" + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + called = {"value": False} + + def _fake_run( + passed_args: agenda_command.AgendaArgs, + nodes: list[Heading], + render: agenda_layout.RenderContext, + view_ctx: AgendaViewContext, + ) -> None: + called["value"] = True + assert passed_args.files == [fixture_path] + assert nodes + assert render.todo_states == ["TODO"] + assert view_ctx.name == "default" + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("org.commands.agenda.command.run_agenda_app", _fake_run) + + agenda_command.run_agenda(args) + + assert called["value"] is True diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index fbf395e..a2e02f4 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -5,18 +5,14 @@ import os import sys from datetime import datetime, timedelta -from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast import org_parser import pytest import typer from org_parser.time import Timestamp -from rich.console import Console from org import config as config_module -from org.commands import archive as archive_command -from org.commands import editor as editor_command from org.commands.agenda import command as agenda_command from org.commands.agenda import events as agenda_events from org.commands.agenda import layout as agenda_layout @@ -26,23 +22,16 @@ _fallback_agenda_view, ) from org.commands.interactive_common import ( - InputEvent, - KeypressEvent, - TimeoutEvent, advance_timestamp_by_repeater, - decode_escape_sequence, detail_org_block, - handle_active_prompt_event, heading_locator, local_now, ) -from org.commands.tasks import capture as capture_command from org.commands.tasks.common import duration_to_org_text, parse_clock_duration if TYPE_CHECKING: from org_parser.document import Document, Heading - from rich.live import Live FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") @@ -788,28 +777,6 @@ def test_run_agenda_invalid_date_raises_bad_parameter(monkeypatch: pytest.Monkey agenda_command.run_agenda(args) -def test_decode_escape_sequence_supports_arrows() -> None: - """Escape-sequence decoder should map plain and shifted arrow keys.""" - assert decode_escape_sequence(b"\x1b") == "ESC" - assert decode_escape_sequence(b"\x1b[A") == "UP" - assert decode_escape_sequence(b"\x1b[B") == "DOWN" - assert decode_escape_sequence(b"\x1b[C") == "RIGHT" - assert decode_escape_sequence(b"\x1b[D") == "LEFT" - assert decode_escape_sequence(b"\x1b[1;2A") == "S-UP" - assert decode_escape_sequence(b"\x1b[1;2B") == "S-DOWN" - assert decode_escape_sequence(b"\x1b[1;2C") == "S-RIGHT" - assert decode_escape_sequence(b"\x1b[1;2D") == "S-LEFT" - assert decode_escape_sequence(b"\x1b[<64;40;10M") == "WHEEL-UP" - assert decode_escape_sequence(b"\x1b[<65;40;11M") == "WHEEL-DOWN" - - -def test_decode_escape_sequence_unknown_escape_is_not_exit() -> None: - """Unknown escape sequences should not decode to ESC quit token.""" - key_name = decode_escape_sequence(b"\x1b[999~") - assert key_name != "ESC" - assert key_name.startswith("UNSUPPORTED-ESC:") - - def test_parse_clock_duration_accepts_multiple_formats() -> None: """Clock duration parser should handle H:MM, minutes, and suffixed values.""" assert duration_to_org_text(parse_clock_duration("1:30")) == "1:30" @@ -904,20 +871,6 @@ def test_interactive_selection_can_land_on_hour_row_and_block_task_actions() -> assert session.status_message == "Action available only on task rows" -def test_handle_interactive_key_mouse_wheel_moves_selection() -> None: - """Mouse wheel tokens should move selection same as down/up keys.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - start = session.selected_row_index - assert agenda_events._handle_keypress_event(session, "WHEEL-DOWN") is True - assert session.selected_row_index == (start + 1) % len(session.row_locations) - - assert agenda_events._handle_keypress_event(session, "WHEEL-UP") is True - assert session.selected_row_index == start - - def test_interactive_row_locations_skip_hidden_first_day_rows() -> None: """Interactive selection should not include the hidden first day header/spacer rows.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") @@ -930,101 +883,6 @@ def test_interactive_row_locations_skip_hidden_first_day_rows() -> None: assert session.row_locations[0] == (0, 2) -def test_timeout_event_refreshes_now_marker_when_minute_changes( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Timeout event should refresh session rows when local minute changes.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - clock = {"value": datetime(2025, 1, 15, 10, 0)} - monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: clock["value"]) - monkeypatch.setattr("org.commands.agenda.events.local_now", lambda: clock["value"]) - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - before = next(row.time_text for row in session.day_models[0].rows if row.kind == "now_marker") - assert before == "10:00" - - clock["value"] = datetime(2025, 1, 15, 10, 1) - assert ( - agenda_events.handle_interactive_event( - session, - TimeoutEvent(), - agenda_events.passthrough_run_external, - ) - is True - ) - - after = next(row.time_text for row in session.day_models[0].rows if row.kind == "now_marker") - assert after == "10:01" - - -def test_handle_interactive_key_unsupported_key_sets_status_and_continues() -> None: - """Unsupported key should set status message without exiting agenda loop.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - unsupported_key = "UNSUPPORTED-ESC:1b5b3939397e" - assert agenda_events._handle_keypress_event(session, unsupported_key) is True - assert session.status_message == f"Unsupported key: {unsupported_key}" - - -def test_handle_interactive_key_slash_activates_search_prompt() -> None: - """Slash should activate agenda search prompt.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - - assert agenda_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - assert session.active_prompt.prompt.label == "Search text (blank clears)" - - -def test_agenda_search_filters_rows_and_clear_restores_rows() -> None: - """Interactive search should filter visible rows and clear should restore all rows.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - all_titles = _visible_agenda_task_titles(session) - - assert "Timed agenda task" in all_titles - assert "Untimed agenda task" in all_titles - - assert agenda_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "focus" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is False - assert session.status_message == "1 matches" - assert _visible_agenda_task_titles(session) == ["Timed agenda task"] - assert session.search_text == "focus" - - assert agenda_events._handle_keypress_event(session, "x") is True - assert session.status_message == "Search cleared" - assert session.search_text == "" - assert _visible_agenda_task_titles(session) == all_titles - - -def test_interactive_renderable_footer_shows_active_search_text() -> None: - """Agenda footer should include currently active interactive search text.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.search_text = "focus" - agenda_events.refresh_visible_nodes(session, None) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=220, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - output = buffer.getvalue() - - assert "Search: focus" in output - - def test_apply_state_change_uses_current_action_time( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1177,491 +1035,6 @@ def _capture_refresh( assert session.status_message == f"Refiled task to {destination_path}" -def test_handle_interactive_key_enter_edits_selected_task( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Enter should trigger external-editor editing on selected task row.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "task" - ) - session.selected_row_index = task_index - - row = agenda_events.selected_task_row(session) - assert row is not None - assert row.node is not None - - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=False) - - monkeypatch.setattr(agenda_events, "edit_heading_subtree_in_external_editor", _fake_edit) - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.status_message == "No changes." - - -def test_handle_interactive_key_enter_reloads_using_selected_node_identity_after_changed_edit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Changed edit should reload using the selected task identity.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "task" - ) - session.selected_row_index = task_index - - row = agenda_events.selected_task_row(session) - assert row is not None - assert row.node is not None - source_node = row.node - - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=True) - - refreshed_identity = None - - def _capture_refresh(_session: agenda_events.AgendaSession, identity: object) -> None: - nonlocal refreshed_identity - refreshed_identity = identity - - monkeypatch.setattr(agenda_events, "edit_heading_subtree_in_external_editor", _fake_edit) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", _capture_refresh) - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.status_message == "Task updated" - assert refreshed_identity == heading_locator(source_node) - - -def test_handle_interactive_key_dollar_archives_selected_task( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """$ should archive highlighted agenda task using shared archive helper.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "task" - ) - session.selected_row_index = task_index - - def _fake_archive( - heading: Heading, - _cache: dict[str, Document], - ) -> archive_command.ArchiveMoveResult: - location = archive_command.ArchiveLocation( - raw_spec="%s_archive::", - file_path="tasks.org_archive", - parent_title=None, - ) - target = archive_command.ArchiveTarget( - location=location, - document=heading.document, - parent_heading=None, - ) - return archive_command.ArchiveMoveResult( - heading=heading, - target=target, - source_document=heading.document, - destination_document=heading.document, - ) - - monkeypatch.setattr(agenda_events, "archive_heading_subtree_and_save", _fake_archive) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) - - assert agenda_events._handle_keypress_event(session, "$") is True - assert session.status_message == "Task archived" - - -def test_handle_interactive_key_a_captures_and_schedules_on_timed_task_row( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a on timed task row should capture and schedule with row-specific time.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - selected_row = agenda_events.selected_row(session) - assert selected_row is not None - expected_time = selected_row.time_text - expected_timestamp = f"<2025-01-15 Wed {expected_time}>" - captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) - saved_documents: list[Document] = [] - reloaded: dict[str, object] = {} - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - monkeypatch.setattr( - agenda_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - - def _capture_save(document: Document) -> None: - saved_documents.append(document) - - monkeypatch.setattr(agenda_events, "_save_document_changes", _capture_save) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - - def _fake_refresh( - current_session: agenda_events.AgendaSession, - preserve_identity: tuple[str, str, str, int | None] | None, - ) -> None: - reloaded["session"] = current_session - reloaded["identity"] = preserve_identity - - monkeypatch.setattr(agenda_events, "refresh_session", _fake_refresh) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert str(captured_node.scheduled) == expected_timestamp - assert saved_documents == [captured_node.document] - assert reloaded["session"] is session - assert reloaded["identity"] == heading_locator(captured_node) - assert session.status_message == f"Task captured and scheduled for {expected_timestamp}" - - -def test_handle_interactive_key_a_uses_now_marker_time( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a on NOW marker row should capture and schedule using NOW row minute.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - local_tz = local_now().tzinfo - monkeypatch.setattr( - agenda_events, - "local_now", - lambda: datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz), - ) - monkeypatch.setattr( - agenda_command, - "local_now", - lambda: datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz), - ) - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - now_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "now_marker" - ) - session.selected_row_index = now_index - captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - monkeypatch.setattr( - agenda_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert str(captured_node.scheduled) == "<2025-01-15 Wed 17:04>" - - -def test_handle_interactive_key_a_on_non_timetable_row_reports_blocked() -> None: - """a on non-timed rows should be blocked with a status message.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - blocked_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "section" - ) - session.selected_row_index = blocked_index - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.status_message == "Capture is available only on timetable time rows" - - -def test_handle_interactive_key_a_blank_template_input_cancels( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should close agenda capture prompt with cancel status on blank input.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is False - assert session.status_message == "Capture cancelled" - - -def test_handle_interactive_key_a_invalid_shortcut_keeps_prompt_open( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should keep agenda capture prompt open for invalid shortcuts.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert agenda_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "42" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is True - assert session.status_message == "Invalid capture template shortcut" - - -def test_prompt_submit_capture_stops_and_restarts_live(monkeypatch: pytest.MonkeyPatch) -> None: - """Agenda prompt submission should pause live rendering for capture action.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - timed_task_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if ( - session.day_models[day_index].rows[row_index].kind == "task" - and session.day_models[day_index].rows[row_index].source - in {"scheduled", "deadline_today", "repeat"} - and ":" in session.day_models[day_index].rows[row_index].time_text - ) - ) - session.selected_row_index = timed_task_index - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - agenda_events._handle_keypress_event(session, "a") - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - - captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) - monkeypatch.setattr( - agenda_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - controller = cast("Any", _InputControllerStub()) - - def _run_prompt_external(callback: object) -> None: - controller.suspend_live(live) - try: - cast("Any", callback)() - finally: - controller.resume_live(live) - - session.run_external = _run_prompt_external - - consumed = handle_active_prompt_event(session, KeypressEvent("ENTER")) - live.update(agenda_layout.interactive_agenda_renderable(live.console, session), refresh=True) - - assert consumed is True - assert events == ["input-stop", "stop", "start", "input-start", "update"] - - -def test_search_prompt_live_updates_and_escape_reverts_search() -> None: - """Agenda search prompt should live-update rows and ESC should restore prior filter.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - assert agenda_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - consumed = handle_active_prompt_event(session, InputEvent("focus")) - live.update(agenda_layout.interactive_agenda_renderable(live.console, session), refresh=True) - - assert consumed is True - assert _visible_agenda_task_titles(session) == ["Timed agenda task"] - assert session.search_text == "focus" - assert session.status_message == "1 matches" - - consumed = handle_active_prompt_event(session, KeypressEvent("ESC")) - live.update(agenda_layout.interactive_agenda_renderable(live.console, session), refresh=True) - - assert consumed is True - assert _visible_agenda_task_titles(session) == [ - "Timed agenda task", - "Repeated completion on day", - "Completed one-off task", - "Untimed agenda task", - "Overdue scheduled task", - "Overdue deadline task", - "Upcoming deadline task", - ] - assert session.search_text == "" - assert session.status_message == "Search cancelled" - assert events == ["update", "update"] - - -def test_handle_interactive_key_enter_on_non_task_row_stays_in_agenda() -> None: - """Enter on non-task rows should keep agenda view and report status.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - hour_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if session.day_models[day_index].rows[row_index].kind == "hour_marker" - ) - session.selected_row_index = hour_index - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.status_message == "Action available only on task rows" - - -def test_handle_interactive_key_escape_quits_interactive_loop() -> None: - """Esc should quit interactive agenda loop.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - assert agenda_events._handle_keypress_event(session, "ESC") is False - - def test_detail_org_block_includes_selected_heading_children() -> None: """Detail task text should include heading subtree including child headings.""" root = org_parser.loads( @@ -1674,121 +1047,6 @@ def test_detail_org_block_includes_selected_heading_children() -> None: assert "** TODO Child task" in output -def test_interactive_renderable_footer_is_two_lines_without_status() -> None: - """Interactive render should reserve exactly two footer lines when status is empty.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.status_message = "" - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=300, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[0].strip() == "Wednesday 2025-01-15" - assert lines[-2].startswith("Lines ") - assert "Type ? for help" in lines[-2] - assert lines[-1] == "" - - -def test_interactive_renderable_footer_is_two_lines_with_status_on_narrow_width() -> None: - """Interactive render should keep footer at two lines even on narrow terminals.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.status_message = "Unsupported key: x" - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=80, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[0].strip() == "Wednesday 2025-01-15" - assert lines[-2].startswith("Lines ") - assert "Type ? for help" in lines[-2] - assert lines[-1] == "Unsupported key: x" - - -def test_interactive_renderable_updates_sticky_day_header_for_selected_day() -> None: - """Interactive render should update the sticky day header when selection moves days.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15", days=2) - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.selected_row_index = next( - index - for index, (day_index, row_index) in enumerate(session.row_locations) - if day_index == 1 and session.day_models[day_index].rows[row_index].kind == "task" - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=160, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert lines[0].strip() == "Thursday 2025-01-16" - - -def test_interactive_renderable_uses_full_dataset_column_widths() -> None: - """Interactive agenda column widths should be based on all rendered day models.""" - root = org_parser.loads( - "#+CATEGORY: short\n" - "* TODO Day one\n" - "SCHEDULED: <2025-01-15 Wed 09:30>\n\n" - "#+CATEGORY: very-long-category\n" - "* TODO Day two\n" - "SCHEDULED: <2025-01-16 Thu>\n" - ":TAGONE:TAGTWO:\n", - ) - args = _make_args(["dummy.org"], date="2025-01-15", days=2) - session = _make_session(args, list(root), color_enabled=False) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - output = buffer.getvalue() - - assert "very-long-category" not in output - assert "short" in output - assert "09:30" in output - - -def test_handle_interactive_key_question_toggles_help_modal() -> None: - """Question mark should open help modal and next key should close it.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - - assert agenda_events._handle_keypress_event(session, "?") is True - assert session.show_help_modal is True - - assert agenda_events._handle_keypress_event(session, "ENTER") is True - assert session.show_help_modal is False - - -def test_interactive_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel for agenda.""" - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - root = org_parser.load(fixture_path) - session = _make_session(args, list(root)) - session.show_help_modal = True - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(agenda_layout.interactive_agenda_renderable(console, session)) - output = buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - def test_shift_planning_time_for_row_shifts_timed_scheduled_by_one_hour() -> None: """Shifting timed scheduled rows by hour should mutate scheduled timestamp time.""" root = org_parser.loads("* TODO X\nSCHEDULED: <2025-01-15 Wed 10:30>\n") From b30b42575f6cd043bc8db33b000ece8bbc40759e Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 7 Jun 2026 17:51:55 +0200 Subject: [PATCH 08/27] Converted the board command to Textual --- src/org/commands/board/app.py | 392 +++++++++++++ src/org/commands/board/command.py | 15 +- src/org/commands/board/events.py | 259 +------- src/org/commands/board/layout.py | 181 ++---- src/org/commands/board/styles/app.tcss | 25 + src/org/commands/interactive_common.py | 681 +--------------------- tests/commands/test_board.py | 588 +------------------ tests/commands/test_board_app.py | 325 +++++++++++ tests/commands/test_interactive_common.py | 180 +----- 9 files changed, 826 insertions(+), 1820 deletions(-) create mode 100644 src/org/commands/board/app.py create mode 100644 src/org/commands/board/styles/app.tcss create mode 100644 tests/commands/test_board_app.py diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py new file mode 100644 index 0000000..75e124b --- /dev/null +++ b/src/org/commands/board/app.py @@ -0,0 +1,392 @@ +"""Textual app for the interactive board command.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from rich.align import Align +from rich.console import Group, RenderableType +from rich.text import Text +from textual.binding import Binding, BindingType +from textual.containers import Horizontal, Vertical +from textual.widgets import Static + +from org.commands import runtime +from org.commands.tasks.common import ( + capture_template_prompt_label, + configured_capture_template_names, + resolve_capture_template_selection, +) + +from . import events, layout +from .events import BoardSession, create_flow_board_session + + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import ClassVar + + from org_parser.document import Heading + from textual.app import ComposeResult + from textual.events import MouseScrollDown, MouseScrollUp, Resize + + from .command import BoardArgs + + +_HELP_FOOTER_TEXT = "Type ? for help" + + +class BoardColumnWidget(Static): + """One interactive board column with title and visible card slice.""" + + def set_column( + self, + title: str, + body: RenderableType, + *, + selected: bool, + ) -> None: + """Update the column title and rendered card group.""" + title_text = layout.render_column_title_text(title) + title_text.no_wrap = True + title_text.overflow = "ellipsis" + if selected: + title_text.stylize("reverse") + self.update(Group(Align.center(title_text), body)) + + +class BoardViewport(Horizontal): + """Horizontal board viewport that owns one widget per visible column.""" + + def ensure_column_pool(self, count: int) -> None: + """Grow or shrink the rendered column pool to match the board state.""" + widgets = self.column_widgets() + while len(widgets) < count: + self.mount(BoardColumnWidget(classes="board-column")) + widgets = self.column_widgets() + while len(widgets) > count: + widgets[-1].remove() + widgets = self.column_widgets() + + def column_widgets(self) -> list[BoardColumnWidget]: + """Return board column widgets in display order.""" + return cast("list[BoardColumnWidget]", list(self.query(BoardColumnWidget))) + + +class BoardApp(runtime.CommandApp): + """Textual app that backs interactive `board`.""" + + CSS_PATH = "styles/app.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", show=False), + Binding("down", "move_down", show=False), + Binding("left", "move_left", show=False), + Binding("right", "move_right", show=False), + Binding("enter", "edit_selected", show=False), + Binding("slash", "prompt_search", show=False), + Binding("x", "clear_search", show=False), + Binding("a", "prompt_capture", show=False), + Binding("dollar_sign", "archive_selected", show=False), + Binding("shift+left", "move_state_left", show=False), + Binding("shift+right", "move_state_right", show=False), + Binding("shift+up", "increase_priority", show=False), + Binding("shift+down", "decrease_priority", show=False), + Binding("question_mark", "show_help", show=False), + Binding("q", "quit_app", show=False), + Binding("escape", "quit_app", show=False), + ] + + def __init__(self, session: BoardSession) -> None: + """Store the prepared board session.""" + super().__init__() + self.session = session + self._visible_end_row = 0 + + def compose(self) -> ComposeResult: + """Build the main board app layout.""" + yield Vertical( + BoardViewport(id="board-body"), + Static(id="board-footer"), + Static(id="board-status"), + ) + + def on_mount(self) -> None: + """Render initial state after mount.""" + self._refresh_view() + + def on_resize(self, _event: Resize) -> None: + """Refresh viewport layout after terminal resize.""" + self._refresh_view() + + def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: + """Match legacy wheel-up navigation behavior.""" + events.move_selection_vertical(self.session, -1) + self._refresh_view() + + def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: + """Match legacy wheel-down navigation behavior.""" + events.move_selection_vertical(self.session, 1) + self._refresh_view() + + def _body_widget(self) -> BoardViewport: + return self.query_one("#board-body", BoardViewport) + + def _footer_widget(self) -> Static: + return self.query_one("#board-footer", Static) + + def _status_widget(self) -> Static: + return self.query_one("#board-status", Static) + + def _body_height(self) -> int: + body_height = self._body_widget().size.height + if body_height <= 0: + return max(3, self.size.height - 2) + return max(3, body_height - 1) + + def _body_width(self) -> int: + body_width = self._body_widget().size.width + if body_width <= 0: + return max(80, self.size.width) + return max(80, body_width) + + def _render_config(self) -> layout.BoardPanelRenderConfig: + return layout.BoardPanelRenderConfig( + width=layout.estimate_panel_content_width( + self._body_width(), + len(self.session.columns), + ), + color_enabled=self.session.color_enabled, + done_states=self.session.done_states, + todo_states=self.session.todo_states, + ) + + def _refresh_columns(self) -> None: + body = self._body_widget() + body.ensure_column_pool(len(self.session.columns)) + render = self._render_config() + body_height = self._body_height() + start_row, end_row = layout.selected_column_window( + self.session, + render, + body_height=body_height, + ) + self._visible_end_row = min(end_row, layout.selected_column_total_rows(self.session)) + + for column_index, (widget, column) in enumerate( + zip(body.column_widgets(), self.session.columns, strict=False), + ): + column_end = layout.column_window_end( + column.nodes, + render, + start_row=start_row, + body_height=body_height, + ) + panels = [ + layout.build_task_panel( + node, + render, + highlighted=( + column_index == self.session.selected_column_index + and row_index == self.session.selected_row_index + ), + ) + for row_index, node in enumerate( + column.nodes[start_row:column_end], + start=start_row, + ) + ] + renderable: RenderableType = Text("") if not panels else Group(*panels) + widget.set_column( + column.title, + renderable, + selected=column_index == self.session.selected_column_index, + ) + + def _refresh_footer(self) -> None: + search_text = self.session.search_text or "-" + total_rows = layout.selected_column_total_rows(self.session) + footer_style = "dim" if self.session.color_enabled else "" + self._footer_widget().update( + Text( + ( + f"Rows {self._visible_end_row}/{total_rows} | Search: {search_text} | " + f"{_HELP_FOOTER_TEXT}" + ), + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_status(self) -> None: + footer_style = "dim" if self.session.color_enabled else "" + status = " ".join((self.session.status_message or "").splitlines()) + self._status_widget().update( + Text( + status, + style=footer_style, + no_wrap=True, + overflow="ellipsis", + ), + ) + + def _refresh_view(self) -> None: + self._refresh_columns() + self._refresh_footer() + self._refresh_status() + + def _set_status(self, status_message: str) -> None: + self.session.status_message = status_message + + def _run_external(self, callback: Callable[[], None]) -> None: + self.suspend_for_external(callback) + self._refresh_view() + + def _open_prompt( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen( + runtime.PromptModalScreen(label, initial_value=initial_value, on_change=on_change), + callback=_complete, + ) + + def action_move_up(self) -> None: + """Move the selection one task upward in the selected column.""" + events.move_selection_vertical(self.session, -1) + self._refresh_view() + + def action_move_down(self) -> None: + """Move the selection one task downward in the selected column.""" + events.move_selection_vertical(self.session, 1) + self._refresh_view() + + def action_move_left(self) -> None: + """Move board focus one non-empty column to the left.""" + events.move_selection_horizontal(self.session, -1) + self._refresh_view() + + def action_move_right(self) -> None: + """Move board focus one non-empty column to the right.""" + events.move_selection_horizontal(self.session, 1) + self._refresh_view() + + def action_move_state_left(self) -> None: + """Step the selected task state backward through document state order.""" + events.apply_state_move(self.session, direction=-1) + self._refresh_view() + + def action_move_state_right(self) -> None: + """Step the selected task state forward through document state order.""" + events.apply_state_move(self.session, direction=1) + self._refresh_view() + + def action_increase_priority(self) -> None: + """Increase the selected task priority.""" + events.apply_priority_shift(self.session, increase=True) + self._refresh_view() + + def action_decrease_priority(self) -> None: + """Decrease the selected task priority.""" + events.apply_priority_shift(self.session, increase=False) + self._refresh_view() + + def action_edit_selected(self) -> None: + """Open the selected task in the external editor.""" + self._run_external(lambda: events.edit_selected_task_in_external_editor(self.session)) + + def action_archive_selected(self) -> None: + """Archive the selected task subtree.""" + events.archive_selected_task(self.session) + self._refresh_view() + + def action_clear_search(self) -> None: + """Clear the active board search filter.""" + events.clear_search(self.session) + self._refresh_view() + + def action_show_help(self) -> None: + """Open the key bindings help modal.""" + self.push_screen( + runtime.HelpModalScreen( + layout.BOARD_HELP_ENTRIES, + color_enabled=self.session.color_enabled, + ), + ) + + def action_prompt_search(self) -> None: + """Open the search prompt with live per-column filtering preview.""" + previous_text = self.session.search_text + + def _preview(value: str) -> None: + events.apply_search_text(self.session, value.strip()) + self._refresh_view() + + self._open_prompt( + "Search text (blank clears)", + initial_value=previous_text, + on_change=_preview, + on_submit=lambda value: events.apply_search_text(self.session, value.strip()), + on_cancel=lambda: self._cancel_search(previous_text), + ) + + def _cancel_search(self, previous_text: str) -> None: + events.apply_search_text(self.session, previous_text) + self.session.status_message = "Search cancelled" + + def action_prompt_capture(self) -> None: + """Prompt for a capture template and create a task.""" + status_message = events.can_activate_capture_prompt(self.session) + if status_message is not None: + self._set_status(status_message) + self._refresh_view() + return + + template_names = configured_capture_template_names() + + def _submit(value: str) -> None: + stripped = value.strip() + if not stripped: + self._set_status("Capture cancelled") + return + template_name = resolve_capture_template_selection(stripped, template_names) + if template_name is None: + self._set_status("Invalid capture template shortcut") + self.action_prompt_capture() + return + self._run_external(lambda: events.apply_capture_task(self.session, template_name)) + + self._open_prompt( + capture_template_prompt_label(template_names), + on_submit=_submit, + on_cancel=lambda: self._set_status("Capture cancelled"), + ) + + def action_quit_app(self) -> None: + """Exit the interactive board app.""" + self.exit() + + +def run_board_app( + args: BoardArgs, + nodes: list[Heading], + todo_states: list[str], + done_states: list[str], + color_enabled: bool, +) -> None: + """Run the Textual-backed interactive board app.""" + session = create_flow_board_session(args, nodes, todo_states, done_states, color_enabled) + BoardApp(session).run() diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index f91540a..5323ff1 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -10,10 +10,12 @@ from org import config as config_module from org.cli_common import load_and_process_data -from org.commands.board import events, layout from org.commands.interactive_common import interactive_help_command_text from org.tui import build_console, processing_status, setup_output +from . import events, layout +from .app import run_board_app + logger = logging.getLogger("org") @@ -95,16 +97,7 @@ def run_flow_board(args: BoardArgs) -> None: return if sys.stdin.isatty() and sys.stdout.isatty(): - events.run_flow_board_interactive( - console, - events.create_flow_board_session( - args, - nodes, - todo_states, - done_states, - color_enabled, - ), - ) + run_board_app(args, nodes, todo_states, done_states, color_enabled) return columns = events.build_selector_board_columns(nodes, events.resolve_column_specs(args)) diff --git a/src/org/commands/board/events.py b/src/org/commands/board/events.py index 088c319..4e2143c 100644 --- a/src/org/commands/board/events.py +++ b/src/org/commands/board/events.py @@ -14,32 +14,17 @@ from org.commands.archive import archive_heading_subtree_and_save from org.commands.editor import edit_heading_subtree_in_external_editor from org.commands.interactive_common import ( - FooterPromptState, HeadingLocator, - InputEvent, - InteractiveEvent, - InteractivePromptState, - TimeoutEvent, advance_timestamp_by_repeater, append_repeat_transition, - apply_help_modal_key, - create_interactive_prompt_state, - handle_active_prompt_event, heading_locator, - interactive_loop, local_now, resolve_heading_locator, shift_priority, ) from org.commands.search_common import filter_nodes_by_search from org.commands.tasks.capture import TasksCaptureArgs, capture_task -from org.commands.tasks.common import ( - PromptActionConfig, - capture_template_prompt_config, - configured_capture_template_names, - resolve_capture_template_selection, - save_document, -) +from org.commands.tasks.common import configured_capture_template_names, save_document from org.query_language import ( CompiledQuery, EvalContext, @@ -49,14 +34,11 @@ compile_query_text, ) -from .layout import _interactive_flow_board_renderable - if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Sequence from org_parser.document import Document, Heading - from rich.console import Console from .command import BoardArgs @@ -97,10 +79,6 @@ class BoardSession: status_message: str all_columns: Sequence[BoardColumn] = field(default_factory=list) search_text: str = "" - search_prompt_previous_text: str | None = None - show_help_modal: bool = False - active_prompt: InteractivePromptState | None = None - run_external: Callable[[Callable[[], None]], None] | None = None def _coerce_latest_timestamp_start(node: Heading) -> datetime | None: @@ -299,7 +277,7 @@ def _ensure_selection_bounds(session: BoardSession) -> None: session.selected_row_index = min(max(session.selected_row_index, 0), len(selected_nodes) - 1) -def _refresh_visible_columns( +def refresh_visible_columns( session: BoardSession, preserve_identity: HeadingLocator | None, ) -> None: @@ -354,7 +332,7 @@ def reload_session( filtered_nodes, resolve_column_specs(session.args), ) - _refresh_visible_columns(session, preserve_identity) + refresh_visible_columns(session, preserve_identity) def create_flow_board_session( @@ -385,9 +363,6 @@ def create_flow_board_session( scroll_offset=0, status_message="", search_text="", - show_help_modal=False, - active_prompt=None, - run_external=None, ) _ensure_selection_bounds(session) return session @@ -429,7 +404,7 @@ def step_heading_state(heading: Heading, *, direction: int) -> tuple[str | None, return new_state, status -def _move_selection_vertical(session: BoardSession, step: int) -> None: +def move_selection_vertical(session: BoardSession, step: int) -> None: """Move highlighted task up/down in the selected column.""" selected_nodes = session.columns[session.selected_column_index].nodes if not selected_nodes: @@ -509,7 +484,7 @@ def apply_state_move(session: BoardSession, *, direction: int) -> None: session.status_message = f"State updated: {old_state or '-'} -> {new_state or '-'}" -def _apply_priority_shift(session: BoardSession, *, increase: bool) -> None: +def apply_priority_shift(session: BoardSession, *, increase: bool) -> None: """Increase or decrease selected task priority.""" heading = selected_node(session) if heading is None: @@ -541,7 +516,7 @@ def _apply_priority_shift(session: BoardSession, *, increase: bool) -> None: session.status_message = f"Priority updated: {old_priority or '-'} -> {new_priority or '-'}" -def _edit_selected_task_in_external_editor(session: BoardSession) -> None: +def edit_selected_task_in_external_editor(session: BoardSession) -> None: """Edit selected task subtree in configured external editor.""" heading = selected_node(session) if heading is None: @@ -568,7 +543,7 @@ def _edit_selected_task_in_external_editor(session: BoardSession) -> None: session.status_message = "Task updated" -def _archive_selected_task(session: BoardSession) -> None: +def archive_selected_task(session: BoardSession) -> None: """Archive selected task subtree using shared archive-location rules.""" heading = selected_node(session) if heading is None: @@ -591,7 +566,7 @@ def _archive_selected_task(session: BoardSession) -> None: session.status_message = "Task archived" -def _apply_capture_task(session: BoardSession, template_name: str) -> None: +def apply_capture_task(session: BoardSession, template_name: str) -> None: """Capture a new task and reload board session.""" session.status_message = "" capture_args = TasksCaptureArgs( @@ -618,7 +593,7 @@ def _apply_capture_task(session: BoardSession, template_name: str) -> None: session.status_message = "Task captured" -def _clear_search(session: BoardSession) -> None: +def clear_search(session: BoardSession) -> None: """Clear active interactive search and restore full board columns.""" if not session.search_text: session.status_message = "Search already clear" @@ -627,220 +602,24 @@ def _clear_search(session: BoardSession) -> None: selected = selected_node(session) preserve_identity = heading_locator(selected) if selected is not None else None session.search_text = "" - _refresh_visible_columns(session, preserve_identity) + refresh_visible_columns(session, preserve_identity) session.status_message = "Search cleared" -def _apply_search_text(session: BoardSession, search_text: str) -> None: +def apply_search_text(session: BoardSession, search_text: str) -> None: """Apply search text to board columns and update match status.""" selected = selected_node(session) preserve_identity = heading_locator(selected) if selected is not None else None session.search_text = search_text - _refresh_visible_columns(session, preserve_identity) + refresh_visible_columns(session, preserve_identity) session.status_message = ( "Search cleared" if not search_text else f"{_visible_task_count(session.columns)} matches" ) -def _activate_prompt( - session: BoardSession, - config: PromptActionConfig, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> None: - """Attach one active footer prompt to the board session.""" - session.active_prompt = create_interactive_prompt_state( - config, - submit_value=submit_value, - preview_value=preview_value, - cancel=cancel, - ) - - -def _activate_capture_prompt(session: BoardSession) -> None: - template_names = configured_capture_template_names() - if not template_names: - session.status_message = "No capture templates configured" - return - config = capture_template_prompt_config() - - def _submit(value: str) -> bool: - value = value.strip() - template_name = resolve_capture_template_selection(value, template_names) - if template_name is None and not value: - session.status_message = config.cancel_status - return False - if template_name is None: - session.status_message = config.invalid_status - return True - _run_external(session, lambda: _apply_capture_task(session, template_name)) - return False - - _activate_prompt(session, config, submit_value=_submit) - - -def _activate_search_prompt(session: BoardSession) -> None: - session.search_prompt_previous_text = session.search_text - config = PromptActionConfig( - prompt=FooterPromptState(label="Search text (blank clears)"), - cancel_status="Search cancelled", - invalid_status="Invalid search input", - ) - - def _submit(value: str) -> bool: - session.search_prompt_previous_text = None - _apply_search_text(session, value.strip()) - return False - - def _preview(value: str) -> None: - _apply_search_text(session, value.strip()) - - def _cancel() -> None: - previous_text = session.search_prompt_previous_text or "" - session.search_prompt_previous_text = None - _apply_search_text(session, previous_text) - session.status_message = config.cancel_status - - _activate_prompt( - session, - config, - submit_value=_submit, - preview_value=_preview, - cancel=_cancel, - ) - - -def _handle_capture_prompt_activation(session: BoardSession) -> None: - _activate_capture_prompt(session) - - -def _handle_search_prompt_activation(session: BoardSession) -> None: - _activate_search_prompt(session) - - -def _handle_help_modal_event(session: BoardSession, event: InteractiveEvent) -> bool: - if not session.show_help_modal: - return False - if not isinstance(event, TimeoutEvent): - session.show_help_modal = False - return True - - -def _handle_navigation_key(session: BoardSession, key: str) -> bool: - handler = { - "DOWN": lambda: _move_selection_vertical(session, 1), - "WHEEL-DOWN": lambda: _move_selection_vertical(session, 1), - "UP": lambda: _move_selection_vertical(session, -1), - "WHEEL-UP": lambda: _move_selection_vertical(session, -1), - "RIGHT": lambda: move_selection_horizontal(session, 1), - "LEFT": lambda: move_selection_horizontal(session, -1), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_mutation_key( - session: BoardSession, - key: str, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "ENTER": lambda: run_external(lambda: _edit_selected_task_in_external_editor(session)), - "$": lambda: _archive_selected_task(session), - "x": lambda: _clear_search(session), - "S-LEFT": lambda: apply_state_move(session, direction=-1), - "S-RIGHT": lambda: apply_state_move(session, direction=1), - "S-UP": lambda: _apply_priority_shift(session, increase=True), - "S-DOWN": lambda: _apply_priority_shift(session, increase=False), - }.get(key) - if handler is None: - return False - handler() - return True - - -def _handle_prompt_activation_key( - session: BoardSession, - key: str, - _run_external_callback: Callable[[Callable[[], None]], None], -) -> bool: - handler = { - "a": _handle_capture_prompt_activation, - "/": _handle_search_prompt_activation, - }.get(key) - if handler is None: - return False - handler(session) - return True - - -def _handle_keypress_event( - session: BoardSession, - key: str, - run_external: Callable[[Callable[[], None]], None] | None = None, -) -> bool: - effective_run_external = passthrough_run_external if run_external is None else run_external - consumed, next_help_modal = apply_help_modal_key(key, show_help_modal=session.show_help_modal) - session.show_help_modal = next_help_modal - if consumed: - return True - if key in {"q", "ESC"}: - return False - if _handle_navigation_key(session, key): - return True - if _handle_mutation_key(session, key, effective_run_external): - return True - if _handle_prompt_activation_key(session, key, effective_run_external): - return True - if key and key != "IGNORE": - session.status_message = f"Unsupported key: {key}" - return True - - -def passthrough_run_external(callback: Callable[[], None]) -> None: - """Run an external callback immediately.""" - callback() - - -def _run_external(session: BoardSession, callback: Callable[[], None]) -> None: - runner = session.run_external or passthrough_run_external - runner(callback) - - -def handle_interactive_event( - session: BoardSession, - event: InteractiveEvent, - run_external: Callable[[Callable[[], None]], None], -) -> bool: - """Handle one flow-board interactive event.""" - if _handle_help_modal_event(session, event): - return True - if session.active_prompt is not None: - return handle_active_prompt_event(session, event) - if isinstance(event, TimeoutEvent): - return True - if isinstance(event, InputEvent): - return True - return _handle_keypress_event(session, event.key, run_external) - - -def run_flow_board_interactive(console: Console, session: BoardSession) -> None: - """Run the interactive flow-board loop.""" - run_external: list[Callable[[Callable[[], None]], None]] = [passthrough_run_external] - - def _bind_run_external(callback: Callable[[Callable[[], None]], None]) -> None: - run_external[0] = callback - session.run_external = callback - - session.run_external = run_external[0] - interactive_loop( - console=console, - render=lambda: _interactive_flow_board_renderable(console, session), - on_event=lambda event: handle_interactive_event(session, event, run_external[0]), - bind_run_external=_bind_run_external, - timeout_seconds=None, - ) +def can_activate_capture_prompt(session: BoardSession) -> str | None: + """Return status text when the capture prompt cannot be opened.""" + del session + if not configured_capture_template_names(): + return "No capture templates configured" + return None diff --git a/src/org/commands/board/layout.py b/src/org/commands/board/layout.py index 05a9a9b..7e8b85b 100644 --- a/src/org/commands/board/layout.py +++ b/src/org/commands/board/layout.py @@ -6,22 +6,16 @@ from typing import TYPE_CHECKING, Protocol from rich import box +from rich.align import Align from rich.cells import cell_len from rich.console import Console, Group, RenderableType from rich.errors import MarkupError -from rich.layout import Layout from rich.panel import Panel -from rich.rule import Rule from rich.table import Table from rich.text import Text from org.color import get_state_color -from org.commands.interactive_common import ( - INTERACTIVE_HELP_FOOTER_HINT, - InteractiveHelpEntry, - build_footer_prompt_text, - render_interactive_help_modal, -) +from org.commands.interactive_common import InteractiveHelpEntry from org.tui import heading_title_to_text, task_priority_to_text, task_tags_to_text @@ -42,9 +36,6 @@ def nodes(self) -> list[Heading]: ... _HIGHLIGHT_PANEL_STYLE = "on grey23" -_INTERACTIVE_HEADER_HEIGHT = 2 -_INTERACTIVE_FOOTER_HEIGHT = 3 -_INTERACTIVE_FOOTER_HEIGHT_WITH_PROMPT = 4 _INTERACTIVE_PANEL_HEIGHT = 4 @@ -213,7 +204,7 @@ def render_column_title_text(title: str) -> Text: return Text(title) -def _estimate_panel_content_width(console_width: int, column_count: int) -> int: +def estimate_panel_content_width(console_width: int, column_count: int) -> int: """Estimate panel inner width for pre-wrapping task card lines.""" safe_columns = max(1, column_count) raw_width = console_width // safe_columns @@ -257,10 +248,10 @@ def render_static_flow_board( for _ in columns: table.add_column(ratio=1) - header_row = [render_column_title_text(column.title) for column in columns] + header_row = [Align.center(render_column_title_text(column.title)) for column in columns] table.add_row(*header_row) - panel_content_width = _estimate_panel_content_width(console.width, len(columns)) + panel_content_width = estimate_panel_content_width(console.width, len(columns)) render = BoardPanelRenderConfig( width=panel_content_width, color_enabled=color_enabled, @@ -289,28 +280,6 @@ def _column_row_heights(nodes: Sequence[Heading], render: BoardPanelRenderConfig return [_interactive_panel_height(node, render) for node in nodes] -def _interactive_viewport_rows(console_height: int) -> int: - available_space = console_height - _INTERACTIVE_HEADER_HEIGHT - _INTERACTIVE_FOOTER_HEIGHT - available_lines = max(1, available_space) - return max(1, available_lines // _INTERACTIVE_PANEL_HEIGHT) - - -def _interactive_row_heights( - session: BoardSession, - render: BoardPanelRenderConfig, -) -> list[int]: - total_rows = _max_column_nodes(session.columns) - row_heights: list[int] = [] - for row_index in range(total_rows): - max_height = 1 - for column in session.columns: - if row_index >= len(column.nodes): - continue - max_height = max(max_height, _interactive_panel_height(column.nodes[row_index], render)) - row_heights.append(max_height) - return row_heights - - def _selected_column_row_heights( session: BoardSession, render: BoardPanelRenderConfig, @@ -342,11 +311,12 @@ def _window_end_for_height( return end, used_lines -def _sync_scroll_for_selection( +def sync_scroll_for_selection( session: BoardSession, row_heights: list[int], available_lines: int, ) -> tuple[int, int, int]: + """Clamp scroll offset and return the selected-column visible row window.""" if not row_heights: session.scroll_offset = 0 return 0, 0, 0 @@ -379,125 +349,42 @@ def _sync_scroll_for_selection( return session.scroll_offset, end_row, used_lines -def _build_board_header(columns: Sequence[_BoardColumnLike]) -> Table: - header = Table(expand=True, box=None, show_lines=False, show_header=False, pad_edge=False) - for _ in columns: - header.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - header_cells: list[Text] = [] - for column in columns: - title_text = render_column_title_text(column.title) - title_text.overflow = "ellipsis" - title_text.no_wrap = True - header_cells.append(title_text) - header.add_row(*header_cells) - return header - - -def _build_board_body( +def selected_column_window( session: BoardSession, - panel_content_width: int, + render: BoardPanelRenderConfig, + *, body_height: int, -) -> tuple[Table, int]: - body = Table(expand=True, box=None, show_lines=False, show_header=False, pad_edge=False) - for _ in session.columns: - body.add_column(ratio=1) - - render = BoardPanelRenderConfig( - width=panel_content_width, - color_enabled=session.color_enabled, - done_states=session.done_states, - todo_states=session.todo_states, - ) +) -> tuple[int, int]: + """Return the selected-column visible row window after syncing scroll state.""" selected_row_heights = _selected_column_row_heights(session, render) - start_row, end_row, _used_lines = _sync_scroll_for_selection( + start_row, end_row, _used_lines = sync_scroll_for_selection( session, selected_row_heights, body_height, ) + return start_row, end_row - body_cells: list[RenderableType] = [] - for column_index, column in enumerate(session.columns): - panels: list[RenderableType] = [] - if start_row >= len(column.nodes): - body_cells.append(Text("")) - continue - column_end_row, _column_used_lines = _window_end_for_height( - _column_row_heights(column.nodes, render), - start_row, - body_height, - ) - if column_end_row < len(column.nodes): - column_end_row += 1 - for row_index in range(start_row, column_end_row): - if row_index >= len(column.nodes): - continue - node = column.nodes[row_index] - highlighted = ( - column_index == session.selected_column_index - and row_index == session.selected_row_index - ) - panels.append(build_task_panel(node, render, highlighted=highlighted)) - body_cells.append(Group(*panels) if panels else Text("")) - - body.add_row(*body_cells) - return body, end_row - - -def _interactive_flow_board_renderable(console: Console, session: BoardSession) -> RenderableType: - if session.show_help_modal: - return render_interactive_help_modal( - BOARD_HELP_ENTRIES, - color_enabled=session.color_enabled, - ) - prompt_line = None - active_prompt = session.active_prompt - if active_prompt is not None: - prompt_line = build_footer_prompt_text(active_prompt.prompt) - footer_height = ( - _INTERACTIVE_FOOTER_HEIGHT - if prompt_line is None - else _INTERACTIVE_FOOTER_HEIGHT_WITH_PROMPT +def column_window_end( + nodes: Sequence[Heading], + render: BoardPanelRenderConfig, + *, + start_row: int, + body_height: int, +) -> int: + """Return the exclusive end row for one board column's visible card slice.""" + column_end_row, _used_lines = _window_end_for_height( + _column_row_heights(nodes, render), + start_row, + body_height, ) - panel_content_width = _estimate_panel_content_width(console.width, len(session.columns)) - body_height = max(1, console.size.height - _INTERACTIVE_HEADER_HEIGHT - footer_height) - header = _build_board_header(session.columns) - body, end_row = _build_board_body(session, panel_content_width, body_height) + if column_end_row < len(nodes): + column_end_row += 1 + return column_end_row - selected_nodes = session.columns[session.selected_column_index].nodes - total_rows = max(len(selected_nodes), 1) - visible_end_row = min(end_row, total_rows) - search_text = session.search_text or "-" - row_text = f"Rows {visible_end_row}/{total_rows} | Search: {search_text}" - status = " ".join((session.status_message or "").splitlines()) - footer_style = "dim" if session.color_enabled else "" - - footer_line = Table.grid(expand=True) - footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - footer_line.add_row( - Text(row_text, style=footer_style, no_wrap=True, overflow="ellipsis"), - Text( - INTERACTIVE_HELP_FOOTER_HINT, - style=footer_style, - no_wrap=True, - overflow="ellipsis", - ), - ) - layout = Layout(name="board") - layout.split_column( - Layout(name="header", size=_INTERACTIVE_HEADER_HEIGHT), - Layout(name="body"), - Layout(name="footer", size=footer_height), - ) - layout["header"].update(Group(header, Rule(style=footer_style))) - layout["body"].update(body) - status_text = Text(status, style=footer_style, no_wrap=True, overflow="ellipsis") - if prompt_line is None: - layout["footer"].update(Group(Rule(style=footer_style), footer_line, status_text)) - else: - layout["footer"].update( - Group(Rule(style=footer_style), footer_line, prompt_line, status_text), - ) - return layout +def selected_column_total_rows(session: BoardSession) -> int: + """Return the selected column row count, defaulting to one for footer display.""" + if not session.columns: + return 1 + return max(len(session.columns[session.selected_column_index].nodes), 1) diff --git a/src/org/commands/board/styles/app.tcss b/src/org/commands/board/styles/app.tcss new file mode 100644 index 0000000..1a97471 --- /dev/null +++ b/src/org/commands/board/styles/app.tcss @@ -0,0 +1,25 @@ +Screen { + layout: vertical; +} + +#board-body { + height: 1fr; +} + +#board-footer, +#board-status { + height: auto; +} + +.board-column { + width: 1fr; + height: 1fr; +} + +.board-column-title { + height: auto; +} + +.board-column-body { + height: 1fr; +} diff --git a/src/org/commands/interactive_common.py b/src/org/commands/interactive_common.py index d44a780..4ba41d6 100644 --- a/src/org/commands/interactive_common.py +++ b/src/org/commands/interactive_common.py @@ -1,41 +1,21 @@ -"""Shared helpers for interactive command-mode UIs.""" +"""Shared helpers retained across interactive command implementations.""" from __future__ import annotations import calendar -import os -import select -import sys -import termios -import tty from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any, Protocol, Self, cast +from typing import TYPE_CHECKING from org_parser.element import Repeat from org_parser.time import Timestamp -from rich.console import Console, Group, RenderableType -from rich.live import Live from rich.markup import escape -from rich.panel import Panel -from rich.table import Table -from rich.text import Text if TYPE_CHECKING: - from collections.abc import Callable - from org_parser.document import Heading -MOUSE_REPORTING_ENABLE = "\x1b[?1000h\x1b[?1006h" -MOUSE_REPORTING_DISABLE = "\x1b[?1000l\x1b[?1006l" -BRACKETED_PASTE_ENABLE = "\x1b[?2004h" -BRACKETED_PASTE_DISABLE = "\x1b[?2004l" -BRACKETED_PASTE_START = b"\x1b[200~" -BRACKETED_PASTE_END = b"\x1b[201~" -INTERACTIVE_HELP_FOOTER_HINT = "Type ? for help" -INTERACTIVE_HELP_CLOSE_HINT = "Press any key to return" INTERACTIVE_HELP_CLI_NOTE = ( "In interactive mode, press ? to open key bindings help (press any key to close)." ) @@ -60,7 +40,7 @@ class HeadingLocator: @dataclass class FooterPromptState: - """Editable footer prompt state shared by interactive TUIs.""" + """Simple prompt label/value state shared by Textual prompt configs.""" label: str value: str = "" @@ -68,127 +48,6 @@ class FooterPromptState: error_message: str = "" -@dataclass(frozen=True) -class KeypressEvent: - """One decoded keypress or control token.""" - - key: str - - -@dataclass(frozen=True) -class InputEvent: - """One pasted text input event.""" - - key: str - - -@dataclass(frozen=True) -class TimeoutEvent: - """One idle timeout event emitted by the interactive loop.""" - - -type InteractiveEvent = KeypressEvent | InputEvent | TimeoutEvent - - -@dataclass -class InteractivePromptState: - """Command-owned footer prompt state and callbacks.""" - - prompt: FooterPromptState - cancel_status: str - invalid_status: str - submit_callback: Callable[[], bool] - preview: Callable[[], None] | None = None - cancel: Callable[[], None] | None = None - - -class InteractivePromptOwner(Protocol): - """Type protocol for objects that own one active interactive prompt.""" - - active_prompt: InteractivePromptState | None - status_message: str - - -class _PromptConfigLike(Protocol): - """Shape required to build one interactive prompt state.""" - - @property - def prompt(self) -> FooterPromptState: - """Return footer prompt configuration.""" - ... - - @property - def cancel_status(self) -> str: - """Return cancel status message.""" - ... - - @property - def invalid_status(self) -> str: - """Return invalid-input status message.""" - ... - - -@dataclass(frozen=True) -class PromptEventResult: - """Result of applying one interactive event to prompt state.""" - - submitted: bool = False - changed: bool = False - - -@dataclass -class InteractiveInputController: - """Manage one interactive terminal session for fullscreen TUIs.""" - - _active: bool = False - _fd: int | None = None - _previous_mode: list[object] | None = None - - def __enter__(self) -> Self: - """Enable mouse reporting and cbreak input mode.""" - _set_mouse_reporting(True) - self.start() - return self - - def __exit__(self, *_args: object) -> None: - """Restore terminal input mode and disable mouse reporting.""" - self.stop() - _set_mouse_reporting(False) - - def start(self) -> None: - """Enable cbreak input mode when inactive.""" - if self._active: - return - - fd = sys.stdin.fileno() - self._fd = fd - self._previous_mode = cast("list[object]", termios.tcgetattr(fd)) - tty.setcbreak(fd) - self._active = True - - def stop(self) -> None: - """Restore the previous terminal input mode when active.""" - if not self._active or self._fd is None or self._previous_mode is None: - return - - termios.tcsetattr(self._fd, termios.TCSADRAIN, cast("Any", self._previous_mode)) - self._fd = None - self._previous_mode = None - self._active = False - - def suspend_live(self, live: Live) -> None: - """Suspend interactive input and mouse support before leaving live mode.""" - self.stop() - _set_mouse_reporting(False) - live.stop() - - def resume_live(self, live: Live) -> None: - """Resume live mode, mouse reporting, and interactive input.""" - live.start() - _set_mouse_reporting(True) - self.start() - - def render_interactive_help_panel_text(entries: list[InteractiveHelpEntry]) -> str: """Render key bindings as plain help text lines.""" key_width = max(12, max((len(entry.key) for entry in entries), default=0) + 1) @@ -201,58 +60,12 @@ def render_interactive_help_panel_text(entries: list[InteractiveHelpEntry]) -> s def interactive_help_command_text(base_text: str, entries: list[InteractiveHelpEntry]) -> str: - """Append shared interactive-help note and key-bindings panel to help text.""" + """Append the shared interactive-help note and key-bindings panel to help text.""" normalized = " ".join(base_text.split()) panel_text = render_interactive_help_panel_text(entries) return f"{normalized} {INTERACTIVE_HELP_CLI_NOTE}\n\n{panel_text}" -def apply_help_modal_key( - key: str, - *, - show_help_modal: bool, -) -> tuple[bool, bool]: - """Apply one key to help-modal state and return (consumed, next_state).""" - if show_help_modal: - return True, False - if key == "?": - return True, True - return False, show_help_modal - - -def render_interactive_help_modal( - entries: list[InteractiveHelpEntry], - *, - color_enabled: bool, - title: str = "Key bindings", - close_hint: str = INTERACTIVE_HELP_CLOSE_HINT, -) -> RenderableType: - """Render a generic interactive key bindings modal panel.""" - key_column_width = max(18, max((len(entry.key) for entry in entries), default=0) + 1) - content = Table.grid(expand=True, padding=(0, 2)) - content.add_column(width=key_column_width, no_wrap=True, overflow="fold") - content.add_column(ratio=1, no_wrap=False, overflow="fold") - for entry in entries: - content.add_row( - Text(entry.key, style="bold", no_wrap=True), - Text(entry.description), - ) - - border_style = "grey50" if color_enabled else "" - panel_content: RenderableType = content - if close_hint: - panel_content = Group(content, Text(close_hint, style="dim")) - - return Panel( - panel_content, - title=title, - title_align="left", - border_style=border_style, - padding=(0, 1), - expand=True, - ) - - def local_now() -> datetime: """Return local timezone-aware current datetime.""" return datetime.now(tz=UTC).astimezone() @@ -290,7 +103,6 @@ def resolve_heading_locator( if (candidate.document.filename or "") == locator.filename: document = candidate.document break - if document is None: return None @@ -316,12 +128,13 @@ def append_repeat_transition( ) -> None: """Append one repeat transition entry at current time.""" transition_time = now.replace(second=0, microsecond=0) - repeat = Repeat( - before=before, - after=after, - timestamp=Timestamp.from_datetime(transition_time, is_active=False), + heading.repeats.append( + Repeat( + before=before, + after=after, + timestamp=Timestamp.from_datetime(transition_time, is_active=False), + ), ) - heading.repeats.append(repeat) def set_timestamp_fields(timestamp: Timestamp, start: datetime, end: datetime | None) -> None: @@ -444,474 +257,8 @@ def advance_timestamp_by_repeater(timestamp: Timestamp) -> bool: return True -def decode_mouse_sequence(sequence: bytes) -> str | None: - """Decode xterm SGR mouse sequence into command token.""" - if not sequence.startswith(b"\x1b[<"): - return None - if sequence[-1:] not in {b"M", b"m"}: - return None - - try: - body = sequence[3:-1].decode("ascii") - cb_text, _col_text, _row_text = body.split(";", 2) - cb = int(cb_text) - except UnicodeDecodeError, ValueError: - return "UNSUPPORTED-MOUSE" - - if cb & 64 == 0: - result = "UNSUPPORTED-MOUSE" - elif cb & 4: - result = "UNSUPPORTED-SHIFT-WHEEL" - else: - button = cb & 0b11 - result = {0: "WHEEL-UP", 1: "WHEEL-DOWN"}.get(button, "UNSUPPORTED-MOUSE") - - return result - - -def decode_escape_sequence(sequence: bytes) -> str: - """Decode terminal escape sequence into one key token.""" - mapping = { - b"\x1b[A": "UP", - b"\x1b[B": "DOWN", - b"\x1b[1;2A": "S-UP", - b"\x1b[1;2B": "S-DOWN", - b"\x1b[1;2C": "S-RIGHT", - b"\x1b[1;2D": "S-LEFT", - b"\x1b[;2A": "S-UP", - b"\x1b[;2B": "S-DOWN", - b"\x1b[;2C": "S-RIGHT", - b"\x1b[;2D": "S-LEFT", - b"\x1b[C": "RIGHT", - b"\x1b[D": "LEFT", - } - mapped = mapping.get(sequence) - if mapped is not None: - return mapped - - if sequence == b"\x1b": - return "ESC" - - mouse_token = decode_mouse_sequence(sequence) - if mouse_token is not None: - return mouse_token - - return f"UNSUPPORTED-ESC:{sequence.hex()}" - - -def read_escape_sequence(fd: int) -> bytes: - """Read one terminal escape sequence from stdin.""" - payload = bytearray(b"\x1b") - ready, _, _ = select.select([fd], [], [], 0.01) - if not ready: - return bytes(payload) - - payload.extend(os.read(fd, 1)) - - second = payload[-1:] - if second != b"[": - return bytes(payload) - - for _ in range(32): - ready, _, _ = select.select([fd], [], [], 0.002) - if not ready: - break - next_byte = os.read(fd, 1) - payload.extend(next_byte) - if next_byte in {b"~", b"M", b"m"}: - break - if next_byte.isalpha(): - break - - return bytes(payload) - - -def read_bracketed_paste_payload(fd: int, initial_payload: bytes) -> bytes: - """Read remaining bytes until bracketed-paste end marker is received.""" - payload = bytearray(initial_payload) - while BRACKETED_PASTE_END not in payload: - ready, _, _ = select.select([fd], [], [], 0.05) - if not ready: - break - payload.extend(os.read(fd, 1024)) - return bytes(payload) - - -def extract_bracketed_paste_text(payload: bytes) -> str | None: - """Extract decoded text from a bracketed-paste payload.""" - if not payload.startswith(BRACKETED_PASTE_START): - return None - end_index = payload.find(BRACKETED_PASTE_END) - if end_index < 0: - return "" - - text_bytes = payload[len(BRACKETED_PASTE_START) : end_index] - try: - return text_bytes.decode("utf-8") - except UnicodeDecodeError: - return text_bytes.decode("utf-8", errors="ignore") - - -def read_available_bytes(fd: int) -> bytes: - """Read currently available bytes from stdin with short timeout.""" - payload = bytearray() - ready, _, _ = select.select([fd], [], [], 0.05) - if not ready: - return b"" - - payload.extend(os.read(fd, 1024)) - while True: - ready, _, _ = select.select([fd], [], [], 0.01) - if not ready: - break - payload.extend(os.read(fd, 1024)) - return bytes(payload) - - -def _utf8_char_width(first_byte: int) -> int: - """Return expected UTF-8 byte width for one first byte.""" - if first_byte & 0b1000_0000 == 0: - return 1 - if first_byte & 0b1110_0000 == 0b1100_0000: - return 2 - if first_byte & 0b1111_0000 == 0b1110_0000: - return 3 - if first_byte & 0b1111_1000 == 0b1111_0000: - return 4 - return 1 - - -def _has_control_characters(text: str) -> bool: - """Return whether text contains ASCII control characters.""" - return any(ord(char) < 32 or ord(char) == 127 for char in text) - - -def read_utf8_input_text(fd: int, first_byte: bytes) -> str | None: - """Read one UTF-8 input text value from initial first byte.""" - utf8_bytes = bytearray(first_byte) - expected = _utf8_char_width(first_byte[0]) - while len(utf8_bytes) < expected: - ready, _, _ = select.select([fd], [], [], 0.01) - if not ready: - break - utf8_bytes.extend(os.read(fd, 1)) - - try: - text = bytes(utf8_bytes).decode("utf-8") - except UnicodeDecodeError: - return None - if _has_control_characters(text): - return None - return text - - -def _decode_bytes_payload(payload: bytes) -> str: - """Decode byte payload as UTF-8 with lossy fallback.""" - try: - return payload.decode("utf-8") - except UnicodeDecodeError: - return payload.decode("utf-8", errors="ignore") - - -def _read_ctrl_p_paste_event(fd: int) -> tuple[str, str]: - """Read one Ctrl-P paste event payload.""" - payload = read_available_bytes(fd) - bracketed_text = extract_bracketed_paste_text(payload) - if bracketed_text is not None: - return ("TEXT", bracketed_text) - return ("TEXT", _decode_bytes_payload(payload)) - - -def _read_escape_input_event( - fd: int, - token_map: dict[str, str] | None, -) -> tuple[str, str]: - """Read one escape-sequence input event.""" - escape_sequence = read_escape_sequence(fd) - if escape_sequence.startswith(BRACKETED_PASTE_START): - bracketed_payload = read_bracketed_paste_payload(fd, escape_sequence) - pasted_text = extract_bracketed_paste_text(bracketed_payload) - return ("IGNORE", "") if pasted_text is None else ("TEXT", pasted_text) - - if escape_sequence == b"\x1b[3~": - return ("DELETE", "") - - escape_token = decode_escape_sequence(escape_sequence) - defaults = { - "LEFT": "LEFT", - "RIGHT": "RIGHT", - "HOME": "HOME", - "END": "END", - "ESC": "ESC", - } - event_map = defaults if token_map is None else token_map - return (event_map.get(escape_token, "IGNORE"), "") - - -def _read_escape_event(fd: int) -> InteractiveEvent: - """Read one escape-sequence interactive event.""" - escape_sequence = read_escape_sequence(fd) - if escape_sequence.startswith(BRACKETED_PASTE_START): - bracketed_payload = read_bracketed_paste_payload(fd, escape_sequence) - pasted_text = extract_bracketed_paste_text(bracketed_payload) - return InputEvent("" if pasted_text is None else pasted_text) - - if escape_sequence == b"\x1b[3~": - return KeypressEvent("DELETE") - - return KeypressEvent(decode_escape_sequence(escape_sequence)) - - -def _read_interactive_event_from_first_byte(fd: int, first: bytes) -> InteractiveEvent: - """Decode one shared interactive event from an already-read first byte.""" - if first in {b"\r", b"\n"}: - return KeypressEvent("ENTER") - if first in {b"\x7f", b"\x08"}: - return KeypressEvent("BACKSPACE") - if first == b"\x10": - return InputEvent(_decode_bytes_payload(read_available_bytes(fd))) - if first == b"\x1b": - return _read_escape_event(fd) - - text = read_utf8_input_text(fd, first) - if text is None: - return KeypressEvent("IGNORE") - return KeypressEvent(text) - - -def read_interactive_event(timeout_seconds: float | None = None) -> InteractiveEvent: - """Read one shared interactive event for fullscreen command loops.""" - fd = sys.stdin.fileno() - if timeout_seconds is not None: - ready, _, _ = select.select([fd], [], [], timeout_seconds) - if not ready: - return TimeoutEvent() - - first = os.read(fd, 1) - return _read_interactive_event_from_first_byte(fd, first) - - -def _read_input_event_from_first_byte( - fd: int, - first: bytes, - *, - token_map: dict[str, str] | None = None, - ctrl_p_as_paste: bool = False, -) -> tuple[str, str]: - """Decode one input event from an already-read first byte.""" - if first in {b"\r", b"\n"}: - return ("ENTER", "") - if first in {b"\x7f", b"\x08"}: - return ("BACKSPACE", "") - - if first == b"\x10" and ctrl_p_as_paste: - return _read_ctrl_p_paste_event(fd) - - if first == b"\x1b": - return _read_escape_input_event(fd, token_map) - - text = read_utf8_input_text(fd, first) - if text is None: - return ("IGNORE", "") - return ("TEXT", text) - - -def read_input_event( - timeout_seconds: float | None = None, - *, - token_map: dict[str, str] | None = None, - ctrl_p_as_paste: bool = False, -) -> tuple[str, str] | None: - """Read one input event token and optional text payload.""" - active_fd = sys.stdin.fileno() - if timeout_seconds is not None: - ready, _, _ = select.select([active_fd], [], [], timeout_seconds) - if not ready: - return None - - first = os.read(active_fd, 1) - return _read_input_event_from_first_byte( - active_fd, - first, - token_map=token_map, - ctrl_p_as_paste=ctrl_p_as_paste, - ) - - -def _set_mouse_reporting(enabled: bool) -> None: - """Enable or disable terminal mouse reporting.""" - if not sys.stdout.isatty(): - return - - sequence = MOUSE_REPORTING_ENABLE if enabled else MOUSE_REPORTING_DISABLE - try: - sys.stdout.write(sequence) - sys.stdout.flush() - except OSError: - return - - -def set_bracketed_paste(enabled: bool) -> None: - """Enable or disable terminal bracketed paste mode.""" - if not sys.stdout.isatty(): - return - - sequence = BRACKETED_PASTE_ENABLE if enabled else BRACKETED_PASTE_DISABLE - try: - sys.stdout.write(sequence) - sys.stdout.flush() - except OSError: - return - - -def apply_prompt_event(prompt: FooterPromptState, event: InteractiveEvent) -> PromptEventResult: - """Apply one interactive event to prompt state.""" - result = PromptEventResult() - if isinstance(event, TimeoutEvent): - return result - - if isinstance(event, InputEvent): - prompt.value = ( - f"{prompt.value[: prompt.cursor_position]}" - f"{event.key}" - f"{prompt.value[prompt.cursor_position :]}" - ) - prompt.cursor_position += len(event.key) - return PromptEventResult(changed=bool(event.key)) - - if event.key == "ENTER": - result = PromptEventResult(submitted=True) - elif event.key in {"LEFT", "RIGHT", "HOME", "END"}: - cursor_targets = { - "LEFT": max(0, prompt.cursor_position - 1), - "RIGHT": min(len(prompt.value), prompt.cursor_position + 1), - "HOME": 0, - "END": len(prompt.value), - } - prompt.cursor_position = cursor_targets[event.key] - elif event.key == "BACKSPACE" and prompt.cursor_position > 0: - prompt.value = ( - f"{prompt.value[: prompt.cursor_position - 1]}{prompt.value[prompt.cursor_position :]}" - ) - prompt.cursor_position -= 1 - result = PromptEventResult(changed=True) - elif event.key == "DELETE" and prompt.cursor_position < len(prompt.value): - prompt.value = ( - f"{prompt.value[: prompt.cursor_position]}{prompt.value[prompt.cursor_position + 1 :]}" - ) - result = PromptEventResult(changed=True) - elif len(event.key) == 1 and ord(event.key) >= 32: - prompt.value = ( - f"{prompt.value[: prompt.cursor_position]}" - f"{event.key}" - f"{prompt.value[prompt.cursor_position :]}" - ) - prompt.cursor_position += len(event.key) - result = PromptEventResult(changed=True) - - return result - - -def create_interactive_prompt_state( - config: _PromptConfigLike, - *, - submit_value: Callable[[str], bool], - preview_value: Callable[[str], None] | None = None, - cancel: Callable[[], None] | None = None, -) -> InteractivePromptState: - """Build one active interactive prompt state from prompt config and callbacks.""" - prompt = FooterPromptState(label=config.prompt.label) - - def _submit() -> bool: - return submit_value(prompt.value) - - def _preview() -> None: - if preview_value is None: - return - preview_value(prompt.value) - - return InteractivePromptState( - prompt=prompt, - cancel_status=config.cancel_status, - invalid_status=config.invalid_status, - submit_callback=_submit, - preview=None if preview_value is None else _preview, - cancel=cancel, - ) - - -def handle_active_prompt_event(session: InteractivePromptOwner, event: InteractiveEvent) -> bool: - """Handle one interactive event while a footer prompt is active.""" - active_prompt = session.active_prompt - if active_prompt is None: - return True - if isinstance(event, TimeoutEvent): - return True - if isinstance(event, KeypressEvent) and event.key == "ESC": - if active_prompt.cancel is not None: - active_prompt.cancel() - else: - session.status_message = active_prompt.cancel_status - session.active_prompt = None - return True - - prompt_result = apply_prompt_event(active_prompt.prompt, event) - if prompt_result.submitted: - keep_open = active_prompt.submit_callback() - if not keep_open: - session.active_prompt = None - return True - if prompt_result.changed and active_prompt.preview is not None: - active_prompt.preview() - return True - - -def interactive_loop( - *, - console: Console, - render: Callable[[], RenderableType], - on_event: Callable[[InteractiveEvent], bool], - bind_run_external: Callable[[Callable[[Callable[[], None]], None]], None] | None = None, - timeout_seconds: float | None = None, -) -> None: - """Run one shared fullscreen interactive event loop.""" - with ( - Live(render(), console=console, screen=True, auto_refresh=False) as live, - InteractiveInputController() as controller, - ): - - def _run_external(callback: Callable[[], None]) -> None: - controller.suspend_live(live) - try: - callback() - finally: - controller.resume_live(live) - - if bind_run_external is not None: - bind_run_external(_run_external) - - while True: - event = read_interactive_event(timeout_seconds) - if not on_event(event): - break - live.update(render(), refresh=True) - - -def build_footer_prompt_text(prompt: FooterPromptState) -> Text: - """Build prompt text with visible cursor for footer rendering.""" - clamped_cursor = max(0, min(prompt.cursor_position, len(prompt.value))) - text = Text(f"{prompt.label}: ", style="bold") - text.append(prompt.value[:clamped_cursor]) - if clamped_cursor < len(prompt.value): - text.append(prompt.value[clamped_cursor], style="reverse") - text.append(prompt.value[clamped_cursor + 1 :]) - else: - text.append(" ", style="reverse") - return text - - def detail_org_block(node: Heading) -> str: - """Build detailed org block text for one heading subtree.""" - filename = node.document.filename or "unknown" - node_text = node.render().rstrip() - return f"# {filename}\n{node_text}" if node_text else f"# {filename}" + """Return one heading subtree rendered back to Org text.""" + parts = [str(node)] + parts.extend(detail_org_block(child) for child in node.children) + return "".join(parts) diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index d3dcd87..05d43c2 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -7,7 +7,7 @@ from contextlib import contextmanager from datetime import UTC, datetime from io import StringIO -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast import pytest import typer @@ -20,12 +20,7 @@ from org.commands.board import command as board_command from org.commands.board import events as board_events from org.commands.board import layout as board_layout -from org.commands.interactive_common import ( - KeypressEvent, - handle_active_prompt_event, - heading_locator, -) -from org.commands.tasks import capture as capture_command +from org.commands.interactive_common import heading_locator from tests.conftest import node_from_org @@ -33,7 +28,6 @@ from collections.abc import Iterator from org_parser.document import Document, Heading - from rich.live import Live FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") @@ -122,7 +116,6 @@ def _make_session( status_message="", all_columns=[], search_text="", - show_help_modal=False, ) session.all_columns = session.columns if resolved_all_columns is None else resolved_all_columns for key, value in overrides.items(): @@ -398,47 +391,6 @@ def test_move_selection_horizontal_skips_empty_columns() -> None: assert session.selected_column_index == 1 -def test_interactive_viewport_rows_scales_with_panel_height() -> None: - """Interactive viewport should account for panel line height.""" - assert board_layout._interactive_viewport_rows(24) == 4 - assert board_layout._interactive_viewport_rows(13) == 2 - assert board_layout._interactive_viewport_rows(9) == 1 - - -def test_sync_scroll_for_selection_keeps_selected_row_visible() -> None: - """Scroll sync should move viewport so selected task stays visible.""" - args = make_board_args([]) - nodes = node_from_org("\n".join(f"* TODO Task {index}" for index in range(6))) - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=4, - scroll_offset=0, - status_message="", - ) - render = board_layout.BoardPanelRenderConfig( - width=30, - color_enabled=False, - done_states=["DONE"], - todo_states=["TODO"], - ) - row_heights = board_layout._interactive_row_heights(session, render) - _start, _end, _used = board_layout._sync_scroll_for_selection( - session, - row_heights, - available_lines=8, - ) - - assert session.scroll_offset == 3 - - def test_step_heading_state_right_from_none_uses_first_document_state() -> None: """Shift-right from empty state should use first all_states item.""" heading = node_from_org("#+TODO: TODO WAITING | DONE\n* Task\n")[0] @@ -573,211 +525,10 @@ def test_reload_session_keeps_same_task_selected_after_priority_reshuffle( assert selected.title_text == "Focus" -def test_interactive_renderable_keeps_footer_at_bottom() -> None: - """Interactive render should reserve bottom lines for footer and status.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="Ready", - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[-2].startswith("Rows ") - assert "Type ? for help" in lines[-2] - assert lines[-1].strip() == "Ready" - - -def test_interactive_renderable_footer_status_is_single_line() -> None: - """Interactive footer status should be normalized to one visible line.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="line one\nline two\nline three", - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - lines = buffer.getvalue().splitlines() - - assert len(lines) == 24 - assert lines[-2].startswith("Rows ") - assert "Type ? for help" in lines[-2] - assert lines[-1].strip() == "line one line two line three" - - -def test_handle_interactive_key_slash_activates_search_prompt() -> None: - """Slash should activate board search prompt.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[_col("TODO", nodes)], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=[_col("TODO", nodes)], - ) - - assert board_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - assert session.active_prompt.prompt.label == "Search text (blank clears)" - - -def test_board_search_filters_each_column_and_clear_restores_columns() -> None: - """Interactive search should filter each column's visible tasks and clear should restore.""" - args = make_board_args([]) - alpha, beta, beta_done = node_from_org( - "* TODO Alpha\n* TODO Beta\n* DONE Beta done\n", - ) - all_columns = [ - _col("Backlog", []), - _col("TODO", [alpha, beta]), - _col("DONE", [beta_done]), - ] - session = _make_session( - args=args, - nodes=[alpha, beta, beta_done], - columns=all_columns, - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=all_columns, - ) - - assert board_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "beta" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is False - assert session.status_message == "2 matches" - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Beta"], - "DONE": ["Beta done"], - } - assert session.search_text == "beta" - - assert board_events._handle_keypress_event(session, "x") is True - assert session.status_message == "Search cleared" - assert session.search_text == "" - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Alpha", "Beta"], - "DONE": ["Beta done"], - } - - -def test_interactive_renderable_footer_shows_active_search_text() -> None: - """Board footer should include currently active interactive search text.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - column = _col("TODO", nodes) - session = _make_session( - args=args, - nodes=nodes, - columns=[column], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=[column], - search_text="task", - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - output = buffer.getvalue() - - assert "Search: task" in output - - -def test_handle_interactive_key_question_toggles_help_modal() -> None: - """Question mark should open help modal and next key should close it.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[_col("TODO", nodes)], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - - assert board_events._handle_keypress_event(session, "?") is True - assert session.show_help_modal is True - - assert board_events._handle_keypress_event(session, "ENTER") is True - assert session.show_help_modal is False - - -def test_interactive_renderable_shows_help_panel() -> None: - """Help modal should render key bindings panel in body area.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Task\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[_col("TODO", nodes)], - color_enabled=False, - selected_column_index=0, - selected_row_index=0, - scroll_offset=0, - status_message="", - show_help_modal=True, - ) - buffer = StringIO() - console = Console(file=buffer, force_terminal=False, width=120, height=24) - - console.print(board_layout._interactive_flow_board_renderable(console, session)) - output = buffer.getvalue() - - assert "Key bindings" in output - assert "Type ? for help" not in output - - -def test_handle_interactive_key_enter_edits_selected_task( +def test_edit_selected_task_in_external_editor_reports_no_changes( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Enter should trigger external-editor task editing for selected panel.""" + """Editing through the shared external-editor path should report no changes.""" args = make_board_args([]) nodes = node_from_org("* TODO Task\n") session = _make_session( @@ -800,331 +551,14 @@ def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: monkeypatch.setattr(board_events, "edit_heading_subtree_in_external_editor", _fake_edit) - assert board_events._handle_keypress_event(session, "ENTER") is True + board_events.edit_selected_task_in_external_editor(session) assert session.status_message == "No changes." -def test_handle_interactive_key_a_captures_task_and_reloads( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should prompt for template then capture and reload board selection.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - captured_node = node_from_org("* TODO Captured\n")[0] - reloaded: dict[str, object] = {} - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - monkeypatch.setattr( - board_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - - def _fake_reload( - current_session: board_events.BoardSession, - preserve_identity: tuple[str, str, str, int | None] | None, - ) -> None: - reloaded["session"] = current_session - reloaded["identity"] = preserve_identity - - monkeypatch.setattr(board_events, "reload_session", _fake_reload) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert reloaded["session"] is session - assert reloaded["identity"] == heading_locator(captured_node) - assert session.status_message == "Task captured" - - -def test_handle_interactive_key_a_capture_blank_input_cancels( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should close prompt with cancel status on blank template input.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "" - keep_open = session.active_prompt.submit_callback() - assert keep_open is False - assert session.status_message == "Capture cancelled" - - -def test_handle_interactive_key_a_capture_invalid_shortcut_keeps_prompt_open( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should keep capture prompt open for invalid template shortcuts.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is not None - session.active_prompt.prompt.value = "99" - keep_open = session.active_prompt.submit_callback() - - assert keep_open is True - assert session.status_message == "Invalid capture template shortcut" - - -def test_handle_interactive_key_a_without_templates_reports_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """a should report a clear status when no capture templates are configured.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {}) - - assert board_events._handle_keypress_event(session, "a") is True - assert session.active_prompt is None - assert session.status_message == "No capture templates configured" - - -def test_prompt_submit_capture_stops_and_restarts_live(monkeypatch: pytest.MonkeyPatch) -> None: - """Board prompt submission should pause live rendering for capture action.""" - args = make_board_args([]) - nodes = node_from_org("* TODO Existing\n") - session = _make_session( - args=args, - nodes=nodes, - columns=[ - _col("NOT STARTED", []), - _col("TODO", nodes), - _col("COMPLETED", []), - ], - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - ) - monkeypatch.setattr( - config_module, - "CONFIG_CAPTURE_TEMPLATES", - {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, - ) - board_events._handle_keypress_event(session, "a") - assert session.active_prompt is not None - session.active_prompt.prompt.value = "1" - - captured_node = node_from_org("* TODO Captured\n")[0] - monkeypatch.setattr( - board_events, - "capture_task", - lambda _args: capture_command.TasksCaptureResult( - template_name="quick", - heading=captured_node, - document=captured_node.document, - ), - ) - monkeypatch.setattr(board_events, "reload_session", lambda _session, _identity: None) - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - controller = cast("Any", _InputControllerStub()) - - def _run_prompt_external(callback: object) -> None: - controller.suspend_live(live) - try: - cast("Any", callback)() - finally: - controller.resume_live(live) - - session.run_external = _run_prompt_external - - consumed = handle_active_prompt_event(session, KeypressEvent("ENTER")) - live.update( - board_layout._interactive_flow_board_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert events == ["input-stop", "stop", "start", "input-start", "update"] - - -def test_search_prompt_live_updates_and_escape_reverts_search() -> None: - """Board search prompt should live-update columns and ESC should restore prior filter.""" - args = make_board_args([]) - alpha, beta, done = node_from_org("* TODO Alpha\n* TODO Beta\n* DONE Finished\n") - all_columns = [ - _col("Backlog", []), - _col("TODO", [alpha, beta]), - _col("DONE", [done]), - ] - session = _make_session( - args=args, - nodes=[alpha, beta, done], - columns=all_columns, - color_enabled=False, - selected_column_index=1, - selected_row_index=0, - scroll_offset=0, - status_message="", - all_columns=all_columns, - ) - assert board_events._handle_keypress_event(session, "/") is True - assert session.active_prompt is not None - - events: list[str] = [] - - class _LiveStub: - console = Console(file=StringIO(), force_terminal=False, width=120, height=24) - - def stop(self) -> None: - events.append("stop") - - def start(self) -> None: - events.append("start") - - def update(self, _renderable: object, refresh: bool) -> None: - assert refresh is True - events.append("update") - - class _InputControllerStub: - def suspend_live(self, live: Live) -> None: - events.append("input-stop") - live.stop() - - def resume_live(self, live: Live) -> None: - live.start() - events.append("input-start") - - live = cast("Live", _LiveStub()) - consumed = handle_active_prompt_event(session, KeypressEvent("b")) - live.update( - board_layout._interactive_flow_board_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Beta"], - "DONE": [], - } - assert session.search_text == "b" - assert session.status_message == "1 matches" - - consumed = handle_active_prompt_event(session, KeypressEvent("ESC")) - live.update( - board_layout._interactive_flow_board_renderable(live.console, session), - refresh=True, - ) - - assert consumed is True - assert _visible_board_titles_by_column(session) == { - "Backlog": [], - "TODO": ["Alpha", "Beta"], - "DONE": ["Finished"], - } - assert session.search_text == "" - assert session.status_message == "Search cancelled" - assert events == ["update", "update"] - - -def test_handle_interactive_key_enter_saves_original_document_after_changed_edit( +def test_edit_selected_task_in_external_editor_reloads_with_identity_after_change( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Changed edit should save the selected task's original document.""" + """Changed edit should reload using the selected task identity.""" args = make_board_args([]) nodes = node_from_org("* TODO Task\n") session = _make_session( @@ -1155,15 +589,15 @@ def _capture_reload(_session: board_events.BoardSession, identity: object) -> No monkeypatch.setattr(board_events, "edit_heading_subtree_in_external_editor", _fake_edit) monkeypatch.setattr(board_events, "reload_session", _capture_reload) - assert board_events._handle_keypress_event(session, "ENTER") is True + board_events.edit_selected_task_in_external_editor(session) assert session.status_message == "Task updated" assert reloaded_identity == heading_locator(source_node) -def test_handle_interactive_key_dollar_archives_selected_task( +def test_archive_selected_task_archives_selected_heading( monkeypatch: pytest.MonkeyPatch, ) -> None: - """$ should archive highlighted task using shared archive helper.""" + """Archiving the selected task should use the shared archive helper.""" args = make_board_args([]) nodes = node_from_org("* TODO Task\n") session = _make_session( @@ -1205,7 +639,7 @@ def _fake_archive( monkeypatch.setattr(board_events, "archive_heading_subtree_and_save", _fake_archive) monkeypatch.setattr(board_events, "reload_session", lambda _session, _identity: None) - assert board_events._handle_keypress_event(session, "$") is True + board_events.archive_selected_task(session) assert session.status_message == "Task archived" diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py new file mode 100644 index 0000000..6c04e22 --- /dev/null +++ b/tests/commands/test_board_app.py @@ -0,0 +1,325 @@ +"""App tests for the board command.""" + +from __future__ import annotations + +import asyncio +import os +import sys +from typing import TYPE_CHECKING, Any, cast + +from textual.widgets import Input, Static + +from org import config as config_module +from org.commands import editor as editor_command +from org.commands.board import command as board_command +from org.commands.board import events as board_events +from org.commands.board.app import BoardApp, BoardViewport +from org.commands.interactive_common import heading_locator +from org.commands.tasks import capture as capture_command +from tests.commands.test_board import make_board_args +from tests.conftest import node_from_org + + +if TYPE_CHECKING: + import pytest + from org_parser.document import Heading + + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") + + +def _col(title: str, nodes: list[Heading]) -> board_events.BoardColumn: + return board_events.BoardColumn(title, nodes) + + +def _make_session( + args: board_command.BoardArgs, + nodes: list[Heading], + **overrides: object, +) -> board_events.BoardSession: + resolved_columns = cast("list[board_events.BoardColumn] | None", overrides.pop("columns", None)) + resolved_all_columns = cast( + "list[board_events.BoardColumn] | None", + overrides.pop("all_columns", None), + ) + session = board_events.BoardSession( + args=args, + nodes=nodes, + todo_states=["TODO"], + done_states=["DONE"], + columns=[_col("TODO", nodes)] if resolved_columns is None else resolved_columns, + color_enabled=False, + selected_column_index=0, + selected_row_index=0, + scroll_offset=0, + status_message="", + all_columns=[], + search_text="", + ) + session.all_columns = session.columns if resolved_all_columns is None else resolved_all_columns + for key, value in overrides.items(): + setattr(session, key, value) + return session + + +def _make_app(session: board_events.BoardSession) -> BoardApp: + return BoardApp(session) + + +def _visible_board_titles_by_column(app: BoardApp) -> dict[str, list[str]]: + return { + column.title: [node.title_text for node in column.nodes] for column in app.session.columns + } + + +def test_board_app_moves_selection_with_arrow_keys() -> None: + """Arrow-key navigation should update the selected task row.""" + + async def _run() -> None: + nodes = node_from_org("* TODO A\n* TODO B\n") + app = _make_app(_make_session(make_board_args([]), nodes)) + + async with app.run_test() as pilot: + await pilot.press("down") + assert app.session.selected_row_index == 1 + + await pilot.press("up") + assert app.session.selected_row_index == 0 + + asyncio.run(_run()) + + +def test_board_app_moves_horizontally_across_non_empty_columns() -> None: + """Left/right navigation should skip empty columns.""" + + async def _run() -> None: + first, second = node_from_org("* TODO First\n* TODO Second\n") + session = _make_session( + make_board_args([]), + [first, second], + columns=[ + _col("Backlog", []), + _col("TODO", [first]), + _col("WAITING", []), + _col("INPROGRESS", [second]), + ], + selected_column_index=1, + ) + app = _make_app(session) + + async with app.run_test() as pilot: + await pilot.press("right") + assert app.session.selected_column_index == 3 + + await pilot.press("left") + assert app.session.selected_column_index == 1 + + asyncio.run(_run()) + + +def test_board_app_search_prompt_filters_columns_live_and_escape_restores() -> None: + """Search modal should live-filter each column and escape should restore the previous search.""" + + async def _run() -> None: + alpha, beta, beta_done = node_from_org("* TODO Alpha\n* TODO Beta\n* DONE Beta done\n") + all_columns = [ + _col("Backlog", []), + _col("TODO", [alpha, beta]), + _col("DONE", [beta_done]), + ] + session = _make_session( + make_board_args([]), + [alpha, beta, beta_done], + columns=all_columns, + all_columns=all_columns, + selected_column_index=1, + ) + app = _make_app(session) + + async with app.run_test() as pilot: + app.action_prompt_search() + await pilot.pause() + + prompt_input = app.screen.query_one(Input) + prompt_input.value = "beta" + prompt_screen = cast("Any", app.screen_stack[-1]) + prompt_screen.on_input_changed(Input.Changed(prompt_input, "beta")) + + assert app.session.search_text == "beta" + assert _visible_board_titles_by_column(app) == { + "Backlog": [], + "TODO": ["Beta"], + "DONE": ["Beta done"], + } + + await pilot.press("escape") + await pilot.pause() + + assert app.session.search_text == "" + assert app.session.status_message == "Search cancelled" + assert _visible_board_titles_by_column(app) == { + "Backlog": [], + "TODO": ["Alpha", "Beta"], + "DONE": ["Beta done"], + } + + asyncio.run(_run()) + + +def test_board_app_help_modal_forwards_key_to_app() -> None: + """Help modal should close and forward the pressed key to the board app.""" + + async def _run() -> None: + first, second = node_from_org("* TODO First\n* TODO Second\n") + session = _make_session( + make_board_args([]), + [first, second], + columns=[_col("TODO", [first]), _col("DONE", [second])], + ) + app = _make_app(session) + + async with app.run_test() as pilot: + app.action_show_help() + await pilot.pause() + + assert app.screen.query_one("#help-content", Static) is not None + await pilot.press("right") + await pilot.pause() + + assert app.session.selected_column_index == 1 + assert app.screen.query_one("#board-body", BoardViewport) is not None + + asyncio.run(_run()) + + +def test_board_app_enter_edits_selected_task(monkeypatch: pytest.MonkeyPatch) -> None: + """Enter should trigger external-editor handling on the selected task card.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Task\n") + monkeypatch.setattr( + board_events, + "edit_heading_subtree_in_external_editor", + lambda _heading: editor_command.DocumentEditResult(changed=False), + ) + app = _make_app(_make_session(make_board_args([]), nodes)) + + async with app.run_test() as pilot: + await pilot.press("enter") + assert app.session.status_message == "No changes." + + asyncio.run(_run()) + + +def test_board_app_capture_prompt_submits_and_reloads(monkeypatch: pytest.MonkeyPatch) -> None: + """Capture prompt submission should create a task and reload board state.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Existing\n") + captured_node = node_from_org("* TODO Captured\n")[0] + reloaded: dict[str, object] = {} + monkeypatch.setattr( + config_module, + "CONFIG_CAPTURE_TEMPLATES", + {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, + ) + monkeypatch.setattr( + board_events, + "capture_task", + lambda _args: capture_command.TasksCaptureResult( + template_name="quick", + heading=captured_node, + document=captured_node.document, + ), + ) + monkeypatch.setattr( + board_events, + "reload_session", + lambda _session, preserve_identity: reloaded.update(identity=preserve_identity), + ) + + app = _make_app(_make_session(make_board_args([]), nodes)) + + async with app.run_test() as pilot: + app.action_prompt_capture() + await pilot.pause() + app.screen_stack[-1].dismiss("1") + await pilot.pause() + + assert reloaded["identity"] == heading_locator(captured_node) + assert app.session.status_message == "Task captured" + + asyncio.run(_run()) + + +def test_board_app_capture_without_templates_sets_status(monkeypatch: pytest.MonkeyPatch) -> None: + """Capture should report a clear status when no templates are configured.""" + + async def _run() -> None: + monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {}) + app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Existing\n"))) + + async with app.run_test(): + app.action_prompt_capture() + assert app.session.status_message == "No capture templates configured" + + asyncio.run(_run()) + + +def test_board_app_shift_bindings_trigger_state_and_priority_actions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shifted arrow bindings should dispatch state and priority mutations.""" + + async def _run() -> None: + calls: list[str] = [] + monkeypatch.setattr( + board_events, + "apply_state_move", + lambda _session, *, direction: calls.append(f"state:{direction}"), + ) + monkeypatch.setattr( + board_events, + "apply_priority_shift", + lambda _session, *, increase: calls.append(f"priority:{increase}"), + ) + app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Existing\n"))) + + async with app.run_test() as pilot: + await pilot.press("shift+left") + await pilot.press("shift+right") + await pilot.press("shift+up") + await pilot.press("shift+down") + + assert calls == ["state:-1", "state:1", "priority:True", "priority:False"] + + asyncio.run(_run()) + + +def test_run_flow_board_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Interactive board path should delegate to the Textual app runner.""" + fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + args = make_board_args([fixture_path], days=100000) + called = {"value": False} + + def _fake_run( + passed_args: board_command.BoardArgs, + nodes: list[Heading], + todo_states: list[str], + done_states: list[str], + color_enabled: bool, + ) -> None: + called["value"] = True + assert passed_args.files == [fixture_path] + assert nodes + assert todo_states + assert done_states + assert color_enabled is False + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr("org.commands.board.command.run_board_app", _fake_run) + + board_command.run_flow_board(args) + + assert called["value"] is True diff --git a/tests/commands/test_interactive_common.py b/tests/commands/test_interactive_common.py index 616f46a..23d7ae5 100644 --- a/tests/commands/test_interactive_common.py +++ b/tests/commands/test_interactive_common.py @@ -1,178 +1,20 @@ -"""Tests for shared interactive helpers.""" +"""Tests for shared interactive helpers still used in production.""" from __future__ import annotations -from io import StringIO -from typing import TYPE_CHECKING, Any, cast - -from rich.console import Console +from typing import Any, cast from org.commands.interactive_common import ( - BRACKETED_PASTE_DISABLE, - BRACKETED_PASTE_ENABLE, - FooterPromptState, HeadingLocator, - InputEvent, InteractiveHelpEntry, - InteractivePromptState, - KeypressEvent, - create_interactive_prompt_state, - extract_bracketed_paste_text, - handle_active_prompt_event, heading_locator, - read_input_event, - render_interactive_help_modal, render_interactive_help_panel_text, resolve_heading_locator, - set_bracketed_paste, ) from org.commands.tasks.common import resolve_todo_state_selection, todo_states_for_heading from tests.conftest import node_from_org -if TYPE_CHECKING: - import pytest - - -class _PromptConfig: - def __init__(self, label: str) -> None: - self.prompt = FooterPromptState(label=label) - self.cancel_status = "Cancelled" - self.invalid_status = "Invalid" - - -class _PromptOwner: - def __init__(self, prompt: InteractivePromptState | None) -> None: - self.active_prompt = prompt - self.status_message = "" - - -def _submit_and_close(values: list[str], value: str) -> bool: - values.append(value) - return False - - -def test_create_interactive_prompt_state_uses_submit_and_preview_callbacks() -> None: - """Prompt state should forward current value to submit and preview callbacks.""" - previewed: list[str] = [] - submitted: list[str] = [] - - prompt = create_interactive_prompt_state( - _PromptConfig("Search"), - submit_value=lambda value: _submit_and_close(submitted, value), - preview_value=previewed.append, - ) - prompt.prompt.value = "needle" - - assert prompt.preview is not None - prompt.preview() - keep_open = prompt.submit_callback() - - assert previewed == ["needle"] - assert submitted == ["needle"] - assert keep_open is False - - -def test_handle_active_prompt_event_closes_prompt_on_escape() -> None: - """Escape should close active prompt and set cancel status by default.""" - owner = _PromptOwner( - InteractivePromptState( - prompt=FooterPromptState(label="Search"), - cancel_status="Cancelled", - invalid_status="Invalid", - submit_callback=lambda: False, - ), - ) - - assert handle_active_prompt_event(owner, KeypressEvent("ESC")) is True - assert owner.active_prompt is None - assert owner.status_message == "Cancelled" - - -def test_handle_active_prompt_event_applies_input_and_submit() -> None: - """Prompt event handling should update prompt text and close after submit.""" - submitted: list[str] = [] - prompt = create_interactive_prompt_state( - _PromptConfig("Search"), - submit_value=lambda value: _submit_and_close(submitted, value), - ) - owner = _PromptOwner(prompt) - - assert handle_active_prompt_event(owner, InputEvent("abc")) is True - assert owner.active_prompt is not None - assert owner.active_prompt.prompt.value == "abc" - - assert handle_active_prompt_event(owner, KeypressEvent("ENTER")) is True - assert submitted == ["abc"] - assert owner.active_prompt is None - - -def test_extract_bracketed_paste_text_decodes_payload() -> None: - """Bracketed paste payload should decode inserted text.""" - payload = b"\x1b[200~Line one\nLine two\x1b[201~" - assert extract_bracketed_paste_text(payload) == "Line one\nLine two" - - -def test_read_input_event_maps_bracketed_paste_to_text(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste input should be surfaced as a TEXT event.""" - monkeypatch.setattr("org.commands.interactive_common.sys.stdin.fileno", lambda: 0) - monkeypatch.setattr("org.commands.interactive_common.os.read", lambda _fd, _n: b"\x1b") - monkeypatch.setattr( - "org.commands.interactive_common.read_escape_sequence", - lambda _fd: b"\x1b[200~Paste value", - ) - monkeypatch.setattr( - "org.commands.interactive_common.read_bracketed_paste_payload", - lambda _fd, initial_payload: initial_payload + b"\x1b[201~", - ) - assert read_input_event(ctrl_p_as_paste=True) == ("TEXT", "Paste value") - - -def test_read_input_event_returns_none_when_timeout_expires( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Timed input reads should return None when no input arrives.""" - - def _fake_select( - _readers: object, - _writers: object, - _errors: object, - _timeout: object, - ) -> tuple[list[int], list[int], list[int]]: - return ([], [], []) - - monkeypatch.setattr("org.commands.interactive_common.select.select", _fake_select) - monkeypatch.setattr("org.commands.interactive_common.sys.stdin.fileno", lambda: 0) - - assert read_input_event(timeout_seconds=0.1) is None - - -def test_set_bracketed_paste_writes_terminal_sequences(monkeypatch: pytest.MonkeyPatch) -> None: - """Bracketed paste toggles should emit proper terminal sequences.""" - - class _FakeStdout: - def __init__(self) -> None: - self.writes: list[str] = [] - - def isatty(self) -> bool: - return True - - def write(self, value: str) -> int: - self.writes.append(value) - return len(value) - - def flush(self) -> None: - return None - - fake_stdout = _FakeStdout() - monkeypatch.setattr("org.commands.interactive_common.sys.stdout", fake_stdout) - - set_bracketed_paste(True) - set_bracketed_paste(False) - - assert fake_stdout.writes == [BRACKETED_PASTE_ENABLE, BRACKETED_PASTE_DISABLE] - - def test_resolve_todo_state_selection_supports_number_and_value() -> None: """State selection should resolve numeric indexes and explicit values.""" states = ["TODO", "NEXT", "DONE"] @@ -198,24 +40,6 @@ class _FakeHeading: assert todo_states_for_heading(fake_heading) == ["TODO", "DONE", "WAIT"] -def test_render_interactive_help_modal_keeps_key_column_wide_without_ellipsis() -> None: - """Help modal should avoid key ellipsis and keep long key text visible.""" - entries = [ - InteractiveHelpEntry( - "CTRL-SHIFT-SUPER-LEFT", - "Very long description text that should wrap instead of being cut with ellipsis.", - ), - ] - renderable = render_interactive_help_modal(entries, color_enabled=False) - output_stream = StringIO() - output_console = Console(file=output_stream, width=40, force_terminal=False, no_color=True) - output_console.print(renderable) - output = output_stream.getvalue() - - assert "..." not in output - assert "CTRL-SHIFT-SUPER-LEFT" in output - - def test_render_interactive_help_panel_text_includes_title_and_rows() -> None: """Rendered help text should include key-bindings title and entries.""" entries = [ From 9d75ce6c46e73b0dabcb5a451f966b1313e51f37 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 7 Jun 2026 20:01:43 +0200 Subject: [PATCH 09/27] Refactored the footer to better match the old layout --- src/org/commands/agenda/app.py | 23 +++++++++++++++------ src/org/commands/agenda/styles/app.tcss | 2 ++ src/org/commands/board/app.py | 21 +++++++++++-------- src/org/commands/board/styles/app.tcss | 1 + src/org/commands/runtime.py | 13 ++++++++++++ src/org/commands/tasks/list/app.py | 18 ++++++++++------ src/org/commands/tasks/list/styles/app.tcss | 1 + tests/commands/tasks/test_tasks_list_app.py | 13 ++++++++++++ tests/commands/test_agenda_app.py | 15 ++++++++++++++ tests/commands/test_board_app.py | 12 +++++++++++ 10 files changed, 98 insertions(+), 21 deletions(-) diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 381b95d..3042f1e 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -2,8 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING +from rich.rule import Rule from rich.text import Text from textual.binding import Binding, BindingType from textual.containers import Vertical @@ -32,6 +33,7 @@ from org_parser.document import Heading from textual.app import ComposeResult from textual.events import MouseScrollDown, MouseScrollUp, Resize + from textual.widget import Widget from .command import AgendaArgs from .views import AgendaViewContext @@ -106,7 +108,9 @@ def compose(self) -> ComposeResult: """Build the main agenda screen layout.""" yield Vertical( Static(id="agenda-header"), - AgendaViewport(id="agenda-body"), + Static(id="agenda-header-rule"), + AgendaViewport(widget_id="agenda-body"), + Static(id="agenda-footer-rule"), Static(id="agenda-footer"), Static(id="agenda-status"), ) @@ -136,9 +140,15 @@ def _header_widget(self) -> Static: def _body_widget(self) -> AgendaViewport: return self.query_one("#agenda-body", AgendaViewport) + def _header_rule_widget(self) -> Static: + return self.query_one("#agenda-header-rule", Static) + def _footer_widget(self) -> Static: return self.query_one("#agenda-footer", Static) + def _footer_rule_widget(self) -> Static: + return self.query_one("#agenda-footer-rule", Static) + def _status_widget(self) -> Static: return self.query_one("#agenda-status", Static) @@ -173,12 +183,13 @@ def _refresh_footer(self, viewport_height: int) -> None: ) search_text = self.session.search_text or "-" footer_style = "dim" if self.session.render.color_enabled else "" + self._header_rule_widget().update(Rule(style=footer_style)) + self._footer_rule_widget().update(Rule(style=footer_style)) self._footer_widget().update( - Text( - f"Lines {end_line}/{total_rows} | Search: {search_text} | {_HELP_FOOTER_TEXT}", + runtime.footer_renderable( + f"Lines {end_line}/{total_rows} | Search: {search_text}", + _HELP_FOOTER_TEXT, style=footer_style, - no_wrap=True, - overflow="ellipsis", ), ) diff --git a/src/org/commands/agenda/styles/app.tcss b/src/org/commands/agenda/styles/app.tcss index ada5825..5958219 100644 --- a/src/org/commands/agenda/styles/app.tcss +++ b/src/org/commands/agenda/styles/app.tcss @@ -3,7 +3,9 @@ Screen { } #agenda-header, +#agenda-header-rule, #agenda-footer, +#agenda-footer-rule, #agenda-status { height: auto; } diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index 75e124b..835c8e3 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -2,10 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from rich.align import Align from rich.console import Group, RenderableType +from rich.rule import Rule from rich.text import Text from textual.binding import Binding, BindingType from textual.containers import Horizontal, Vertical @@ -29,6 +30,7 @@ from org_parser.document import Heading from textual.app import ComposeResult from textual.events import MouseScrollDown, MouseScrollUp, Resize + from textual.widget import Widget from .command import BoardArgs @@ -106,7 +108,8 @@ def __init__(self, session: BoardSession) -> None: def compose(self) -> ComposeResult: """Build the main board app layout.""" yield Vertical( - BoardViewport(id="board-body"), + BoardViewport(widget_id="board-body"), + Static(id="board-footer-rule"), Static(id="board-footer"), Static(id="board-status"), ) @@ -135,6 +138,9 @@ def _body_widget(self) -> BoardViewport: def _footer_widget(self) -> Static: return self.query_one("#board-footer", Static) + def _footer_rule_widget(self) -> Static: + return self.query_one("#board-footer-rule", Static) + def _status_widget(self) -> Static: return self.query_one("#board-status", Static) @@ -207,15 +213,12 @@ def _refresh_footer(self) -> None: search_text = self.session.search_text or "-" total_rows = layout.selected_column_total_rows(self.session) footer_style = "dim" if self.session.color_enabled else "" + self._footer_rule_widget().update(Rule(style=footer_style)) self._footer_widget().update( - Text( - ( - f"Rows {self._visible_end_row}/{total_rows} | Search: {search_text} | " - f"{_HELP_FOOTER_TEXT}" - ), + runtime.footer_renderable( + f"Rows {self._visible_end_row}/{total_rows} | Search: {search_text}", + _HELP_FOOTER_TEXT, style=footer_style, - no_wrap=True, - overflow="ellipsis", ), ) diff --git a/src/org/commands/board/styles/app.tcss b/src/org/commands/board/styles/app.tcss index 1a97471..937322c 100644 --- a/src/org/commands/board/styles/app.tcss +++ b/src/org/commands/board/styles/app.tcss @@ -7,6 +7,7 @@ Screen { } #board-footer, +#board-footer-rule, #board-status { height: auto; } diff --git a/src/org/commands/runtime.py b/src/org/commands/runtime.py index dba3c74..3f9448d 100644 --- a/src/org/commands/runtime.py +++ b/src/org/commands/runtime.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, cast +from rich.table import Table from rich.text import Text from textual import events as textual_events from textual.app import App, ComposeResult, SuspendNotSupported @@ -29,6 +30,18 @@ def _help_body_text(entries: list[InteractiveHelpEntry]) -> Text: return Text.from_markup("\n".join(body_lines)) +def footer_renderable(left_text: str, right_text: str, *, style: str) -> Table: + """Build one footer line with a right-aligned help hint.""" + footer_line = Table.grid(expand=True) + footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") + footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") + footer_line.add_row( + Text(left_text, style=style, no_wrap=True, overflow="ellipsis"), + Text(right_text, style=style, no_wrap=True, overflow="ellipsis"), + ) + return footer_line + + class HelpModalScreen(ModalScreen[None]): """Generic modal screen that renders command key bindings.""" diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py index fc89156..53be262 100644 --- a/src/org/commands/tasks/list/app.py +++ b/src/org/commands/tasks/list/app.py @@ -4,7 +4,8 @@ from typing import TYPE_CHECKING -from rich.console import Group +from rich.console import Group, RenderableType +from rich.rule import Rule from rich.text import Text from textual.binding import Binding, BindingType from textual.containers import Vertical @@ -78,6 +79,7 @@ def compose(self) -> ComposeResult: """Build the main app layout.""" yield Vertical( Static(id="tasks-body"), + Static(id="tasks-footer-rule"), Static(id="tasks-footer"), Static(id="tasks-status"), ) @@ -106,6 +108,9 @@ def _body_widget(self) -> Static: def _footer_widget(self) -> Static: return self.query_one("#tasks-footer", Static) + def _footer_rule_widget(self) -> Static: + return self.query_one("#tasks-footer-rule", Static) + def _status_widget(self) -> Static: return self.query_one("#tasks-status", Static) @@ -157,16 +162,15 @@ def _body_renderable(self) -> Group: rows.extend(Text("") for _ in range(viewport_height - len(window))) return Group(*rows) - def _footer_renderable(self) -> Text: + def _footer_renderable(self) -> RenderableType: selected_row = self.session.selected_index + 1 if self.session.visible_nodes else 0 total_rows = len(self.session.visible_nodes) search_text = self.session.search_text or "-" footer_style = "dim" if self.session.color_enabled else "" - return Text( - f"Rows {selected_row}/{total_rows} | Search: {search_text} | {_HELP_FOOTER_TEXT}", + return runtime.footer_renderable( + f"Rows {selected_row}/{total_rows} | Search: {search_text}", + _HELP_FOOTER_TEXT, style=footer_style, - no_wrap=True, - overflow="ellipsis", ) def _status_renderable(self) -> Text: @@ -179,7 +183,9 @@ def _status_renderable(self) -> Text: ) def _refresh_view(self) -> None: + footer_style = "dim" if self.session.color_enabled else "" self._body_widget().update(self._body_renderable()) + self._footer_rule_widget().update(Rule(style=footer_style)) self._footer_widget().update(self._footer_renderable()) self._status_widget().update(self._status_renderable()) diff --git a/src/org/commands/tasks/list/styles/app.tcss b/src/org/commands/tasks/list/styles/app.tcss index 1e3f41e..f3a371a 100644 --- a/src/org/commands/tasks/list/styles/app.tcss +++ b/src/org/commands/tasks/list/styles/app.tcss @@ -7,6 +7,7 @@ Screen { } #tasks-footer, +#tasks-footer-rule, #tasks-status { height: auto; } diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py index 89b0cf0..72d1091 100644 --- a/tests/commands/tasks/test_tasks_list_app.py +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -158,6 +158,19 @@ async def _run() -> None: asyncio.run(_run()) +def test_tasks_list_app_renders_footer_separator_widgets() -> None: + """Tasks list app should mount rule widgets between body, footer, and status.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test(): + assert app.screen.query_one("#tasks-footer-rule", Static) is not None + + asyncio.run(_run()) + + def test_run_tasks_list_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: """Interactive tasks list path should delegate to the Textual app runner.""" nodes = node_from_org("* TODO Alpha\n") diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index f09c407..4c14369 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -133,6 +133,21 @@ async def _run() -> None: asyncio.run(_run()) +def test_agenda_app_renders_separator_widgets() -> None: + """Agenda app should mount rule widgets around header, footer, and status.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + + async with app.run_test(): + assert app.screen.query_one("#agenda-header-rule", Static) is not None + assert app.screen.query_one("#agenda-footer-rule", Static) is not None + + asyncio.run(_run()) + + def test_agenda_app_pages_date_window_with_right_key() -> None: """Right-arrow paging should move the agenda start date by the current span.""" diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 6c04e22..7c7659e 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -192,6 +192,18 @@ async def _run() -> None: asyncio.run(_run()) +def test_board_app_renders_footer_separator_widgets() -> None: + """Board app should mount rule widgets between body, footer, and status.""" + + async def _run() -> None: + app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Task\n"))) + + async with app.run_test(): + assert app.screen.query_one("#board-footer-rule", Static) is not None + + asyncio.run(_run()) + + def test_board_app_enter_edits_selected_task(monkeypatch: pytest.MonkeyPatch) -> None: """Enter should trigger external-editor handling on the selected task card.""" From d9cba2fd04ad6eef3b6b1bf29041116c62fe7db5 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 7 Jun 2026 20:03:07 +0200 Subject: [PATCH 10/27] Fixed agenda/board hangups --- src/org/commands/agenda/app.py | 34 ++++++++++++++++++++++++++-------- src/org/commands/board/app.py | 34 ++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 3042f1e..ff726ea 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -53,19 +53,37 @@ def set_row(self, row_text: Text) -> None: class AgendaViewport(Vertical): """Virtualized agenda viewport backed by a reusable row widget pool.""" + def __init__( + self, + *children: Widget, + name: str | None = None, + widget_id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Track row widgets explicitly so pool updates stay synchronous.""" + super().__init__( + *children, + name=name, + id=widget_id, + classes=classes, + disabled=disabled, + ) + self._row_widgets: list[AgendaRowWidget] = [] + def ensure_row_pool(self, count: int) -> None: """Grow or shrink the rendered row pool to match the viewport height.""" - widgets = self.row_widgets() - while len(widgets) < count: - self.mount(AgendaRowWidget(classes="agenda-row")) - widgets = self.row_widgets() - while len(widgets) > count: - widgets[-1].remove() - widgets = self.row_widgets() + missing = count - len(self._row_widgets) + for _ in range(max(0, missing)): + widget = AgendaRowWidget(classes="agenda-row") + self._row_widgets.append(widget) + self.mount(widget) + while len(self._row_widgets) > count: + self._row_widgets.pop().remove() def row_widgets(self) -> list[AgendaRowWidget]: """Return row widgets in display order.""" - return cast("list[AgendaRowWidget]", list(self.query(AgendaRowWidget))) + return self._row_widgets class AgendaApp(runtime.CommandApp): diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index 835c8e3..5553681 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -60,19 +60,37 @@ def set_column( class BoardViewport(Horizontal): """Horizontal board viewport that owns one widget per visible column.""" + def __init__( + self, + *children: Widget, + name: str | None = None, + widget_id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Track column widgets explicitly so pool updates stay synchronous.""" + super().__init__( + *children, + name=name, + id=widget_id, + classes=classes, + disabled=disabled, + ) + self._column_widgets: list[BoardColumnWidget] = [] + def ensure_column_pool(self, count: int) -> None: """Grow or shrink the rendered column pool to match the board state.""" - widgets = self.column_widgets() - while len(widgets) < count: - self.mount(BoardColumnWidget(classes="board-column")) - widgets = self.column_widgets() - while len(widgets) > count: - widgets[-1].remove() - widgets = self.column_widgets() + missing = count - len(self._column_widgets) + for _ in range(max(0, missing)): + widget = BoardColumnWidget(classes="board-column") + self._column_widgets.append(widget) + self.mount(widget) + while len(self._column_widgets) > count: + self._column_widgets.pop().remove() def column_widgets(self) -> list[BoardColumnWidget]: """Return board column widgets in display order.""" - return cast("list[BoardColumnWidget]", list(self.query(BoardColumnWidget))) + return self._column_widgets class BoardApp(runtime.CommandApp): From 5c2b03c40a02af8d0e31beb0de9444654df26412 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 7 Jun 2026 23:16:59 +0200 Subject: [PATCH 11/27] Made the test suite a bit faster. --- tests/cli/test_cli_smoke.py | 82 -------------------- tests/commands/tasks/test_tasks_list_app.py | 85 ++++++--------------- tests/commands/test_agenda_app.py | 77 +++++-------------- tests/commands/test_board_app.py | 85 ++++++--------------- 4 files changed, 66 insertions(+), 263 deletions(-) delete mode 100644 tests/cli/test_cli_smoke.py diff --git a/tests/cli/test_cli_smoke.py b/tests/cli/test_cli_smoke.py deleted file mode 100644 index bb00187..0000000 --- a/tests/cli/test_cli_smoke.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Subprocess smoke tests for the CLI entrypoint.""" - -from __future__ import annotations - -import os -import subprocess -import sys - -from typer.testing import CliRunner - -from org.cli import app - - -PROJECT_ROOT = os.path.join(os.path.dirname(__file__), "..", "..") -FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") - - -def test_cli_summary_smoke() -> None: - """Ensure summary command runs via python -m org.""" - fixture_path = os.path.join(FIXTURES_DIR, "single_task.org") - config_path = os.path.join(FIXTURES_DIR, "empty-config.yaml") - - result = subprocess.run( - [ - sys.executable, - "-m", - "org", - "stats", - "all", - "--config", - config_path, - "--no-color", - fixture_path, - ], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) - - assert result.returncode == 0 - assert "Total tasks:" in result.stdout - - -def test_cli_summary_multiple_files_smoke() -> None: - """Ensure multiple files are processed via CLI.""" - fixture1 = os.path.join(FIXTURES_DIR, "simple.org") - fixture2 = os.path.join(FIXTURES_DIR, "single_task.org") - config_path = os.path.join(FIXTURES_DIR, "empty-config.yaml") - - result = subprocess.run( - [ - sys.executable, - "-m", - "org", - "--verbose", - "stats", - "all", - "--config", - config_path, - "--no-color", - fixture1, - fixture2, - ], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) - - assert result.returncode == 0 - assert result.stdout.count("Processing") == 2 - - -def test_cli_stats_summary_smoke() -> None: - """Ensure stats summary runs in-process without tag sections.""" - runner = CliRunner() - fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") - - result = runner.invoke(app, ["stats", "summary", "--no-color", fixture_path]) - - assert result.exit_code == 0 - assert "Task states:" in result.stdout - assert "TAGS" not in result.stdout diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py index 72d1091..c130f3a 100644 --- a/tests/commands/tasks/test_tasks_list_app.py +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -27,8 +27,8 @@ def _make_session_data(nodes: list[Heading]) -> tasks_list._TasksListSessionData ) -def test_tasks_list_app_moves_selection_with_arrow_keys() -> None: - """Arrow-key navigation should update the selected task row.""" +def test_tasks_list_app_navigation_and_help_forwarding() -> None: + """Tasks list app should support navigation and help key forwarding.""" async def _run() -> None: nodes = node_from_org("* TODO A\n* TODO B\n") @@ -41,6 +41,25 @@ async def _run() -> None: await pilot.press("up") assert app.session.selected_index == 0 + app.action_show_help() + await pilot.pause() + + help_title = app.screen.query_one("#help-title", Static) + help_content = app.screen.query_one("#help-content", Static) + title_rendered = cast("Any", help_title).render() + rendered = cast("Any", help_content).render() + title_text = getattr(title_rendered, "plain", str(title_rendered)) + plain_text = getattr(rendered, "plain", str(rendered)) + assert title_text == "Key bindings" + assert "Key bindings:" not in plain_text + assert "Esc/q" in plain_text + + await pilot.press("down") + await pilot.pause() + + assert app.session.selected_index == 1 + assert app.screen.query_one("#tasks-body", Static) is not None + asyncio.run(_run()) @@ -72,38 +91,16 @@ async def _run() -> None: asyncio.run(_run()) -def test_tasks_list_app_help_modal_shows_key_bindings() -> None: - """Help modal should stay open and show key binding text.""" +def test_tasks_list_app_help_prompt_and_separator_widgets() -> None: + """Search prompt label and footer separators should render correctly.""" async def _run() -> None: nodes = node_from_org("* TODO Alpha\n") app = TasksListApp(make_list_args([]), _make_session_data(nodes)) async with app.run_test() as pilot: - app.action_show_help() - await pilot.pause() - - help_title = app.screen.query_one("#help-title", Static) - help_content = app.screen.query_one("#help-content", Static) - title_rendered = cast("Any", help_title).render() - rendered = cast("Any", help_content).render() - title_text = getattr(title_rendered, "plain", str(title_rendered)) - plain_text = getattr(rendered, "plain", str(rendered)) - assert title_text == "Key bindings" - assert "Key bindings:" not in plain_text - assert "Esc/q" in plain_text - - asyncio.run(_run()) - - -def test_tasks_list_app_search_prompt_shows_label() -> None: - """Search prompt should display its label when opened.""" - - async def _run() -> None: - nodes = node_from_org("* TODO Alpha\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + assert app.screen.query_one("#tasks-footer-rule", Static) is not None - async with app.run_test() as pilot: app.action_prompt_search() await pilot.pause() @@ -137,40 +134,6 @@ async def _run() -> None: asyncio.run(_run()) -def test_tasks_list_app_help_modal_applies_key_to_underlying_screen() -> None: - """Help modal should close and forward the pressed key to the app.""" - - async def _run() -> None: - nodes = node_from_org("* TODO Alpha\n* TODO Beta\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) - - async with app.run_test() as pilot: - app.action_show_help() - await pilot.pause() - assert app.screen.query_one("#help-content", Static) is not None - - await pilot.press("down") - await pilot.pause() - - assert app.session.selected_index == 1 - assert app.screen.query_one("#tasks-body", Static) is not None - - asyncio.run(_run()) - - -def test_tasks_list_app_renders_footer_separator_widgets() -> None: - """Tasks list app should mount rule widgets between body, footer, and status.""" - - async def _run() -> None: - nodes = node_from_org("* TODO Alpha\n") - app = TasksListApp(make_list_args([]), _make_session_data(nodes)) - - async with app.run_test(): - assert app.screen.query_one("#tasks-footer-rule", Static) is not None - - asyncio.run(_run()) - - def test_run_tasks_list_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: """Interactive tasks list path should delegate to the Textual app runner.""" nodes = node_from_org("* TODO Alpha\n") diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index 4c14369..75f0599 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -57,8 +57,8 @@ def _body_lines(app: AgendaApp) -> list[str]: return lines -def test_agenda_app_moves_selection_with_arrow_keys() -> None: - """Arrow-key navigation should update the selected agenda row.""" +def test_agenda_app_navigation_help_and_separators() -> None: + """Agenda app should support navigation, help forwarding, and separator widgets.""" async def _run() -> None: fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") @@ -66,12 +66,30 @@ async def _run() -> None: app = _make_app(args, list(org_parser.load(fixture_path))) async with app.run_test() as pilot: + assert app.screen.query_one("#agenda-header-rule", Static) is not None + assert app.screen.query_one("#agenda-footer-rule", Static) is not None + await pilot.press("down") assert app.session.selected_row_index == 1 await pilot.press("up") assert app.session.selected_row_index == 0 + app.action_show_help() + await pilot.pause() + + assert app.screen.query_one("#help-content", Static) is not None + await pilot.press("down") + await pilot.pause() + + assert app.session.selected_row_index == 1 + assert app.screen.query_one("#agenda-body", AgendaViewport) is not None + await pilot.press("right") + await pilot.pause() + + header = cast("Any", app.query_one("#agenda-header", Static).render()) + assert getattr(header, "plain", str(header)) == "Thursday 2025-01-16" + asyncio.run(_run()) @@ -111,61 +129,6 @@ async def _run() -> None: asyncio.run(_run()) -def test_agenda_app_help_modal_forwards_key_to_app() -> None: - """Help modal should close and forward the pressed key to the agenda app.""" - - async def _run() -> None: - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - app = _make_app(args, list(org_parser.load(fixture_path))) - - async with app.run_test() as pilot: - app.action_show_help() - await pilot.pause() - - assert app.screen.query_one("#help-content", Static) is not None - await pilot.press("down") - await pilot.pause() - - assert app.session.selected_row_index == 1 - assert app.screen.query_one("#agenda-body", AgendaViewport) is not None - - asyncio.run(_run()) - - -def test_agenda_app_renders_separator_widgets() -> None: - """Agenda app should mount rule widgets around header, footer, and status.""" - - async def _run() -> None: - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15") - app = _make_app(args, list(org_parser.load(fixture_path))) - - async with app.run_test(): - assert app.screen.query_one("#agenda-header-rule", Static) is not None - assert app.screen.query_one("#agenda-footer-rule", Static) is not None - - asyncio.run(_run()) - - -def test_agenda_app_pages_date_window_with_right_key() -> None: - """Right-arrow paging should move the agenda start date by the current span.""" - - async def _run() -> None: - fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") - args = _make_args([fixture_path], date="2025-01-15", days=2) - app = _make_app(args, list(org_parser.load(fixture_path))) - - async with app.run_test() as pilot: - await pilot.press("right") - await pilot.pause() - - header = cast("Any", app.query_one("#agenda-header", Static).render()) - assert getattr(header, "plain", str(header)) == "Friday 2025-01-17" - - asyncio.run(_run()) - - def test_agenda_app_timer_refresh_updates_now_marker(monkeypatch: pytest.MonkeyPatch) -> None: """Minute refresh should update the rendered NOW marker in the viewport.""" diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 7c7659e..69d6a90 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -72,25 +72,8 @@ def _visible_board_titles_by_column(app: BoardApp) -> dict[str, list[str]]: } -def test_board_app_moves_selection_with_arrow_keys() -> None: - """Arrow-key navigation should update the selected task row.""" - - async def _run() -> None: - nodes = node_from_org("* TODO A\n* TODO B\n") - app = _make_app(_make_session(make_board_args([]), nodes)) - - async with app.run_test() as pilot: - await pilot.press("down") - assert app.session.selected_row_index == 1 - - await pilot.press("up") - assert app.session.selected_row_index == 0 - - asyncio.run(_run()) - - -def test_board_app_moves_horizontally_across_non_empty_columns() -> None: - """Left/right navigation should skip empty columns.""" +def test_board_app_navigation_help_and_separators() -> None: + """Board app should support navigation, help forwarding, and footer separators.""" async def _run() -> None: first, second = node_from_org("* TODO First\n* TODO Second\n") @@ -108,11 +91,25 @@ async def _run() -> None: app = _make_app(session) async with app.run_test() as pilot: + assert app.screen.query_one("#board-footer-rule", Static) is not None + + await pilot.press("down") + assert app.session.selected_row_index == 0 + await pilot.press("right") assert app.session.selected_column_index == 3 await pilot.press("left") assert app.session.selected_column_index == 1 + app.action_show_help() + await pilot.pause() + + assert app.screen.query_one("#help-content", Static) is not None + await pilot.press("right") + await pilot.pause() + + assert app.session.selected_column_index == 3 + assert app.screen.query_one("#board-body", BoardViewport) is not None asyncio.run(_run()) @@ -166,44 +163,6 @@ async def _run() -> None: asyncio.run(_run()) -def test_board_app_help_modal_forwards_key_to_app() -> None: - """Help modal should close and forward the pressed key to the board app.""" - - async def _run() -> None: - first, second = node_from_org("* TODO First\n* TODO Second\n") - session = _make_session( - make_board_args([]), - [first, second], - columns=[_col("TODO", [first]), _col("DONE", [second])], - ) - app = _make_app(session) - - async with app.run_test() as pilot: - app.action_show_help() - await pilot.pause() - - assert app.screen.query_one("#help-content", Static) is not None - await pilot.press("right") - await pilot.pause() - - assert app.session.selected_column_index == 1 - assert app.screen.query_one("#board-body", BoardViewport) is not None - - asyncio.run(_run()) - - -def test_board_app_renders_footer_separator_widgets() -> None: - """Board app should mount rule widgets between body, footer, and status.""" - - async def _run() -> None: - app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Task\n"))) - - async with app.run_test(): - assert app.screen.query_one("#board-footer-rule", Static) is not None - - asyncio.run(_run()) - - def test_board_app_enter_edits_selected_task(monkeypatch: pytest.MonkeyPatch) -> None: """Enter should trigger external-editor handling on the selected task card.""" @@ -281,7 +240,7 @@ async def _run() -> None: def test_board_app_shift_bindings_trigger_state_and_priority_actions( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Shifted arrow bindings should dispatch state and priority mutations.""" + """Shift actions should dispatch state and priority mutations.""" async def _run() -> None: calls: list[str] = [] @@ -297,11 +256,11 @@ async def _run() -> None: ) app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Existing\n"))) - async with app.run_test() as pilot: - await pilot.press("shift+left") - await pilot.press("shift+right") - await pilot.press("shift+up") - await pilot.press("shift+down") + async with app.run_test(): + app.action_move_state_left() + app.action_move_state_right() + app.action_increase_priority() + app.action_decrease_priority() assert calls == ["state:-1", "state:1", "priority:True", "priority:False"] From 4d03f9c2a386a3a9804bedd75fd104885463987b Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 8 Jun 2026 12:30:59 +0200 Subject: [PATCH 12/27] Refactored external editor handling. --- VIBES.org | 11 ++- src/org/commands/agenda/app.py | 3 +- src/org/commands/board/app.py | 3 +- src/org/commands/editor.py | 54 +++----------- src/org/commands/runtime.py | 10 +++ src/org/commands/tasks/list/app.py | 3 +- tests/commands/tasks/test_tasks_edit.py | 99 +++++++++++-------------- tests/commands/test_board_app.py | 6 +- 8 files changed, 78 insertions(+), 111 deletions(-) diff --git a/VIBES.org b/VIBES.org index e84af2f..afb0551 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7351,8 +7351,11 @@ Organize it the same as the other commands (most recentry modified: agenda, boar *** Avoid using =getattr= and =setattr= like the plague Refactor all places that currently use either of these functions so that the concrete type is known and concrete field is accessed. Introduce linter rules that make sure these function cannot be used. -*** Avoid monkey patching in tests -*** TODO Textual for app organization & input handling +*** Avoid monkey patching in tests :Tests: +*** DONE Textual for app organization & input handling +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-07 Sun 23:17] +:END: Refine the plan in @plans/textual-migration.md to better reflect the current state of the codebase. Focus on just the TUI organization & input handling aspects, we'll handle widgets etc later. @@ -7468,6 +7471,10 @@ This wrapper should be removed, prefer renaming functions and adjusting callsite *** Use Textual widgets for better TUI organization - Selection for prompts - Sparkline for freequency plots +*** Use Textual header & footer widgets in the interactive commands +*** Use Textual textarea for any org-mode syntax highlighting +- Limit to just the interactive commands. +- Potentially use it for code edits of the files? *** REWORKED Interactive loop refactor :LOGBOOK: - State "REWORKED" from "TODO" [2026-05-26 Tue 21:27] diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index ff726ea..1d99249 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -261,8 +261,7 @@ def _set_status(self, status_message: str) -> None: self.session.status_message = status_message def _run_external(self, callback: Callable[[], None]) -> None: - self.suspend_for_external(callback) - self._refresh_view() + self.run_external_and_refresh(callback, refresh=self._refresh_view) def _open_prompt( self, diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index 5553681..fe02168 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -261,8 +261,7 @@ def _set_status(self, status_message: str) -> None: self.session.status_message = status_message def _run_external(self, callback: Callable[[], None]) -> None: - self.suspend_for_external(callback) - self._refresh_view() + self.run_external_and_refresh(callback, refresh=self._refresh_view) def _open_prompt( self, diff --git a/src/org/commands/editor.py b/src/org/commands/editor.py index 5c40cf5..ef1d516 100644 --- a/src/org/commands/editor.py +++ b/src/org/commands/editor.py @@ -67,18 +67,6 @@ def _run_editor_at_line(filename: str, line: int) -> int: return 0 -def _confirm_temporary_file_edit(prompt: str) -> bool: - """Ask whether a temporary-file fallback edit should be opened.""" - return typer.confirm(prompt, default=False) - - -def _edit_via_temporary_file(document_text: str, prompt: str) -> str | None: - """Prompt for and run the temporary-file fallback editor flow.""" - if not _confirm_temporary_file_edit(prompt): - return None - return edit_text_in_external_editor(document_text) - - def _read_document_text(path: Path, filename: str) -> str: """Read one document file from disk.""" try: @@ -97,24 +85,18 @@ def _write_document_text(path: Path, filename: str, document_text: str) -> None: raise typer.BadParameter(f"Permission denied for '{filename}'") from err -def _edit_document_without_filename(heading: Heading) -> DocumentEditResult: - """Edit a document that does not have a backing filename.""" - original_text = heading.document.render() - edited_text = _edit_via_temporary_file( - original_text, - "This task is not associated with a file. Edit a temporary copy instead?", - ) - if edited_text is None or edited_text == original_text: - return DocumentEditResult(changed=False) - - return DocumentEditResult(changed=True) - +def attempt_edit_heading_subtree_in_external_editor( + heading: Heading, +) -> DocumentEditResult: + """Edit one file-backed task subtree in the configured external editor.""" + filename = heading.document.filename or None + if filename is None: + raise typer.BadParameter( + "This task is not associated with a file and cannot be edited.", + ) -def _edit_document_with_filename(heading: Heading, filename: str) -> DocumentEditResult: - """Edit a document backed by a filename, preferring in-place file edits.""" path = Path(filename) original_text = _read_document_text(path, filename) - return_code = _run_editor_at_line(filename, 0 if heading.line is None else heading.line) if return_code == 0: edited_text = _read_document_text(path, filename) @@ -123,23 +105,9 @@ def _edit_document_with_filename(heading: Heading, filename: str) -> DocumentEdi _load_document_from_text(edited_text, filename) return DocumentEditResult(changed=True) - fallback_text = _edit_via_temporary_file( - original_text, - "Opening the original file at the task line failed. Edit a temporary copy instead?", - ) - if fallback_text is None: - raise typer.BadParameter("Editor failed to open") - if fallback_text == original_text: - return DocumentEditResult(changed=False) - - _write_document_text(path, filename, fallback_text) - _load_document_from_text(fallback_text, filename) - return DocumentEditResult(changed=True) + raise typer.BadParameter("Editor failed to open") def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: """Edit the original task document, preferring in-place file editing at task line.""" - filename = heading.document.filename or None - if filename is None: - return _edit_document_without_filename(heading) - return _edit_document_with_filename(heading, filename) + return attempt_edit_heading_subtree_in_external_editor(heading) diff --git a/src/org/commands/runtime.py b/src/org/commands/runtime.py index 3f9448d..7a5e16f 100644 --- a/src/org/commands/runtime.py +++ b/src/org/commands/runtime.py @@ -144,3 +144,13 @@ def suspend_for_external(self, callback: Callable[[], None]) -> None: callback() else: callback() + + def run_external_and_refresh( + self, + callback: Callable[[], None], + *, + refresh: Callable[[], None], + ) -> None: + """Suspend around one external action and refresh afterwards.""" + self.suspend_for_external(callback) + refresh() diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py index 53be262..a6b474d 100644 --- a/src/org/commands/tasks/list/app.py +++ b/src/org/commands/tasks/list/app.py @@ -211,8 +211,7 @@ def _complete(result: str | None) -> None: ) def _run_external(self, callback: Callable[[], None]) -> None: - self.suspend_for_external(callback) - self._refresh_view() + self.run_external_and_refresh(callback, refresh=self._refresh_view) def action_move_up(self) -> None: """Move the selection one row upward.""" diff --git a/tests/commands/tasks/test_tasks_edit.py b/tests/commands/tasks/test_tasks_edit.py index b2e73ec..32b0e67 100644 --- a/tests/commands/tasks/test_tasks_edit.py +++ b/tests/commands/tasks/test_tasks_edit.py @@ -170,26 +170,15 @@ def test_run_tasks_edit_errors_on_non_zero_editor_exit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Edit should report a clear error when line-open fallback is declined.""" + """Edit should report a clear error when opening the file in the editor fails.""" source = tmp_path / "tasks.org" source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") - prompts: list[str] = [] - - def _confirm(prompt: str) -> bool: - prompts.append(prompt) - return False - monkeypatch.setenv("EDITOR", "sh -c 'exit 7'") - monkeypatch.setattr(editor_command, "_confirm_temporary_file_edit", _confirm) with pytest.raises(typer.BadParameter, match="Editor failed to open"): tasks_edit.run_tasks_edit(make_edit_args([str(source)])) - assert prompts == [ - "Opening the original file at the task line failed. Edit a temporary copy instead?", - ] - def test_run_tasks_edit_skips_save_when_content_is_unchanged( tmp_path: Path, @@ -246,70 +235,64 @@ def _edit_file(filename: str, line: int) -> int: assert updated_heading.title_text.strip() == "Updated" -def test_edit_heading_subtree_prompts_for_temp_file_when_document_has_no_filename( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Helper should prompt before using temp-file editing without a backing file.""" +def test_edit_heading_subtree_errors_when_document_has_no_filename() -> None: + """Helper should reject editing tasks without a backing file.""" root = org_parser.loads("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n") heading = root.heading_by_id("task-1") assert heading is not None - prompts: list[str] = [] + with pytest.raises( + typer.BadParameter, + match=r"This task is not associated with a file and cannot be edited\.", + ): + editor_command.edit_heading_subtree_in_external_editor(heading) - def _confirm(prompt: str) -> bool: - prompts.append(prompt) - return True - monkeypatch.setattr(editor_command, "_confirm_temporary_file_edit", _confirm) - monkeypatch.setattr( - editor_command, - "edit_text_in_external_editor", - lambda _text: "* TODO Updated\n:PROPERTIES:\n:ID: task-1\n:END:\n", - ) - - result = editor_command.edit_heading_subtree_in_external_editor(heading) +def test_attempt_edit_heading_subtree_errors_when_document_has_no_filename() -> None: + """Non-prompting edit helper should reject tasks without a backing file.""" + root = org_parser.loads("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n") + heading = root.heading_by_id("task-1") + assert heading is not None - assert prompts == ["This task is not associated with a file. Edit a temporary copy instead?"] - assert result.changed is True - assert heading.title_text.strip() == "Keep" + with pytest.raises( + typer.BadParameter, + match=r"This task is not associated with a file and cannot be edited\.", + ): + editor_command.attempt_edit_heading_subtree_in_external_editor(heading) -def test_edit_heading_subtree_falls_back_to_temp_file_after_line_open_failure( +def test_edit_heading_subtree_errors_after_line_open_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Helper should prompt and write back edited temp-file content after line-open failure.""" + """Helper should error when opening the file in the editor fails.""" source = tmp_path / "tasks.org" source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") root = org_parser.load(str(source)) heading = root.heading_by_id("task-1") assert heading is not None - prompts: list[str] = [] + monkeypatch.setattr(editor_command, "_run_editor_at_line", lambda _filename, _line: 7) - def _confirm(prompt: str) -> bool: - prompts.append(prompt) - return True + with pytest.raises(typer.BadParameter, match="Editor failed to open"): + editor_command.edit_heading_subtree_in_external_editor(heading) - monkeypatch.setattr(editor_command, "_run_editor_at_line", lambda _filename, _line: 7) - monkeypatch.setattr( - editor_command, - "_confirm_temporary_file_edit", - _confirm, - ) - monkeypatch.setattr( - editor_command, - "edit_text_in_external_editor", - lambda _text: "* TODO Updated\n:PROPERTIES:\n:ID: task-1\n:END:\n", - ) - result = editor_command.edit_heading_subtree_in_external_editor(heading) +def test_attempt_edit_heading_subtree_errors_after_line_open_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-prompting edit helper should error after line-open failure.""" + source = tmp_path / "tasks.org" + source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") + root = org_parser.load(str(source)) + heading = root.heading_by_id("task-1") + assert heading is not None - assert prompts == [ - "Opening the original file at the task line failed. Edit a temporary copy instead?", - ] - assert result.changed is True - assert source.read_text(encoding="utf-8").startswith("* TODO Updated\n") + monkeypatch.setattr(editor_command, "_run_editor_at_line", lambda _filename, _line: 7) + + with pytest.raises(typer.BadParameter, match="Editor failed to open"): + editor_command.attempt_edit_heading_subtree_in_external_editor(heading) def test_edit_heading_subtree_rejects_invalid_full_document_content( @@ -323,9 +306,11 @@ def test_edit_heading_subtree_rejects_invalid_full_document_content( heading = root.heading_by_id("task-1") assert heading is not None - monkeypatch.setattr(editor_command, "_run_editor_at_line", lambda _filename, _line: 7) - monkeypatch.setattr(editor_command, "_confirm_temporary_file_edit", lambda _prompt: True) - monkeypatch.setattr(editor_command, "edit_text_in_external_editor", lambda _text: "***") + def _edit_file(filename: str, _line: int) -> int: + Path(filename).write_text("***", encoding="utf-8") + return 0 + + monkeypatch.setattr(editor_command, "_run_editor_at_line", _edit_file) monkeypatch.setattr( org_parser, "loads", diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 69d6a90..34aa3ab 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -10,7 +10,6 @@ from textual.widgets import Input, Static from org import config as config_module -from org.commands import editor as editor_command from org.commands.board import command as board_command from org.commands.board import events as board_events from org.commands.board.app import BoardApp, BoardViewport @@ -170,10 +169,11 @@ async def _run() -> None: nodes = node_from_org("* TODO Task\n") monkeypatch.setattr( board_events, - "edit_heading_subtree_in_external_editor", - lambda _heading: editor_command.DocumentEditResult(changed=False), + "edit_selected_task_in_external_editor", + lambda _session: None, ) app = _make_app(_make_session(make_board_args([]), nodes)) + app.session.status_message = "No changes." async with app.run_test() as pilot: await pilot.press("enter") From 6017fc6c28f55201818cf985744a75601b4d4917 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Mon, 8 Jun 2026 12:54:09 +0200 Subject: [PATCH 13/27] Renamed events to actions and layout to ui. --- .../commands/agenda/{events.py => actions.py} | 2 +- src/org/commands/agenda/app.py | 72 +++++----- src/org/commands/agenda/command.py | 12 +- src/org/commands/agenda/{layout.py => ui.py} | 2 +- .../commands/board/{events.py => actions.py} | 0 src/org/commands/board/app.py | 64 ++++----- src/org/commands/board/command.py | 12 +- src/org/commands/board/{layout.py => ui.py} | 2 +- .../tasks/list/{events.py => actions.py} | 0 src/org/commands/tasks/list/app.py | 42 +++--- src/org/commands/tasks/list/command.py | 4 +- tests/commands/tasks/test_tasks_list.py | 60 ++++---- tests/commands/test_agenda_app.py | 23 ++- tests/commands/test_agenda_command.py | 81 ++++++----- tests/commands/test_board.py | 131 +++++++++--------- tests/commands/test_board_app.py | 26 ++-- 16 files changed, 264 insertions(+), 269 deletions(-) rename src/org/commands/agenda/{events.py => actions.py} (99%) rename src/org/commands/agenda/{layout.py => ui.py} (99%) rename src/org/commands/board/{events.py => actions.py} (100%) rename src/org/commands/board/{layout.py => ui.py} (99%) rename src/org/commands/tasks/list/{events.py => actions.py} (100%) diff --git a/src/org/commands/agenda/events.py b/src/org/commands/agenda/actions.py similarity index 99% rename from src/org/commands/agenda/events.py rename to src/org/commands/agenda/actions.py index e55ab37..89ad1ff 100644 --- a/src/org/commands/agenda/events.py +++ b/src/org/commands/agenda/actions.py @@ -36,7 +36,7 @@ todo_states_for_heading, ) -from .layout import ( +from .ui import ( AgendaColumnWidths, AgendaRow, DayRowModel, diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 1d99249..0fe2552 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -22,8 +22,8 @@ state_selection_prompt_label, ) -from . import events, layout -from .events import AgendaSession, create_agenda_session +from . import actions, ui +from .actions import AgendaSession, create_agenda_session if TYPE_CHECKING: @@ -123,7 +123,7 @@ def __init__(self, session: AgendaSession) -> None: self.session = session def compose(self) -> ComposeResult: - """Build the main agenda screen layout.""" + """Build the main agenda screen ui.""" yield Vertical( Static(id="agenda-header"), Static(id="agenda-header-rule"), @@ -139,17 +139,17 @@ def on_mount(self) -> None: self._refresh_view() def on_resize(self, _event: Resize) -> None: - """Refresh viewport layout after terminal resize.""" + """Refresh viewport ui after terminal resize.""" self._refresh_view() def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: """Match legacy wheel-up navigation behavior.""" - events.move_selection(self.session, -1) + actions.move_selection(self.session, -1) self._refresh_view() def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: """Match legacy wheel-down navigation behavior.""" - events.move_selection(self.session, 1) + actions.move_selection(self.session, 1) self._refresh_view() def _header_widget(self) -> Static: @@ -183,7 +183,7 @@ def _line_width(self) -> int: return max(40, body_width) def _refresh_header(self) -> None: - sticky = layout.sticky_day(self.session) + sticky = ui.sticky_day(self.session) self._header_widget().update( Text( sticky.strftime("%A %Y-%m-%d"), @@ -225,8 +225,8 @@ def _refresh_status(self) -> None: def _refresh_rows(self, viewport_height: int) -> None: body = self._body_widget() body.ensure_row_pool(viewport_height) - layout.sync_scroll_offset(self.session, viewport_height) - selected_location = layout.selected_row_location(self.session) + ui.sync_scroll_offset(self.session, viewport_height) + selected_location = ui.selected_row_location(self.session) line_width = self._line_width() window = self.session.interactive_rows[ self.session.scroll_offset : self.session.scroll_offset + viewport_height @@ -234,7 +234,7 @@ def _refresh_rows(self, viewport_height: int) -> None: for widget, row in zip(body.row_widgets(), window, strict=False): widget.set_row( - layout.render_viewport_row_text( + ui.render_viewport_row_text( row, self.session.render, self.session.column_widths, @@ -254,7 +254,7 @@ def _refresh_view(self) -> None: self._refresh_status() def _refresh_for_clock_tick(self) -> None: - if events.refresh_session_if_minute_changed(self.session): + if actions.refresh_session_if_minute_changed(self.session): self._refresh_view() def _set_status(self, status_message: str) -> None: @@ -286,63 +286,63 @@ def _complete(result: str | None) -> None: def action_move_up(self) -> None: """Move the selection one row upward.""" - events.move_selection(self.session, -1) + actions.move_selection(self.session, -1) self._refresh_view() def action_move_down(self) -> None: """Move the selection one row downward.""" - events.move_selection(self.session, 1) + actions.move_selection(self.session, 1) self._refresh_view() def action_page_backward(self) -> None: """Move the visible agenda window backward by the current span.""" - events.set_start_date_relative(self.session, day_delta=-self.session.days) + actions.set_start_date_relative(self.session, day_delta=-self.session.days) self._refresh_view() def action_page_forward(self) -> None: """Move the visible agenda window forward by the current span.""" - events.set_start_date_relative(self.session, day_delta=self.session.days) + actions.set_start_date_relative(self.session, day_delta=self.session.days) self._refresh_view() def action_edit_selected(self) -> None: """Open the selected task in the external editor.""" - self._run_external(lambda: events.edit_selected_task_in_external_editor(self.session)) + self._run_external(lambda: actions.edit_selected_task_in_external_editor(self.session)) def action_archive_selected(self) -> None: """Archive the selected task subtree.""" - events.archive_selected_task(self.session) + actions.archive_selected_task(self.session) self._refresh_view() def action_clear_search(self) -> None: """Clear the active search filter.""" - events.clear_search(self.session) + actions.clear_search(self.session) self._refresh_view() def action_shift_date_backward(self) -> None: """Shift the selected planning date backward by one day.""" - events.apply_shift_date(self.session, day_delta=-1) + actions.apply_shift_date(self.session, day_delta=-1) self._refresh_view() def action_shift_date_forward(self) -> None: """Shift the selected planning date forward by one day.""" - events.apply_shift_date(self.session, day_delta=1) + actions.apply_shift_date(self.session, day_delta=1) self._refresh_view() def action_shift_time_backward(self) -> None: """Shift the selected timed planning row backward by one hour.""" - events.apply_shift_time(self.session, hour_delta=-1) + actions.apply_shift_time(self.session, hour_delta=-1) self._refresh_view() def action_shift_time_forward(self) -> None: """Shift the selected timed planning row forward by one hour.""" - events.apply_shift_time(self.session, hour_delta=1) + actions.apply_shift_time(self.session, hour_delta=1) self._refresh_view() def action_show_help(self) -> None: """Open the key bindings help modal.""" self.push_screen( runtime.HelpModalScreen( - layout.AGENDA_HELP_ENTRIES, + ui.AGENDA_HELP_ENTRIES, color_enabled=self.session.render.color_enabled, ), ) @@ -353,24 +353,24 @@ def action_prompt_search(self) -> None: self._search_cancel_text = previous_text def _preview(value: str) -> None: - events.apply_search_text(self.session, value.strip()) + actions.apply_search_text(self.session, value.strip()) self._refresh_view() self._open_prompt( "Search text (blank clears)", initial_value=previous_text, on_change=_preview, - on_submit=lambda value: events.apply_search_text(self.session, value.strip()), + on_submit=lambda value: actions.apply_search_text(self.session, value.strip()), on_cancel=lambda: self._cancel_search(previous_text), ) def _cancel_search(self, previous_text: str) -> None: - events.apply_search_text(self.session, previous_text) + actions.apply_search_text(self.session, previous_text) self.session.status_message = "Search cancelled" def action_prompt_capture(self) -> None: """Prompt for a capture template and create a task.""" - status_message = events.can_activate_agenda_capture_prompt(self.session) + status_message = actions.can_activate_agenda_capture_prompt(self.session) if status_message is not None: self._set_status(status_message) self._refresh_view() @@ -387,7 +387,7 @@ def _submit(value: str) -> None: if template_name is None: self._set_status("Invalid capture template shortcut") return - self._run_external(lambda: events.apply_capture_task(self.session, template_name)) + self._run_external(lambda: actions.apply_capture_task(self.session, template_name)) self._open_prompt( capture_template_prompt_label(template_names), @@ -397,12 +397,12 @@ def _submit(value: str) -> None: def action_prompt_state(self) -> None: """Prompt for a TODO state transition.""" - status_message = events.can_activate_agenda_state_prompt(self.session) + status_message = actions.can_activate_agenda_state_prompt(self.session) if status_message is not None: self._set_status(status_message) self._refresh_view() return - states = events.state_choices_for_selected_row(self.session) + states = actions.state_choices_for_selected_row(self.session) def _submit(value: str) -> None: stripped = value.strip() @@ -413,7 +413,7 @@ def _submit(value: str) -> None: if selected_state is None: self._set_status("Invalid TODO state selection") return - events.apply_state_change_with_value(self.session, selected_state) + actions.apply_state_change_with_value(self.session, selected_state) self._open_prompt( state_selection_prompt_label(states), @@ -423,27 +423,27 @@ def _submit(value: str) -> None: def action_prompt_refile(self) -> None: """Prompt for a destination file and refile the selected task.""" - if events.selected_task_row(self.session) is None: + if actions.selected_task_row(self.session) is None: self._set_status("Action available only on task rows") self._refresh_view() return self._open_prompt( refile_prompt_label(resolve_input_paths(self.session.args.files)), - on_submit=lambda value: events.apply_refile_with_value(self.session, value.strip()), + on_submit=lambda value: actions.apply_refile_with_value(self.session, value.strip()), on_cancel=lambda: self._set_status("Refile cancelled"), ) def action_prompt_clock(self) -> None: """Prompt for a clock duration and append the clock entry.""" - if events.selected_task_row(self.session) is None: + if actions.selected_task_row(self.session) is None: self._set_status("Action available only on task rows") self._refresh_view() return self._open_prompt( clock_duration_prompt_label(), - on_submit=lambda value: events.apply_clock_entry_with_value( + on_submit=lambda value: actions.apply_clock_entry_with_value( self.session, value.strip(), ), @@ -458,7 +458,7 @@ def action_quit_app(self) -> None: def run_agenda_app( args: AgendaArgs, nodes: list[Heading], - render: layout.RenderContext, + render: ui.RenderContext, view_ctx: AgendaViewContext, ) -> None: """Run the Textual-backed interactive agenda app.""" diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 047666f..8e502be 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -13,7 +13,7 @@ from org.commands.interactive_common import interactive_help_command_text, local_now from org.tui import build_console, processing_status, setup_output -from . import layout +from . import ui from .app import run_agenda_app from .views import resolve_view_context @@ -99,7 +99,7 @@ def run_agenda(args: AgendaArgs) -> None: run_agenda_app( args, nodes, - layout.RenderContext( + ui.RenderContext( color_enabled=color_enabled, done_states=done_states, todo_states=todo_states, @@ -108,13 +108,13 @@ def run_agenda(args: AgendaArgs) -> None: ) return - layout.render_agenda( + ui.render_agenda( console, - layout.AgendaRenderInput( + ui.AgendaRenderInput( args=args, nodes=nodes, now=local_now(), - render=layout.RenderContext( + render=ui.RenderContext( color_enabled=color_enabled, done_states=done_states, todo_states=todo_states, @@ -132,7 +132,7 @@ def register(app: typer.Typer) -> None: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, help=interactive_help_command_text( "Show agenda for one day or a date range.", - layout.AGENDA_HELP_ENTRIES, + ui.AGENDA_HELP_ENTRIES, ), ) def agenda( # noqa: PLR0913 diff --git a/src/org/commands/agenda/layout.py b/src/org/commands/agenda/ui.py similarity index 99% rename from src/org/commands/agenda/layout.py rename to src/org/commands/agenda/ui.py index 70bcca2..513d0b8 100644 --- a/src/org/commands/agenda/layout.py +++ b/src/org/commands/agenda/ui.py @@ -31,8 +31,8 @@ from org_parser.document import Heading from rich.console import Console + from .actions import AgendaSession from .command import AgendaArgs - from .events import AgendaSession from .views import AgendaSectionSpec, AgendaViewContext diff --git a/src/org/commands/board/events.py b/src/org/commands/board/actions.py similarity index 100% rename from src/org/commands/board/events.py rename to src/org/commands/board/actions.py diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index fe02168..6e73333 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -19,8 +19,8 @@ resolve_capture_template_selection, ) -from . import events, layout -from .events import BoardSession, create_flow_board_session +from . import actions, ui +from .actions import BoardSession, create_flow_board_session if TYPE_CHECKING: @@ -49,7 +49,7 @@ def set_column( selected: bool, ) -> None: """Update the column title and rendered card group.""" - title_text = layout.render_column_title_text(title) + title_text = ui.render_column_title_text(title) title_text.no_wrap = True title_text.overflow = "ellipsis" if selected: @@ -124,7 +124,7 @@ def __init__(self, session: BoardSession) -> None: self._visible_end_row = 0 def compose(self) -> ComposeResult: - """Build the main board app layout.""" + """Build the main board app ui.""" yield Vertical( BoardViewport(widget_id="board-body"), Static(id="board-footer-rule"), @@ -137,17 +137,17 @@ def on_mount(self) -> None: self._refresh_view() def on_resize(self, _event: Resize) -> None: - """Refresh viewport layout after terminal resize.""" + """Refresh viewport ui after terminal resize.""" self._refresh_view() def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: """Match legacy wheel-up navigation behavior.""" - events.move_selection_vertical(self.session, -1) + actions.move_selection_vertical(self.session, -1) self._refresh_view() def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: """Match legacy wheel-down navigation behavior.""" - events.move_selection_vertical(self.session, 1) + actions.move_selection_vertical(self.session, 1) self._refresh_view() def _body_widget(self) -> BoardViewport: @@ -174,9 +174,9 @@ def _body_width(self) -> int: return max(80, self.size.width) return max(80, body_width) - def _render_config(self) -> layout.BoardPanelRenderConfig: - return layout.BoardPanelRenderConfig( - width=layout.estimate_panel_content_width( + def _render_config(self) -> ui.BoardPanelRenderConfig: + return ui.BoardPanelRenderConfig( + width=ui.estimate_panel_content_width( self._body_width(), len(self.session.columns), ), @@ -190,24 +190,24 @@ def _refresh_columns(self) -> None: body.ensure_column_pool(len(self.session.columns)) render = self._render_config() body_height = self._body_height() - start_row, end_row = layout.selected_column_window( + start_row, end_row = ui.selected_column_window( self.session, render, body_height=body_height, ) - self._visible_end_row = min(end_row, layout.selected_column_total_rows(self.session)) + self._visible_end_row = min(end_row, ui.selected_column_total_rows(self.session)) for column_index, (widget, column) in enumerate( zip(body.column_widgets(), self.session.columns, strict=False), ): - column_end = layout.column_window_end( + column_end = ui.column_window_end( column.nodes, render, start_row=start_row, body_height=body_height, ) panels = [ - layout.build_task_panel( + ui.build_task_panel( node, render, highlighted=( @@ -229,7 +229,7 @@ def _refresh_columns(self) -> None: def _refresh_footer(self) -> None: search_text = self.session.search_text or "-" - total_rows = layout.selected_column_total_rows(self.session) + total_rows = ui.selected_column_total_rows(self.session) footer_style = "dim" if self.session.color_enabled else "" self._footer_rule_widget().update(Rule(style=footer_style)) self._footer_widget().update( @@ -286,63 +286,63 @@ def _complete(result: str | None) -> None: def action_move_up(self) -> None: """Move the selection one task upward in the selected column.""" - events.move_selection_vertical(self.session, -1) + actions.move_selection_vertical(self.session, -1) self._refresh_view() def action_move_down(self) -> None: """Move the selection one task downward in the selected column.""" - events.move_selection_vertical(self.session, 1) + actions.move_selection_vertical(self.session, 1) self._refresh_view() def action_move_left(self) -> None: """Move board focus one non-empty column to the left.""" - events.move_selection_horizontal(self.session, -1) + actions.move_selection_horizontal(self.session, -1) self._refresh_view() def action_move_right(self) -> None: """Move board focus one non-empty column to the right.""" - events.move_selection_horizontal(self.session, 1) + actions.move_selection_horizontal(self.session, 1) self._refresh_view() def action_move_state_left(self) -> None: """Step the selected task state backward through document state order.""" - events.apply_state_move(self.session, direction=-1) + actions.apply_state_move(self.session, direction=-1) self._refresh_view() def action_move_state_right(self) -> None: """Step the selected task state forward through document state order.""" - events.apply_state_move(self.session, direction=1) + actions.apply_state_move(self.session, direction=1) self._refresh_view() def action_increase_priority(self) -> None: """Increase the selected task priority.""" - events.apply_priority_shift(self.session, increase=True) + actions.apply_priority_shift(self.session, increase=True) self._refresh_view() def action_decrease_priority(self) -> None: """Decrease the selected task priority.""" - events.apply_priority_shift(self.session, increase=False) + actions.apply_priority_shift(self.session, increase=False) self._refresh_view() def action_edit_selected(self) -> None: """Open the selected task in the external editor.""" - self._run_external(lambda: events.edit_selected_task_in_external_editor(self.session)) + self._run_external(lambda: actions.edit_selected_task_in_external_editor(self.session)) def action_archive_selected(self) -> None: """Archive the selected task subtree.""" - events.archive_selected_task(self.session) + actions.archive_selected_task(self.session) self._refresh_view() def action_clear_search(self) -> None: """Clear the active board search filter.""" - events.clear_search(self.session) + actions.clear_search(self.session) self._refresh_view() def action_show_help(self) -> None: """Open the key bindings help modal.""" self.push_screen( runtime.HelpModalScreen( - layout.BOARD_HELP_ENTRIES, + ui.BOARD_HELP_ENTRIES, color_enabled=self.session.color_enabled, ), ) @@ -352,24 +352,24 @@ def action_prompt_search(self) -> None: previous_text = self.session.search_text def _preview(value: str) -> None: - events.apply_search_text(self.session, value.strip()) + actions.apply_search_text(self.session, value.strip()) self._refresh_view() self._open_prompt( "Search text (blank clears)", initial_value=previous_text, on_change=_preview, - on_submit=lambda value: events.apply_search_text(self.session, value.strip()), + on_submit=lambda value: actions.apply_search_text(self.session, value.strip()), on_cancel=lambda: self._cancel_search(previous_text), ) def _cancel_search(self, previous_text: str) -> None: - events.apply_search_text(self.session, previous_text) + actions.apply_search_text(self.session, previous_text) self.session.status_message = "Search cancelled" def action_prompt_capture(self) -> None: """Prompt for a capture template and create a task.""" - status_message = events.can_activate_capture_prompt(self.session) + status_message = actions.can_activate_capture_prompt(self.session) if status_message is not None: self._set_status(status_message) self._refresh_view() @@ -387,7 +387,7 @@ def _submit(value: str) -> None: self._set_status("Invalid capture template shortcut") self.action_prompt_capture() return - self._run_external(lambda: events.apply_capture_task(self.session, template_name)) + self._run_external(lambda: actions.apply_capture_task(self.session, template_name)) self._open_prompt( capture_template_prompt_label(template_names), diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index 5323ff1..5e6d784 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -13,7 +13,7 @@ from org.commands.interactive_common import interactive_help_command_text from org.tui import build_console, processing_status, setup_output -from . import events, layout +from . import actions, ui from .app import run_board_app @@ -59,7 +59,7 @@ class BoardArgs: with_tags_as_category: bool -_BOARD_HELP_ENTRIES = layout.BOARD_HELP_ENTRIES +_BOARD_HELP_ENTRIES = ui.BOARD_HELP_ENTRIES def _resolve_tasks_limit(max_results: int | None) -> int: @@ -85,8 +85,8 @@ def run_flow_board(args: BoardArgs) -> None: with processing_status(console, color_enabled): nodes, discovered_todo_states, discovered_done_states = load_and_process_data(args) - nodes = events.filter_recent_completed_nodes(nodes, args.days) - todo_states, done_states = events.resolved_states( + nodes = actions.filter_recent_completed_nodes(nodes, args.days) + todo_states, done_states = actions.resolved_states( args, discovered_todo_states, discovered_done_states, @@ -100,8 +100,8 @@ def run_flow_board(args: BoardArgs) -> None: run_board_app(args, nodes, todo_states, done_states, color_enabled) return - columns = events.build_selector_board_columns(nodes, events.resolve_column_specs(args)) - layout.render_static_flow_board( + columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) + ui.render_static_flow_board( console, columns, done_states=done_states, diff --git a/src/org/commands/board/layout.py b/src/org/commands/board/ui.py similarity index 99% rename from src/org/commands/board/layout.py rename to src/org/commands/board/ui.py index 7e8b85b..bf5019d 100644 --- a/src/org/commands/board/layout.py +++ b/src/org/commands/board/ui.py @@ -24,7 +24,7 @@ from org_parser.document import Heading - from .events import BoardSession + from .actions import BoardSession class _BoardColumnLike(Protocol): diff --git a/src/org/commands/tasks/list/events.py b/src/org/commands/tasks/list/actions.py similarity index 100% rename from src/org/commands/tasks/list/events.py rename to src/org/commands/tasks/list/actions.py diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py index a6b474d..09f6cfd 100644 --- a/src/org/commands/tasks/list/app.py +++ b/src/org/commands/tasks/list/app.py @@ -24,8 +24,8 @@ ) from org.tui import TaskLineConfig, format_task_line -from . import events -from .events import TASKS_LIST_HELP_ENTRIES, create_tasks_list_session +from . import actions +from .actions import TASKS_LIST_HELP_ENTRIES, create_tasks_list_session if TYPE_CHECKING: @@ -94,12 +94,12 @@ def on_resize(self, _event: Resize) -> None: def on_mouse_scroll_up(self, _event: MouseScrollUp) -> None: """Match legacy wheel-up navigation behavior.""" - events.move_selection(self.session, -1) + actions.move_selection(self.session, -1) self._refresh_view() def on_mouse_scroll_down(self, _event: MouseScrollDown) -> None: """Match legacy wheel-down navigation behavior.""" - events.move_selection(self.session, 1) + actions.move_selection(self.session, 1) self._refresh_view() def _body_widget(self) -> Static: @@ -147,7 +147,7 @@ def _row_text(self, node: Heading, *, line_width: int) -> Text: def _body_renderable(self) -> Group: viewport_height = self._viewport_height() - events.ensure_selection_bounds(self.session) + actions.ensure_selection_bounds(self.session) self._sync_scroll(viewport_height) window = self.session.visible_nodes[ self.session.scroll_offset : self.session.scroll_offset + viewport_height @@ -215,36 +215,36 @@ def _run_external(self, callback: Callable[[], None]) -> None: def action_move_up(self) -> None: """Move the selection one row upward.""" - events.move_selection(self.session, -1) + actions.move_selection(self.session, -1) self._refresh_view() def action_move_down(self) -> None: """Move the selection one row downward.""" - events.move_selection(self.session, 1) + actions.move_selection(self.session, 1) self._refresh_view() def action_increase_priority(self) -> None: """Raise the selected task priority one step.""" - events.apply_priority_shift(self.session, increase=True) + actions.apply_priority_shift(self.session, increase=True) self._refresh_view() def action_decrease_priority(self) -> None: """Lower the selected task priority one step.""" - events.apply_priority_shift(self.session, increase=False) + actions.apply_priority_shift(self.session, increase=False) self._refresh_view() def action_clear_search(self) -> None: """Clear the active search filter.""" - events.clear_search(self.session) + actions.clear_search(self.session) self._refresh_view() def action_edit_selected(self) -> None: """Open the selected task in the external editor.""" - self._run_external(lambda: events.edit_selected_task_in_external_editor(self.session)) + self._run_external(lambda: actions.edit_selected_task_in_external_editor(self.session)) def action_archive_selected(self) -> None: """Archive the selected task subtree.""" - events.archive_selected_task(self.session) + actions.archive_selected_task(self.session) self._refresh_view() def action_quit_app(self) -> None: @@ -265,19 +265,19 @@ def action_prompt_search(self) -> None: previous_text = self.session.search_text def _preview(value: str) -> None: - events.apply_search_text(self.session, value.strip()) + actions.apply_search_text(self.session, value.strip()) self._refresh_view() self._open_prompt( "Search text (blank clears)", initial_value=self.session.search_text, on_change=_preview, - on_submit=lambda value: events.apply_search_text(self.session, value.strip()), + on_submit=lambda value: actions.apply_search_text(self.session, value.strip()), on_cancel=lambda: self._cancel_search(previous_text), ) def _cancel_search(self, previous_text: str) -> None: - events.apply_search_text(self.session, previous_text) + actions.apply_search_text(self.session, previous_text) self.session.status_message = "Search cancelled" def action_prompt_capture(self) -> None: @@ -297,7 +297,7 @@ def _submit(value: str) -> None: if template_name is None: self.session.status_message = "Invalid capture template shortcut" return - self._run_external(lambda: events.apply_capture_task(self.session, template_name)) + self._run_external(lambda: actions.apply_capture_task(self.session, template_name)) self._open_prompt( capture_template_prompt_label(template_names), @@ -307,12 +307,12 @@ def _submit(value: str) -> None: def action_prompt_state(self) -> None: """Prompt for a TODO state transition.""" - status_message = events.can_activate_state_prompt(self.session) + status_message = actions.can_activate_state_prompt(self.session) if status_message is not None: self.session.status_message = status_message self._refresh_view() return - states = events.state_choices_for_selected_node(self.session) + states = actions.state_choices_for_selected_node(self.session) def _submit(value: str) -> None: stripped = value.strip() @@ -323,7 +323,7 @@ def _submit(value: str) -> None: if selected_state is None: self.session.status_message = "Invalid TODO state selection" return - events.apply_state_change_with_value(self.session, selected_state) + actions.apply_state_change_with_value(self.session, selected_state) self._open_prompt( state_selection_prompt_label(states), @@ -335,7 +335,7 @@ def action_prompt_tags(self) -> None: """Prompt for replacement tag CSV on the selected task.""" self._open_prompt( tags_prompt_label(), - on_submit=lambda value: events.apply_tags_edit(self.session, value), + on_submit=lambda value: actions.apply_tags_edit(self.session, value), on_cancel=lambda: self._set_status("Tags edit cancelled"), ) @@ -345,7 +345,7 @@ def _set_status(self, status_message: str) -> None: def _prompt_planning(self, field: PlanningTimestampField) -> None: self._open_prompt( planning_prompt_label(field), - on_submit=lambda value: events.apply_planning_timestamp_edit( + on_submit=lambda value: actions.apply_planning_timestamp_edit( self.session, field=field, raw_timestamp=value, diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index 071c5bf..d6f3f30 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -37,7 +37,7 @@ setup_output, ) -from . import events +from . import actions from .app import run_tasks_list_app @@ -406,7 +406,7 @@ def register(app: typer.Typer) -> None: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, help=interactive_help_command_text( "List tasks matching filters.", - events.TASKS_LIST_HELP_ENTRIES, + actions.TASKS_LIST_HELP_ENTRIES, ), ) def tasks_list( # noqa: PLR0913 diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 61c3bad..2c61005 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -20,8 +20,8 @@ from org.commands.interactive_common import heading_locator from org.commands.search_common import filter_nodes_by_search from org.commands.tasks import capture as capture_command +from org.commands.tasks.list import actions from org.commands.tasks.list import command as tasks_list -from org.commands.tasks.list import events as tasks_list_events from org.histogram import visual_len from org.output_format import OutputFormat, OutputFormatError from tests.conftest import node_from_org @@ -98,8 +98,8 @@ def _make_session( nodes: list[Heading], *, color_enabled: bool = False, -) -> tasks_list_events.TasksListSession: - return tasks_list_events.create_tasks_list_session( +) -> actions.TasksListSession: + return actions.create_tasks_list_session( make_list_args([]), _make_session_data(nodes, color_enabled=color_enabled), ) @@ -756,9 +756,9 @@ def test_edit_selected_task_in_external_editor_sets_no_changes_status( def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: return editor_command.DocumentEditResult(changed=False) - monkeypatch.setattr(tasks_list_events, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) - tasks_list_events.edit_selected_task_in_external_editor(session) + actions.edit_selected_task_in_external_editor(session) assert session.status_message == "No changes." @@ -776,7 +776,7 @@ def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: reloaded_identity = None def _fake_reload( - current_session: tasks_list_events.TasksListSession, + current_session: actions.TasksListSession, identity: object, ) -> bool: nonlocal reloaded_identity @@ -784,10 +784,10 @@ def _fake_reload( reloaded_identity = identity return True - monkeypatch.setattr(tasks_list_events, "edit_heading_subtree_in_external_editor", _fake_edit) - monkeypatch.setattr(tasks_list_events, "reload_session_nodes", _fake_reload) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "reload_session_nodes", _fake_reload) - tasks_list_events.edit_selected_task_in_external_editor(session) + actions.edit_selected_task_in_external_editor(session) assert reloaded_identity == heading_locator(source_node) assert session.status_message == "Task updated" @@ -820,14 +820,14 @@ def _fake_archive( destination_document=heading.document, ) - monkeypatch.setattr(tasks_list_events, "archive_heading_subtree_and_save", _fake_archive) + monkeypatch.setattr(actions, "archive_heading_subtree_and_save", _fake_archive) monkeypatch.setattr( - tasks_list_events, + actions, "reload_session_nodes", lambda _session, _identity: True, ) - tasks_list_events.archive_selected_task(session) + actions.archive_selected_task(session) assert session.status_message == "Task archived" @@ -846,7 +846,7 @@ def test_apply_capture_task_captures_task_and_reloads( ) monkeypatch.setattr( - tasks_list_events, + actions, "capture_task", lambda _args: capture_command.TasksCaptureResult( template_name="quick", @@ -856,16 +856,16 @@ def test_apply_capture_task_captures_task_and_reloads( ) def _fake_reload( - current_session: tasks_list_events.TasksListSession, + current_session: actions.TasksListSession, preserve_identity: object, ) -> bool: reload_args["session"] = current_session reload_args["identity"] = preserve_identity return True - monkeypatch.setattr(tasks_list_events, "reload_session_nodes", _fake_reload) + monkeypatch.setattr(actions, "reload_session_nodes", _fake_reload) - tasks_list_events.apply_capture_task(session, "quick") + actions.apply_capture_task(session, "quick") assert reload_args["session"] is session assert reload_args["identity"] == heading_locator(captured_node) assert session.status_message == "Task captured" @@ -881,9 +881,9 @@ def test_apply_capture_task_reports_keyboard_interrupt_as_cancelled( def _raise_interrupt(_args: object) -> object: raise KeyboardInterrupt - monkeypatch.setattr(tasks_list_events, "capture_task", _raise_interrupt) + monkeypatch.setattr(actions, "capture_task", _raise_interrupt) - tasks_list_events.apply_capture_task(session, "quick") + actions.apply_capture_task(session, "quick") assert session.status_message == "Capture cancelled" @@ -897,9 +897,9 @@ def test_apply_capture_task_reports_bad_parameter_error( def _raise_bad_parameter(_args: object) -> object: raise typer.BadParameter("Invalid capture template shortcut") - monkeypatch.setattr(tasks_list_events, "capture_task", _raise_bad_parameter) + monkeypatch.setattr(actions, "capture_task", _raise_bad_parameter) - tasks_list_events.apply_capture_task(session, "quick") + actions.apply_capture_task(session, "quick") assert session.status_message == "Invalid capture template shortcut" @@ -909,12 +909,12 @@ def test_apply_state_change_appends_repeat_transition(monkeypatch: pytest.Monkey node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_state_change_with_value(session, "DONE") + actions.apply_state_change_with_value(session, "DONE") assert node.todo == "DONE" assert node.repeats @@ -928,12 +928,12 @@ def test_apply_state_change_advances_repeater_timestamp(monkeypatch: pytest.Monk node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_state_change_with_value(session, "DONE") + actions.apply_state_change_with_value(session, "DONE") assert node.scheduled is not None assert str(node.scheduled).startswith("<2025-01-22") @@ -945,12 +945,12 @@ def test_apply_scheduled_edit_updates_scheduled(monkeypatch: pytest.MonkeyPatch) node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_planning_timestamp_edit( + actions.apply_planning_timestamp_edit( session, field="scheduled", raw_timestamp="<2025-01-20 Mon>", @@ -966,12 +966,12 @@ def test_apply_closed_edit_updates_closed(monkeypatch: pytest.MonkeyPatch) -> No node = nodes[0] session = _make_session(nodes) monkeypatch.setattr( - tasks_list_events, + actions, "persist_and_reload_selected", lambda _session, _node, _status: None, ) - tasks_list_events.apply_planning_timestamp_edit( + actions.apply_planning_timestamp_edit( session, field="closed", raw_timestamp="[2025-01-20 Mon 09:00]", @@ -991,8 +991,8 @@ def test_persist_and_reload_selected_reports_save_failures( def _raise_save(_document: object) -> None: raise typer.BadParameter("Permission denied for 'dummy.org'") - monkeypatch.setattr(tasks_list_events, "save_document_changes", _raise_save) + monkeypatch.setattr(actions, "save_document_changes", _raise_save) - tasks_list_events.persist_and_reload_selected(session, nodes[0], "updated") + actions.persist_and_reload_selected(session, nodes[0], "updated") assert session.status_message == "Permission denied for 'dummy.org'" diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index 75f0599..b93b7a9 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -12,9 +12,8 @@ from textual.widgets import Input, Static from org import config as config_module +from org.commands.agenda import actions, ui from org.commands.agenda import command as agenda_command -from org.commands.agenda import events as agenda_events -from org.commands.agenda import layout as agenda_layout from org.commands.agenda.app import AgendaApp, AgendaViewport from org.commands.agenda.views import ( AgendaViewContext, @@ -41,12 +40,12 @@ def _make_app( ) -> AgendaApp: view = _fallback_agenda_view() view_ctx = AgendaViewContext(section_specs=_compile_view_section_specs(view), name=view.name) - render = agenda_layout.RenderContext( + render = ui.RenderContext( color_enabled=color_enabled, done_states=["DONE"], todo_states=["TODO"], ) - return AgendaApp(agenda_events.create_agenda_session(args, nodes, render, view_ctx)) + return AgendaApp(actions.create_agenda_session(args, nodes, render, view_ctx)) def _body_lines(app: AgendaApp) -> list[str]: @@ -137,8 +136,8 @@ async def _run() -> None: args = _make_args([fixture_path], date="2025-01-15") clock = {"value": datetime(2025, 1, 15, 10, 0)} monkeypatch.setattr(agenda_command, "local_now", lambda: clock["value"]) - monkeypatch.setattr(agenda_events, "local_now", lambda: clock["value"]) - monkeypatch.setattr(agenda_layout, "local_now", lambda: clock["value"]) + monkeypatch.setattr(actions, "local_now", lambda: clock["value"]) + monkeypatch.setattr(ui, "local_now", lambda: clock["value"]) app = _make_app(args, list(org_parser.load(fixture_path))) async with app.run_test(): @@ -193,12 +192,12 @@ def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: document=captured_node.document, ) - monkeypatch.setattr(agenda_events, "capture_task", _fake_capture) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) + monkeypatch.setattr(actions, "capture_task", _fake_capture) + monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) app = _make_app(args, list(org_parser.load(fixture_path))) - monkeypatch.setattr(agenda_events, "refresh_session", lambda _session, _identity: None) + monkeypatch.setattr(actions, "refresh_session", lambda _session, _identity: None) app.session.selected_row_index = next( index for index, (day_index, row_index) in enumerate(app.session.row_locations) @@ -207,7 +206,7 @@ def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: and ":" in app.session.day_models[day_index].rows[row_index].time_text ) ) - selected_row = agenda_events.selected_row(app.session) + selected_row = actions.selected_row(app.session) assert selected_row is not None expected_timestamp = f"<2025-01-15 Wed {selected_row.time_text}>" @@ -235,7 +234,7 @@ def test_run_agenda_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) def _fake_run( passed_args: agenda_command.AgendaArgs, nodes: list[Heading], - render: agenda_layout.RenderContext, + render: ui.RenderContext, view_ctx: AgendaViewContext, ) -> None: called["value"] = True diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index a2e02f4..5883ec6 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -13,9 +13,8 @@ from org_parser.time import Timestamp from org import config as config_module +from org.commands.agenda import actions, ui from org.commands.agenda import command as agenda_command -from org.commands.agenda import events as agenda_events -from org.commands.agenda import layout as agenda_layout from org.commands.agenda.views import ( AgendaViewContext, _compile_view_section_specs, @@ -86,7 +85,7 @@ def _make_args(files: list[str], **overrides: object) -> agenda_command.AgendaAr def _visible_agenda_task_titles(session: object) -> list[str]: """Return visible task titles from current interactive agenda rows.""" titles: list[str] = [] - typed_session = cast("agenda_events.AgendaSession", session) + typed_session = cast("actions.AgendaSession", session) for day_model in typed_session.day_models: titles.extend( row.node.title_text.strip() @@ -103,23 +102,23 @@ def _make_session( done_states: list[str] | None = None, todo_states: list[str] | None = None, color_enabled: bool = False, -) -> agenda_events.AgendaSession: +) -> actions.AgendaSession: view = _fallback_agenda_view() view_ctx = AgendaViewContext(section_specs=_compile_view_section_specs(view), name=view.name) - render = agenda_layout.RenderContext( + render = ui.RenderContext( color_enabled=color_enabled, done_states=["DONE"] if done_states is None else done_states, todo_states=["TODO"] if todo_states is None else todo_states, ) - return agenda_events.create_agenda_session(args, nodes, render, view_ctx) + return actions.create_agenda_session(args, nodes, render, view_ctx) def _pin_agenda_now(monkeypatch: pytest.MonkeyPatch) -> datetime: """Pin agenda command current time for deterministic date-sensitive tests.""" pinned_now = datetime(2025, 1, 15, 12, 0) monkeypatch.setattr(agenda_command, "local_now", lambda: pinned_now) - monkeypatch.setattr(agenda_events, "local_now", lambda: pinned_now) - monkeypatch.setattr(agenda_layout, "local_now", lambda: pinned_now) + monkeypatch.setattr(actions, "local_now", lambda: pinned_now) + monkeypatch.setattr(ui, "local_now", lambda: pinned_now) return pinned_now @@ -550,7 +549,7 @@ def test_build_view_day_model_plain_rows_choose_matching_planning_source() -> No ), ], ) - day_model = agenda_layout.build_view_day_model( + day_model = ui.build_view_day_model( nodes, datetime(2025, 1, 15).date(), datetime(2025, 1, 15, 12, 0), @@ -636,7 +635,7 @@ def test_build_view_day_model_timeline_rows_append_untimed_with_deadline_precede ), ], ) - day_model = agenda_layout.build_view_day_model( + day_model = ui.build_view_day_model( nodes, datetime(2025, 1, 15).date(), datetime(2025, 1, 15, 12, 0), @@ -865,9 +864,9 @@ def test_interactive_selection_can_land_on_hour_row_and_block_task_actions() -> ) session.selected_row_index = hour_index - assert agenda_events.selected_task_row(session) is None + assert actions.selected_task_row(session) is None - agenda_events.apply_shift_date(session, day_delta=1) + actions.apply_shift_date(session, day_delta=1) assert session.status_message == "Action available only on task rows" @@ -899,16 +898,16 @@ def test_apply_state_change_uses_current_action_time( session.now = datetime(2025, 1, 15, 16, 30) action_now = datetime(2025, 1, 15, 17, 4, 33) monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: action_now) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "local_now", lambda: action_now) + monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) + monkeypatch.setattr(actions, "local_now", lambda: action_now) monkeypatch.setattr( - agenda_events, + actions, "refresh_session", lambda _session, _preserve_identity: None, ) - agenda_events.apply_state_change_with_value(session, "DONE") + actions.apply_state_change_with_value(session, "DONE") assert heading.todo == "DONE" assert heading.repeats @@ -933,16 +932,16 @@ def test_apply_clock_entry_uses_current_action_time( session.now = datetime(2025, 1, 15, 16, 30) action_now = datetime(2025, 1, 15, 17, 4, 33) monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: action_now) - monkeypatch.setattr(agenda_events, "_save_document_changes", lambda _document: None) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) - monkeypatch.setattr(agenda_events, "local_now", lambda: action_now) + monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) + monkeypatch.setattr(actions, "local_now", lambda: action_now) monkeypatch.setattr( - agenda_events, + actions, "refresh_session", lambda _session, _preserve_identity: None, ) - agenda_events.apply_clock_entry_with_value(session, "30") + actions.apply_clock_entry_with_value(session, "30") assert heading.clock_entries timestamp = heading.clock_entries[-1].timestamp @@ -970,7 +969,7 @@ def test_apply_refile_rejects_same_file_with_equivalent_path( ) destination_alias = os.path.join(tmp_path, ".", "agenda_refile_same.org") - agenda_events.apply_refile_with_value(session, destination_alias) + actions.apply_refile_with_value(session, destination_alias) assert session.status_message == "Task already in destination file" with open(fixture_path, encoding="utf-8") as handle: @@ -1002,9 +1001,9 @@ def test_apply_refile_preserves_moved_task_locator( heading = root.heading_by_id("task-1") assert heading is not None monkeypatch.setattr( - agenda_events, + actions, "selected_task_row", - lambda _session: agenda_layout.AgendaRow( + lambda _session: ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, @@ -1015,19 +1014,19 @@ def test_apply_refile_preserves_moved_task_locator( saved_documents: list[Document] = [] refreshed: dict[str, object] = {} - monkeypatch.setattr(agenda_events, "_save_document_changes", saved_documents.append) - monkeypatch.setattr(agenda_events, "_reload_session_nodes", lambda _session: None) + monkeypatch.setattr(actions, "_save_document_changes", saved_documents.append) + monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) def _capture_refresh( - current_session: agenda_events.AgendaSession, + current_session: actions.AgendaSession, preserve_identity: object, ) -> None: refreshed["session"] = current_session refreshed["identity"] = preserve_identity - monkeypatch.setattr(agenda_events, "refresh_session", _capture_refresh) + monkeypatch.setattr(actions, "refresh_session", _capture_refresh) - agenda_events.apply_refile_with_value(session, destination_path) + actions.apply_refile_with_value(session, destination_path) assert refreshed["session"] is session assert refreshed["identity"] == heading_locator(heading) @@ -1051,14 +1050,14 @@ def test_shift_planning_time_for_row_shifts_timed_scheduled_by_one_hour() -> Non """Shifting timed scheduled rows by hour should mutate scheduled timestamp time.""" root = org_parser.loads("* TODO X\nSCHEDULED: <2025-01-15 Wed 10:30>\n") heading = next(iter(root)) - row = agenda_layout.AgendaRow( + row = ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, source="scheduled", ) - timestamp, status = agenda_events.shift_planning_time_for_row(row, hour_delta=1) + timestamp, status = actions.shift_planning_time_for_row(row, hour_delta=1) assert timestamp is not None assert status == "Shifted scheduled forward by 1 hour" assert str(timestamp).startswith("<2025-01-15 Wed 11:30") @@ -1068,14 +1067,14 @@ def test_shift_planning_time_for_row_shifts_timed_deadline_by_one_hour() -> None """Shifting timed deadline rows by hour should mutate deadline timestamp time.""" root = org_parser.loads("* TODO X\nDEADLINE: <2025-01-15 Wed 10:30>\n") heading = next(iter(root)) - row = agenda_layout.AgendaRow( + row = ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, source="deadline", ) - timestamp, status = agenda_events.shift_planning_time_for_row(row, hour_delta=1) + timestamp, status = actions.shift_planning_time_for_row(row, hour_delta=1) assert timestamp is not None assert status == "Shifted deadline forward by 1 hour" assert str(timestamp).startswith("<2025-01-15 Wed 11:30") @@ -1085,14 +1084,14 @@ def test_shift_planning_time_for_row_rejects_non_timed_rows() -> None: """Hour shifting should reject non-timed or non-planning row sources.""" root = org_parser.loads("* TODO X\nSCHEDULED: <2025-01-15 Wed>\n") heading = next(iter(root)) - row = agenda_layout.AgendaRow( + row = ui.AgendaRow( kind="task", day=datetime(2025, 1, 15).date(), node=heading, source="scheduled_untimed", ) - timestamp, status = agenda_events.shift_planning_time_for_row(row, hour_delta=-1) + timestamp, status = actions.shift_planning_time_for_row(row, hour_delta=-1) assert timestamp is None assert status == "Time shifting is available only for timed scheduled/deadline rows" @@ -1108,19 +1107,19 @@ def test_build_timeline_section_rows_preserves_timed_entry_sources() -> None: assert root is not None scheduled, deadline, repeated = list(root) day = datetime(2025, 1, 15).date() - entries = agenda_layout._ViewTimelineEntries( + entries = ui._ViewTimelineEntries( timed=[ - agenda_layout._TimedEntry( + ui._TimedEntry( node=scheduled, when=datetime(2025, 1, 15, 9, 0), kind="scheduled", ), - agenda_layout._TimedEntry( + ui._TimedEntry( node=deadline, when=datetime(2025, 1, 15, 10, 0), kind="deadline", ), - agenda_layout._TimedEntry( + ui._TimedEntry( node=repeated, when=datetime(2025, 1, 15, 11, 0), kind="repeat", @@ -1129,7 +1128,7 @@ def test_build_timeline_section_rows_preserves_timed_entry_sources() -> None: untimed=[], ) - rows = agenda_layout._build_timeline_section_rows( + rows = ui._build_timeline_section_rows( day, datetime(2025, 1, 15, 12, 0), entries, diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index 05d43c2..fb3fdcd 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -17,9 +17,8 @@ from org import config as config_module from org.commands import archive as archive_command from org.commands import editor as editor_command +from org.commands.board import actions, ui from org.commands.board import command as board_command -from org.commands.board import events as board_events -from org.commands.board import layout as board_layout from org.commands.interactive_common import heading_locator from tests.conftest import node_from_org @@ -76,17 +75,17 @@ def make_board_args(files: list[str], **overrides: object) -> board_command.Boar def _visible_board_titles_by_column( - session: board_events.BoardSession, + session: actions.BoardSession, ) -> dict[str, list[str]]: """Return visible task titles grouped by board column title.""" return {column.title: [node.title_text for node in column.nodes] for column in session.columns} -def _col(title: str, nodes: list[Heading]) -> board_events.BoardColumn: - return board_events.BoardColumn(title, nodes) +def _col(title: str, nodes: list[Heading]) -> actions.BoardColumn: + return actions.BoardColumn(title, nodes) -def _default_columns(nodes: list[Heading]) -> list[board_events.BoardColumn]: +def _default_columns(nodes: list[Heading]) -> list[actions.BoardColumn]: return [_col("TODO", nodes)] @@ -94,16 +93,16 @@ def _make_session( args: board_command.BoardArgs, nodes: list[Heading], **overrides: object, -) -> board_events.BoardSession: +) -> actions.BoardSession: resolved_columns = cast( - "list[board_events.BoardColumn] | None", + "list[actions.BoardColumn] | None", overrides.pop("columns", None), ) resolved_all_columns = cast( - "list[board_events.BoardColumn] | None", + "list[actions.BoardColumn] | None", overrides.pop("all_columns", None), ) - session = board_events.BoardSession( + session = actions.BoardSession( args=args, nodes=nodes, todo_states=["TODO"], @@ -126,7 +125,7 @@ def _make_session( def _pin_board_now(monkeypatch: pytest.MonkeyPatch) -> None: """Pin board command current time for deterministic completed-task windows.""" monkeypatch.setattr( - board_events, + actions, "local_now", lambda: datetime(2026, 5, 9, 12, 0, tzinfo=UTC), ) @@ -137,7 +136,7 @@ def test_filter_recent_completed_nodes_uses_latest_timestamp_window( ) -> None: """Completed tasks should be filtered by latest_timestamp recency.""" now = datetime(2026, 5, 9, 12, 0, tzinfo=UTC) - monkeypatch.setattr(board_events, "local_now", lambda: now) + monkeypatch.setattr(actions, "local_now", lambda: now) nodes = node_from_org( "* TODO Active task\n" @@ -147,7 +146,7 @@ def test_filter_recent_completed_nodes_uses_latest_timestamp_window( "CLOSED: [2026-04-01 Wed 09:00]\n", ) - filtered = board_events.filter_recent_completed_nodes(nodes, days=7) + filtered = actions.filter_recent_completed_nodes(nodes, days=7) assert [node.title_text for node in filtered] == ["Active task", "Recent done"] @@ -157,16 +156,14 @@ def test_filter_recent_completed_nodes_respects_days_override( ) -> None: """Days override should widen completed-task retention window.""" now = datetime(2026, 5, 9, 12, 0, tzinfo=UTC) - monkeypatch.setattr(board_events, "local_now", lambda: now) + monkeypatch.setattr(actions, "local_now", lambda: now) nodes = node_from_org( "* DONE Mid-age done\nCLOSED: [2026-04-25 Sat 09:00]\n", ) - assert board_events.filter_recent_completed_nodes(nodes, days=7) == [] - assert [ - node.title_text for node in board_events.filter_recent_completed_nodes(nodes, days=30) - ] == [ + assert actions.filter_recent_completed_nodes(nodes, days=7) == [] + assert [node.title_text for node in actions.filter_recent_completed_nodes(nodes, days=30)] == [ "Mid-age done", ] @@ -273,8 +270,8 @@ def test_build_selector_board_columns_preserves_processed_order() -> None: "* TODO [#C] Low\n* TODO Middle\n* TODO [#A] High\n* TODO [#B] Medium\n", ) - specs = board_events.resolve_column_specs(make_board_args([])) - columns = board_events.build_selector_board_columns(nodes, specs) + specs = actions.resolve_column_specs(make_board_args([])) + columns = actions.build_selector_board_columns(nodes, specs) todo_column = next(column for column in columns if column.title == "TODO") assert [node.title_text for node in todo_column.nodes] == ["Low", "Middle", "High", "Medium"] @@ -296,9 +293,9 @@ def test_build_selector_board_columns_with_order_by_overrides_processed_order() ], ) - columns = board_events.build_selector_board_columns( + columns = actions.build_selector_board_columns( nodes, - board_events.compile_view_column_specs(view), + actions.compile_view_column_specs(view), ) todo_column = next(column for column in columns if column.title == "TODO") @@ -316,9 +313,9 @@ def test_build_selector_board_columns_allows_matches_in_multiple_columns() -> No ], ) - columns = board_events.build_selector_board_columns( + columns = actions.build_selector_board_columns( nodes, - board_events.compile_view_column_specs(view), + actions.compile_view_column_specs(view), ) any_todo_column = next(column for column in columns if column.title == "Any todo") @@ -337,9 +334,9 @@ def test_build_selector_board_columns_omits_non_matching_tasks() -> None: ], ) - columns = board_events.build_selector_board_columns( + columns = actions.build_selector_board_columns( nodes, - board_events.compile_view_column_specs(view), + actions.compile_view_column_specs(view), ) assert len(columns) == 1 @@ -349,7 +346,7 @@ def test_build_selector_board_columns_omits_non_matching_tasks() -> None: def test_render_column_title_text_allows_rich_markup() -> None: """Column titles should support Rich markup syntax.""" - title = board_layout.render_column_title_text("[bold green]Complete[/]") + title = ui.render_column_title_text("[bold green]Complete[/]") assert title.plain == "Complete" assert title.spans @@ -357,7 +354,7 @@ def test_render_column_title_text_allows_rich_markup() -> None: def test_render_column_title_text_falls_back_on_invalid_markup() -> None: """Invalid markup in column title should render as literal text.""" - title = board_layout.render_column_title_text("[bold") + title = ui.render_column_title_text("[bold") assert title.plain == "[bold" @@ -384,10 +381,10 @@ def test_move_selection_horizontal_skips_empty_columns() -> None: ) assert session.selected_column_index == 1 - board_events.move_selection_horizontal(session, 1) + actions.move_selection_horizontal(session, 1) assert session.selected_column_index == 3 - board_events.move_selection_horizontal(session, -1) + actions.move_selection_horizontal(session, -1) assert session.selected_column_index == 1 @@ -395,7 +392,7 @@ def test_step_heading_state_right_from_none_uses_first_document_state() -> None: """Shift-right from empty state should use first all_states item.""" heading = node_from_org("#+TODO: TODO WAITING | DONE\n* Task\n")[0] - new_state, status = board_events.step_heading_state(heading, direction=1) + new_state, status = actions.step_heading_state(heading, direction=1) assert new_state == "TODO" assert status is None @@ -405,7 +402,7 @@ def test_step_heading_state_left_from_first_moves_to_none() -> None: """Shift-left from first state should clear todo state.""" heading = node_from_org("#+TODO: TODO WAITING | DONE\n* TODO Task\n")[0] - new_state, status = board_events.step_heading_state(heading, direction=-1) + new_state, status = actions.step_heading_state(heading, direction=-1) assert new_state is None assert status is None @@ -415,8 +412,8 @@ def test_step_heading_state_moves_prev_next_in_middle() -> None: """Shift-left/right should move through middle states.""" heading = node_from_org("#+TODO: TODO WAITING | DONE\n* WAITING Task\n")[0] - next_state, next_status = board_events.step_heading_state(heading, direction=1) - prev_state, prev_status = board_events.step_heading_state(heading, direction=-1) + next_state, next_status = actions.step_heading_state(heading, direction=1) + prev_state, prev_status = actions.step_heading_state(heading, direction=-1) assert next_state == "DONE" assert next_status is None @@ -429,8 +426,8 @@ def test_step_heading_state_boundary_no_ops() -> None: empty_heading = node_from_org("#+TODO: TODO | DONE\n* Task\n")[0] done_heading = node_from_org("#+TODO: TODO | DONE\n* DONE Task\n")[0] - no_state, no_state_status = board_events.step_heading_state(empty_heading, direction=-1) - last_state, last_state_status = board_events.step_heading_state(done_heading, direction=1) + no_state, no_state_status = actions.step_heading_state(empty_heading, direction=-1) + last_state, last_state_status = actions.step_heading_state(done_heading, direction=1) assert no_state is None assert no_state_status == "State unchanged" @@ -442,7 +439,7 @@ def test_step_heading_state_deduplicates_document_states() -> None: """State stepping should deduplicate repeated all_states values.""" heading = node_from_org("#+TODO: TODO TODO WAITING | DONE DONE\n* TODO Task\n")[0] - new_state, status = board_events.step_heading_state(heading, direction=1) + new_state, status = actions.step_heading_state(heading, direction=1) assert new_state == "WAITING" assert status is None @@ -471,14 +468,14 @@ def test_apply_state_move_steps_state_and_reloads( def _capture_save(document: Document) -> None: saved_documents.append(document) - monkeypatch.setattr(board_events, "_save_document_changes", _capture_save) + monkeypatch.setattr(actions, "_save_document_changes", _capture_save) monkeypatch.setattr( - board_events, + actions, "reload_session", lambda _session, preserve_identity: reloaded.append(preserve_identity), ) - board_events.apply_state_move(session, direction=1) + actions.apply_state_move(session, direction=1) assert node.todo == "DONE" assert saved_documents == [node.document] @@ -495,9 +492,9 @@ def test_reload_session_keeps_same_task_selected_after_priority_reshuffle( session = _make_session( args=args, nodes=original_nodes, - columns=board_events.build_selector_board_columns( + columns=actions.build_selector_board_columns( original_nodes, - board_events.resolve_column_specs(args), + actions.resolve_column_specs(args), ), color_enabled=False, selected_column_index=1, @@ -513,14 +510,14 @@ def test_reload_session_keeps_same_task_selected_after_priority_reshuffle( reloaded_nodes = node_from_org("* TODO [#A] Other\n* TODO [#A] Focus\n") monkeypatch.setattr( - board_events, + actions, "load_and_process_data", lambda _args: (reloaded_nodes, ["TODO"], ["DONE"]), ) - board_events.reload_session(session, preserve_identity) + actions.reload_session(session, preserve_identity) - selected = board_events.selected_node(session) + selected = actions.selected_node(session) assert selected is not None assert selected.title_text == "Focus" @@ -549,9 +546,9 @@ def test_edit_selected_task_in_external_editor_reports_no_changes( def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: return editor_command.DocumentEditResult(changed=False) - monkeypatch.setattr(board_events, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) - board_events.edit_selected_task_in_external_editor(session) + actions.edit_selected_task_in_external_editor(session) assert session.status_message == "No changes." @@ -582,14 +579,14 @@ def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: reloaded_identity = None - def _capture_reload(_session: board_events.BoardSession, identity: object) -> None: + def _capture_reload(_session: actions.BoardSession, identity: object) -> None: nonlocal reloaded_identity reloaded_identity = identity - monkeypatch.setattr(board_events, "edit_heading_subtree_in_external_editor", _fake_edit) - monkeypatch.setattr(board_events, "reload_session", _capture_reload) + monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) + monkeypatch.setattr(actions, "reload_session", _capture_reload) - board_events.edit_selected_task_in_external_editor(session) + actions.edit_selected_task_in_external_editor(session) assert session.status_message == "Task updated" assert reloaded_identity == heading_locator(source_node) @@ -636,10 +633,10 @@ def _fake_archive( destination_document=heading.document, ) - monkeypatch.setattr(board_events, "archive_heading_subtree_and_save", _fake_archive) - monkeypatch.setattr(board_events, "reload_session", lambda _session, _identity: None) + monkeypatch.setattr(actions, "archive_heading_subtree_and_save", _fake_archive) + monkeypatch.setattr(actions, "reload_session", lambda _session, _identity: None) - board_events.archive_selected_task(session) + actions.archive_selected_task(session) assert session.status_message == "Task archived" @@ -1021,7 +1018,7 @@ def test_run_flow_board_uses_default_view_from_config( try: # Simulate Typer default_map applying defaults: --view=kanban args.view = "kanban" - specs = board_events.resolve_column_specs(args) + specs = actions.resolve_column_specs(args) assert [spec.name for spec in specs] == ["Backlog", "Working"] monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "150"]) board_command.run_flow_board(args) @@ -1128,9 +1125,9 @@ def test_apply_state_move_reload_reassigns_task_across_selector_columns( session = _make_session( args=args, nodes=[source_node], - columns=board_events.build_selector_board_columns( + columns=actions.build_selector_board_columns( [source_node], - board_events.resolve_column_specs(args), + actions.resolve_column_specs(args), ), color_enabled=False, selected_column_index=0, @@ -1157,14 +1154,14 @@ def _load_after_change( ) return ([source_node], ["TODO"], ["DONE"]) - monkeypatch.setattr(board_events, "_save_document_changes", _capture_save) - monkeypatch.setattr(board_events, "load_and_process_data", _load_after_change) + monkeypatch.setattr(actions, "_save_document_changes", _capture_save) + monkeypatch.setattr(actions, "load_and_process_data", _load_after_change) try: - board_events.apply_state_move(session, direction=1) + actions.apply_state_move(session, direction=1) assert saved_documents == [source_node.document] assert session.selected_column_index == 1 - selected = board_events.selected_node(session) + selected = actions.selected_node(session) assert selected is not None assert selected.todo == "DONE" finally: @@ -1182,9 +1179,9 @@ def test_build_task_panel_renders_rich_title_content() -> None: ), ) - panel = board_layout.build_task_panel( + panel = ui.build_task_panel( nodes[0], - board_layout.BoardPanelRenderConfig( + ui.BoardPanelRenderConfig( width=60, color_enabled=True, done_states=["DONE"], @@ -1258,9 +1255,9 @@ def test_build_task_panel_shows_colored_todo_state_prefix() -> None: """Task panels should show and colorize current TODO state.""" node = node_from_org("#+TODO: TODO WAITING | DONE\n* TODO Task\n")[0] - panel = board_layout.build_task_panel( + panel = ui.build_task_panel( node, - board_layout.BoardPanelRenderConfig( + ui.BoardPanelRenderConfig( width=60, color_enabled=True, done_states=["DONE"], @@ -1279,9 +1276,9 @@ def test_build_task_panel_without_todo_state_has_no_prefix() -> None: """Task panels without TODO state should keep heading text unchanged.""" node = node_from_org("* Task without state\n")[0] - panel = board_layout.build_task_panel( + panel = ui.build_task_panel( node, - board_layout.BoardPanelRenderConfig( + ui.BoardPanelRenderConfig( width=60, color_enabled=True, done_states=["DONE"], diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 34aa3ab..356c289 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -10,8 +10,8 @@ from textual.widgets import Input, Static from org import config as config_module +from org.commands.board import actions from org.commands.board import command as board_command -from org.commands.board import events as board_events from org.commands.board.app import BoardApp, BoardViewport from org.commands.interactive_common import heading_locator from org.commands.tasks import capture as capture_command @@ -27,21 +27,21 @@ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") -def _col(title: str, nodes: list[Heading]) -> board_events.BoardColumn: - return board_events.BoardColumn(title, nodes) +def _col(title: str, nodes: list[Heading]) -> actions.BoardColumn: + return actions.BoardColumn(title, nodes) def _make_session( args: board_command.BoardArgs, nodes: list[Heading], **overrides: object, -) -> board_events.BoardSession: - resolved_columns = cast("list[board_events.BoardColumn] | None", overrides.pop("columns", None)) +) -> actions.BoardSession: + resolved_columns = cast("list[actions.BoardColumn] | None", overrides.pop("columns", None)) resolved_all_columns = cast( - "list[board_events.BoardColumn] | None", + "list[actions.BoardColumn] | None", overrides.pop("all_columns", None), ) - session = board_events.BoardSession( + session = actions.BoardSession( args=args, nodes=nodes, todo_states=["TODO"], @@ -61,7 +61,7 @@ def _make_session( return session -def _make_app(session: board_events.BoardSession) -> BoardApp: +def _make_app(session: actions.BoardSession) -> BoardApp: return BoardApp(session) @@ -168,7 +168,7 @@ def test_board_app_enter_edits_selected_task(monkeypatch: pytest.MonkeyPatch) -> async def _run() -> None: nodes = node_from_org("* TODO Task\n") monkeypatch.setattr( - board_events, + actions, "edit_selected_task_in_external_editor", lambda _session: None, ) @@ -195,7 +195,7 @@ async def _run() -> None: {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, ) monkeypatch.setattr( - board_events, + actions, "capture_task", lambda _args: capture_command.TasksCaptureResult( template_name="quick", @@ -204,7 +204,7 @@ async def _run() -> None: ), ) monkeypatch.setattr( - board_events, + actions, "reload_session", lambda _session, preserve_identity: reloaded.update(identity=preserve_identity), ) @@ -245,12 +245,12 @@ def test_board_app_shift_bindings_trigger_state_and_priority_actions( async def _run() -> None: calls: list[str] = [] monkeypatch.setattr( - board_events, + actions, "apply_state_move", lambda _session, *, direction: calls.append(f"state:{direction}"), ) monkeypatch.setattr( - board_events, + actions, "apply_priority_shift", lambda _session, *, increase: calls.append(f"priority:{increase}"), ) From c183765c11a81bfe5ee263aab3ce2fcdbcb6b8e6 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Wed, 10 Jun 2026 19:44:58 +0200 Subject: [PATCH 14/27] Drop "legacy" noniteractive output from interactive commands --- VIBES.org | 13 ++ docs/agenda_command.md | 4 +- docs/board_command.md | 5 +- docs/tasks_capture_command.md | 6 + docs/tasks_list_command.md | 5 +- src/org/commands/agenda/command.py | 36 ++--- src/org/commands/board/command.py | 16 +-- src/org/commands/tasks/capture/command.py | 12 +- src/org/commands/tasks/list/command.py | 42 +++--- tests/cli/test_cli_runner.py | 43 +++--- tests/commands/tasks/test_tasks_list.py | 12 +- tests/commands/test_agenda_app.py | 1 - tests/commands/test_agenda_command.py | 165 ++++++++++----------- tests/commands/test_board.py | 168 ++++++++++++---------- 14 files changed, 274 insertions(+), 254 deletions(-) diff --git a/VIBES.org b/VIBES.org index afb0551..c229747 100644 --- a/VIBES.org +++ b/VIBES.org @@ -6932,6 +6932,19 @@ Perform steps in a loop while there are any tasks available for stepping. - =--log= - file to log the transitions into - =--workflow= to specify which configured workflow to use. +*** Embed a rules-based engine into the workflow +- Each workflow step is a rule. +- Org files are the store. +- Support unification in the query language (=x as $v= and =$v as x= and =$v as $u= and =x as y= should unify the variable/value with the other value/variable). Bindings should be available in the rule body. +- Use a proper DSL for the rules: +#+begin_example +rule Init (.todo as $foo and $foo as null): + .id = .category + "-" + str([.document.all_headings[] | select(.id != null)] | length); + .todo = "TODO" +#+end_example +- Embed a Rete/datalog engine? +- Alternatively, when the store changes, reload the file, compute a delta and rerun the rules on just the affected tasks. + *** CANCELLED Flow refine command :LOGBOOK: - State "CANCELLED" from [2026-05-05 Tue 18:47] \\ diff --git a/docs/agenda_command.md b/docs/agenda_command.md index 2b057e4..dd18fb9 100644 --- a/docs/agenda_command.md +++ b/docs/agenda_command.md @@ -36,9 +36,7 @@ poetry run org agenda [OPTIONS] [FILE ...] ## Interactive mode -When both stdin and stdout are TTYs, `org agenda` runs in interactive mode. -When not running in a TTY (for example in tests, piping, or redirected output), it falls back -to non-interactive rendering. +`org agenda` runs in interactive mode and requires both stdin and stdout to be TTYs. Interactive keys: diff --git a/docs/board_command.md b/docs/board_command.md index 4ba4a6d..147ca93 100644 --- a/docs/board_command.md +++ b/docs/board_command.md @@ -47,9 +47,7 @@ When omitted, column results keep the processed order from the main board pipeli ## Interactive mode -When both stdin and stdout are TTYs, `org board` runs in interactive mode. -When not running in a TTY (for example in tests, piping, or redirected output), it falls back -to non-interactive rendering. +`org board` runs in interactive mode and requires both stdin and stdout to be TTYs. Interactive keys: @@ -136,5 +134,4 @@ poetry run org board \ ## Output - The command always renders a Rich board layout (no `--out` format selection). -- In non-interactive mode, when rendered board height exceeds the viewport, output is shown in a pager. - Empty result set prints `No results`. diff --git a/docs/tasks_capture_command.md b/docs/tasks_capture_command.md index 09e806a..033d2b2 100644 --- a/docs/tasks_capture_command.md +++ b/docs/tasks_capture_command.md @@ -73,6 +73,12 @@ capture: - `--set` values are used directly and skip prompting for those placeholders. - Placeholder matching supports whitespace: `{{ title }}` and `{{title}}` are equivalent. +Outside a TTY, capture only works when the CLI fully resolves the request up front: + +- `TEMPLATE_NAME` must be provided explicitly. +- Every non-built-in placeholder must already be resolved by `--set`, document metadata, or parent metadata. +- If template selection or placeholder prompting would be required, the command exits with an error. + ## Validation and insertion - Rendered template content is parsed with `Heading.from_source` before file mutation. diff --git a/docs/tasks_list_command.md b/docs/tasks_list_command.md index 27b72ce..04f976d 100644 --- a/docs/tasks_list_command.md +++ b/docs/tasks_list_command.md @@ -10,8 +10,9 @@ poetry run org tasks list [OPTIONS] [FILE ...] ## Interactive mode -When both stdin and stdout are TTYs, `org tasks list` runs in interactive backlog mode. -If `--details` or `--out` is explicitly passed, it stays in non-interactive static mode. +When neither `--details` nor `--out` is explicitly passed, `org tasks list` runs in interactive +backlog mode and requires both stdin and stdout to be TTYs. +If `--details` or `--out` is explicitly passed, it uses the non-interactive static/export path. The footer shows row/search context on the left and `Type ? for help` on the right. Press `?` to open a key-bindings modal panel; press any key to close it and return. diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 8e502be..8587dc0 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -6,11 +6,12 @@ import sys from dataclasses import dataclass +import click import typer from org import config as config_module from org.cli_common import load_and_process_data -from org.commands.interactive_common import interactive_help_command_text, local_now +from org.commands.interactive_common import interactive_help_command_text from org.tui import build_console, processing_status, setup_output from . import ui @@ -85,6 +86,7 @@ def run_agenda(args: AgendaArgs) -> None: raise typer.BadParameter("--days must be at least 1") args.max_results = _resolve_tasks_limit(args.max_results) + ui.resolve_agenda_start_date(args.date) view_ctx = resolve_view_context(args) @@ -95,30 +97,16 @@ def run_agenda(args: AgendaArgs) -> None: console.print("No results", markup=False) return - if sys.stdin.isatty() and sys.stdout.isatty(): - run_agenda_app( - args, - nodes, - ui.RenderContext( - color_enabled=color_enabled, - done_states=done_states, - todo_states=todo_states, - ), - view_ctx, - ) - return + if not (sys.stdin.isatty() and sys.stdout.isatty()): + raise click.UsageError("org agenda requires a TTY") - ui.render_agenda( - console, - ui.AgendaRenderInput( - args=args, - nodes=nodes, - now=local_now(), - render=ui.RenderContext( - color_enabled=color_enabled, - done_states=done_states, - todo_states=todo_states, - ), + run_agenda_app( + args, + nodes, + ui.RenderContext( + color_enabled=color_enabled, + done_states=done_states, + todo_states=todo_states, ), view_ctx, ) diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index 5e6d784..e5ffd63 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -6,6 +6,7 @@ import sys from dataclasses import dataclass +import click import typer from org import config as config_module @@ -82,6 +83,7 @@ def run_flow_board(args: BoardArgs) -> None: if args.days < 0: raise typer.BadParameter("--days must be non-negative") args.max_results = _resolve_tasks_limit(args.max_results) + actions.resolve_column_specs(args) with processing_status(console, color_enabled): nodes, discovered_todo_states, discovered_done_states = load_and_process_data(args) @@ -96,18 +98,10 @@ def run_flow_board(args: BoardArgs) -> None: console.print("No results", markup=False) return - if sys.stdin.isatty() and sys.stdout.isatty(): - run_board_app(args, nodes, todo_states, done_states, color_enabled) - return + if not (sys.stdin.isatty() and sys.stdout.isatty()): + raise click.UsageError("org board requires a TTY") - columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) - ui.render_static_flow_board( - console, - columns, - done_states=done_states, - todo_states=todo_states, - color_enabled=color_enabled, - ) + run_board_app(args, nodes, todo_states, done_states, color_enabled) def register(app: typer.Typer) -> None: diff --git a/src/org/commands/tasks/capture/command.py b/src/org/commands/tasks/capture/command.py index 424865f..a553226 100644 --- a/src/org/commands/tasks/capture/command.py +++ b/src/org/commands/tasks/capture/command.py @@ -5,6 +5,7 @@ import sys from dataclasses import replace +import click import typer from org import config as config_module @@ -37,8 +38,10 @@ def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: interactive_used = False resolved_args = args + interactive_terminal = _is_interactive_terminal() + if args.template_name is None: - if not _is_interactive_terminal(): + if not interactive_terminal: raise _missing_template_name_error() selected_name = run_template_selection_app(_template_names(templates)) if selected_name is None: @@ -57,7 +60,7 @@ def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: plan = prepare_capture_plan(resolved_args, resolved_args.template_name) if plan.unresolved_placeholders: - if not _is_interactive_terminal(): + if not interactive_terminal: raise _missing_placeholder_error(plan.unresolved_placeholders) values = run_capture_form_app(plan) if values is None: @@ -69,6 +72,11 @@ def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: def run_tasks_capture(args: TasksCaptureArgs) -> None: """Run tasks capture command using configured templates.""" + if args.template_name is None and not _is_interactive_terminal(): + raise click.UsageError( + "org tasks capture requires a TTY unless TEMPLATE_NAME and all values are provided", + ) + result = capture_task(args) if not result.interactive_used: typer.echo(result.heading.id or result.heading.title_text) diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index d6f3f30..dec8b4e 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -356,22 +356,11 @@ def _run_tasks_list_interactive( run_tasks_list_app(args, data) -def _is_cli_option_present(argv: list[str], option: str) -> bool: - """Return True when a long option token is present in argv.""" - return any(token == option or token.startswith(f"{option}=") for token in argv) - - def _is_interactive_tty() -> bool: """Return whether both stdin and stdout are attached to a TTY.""" return sys.stdin.isatty() and sys.stdout.isatty() -def _effective_noninteractive_mode(args: ListArgs) -> bool: - """Resolve whether tasks list should run in non-interactive mode.""" - out_is_org = args.out.strip().lower() == OutputFormat.ORG - return args.noninteractive or args.details or not out_is_org or not _is_interactive_tty() - - def run_tasks_list(args: ListArgs) -> None: """Run the tasks list command.""" color_enabled = setup_output(args) @@ -391,11 +380,16 @@ def run_tasks_list(args: ListArgs) -> None: color_enabled=color_enabled, ) - if not _effective_noninteractive_mode(args): - _run_tasks_list_interactive(args, session_data) + if args.noninteractive: + _run_tasks_list_static(console, args, session_data) return - _run_tasks_list_static(console, args, session_data) + if not _is_interactive_tty(): + raise click.UsageError( + "org tasks list requires a TTY unless --details or --out is provided", + ) + + _run_tasks_list_interactive(args, session_data) def register(app: typer.Typer) -> None: @@ -551,8 +545,8 @@ def tasks_list( # noqa: PLR0913 metavar="N", help="Number of results to skip before displaying", ), - details: bool = typer.Option( - False, + details: bool | None = typer.Option( + None, "--details", help="Show full org node details", ), @@ -591,8 +585,8 @@ def tasks_list( # noqa: PLR0913 "--with-tags-as-category", help="Preprocess nodes to set category from first tag", ), - out: str = typer.Option( - OutputFormat.ORG, + out: str | None = typer.Option( + None, "--out", help="Output format: org, json, or any pandoc writer format", ), @@ -609,6 +603,8 @@ def tasks_list( # noqa: PLR0913 ), ) -> None: """List tasks matching filters.""" + details_switch_present = details is not None + out_switch_present = out is not None args = ListArgs( files=files, config=config, @@ -633,7 +629,7 @@ def tasks_list( # noqa: PLR0913 color_flag=color_flag, width=width, max_results=max_results, - details=details, + details=False if details is None else details, offset=offset, order_by_level=order_by_level, order_by_file_order=order_by_file_order, @@ -642,16 +638,12 @@ def tasks_list( # noqa: PLR0913 order_by_timestamp_asc=order_by_timestamp_asc, order_by_timestamp_desc=order_by_timestamp_desc, with_tags_as_category=with_tags_as_category, - out=out, + out=OutputFormat.ORG if out is None else out, out_theme=out_theme, pandoc_args=pandoc_args, ) config_module.apply_config_defaults(args) - details_switch_present = _is_cli_option_present(sys.argv[1:], "--details") - out_switch_present = _is_cli_option_present(sys.argv[1:], "--out") - args.noninteractive = ( - details_switch_present or out_switch_present or not _is_interactive_tty() - ) + args.noninteractive = details_switch_present or out_switch_present config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks list") config_module.log_command_arguments(args, "tasks list") run_tasks_list(args) diff --git a/tests/cli/test_cli_runner.py b/tests/cli/test_cli_runner.py index 3d24ea0..cc23a84 100644 --- a/tests/cli/test_cli_runner.py +++ b/tests/cli/test_cli_runner.py @@ -208,7 +208,15 @@ def test_cli_runner_tasks_list_custom_filter_without_arg() -> None: result = runner.invoke( app, - ["tasks", "list", "--no-color", "--filter-has-todo", fixture_path], + [ + "tasks", + "list", + "--no-color", + "--out", + "org", + "--filter-has-todo", + fixture_path, + ], ) assert result.exit_code == 0 @@ -231,7 +239,16 @@ def test_cli_runner_tasks_list_custom_filter_with_arg() -> None: result = runner.invoke( app, - ["tasks", "list", "--no-color", "--filter-level-above", "0", fixture_path], + [ + "tasks", + "list", + "--no-color", + "--out", + "org", + "--filter-level-above", + "0", + fixture_path, + ], ) assert result.exit_code == 0 @@ -265,8 +282,8 @@ def test_cli_runner_tasks_list_custom_filter_required_arg_error() -> None: config.CONFIG_CUSTOM_FILTERS.update(original_filters) -def test_cli_runner_board_renders_columns() -> None: - """CLI should render board columns for root board command.""" +def test_cli_runner_board_requires_tty() -> None: + """CLI should reject non-TTY board execution.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "custom_states.org").resolve()) @@ -285,10 +302,8 @@ def test_cli_runner_board_renders_columns() -> None: ], ) - assert result.exit_code == 0 - assert "Backlog" in result.stdout - assert "TODO" in result.stdout - assert "DONE" in result.stdout + assert result.exit_code != 0 + assert "org board requires a TTY" in clean_combined_output(result) def test_cli_runner_flow_subcommand_is_not_registered() -> None: @@ -301,8 +316,8 @@ def test_cli_runner_flow_subcommand_is_not_registered() -> None: assert "No such command 'flow'" in clean_combined_output(result) -def test_cli_runner_agenda_renders_day_view() -> None: - """CLI should render agenda day sections and items.""" +def test_cli_runner_agenda_requires_tty() -> None: + """CLI should reject non-TTY agenda execution.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "agenda_sample.org").resolve()) @@ -317,12 +332,8 @@ def test_cli_runner_agenda_renders_day_view() -> None: ], ) - assert result.exit_code == 0 - plain_stdout = result.stdout.replace("…", "") - assert "CATEGORY" in plain_stdout - assert "TASK" in plain_stdout - assert "2025-01-15" in plain_stdout - assert "Timed agenda task" in plain_stdout + assert result.exit_code != 0 + assert "org agenda requires a TTY" in clean_combined_output(result) def test_cli_runner_tasks_add_writes_heading(tmp_path: Path) -> None: diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 2c61005..7c376a2 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -75,6 +75,7 @@ def make_list_args(files: list[str], **overrides: object) -> tasks_list.ListArgs out=OutputFormat.ORG, out_theme="github-dark", pandoc_args=None, + noninteractive=True, ) for key, value in overrides.items(): setattr(args, key, value) @@ -612,7 +613,7 @@ def test_run_tasks_list_uses_interactive_mode_when_tty_and_not_blocked( ) -> None: """TTY execution should enter interactive mode when no blocking switches are explicit.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") - args = make_list_args([fixture_path]) + args = make_list_args([fixture_path], noninteractive=False) called = {"interactive": False} def _fake_interactive( @@ -641,6 +642,15 @@ def _fake_interactive( assert called["interactive"] +def test_run_tasks_list_requires_tty_without_explicit_static_mode() -> None: + """Default tasks list mode should fail without a TTY.""" + fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + args = make_list_args([fixture_path], noninteractive=False) + + with pytest.raises(click.UsageError, match="requires a TTY unless --details or --out"): + tasks_list.run_tasks_list(args) + + def test_run_tasks_list_details_switch_blocks_interactive_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index b93b7a9..6e945b9 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -135,7 +135,6 @@ async def _run() -> None: fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") clock = {"value": datetime(2025, 1, 15, 10, 0)} - monkeypatch.setattr(agenda_command, "local_now", lambda: clock["value"]) monkeypatch.setattr(actions, "local_now", lambda: clock["value"]) monkeypatch.setattr(ui, "local_now", lambda: clock["value"]) app = _make_app(args, list(org_parser.load(fixture_path))) diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index 5883ec6..baa49c5 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -5,20 +5,25 @@ import os import sys from datetime import datetime, timedelta +from io import StringIO from typing import TYPE_CHECKING, cast import org_parser import pytest import typer from org_parser.time import Timestamp +from rich.console import Console from org import config as config_module +from org.cli_common import load_and_process_data +from org.commands import interactive_common from org.commands.agenda import actions, ui from org.commands.agenda import command as agenda_command from org.commands.agenda.views import ( AgendaViewContext, _compile_view_section_specs, _fallback_agenda_view, + resolve_view_context, ) from org.commands.interactive_common import ( advance_timestamp_by_repeater, @@ -27,6 +32,7 @@ local_now, ) from org.commands.tasks.common import duration_to_org_text, parse_clock_duration +from org.tui import setup_output if TYPE_CHECKING: @@ -116,16 +122,44 @@ def _make_session( def _pin_agenda_now(monkeypatch: pytest.MonkeyPatch) -> datetime: """Pin agenda command current time for deterministic date-sensitive tests.""" pinned_now = datetime(2025, 1, 15, 12, 0) - monkeypatch.setattr(agenda_command, "local_now", lambda: pinned_now) monkeypatch.setattr(actions, "local_now", lambda: pinned_now) monkeypatch.setattr(ui, "local_now", lambda: pinned_now) + monkeypatch.setattr("org.commands.interactive_common.local_now", lambda: pinned_now) return pinned_now -def test_run_agenda_renders_expected_sections( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def _render_agenda_output(args: agenda_command.AgendaArgs) -> str: + """Render agenda output through shared UI helpers for rendering tests.""" + color_enabled = setup_output(args) + args.max_results = agenda_command._resolve_tasks_limit(args.max_results) + ui.resolve_agenda_start_date(args.date) + console_output = StringIO() + console = Console( + file=console_output, + width=args.width or 140, + height=1000, + no_color=not color_enabled, + force_terminal=color_enabled, + ) + nodes, todo_states, done_states = load_and_process_data(args) + ui.render_agenda( + console, + ui.AgendaRenderInput( + args=args, + nodes=nodes, + now=interactive_common.local_now(), + render=ui.RenderContext( + color_enabled=color_enabled, + done_states=done_states, + todo_states=todo_states, + ), + ), + resolve_view_context(args), + ) + return console_output.getvalue() + + +def test_run_agenda_renders_expected_sections(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should render the current view-based agenda for the selected day.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") @@ -136,8 +170,7 @@ def test_run_agenda_renders_expected_sections( "argv", ["org", "agenda", "--date", "2025-01-15", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) plain_output = output.replace("…", "") assert "2025-01-15" in plain_output @@ -154,7 +187,6 @@ def test_run_agenda_renders_expected_sections( def test_run_agenda_no_completed_hides_completed_and_repeats( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Agenda should hide completed states and repeat completions with --no-completed.""" @@ -162,47 +194,37 @@ def test_run_agenda_no_completed_hides_completed_and_repeats( args = _make_args([fixture_path], date="2025-01-15", no_completed=True) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--no-completed"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeated completion on day" not in output assert "Completed one-off task" not in output -def test_run_agenda_no_overdue_hides_overdue_sections( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_no_overdue_hides_overdue_sections(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should hide overdue sections and rows with --no-overdue.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15", no_overdue=True) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--no-overdue"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Overdue scheduled task" not in output assert "Overdue deadline task" not in output -def test_run_agenda_no_upcoming_hides_upcoming_section( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_no_upcoming_hides_upcoming_section(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should hide upcoming deadline section with --no-upcoming.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15", no_upcoming=True) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--no-upcoming"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Upcoming deadlines (30d)" not in output assert "Upcoming deadline task" not in output def test_run_agenda_shows_future_repeated_scheduled_by_default( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -213,15 +235,13 @@ def test_run_agenda_shows_future_repeated_scheduled_by_default( args = _make_args([fixture_path], date="2025-01-17") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-17", fixture_path]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeat scheduled" in output assert "09:30" in output def test_run_agenda_shows_future_repeated_deadline_by_default( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -232,15 +252,13 @@ def test_run_agenda_shows_future_repeated_deadline_by_default( args = _make_args([fixture_path], date="2025-01-17") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-17", fixture_path]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeat deadline" in output assert "13:15" in output def test_run_agenda_no_future_repeats_hides_projected_repeats( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -255,40 +273,31 @@ def test_run_agenda_no_future_repeats_hides_projected_repeats( "argv", ["org", "agenda", "--date", "2025-01-16", "--no-future-repeats", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Repeat scheduled" not in output -def test_run_agenda_days_renders_multiple_day_headers( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_days_renders_multiple_day_headers(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should render one weekday/date header per day in the requested range.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15", days=2) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15", "--days", "2"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) plain_output = output.replace("…", "") assert "Wednesday 2025-01-15" in plain_output assert "Thursday 2025-01-16" in plain_output -def test_run_agenda_single_day_default_shows_day_header( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_single_day_default_shows_day_header(monkeypatch: pytest.MonkeyPatch) -> None: """Single-day agenda should render a weekday/date header row.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path]) monkeypatch.setattr(sys, "argv", ["org", "agenda"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) plain_output = output.replace("…", "") expected_day_header = local_now().strftime("%A %Y-%m-%d") @@ -296,10 +305,7 @@ def test_run_agenda_single_day_default_shows_day_header( assert expected_day_header in plain_output -def test_run_agenda_hides_repeat_prefix( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_hides_repeat_prefix(monkeypatch: pytest.MonkeyPatch) -> None: """Agenda should render repeated tasks without the REPEAT prefix.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") @@ -309,14 +315,12 @@ def test_run_agenda_hides_repeat_prefix( "argv", ["org", "agenda", "--date", "2025-01-15", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "REPEAT " not in output def test_run_agenda_repeat_row_uses_repeat_after_state( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -334,15 +338,13 @@ def test_run_agenda_repeat_row_uses_repeat_after_state( args = _make_args([fixture_path], date="2025-01-15") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "DONE Reopened repeat task" in output assert "TODO Reopened repeat task" not in output def test_run_agenda_excludes_completed_untimed_scheduled( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -362,15 +364,13 @@ def test_run_agenda_excludes_completed_untimed_scheduled( "argv", ["org", "agenda", "--date", "2025-01-15", "--no-completed", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Active timed task" in output assert "Completed timed task" not in output def test_run_agenda_timed_deadline_and_scheduled_tasks_appear_in_time_order( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -390,8 +390,7 @@ def test_run_agenda_timed_deadline_and_scheduled_tasks_appear_in_time_order( "argv", ["org", "agenda", "--date", "2025-01-15", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "Due today timed" in output assert "Scheduled today timed" in output @@ -399,7 +398,6 @@ def test_run_agenda_timed_deadline_and_scheduled_tasks_appear_in_time_order( def test_run_agenda_deadline_with_time_is_aligned_to_timetable( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -412,30 +410,24 @@ def test_run_agenda_deadline_with_time_is_aligned_to_timetable( args = _make_args([fixture_path], date="2025-01-15") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "09:30" in output assert "Timed due" in output -def test_run_agenda_untimed_scheduled_omits_all_day_label( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_agenda_untimed_scheduled_omits_all_day_label(monkeypatch: pytest.MonkeyPatch) -> None: """Untimed scheduled rows should not include an all-day marker.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") args = _make_args([fixture_path], date="2025-01-15") monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "all day" not in output def test_run_agenda_default_view_shows_overdue_scheduled_and_deadline_tasks( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -453,15 +445,13 @@ def test_run_agenda_default_view_shows_overdue_scheduled_and_deadline_tasks( args = _make_args([fixture_path], date=today.isoformat()) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", today.isoformat()]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "overdue sched" in output assert "overdue deadline" in output def test_run_agenda_same_day_timed_tasks_are_ordered_by_time( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -479,14 +469,12 @@ def test_run_agenda_same_day_timed_tasks_are_ordered_by_time( args = _make_args([fixture_path], date=today.isoformat()) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", today.isoformat()]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert output.index("earlier task") < output.index("later task") def test_run_agenda_plain_view_rows_show_relative_day_labels( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -522,8 +510,7 @@ def test_run_agenda_plain_view_rows_show_relative_day_labels( "argv", ["org", "agenda", "--date", "2025-01-15", "--view", "plain", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "2 days ago" in output assert "today" in output @@ -570,7 +557,6 @@ def test_build_view_day_model_plain_rows_choose_matching_planning_source() -> No def test_run_agenda_timeline_view_appends_selected_untimed_tasks( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -604,8 +590,7 @@ def test_run_agenda_timeline_view_appends_selected_untimed_tasks( "argv", ["org", "agenda", "--date", "2025-01-15", "--view", "timeline", fixture_path], ) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "09:30" in output assert "Timed task" in output @@ -658,7 +643,6 @@ def test_build_view_day_model_timeline_rows_append_untimed_with_deadline_precede def test_run_agenda_omits_inactive_planning_timestamps( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -681,8 +665,7 @@ def test_run_agenda_omits_inactive_planning_timestamps( args = _make_args([fixture_path], date=today.isoformat()) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", today.isoformat()]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out + output = _render_agenda_output(args) assert "inactive scheduled" not in output assert "inactive deadline" not in output @@ -690,7 +673,6 @@ def test_run_agenda_omits_inactive_planning_timestamps( def test_run_agenda_now_marker_renders_after_same_time_tasks( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -708,14 +690,15 @@ def test_run_agenda_now_marker_renders_after_same_time_tasks( args = _make_args([fixture_path], date="2025-01-15") local_tz = local_now().tzinfo - monkeypatch.setattr( - agenda_command, - "local_now", - lambda: datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz), - ) + + def current_now() -> datetime: + return datetime(2025, 1, 15, 17, 4, 0, tzinfo=local_tz) + + monkeypatch.setattr(actions, "local_now", current_now) + monkeypatch.setattr(ui, "local_now", current_now) + monkeypatch.setattr("org.commands.interactive_common.local_now", current_now) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) - agenda_command.run_agenda(args) - output = capsys.readouterr().out.replace("…", "") + output = _render_agenda_output(args).replace("…", "") assert output.index("Same minute task") < output.index("------ NOW ------") assert output.index("------ NOW ------") < output.index("Later task") @@ -897,7 +880,6 @@ def test_apply_state_change_uses_current_action_time( ) session.now = datetime(2025, 1, 15, 16, 30) action_now = datetime(2025, 1, 15, 17, 4, 33) - monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: action_now) monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) monkeypatch.setattr(actions, "local_now", lambda: action_now) @@ -931,7 +913,6 @@ def test_apply_clock_entry_uses_current_action_time( ) session.now = datetime(2025, 1, 15, 16, 30) action_now = datetime(2025, 1, 15, 17, 4, 33) - monkeypatch.setattr("org.commands.agenda.command.local_now", lambda: action_now) monkeypatch.setattr(actions, "_save_document_changes", lambda _document: None) monkeypatch.setattr(actions, "_reload_session_nodes", lambda _session: None) monkeypatch.setattr(actions, "local_now", lambda: action_now) diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index fb3fdcd..abb345c 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -15,11 +15,13 @@ from rich.text import Text from org import config as config_module +from org.cli_common import load_and_process_data from org.commands import archive as archive_command from org.commands import editor as editor_command from org.commands.board import actions, ui from org.commands.board import command as board_command from org.commands.interactive_common import heading_locator +from org.tui import setup_output from tests.conftest import node_from_org @@ -131,6 +133,39 @@ def _pin_board_now(monkeypatch: pytest.MonkeyPatch) -> None: ) +def _render_board_output(args: board_command.BoardArgs) -> str: + """Render board output through shared UI helpers for rendering tests.""" + color_enabled = setup_output(args) + args.max_results = board_command._resolve_tasks_limit(args.max_results) + console_output = StringIO() + console = Console( + file=console_output, + width=args.width or 120, + no_color=not color_enabled, + force_terminal=color_enabled, + ) + nodes, discovered_todo_states, discovered_done_states = load_and_process_data( + args, + ) + nodes = actions.filter_recent_completed_nodes(nodes, args.days) + todo_states, done_states = actions.resolved_states( + args, + discovered_todo_states, + discovered_done_states, + ) + if not nodes: + return "No results\n" + columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) + ui.render_static_flow_board( + console, + columns, + done_states=done_states, + todo_states=todo_states, + color_enabled=color_enabled, + ) + return console_output.getvalue() + + def test_filter_recent_completed_nodes_uses_latest_timestamp_window( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -168,10 +203,7 @@ def test_filter_recent_completed_nodes_respects_days_override( ] -def test_run_flow_board_renders_expected_columns( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_flow_board_renders_expected_columns(monkeypatch: pytest.MonkeyPatch) -> None: """Board should render built-in selector fallback columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args( @@ -195,8 +227,7 @@ def test_run_flow_board_renders_expected_columns( "150", ], ) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Backlog" in output assert "TODO" in output @@ -204,7 +235,6 @@ def test_run_flow_board_renders_expected_columns( def test_run_flow_board_column_order_follows_document_todo_order( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -226,8 +256,7 @@ def test_run_flow_board_column_order_follows_document_todo_order( width=220, ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "220"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) pos_not_started = output.find("Backlog") pos_todo = output.find("TODO") @@ -241,7 +270,6 @@ def test_run_flow_board_column_order_follows_document_todo_order( def test_run_flow_board_preserves_order_in_column_when_priorities_equal( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Board should keep stable per-column order when priorities are equal.""" @@ -254,8 +282,7 @@ def test_run_flow_board_preserves_order_in_column_when_priorities_equal( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--order-by-file-order"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) first = output.find("Refactor codebase") second = output.find("Fix bug in parser") @@ -641,7 +668,6 @@ def _fake_archive( def test_run_flow_board_does_not_hide_unknown_or_empty_states( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Unknown and empty task states should still be visible on the board.""" @@ -649,24 +675,19 @@ def test_run_flow_board_does_not_hide_unknown_or_empty_states( args = make_board_args([fixture_path], width=120) monkeypatch.setattr(sys, "argv", ["org", "board"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Task without any state" in output assert "WAITING Custom todo state" in output -def test_run_flow_board_no_results( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_flow_board_no_results(monkeypatch: pytest.MonkeyPatch) -> None: """Board should print No results when filters remove all tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], filter_tags=["nomatch$"]) monkeypatch.setattr(sys, "argv", ["org", "board", "--filter-tag", "nomatch$"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert output.strip() == "No results" @@ -681,33 +702,25 @@ def test_run_flow_board_rejects_width_below_80(monkeypatch: pytest.MonkeyPatch) board_command.run_flow_board(args) -def test_run_flow_board_limit_applies_before_grouping( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_flow_board_limit_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: """Board should respect --limit when selecting processed tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], max_results=1) monkeypatch.setattr(sys, "argv", ["org", "board", "--limit", "1"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Refactor codebase" in output assert "Fix bug in parser" not in output -def test_run_flow_board_offset_applies_before_grouping( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_flow_board_offset_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: """Board should respect --offset when selecting processed tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], max_results=1, offset=1, days=100000) monkeypatch.setattr(sys, "argv", ["org", "board", "--offset", "1", "--limit", "1"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Refactor codebase" not in output assert "Fix bug in parser" in output @@ -763,17 +776,31 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: yield monkeypatch.setattr(console, "pager", _fake_pager) - monkeypatch.setattr("org.commands.board.command.build_console", lambda _color, _width: console) args = make_board_args([fixture_path], max_results=None) - monkeypatch.setattr(sys, "argv", ["org", "board"]) - board_command.run_flow_board(args) + args.max_results = board_command._resolve_tasks_limit(args.max_results) + nodes, discovered_todo_states, discovered_done_states = load_and_process_data( + args, + ) + nodes = actions.filter_recent_completed_nodes(nodes, args.days) + todo_states, done_states = actions.resolved_states( + args, + discovered_todo_states, + discovered_done_states, + ) + columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) + ui.render_static_flow_board( + console, + columns, + done_states=done_states, + todo_states=todo_states, + color_enabled=False, + ) assert pager_called["value"] def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -797,8 +824,7 @@ def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( try: monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "160"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "First file task" in output assert "Second file task" in output finally: @@ -807,7 +833,6 @@ def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( def test_run_flow_board_coalesce_completed_true_shows_completed_column( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector view should include DONE column for completed tasks.""" @@ -821,15 +846,13 @@ def test_run_flow_board_coalesce_completed_true_shows_completed_column( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "DONE" in output assert "Completed task" in output def test_run_flow_board_coalesce_completed_true_prefixes_state_in_panel( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback DONE selector should include all completed state tasks.""" @@ -843,8 +866,7 @@ def test_run_flow_board_coalesce_completed_true_prefixes_state_in_panel( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Completed task" in output assert "Custom done state" in output @@ -852,7 +874,6 @@ def test_run_flow_board_coalesce_completed_true_prefixes_state_in_panel( def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector view should not render legacy COMPLETED header.""" @@ -866,8 +887,7 @@ def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "COMPLETED" not in output assert "DONE" in output @@ -876,7 +896,6 @@ def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo_columns( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector headers should stay in Backlog/TODO/DONE order.""" @@ -890,8 +909,7 @@ def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) pos_in_progress = output.find("TODO") pos_done = output.find("DONE") @@ -902,7 +920,6 @@ def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo def test_run_flow_board_coalesce_completed_false_tasks_in_correct_columns( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback DONE selector should show all completed-state tasks.""" @@ -916,18 +933,14 @@ def test_run_flow_board_coalesce_completed_false_tasks_in_correct_columns( ) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "200"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Completed task" in output assert "Custom done state" in output assert "Another done state" in output -def test_run_flow_board_uses_configured_view_columns( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_flow_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyPatch) -> None: """Requested configured view should render configured columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view="kanban", width=150) @@ -949,8 +962,7 @@ def test_run_flow_board_uses_configured_view_columns( try: monkeypatch.setattr(sys, "argv", ["org", "board", "--view", "kanban", "--width", "150"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Backlog" in output assert "Working" in output finally: @@ -958,7 +970,7 @@ def test_run_flow_board_uses_configured_view_columns( config_module.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_missing_requested_view_raises() -> None: +def test_run_flow_board_missing_requested_view_raises(monkeypatch: pytest.MonkeyPatch) -> None: """Missing requested view should return explicit BadParameter.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="missing") @@ -974,6 +986,8 @@ def test_run_flow_board_missing_requested_view_raises() -> None: ], ) try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="Requested board view not found"): board_command.run_flow_board(args) finally: @@ -981,13 +995,17 @@ def test_run_flow_board_missing_requested_view_raises() -> None: config_module.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_requested_view_without_configured_views_raises() -> None: +def test_run_flow_board_requested_view_without_configured_views_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Explicit --view should fail when no configured views exist.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") original_views = dict(config_module.CONFIG_BOARD_VIEWS) config_module.CONFIG_BOARD_VIEWS.clear() try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="no board views are configured"): board_command.run_flow_board(args) finally: @@ -995,10 +1013,7 @@ def test_run_flow_board_requested_view_without_configured_views_raises() -> None config_module.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_uses_default_view_from_config( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_run_flow_board_uses_default_view_from_config(monkeypatch: pytest.MonkeyPatch) -> None: """Config-defaulted view value should drive board view resolution.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view=None, width=150) @@ -1021,15 +1036,16 @@ def test_run_flow_board_uses_default_view_from_config( specs = actions.resolve_column_specs(args) assert [spec.name for spec in specs] == ["Backlog", "Working"] monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "150"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Backlog" in output finally: config_module.CONFIG_BOARD_VIEWS.clear() config_module.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context() -> None: +def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Filter/order-by parse failures should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") @@ -1042,6 +1058,8 @@ def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context() -> ], ) try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): board_command.run_flow_board(args) finally: @@ -1071,6 +1089,8 @@ def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( "load_and_process_data", lambda _args: ([node_from_org("* TODO Task\n")[0]], ["TODO"], ["DONE"]), ) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) try: with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): board_command.run_flow_board(args) @@ -1079,7 +1099,9 @@ def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( config_module.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_order_by_parse_error_has_context() -> None: +def test_run_flow_board_invalid_order_by_parse_error_has_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Invalid order-by parse should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") @@ -1096,6 +1118,8 @@ def test_run_flow_board_invalid_order_by_parse_error_has_context() -> None: ], ) try: + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises( typer.BadParameter, match=r"Invalid board filter/order-by \(view=kanban, column=Broken\)", @@ -1213,7 +1237,6 @@ def test_build_task_panel_renders_rich_title_content() -> None: def test_run_flow_board_renders_rich_title_plain_output( - capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -1227,8 +1250,7 @@ def test_run_flow_board_renders_rich_title_plain_output( args = make_board_args([fixture_path], width=120) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "120"]) - board_command.run_flow_board(args) - output = capsys.readouterr().out + output = _render_board_output(args) assert "Bold" in output assert "Italic" in output From 886cd0439291dd78752cabeeb5d28e69f2d7dcb7 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Thu, 11 Jun 2026 11:35:29 +0200 Subject: [PATCH 15/27] Refactor selection input modals --- VIBES.org | 48 ++++++- src/org/commands/agenda/actions.py | 17 +-- src/org/commands/agenda/app.py | 76 ++++++----- src/org/commands/board/app.py | 40 +++--- src/org/commands/runtime.py | 61 ++++++++- src/org/commands/styles/selection_modal.tcss | 29 ++++ src/org/commands/tasks/capture/__init__.py | 2 - src/org/commands/tasks/capture/app.py | 33 ++--- src/org/commands/tasks/capture/domain.py | 18 --- src/org/commands/tasks/common.py | 128 ------------------ src/org/commands/tasks/list/app.py | 62 ++++----- .../commands/tasks/test_tasks_capture_app.py | 24 +++- tests/commands/tasks/test_tasks_list.py | 4 +- tests/commands/tasks/test_tasks_list_app.py | 53 +++++++- tests/commands/test_agenda_app.py | 77 ++++++++++- tests/commands/test_board_app.py | 5 +- tests/commands/test_interactive_common.py | 13 +- tests/commands/test_runtime.py | 87 ++++++++++++ 18 files changed, 480 insertions(+), 297 deletions(-) create mode 100644 src/org/commands/styles/selection_modal.tcss create mode 100644 tests/commands/test_runtime.py diff --git a/VIBES.org b/VIBES.org index c229747..c7476c3 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7360,7 +7360,6 @@ The timestamp advancement should only be used if the new state is a completed st :END: Organize it the same as the other commands (most recentry modified: agenda, board and tasks list). -*** Refactor the task CRUD utils into a separate module re-used by all the commands *** Avoid using =getattr= and =setattr= like the plague Refactor all places that currently use either of these functions so that the concrete type is known and concrete field is accessed. Introduce linter rules that make sure these function cannot be used. @@ -7481,13 +7480,52 @@ In file src/org/commands/agenda/layout.py around line 624, near the ~def _select #+end_example This wrapper should be removed, prefer renaming functions and adjusting callsites. -*** Use Textual widgets for better TUI organization -- Selection for prompts -- Sparkline for freequency plots -*** Use Textual header & footer widgets in the interactive commands +*** DONE Use Textual Selection widget for selections from a list of values +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 11:32] +:END: +This should be done instead of an input prompt wheneven the set of acceptable values is know before prompting. +Some examples include: prompting for new state when changing task's todo state, capture template selection, refiling tasks to known files. + +*** TODO Show a Textual modal when performing external edits, instead of suspending the app +*** CANCELLED Use Textual header & footer widgets in the interactive commands +:LOGBOOK: +- State "CANCELLED" from [2026-06-11 Thu 09:46] \\ + Not very useful right now. +:END: *** Use Textual textarea for any org-mode syntax highlighting - Limit to just the interactive commands. - Potentially use it for code edits of the files? +*** Reorganize TUI +With Textual as the backbone of the interactive commands, it makes sense to expand the =org/tui.py= module to be a full package +=org/tui= that contains: +- =color= - current =org/color.py= +- =bits= - current contents of =org/tui.py= that render specific items to Rich renderables, +- =plot= - current =org/plot.py= +- =histogram= - current =org/histogram.py= +- =help= - current contents of =org/commands/runtime.py=, specifically the help modal, and also the help modal rendering from =org/commands/interactive_common.py= +- =prompt= - current contents of =org/commands/runtime.py=, specifically the input prompt modal +- =selection= - current contents of =org/commands/runtime.py=, specifically the selection modal +- =footer= - footer rendering of =org/commands/runtime.py=, current contents of =org/commands/interactive_common.py= pertaining to footer state. +- =header= - current contents of =org/commands/interactive_common.py= pertaining to header state. + +When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new TUI package. + +*** Reorganize command logic +The shared logic that the commands use are currently present in multiple modules, but should be reorganized into a new package =org/logic= that contains: +- =tasks= - current contents of =org/commands/interactive_common.py= pertaining to modifying tasks, +- =archive= - current contents of =org/commands/archive.py= +- =edit= - current contents of =org/commands/editor.py= +- =search= - current contents of =org/commands/search_common.py= +- =stats= - current contents of =org/analyze.py= +- =filter= - current contents of =org/cli_common.py= +- =time= - current contents of =org/timestamp.py= and time-related functions from =org/commands/interactive_common.py= + +When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new logic package. + +*** Refactor the task CRUD utils into a separate module re-used by all the commands *** REWORKED Interactive loop refactor :LOGBOOK: - State "REWORKED" from "TODO" [2026-05-26 Tue 21:27] diff --git a/src/org/commands/agenda/actions.py b/src/org/commands/agenda/actions.py index 89ad1ff..2b8d7fe 100644 --- a/src/org/commands/agenda/actions.py +++ b/src/org/commands/agenda/actions.py @@ -11,7 +11,7 @@ import typer from org_parser.time import Clock, Timestamp -from org.cli_common import load_and_process_data, resolve_input_paths +from org.cli_common import load_and_process_data from org.commands.archive import archive_heading_subtree_and_save from org.commands.editor import edit_heading_subtree_in_external_editor from org.commands.interactive_common import ( @@ -31,7 +31,6 @@ iter_descendants, load_document, parse_clock_duration, - resolve_refile_destination_input, save_document, todo_states_for_heading, ) @@ -409,18 +408,8 @@ def apply_refile_with_value(session: AgendaSession, destination_input: str) -> N if row is None or row.node is None: session.status_message = "Action available only on task rows" return - current_files = resolve_input_paths(session.args.files) - destination_path, validation_error = resolve_refile_destination_input( - destination_input, - current_files, - ) - if destination_path is None and validation_error is None: - session.status_message = "Refile cancelled" - return - if validation_error is not None: - session.status_message = validation_error - return - if destination_path is None: + destination_path = destination_input.strip() + if not destination_path: session.status_message = "Refile cancelled" return try: diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 0fe2552..201479a 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -13,13 +13,8 @@ from org.cli_common import resolve_input_paths from org.commands import runtime from org.commands.tasks.common import ( - capture_template_prompt_label, clock_duration_prompt_label, configured_capture_template_names, - refile_prompt_label, - resolve_capture_template_selection, - resolve_todo_state_selection, - state_selection_prompt_label, ) from . import actions, ui @@ -284,6 +279,23 @@ def _complete(result: str | None) -> None: callback=_complete, ) + def _open_selection( + self, + label: str, + options: list[runtime.SelectionOption], + *, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen(runtime.SelectionModalScreen(label, options), callback=_complete) + def action_move_up(self) -> None: """Move the selection one row upward.""" actions.move_selection(self.session, -1) @@ -378,20 +390,12 @@ def action_prompt_capture(self) -> None: template_names = configured_capture_template_names() - def _submit(value: str) -> None: - stripped = value.strip() - if not stripped: - self._set_status("Capture cancelled") - return - template_name = resolve_capture_template_selection(stripped, template_names) - if template_name is None: - self._set_status("Invalid capture template shortcut") - return - self._run_external(lambda: actions.apply_capture_task(self.session, template_name)) - - self._open_prompt( - capture_template_prompt_label(template_names), - on_submit=_submit, + self._open_selection( + "Capture template", + [runtime.SelectionOption(value=name, label=name) for name in template_names], + on_submit=lambda template_name: self._run_external( + lambda: actions.apply_capture_task(self.session, template_name), + ), on_cancel=lambda: self._set_status("Capture cancelled"), ) @@ -404,20 +408,13 @@ def action_prompt_state(self) -> None: return states = actions.state_choices_for_selected_row(self.session) - def _submit(value: str) -> None: - stripped = value.strip() - if not stripped: - self._set_status("State change cancelled") - return - selected_state = resolve_todo_state_selection(stripped, states) - if selected_state is None: - self._set_status("Invalid TODO state selection") - return - actions.apply_state_change_with_value(self.session, selected_state) - - self._open_prompt( - state_selection_prompt_label(states), - on_submit=_submit, + self._open_selection( + "TODO state", + [runtime.SelectionOption(value=state, label=state) for state in states], + on_submit=lambda selected_state: actions.apply_state_change_with_value( + self.session, + selected_state, + ), on_cancel=lambda: self._set_status("State change cancelled"), ) @@ -428,9 +425,16 @@ def action_prompt_refile(self) -> None: self._refresh_view() return - self._open_prompt( - refile_prompt_label(resolve_input_paths(self.session.args.files)), - on_submit=lambda value: actions.apply_refile_with_value(self.session, value.strip()), + self._open_selection( + "Destination file", + [ + runtime.SelectionOption(value=path, label=path) + for path in resolve_input_paths(self.session.args.files) + ], + on_submit=lambda destination_path: actions.apply_refile_with_value( + self.session, + destination_path, + ), on_cancel=lambda: self._set_status("Refile cancelled"), ) diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index 6e73333..a589a77 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -14,9 +14,7 @@ from org.commands import runtime from org.commands.tasks.common import ( - capture_template_prompt_label, configured_capture_template_names, - resolve_capture_template_selection, ) from . import actions, ui @@ -284,6 +282,23 @@ def _complete(result: str | None) -> None: callback=_complete, ) + def _open_selection( + self, + label: str, + options: list[runtime.SelectionOption], + *, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen(runtime.SelectionModalScreen(label, options), callback=_complete) + def action_move_up(self) -> None: """Move the selection one task upward in the selected column.""" actions.move_selection_vertical(self.session, -1) @@ -377,21 +392,12 @@ def action_prompt_capture(self) -> None: template_names = configured_capture_template_names() - def _submit(value: str) -> None: - stripped = value.strip() - if not stripped: - self._set_status("Capture cancelled") - return - template_name = resolve_capture_template_selection(stripped, template_names) - if template_name is None: - self._set_status("Invalid capture template shortcut") - self.action_prompt_capture() - return - self._run_external(lambda: actions.apply_capture_task(self.session, template_name)) - - self._open_prompt( - capture_template_prompt_label(template_names), - on_submit=_submit, + self._open_selection( + "Capture template", + [runtime.SelectionOption(value=name, label=name) for name in template_names], + on_submit=lambda template_name: self._run_external( + lambda: actions.apply_capture_task(self.session, template_name), + ), on_cancel=lambda: self._set_status("Capture cancelled"), ) diff --git a/src/org/commands/runtime.py b/src/org/commands/runtime.py index 7a5e16f..9170b95 100644 --- a/src/org/commands/runtime.py +++ b/src/org/commands/runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, cast from rich.table import Table @@ -10,7 +11,7 @@ from textual.app import App, ComposeResult, SuspendNotSupported from textual.containers import Container from textual.screen import ModalScreen -from textual.widgets import Input, Static +from textual.widgets import Input, OptionList, Static from org.commands.interactive_common import ( InteractiveHelpEntry, @@ -130,6 +131,64 @@ def on_key(self, event: textual_events.Key) -> None: self.dismiss(None) +@dataclass(frozen=True) +class SelectionOption: + """One selectable value shown by the shared selection modal.""" + + value: str + label: str + + +class SelectionModalScreen(ModalScreen[str | None]): + """Generic modal screen for keyboard-only single-value selection.""" + + CSS_PATH = "styles/selection_modal.tcss" + + def __init__( + self, + label: str, + options: list[SelectionOption], + *, + initial_index: int = 0, + ) -> None: + """Store modal selection configuration.""" + super().__init__() + self._label = label + self._options = options + self._initial_index = initial_index + + def compose(self) -> ComposeResult: + """Render the selection label and option list.""" + yield Container( + Static(Text(self._label, style="bold"), id="selection-label"), + Static( + Text("Up/down moves. Enter selects. Esc cancels.", style="dim"), + id="selection-instructions", + ), + OptionList(*(option.label for option in self._options), id="selection-options"), + id="selection-dialog", + ) + + def on_mount(self) -> None: + """Focus the selection list after the modal is mounted.""" + option_list = self.query_one(OptionList) + if self._options: + option_list.highlighted = min(max(self._initial_index, 0), len(self._options) - 1) + option_list.focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Return the selected option value to the caller.""" + event.stop() + self.dismiss(self._options[event.option_index].value) + + def on_key(self, event: textual_events.Key) -> None: + """Consume escape so selection cancel doesn't fall through to app bindings.""" + if event.key != "escape": + return + event.stop() + self.dismiss(None) + + class CommandApp(App[None]): """Shared helpers for Textual-backed interactive command apps.""" diff --git a/src/org/commands/styles/selection_modal.tcss b/src/org/commands/styles/selection_modal.tcss new file mode 100644 index 0000000..dfc398a --- /dev/null +++ b/src/org/commands/styles/selection_modal.tcss @@ -0,0 +1,29 @@ +SelectionModalScreen { + align: center middle; + background: $surface 60%; +} + +#selection-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 24; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#selection-label { + margin-bottom: 1; +} + +#selection-instructions { + margin-bottom: 1; +} + +#selection-options { + width: 1fr; + min-width: 20; + height: auto; + max-height: 16; +} diff --git a/src/org/commands/tasks/capture/__init__.py b/src/org/commands/tasks/capture/__init__.py index fe1070d..c34e69d 100644 --- a/src/org/commands/tasks/capture/__init__.py +++ b/src/org/commands/tasks/capture/__init__.py @@ -18,7 +18,6 @@ _template_placeholders, _validate_rendered_heading, prepare_capture_plan, - resolve_interactive_capture_template_selection, ) @@ -40,7 +39,6 @@ "capture_task", "prepare_capture_plan", "register", - "resolve_interactive_capture_template_selection", "run_capture_form_app", "run_tasks_capture", "run_template_selection_app", diff --git a/src/org/commands/tasks/capture/app.py b/src/org/commands/tasks/capture/app.py index 62b5750..109f028 100644 --- a/src/org/commands/tasks/capture/app.py +++ b/src/org/commands/tasks/capture/app.py @@ -16,7 +16,6 @@ _CAPTURE_HELP_ENTRIES, CapturePlan, _build_template_body, - resolve_interactive_capture_template_selection, ) @@ -36,42 +35,30 @@ def __init__(self, template_names: list[str]) -> None: super().__init__() self._template_names = template_names self.selected_template_name: str | None = None - self._status_message = "" def compose(self) -> ComposeResult: yield Container( Static(Text("Capture template", style="bold"), id="selection-title"), - Static(id="selection-status"), id="selection-root", ) def on_mount(self) -> None: self._prompt_template_selection() - def _refresh_status(self) -> None: - self.query_one("#selection-status", Static).update(Text(self._status_message)) - def _prompt_template_selection(self) -> None: - options_text = ", ".join( - f"{idx}:{name}" for idx, name in enumerate(self._template_names, start=1) - ) - label = f"Capture template name or number [{options_text}]" - - def _submit(value: str) -> None: - selected = resolve_interactive_capture_template_selection(value, self._template_names) - if selected is None: - self._status_message = "Invalid capture template selection" - self._refresh_status() - self._prompt_template_selection() - return - self.selected_template_name = selected - self.exit() - self.push_screen( - runtime.PromptModalScreen(label), - callback=lambda value: _submit(value) if value is not None else self.exit(), + runtime.SelectionModalScreen( + "Capture template", + [runtime.SelectionOption(value=name, label=name) for name in self._template_names], + ), + callback=self._complete_template_selection, ) + def _complete_template_selection(self, value: str | None) -> None: + """Exit after one shared selection modal result.""" + self.selected_template_name = value + self.exit() + class CaptureApp(runtime.CommandApp): """Interactive multi-field capture form with preview.""" diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index 4501e62..943c95b 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -103,24 +103,6 @@ def _valid_template_names_text(template_names: list[str]) -> str: return ", ".join(template_names) -def resolve_interactive_capture_template_selection( - selection: str, - template_names: list[str], -) -> str | None: - """Resolve capture template from interactive selection input.""" - normalized = selection.strip() - if not normalized: - return None - if normalized in template_names: - return normalized - if not normalized.isdigit(): - return None - index = int(normalized) - 1 - if 0 <= index < len(template_names): - return template_names[index] - return None - - def _template_placeholders(template_content: str) -> list[str]: """Return unique placeholders in first-appearance order.""" placeholders = [match.group(1) for match in _PLACEHOLDER_RE.finditer(template_content)] diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index fecad48..7fae9c6 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -4,7 +4,6 @@ import json import logging -from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Literal @@ -15,7 +14,6 @@ from org_parser.time import Timestamp from org import config as config_module -from org.commands.interactive_common import FooterPromptState from org.query_language import ( EvalContext, QueryParseError, @@ -33,15 +31,6 @@ PlanningTimestampField = Literal["scheduled", "deadline", "closed"] -@dataclass(frozen=True) -class PromptActionConfig: - """Standardized interactive prompt texts for one user-input action.""" - - prompt: FooterPromptState - cancel_status: str - invalid_status: str - - def parse_comment_flag(value: str) -> bool: """Parse --comment value as strict true/false.""" normalized = value.strip().lower() @@ -208,50 +197,11 @@ def todo_states_for_heading(heading: Heading) -> list[str]: return list(dict.fromkeys(heading.document.all_states)) -def resolve_todo_state_selection(selection: str, states: list[str]) -> str | None: - """Resolve TODO state from numeric shortcut or explicit value.""" - normalized = selection.strip() - if not normalized: - return None - if normalized.isdigit(): - index = int(normalized) - 1 - if 0 <= index < len(states): - return states[index] - return None - if normalized in states: - return normalized - return None - - -def state_selection_prompt_label(states: list[str]) -> str: - """Return standard interactive prompt label for TODO state selection.""" - options = ", ".join(f"{index}:{state}" for index, state in enumerate(states, start=1)) - return f"State number or value ({options})" - - -def state_selection_prompt_config(states: list[str]) -> PromptActionConfig: - """Return standardized prompt config for TODO state selection.""" - return PromptActionConfig( - prompt=FooterPromptState(label=state_selection_prompt_label(states)), - cancel_status="State change cancelled", - invalid_status="Invalid TODO state selection", - ) - - def tags_prompt_label() -> str: """Return standard interactive prompt label for tags editing.""" return "Tags CSV (blank clears)" -def tags_prompt_config() -> PromptActionConfig: - """Return standardized prompt config for tags editing.""" - return PromptActionConfig( - prompt=FooterPromptState(label=tags_prompt_label()), - cancel_status="Tags edit cancelled", - invalid_status="Invalid tags input", - ) - - def planning_prompt_label(field: PlanningTimestampField) -> str: """Return standard interactive prompt label for planning timestamp editing.""" labels: dict[PlanningTimestampField, str] = { @@ -262,60 +212,11 @@ def planning_prompt_label(field: PlanningTimestampField) -> str: return f"{labels[field]} (blank clears)" -def planning_prompt_config(field: PlanningTimestampField) -> PromptActionConfig: - """Return standardized prompt config for planning timestamp editing.""" - return PromptActionConfig( - prompt=FooterPromptState(label=planning_prompt_label(field)), - cancel_status="Planning timestamp edit cancelled", - invalid_status="Invalid planning timestamp", - ) - - -def refile_prompt_label(paths: list[str]) -> str: - """Return standard interactive prompt label for refile destination.""" - options = ", ".join(f"{index}:{filename}" for index, filename in enumerate(paths, start=1)) - return f"Destination file (# or path, blank cancels) [{options}]" - - -def refile_prompt_config(paths: list[str]) -> PromptActionConfig: - """Return standardized prompt config for task refile destination.""" - return PromptActionConfig( - prompt=FooterPromptState(label=refile_prompt_label(paths)), - cancel_status="Refile cancelled", - invalid_status="Invalid destination shortcut", - ) - - -def resolve_refile_destination_input( - destination_input: str, - paths: list[str], -) -> tuple[str | None, str | None]: - """Resolve refile destination path or return a validation error message.""" - normalized = destination_input.strip() - if not normalized: - return None, None - if normalized.isdigit(): - index = int(normalized) - 1 - if 0 <= index < len(paths): - return paths[index], None - return None, "Invalid destination shortcut" - return normalized, None - - def clock_duration_prompt_label() -> str: """Return standard interactive prompt label for clock duration input.""" return "Clock duration (H:MM, Xm, Xh, minutes; blank cancels)" -def clock_duration_prompt_config() -> PromptActionConfig: - """Return standardized prompt config for clock duration input.""" - return PromptActionConfig( - prompt=FooterPromptState(label=clock_duration_prompt_label()), - cancel_status="Clock action cancelled", - invalid_status="Invalid clock duration", - ) - - def capture_template_names(templates: dict[str, dict[str, str]]) -> list[str]: """Return stable capture template name ordering for prompts.""" return sorted(templates) @@ -326,35 +227,6 @@ def configured_capture_template_names() -> list[str]: return capture_template_names(config_module.CONFIG_CAPTURE_TEMPLATES) -def capture_template_prompt_label(template_names: list[str]) -> str: - """Return standard interactive prompt label for capture template selection.""" - options = ", ".join(f"{index}:{name}" for index, name in enumerate(template_names, start=1)) - return f"Capture template number (blank cancels) [{options}]" - - -def capture_template_prompt_config(template_names: list[str] | None = None) -> PromptActionConfig: - """Return standardized prompt config for capture template selection.""" - names = configured_capture_template_names() if template_names is None else template_names - return PromptActionConfig( - prompt=FooterPromptState(label=capture_template_prompt_label(names)), - cancel_status="Capture cancelled", - invalid_status="Invalid capture template shortcut", - ) - - -def resolve_capture_template_selection(selection: str, template_names: list[str]) -> str | None: - """Resolve capture template from numeric shortcut input.""" - normalized = selection.strip() - if not normalized: - return None - if not normalized.isdigit(): - return None - index = int(normalized) - 1 - if 0 <= index < len(template_names): - return template_names[index] - return None - - def parse_clock_duration(value: str) -> timedelta: """Parse user-entered clock duration text.""" normalized = value.strip().lower() diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py index 09f6cfd..6318c57 100644 --- a/src/org/commands/tasks/list/app.py +++ b/src/org/commands/tasks/list/app.py @@ -14,12 +14,8 @@ from org.commands import runtime from org.commands.tasks.common import ( PlanningTimestampField, - capture_template_prompt_label, configured_capture_template_names, planning_prompt_label, - resolve_capture_template_selection, - resolve_todo_state_selection, - state_selection_prompt_label, tags_prompt_label, ) from org.tui import TaskLineConfig, format_task_line @@ -213,6 +209,23 @@ def _complete(result: str | None) -> None: def _run_external(self, callback: Callable[[], None]) -> None: self.run_external_and_refresh(callback, refresh=self._refresh_view) + def _open_selection( + self, + label: str, + options: list[runtime.SelectionOption], + *, + on_submit: Callable[[str], None], + on_cancel: Callable[[], None], + ) -> None: + def _complete(result: str | None) -> None: + if result is None: + on_cancel() + else: + on_submit(result) + self._refresh_view() + + self.push_screen(runtime.SelectionModalScreen(label, options), callback=_complete) + def action_move_up(self) -> None: """Move the selection one row upward.""" actions.move_selection(self.session, -1) @@ -288,20 +301,12 @@ def action_prompt_capture(self) -> None: self._refresh_view() return - def _submit(value: str) -> None: - stripped = value.strip() - if not stripped: - self.session.status_message = "Capture cancelled" - return - template_name = resolve_capture_template_selection(stripped, template_names) - if template_name is None: - self.session.status_message = "Invalid capture template shortcut" - return - self._run_external(lambda: actions.apply_capture_task(self.session, template_name)) - - self._open_prompt( - capture_template_prompt_label(template_names), - on_submit=_submit, + self._open_selection( + "Capture template", + [runtime.SelectionOption(value=name, label=name) for name in template_names], + on_submit=lambda template_name: self._run_external( + lambda: actions.apply_capture_task(self.session, template_name), + ), on_cancel=lambda: self._set_status("Capture cancelled"), ) @@ -314,20 +319,13 @@ def action_prompt_state(self) -> None: return states = actions.state_choices_for_selected_node(self.session) - def _submit(value: str) -> None: - stripped = value.strip() - if not stripped: - self.session.status_message = "State change cancelled" - return - selected_state = resolve_todo_state_selection(stripped, states) - if selected_state is None: - self.session.status_message = "Invalid TODO state selection" - return - actions.apply_state_change_with_value(self.session, selected_state) - - self._open_prompt( - state_selection_prompt_label(states), - on_submit=_submit, + self._open_selection( + "TODO state", + [runtime.SelectionOption(value=state, label=state) for state in states], + on_submit=lambda selected_state: actions.apply_state_change_with_value( + self.session, + selected_state, + ), on_cancel=lambda: self._set_status("State change cancelled"), ) diff --git a/tests/commands/tasks/test_tasks_capture_app.py b/tests/commands/tasks/test_tasks_capture_app.py index be19c25..7a7a534 100644 --- a/tests/commands/tasks/test_tasks_capture_app.py +++ b/tests/commands/tasks/test_tasks_capture_app.py @@ -5,10 +5,10 @@ import asyncio from typing import TYPE_CHECKING, Any, cast -from textual.widgets import Static, TextArea +from textual.widgets import OptionList, Static, TextArea from org.commands.tasks import capture -from org.commands.tasks.capture.app import CaptureApp +from org.commands.tasks.capture.app import CaptureApp, _TemplateSelectionApp if TYPE_CHECKING: @@ -104,3 +104,23 @@ async def _run() -> None: assert app.result_values["owner"] == "Sam" asyncio.run(_run()) + + +def test_template_selection_app_uses_shared_keyboard_selection_modal() -> None: + """Template selection should use the shared keyboard-only modal.""" + + async def _run() -> None: + app = _TemplateSelectionApp(["later", "quick"]) + + async with app.run_test() as pilot: + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert app.selected_template_name == "quick" + + asyncio.run(_run()) diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 7c376a2..f44343c 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -905,12 +905,12 @@ def test_apply_capture_task_reports_bad_parameter_error( session = _make_session(nodes) def _raise_bad_parameter(_args: object) -> object: - raise typer.BadParameter("Invalid capture template shortcut") + raise typer.BadParameter("Capture failed") monkeypatch.setattr(actions, "capture_task", _raise_bad_parameter) actions.apply_capture_task(session, "quick") - assert session.status_message == "Invalid capture template shortcut" + assert session.status_message == "Capture failed" def test_apply_state_change_appends_repeat_transition(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py index c130f3a..8d6d6e4 100644 --- a/tests/commands/tasks/test_tasks_list_app.py +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -5,8 +5,9 @@ import asyncio from typing import TYPE_CHECKING, Any, cast -from textual.widgets import Input, Static +from textual.widgets import Input, OptionList, Static +from org import config as config_module from org.commands.tasks.list import command as tasks_list from org.commands.tasks.list.app import TasksListApp from tests.commands.tasks.test_tasks_list import make_list_args @@ -134,6 +135,56 @@ async def _run() -> None: asyncio.run(_run()) +def test_tasks_list_app_capture_selection_uses_keyboard(monkeypatch: pytest.MonkeyPatch) -> None: + """Capture selection should be fully keyboard navigable.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + called: list[str] = [] + monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {"quick": {}, "later": {}}) + monkeypatch.setattr( + "org.commands.tasks.list.actions.apply_capture_task", + lambda _session, template_name: called.append(template_name), + ) + + async with app.run_test() as pilot: + app.action_prompt_capture() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert called == ["quick"] + + asyncio.run(_run()) + + +def test_tasks_list_app_state_selection_uses_keyboard() -> None: + """State selection should be fully keyboard navigable.""" + + async def _run() -> None: + nodes = node_from_org("* TODO Alpha\n") + app = TasksListApp(make_list_args([]), _make_session_data(nodes)) + + async with app.run_test() as pilot: + app.action_prompt_state() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("enter") + await pilot.pause() + + assert nodes[0].todo == "TODO" + + asyncio.run(_run()) + + def test_run_tasks_list_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: """Interactive tasks list path should delegate to the Textual app runner.""" nodes = node_from_org("* TODO Alpha\n") diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index 6e945b9..3cf0a55 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, cast import org_parser -from textual.widgets import Input, Static +from textual.widgets import Input, OptionList, Static from org import config as config_module from org.commands.agenda import actions, ui @@ -212,7 +212,8 @@ def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: async with app.run_test() as pilot: app.action_prompt_capture() await pilot.pause() - app.screen_stack[-1].dismiss("1") + assert app.screen.query_one(OptionList).has_focus + await pilot.press("enter") await pilot.pause() assert str(captured_node.scheduled) == expected_timestamp @@ -224,6 +225,78 @@ def _fake_capture(_args: object) -> capture_command.TasksCaptureResult: asyncio.run(_run()) +def test_agenda_app_state_selection_uses_keyboard(monkeypatch: pytest.MonkeyPatch) -> None: + """State selection should be fully keyboard navigable.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + args = _make_args([fixture_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + calls: list[str] = [] + monkeypatch.setattr(actions, "can_activate_agenda_state_prompt", lambda _session: None) + monkeypatch.setattr( + actions, + "state_choices_for_selected_row", + lambda _session: ["TODO", "DONE"], + ) + monkeypatch.setattr( + actions, + "apply_state_change_with_value", + lambda _session, state: calls.append(state), + ) + + async with app.run_test() as pilot: + app.action_prompt_state() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("enter") + await pilot.pause() + + assert calls == ["TODO"] + + asyncio.run(_run()) + + +def test_agenda_app_refile_selection_uses_loaded_files_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Refile selection should submit one of the loaded files via keyboard selection.""" + + async def _run() -> None: + fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") + second_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") + args = _make_args([fixture_path, second_path], date="2025-01-15") + app = _make_app(args, list(org_parser.load(fixture_path))) + destinations: list[str] = [] + app.session.selected_row_index = next( + index + for index, (day_index, row_index) in enumerate(app.session.row_locations) + if app.session.day_models[day_index].rows[row_index].kind == "task" + ) + monkeypatch.setattr( + actions, + "apply_refile_with_value", + lambda _session, destination: destinations.append(destination), + ) + + async with app.run_test() as pilot: + app.action_prompt_refile() + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert destinations == [second_path] + + asyncio.run(_run()) + + def test_run_agenda_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: """Interactive agenda path should delegate to the Textual app runner.""" fixture_path = os.path.join(FIXTURES_DIR, "agenda_sample.org") diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 356c289..72c7e26 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -7,7 +7,7 @@ import sys from typing import TYPE_CHECKING, Any, cast -from textual.widgets import Input, Static +from textual.widgets import Input, OptionList, Static from org import config as config_module from org.commands.board import actions @@ -214,7 +214,8 @@ async def _run() -> None: async with app.run_test() as pilot: app.action_prompt_capture() await pilot.pause() - app.screen_stack[-1].dismiss("1") + assert app.screen.query_one(OptionList).has_focus + await pilot.press("down", "enter") await pilot.pause() assert reloaded["identity"] == heading_locator(captured_node) diff --git a/tests/commands/test_interactive_common.py b/tests/commands/test_interactive_common.py index 23d7ae5..3348568 100644 --- a/tests/commands/test_interactive_common.py +++ b/tests/commands/test_interactive_common.py @@ -11,21 +11,10 @@ render_interactive_help_panel_text, resolve_heading_locator, ) -from org.commands.tasks.common import resolve_todo_state_selection, todo_states_for_heading +from org.commands.tasks.common import todo_states_for_heading from tests.conftest import node_from_org -def test_resolve_todo_state_selection_supports_number_and_value() -> None: - """State selection should resolve numeric indexes and explicit values.""" - states = ["TODO", "NEXT", "DONE"] - - assert resolve_todo_state_selection("2", states) == "NEXT" - assert resolve_todo_state_selection("DONE", states) == "DONE" - assert resolve_todo_state_selection("", states) is None - assert resolve_todo_state_selection("99", states) is None - assert resolve_todo_state_selection("UNKNOWN", states) is None - - def test_todo_states_for_heading_returns_stable_unique_order() -> None: """TODO states helper should deduplicate while preserving first appearance order.""" diff --git a/tests/commands/test_runtime.py b/tests/commands/test_runtime.py new file mode 100644 index 0000000..2423a2f --- /dev/null +++ b/tests/commands/test_runtime.py @@ -0,0 +1,87 @@ +"""Tests for shared interactive runtime helpers.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.widgets import OptionList, Static + +from org.commands import runtime + + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +class _SelectionHarnessApp(runtime.CommandApp): + """Minimal app for exercising the shared selection modal.""" + + BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "quit_app", show=False)] + + def __init__(self) -> None: + super().__init__() + self.result: str | None = None + + def compose(self) -> ComposeResult: + yield Vertical(Static("body")) + + def on_mount(self) -> None: + self.push_screen( + runtime.SelectionModalScreen( + "Choose value", + [ + runtime.SelectionOption(value="alpha", label="alpha"), + runtime.SelectionOption(value="beta", label="beta"), + ], + ), + callback=self._store_result, + ) + + def _store_result(self, value: str | None) -> None: + self.result = value + + def action_quit_app(self) -> None: + """Exit the harness app.""" + self.exit() + + +def test_selection_modal_supports_keyboard_navigation_and_submit() -> None: + """Selection modal should support keyboard-only navigation and submit.""" + + async def _run() -> None: + app = _SelectionHarnessApp() + + async with app.run_test() as pilot: + await pilot.pause() + + option_list = app.screen.query_one(OptionList) + assert option_list.has_focus + + await pilot.press("down", "enter") + await pilot.pause() + + assert app.result == "beta" + + asyncio.run(_run()) + + +def test_selection_modal_escape_cancels_without_exiting_parent_app() -> None: + """Escape should cancel the modal without triggering parent app bindings.""" + + async def _run() -> None: + app = _SelectionHarnessApp() + + async with app.run_test() as pilot: + await pilot.pause() + assert app.screen.query_one("#selection-label", Static) is not None + + await pilot.press("escape") + await pilot.pause() + + assert app.result is None + assert app.is_running + + asyncio.run(_run()) From 1266c6e288f31a221c284db2b3687a9e10be1e5e Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Thu, 11 Jun 2026 12:07:03 +0200 Subject: [PATCH 16/27] Updated the vibes. --- VIBES.org | 75 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/VIBES.org b/VIBES.org index c7476c3..ad45f21 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7487,16 +7487,7 @@ This wrapper should be removed, prefer renaming functions and adjusting callsite This should be done instead of an input prompt wheneven the set of acceptable values is know before prompting. Some examples include: prompting for new state when changing task's todo state, capture template selection, refiling tasks to known files. -*** TODO Show a Textual modal when performing external edits, instead of suspending the app -*** CANCELLED Use Textual header & footer widgets in the interactive commands -:LOGBOOK: -- State "CANCELLED" from [2026-06-11 Thu 09:46] \\ - Not very useful right now. -:END: -*** Use Textual textarea for any org-mode syntax highlighting -- Limit to just the interactive commands. -- Potentially use it for code edits of the files? -*** Reorganize TUI +*** TODO Reorganize the TUI With Textual as the backbone of the interactive commands, it makes sense to expand the =org/tui.py= module to be a full package =org/tui= that contains: - =color= - current =org/color.py= @@ -7509,23 +7500,48 @@ With Textual as the backbone of the interactive commands, it makes sense to expa - =footer= - footer rendering of =org/commands/runtime.py=, current contents of =org/commands/interactive_common.py= pertaining to footer state. - =header= - current contents of =org/commands/interactive_common.py= pertaining to header state. -When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. Make sure to update the commands to use this new TUI package. -*** Reorganize command logic +*** TODO Reorganize common command logic The shared logic that the commands use are currently present in multiple modules, but should be reorganized into a new package =org/logic= that contains: - =tasks= - current contents of =org/commands/interactive_common.py= pertaining to modifying tasks, - =archive= - current contents of =org/commands/archive.py= - =edit= - current contents of =org/commands/editor.py= - =search= - current contents of =org/commands/search_common.py= - =stats= - current contents of =org/analyze.py= -- =filter= - current contents of =org/cli_common.py= +- =filtering= - current contents of =org/cli_common.py= - =time= - current contents of =org/timestamp.py= and time-related functions from =org/commands/interactive_common.py= +- =validation= - current contents of =org/validation.py= -When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. Make sure to update the commands to use this new logic package. -*** Refactor the task CRUD utils into a separate module re-used by all the commands +*** TODO Reorganize file parsing & formatting +Add a new package =org/serde= that will contain any parsing/output formatting code. It should contain: +- =parse= - current contents of =org/parse.py= +- =format= - current contents of =org/output_format.py= + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new serialization package. + +*** TODO Reorganize the config file handling +Add a new package =org/config= that will contain: +- =app= - current config of =org/config.py= +- =logging= - current config of =org/logging_config.py= + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new config package. + +*** Use Textual textarea for any org-mode syntax highlighting +- Limit to just the interactive commands. +- Potentially use it for code edits of the files? +**** Show a Textual modal when performing external edits, instead of suspending the app +*** CANCELLED Use Textual header & footer widgets in the interactive commands +:LOGBOOK: +- State "CANCELLED" from [2026-06-11 Thu 09:46] \\ + Not very useful right now. +:END: *** REWORKED Interactive loop refactor :LOGBOOK: - State "REWORKED" from "TODO" [2026-05-26 Tue 21:27] @@ -7541,7 +7557,34 @@ A domain should be defined with all common utils reorganized as domain specific For command output rendering, a common toolkit of widgets and widget-adjacent rendering tools should be extracted. Lastly, some common logic should be extracted that allows performing actions generically. Views should only trigger those actions and actions should modify the domain. -** TODO Agenda usability improvements :UX: +*** DONE Remove legacy non-interactive rendering from interactive commands +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-10 Wed 19:48] +:END: +**** DONE src/org/commands/tasks/list/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-10 Wed 19:48] +:END: + +In file src/org/commands/tasks/list/command.py around line 663, near the ~def register(app: typer.Typer) -> None:~ + +#+begin_example ++ if context is not None: ++ details_switch_present = details_switch_present or ( ++ context.get_parameter_source("details") == ParameterSource.COMMANDLINE ++ ) ++ out_switch_present = out_switch_present or ( ++ context.get_parameter_source("out") == ParameterSource.COMMANDLINE ++ ) + +#+end_example + +I absolutely hate this. Please refactor the =out= and =details= switches to be optional and check their existance that way. + +** TODO Agenda usability improvements :UX:Agenda: *** Agenda non-interactive output format - Should be adjusted to "look and feel" just like the interactive one. *** Show state transition for repeats on the timeline From f861bf3e49636fec1ba96e15c96db55eefc72dc9 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Thu, 11 Jun 2026 12:29:27 +0200 Subject: [PATCH 17/27] Reorganized the serialization/deserialization modules. --- VIBES.org | 35 ++++++++++--------- pyproject.toml | 2 +- src/org/cli_common.py | 2 +- src/org/commands/tasks/capture/domain.py | 2 +- src/org/commands/tasks/find.py | 12 +++---- src/org/commands/tasks/list/command.py | 2 +- src/org/commands/tasks/query.py | 16 ++++----- src/org/serde/__init__.py | 1 + src/org/{output_format.py => serde/format.py} | 0 src/org/{ => serde}/parse.py | 0 tests/cli_common/test_file_errors.py | 4 +-- tests/commands/tasks/test_tasks_list.py | 2 +- tests/commands/test_tasks_query_command.py | 2 +- tests/test_output_format.py | 10 +++--- 14 files changed, 47 insertions(+), 43 deletions(-) create mode 100644 src/org/serde/__init__.py rename src/org/{output_format.py => serde/format.py} (100%) rename src/org/{ => serde}/parse.py (100%) diff --git a/VIBES.org b/VIBES.org index ad45f21..406d3b6 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7487,6 +7487,25 @@ This wrapper should be removed, prefer renaming functions and adjusting callsite This should be done instead of an input prompt wheneven the set of acceptable values is know before prompting. Some examples include: prompting for new state when changing task's todo state, capture template selection, refiling tasks to known files. +*** DONE Reorganize file parsing & formatting +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 12:30] +:END: +Add a new package =org/serde= that will contain any parsing/output formatting code. It should contain: +- =parse= - current contents of =org/parse.py= +- =format= - current contents of =org/output_format.py= + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new serialization package. + +*** TODO Reorganize the config file handling +Add a new package =org/config= that will contain: +- =app= - current config of =org/config.py= +- =logging= - current config of =org/logging_config.py= + +Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. +Make sure to update the commands to use this new config package. + *** TODO Reorganize the TUI With Textual as the backbone of the interactive commands, it makes sense to expand the =org/tui.py= module to be a full package =org/tui= that contains: @@ -7517,22 +7536,6 @@ The shared logic that the commands use are currently present in multiple modules Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. Make sure to update the commands to use this new logic package. -*** TODO Reorganize file parsing & formatting -Add a new package =org/serde= that will contain any parsing/output formatting code. It should contain: -- =parse= - current contents of =org/parse.py= -- =format= - current contents of =org/output_format.py= - -Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. -Make sure to update the commands to use this new serialization package. - -*** TODO Reorganize the config file handling -Add a new package =org/config= that will contain: -- =app= - current config of =org/config.py= -- =logging= - current config of =org/logging_config.py= - -Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. -Make sure to update the commands to use this new config package. - *** Use Textual textarea for any org-mode syntax highlighting - Limit to just the interactive commands. - Potentially use it for code edits of the files? diff --git a/pyproject.toml b/pyproject.toml index 9ec493d..6f5abdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ lines-after-imports = 2 "SLF001", # private member access is tolerated in tests/support code ] -"src/org/output_format.py" = [ +"src/org/serde/format.py" = [ "S603", # pandoc subprocess invocation is an intentional feature "S112", # best-effort serialization intentionally skips unreadable attributes ] diff --git a/src/org/cli_common.py b/src/org/cli_common.py index 437bfd6..6010182 100644 --- a/src/org/cli_common.py +++ b/src/org/cli_common.py @@ -16,7 +16,6 @@ from org import config as config_module from org.analyze import TimeRange, normalize -from org.parse import load_root_nodes from org.query_language import ( EvalContext, QueryParseError, @@ -24,6 +23,7 @@ Stream, compile_query_text, ) +from org.serde.parse import load_root_nodes from org.timestamp import extract_timestamp_any from org.validation import ( parse_date_argument, diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index 943c95b..271934d 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -16,7 +16,6 @@ from org import config as config_module from org.commands.interactive_common import InteractiveHelpEntry from org.commands.tasks.common import load_document, resolve_parent_heading, save_document -from org.output_format import DEFAULT_OUTPUT_THEME from org.query_language import ( EvalContext, QueryParseError, @@ -24,6 +23,7 @@ Stream, compile_query_text, ) +from org.serde.format import DEFAULT_OUTPUT_THEME if TYPE_CHECKING: diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index 747231a..ebe968f 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -16,12 +16,6 @@ from org.cli_common import load_root_data from org.commands.tasks.common import normalize_selector from org.commands.tasks.query import get_query_formatter -from org.output_format import ( - DEFAULT_OUTPUT_THEME, - OutputFormat, - OutputFormatError, - print_prepared_output, -) from org.query_language import ( EvalContext, QueryParseError, @@ -29,6 +23,12 @@ Stream, compile_query_text, ) +from org.serde.format import ( + DEFAULT_OUTPUT_THEME, + OutputFormat, + OutputFormatError, + print_prepared_output, +) from org.tui import build_console, processing_status, setup_output diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index dec8b4e..d74d252 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -14,7 +14,7 @@ from org import config as config_module from org.cli_common import load_and_process_data from org.commands.interactive_common import interactive_help_command_text -from org.output_format import ( +from org.serde.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index e21c2dd..5f6a152 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -18,7 +18,14 @@ from org import config as config_module from org.cli_common import load_root_data -from org.output_format import ( +from org.query_language import ( + EvalContext, + QueryParseError, + QueryRuntimeError, + Stream, + compile_query_text, +) +from org.serde.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, @@ -32,13 +39,6 @@ _prepare_output, print_prepared_output, ) -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) from org.tui import build_console, processing_status, setup_output diff --git a/src/org/serde/__init__.py b/src/org/serde/__init__.py new file mode 100644 index 0000000..e614ed6 --- /dev/null +++ b/src/org/serde/__init__.py @@ -0,0 +1 @@ +"""Serialization helpers for parsing and output formatting.""" diff --git a/src/org/output_format.py b/src/org/serde/format.py similarity index 100% rename from src/org/output_format.py rename to src/org/serde/format.py diff --git a/src/org/parse.py b/src/org/serde/parse.py similarity index 100% rename from src/org/parse.py rename to src/org/serde/parse.py diff --git a/tests/cli_common/test_file_errors.py b/tests/cli_common/test_file_errors.py index fdbdda8..f499d58 100644 --- a/tests/cli_common/test_file_errors.py +++ b/tests/cli_common/test_file_errors.py @@ -181,7 +181,7 @@ def test_load_mapping_mixed_non_string_types(tmp_path: Path) -> None: def test_load_root_nodes_valid() -> None: """Test loading valid org files as root nodes.""" - from org.parse import load_root_nodes + from org.serde.parse import load_root_nodes fixture_path = os.path.join(FIXTURES_DIR, "simple.org") roots, _, _ = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) @@ -192,7 +192,7 @@ def test_load_root_nodes_valid() -> None: def test_load_root_nodes_todo_states() -> None: """Test loading todo keys from root nodes.""" - from org.parse import load_root_nodes + from org.serde.parse import load_root_nodes fixture_path = os.path.join(FIXTURES_DIR, "todo_states.org") roots, todo_states, done_states = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index f44343c..0b512cf 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -23,7 +23,7 @@ from org.commands.tasks.list import actions from org.commands.tasks.list import command as tasks_list from org.histogram import visual_len -from org.output_format import OutputFormat, OutputFormatError +from org.serde.format import OutputFormat, OutputFormatError from tests.conftest import node_from_org diff --git a/tests/commands/test_tasks_query_command.py b/tests/commands/test_tasks_query_command.py index 8a18e96..c073c74 100644 --- a/tests/commands/test_tasks_query_command.py +++ b/tests/commands/test_tasks_query_command.py @@ -14,7 +14,7 @@ from org.cli import app from org.commands.tasks.query import TasksQueryArgs, _is_org_object, run_tasks_query -from org.output_format import OutputFormat, OutputFormatError +from org.serde.format import OutputFormat, OutputFormatError FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") diff --git a/tests/test_output_format.py b/tests/test_output_format.py index 6316d4b..67bce24 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -14,11 +14,11 @@ from org_parser.text import RichText from rich.syntax import Syntax -from org import output_format from org.analyze import AnalysisResult, Group, Tag, TimeRange from org.commands.tasks import query as query_command from org.commands.tasks.list import command as tasks_list_command from org.histogram import Histogram +from org.serde import format as output_format if TYPE_CHECKING: @@ -48,7 +48,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs return SimpleNamespace(returncode=0, stdout=b"# converted", stderr=b"pandoc warning text\n") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) caplog.set_level(logging.INFO, logger="org") markdown_text = output_format._org_to_pandoc_format("* TODO test", "markdown", []) @@ -68,7 +68,7 @@ def _fake_run(command: list[str], **kwargs: object) -> SimpleNamespace: seen["kwargs"] = kwargs return SimpleNamespace(returncode=0, stdout=b"converted", stderr=b"") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) rendered = output_format._org_to_pandoc_format( "* TODO test", @@ -95,7 +95,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs return SimpleNamespace(returncode=22, stdout=b"", stderr=b"Unknown output format\n") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) with pytest.raises(output_format.OutputFormatError, match="Unknown output format"): output_format._org_to_pandoc_format("* TODO test", "invalid-format", []) @@ -111,7 +111,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs raise FileNotFoundError("pandoc not found") - monkeypatch.setattr("org.output_format.subprocess.run", _fake_run) + monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) with pytest.raises(output_format.OutputFormatError, match="pandoc not found"): output_format._org_to_pandoc_format("* TODO test", "gfm", []) From 96e53c134c4cef52451439d902df4458c7e4dde8 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Thu, 11 Jun 2026 17:16:29 +0200 Subject: [PATCH 18/27] Refactored the config & logging modules --- VIBES.org | 5 +- src/org/cli.py | 45 ++--- src/org/cli_common.py | 32 +-- src/org/commands/agenda/command.py | 9 +- src/org/commands/agenda/views.py | 12 +- src/org/commands/board/actions.py | 16 +- src/org/commands/board/command.py | 9 +- src/org/commands/stats/all.py | 9 +- src/org/commands/stats/command.py | 4 +- src/org/commands/stats/groups.py | 9 +- src/org/commands/stats/summary.py | 9 +- src/org/commands/stats/tags.py | 9 +- src/org/commands/tasks/add.py | 9 +- src/org/commands/tasks/archive.py | 9 +- src/org/commands/tasks/capture/command.py | 7 +- src/org/commands/tasks/capture/domain.py | 4 +- src/org/commands/tasks/common.py | 4 +- src/org/commands/tasks/edit.py | 9 +- src/org/commands/tasks/find.py | 9 +- src/org/commands/tasks/list/command.py | 9 +- src/org/commands/tasks/query.py | 9 +- src/org/commands/tasks/remove.py | 9 +- src/org/commands/tasks/update.py | 9 +- src/org/config/__init__.py | 1 + src/org/{config.py => config/app.py} | 80 -------- src/org/logging.py | 114 +++++++++++ src/org/logging_config.py | 30 --- tests/cli/test_cli_main.py | 91 ++++----- tests/cli/test_cli_runner.py | 142 +++++++------- tests/cli_common/test_file_errors.py | 32 +-- tests/cli_common/test_filter_construction.py | 92 ++++----- tests/commands/tasks/test_tasks_list.py | 4 +- tests/commands/tasks/test_tasks_list_app.py | 4 +- tests/commands/test_agenda_app.py | 4 +- tests/commands/test_agenda_command.py | 22 +-- tests/commands/test_board.py | 126 ++++++------ tests/commands/test_board_app.py | 6 +- tests/commands/test_tasks_capture_command.py | 12 +- tests/config/test_config_core.py | 193 ++++++++++--------- 39 files changed, 619 insertions(+), 589 deletions(-) create mode 100644 src/org/config/__init__.py rename src/org/{config.py => config/app.py} (93%) create mode 100644 src/org/logging.py delete mode 100644 src/org/logging_config.py diff --git a/VIBES.org b/VIBES.org index 406d3b6..2d15ba9 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7498,7 +7498,10 @@ Add a new package =org/serde= that will contain any parsing/output formatting co Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. Make sure to update the commands to use this new serialization package. -*** TODO Reorganize the config file handling +*** DONE Reorganize the config file handling +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 17:17] +:END: Add a new package =org/config= that will contain: - =app= - current config of =org/config.py= - =logging= - current config of =org/logging_config.py= diff --git a/src/org/cli.py b/src/org/cli.py index 21af731..d89aab9 100644 --- a/src/org/cli.py +++ b/src/org/cli.py @@ -6,7 +6,8 @@ import typer -from org import config, logging_config +import org.config.app +import org.logging from org.cli_common import DEFAULT_VERBOSE, resolve_verbose from org.commands import agenda, stats from org.commands import board as board_command @@ -31,7 +32,7 @@ def main_callback( """Global CLI options.""" if verbose is None and not DEFAULT_VERBOSE["value"]: return - logging_config.configure_logging(resolve_verbose(verbose)) + org.logging.configure_logging(resolve_verbose(verbose)) agenda.register(app) @@ -42,32 +43,32 @@ def main_callback( def main() -> None: """Run the CLI entry point.""" - loaded_config = config.load_cli_config(sys.argv) + loaded_config = org.config.app.load_cli_config(sys.argv) defaults = loaded_config.defaults if defaults is not None: DEFAULT_VERBOSE["value"] = bool(defaults.pop("verbose", False)) - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(loaded_config.append_defaults) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(loaded_config.inline_defaults) - config.CONFIG_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(loaded_config.append_defaults) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(loaded_config.inline_defaults) + org.config.app.CONFIG_DEFAULTS.clear() if defaults is not None: - config.CONFIG_DEFAULTS.update(defaults) - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(loaded_config.custom_filters) - config.CONFIG_CUSTOM_ORDER_BY.clear() - config.CONFIG_CUSTOM_ORDER_BY.update(loaded_config.custom_order_by) - config.CONFIG_CUSTOM_WITH.clear() - config.CONFIG_CUSTOM_WITH.update(loaded_config.custom_with) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(loaded_config.capture_templates) - config.CONFIG_BOARD_VIEWS.clear() - config.CONFIG_BOARD_VIEWS.update(loaded_config.board_views) - config.CONFIG_AGENDA_VIEWS.clear() - config.CONFIG_AGENDA_VIEWS.update(loaded_config.agenda_views) + org.config.app.CONFIG_DEFAULTS.update(defaults) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(loaded_config.custom_filters) + org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() + org.config.app.CONFIG_CUSTOM_ORDER_BY.update(loaded_config.custom_order_by) + org.config.app.CONFIG_CUSTOM_WITH.clear() + org.config.app.CONFIG_CUSTOM_WITH.update(loaded_config.custom_with) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(loaded_config.capture_templates) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(loaded_config.board_views) + org.config.app.CONFIG_AGENDA_VIEWS.clear() + org.config.app.CONFIG_AGENDA_VIEWS.update(loaded_config.agenda_views) command = typer.main.get_command(app) - default_map = config.build_default_map(defaults) if defaults else None + default_map = org.config.app.build_default_map(defaults) if defaults else None command.main( args=sys.argv[1:], prog_name="org", diff --git a/src/org/cli_common.py b/src/org/cli_common.py index 6010182..074f662 100644 --- a/src/org/cli_common.py +++ b/src/org/cli_common.py @@ -14,7 +14,7 @@ import typer from org_parser.document import Heading -from org import config as config_module +import org.config.app from org.analyze import TimeRange, normalize from org.query_language import ( EvalContext, @@ -303,19 +303,19 @@ def _resolve_custom_option(option: str) -> tuple[str, bool] | None: """Resolve configured custom option to (query, requires_arg).""" filter_name = _custom_option_name(option, "filter") if filter_name is not None: - query = config_module.CONFIG_CUSTOM_FILTERS.get(filter_name) + query = org.config.app.CONFIG_CUSTOM_FILTERS.get(filter_name) if query is not None: return (query, _query_uses_arg(query)) order_name = _custom_option_name(option, "order-by") if order_name is not None: - query = config_module.CONFIG_CUSTOM_ORDER_BY.get(order_name) + query = org.config.app.CONFIG_CUSTOM_ORDER_BY.get(order_name) if query is not None: return (query, _query_uses_arg(query)) with_name = _custom_option_name(option, "with") if with_name is not None: - query = config_module.CONFIG_CUSTOM_WITH.get(with_name) + query = org.config.app.CONFIG_CUSTOM_WITH.get(with_name) if query is not None: return (query, _query_uses_arg(query)) @@ -451,13 +451,13 @@ def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> """Validate prefixed custom switches against configured/built-in options.""" builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( - {f"--filter-{name}" for name in config_module.CONFIG_CUSTOM_FILTERS}, + {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, ) allowed_order_options = builtin_order_options.union( - {f"--order-by-{name}" for name in config_module.CONFIG_CUSTOM_ORDER_BY}, + {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, ) allowed_with_options = WITH_OPTIONS_FLAGS.union( - {f"--with-{name}" for name in config_module.CONFIG_CUSTOM_WITH}, + {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, ) for index, token in enumerate(argv): @@ -503,11 +503,11 @@ def parse_filter_entries_from_argv( continue name = _custom_option_name(option, "filter") - if name is None or name not in config_module.CONFIG_CUSTOM_FILTERS or option in builtins: + if name is None or name not in org.config.app.CONFIG_CUSTOM_FILTERS or option in builtins: index += 1 continue - query = config_module.CONFIG_CUSTOM_FILTERS[name] + query = org.config.app.CONFIG_CUSTOM_FILTERS[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( @@ -559,13 +559,13 @@ def parse_order_entries_from_argv( name = _custom_option_name(option, "order-by") if ( name is None - or name not in config_module.CONFIG_CUSTOM_ORDER_BY + or name not in org.config.app.CONFIG_CUSTOM_ORDER_BY or option in builtin_options ): index += 1 continue - query = config_module.CONFIG_CUSTOM_ORDER_BY[name] + query = org.config.app.CONFIG_CUSTOM_ORDER_BY[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( @@ -612,11 +612,11 @@ def parse_with_entries_from_argv( continue name = _custom_option_name(option, "with") - if name is None or name not in config_module.CONFIG_CUSTOM_WITH or option in builtins: + if name is None or name not in org.config.app.CONFIG_CUSTOM_WITH or option in builtins: index += 1 continue - query = config_module.CONFIG_CUSTOM_WITH[name] + query = org.config.app.CONFIG_CUSTOM_WITH[name] requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( @@ -1085,16 +1085,16 @@ def resolve_mapping(args: object) -> dict[str, str]: if mapping_inline is not None: return mapping_inline or MAP mapping_file = getattr(args, "mapping", None) - return config_module.load_mapping(mapping_file) or MAP + return org.config.app.load_mapping(mapping_file) or MAP def resolve_exclude_set(args: object) -> set[str]: """Resolve exclude set based on inline or file-based configuration.""" exclude_inline = getattr(args, "exclude_inline", None) if exclude_inline is not None: - return config_module.normalize_exclude_values(exclude_inline) or DEFAULT_EXCLUDE + return org.config.app.normalize_exclude_values(exclude_inline) or DEFAULT_EXCLUDE exclude_file = getattr(args, "exclude", None) - return config_module.load_exclude_list(exclude_file) or DEFAULT_EXCLUDE + return org.config.app.load_exclude_list(exclude_file) or DEFAULT_EXCLUDE class DataLoadArgs(QueryBuildArgs, Protocol): diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 8587dc0..61309d2 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -9,7 +9,8 @@ import click import typer -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import load_and_process_data from org.commands.interactive_common import interactive_help_command_text from org.tui import build_console, processing_status, setup_output @@ -381,7 +382,7 @@ def agenda( # noqa: PLR0913 future_repeats=future_repeats, view=view, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "agenda") - config_module.log_command_arguments(args, "agenda") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "agenda") + org.logging.log_command_arguments(args, "agenda") run_agenda(args) diff --git a/src/org/commands/agenda/views.py b/src/org/commands/agenda/views.py index 28c2b57..9c8c271 100644 --- a/src/org/commands/agenda/views.py +++ b/src/org/commands/agenda/views.py @@ -7,7 +7,7 @@ import typer -from org import config as config_module +import org.config.app from org.query_language import CompiledQuery, QueryParseError, compile_query_text @@ -41,12 +41,12 @@ def _compile_section_query(filter_query: str, order_by: str | None) -> CompiledQ return compile_query_text(f"{base_query} | sort_by({order_by})") -def _fallback_agenda_view() -> config_module.AgendaViewConfig: +def _fallback_agenda_view() -> org.config.app.AgendaViewConfig: """Return built-in fallback agenda view definition.""" - return config_module.AgendaViewConfig( + return org.config.app.AgendaViewConfig( name="default", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="[bold dim white]Agenda[/]", filter="true", order_by=None, @@ -57,7 +57,7 @@ def _fallback_agenda_view() -> config_module.AgendaViewConfig: ) -def _compile_view_section_specs(view: config_module.AgendaViewConfig) -> list[AgendaSectionSpec]: +def _compile_view_section_specs(view: org.config.app.AgendaViewConfig) -> list[AgendaSectionSpec]: """Compile one agenda view's filters into renderable section specs.""" section_specs: list[AgendaSectionSpec] = [] for section in view.sections: @@ -81,7 +81,7 @@ def _compile_view_section_specs(view: config_module.AgendaViewConfig) -> list[Ag def resolve_view_context(args: AgendaArgs) -> AgendaViewContext: """Resolve configured or fallback view context for agenda rendering.""" selected_view = args.view.strip() if args.view else None - configured_views = config_module.CONFIG_AGENDA_VIEWS + configured_views = org.config.app.CONFIG_AGENDA_VIEWS if selected_view is None: view = _fallback_agenda_view() diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index 4e2143c..065e8d8 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -9,7 +9,7 @@ import typer -from org import config as config_module +import org.config.app from org.cli_common import load_and_process_data from org.commands.archive import archive_heading_subtree_and_save from org.commands.editor import edit_heading_subtree_in_external_editor @@ -148,22 +148,22 @@ def _compile_column_filter_query( return compile_query_text(f"{base_query} | sort_by({order_by})") -def _fallback_view_config() -> config_module.BoardViewConfig: +def _fallback_view_config() -> org.config.app.BoardViewConfig: """Return built-in fallback board filter view definition.""" - return config_module.BoardViewConfig( + return org.config.app.BoardViewConfig( name="fallback", columns=[ - config_module.BoardColumnConfig(name="Backlog", filter=".todo == null"), - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), + org.config.app.BoardColumnConfig( name="TODO", filter=".todo != null and not(.is_completed)", ), - config_module.BoardColumnConfig(name="DONE", filter=".is_completed"), + org.config.app.BoardColumnConfig(name="DONE", filter=".is_completed"), ], ) -def compile_view_column_specs(view: config_module.BoardViewConfig) -> list[BoardColumnSpec]: +def compile_view_column_specs(view: org.config.app.BoardViewConfig) -> list[BoardColumnSpec]: """Compile one board view's filters into renderable column specs.""" column_specs: list[BoardColumnSpec] = [] for column in view.columns: @@ -197,7 +197,7 @@ def _resolve_selected_view_name(args: BoardArgs) -> str | None: def resolve_column_specs(args: BoardArgs) -> list[BoardColumnSpec]: """Resolve configured or fallback filter columns for board rendering.""" selected_view = _resolve_selected_view_name(args) - configured_views = config_module.CONFIG_BOARD_VIEWS + configured_views = org.config.app.CONFIG_BOARD_VIEWS if selected_view is None: return compile_view_column_specs(_fallback_view_config()) diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index e5ffd63..f3fa377 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -9,7 +9,8 @@ import click import typer -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import load_and_process_data from org.commands.interactive_common import interactive_help_command_text from org.tui import build_console, processing_status, setup_output @@ -341,7 +342,7 @@ def flow_board( # noqa: PLR0913 order_by_timestamp_desc=order_by_timestamp_desc, with_tags_as_category=with_tags_as_category, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "board") - config_module.log_command_arguments(args, "board") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "board") + org.logging.log_command_arguments(args, "board") run_flow_board(args) diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index f7cc32b..6a72b8b 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -11,7 +11,8 @@ from rich.panel import Panel from rich.text import Text -from org import config as config_module +import org.config.app +import org.logging from org.analyze import AnalysisResult, Tag, TimeRange, analyze, clean from org.analyze import Group as TagGroup from org.cli_common import ( @@ -771,7 +772,7 @@ def stats_all( # noqa: PLR0913 min_group_size=min_group_size, max_groups=max_groups, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats all") - config_module.log_command_arguments(args, "stats all") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats all") + org.logging.log_command_arguments(args, "stats all") run_stats(args) diff --git a/src/org/commands/stats/command.py b/src/org/commands/stats/command.py index 117af6e..99fd35b 100644 --- a/src/org/commands/stats/command.py +++ b/src/org/commands/stats/command.py @@ -4,7 +4,7 @@ import typer -from org import logging_config +import org.logging from org.cli_common import DEFAULT_VERBOSE, resolve_verbose from org.commands.stats import all as stats_all from org.commands.stats import groups, tags @@ -29,7 +29,7 @@ def stats_callback( """Global stats CLI options.""" if verbose is None and not DEFAULT_VERBOSE["value"]: return - logging_config.configure_logging(resolve_verbose(verbose)) + org.logging.configure_logging(resolve_verbose(verbose)) def register(app: typer.Typer) -> None: diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index 339ccd4..ea04548 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -8,7 +8,8 @@ import typer -from org import config as config_module +import org.config.app +import org.logging from org.analyze import ( Group, TimeRange, @@ -399,7 +400,7 @@ def stats_groups( # noqa: PLR0913 min_group_size=0, max_groups=0, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats groups") - config_module.log_command_arguments(args, "stats groups") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats groups") + org.logging.log_command_arguments(args, "stats groups") run_stats_groups(args) diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index 51f04f5..981b084 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -8,7 +8,8 @@ import typer -from org import config as config_module +import org.config.app +import org.logging from org.analyze import ( AnalysisResult, compute_avg_tasks_per_day, @@ -447,7 +448,7 @@ def stats_summary( # noqa: PLR0913 max_results=max_results, with_tags_as_category=with_tags_as_category, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats summary") - config_module.log_command_arguments(args, "stats summary") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats summary") + org.logging.log_command_arguments(args, "stats summary") run_stats_summary(args) diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index f3cdd1a..2e519df 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -8,7 +8,8 @@ import typer -from org import config as config_module +import org.config.app +import org.logging from org.analyze import ( Tag, TimeRange, @@ -409,7 +410,7 @@ def stats_tags( # noqa: PLR0913 min_group_size=2, max_groups=0, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "stats tags") - config_module.log_command_arguments(args, "stats tags") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "stats tags") + org.logging.log_command_arguments(args, "stats tags") run_stats_tags(args) diff --git a/src/org/commands/tasks/add.py b/src/org/commands/tasks/add.py index 45b9354..19976d2 100644 --- a/src/org/commands/tasks/add.py +++ b/src/org/commands/tasks/add.py @@ -10,7 +10,8 @@ import typer from org_parser.document import Document, Heading -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import resolve_input_paths from org.commands.tasks.common import ( load_document, @@ -477,7 +478,7 @@ def tasks_add( # noqa: PLR0913 parent=parent, file=file, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks add") - config_module.log_command_arguments(args, "tasks add") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks add") + org.logging.log_command_arguments(args, "tasks add") run_tasks_add(args) diff --git a/src/org/commands/tasks/archive.py b/src/org/commands/tasks/archive.py index ecb0187..fc4951b 100644 --- a/src/org/commands/tasks/archive.py +++ b/src/org/commands/tasks/archive.py @@ -9,7 +9,8 @@ import typer from org_parser.document import Document, Heading -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import resolve_input_paths from org.commands.archive import archive_heading_subtree, archive_result_documents_to_save from org.commands.tasks.common import ( @@ -126,7 +127,7 @@ def tasks_archive( query_id=query_id, query=query, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks archive") - config_module.log_command_arguments(args, "tasks archive") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks archive") + org.logging.log_command_arguments(args, "tasks archive") run_tasks_archive(args) diff --git a/src/org/commands/tasks/capture/command.py b/src/org/commands/tasks/capture/command.py index a553226..07a110a 100644 --- a/src/org/commands/tasks/capture/command.py +++ b/src/org/commands/tasks/capture/command.py @@ -8,7 +8,8 @@ import click import typer -from org import config as config_module +import org.config.app +import org.logging from org.commands.interactive_common import interactive_help_command_text from .app import run_capture_form_app, run_template_selection_app @@ -33,7 +34,7 @@ def _is_interactive_terminal() -> bool: def capture_task(args: TasksCaptureArgs) -> TasksCaptureResult: """Capture one task from templates and return created heading metadata.""" - templates = config_module.CONFIG_CAPTURE_TEMPLATES + templates = org.config.app.CONFIG_CAPTURE_TEMPLATES _require_templates(templates) interactive_used = False @@ -131,7 +132,7 @@ def capture( parent=parent, set_values=set_values, ) - config_module.log_command_arguments(args, "tasks capture") + org.logging.log_command_arguments(args, "tasks capture") run_tasks_capture(args) diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index 271934d..03fb6b4 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -13,7 +13,7 @@ from org_parser.document import Document, Heading from rich.syntax import Syntax -from org import config as config_module +import org.config.app from org.commands.interactive_common import InteractiveHelpEntry from org.commands.tasks.common import load_document, resolve_parent_heading, save_document from org.query_language import ( @@ -339,7 +339,7 @@ def _attach_heading(document: Document, parent_heading: Heading | None, heading: def prepare_capture_plan(args: TasksCaptureArgs, template_name: str) -> CapturePlan: """Prepare capture plan from resolved template and current document state.""" - templates = config_module.CONFIG_CAPTURE_TEMPLATES + templates = org.config.app.CONFIG_CAPTURE_TEMPLATES template = templates[template_name] target_file = template["file"] if args.file is None else args.file template_parent_selector = template.get("parent") diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index 7fae9c6..ca2c053 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -13,7 +13,7 @@ from org_parser.text import CompletionCounter from org_parser.time import Timestamp -from org import config as config_module +import org.config.app from org.query_language import ( EvalContext, QueryParseError, @@ -224,7 +224,7 @@ def capture_template_names(templates: dict[str, dict[str, str]]) -> list[str]: def configured_capture_template_names() -> list[str]: """Return stable capture template names from loaded config.""" - return capture_template_names(config_module.CONFIG_CAPTURE_TEMPLATES) + return capture_template_names(org.config.app.CONFIG_CAPTURE_TEMPLATES) def parse_clock_duration(value: str) -> timedelta: diff --git a/src/org/commands/tasks/edit.py b/src/org/commands/tasks/edit.py index d0422a5..f9d7429 100644 --- a/src/org/commands/tasks/edit.py +++ b/src/org/commands/tasks/edit.py @@ -8,7 +8,8 @@ import typer -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import resolve_input_paths from org.commands.editor import edit_heading_subtree_in_external_editor from org.commands.tasks.common import ( @@ -101,7 +102,7 @@ def tasks_edit( query_id=query_id, query=query, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks edit") - config_module.log_command_arguments(args, "tasks edit") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks edit") + org.logging.log_command_arguments(args, "tasks edit") run_tasks_edit(args) diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index ebe968f..5b8e43d 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -12,7 +12,8 @@ import typer from org_parser.document import Heading -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import load_root_data from org.commands.tasks.common import normalize_selector from org.commands.tasks.query import get_query_formatter @@ -307,7 +308,7 @@ def find_command( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks find") - config_module.log_command_arguments(args, "tasks find") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks find") + org.logging.log_command_arguments(args, "tasks find") run_tasks_find(args) diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index d74d252..cc94a3a 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -11,7 +11,8 @@ import typer from rich.syntax import Syntax -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import load_and_process_data from org.commands.interactive_common import interactive_help_command_text from org.serde.format import ( @@ -642,8 +643,8 @@ def tasks_list( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - config_module.apply_config_defaults(args) + org.config.app.apply_config_defaults(args) args.noninteractive = details_switch_present or out_switch_present - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks list") - config_module.log_command_arguments(args, "tasks list") + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks list") + org.logging.log_command_arguments(args, "tasks list") run_tasks_list(args) diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index 5f6a152..a2c6713 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -16,7 +16,8 @@ from org_parser.time import Timestamp from rich.syntax import Syntax -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import load_root_data from org.query_language import ( EvalContext, @@ -381,7 +382,7 @@ def query_command( # noqa: PLR0913 out_theme=out_theme, pandoc_args=pandoc_args, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks query") - config_module.log_command_arguments(args, "tasks query") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks query") + org.logging.log_command_arguments(args, "tasks query") run_tasks_query(args) diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index 5725e3f..e012bb3 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -11,7 +11,8 @@ from rich.console import Console from rich.prompt import Confirm -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import resolve_input_paths from org.color import should_use_color from org.commands.tasks.common import ( @@ -162,7 +163,7 @@ def tasks_remove( # noqa: PLR0913 yes=yes, color_flag=color_flag, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks remove") - config_module.log_command_arguments(args, "tasks remove") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks remove") + org.logging.log_command_arguments(args, "tasks remove") run_tasks_remove(args) diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 75fe751..634df06 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -14,7 +14,8 @@ from rich.console import Console from rich.prompt import Confirm -from org import config as config_module +import org.config.app +import org.logging from org.cli_common import resolve_input_paths from org.color import should_use_color from org.commands.tasks.common import ( @@ -685,7 +686,7 @@ def tasks_update( # noqa: PLR0913 yes=yes, color_flag=color_flag, ) - config_module.apply_config_defaults(args) - config_module.log_applied_config_defaults(args, sys.argv[1:], "tasks update") - config_module.log_command_arguments(args, "tasks update") + org.config.app.apply_config_defaults(args) + org.logging.log_applied_config_defaults(args, sys.argv[1:], "tasks update") + org.logging.log_command_arguments(args, "tasks update") run_tasks_update(args) diff --git a/src/org/config/__init__.py b/src/org/config/__init__.py new file mode 100644 index 0000000..aee9045 --- /dev/null +++ b/src/org/config/__init__.py @@ -0,0 +1 @@ +"""Configuration package for the org CLI.""" diff --git a/src/org/config.py b/src/org/config/app.py similarity index 93% rename from src/org/config.py rename to src/org/config/app.py index 9a05f7d..6271a06 100644 --- a/src/org/config.py +++ b/src/org/config/app.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import logging import re from dataclasses import dataclass from datetime import datetime @@ -129,9 +128,6 @@ } -logger = logging.getLogger("org") - - def normalize_exclude_values(values: list[str]) -> set[str]: """Normalize exclude values to match file-based behavior.""" return {line.strip() for line in values if line.strip()} @@ -1147,82 +1143,6 @@ def build_default_map(defaults: dict[str, object]) -> CliDefaultMap: } -def _format_default_log_entry(option_name: str, value: object) -> str: - """Format one option/value pair for config-default logging.""" - return f"{option_name}={value!r}" - - -def _format_argument_log_entry(arg_name: str, value: object) -> str: - """Format one argument/value pair for command argument logging.""" - return f"{arg_name}={value!r}" - - -def _redact_inline_config_value(option_name: str, value: object) -> object: - """Redact inline mapping/exclude values in default logs.""" - if option_name in {"--mapping", "--exclude"} and isinstance(value, (dict, list)): - return "" - return value - - -def log_applied_config_defaults(_args: object, _argv: list[str], command_name: str) -> None: - """Log config defaults loaded from config file.""" - if not logger.isEnabledFor(logging.INFO): - return - - entries: list[str] = [] - - for dest, default_value in sorted(CONFIG_DEFAULTS.items(), key=lambda item: item[0]): - option_name = DEST_TO_OPTION_NAME.get(dest) - if option_name is None: - continue - entries.append( - _format_default_log_entry( - option_name, - _redact_inline_config_value(option_name, default_value), - ), - ) - - for dest, values in sorted(CONFIG_APPEND_DEFAULTS.items(), key=lambda item: item[0]): - option_name = DEST_TO_OPTION_NAME.get(dest) - if option_name is None: - continue - entries.append( - _format_default_log_entry( - option_name, - _redact_inline_config_value(option_name, values), - ), - ) - - for dest, option_name in ( - ("mapping_inline", "--mapping"), - ("exclude_inline", "--exclude"), - ): - inline_value = CONFIG_INLINE_DEFAULTS.get(dest) - if inline_value is None: - continue - entries.append(_format_default_log_entry(option_name, "")) - - if entries: - logger.info("Config defaults applied (%s): %s", command_name, ", ".join(entries)) - - -def log_command_arguments(args: object, command_name: str) -> None: - """Log all final argument values used to run a command.""" - if not logger.isEnabledFor(logging.INFO): - return - - try: - arg_items = vars(args).items() - except TypeError: - return - - entries = [ - _format_argument_log_entry(arg_name, arg_value) - for arg_name, arg_value in sorted(arg_items, key=lambda item: item[0]) - ] - logger.info("Command arguments (%s): %s", command_name, ", ".join(entries)) - - def apply_config_defaults(args: object) -> None: """Apply config-provided defaults for append and inline values.""" for dest, values in CONFIG_APPEND_DEFAULTS.items(): diff --git a/src/org/logging.py b/src/org/logging.py new file mode 100644 index 0000000..7c0995b --- /dev/null +++ b/src/org/logging.py @@ -0,0 +1,114 @@ +"""Logging configuration and command logging helpers.""" + +from __future__ import annotations + +import logging +import sys + +import org.config.app + + +LOGGER_NAME = "org" +logger = logging.getLogger(LOGGER_NAME) + + +def configure_logging(verbose: bool) -> None: + """Configure logging output based on verbosity. + + Args: + verbose: Whether to enable INFO logging to stdout + """ + logger.propagate = False + + if verbose: + logger.setLevel(logging.INFO) + if not any(isinstance(handler, logging.StreamHandler) for handler in logger.handlers): + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(handler) + else: + logger.setLevel(logging.WARNING) + logger.handlers.clear() + + +def _format_default_log_entry(option_name: str, value: object) -> str: + """Format one option/value pair for config-default logging.""" + return f"{option_name}={value!r}" + + +def _format_argument_log_entry(arg_name: str, value: object) -> str: + """Format one argument/value pair for command argument logging.""" + return f"{arg_name}={value!r}" + + +def _redact_inline_config_value(option_name: str, value: object) -> object: + """Redact inline mapping/exclude values in default logs.""" + if option_name in {"--mapping", "--exclude"} and isinstance(value, (dict, list)): + return "" + return value + + +def log_applied_config_defaults(_args: object, _argv: list[str], command_name: str) -> None: + """Log config defaults loaded from config file.""" + if not logger.isEnabledFor(logging.INFO): + return + + entries: list[str] = [] + + for dest, default_value in sorted( + org.config.app.CONFIG_DEFAULTS.items(), + key=lambda item: item[0], + ): + option_name = org.config.app.DEST_TO_OPTION_NAME.get(dest) + if option_name is None: + continue + entries.append( + _format_default_log_entry( + option_name, + _redact_inline_config_value(option_name, default_value), + ), + ) + + for dest, values in sorted( + org.config.app.CONFIG_APPEND_DEFAULTS.items(), + key=lambda item: item[0], + ): + option_name = org.config.app.DEST_TO_OPTION_NAME.get(dest) + if option_name is None: + continue + entries.append( + _format_default_log_entry( + option_name, + _redact_inline_config_value(option_name, values), + ), + ) + + for dest, option_name in ( + ("mapping_inline", "--mapping"), + ("exclude_inline", "--exclude"), + ): + inline_value = org.config.app.CONFIG_INLINE_DEFAULTS.get(dest) + if inline_value is None: + continue + entries.append(_format_default_log_entry(option_name, "")) + + if entries: + logger.info("Config defaults applied (%s): %s", command_name, ", ".join(entries)) + + +def log_command_arguments(args: object, command_name: str) -> None: + """Log all final argument values used to run a command.""" + if not logger.isEnabledFor(logging.INFO): + return + + try: + arg_items = vars(args).items() + except TypeError: + return + + entries = [ + _format_argument_log_entry(arg_name, arg_value) + for arg_name, arg_value in sorted(arg_items, key=lambda item: item[0]) + ] + logger.info("Command arguments (%s): %s", command_name, ", ".join(entries)) diff --git a/src/org/logging_config.py b/src/org/logging_config.py deleted file mode 100644 index c4bc7ef..0000000 --- a/src/org/logging_config.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Logging configuration for the org CLI.""" - -from __future__ import annotations - -import logging -import sys - - -LOGGER_NAME = "org" - - -def configure_logging(verbose: bool) -> None: - """Configure logging output based on verbosity. - - Args: - verbose: Whether to enable INFO logging to stdout - """ - logger = logging.getLogger(LOGGER_NAME) - logger.propagate = False - - if verbose: - logger.setLevel(logging.INFO) - if not any(isinstance(handler, logging.StreamHandler) for handler in logger.handlers): - handler = logging.StreamHandler(sys.stdout) - handler.setLevel(logging.INFO) - handler.setFormatter(logging.Formatter("%(message)s")) - logger.addHandler(handler) - else: - logger.setLevel(logging.WARNING) - logger.handlers.clear() diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 34c2250..e80a97e 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -8,7 +8,8 @@ import typer -from org import cli, config +import org.config.app +from org import cli if TYPE_CHECKING: @@ -36,9 +37,9 @@ def fake_get_command(_app: object) -> DummyCommand: return DummyCommand() monkeypatch.setattr( - config, + org.config.app, "load_cli_config", - lambda _argv: config.LoadedCliConfig( + lambda _argv: org.config.app.LoadedCliConfig( defaults={"max_results": 3}, append_defaults={}, inline_defaults={}, @@ -51,7 +52,7 @@ def fake_get_command(_app: object) -> DummyCommand: ), ) monkeypatch.setattr( - config, + org.config.app, "build_default_map", lambda _defaults: {"stats": _defaults, "agenda": {}}, ) @@ -68,28 +69,28 @@ def fake_get_command(_app: object) -> DummyCommand: def test_cli_main_updates_config_globals(monkeypatch: pytest.MonkeyPatch) -> None: """main should update config append and inline defaults.""" - original_append = dict(config.CONFIG_APPEND_DEFAULTS) - original_inline = dict(config.CONFIG_INLINE_DEFAULTS) - original_custom_filters = dict(config.CONFIG_CUSTOM_FILTERS) - original_custom_order_by = dict(config.CONFIG_CUSTOM_ORDER_BY) - original_custom_with = dict(config.CONFIG_CUSTOM_WITH) - original_capture_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) - original_board_views = dict(config.CONFIG_BOARD_VIEWS) - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_ORDER_BY.clear() - config.CONFIG_CUSTOM_WITH.clear() - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_BOARD_VIEWS.clear() + original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) + original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) + original_custom_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) + original_custom_order_by = dict(org.config.app.CONFIG_CUSTOM_ORDER_BY) + original_custom_with = dict(org.config.app.CONFIG_CUSTOM_WITH) + original_capture_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) + original_board_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() + org.config.app.CONFIG_CUSTOM_WITH.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_BOARD_VIEWS.clear() def fake_get_command(_app: object) -> SimpleNamespace: return SimpleNamespace(main=lambda **_: None) monkeypatch.setattr( - config, + org.config.app, "load_cli_config", - lambda _argv: config.LoadedCliConfig( + lambda _argv: org.config.app.LoadedCliConfig( defaults={}, append_defaults={"filter_tags": ["alpha"]}, inline_defaults={"mapping_inline": {"a": "b"}}, @@ -98,45 +99,45 @@ def fake_get_command(_app: object) -> SimpleNamespace: custom_with={"my-with": "."}, capture_templates={"quick": {"file": "tasks.org", "content": "* TODO {{title}}"}}, board_views={ - "kanban": config.BoardViewConfig( + "kanban": org.config.app.BoardViewConfig( name="kanban", columns=[ - config.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), + org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), ], ), }, agenda_views={}, ), ) - monkeypatch.setattr(config, "build_default_map", lambda _defaults: {}) + monkeypatch.setattr(org.config.app, "build_default_map", lambda _defaults: {}) monkeypatch.setattr(typer.main, "get_command", fake_get_command) monkeypatch.setattr(sys, "argv", ["org", "stats", "all"]) try: cli.main() - assert config.CONFIG_APPEND_DEFAULTS == {"filter_tags": ["alpha"]} - assert config.CONFIG_INLINE_DEFAULTS == {"mapping_inline": {"a": "b"}} - assert config.CONFIG_CUSTOM_FILTERS == {"my-filter": ".[]"} - assert config.CONFIG_CUSTOM_ORDER_BY == {"my-order": "."} - assert config.CONFIG_CUSTOM_WITH == {"my-with": "."} - assert config.CONFIG_CAPTURE_TEMPLATES == { + assert org.config.app.CONFIG_APPEND_DEFAULTS == {"filter_tags": ["alpha"]} + assert org.config.app.CONFIG_INLINE_DEFAULTS == {"mapping_inline": {"a": "b"}} + assert org.config.app.CONFIG_CUSTOM_FILTERS == {"my-filter": ".[]"} + assert org.config.app.CONFIG_CUSTOM_ORDER_BY == {"my-order": "."} + assert org.config.app.CONFIG_CUSTOM_WITH == {"my-with": "."} + assert org.config.app.CONFIG_CAPTURE_TEMPLATES == { "quick": {"file": "tasks.org", "content": "* TODO {{title}}"}, } - assert set(config.CONFIG_BOARD_VIEWS) == {"kanban"} - assert config.CONFIG_BOARD_VIEWS["kanban"].name == "kanban" + assert set(org.config.app.CONFIG_BOARD_VIEWS) == {"kanban"} + assert org.config.app.CONFIG_BOARD_VIEWS["kanban"].name == "kanban" finally: - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(original_append) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(original_inline) - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_custom_filters) - config.CONFIG_CUSTOM_ORDER_BY.clear() - config.CONFIG_CUSTOM_ORDER_BY.update(original_custom_order_by) - config.CONFIG_CUSTOM_WITH.clear() - config.CONFIG_CUSTOM_WITH.update(original_custom_with) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_capture_templates) - config.CONFIG_BOARD_VIEWS.clear() - config.CONFIG_BOARD_VIEWS.update(original_board_views) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_custom_filters) + org.config.app.CONFIG_CUSTOM_ORDER_BY.clear() + org.config.app.CONFIG_CUSTOM_ORDER_BY.update(original_custom_order_by) + org.config.app.CONFIG_CUSTOM_WITH.clear() + org.config.app.CONFIG_CUSTOM_WITH.update(original_custom_with) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_capture_templates) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_board_views) diff --git a/tests/cli/test_cli_runner.py b/tests/cli/test_cli_runner.py index cc23a84..feccd61 100644 --- a/tests/cli/test_cli_runner.py +++ b/tests/cli/test_cli_runner.py @@ -8,7 +8,7 @@ from typer.testing import CliRunner -from org import config +import org.config.app from org.cli import app @@ -200,11 +200,11 @@ def test_cli_runner_tasks_list_custom_filter_without_arg() -> None: """Custom filter switches should not be parsed as FILE arguments.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "multiple_tags.org").resolve()) - original_filters = dict(config.CONFIG_CUSTOM_FILTERS) + original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) try: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update({"has-todo": "select(.todo != null)"}) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update({"has-todo": "select(.todo != null)"}) result = runner.invoke( app, @@ -223,19 +223,19 @@ def test_cli_runner_tasks_list_custom_filter_without_arg() -> None: assert "* TODO Refactor" in result.stdout assert ":Maintenance:" in result.stdout finally: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_filters) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) def test_cli_runner_tasks_list_custom_filter_with_arg() -> None: """Custom filter argument value should not be parsed as FILE argument.""" runner = CliRunner() fixture_path = str((FIXTURES_DIR / "multiple_tags.org").resolve()) - original_filters = dict(config.CONFIG_CUSTOM_FILTERS) + original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) try: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) result = runner.invoke( app, @@ -255,18 +255,18 @@ def test_cli_runner_tasks_list_custom_filter_with_arg() -> None: assert "* TODO Refactor" in result.stdout assert ":Maintenance:" in result.stdout finally: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_filters) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) def test_cli_runner_tasks_list_custom_filter_required_arg_error() -> None: """Custom filters with $arg should fail when the argument is missing.""" runner = CliRunner() - original_filters = dict(config.CONFIG_CUSTOM_FILTERS) + original_filters = dict(org.config.app.CONFIG_CUSTOM_FILTERS) try: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update({"level-above": "select(.level > $arg)"}) result = runner.invoke( app, @@ -278,8 +278,8 @@ def test_cli_runner_tasks_list_custom_filter_required_arg_error() -> None: combined_output = clean_combined_output(result) assert "--filter-level-above requires exactly one argument" in combined_output finally: - config.CONFIG_CUSTOM_FILTERS.clear() - config.CONFIG_CUSTOM_FILTERS.update(original_filters) + org.config.app.CONFIG_CUSTOM_FILTERS.clear() + org.config.app.CONFIG_CUSTOM_FILTERS.update(original_filters) def test_cli_runner_board_requires_tty() -> None: @@ -825,11 +825,11 @@ def test_cli_runner_capture_direct_template_path(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -844,8 +844,8 @@ def test_cli_runner_capture_direct_template_path(tmp_path: Path) -> None: updated = target.read_text(encoding="utf-8") assert "* TODO Write docs" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_uses_interactive_form_runner( @@ -856,7 +856,7 @@ def test_cli_runner_capture_uses_interactive_form_runner( runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) support_path = "org.commands.tasks.capture.command._is_interactive_terminal" form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" @@ -866,8 +866,8 @@ def test_cli_runner_capture_uses_interactive_form_runner( form_runner_path, lambda plan: {**plan.values, "title": "Write docs", "owner": "Jane"}, ) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -881,8 +881,8 @@ def test_cli_runner_capture_uses_interactive_form_runner( assert result.exit_code == 0 assert "* TODO Write docs @Jane" in target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: @@ -892,11 +892,11 @@ def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: override_target = tmp_path / "override.org" configured_target.write_text("* TODO Configured\n", encoding="utf-8") override_target.write_text("* TODO Override\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(configured_target), @@ -922,8 +922,8 @@ def test_cli_runner_capture_accepts_file_override(tmp_path: Path) -> None: assert "Write docs" not in configured_target.read_text(encoding="utf-8") assert "Write docs" in override_target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: @@ -934,11 +934,11 @@ def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", encoding="utf-8", ) - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "child": { "file": str(target), @@ -958,8 +958,8 @@ def test_cli_runner_capture_accepts_parent_override(tmp_path: Path) -> None: assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO From cli\n" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> None: @@ -970,11 +970,11 @@ def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> Non "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n", encoding="utf-8", ) - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "child": { "file": str(target), @@ -994,8 +994,8 @@ def test_cli_runner_capture_parent_override_accepts_title(tmp_path: Path) -> Non assert "* TODO One\n:PROPERTIES:\n:ID: one\n:END:\n\n" in updated assert "* TODO Two\n:PROPERTIES:\n:ID: two\n:END:\n** TODO By title\n" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: @@ -1003,11 +1003,11 @@ def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1022,8 +1022,8 @@ def test_cli_runner_capture_set_values_bypass_prompt(tmp_path: Path) -> None: assert result.stdout.strip() == "From cli (From cli)" assert "* TODO From cli (From cli)" in target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> None: @@ -1031,11 +1031,11 @@ def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1050,8 +1050,8 @@ def test_cli_runner_capture_non_interactive_prefers_id_output(tmp_path: Path) -> assert result.stdout.strip() == "task-99" assert "* TODO From cli" in target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) -> None: @@ -1059,11 +1059,11 @@ def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1077,8 +1077,8 @@ def test_cli_runner_capture_set_values_ignores_unknown_parameter(tmp_path: Path) assert result.exit_code == 0 assert "* TODO Static" in target.read_text(encoding="utf-8") finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_accepts_empty_placeholder_value( @@ -1089,15 +1089,15 @@ def test_cli_runner_capture_accepts_empty_placeholder_value( runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) support_path = "org.commands.tasks.capture.command._is_interactive_terminal" form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" try: monkeypatch.setattr(support_path, lambda: True) monkeypatch.setattr(form_runner_path, lambda plan: {**plan.values, "title": ""}) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "quick": { "file": str(target), @@ -1113,8 +1113,8 @@ def test_cli_runner_capture_accepts_empty_placeholder_value( assert "* TODO Existing" in updated assert updated.count("* TODO") == 2 finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_interactive_numeric_selection( @@ -1125,7 +1125,7 @@ def test_cli_runner_capture_interactive_numeric_selection( runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) support_path = "org.commands.tasks.capture.command._is_interactive_terminal" selection_path = "org.commands.tasks.capture.command.run_template_selection_app" form_runner_path = "org.commands.tasks.capture.command.run_capture_form_app" @@ -1137,8 +1137,8 @@ def test_cli_runner_capture_interactive_numeric_selection( form_runner_path, lambda plan: {**plan.values, "title": "Captured task"}, ) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "alpha": {"file": str(target), "content": "* TODO Alpha"}, "beta": {"file": str(target), "content": "* TODO {{title}}"}, @@ -1151,8 +1151,8 @@ def test_cli_runner_capture_interactive_numeric_selection( updated = target.read_text(encoding="utf-8") assert "* TODO Captured task" in updated finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) -> None: @@ -1160,11 +1160,11 @@ def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) - runner = CliRunner() target = tmp_path / "tasks.org" target.write_text("* TODO Existing\n", encoding="utf-8") - original_templates = dict(config.CONFIG_CAPTURE_TEMPLATES) + original_templates = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) try: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update( + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update( { "alpha": {"file": str(target), "content": "* TODO Alpha"}, "beta": {"file": str(target), "content": "* TODO Beta"}, @@ -1179,5 +1179,5 @@ def test_cli_runner_capture_unknown_template_lists_valid_names(tmp_path: Path) - assert "Valid templates: alpha," in combined_output assert "beta" in combined_output finally: - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(original_templates) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(original_templates) diff --git a/tests/cli_common/test_file_errors.py b/tests/cli_common/test_file_errors.py index f499d58..a1cc22a 100644 --- a/tests/cli_common/test_file_errors.py +++ b/tests/cli_common/test_file_errors.py @@ -16,7 +16,7 @@ def test_load_exclude_list_file_not_found() -> None: """Test that loading non-existent exclude list raises error.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list with pytest.raises(typer.BadParameter, match="Exclude list file"): load_exclude_list("/nonexistent/path/to/exclude.txt") @@ -24,7 +24,7 @@ def test_load_exclude_list_file_not_found() -> None: def test_load_exclude_list_none() -> None: """Test that None filepath returns empty set.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list result = load_exclude_list(None) assert result == set() @@ -32,7 +32,7 @@ def test_load_exclude_list_none() -> None: def test_load_exclude_list_valid(tmp_path: Path) -> None: """Test loading valid exclude list.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list exclude_file = tmp_path / "exclude.txt" exclude_file.write_text("word1\nword2\nWORD3\n") @@ -43,7 +43,7 @@ def test_load_exclude_list_valid(tmp_path: Path) -> None: def test_load_exclude_list_with_empty_lines(tmp_path: Path) -> None: """Test loading exclude list with empty lines.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list exclude_file = tmp_path / "exclude.txt" exclude_file.write_text("word1\n\nword2\n\n\nword3\n") @@ -54,7 +54,7 @@ def test_load_exclude_list_with_empty_lines(tmp_path: Path) -> None: def test_load_exclude_list_with_whitespace(tmp_path: Path) -> None: """Test loading exclude list with whitespace.""" - from org.config import load_exclude_list + from org.config.app import load_exclude_list exclude_file = tmp_path / "exclude.txt" exclude_file.write_text(" word1 \n\t word2\t\nword3 ") @@ -65,7 +65,7 @@ def test_load_exclude_list_with_whitespace(tmp_path: Path) -> None: def test_load_mapping_file_not_found() -> None: """Test that loading non-existent mapping file raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping with pytest.raises(typer.BadParameter, match="Mapping file"): load_mapping("/nonexistent/path/to/mapping.json") @@ -73,7 +73,7 @@ def test_load_mapping_file_not_found() -> None: def test_load_mapping_none() -> None: """Test that None filepath returns empty dict.""" - from org.config import load_mapping + from org.config.app import load_mapping result = load_mapping(None) assert result == {} @@ -81,7 +81,7 @@ def test_load_mapping_none() -> None: def test_load_mapping_valid(tmp_path: Path) -> None: """Test loading valid mapping file.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": "testing", "webdev": "frontend"}') @@ -92,7 +92,7 @@ def test_load_mapping_valid(tmp_path: Path) -> None: def test_load_mapping_empty_dict(tmp_path: Path) -> None: """Test loading empty mapping dict.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text("{}") @@ -103,7 +103,7 @@ def test_load_mapping_empty_dict(tmp_path: Path) -> None: def test_load_mapping_invalid_json(tmp_path: Path) -> None: """Test that invalid JSON raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": "testing",') @@ -114,7 +114,7 @@ def test_load_mapping_invalid_json(tmp_path: Path) -> None: def test_load_mapping_non_dict_json_array(tmp_path: Path) -> None: """Test that JSON array raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('["test", "testing"]') @@ -125,7 +125,7 @@ def test_load_mapping_non_dict_json_array(tmp_path: Path) -> None: def test_load_mapping_non_dict_json_string(tmp_path: Path) -> None: """Test that JSON string raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('"test string"') @@ -136,7 +136,7 @@ def test_load_mapping_non_dict_json_string(tmp_path: Path) -> None: def test_load_mapping_non_dict_json_number(tmp_path: Path) -> None: """Test that JSON number raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text("42") @@ -147,7 +147,7 @@ def test_load_mapping_non_dict_json_number(tmp_path: Path) -> None: def test_load_mapping_non_string_keys(tmp_path: Path) -> None: """Test that mapping with valid string keys works (JSON converts int keys to strings).""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"123": "testing"}') @@ -159,7 +159,7 @@ def test_load_mapping_non_string_keys(tmp_path: Path) -> None: def test_load_mapping_non_string_values(tmp_path: Path) -> None: """Test that mapping with non-string values raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": 123}') @@ -170,7 +170,7 @@ def test_load_mapping_non_string_values(tmp_path: Path) -> None: def test_load_mapping_mixed_non_string_types(tmp_path: Path) -> None: """Test that mapping with mixed non-string types raises error.""" - from org.config import load_mapping + from org.config.app import load_mapping mapping_file = tmp_path / "mapping.json" mapping_file.write_text('{"test": "testing", "another": ["array"]}') diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index a6c92d7..68ed3f6 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -202,19 +202,19 @@ def test_build_query_text_with_custom_filter_and_optional_arg( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom filter switches should bind $arg and support omitted values.""" - from org import config + import org.config.app from org.cli_common import build_query_text monkeypatch.setattr( - config, + org.config.app, "CONFIG_CUSTOM_FILTERS", { "todo-state": "select(.todo == $arg)", "has-todo": "select(.todo != null)", }, ) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = [ @@ -239,12 +239,12 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom arguments are embedded in query text and not added to context vars.""" - from org import config + import org.config.app from org.cli_common import collect_custom_context_vars - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(files=["file.org"]) argv = [ @@ -271,12 +271,12 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyPatch) -> None: """Custom enrichment stages should be applied before filters.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(filter_completed=True, files=["file.org"]) argv = [ @@ -300,12 +300,12 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( monkeypatch: pytest.MonkeyPatch, ) -> None: """Built-in and custom with stages should preserve CLI order.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(with_tags_as_category=True, files=["file.org"]) argv = [ @@ -328,12 +328,12 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPatch) -> None: """Custom order-by switches should work even when built-in ordering is disabled.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) args = make_args(files=["file.org"]) argv = ["org", "stats", "all", "--order-by-weight", "file.org"] @@ -361,12 +361,12 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( monkeypatch: pytest.MonkeyPatch, ) -> None: """Built-in and custom ordering switches should follow CLI specification order.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) args = make_args(order_by_timestamp_desc=True, files=["file.org"]) argv = [ @@ -388,12 +388,12 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.MonkeyPatch) -> None: """Unknown prefixed custom switches should fail validation.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--filter-unknown", "file.org"] @@ -406,12 +406,16 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom filters using $arg should require exactly one argument.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"level-above": "select(.level > $arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr( + org.config.app, + "CONFIG_CUSTOM_FILTERS", + {"level-above": "select(.level > $arg)"}, + ) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--filter-level-above"] @@ -427,12 +431,12 @@ def test_build_query_text_custom_order_by_requires_exactly_one_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom order-by switches using $arg should require exactly one argument.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--order-by-weight"] @@ -445,12 +449,12 @@ def test_build_query_text_custom_with_requires_exactly_one_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Custom enrichment switches using $arg should require exactly one argument.""" - from org import config + import org.config.app from org.cli_common import build_query_text - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {"mark": '. + {"x": $arg}'}) args = make_args(files=["file.org"]) argv = ["org", "tasks", "list", "--with-mark"] @@ -463,12 +467,12 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( monkeypatch: pytest.MonkeyPatch, ) -> None: """Required custom arguments should consume path-like values.""" - from org import config + import org.config.app from org.cli_common import normalize_cli_files_for_custom_switches - monkeypatch.setattr(config, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_ORDER_BY", {}) - monkeypatch.setattr(config, "CONFIG_CUSTOM_WITH", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) files = ["--filter-value", "README.md", "file.org"] diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 0b512cf..7bddc66 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -14,7 +14,7 @@ import typer from rich.console import Console -from org import config as config_module +import org.config.app from org.commands import archive as archive_command from org.commands import editor as editor_command from org.commands.interactive_common import heading_locator @@ -850,7 +850,7 @@ def test_apply_capture_task_captures_task_and_reloads( captured_node = node_from_org("* TODO Captured\n")[0] reload_args: dict[str, object] = {} monkeypatch.setattr( - config_module, + org.config.app, "CONFIG_CAPTURE_TEMPLATES", {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, ) diff --git a/tests/commands/tasks/test_tasks_list_app.py b/tests/commands/tasks/test_tasks_list_app.py index 8d6d6e4..4cad6ef 100644 --- a/tests/commands/tasks/test_tasks_list_app.py +++ b/tests/commands/tasks/test_tasks_list_app.py @@ -7,7 +7,7 @@ from textual.widgets import Input, OptionList, Static -from org import config as config_module +import org.config.app from org.commands.tasks.list import command as tasks_list from org.commands.tasks.list.app import TasksListApp from tests.commands.tasks.test_tasks_list import make_list_args @@ -142,7 +142,7 @@ async def _run() -> None: nodes = node_from_org("* TODO Alpha\n") app = TasksListApp(make_list_args([]), _make_session_data(nodes)) called: list[str] = [] - monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {"quick": {}, "later": {}}) + monkeypatch.setattr(org.config.app, "CONFIG_CAPTURE_TEMPLATES", {"quick": {}, "later": {}}) monkeypatch.setattr( "org.commands.tasks.list.actions.apply_capture_task", lambda _session, template_name: called.append(template_name), diff --git a/tests/commands/test_agenda_app.py b/tests/commands/test_agenda_app.py index 3cf0a55..0dd050e 100644 --- a/tests/commands/test_agenda_app.py +++ b/tests/commands/test_agenda_app.py @@ -11,7 +11,7 @@ import org_parser from textual.widgets import Input, OptionList, Static -from org import config as config_module +import org.config.app from org.commands.agenda import actions, ui from org.commands.agenda import command as agenda_command from org.commands.agenda.app import AgendaApp, AgendaViewport @@ -179,7 +179,7 @@ async def _run() -> None: args = _make_args([fixture_path], date="2025-01-15") captured_node = next(iter(org_parser.loads("* TODO Captured\n"))) monkeypatch.setattr( - config_module, + org.config.app, "CONFIG_CAPTURE_TEMPLATES", {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, ) diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index baa49c5..163726c 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -14,7 +14,7 @@ from org_parser.time import Timestamp from rich.console import Console -from org import config as config_module +import org.config.app from org.cli_common import load_and_process_data from org.commands import interactive_common from org.commands.agenda import actions, ui @@ -490,11 +490,11 @@ def test_run_agenda_plain_view_rows_show_relative_day_labels( "DEADLINE: <2025-01-18 Sat>\n", ) - config_module.CONFIG_AGENDA_VIEWS.clear() - config_module.CONFIG_AGENDA_VIEWS["plain"] = config_module.AgendaViewConfig( + org.config.app.CONFIG_AGENDA_VIEWS.clear() + org.config.app.CONFIG_AGENDA_VIEWS["plain"] = org.config.app.AgendaViewConfig( name="plain", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Planning", filter=".scheduled != null or .deadline != null", order_by=None, @@ -524,10 +524,10 @@ def test_build_view_day_model_plain_rows_choose_matching_planning_source() -> No "* TODO Scheduled row\nSCHEDULED: <2025-01-13 Mon>\n", ) nodes = list(root) - view = config_module.AgendaViewConfig( + view = org.config.app.AgendaViewConfig( name="plain", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Planning", filter=".scheduled != null or .deadline != null", order_by=None, @@ -570,11 +570,11 @@ def test_run_agenda_timeline_view_appends_selected_untimed_tasks( "SCHEDULED: <2025-01-15 Wed>\n", ) - config_module.CONFIG_AGENDA_VIEWS.clear() - config_module.CONFIG_AGENDA_VIEWS["timeline"] = config_module.AgendaViewConfig( + org.config.app.CONFIG_AGENDA_VIEWS.clear() + org.config.app.CONFIG_AGENDA_VIEWS["timeline"] = org.config.app.AgendaViewConfig( name="timeline", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Agenda", filter=".scheduled != null", order_by=None, @@ -608,10 +608,10 @@ def test_build_view_day_model_timeline_rows_append_untimed_with_deadline_precede "* TODO Both row\nSCHEDULED: <2025-01-14 Tue>\nDEADLINE: <2025-01-17 Fri>\n", ) nodes = list(root) - view = config_module.AgendaViewConfig( + view = org.config.app.AgendaViewConfig( name="timeline", sections=[ - config_module.AgendaSectionConfig( + org.config.app.AgendaSectionConfig( name="Agenda", filter=".scheduled != null or .deadline != null", order_by=None, diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index abb345c..dd2d521 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -14,7 +14,7 @@ from rich.console import Console from rich.text import Text -from org import config as config_module +import org.config.app from org.cli_common import load_and_process_data from org.commands import archive as archive_command from org.commands import editor as editor_command @@ -309,10 +309,10 @@ def test_build_selector_board_columns_with_order_by_overrides_processed_order() nodes = node_from_org( "* TODO a\n* TODO c\n* TODO b\n", ) - view = config_module.BoardViewConfig( + view = org.config.app.BoardViewConfig( name="ordered", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="TODO", filter=".todo != null", order_by=".title_text", @@ -332,11 +332,11 @@ def test_build_selector_board_columns_with_order_by_overrides_processed_order() def test_build_selector_board_columns_allows_matches_in_multiple_columns() -> None: """One task can appear in multiple selector columns.""" nodes = node_from_org("* TODO Shared\n* DONE Finished\n") - view = config_module.BoardViewConfig( + view = org.config.app.BoardViewConfig( name="overlap", columns=[ - config_module.BoardColumnConfig(name="Any todo", filter=".todo != null"), - config_module.BoardColumnConfig(name="Open", filter="not(.is_completed)"), + org.config.app.BoardColumnConfig(name="Any todo", filter=".todo != null"), + org.config.app.BoardColumnConfig(name="Open", filter="not(.is_completed)"), ], ) @@ -354,10 +354,10 @@ def test_build_selector_board_columns_allows_matches_in_multiple_columns() -> No def test_build_selector_board_columns_omits_non_matching_tasks() -> None: """Tasks that match no selectors should not appear in any column.""" nodes = node_from_org("* TODO Open\n* DONE Closed\n") - view = config_module.BoardViewConfig( + view = org.config.app.BoardViewConfig( name="nomatch", columns=[ - config_module.BoardColumnConfig(name="Backlog", filter=".todo == null"), + org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), ], ) @@ -813,12 +813,12 @@ def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( second_handle.write("* TODO Second file task\n") args = make_board_args([first_path, second_path], view="kanban", width=160) - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="TODO", filter='str(.todo) == "TODO"'), + org.config.app.BoardColumnConfig(name="TODO", filter='str(.todo) == "TODO"'), ], ) @@ -828,8 +828,8 @@ def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( assert "First file task" in output assert "Second file task" in output finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_run_flow_board_coalesce_completed_true_shows_completed_column( @@ -944,16 +944,16 @@ def test_run_flow_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyP """Requested configured view should render configured columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view="kanban", width=150) - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Backlog", filter=".todo == null", ), - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Working", filter=".todo != null and not(.is_completed)", ), @@ -966,20 +966,20 @@ def test_run_flow_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyP assert "Backlog" in output assert "Working" in output finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_run_flow_board_missing_requested_view_raises(monkeypatch: pytest.MonkeyPatch) -> None: """Missing requested view should return explicit BadParameter.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="missing") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["other"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["other"] = org.config.app.BoardViewConfig( name="other", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="TODO", filter='.todo == "TODO"', ), @@ -991,8 +991,8 @@ def test_run_flow_board_missing_requested_view_raises(monkeypatch: pytest.Monkey with pytest.raises(typer.BadParameter, match="Requested board view not found"): board_command.run_flow_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_run_flow_board_requested_view_without_configured_views_raises( @@ -1001,29 +1001,29 @@ def test_run_flow_board_requested_view_without_configured_views_raises( """Explicit --view should fail when no configured views exist.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() try: monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="no board views are configured"): board_command.run_flow_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_run_flow_board_uses_default_view_from_config(monkeypatch: pytest.MonkeyPatch) -> None: """Config-defaulted view value should drive board view resolution.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view=None, width=150) - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="Backlog", filter=".todo == null"), - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig(name="Backlog", filter=".todo == null"), + org.config.app.BoardColumnConfig( name="Working", filter=".todo != null and not(.is_completed)", ), @@ -1039,8 +1039,8 @@ def test_run_flow_board_uses_default_view_from_config(monkeypatch: pytest.Monkey output = _render_board_output(args) assert "Backlog" in output finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context( @@ -1049,12 +1049,12 @@ def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context( """Filter/order-by parse failures should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="Broken", filter=".todo =="), + org.config.app.BoardColumnConfig(name="Broken", filter=".todo =="), ], ) try: @@ -1063,8 +1063,8 @@ def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context( with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): board_command.run_flow_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( @@ -1073,12 +1073,12 @@ def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( """Filter/order-by runtime failures should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Broken", filter="unknown_fn(.todo)", ), @@ -1095,8 +1095,8 @@ def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): board_command.run_flow_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_run_flow_board_invalid_order_by_parse_error_has_context( @@ -1105,12 +1105,12 @@ def test_run_flow_board_invalid_order_by_parse_error_has_context( """Invalid order-by parse should include view and column context.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="kanban") - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig( + org.config.app.BoardColumnConfig( name="Broken", filter='.todo == "TODO"', order_by=".priority ==", @@ -1126,8 +1126,8 @@ def test_run_flow_board_invalid_order_by_parse_error_has_context( ): board_command.run_flow_board(args) finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_apply_state_move_reload_reassigns_task_across_selector_columns( @@ -1136,13 +1136,13 @@ def test_apply_state_move_reload_reassigns_task_across_selector_columns( """State changes should reload and move task to new selector column.""" args = make_board_args([], view="kanban", days=100000) source_node = node_from_org("#+TODO: TODO | DONE\n* TODO Task\n")[0] - original_views = dict(config_module.CONFIG_BOARD_VIEWS) - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS["kanban"] = config_module.BoardViewConfig( + original_views = dict(org.config.app.CONFIG_BOARD_VIEWS) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS["kanban"] = org.config.app.BoardViewConfig( name="kanban", columns=[ - config_module.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), - config_module.BoardColumnConfig(name="DONE", filter='.todo == "DONE"'), + org.config.app.BoardColumnConfig(name="TODO", filter='.todo == "TODO"'), + org.config.app.BoardColumnConfig(name="DONE", filter='.todo == "DONE"'), ], ) @@ -1189,8 +1189,8 @@ def _load_after_change( assert selected is not None assert selected.todo == "DONE" finally: - config_module.CONFIG_BOARD_VIEWS.clear() - config_module.CONFIG_BOARD_VIEWS.update(original_views) + org.config.app.CONFIG_BOARD_VIEWS.clear() + org.config.app.CONFIG_BOARD_VIEWS.update(original_views) def test_build_task_panel_renders_rich_title_content() -> None: diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 72c7e26..e460bf7 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -9,7 +9,7 @@ from textual.widgets import Input, OptionList, Static -from org import config as config_module +import org.config.app from org.commands.board import actions from org.commands.board import command as board_command from org.commands.board.app import BoardApp, BoardViewport @@ -190,7 +190,7 @@ async def _run() -> None: captured_node = node_from_org("* TODO Captured\n")[0] reloaded: dict[str, object] = {} monkeypatch.setattr( - config_module, + org.config.app, "CONFIG_CAPTURE_TEMPLATES", {"quick": {"file": "tasks.org", "content": "* TODO Captured"}}, ) @@ -228,7 +228,7 @@ def test_board_app_capture_without_templates_sets_status(monkeypatch: pytest.Mon """Capture should report a clear status when no templates are configured.""" async def _run() -> None: - monkeypatch.setattr(config_module, "CONFIG_CAPTURE_TEMPLATES", {}) + monkeypatch.setattr(org.config.app, "CONFIG_CAPTURE_TEMPLATES", {}) app = _make_app(_make_session(make_board_args([]), node_from_org("* TODO Existing\n"))) async with app.run_test(): diff --git a/tests/commands/test_tasks_capture_command.py b/tests/commands/test_tasks_capture_command.py index b660a4e..0228ab4 100644 --- a/tests/commands/test_tasks_capture_command.py +++ b/tests/commands/test_tasks_capture_command.py @@ -9,7 +9,7 @@ import pytest import typer -from org import config +import org.config.app from org.commands.tasks import capture @@ -36,16 +36,16 @@ def _make_capture_args( def _set_capture_templates(templates: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]: """Replace global capture templates and return previous value.""" - previous = dict(config.CONFIG_CAPTURE_TEMPLATES) - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(templates) + previous = dict(org.config.app.CONFIG_CAPTURE_TEMPLATES) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(templates) return previous def _restore_capture_templates(previous: dict[str, dict[str, str]]) -> None: """Restore global capture templates from previous value.""" - config.CONFIG_CAPTURE_TEMPLATES.clear() - config.CONFIG_CAPTURE_TEMPLATES.update(previous) + org.config.app.CONFIG_CAPTURE_TEMPLATES.clear() + org.config.app.CONFIG_CAPTURE_TEMPLATES.update(previous) def test_run_capture_errors_when_no_templates_configured() -> None: diff --git a/tests/config/test_config_core.py b/tests/config/test_config_core.py index 56fb1c0..555eded 100644 --- a/tests/config/test_config_core.py +++ b/tests/config/test_config_core.py @@ -9,7 +9,8 @@ import pytest import typer -from org import config +import org.config.app +import org.logging if TYPE_CHECKING: @@ -19,7 +20,7 @@ def test_load_config_missing_file_returns_empty(tmp_path: Path) -> None: """Missing config should return empty config without error.""" missing = tmp_path / "missing.yaml" - data, malformed = config.load_config(str(missing)) + data, malformed = org.config.app.load_config(str(missing)) assert data == {} assert malformed is False @@ -29,7 +30,7 @@ def test_load_config_directory_path_is_malformed(tmp_path: Path) -> None: """Directory path should be treated as malformed config.""" config_dir = tmp_path / "cfgdir" config_dir.mkdir() - data, malformed = config.load_config(str(config_dir)) + data, malformed = org.config.app.load_config(str(config_dir)) assert data == {} assert malformed is True @@ -40,7 +41,7 @@ def test_load_config_non_dict_is_malformed(tmp_path: Path) -> None: config_path = tmp_path / "config.yaml" config_path.write_text("- 1\n- 2\n- 3\n", encoding="utf-8") - data, malformed = config.load_config(str(config_path)) + data, malformed = org.config.app.load_config(str(config_path)) assert data == {} assert malformed is True @@ -48,7 +49,7 @@ def test_load_config_non_dict_is_malformed(tmp_path: Path) -> None: def test_parse_color_defaults_conflict() -> None: """Conflicting color flags should be rejected.""" - defaults, valid = config.parse_color_defaults({"--color": True, "--no-color": True}) + defaults, valid = org.config.app.parse_color_defaults({"--color": True, "--no-color": True}) assert defaults == {} assert valid is False @@ -56,7 +57,7 @@ def test_parse_color_defaults_conflict() -> None: def test_parse_color_defaults_ignores_value() -> None: """Color defaults should be driven by key presence, not YAML value.""" - defaults, valid = config.parse_color_defaults({"--color": "yes"}) + defaults, valid = org.config.app.parse_color_defaults({"--color": "yes"}) assert defaults == {"color_flag": True} assert valid is True @@ -72,7 +73,7 @@ def test_build_config_defaults_applies_values() -> None: "--pandoc-args": "--wrap=none", } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, stats_defaults, append_defaults = defaults @@ -87,7 +88,7 @@ def test_build_config_defaults_rejects_invalid_entry() -> None: """Invalid values should cause config defaults to be rejected.""" raw: dict[str, object] = {"--limit": "not-a-number"} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is None @@ -100,7 +101,7 @@ def test_build_config_defaults_accepts_ordering_flags() -> None: "--order-by-timestamp-desc": True, } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -116,7 +117,7 @@ def test_build_config_defaults_keeps_regular_boolean_values() -> None: "--order-by-level": False, } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -131,7 +132,7 @@ def test_build_config_defaults_ignores_value_for_paired_boolean_flags() -> None: "--no-color": "disabled", } - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -143,7 +144,7 @@ def test_build_config_defaults_sets_false_for_negated_paired_flag() -> None: """Negated paired flags should store the negative destination value.""" raw: dict[str, object] = {"--no-future-repeats": True} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -154,7 +155,7 @@ def test_build_config_defaults_accepts_view_default() -> None: """Board view default should be accepted as a non-empty string.""" raw: dict[str, object] = {"--view": "kanban"} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is not None default_values, _stats_defaults, _append_defaults = defaults @@ -165,7 +166,7 @@ def test_build_config_defaults_rejects_non_boolean_ordering_flags() -> None: """Ordering switch defaults must be boolean values.""" raw: dict[str, object] = {"--order-by-level": "yes"} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is None @@ -174,7 +175,7 @@ def test_build_config_defaults_rejects_empty_view_default() -> None: """Board view default must not be empty.""" raw: dict[str, object] = {"--view": ""} - defaults = config.build_config_defaults(raw) + defaults = org.config.app.build_config_defaults(raw) assert defaults is None @@ -183,19 +184,19 @@ def test_parse_config_argument_prefers_cli_value() -> None: """--config argument should override default config name.""" argv = ["org", "stats", "all", "--config", "custom.yaml"] - assert config.parse_config_argument(argv) == "custom.yaml" + assert org.config.app.parse_config_argument(argv) == "custom.yaml" def test_parse_config_argument_supports_equals_form() -> None: """--config=FILE format should be parsed.""" argv = ["org", "stats", "all", "--config=inline.yaml"] - assert config.parse_config_argument(argv) == "inline.yaml" + assert org.config.app.parse_config_argument(argv) == "inline.yaml" def test_parse_config_argument_default() -> None: """Default config name should be used when not specified.""" - assert config.parse_config_argument(["org", "stats", "all"]) == ".org-cli.yaml" + assert org.config.app.parse_config_argument(["org", "stats", "all"]) == ".org-cli.yaml" def test_load_cli_config_reads_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -216,7 +217,7 @@ def test_load_cli_config_reads_defaults(tmp_path: Path, monkeypatch: pytest.Monk ) monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert loaded.defaults["max_results"] == 7 assert loaded.append_defaults == {} @@ -237,7 +238,7 @@ def test_load_cli_config_sections_are_optional( config_path.write_text("defaults:\n --limit: 7\n", encoding="utf-8") monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert loaded.defaults["max_results"] == 7 assert loaded.custom_filters == {} @@ -269,7 +270,7 @@ def test_load_cli_config_parses_capture_templates( ) monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert loaded.capture_templates == { "quick": {"file": "tasks.org", "content": "* TODO {{title}}"}, @@ -304,7 +305,7 @@ def test_load_cli_config_parses_board_views( ) monkeypatch.chdir(config_path.parent) - loaded = config.load_cli_config(["org"]) + loaded = org.config.app.load_cli_config(["org"]) assert set(loaded.board_views) == {"kanban"} assert loaded.board_views["kanban"].name == "kanban" @@ -329,7 +330,7 @@ def test_load_cli_config_rejects_empty_board_section( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_board_views_not_list( @@ -342,7 +343,7 @@ def test_load_cli_config_rejects_board_views_not_list( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_duplicate_board_view_names( @@ -369,7 +370,7 @@ def test_load_cli_config_rejects_duplicate_board_view_names( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_columns( @@ -385,7 +386,7 @@ def test_load_cli_config_rejects_empty_board_columns( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_invalid_board_column_fields( @@ -408,7 +409,7 @@ def test_load_cli_config_rejects_invalid_board_column_fields( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_unknown_board_keys( @@ -432,7 +433,7 @@ def test_load_cli_config_rejects_unknown_board_keys( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_board_with_wrong_top_level_type( @@ -445,7 +446,7 @@ def test_load_cli_config_rejects_board_with_wrong_top_level_type( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_view_name( @@ -468,7 +469,7 @@ def test_load_cli_config_rejects_empty_board_view_name( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_missing_board_view_columns( @@ -484,7 +485,7 @@ def test_load_cli_config_rejects_missing_board_view_columns( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_missing_board_column_name( @@ -506,7 +507,7 @@ def test_load_cli_config_rejects_missing_board_column_name( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_missing_board_column_filter( @@ -522,7 +523,7 @@ def test_load_cli_config_rejects_missing_board_column_filter( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_column_filter( @@ -545,7 +546,7 @@ def test_load_cli_config_rejects_empty_board_column_filter( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_empty_board_column_order_by( @@ -569,7 +570,7 @@ def test_load_cli_config_rejects_empty_board_column_order_by( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_non_string_board_column_order_by( @@ -593,7 +594,7 @@ def test_load_cli_config_rejects_non_string_board_column_order_by( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_malformed_capture_templates( @@ -609,7 +610,7 @@ def test_load_cli_config_rejects_malformed_capture_templates( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_unknown_capture_template_fields( @@ -632,7 +633,7 @@ def test_load_cli_config_rejects_unknown_capture_template_fields( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_rejects_invalid_custom_section_values( @@ -648,7 +649,7 @@ def test_load_cli_config_rejects_invalid_custom_section_values( monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_load_cli_config_malformed_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -658,56 +659,56 @@ def test_load_cli_config_malformed_yaml(tmp_path: Path, monkeypatch: pytest.Monk monkeypatch.chdir(config_path.parent) with pytest.raises(typer.BadParameter, match="Malformed config"): - config.load_cli_config(["org"]) + org.config.app.load_cli_config(["org"]) def test_validate_helpers() -> None: """Validation helpers should accept valid values and reject invalid ones.""" - assert config.is_valid_date_argument("2025-01-15") is True - assert config.is_valid_date_argument("2025/01/15") is False + assert org.config.app.is_valid_date_argument("2025-01-15") is True + assert org.config.app.is_valid_date_argument("2025/01/15") is False - assert config.is_valid_keys_string("TODO,DONE") is True - assert config.is_valid_keys_string("TODO|WAIT") is False + assert org.config.app.is_valid_keys_string("TODO,DONE") is True + assert org.config.app.is_valid_keys_string("TODO|WAIT") is False - assert config.validate_int_option(3, 0) == 3 - assert config.validate_int_option(-1, 0) is None - assert config.validate_int_option("nope", 0) is None + assert org.config.app.validate_int_option(3, 0) == 3 + assert org.config.app.validate_int_option(-1, 0) is None + assert org.config.app.validate_int_option("nope", 0) is None - assert config.validate_str_option("--use", "tags") == "tags" - assert config.validate_str_option("--use", "nope") is None - assert config.validate_str_option("--out", "gfm") == "gfm" - assert config.validate_str_option("--out", "") is None - assert config.validate_str_option("--date", "2025-01-15") == "2025-01-15" - assert config.validate_str_option("--date", "2025/01/15") is None - assert config.validate_str_option("--todo-states", "TODO|WAIT") is None - assert config.validate_str_option("--filter-date-from", "2025/01/15") is None + assert org.config.app.validate_str_option("--use", "tags") == "tags" + assert org.config.app.validate_str_option("--use", "nope") is None + assert org.config.app.validate_str_option("--out", "gfm") == "gfm" + assert org.config.app.validate_str_option("--out", "") is None + assert org.config.app.validate_str_option("--date", "2025-01-15") == "2025-01-15" + assert org.config.app.validate_str_option("--date", "2025/01/15") is None + assert org.config.app.validate_str_option("--todo-states", "TODO|WAIT") is None + assert org.config.app.validate_str_option("--filter-date-from", "2025/01/15") is None - assert config.validate_list_option("--filter-property", ["key=value"]) == ["key=value"] - assert config.validate_list_option("--filter-property", ["novalue"]) is None - assert config.validate_list_option("--filter-tag", ["["]) is None - assert config.validate_list_option("--filter-body", ["["]) is None + assert org.config.app.validate_list_option("--filter-property", ["key=value"]) == ["key=value"] + assert org.config.app.validate_list_option("--filter-property", ["novalue"]) is None + assert org.config.app.validate_list_option("--filter-tag", ["["]) is None + assert org.config.app.validate_list_option("--filter-body", ["["]) is None def test_apply_mapping_and_exclude_config() -> None: """Mapping/exclude config should support string and inline forms.""" defaults: dict[str, object] = {} - assert config.apply_mapping_config("mapping.json", defaults) is True + assert org.config.app.apply_mapping_config("mapping.json", defaults) is True assert defaults["mapping"] == "mapping.json" defaults.clear() - assert config.apply_mapping_config({"foo": "bar"}, defaults) is True + assert org.config.app.apply_mapping_config({"foo": "bar"}, defaults) is True assert defaults["mapping_inline"] == {"foo": "bar"} defaults.clear() - assert config.apply_mapping_config("", defaults) is False + assert org.config.app.apply_mapping_config("", defaults) is False defaults.clear() - assert config.apply_exclude_config("exclude.txt", defaults) is True + assert org.config.app.apply_exclude_config("exclude.txt", defaults) is True assert defaults["exclude"] == "exclude.txt" defaults.clear() - assert config.apply_exclude_config(["alpha", "beta"], defaults) is True + assert org.config.app.apply_exclude_config(["alpha", "beta"], defaults) is True assert defaults["exclude_inline"] == ["alpha", "beta"] @@ -724,7 +725,7 @@ def test_build_default_map_strips_command_specific_values() -> None: "groups": ["a,b"], } - default_map = config.build_default_map(defaults) + default_map = org.config.app.build_default_map(defaults) summary_defaults = default_map["stats"]["all"] assert "tags" not in summary_defaults @@ -749,7 +750,7 @@ def test_build_default_map_strips_command_specific_values() -> None: def test_build_default_map_keeps_ordering_boolean_defaults() -> None: """Tasks list and board ordering defaults should remain boolean flags.""" - default_map = config.build_default_map( + default_map = org.config.app.build_default_map( { "order_by_level": True, "order_by_timestamp_desc": False, @@ -771,7 +772,7 @@ def test_build_default_map_keeps_ordering_boolean_defaults() -> None: def test_build_default_map_strips_flow_board_unsupported_defaults() -> None: """Board default map should omit list-only output options.""" - default_map = config.build_default_map( + default_map = org.config.app.build_default_map( { "details": True, "max_results": 5, @@ -795,7 +796,7 @@ def test_build_default_map_strips_flow_board_unsupported_defaults() -> None: def test_build_default_map_includes_agenda_defaults() -> None: """Agenda default map should include agenda-specific and shared task options.""" - default_map = config.build_default_map( + default_map = org.config.app.build_default_map( { "date": "2025-01-15", "days": 3, @@ -829,43 +830,43 @@ def test_build_default_map_includes_agenda_defaults() -> None: def test_apply_config_defaults_applies_inline_and_append_defaults() -> None: """apply_config_defaults should fill append and inline values.""" - original_append = dict(config.CONFIG_APPEND_DEFAULTS) - original_inline = dict(config.CONFIG_INLINE_DEFAULTS) + original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) + original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) try: - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS["filter_tags"] = ["alpha"] - config.CONFIG_INLINE_DEFAULTS["mapping_inline"] = {"foo": "bar"} + org.config.app.CONFIG_APPEND_DEFAULTS["filter_tags"] = ["alpha"] + org.config.app.CONFIG_INLINE_DEFAULTS["mapping_inline"] = {"foo": "bar"} args = SimpleNamespace(filter_tags=None, mapping_inline=None, exclude_inline=None) - config.apply_config_defaults(args) + org.config.app.apply_config_defaults(args) assert args.filter_tags == ["alpha"] assert args.mapping_inline == {"foo": "bar"} assert args.exclude_inline is None finally: - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(original_append) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(original_inline) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) def test_log_applied_config_defaults_logs_all_config_values( caplog: pytest.LogCaptureFixture, ) -> None: """Default logging should include all values loaded from config.""" - original_defaults = dict(config.CONFIG_DEFAULTS) - original_append = dict(config.CONFIG_APPEND_DEFAULTS) - original_inline = dict(config.CONFIG_INLINE_DEFAULTS) + original_defaults = dict(org.config.app.CONFIG_DEFAULTS) + original_append = dict(org.config.app.CONFIG_APPEND_DEFAULTS) + original_inline = dict(org.config.app.CONFIG_INLINE_DEFAULTS) try: - config.CONFIG_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_DEFAULTS.update({"max_results": 10}) - config.CONFIG_APPEND_DEFAULTS.update({"filter_tags": ["work"]}) - config.CONFIG_INLINE_DEFAULTS.update({"mapping_inline": {"foo": "bar"}}) + org.config.app.CONFIG_DEFAULTS.update({"max_results": 10}) + org.config.app.CONFIG_APPEND_DEFAULTS.update({"filter_tags": ["work"]}) + org.config.app.CONFIG_INLINE_DEFAULTS.update({"mapping_inline": {"foo": "bar"}}) args = SimpleNamespace( max_results=10, @@ -874,7 +875,7 @@ def test_log_applied_config_defaults_logs_all_config_values( ) with caplog.at_level(logging.INFO, logger="org"): - config.log_applied_config_defaults( + org.logging.log_applied_config_defaults( args, ["stats", "all", "--limit", "20"], "stats all", @@ -885,12 +886,12 @@ def test_log_applied_config_defaults_logs_all_config_values( assert "--filter-tag=['work']" in caplog.text assert "--mapping=''" in caplog.text finally: - config.CONFIG_DEFAULTS.clear() - config.CONFIG_DEFAULTS.update(original_defaults) - config.CONFIG_APPEND_DEFAULTS.clear() - config.CONFIG_APPEND_DEFAULTS.update(original_append) - config.CONFIG_INLINE_DEFAULTS.clear() - config.CONFIG_INLINE_DEFAULTS.update(original_inline) + org.config.app.CONFIG_DEFAULTS.clear() + org.config.app.CONFIG_DEFAULTS.update(original_defaults) + org.config.app.CONFIG_APPEND_DEFAULTS.clear() + org.config.app.CONFIG_APPEND_DEFAULTS.update(original_append) + org.config.app.CONFIG_INLINE_DEFAULTS.clear() + org.config.app.CONFIG_INLINE_DEFAULTS.update(original_inline) def test_log_command_arguments_logs_all_values(caplog: pytest.LogCaptureFixture) -> None: @@ -898,7 +899,7 @@ def test_log_command_arguments_logs_all_values(caplog: pytest.LogCaptureFixture) args = SimpleNamespace(max_results=10, filter_tags=["work"]) with caplog.at_level(logging.INFO, logger="org"): - config.log_command_arguments(args, "stats all") + org.logging.log_command_arguments(args, "stats all") assert "Command arguments (stats all):" in caplog.text assert "max_results=10" in caplog.text From e36e570cedaa53dafea1e425c634d8ff3ecc996e Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Thu, 11 Jun 2026 19:57:11 +0200 Subject: [PATCH 19/27] Reorganized the TUI modules --- VIBES.org | 5 +- src/org/analyze.py | 51 +++-- src/org/commands/agenda/app.py | 28 ++- src/org/commands/agenda/command.py | 4 +- src/org/commands/agenda/ui.py | 9 +- src/org/commands/board/app.py | 24 ++- src/org/commands/board/command.py | 4 +- src/org/commands/board/ui.py | 6 +- src/org/commands/interactive_common.py | 42 ---- src/org/commands/runtime.py | 215 -------------------- src/org/commands/stats/all.py | 4 +- src/org/commands/stats/groups.py | 4 +- src/org/commands/stats/summary.py | 14 +- src/org/commands/stats/tags.py | 4 +- src/org/commands/tasks/capture/app.py | 17 +- src/org/commands/tasks/capture/command.py | 2 +- src/org/commands/tasks/capture/domain.py | 2 +- src/org/commands/tasks/find.py | 2 +- src/org/commands/tasks/list/actions.py | 2 +- src/org/commands/tasks/list/app.py | 28 ++- src/org/commands/tasks/list/command.py | 4 +- src/org/commands/tasks/query.py | 2 +- src/org/commands/tasks/remove.py | 2 +- src/org/commands/tasks/update.py | 2 +- src/org/serde/format.py | 9 +- src/org/tui/__init__.py | 1 + src/org/tui/app.py | 38 ++++ src/org/{tui.py => tui/bits.py} | 135 ++---------- src/org/{ => tui}/color.py | 52 +---- src/org/tui/footer.py | 30 +++ src/org/tui/header.py | 1 + src/org/tui/help.py | 95 +++++++++ src/org/{ => tui}/histogram.py | 106 +++++----- src/org/{ => tui}/plot.py | 119 +++++++---- src/org/tui/prompt.py | 65 ++++++ src/org/tui/selection.py | 74 +++++++ src/org/tui/styles/help_modal.tcss | 18 ++ src/org/tui/styles/prompt_modal.tcss | 26 +++ src/org/tui/styles/selection_modal.tcss | 29 +++ tests/color/test_color.py | 2 +- tests/commands/stats/test_stats_commands.py | 35 ++-- tests/commands/tasks/test_tasks_list.py | 2 +- tests/commands/test_agenda_command.py | 2 +- tests/commands/test_board.py | 2 +- tests/commands/test_interactive_common.py | 3 +- tests/commands/test_runtime.py | 11 +- tests/test_analysis_result.py | 75 ++++--- tests/test_histogram.py | 38 ++-- tests/test_output_format.py | 19 +- tests/test_priority.py | 3 +- tests/test_render_histogram.py | 45 ++-- tests/test_timeline_bucketing.py | 2 +- tests/test_timeline_chart.py | 2 +- tests/test_timeline_expansion.py | 2 +- tests/tui/test_tui_display.py | 2 +- 55 files changed, 774 insertions(+), 746 deletions(-) delete mode 100644 src/org/commands/runtime.py create mode 100644 src/org/tui/__init__.py create mode 100644 src/org/tui/app.py rename src/org/{tui.py => tui/bits.py} (84%) rename src/org/{ => tui}/color.py (65%) create mode 100644 src/org/tui/footer.py create mode 100644 src/org/tui/header.py create mode 100644 src/org/tui/help.py rename src/org/{ => tui}/histogram.py (63%) rename src/org/{ => tui}/plot.py (61%) create mode 100644 src/org/tui/prompt.py create mode 100644 src/org/tui/selection.py create mode 100644 src/org/tui/styles/help_modal.tcss create mode 100644 src/org/tui/styles/prompt_modal.tcss create mode 100644 src/org/tui/styles/selection_modal.tcss diff --git a/VIBES.org b/VIBES.org index 2d15ba9..e9c5134 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7509,7 +7509,10 @@ Add a new package =org/config= that will contain: Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. Make sure to update the commands to use this new config package. -*** TODO Reorganize the TUI +*** DONE Reorganize the TUI +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-11 Thu 19:32] +:END: With Textual as the backbone of the interactive commands, it makes sense to expand the =org/tui.py= module to be a full package =org/tui= that contains: - =color= - current =org/color.py= diff --git a/src/org/analyze.py b/src/org/analyze.py index be63992..5f9f7a5 100644 --- a/src/org/analyze.py +++ b/src/org/analyze.py @@ -4,7 +4,6 @@ from datetime import date, datetime from typing import TYPE_CHECKING -from org.histogram import Histogram from org.timestamp import extract_timestamp_any @@ -114,6 +113,17 @@ class Relations: relations: dict[str, int] +@dataclass +class Distribution: + """Represents a distribution of values.""" + + values: dict[str, int] = field(default_factory=dict) + + def update(self, key: str, amount: int) -> None: + """Update the count for a given key by the specified amount.""" + self.values[key] = self.values.get(key, 0) + amount + + @dataclass class Tag: """Represents complete statistics for a single tag. @@ -161,10 +171,11 @@ class AnalysisResult: Attributes: total_tasks: Total number of tasks analyzed (including repeats) unique_tasks: Number of unique nodes (without repeats) - task_states: Histogram of task states (TODO, DONE, DELEGATED, CANCELLED, SUSPENDED, other) - task_categories: Histogram of task categories by gamify_exp (simple, regular, hard) - task_priorities: Histogram of task priorities (A, B, C, etc.) - task_days: Histogram of tasks by day of week + task_states: Distribution of task states + (TODO, DONE, DELEGATED, CANCELLED, SUSPENDED, other) + task_categories: Distribution of task categories by gamify_exp (simple, regular, hard) + task_priorities: Distribution of task priorities (A, B, C, etc.) + task_days: Distribution of tasks by day of week timerange: Global time range for all tasks avg_tasks_per_day: Average tasks per day (total tasks / days spanned) max_single_day_count: Highest number of tasks on a single day @@ -178,10 +189,10 @@ class AnalysisResult: total_tasks: int unique_tasks: int - task_states: Histogram - task_categories: Histogram - task_priorities: Histogram - task_days: Histogram + task_states: Distribution + task_categories: Distribution + task_priorities: Distribution + task_days: Distribution timerange: TimeRange avg_tasks_per_day: float max_single_day_count: int @@ -315,7 +326,7 @@ def compute_avg_tasks_per_day(timerange: TimeRange, total_count: int) -> float: return total_count / days_spanned -def compute_task_state_histogram(nodes: list[Heading]) -> Histogram: +def compute_task_state_histogram(nodes: list[Heading]) -> Distribution: """Compute histogram of task states across all nodes. Counts states from repeated tasks if present, otherwise uses node.todo. @@ -324,9 +335,9 @@ def compute_task_state_histogram(nodes: list[Heading]) -> Histogram: nodes: List of org-mode nodes Returns: - Histogram with counts for each task state + Distribution with counts for each task state """ - task_states = Histogram(values={}) + task_states = Distribution(values={}) for node in nodes: if node.repeats: @@ -340,7 +351,7 @@ def compute_task_state_histogram(nodes: list[Heading]) -> Histogram: return task_states -def compute_day_of_week_histogram(nodes: list[Heading]) -> Histogram: +def compute_day_of_week_histogram(nodes: list[Heading]) -> Distribution: """Compute histogram of task completion days across all tasks. Extracts timestamps from all tasks and counts by day of week. @@ -350,9 +361,9 @@ def compute_day_of_week_histogram(nodes: list[Heading]) -> Histogram: nodes: List of org-mode nodes Returns: - Histogram with counts for each day of week (Monday-Sunday, unknown) + Distribution with counts for each day of week (Monday-Sunday, unknown) """ - task_days = Histogram(values={}) + task_days = Distribution(values={}) for node in nodes: count = max(1, len(node.repeats)) @@ -368,9 +379,9 @@ def compute_day_of_week_histogram(nodes: list[Heading]) -> Histogram: return task_days -def compute_category_histogram(nodes: list[Heading]) -> Histogram: +def compute_category_histogram(nodes: list[Heading]) -> Distribution: """Compute histogram based on effective heading category values.""" - task_categories = Histogram(values={}) + task_categories = Distribution(values={}) for node in nodes: count = max(1, len(node.repeats)) @@ -384,7 +395,7 @@ def compute_category_histogram(nodes: list[Heading]) -> Histogram: return task_categories -def compute_priority_histogram(nodes: list[Heading]) -> Histogram: +def compute_priority_histogram(nodes: list[Heading]) -> Distribution: """Compute histogram of task priorities across all nodes. Counts priorities from all tasks (including repeats). @@ -394,9 +405,9 @@ def compute_priority_histogram(nodes: list[Heading]) -> Histogram: nodes: List of org-mode nodes Returns: - Histogram with counts for each priority value + Distribution with counts for each priority value """ - task_priorities = Histogram(values={}) + task_priorities = Distribution(values={}) for node in nodes: count = max(1, len(node.repeats)) diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 201479a..07779a1 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -10,8 +10,12 @@ from textual.containers import Vertical from textual.widgets import Static +import org.tui.app +import org.tui.footer +import org.tui.help +import org.tui.prompt +import org.tui.selection from org.cli_common import resolve_input_paths -from org.commands import runtime from org.commands.tasks.common import ( clock_duration_prompt_label, configured_capture_template_names, @@ -81,7 +85,7 @@ def row_widgets(self) -> list[AgendaRowWidget]: return self._row_widgets -class AgendaApp(runtime.CommandApp): +class AgendaApp(org.tui.app.CommandApp): """Textual app that backs interactive `agenda`.""" CSS_PATH = "styles/app.tcss" @@ -199,7 +203,7 @@ def _refresh_footer(self, viewport_height: int) -> None: self._header_rule_widget().update(Rule(style=footer_style)) self._footer_rule_widget().update(Rule(style=footer_style)) self._footer_widget().update( - runtime.footer_renderable( + org.tui.footer.footer_renderable( f"Lines {end_line}/{total_rows} | Search: {search_text}", _HELP_FOOTER_TEXT, style=footer_style, @@ -275,14 +279,18 @@ def _complete(result: str | None) -> None: self._refresh_view() self.push_screen( - runtime.PromptModalScreen(label, initial_value=initial_value, on_change=on_change), + org.tui.prompt.PromptModalScreen( + label, + initial_value=initial_value, + on_change=on_change, + ), callback=_complete, ) def _open_selection( self, label: str, - options: list[runtime.SelectionOption], + options: list[org.tui.selection.SelectionOption], *, on_submit: Callable[[str], None], on_cancel: Callable[[], None], @@ -294,7 +302,7 @@ def _complete(result: str | None) -> None: on_submit(result) self._refresh_view() - self.push_screen(runtime.SelectionModalScreen(label, options), callback=_complete) + self.push_screen(org.tui.selection.SelectionModalScreen(label, options), callback=_complete) def action_move_up(self) -> None: """Move the selection one row upward.""" @@ -353,7 +361,7 @@ def action_shift_time_forward(self) -> None: def action_show_help(self) -> None: """Open the key bindings help modal.""" self.push_screen( - runtime.HelpModalScreen( + org.tui.help.HelpModalScreen( ui.AGENDA_HELP_ENTRIES, color_enabled=self.session.render.color_enabled, ), @@ -392,7 +400,7 @@ def action_prompt_capture(self) -> None: self._open_selection( "Capture template", - [runtime.SelectionOption(value=name, label=name) for name in template_names], + [org.tui.selection.SelectionOption(value=name, label=name) for name in template_names], on_submit=lambda template_name: self._run_external( lambda: actions.apply_capture_task(self.session, template_name), ), @@ -410,7 +418,7 @@ def action_prompt_state(self) -> None: self._open_selection( "TODO state", - [runtime.SelectionOption(value=state, label=state) for state in states], + [org.tui.selection.SelectionOption(value=state, label=state) for state in states], on_submit=lambda selected_state: actions.apply_state_change_with_value( self.session, selected_state, @@ -428,7 +436,7 @@ def action_prompt_refile(self) -> None: self._open_selection( "Destination file", [ - runtime.SelectionOption(value=path, label=path) + org.tui.selection.SelectionOption(value=path, label=path) for path in resolve_input_paths(self.session.args.files) ], on_submit=lambda destination_path: actions.apply_refile_with_value( diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 61309d2..a52ebc6 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -12,8 +12,8 @@ import org.config.app import org.logging from org.cli_common import load_and_process_data -from org.commands.interactive_common import interactive_help_command_text -from org.tui import build_console, processing_status, setup_output +from org.tui.bits import build_console, processing_status, setup_output +from org.tui.help import interactive_help_command_text from . import ui from .app import run_agenda_app diff --git a/src/org/commands/agenda/ui.py b/src/org/commands/agenda/ui.py index 513d0b8..25b2a62 100644 --- a/src/org/commands/agenda/ui.py +++ b/src/org/commands/agenda/ui.py @@ -12,18 +12,15 @@ from rich.table import Table from rich.text import Text -from org.commands.interactive_common import ( - InteractiveHelpEntry, - local_now, - shift_datetimes_by_unit, -) +from org.commands.interactive_common import local_now, shift_datetimes_by_unit from org.query_language import EvalContext, QueryRuntimeError, Stream -from org.tui import ( +from org.tui.bits import ( heading_title_to_text, task_priority_to_text, task_state_prefix_to_text, task_tags_to_text, ) +from org.tui.help import InteractiveHelpEntry from org.validation import parse_date_argument diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index a589a77..73c6d5d 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -12,7 +12,11 @@ from textual.containers import Horizontal, Vertical from textual.widgets import Static -from org.commands import runtime +import org.tui.app +import org.tui.footer +import org.tui.help +import org.tui.prompt +import org.tui.selection from org.commands.tasks.common import ( configured_capture_template_names, ) @@ -91,7 +95,7 @@ def column_widgets(self) -> list[BoardColumnWidget]: return self._column_widgets -class BoardApp(runtime.CommandApp): +class BoardApp(org.tui.app.CommandApp): """Textual app that backs interactive `board`.""" CSS_PATH = "styles/app.tcss" @@ -231,7 +235,7 @@ def _refresh_footer(self) -> None: footer_style = "dim" if self.session.color_enabled else "" self._footer_rule_widget().update(Rule(style=footer_style)) self._footer_widget().update( - runtime.footer_renderable( + org.tui.footer.footer_renderable( f"Rows {self._visible_end_row}/{total_rows} | Search: {search_text}", _HELP_FOOTER_TEXT, style=footer_style, @@ -278,14 +282,18 @@ def _complete(result: str | None) -> None: self._refresh_view() self.push_screen( - runtime.PromptModalScreen(label, initial_value=initial_value, on_change=on_change), + org.tui.prompt.PromptModalScreen( + label, + initial_value=initial_value, + on_change=on_change, + ), callback=_complete, ) def _open_selection( self, label: str, - options: list[runtime.SelectionOption], + options: list[org.tui.selection.SelectionOption], *, on_submit: Callable[[str], None], on_cancel: Callable[[], None], @@ -297,7 +305,7 @@ def _complete(result: str | None) -> None: on_submit(result) self._refresh_view() - self.push_screen(runtime.SelectionModalScreen(label, options), callback=_complete) + self.push_screen(org.tui.selection.SelectionModalScreen(label, options), callback=_complete) def action_move_up(self) -> None: """Move the selection one task upward in the selected column.""" @@ -356,7 +364,7 @@ def action_clear_search(self) -> None: def action_show_help(self) -> None: """Open the key bindings help modal.""" self.push_screen( - runtime.HelpModalScreen( + org.tui.help.HelpModalScreen( ui.BOARD_HELP_ENTRIES, color_enabled=self.session.color_enabled, ), @@ -394,7 +402,7 @@ def action_prompt_capture(self) -> None: self._open_selection( "Capture template", - [runtime.SelectionOption(value=name, label=name) for name in template_names], + [org.tui.selection.SelectionOption(value=name, label=name) for name in template_names], on_submit=lambda template_name: self._run_external( lambda: actions.apply_capture_task(self.session, template_name), ), diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index f3fa377..f414a2d 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -12,8 +12,8 @@ import org.config.app import org.logging from org.cli_common import load_and_process_data -from org.commands.interactive_common import interactive_help_command_text -from org.tui import build_console, processing_status, setup_output +from org.tui.bits import build_console, processing_status, setup_output +from org.tui.help import interactive_help_command_text from . import actions, ui from .app import run_board_app diff --git a/src/org/commands/board/ui.py b/src/org/commands/board/ui.py index bf5019d..15eea16 100644 --- a/src/org/commands/board/ui.py +++ b/src/org/commands/board/ui.py @@ -14,9 +14,9 @@ from rich.table import Table from rich.text import Text -from org.color import get_state_color -from org.commands.interactive_common import InteractiveHelpEntry -from org.tui import heading_title_to_text, task_priority_to_text, task_tags_to_text +from org.tui.bits import heading_title_to_text, task_priority_to_text, task_tags_to_text +from org.tui.color import get_state_color +from org.tui.help import InteractiveHelpEntry if TYPE_CHECKING: diff --git a/src/org/commands/interactive_common.py b/src/org/commands/interactive_common.py index 4ba41d6..6fab6af 100644 --- a/src/org/commands/interactive_common.py +++ b/src/org/commands/interactive_common.py @@ -9,26 +9,12 @@ from org_parser.element import Repeat from org_parser.time import Timestamp -from rich.markup import escape if TYPE_CHECKING: from org_parser.document import Heading -INTERACTIVE_HELP_CLI_NOTE = ( - "In interactive mode, press ? to open key bindings help (press any key to close)." -) - - -@dataclass(frozen=True) -class InteractiveHelpEntry: - """One key binding entry for interactive help rendering.""" - - key: str - description: str - - @dataclass(frozen=True) class HeadingLocator: """Stable heading locator used to restore selection across reloads.""" @@ -38,34 +24,6 @@ class HeadingLocator: title: str -@dataclass -class FooterPromptState: - """Simple prompt label/value state shared by Textual prompt configs.""" - - label: str - value: str = "" - cursor_position: int = 0 - error_message: str = "" - - -def render_interactive_help_panel_text(entries: list[InteractiveHelpEntry]) -> str: - """Render key bindings as plain help text lines.""" - key_width = max(12, max((len(entry.key) for entry in entries), default=0) + 1) - lines = ["[white not dim]Key bindings:[/]"] - for entry in entries: - key_text = escape(f"{entry.key:<{key_width}}") - description_text = escape(entry.description) - lines.append(f" [bold white not dim]{key_text}[/][white not dim]{description_text}[/]") - return "\n".join(lines) - - -def interactive_help_command_text(base_text: str, entries: list[InteractiveHelpEntry]) -> str: - """Append the shared interactive-help note and key-bindings panel to help text.""" - normalized = " ".join(base_text.split()) - panel_text = render_interactive_help_panel_text(entries) - return f"{normalized} {INTERACTIVE_HELP_CLI_NOTE}\n\n{panel_text}" - - def local_now() -> datetime: """Return local timezone-aware current datetime.""" return datetime.now(tz=UTC).astimezone() diff --git a/src/org/commands/runtime.py b/src/org/commands/runtime.py deleted file mode 100644 index 9170b95..0000000 --- a/src/org/commands/runtime.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Shared Textual runtime helpers for interactive command UIs.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, cast - -from rich.table import Table -from rich.text import Text -from textual import events as textual_events -from textual.app import App, ComposeResult, SuspendNotSupported -from textual.containers import Container -from textual.screen import ModalScreen -from textual.widgets import Input, OptionList, Static - -from org.commands.interactive_common import ( - InteractiveHelpEntry, - render_interactive_help_panel_text, -) - - -if TYPE_CHECKING: - from collections.abc import Callable - from contextlib import AbstractContextManager - - -def _help_body_text(entries: list[InteractiveHelpEntry]) -> Text: - """Build help body text without duplicating the modal title.""" - panel_text = render_interactive_help_panel_text(entries) - body_lines = panel_text.splitlines()[1:] - return Text.from_markup("\n".join(body_lines)) - - -def footer_renderable(left_text: str, right_text: str, *, style: str) -> Table: - """Build one footer line with a right-aligned help hint.""" - footer_line = Table.grid(expand=True) - footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") - footer_line.add_row( - Text(left_text, style=style, no_wrap=True, overflow="ellipsis"), - Text(right_text, style=style, no_wrap=True, overflow="ellipsis"), - ) - return footer_line - - -class HelpModalScreen(ModalScreen[None]): - """Generic modal screen that renders command key bindings.""" - - CSS_PATH = "styles/help_modal.tcss" - - def __init__(self, entries: list[InteractiveHelpEntry], *, color_enabled: bool) -> None: - """Store key binding data for modal rendering.""" - super().__init__() - self._entries = entries - self._color_enabled = color_enabled - self._ready_to_close = False - - def compose(self) -> ComposeResult: - """Render the help modal contents.""" - title_text = Text("Key bindings", style="bold") - help_text = _help_body_text(self._entries) - yield Container( - Static(title_text, id="help-title"), - Static(help_text, id="help-content"), - id="help-dialog", - ) - - def on_mount(self) -> None: - """Arm key-to-close after the opening key event finishes.""" - self.call_after_refresh(self._enable_close) - - def _enable_close(self) -> None: - """Allow subsequent key presses to close the help modal.""" - self._ready_to_close = True - - async def on_key(self, event: textual_events.Key) -> None: - """Close and forward the next key press to the underlying app.""" - event.stop() - if not self._ready_to_close: - return - forwarded_event = textual_events.Key(event.key, event.character) - self.dismiss(None) - self.app.call_after_refresh(lambda: self.app.post_message(forwarded_event)) - - -class PromptModalScreen(ModalScreen[str | None]): - """Generic modal screen for single-value text prompts.""" - - CSS_PATH = "styles/prompt_modal.tcss" - - def __init__( - self, - label: str, - *, - initial_value: str = "", - on_change: Callable[[str], None] | None = None, - ) -> None: - """Store modal prompt configuration.""" - super().__init__() - self._label = label - self._initial_value = initial_value - self._on_change = on_change - - def compose(self) -> ComposeResult: - """Render the prompt label and input field.""" - yield Container( - Static(Text(self._label, style="bold"), id="prompt-label"), - Static(Text("Enter submits. Esc cancels.", style="dim"), id="prompt-instructions"), - Input(value=self._initial_value, id="prompt-input"), - id="prompt-dialog", - ) - - def on_mount(self) -> None: - """Focus the prompt input after the modal is mounted.""" - self.query_one(Input).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - """Forward value changes to optional preview callback.""" - if self._on_change is not None: - self._on_change(event.value) - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Return submitted input value to caller.""" - self.dismiss(event.value) - - def on_key(self, event: textual_events.Key) -> None: - """Consume escape so prompt cancel doesn't fall through to app bindings.""" - if event.key != "escape": - return - event.stop() - self.dismiss(None) - - -@dataclass(frozen=True) -class SelectionOption: - """One selectable value shown by the shared selection modal.""" - - value: str - label: str - - -class SelectionModalScreen(ModalScreen[str | None]): - """Generic modal screen for keyboard-only single-value selection.""" - - CSS_PATH = "styles/selection_modal.tcss" - - def __init__( - self, - label: str, - options: list[SelectionOption], - *, - initial_index: int = 0, - ) -> None: - """Store modal selection configuration.""" - super().__init__() - self._label = label - self._options = options - self._initial_index = initial_index - - def compose(self) -> ComposeResult: - """Render the selection label and option list.""" - yield Container( - Static(Text(self._label, style="bold"), id="selection-label"), - Static( - Text("Up/down moves. Enter selects. Esc cancels.", style="dim"), - id="selection-instructions", - ), - OptionList(*(option.label for option in self._options), id="selection-options"), - id="selection-dialog", - ) - - def on_mount(self) -> None: - """Focus the selection list after the modal is mounted.""" - option_list = self.query_one(OptionList) - if self._options: - option_list.highlighted = min(max(self._initial_index, 0), len(self._options) - 1) - option_list.focus() - - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - """Return the selected option value to the caller.""" - event.stop() - self.dismiss(self._options[event.option_index].value) - - def on_key(self, event: textual_events.Key) -> None: - """Consume escape so selection cancel doesn't fall through to app bindings.""" - if event.key != "escape": - return - event.stop() - self.dismiss(None) - - -class CommandApp(App[None]): - """Shared helpers for Textual-backed interactive command apps.""" - - def suspend_for_external(self, callback: Callable[[], None]) -> None: - """Suspend the app when possible around one blocking callback.""" - suspend = getattr(self, "suspend", None) - if callable(suspend): - try: - with cast("AbstractContextManager[object]", suspend()): - callback() - except SuspendNotSupported: - callback() - else: - callback() - - def run_external_and_refresh( - self, - callback: Callable[[], None], - *, - refresh: Callable[[], None], - ) -> None: - """Suspend around one external action and refresh afterwards.""" - self.suspend_for_external(callback) - refresh() diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 6a72b8b..5f6e0e7 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -24,11 +24,10 @@ resolve_mapping, ) from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary -from org.tui import ( +from org.tui.bits import ( GroupBlockConfig, TagBlockConfig, TaskLineConfig, - TimelineFormatConfig, build_console, format_group_block, format_tag_block, @@ -37,6 +36,7 @@ processing_status, setup_output, ) +from org.tui.plot import TimelineFormatConfig from org.validation import validate_stats_arguments diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index ea04548..a0ccc1e 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -28,9 +28,8 @@ resolve_group_values, resolve_mapping, ) -from org.tui import ( +from org.tui.bits import ( GroupBlockConfig, - TimelineFormatConfig, apply_indent, build_console, format_group_block, @@ -39,6 +38,7 @@ processing_status, setup_output, ) +from org.tui.plot import TimelineFormatConfig from org.validation import validate_stats_arguments diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index 981b084..e43adbb 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -12,6 +12,7 @@ import org.logging from org.analyze import ( AnalysisResult, + Distribution, compute_avg_tasks_per_day, compute_category_histogram, compute_day_of_week_histogram, @@ -22,20 +23,17 @@ compute_task_stats, ) from org.cli_common import load_and_process_data, resolve_date_filters -from org.color import magenta -from org.histogram import Histogram, RenderConfig -from org.tui import ( - HistogramSectionConfig, - TimelineFormatConfig, +from org.tui.bits import ( apply_indent, build_console, - format_histogram_section, - format_timeline_lines, lines_to_text, print_output, processing_status, setup_output, ) +from org.tui.color import magenta +from org.tui.histogram import HistogramSectionConfig, RenderConfig, format_histogram_section +from org.tui.plot import TimelineFormatConfig, format_timeline_lines if TYPE_CHECKING: @@ -90,7 +88,7 @@ def _validate_summary_arguments(args: SummaryArgs) -> None: def _build_task_state_order( - task_states: Histogram, + task_states: Distribution, done_states: list[str], todo_states: list[str], ) -> list[str]: diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index 2e519df..5b54c41 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -27,9 +27,8 @@ resolve_exclude_set, resolve_mapping, ) -from org.tui import ( +from org.tui.bits import ( TagBlockConfig, - TimelineFormatConfig, apply_indent, build_console, format_tag_block, @@ -38,6 +37,7 @@ processing_status, setup_output, ) +from org.tui.plot import TimelineFormatConfig from org.validation import validate_stats_arguments diff --git a/src/org/commands/tasks/capture/app.py b/src/org/commands/tasks/capture/app.py index 109f028..6f6bc44 100644 --- a/src/org/commands/tasks/capture/app.py +++ b/src/org/commands/tasks/capture/app.py @@ -10,7 +10,9 @@ from textual.containers import Container, Horizontal, Vertical from textual.widgets import Button, Static, TextArea -from org.commands import runtime +import org.tui.app +import org.tui.help +import org.tui.selection from .domain import ( _CAPTURE_HELP_ENTRIES, @@ -26,7 +28,7 @@ from textual.app import ComposeResult -class _TemplateSelectionApp(runtime.CommandApp): +class _TemplateSelectionApp(org.tui.app.CommandApp): """Small app for selecting a capture template interactively.""" CSS_PATH = "styles/selection.tcss" @@ -47,9 +49,12 @@ def on_mount(self) -> None: def _prompt_template_selection(self) -> None: self.push_screen( - runtime.SelectionModalScreen( + org.tui.selection.SelectionModalScreen( "Capture template", - [runtime.SelectionOption(value=name, label=name) for name in self._template_names], + [ + org.tui.selection.SelectionOption(value=name, label=name) + for name in self._template_names + ], ), callback=self._complete_template_selection, ) @@ -60,7 +65,7 @@ def _complete_template_selection(self, value: str | None) -> None: self.exit() -class CaptureApp(runtime.CommandApp): +class CaptureApp(org.tui.app.CommandApp): """Interactive multi-field capture form with preview.""" CSS_PATH = "styles/app.tcss" @@ -176,7 +181,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: def action_show_help(self) -> None: """Open the key bindings help modal.""" - self.push_screen(runtime.HelpModalScreen(_CAPTURE_HELP_ENTRIES, color_enabled=True)) + self.push_screen(org.tui.help.HelpModalScreen(_CAPTURE_HELP_ENTRIES, color_enabled=True)) def action_cancel_capture(self) -> None: """Cancel interactive capture without saving.""" diff --git a/src/org/commands/tasks/capture/command.py b/src/org/commands/tasks/capture/command.py index 07a110a..6fd489d 100644 --- a/src/org/commands/tasks/capture/command.py +++ b/src/org/commands/tasks/capture/command.py @@ -10,7 +10,7 @@ import org.config.app import org.logging -from org.commands.interactive_common import interactive_help_command_text +from org.tui.help import interactive_help_command_text from .app import run_capture_form_app, run_template_selection_app from .domain import ( diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index 03fb6b4..dcad5d6 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -14,7 +14,6 @@ from rich.syntax import Syntax import org.config.app -from org.commands.interactive_common import InteractiveHelpEntry from org.commands.tasks.common import load_document, resolve_parent_heading, save_document from org.query_language import ( EvalContext, @@ -24,6 +23,7 @@ compile_query_text, ) from org.serde.format import DEFAULT_OUTPUT_THEME +from org.tui.help import InteractiveHelpEntry if TYPE_CHECKING: diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index 5b8e43d..ae98979 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -30,7 +30,7 @@ OutputFormatError, print_prepared_output, ) -from org.tui import build_console, processing_status, setup_output +from org.tui.bits import build_console, processing_status, setup_output @dataclass diff --git a/src/org/commands/tasks/list/actions.py b/src/org/commands/tasks/list/actions.py index 51670cd..e687def 100644 --- a/src/org/commands/tasks/list/actions.py +++ b/src/org/commands/tasks/list/actions.py @@ -13,7 +13,6 @@ from org.commands.editor import edit_heading_subtree_in_external_editor from org.commands.interactive_common import ( HeadingLocator, - InteractiveHelpEntry, advance_timestamp_by_repeater, append_repeat_transition, heading_locator, @@ -31,6 +30,7 @@ save_document, todo_states_for_heading, ) +from org.tui.help import InteractiveHelpEntry if TYPE_CHECKING: diff --git a/src/org/commands/tasks/list/app.py b/src/org/commands/tasks/list/app.py index 6318c57..034e968 100644 --- a/src/org/commands/tasks/list/app.py +++ b/src/org/commands/tasks/list/app.py @@ -11,14 +11,18 @@ from textual.containers import Vertical from textual.widgets import Static -from org.commands import runtime +import org.tui.app +import org.tui.footer +import org.tui.help +import org.tui.prompt +import org.tui.selection from org.commands.tasks.common import ( PlanningTimestampField, configured_capture_template_names, planning_prompt_label, tags_prompt_label, ) -from org.tui import TaskLineConfig, format_task_line +from org.tui.bits import TaskLineConfig, format_task_line from . import actions from .actions import TASKS_LIST_HELP_ENTRIES, create_tasks_list_session @@ -39,7 +43,7 @@ _HELP_FOOTER_TEXT = "Type ? for help" -class TasksListApp(runtime.CommandApp): +class TasksListApp(org.tui.app.CommandApp): """Textual app that backs interactive `tasks list`.""" CSS_PATH = "styles/app.tcss" @@ -163,7 +167,7 @@ def _footer_renderable(self) -> RenderableType: total_rows = len(self.session.visible_nodes) search_text = self.session.search_text or "-" footer_style = "dim" if self.session.color_enabled else "" - return runtime.footer_renderable( + return org.tui.footer.footer_renderable( f"Rows {selected_row}/{total_rows} | Search: {search_text}", _HELP_FOOTER_TEXT, style=footer_style, @@ -202,7 +206,11 @@ def _complete(result: str | None) -> None: self._refresh_view() self.push_screen( - runtime.PromptModalScreen(label, initial_value=initial_value, on_change=on_change), + org.tui.prompt.PromptModalScreen( + label, + initial_value=initial_value, + on_change=on_change, + ), callback=_complete, ) @@ -212,7 +220,7 @@ def _run_external(self, callback: Callable[[], None]) -> None: def _open_selection( self, label: str, - options: list[runtime.SelectionOption], + options: list[org.tui.selection.SelectionOption], *, on_submit: Callable[[str], None], on_cancel: Callable[[], None], @@ -224,7 +232,7 @@ def _complete(result: str | None) -> None: on_submit(result) self._refresh_view() - self.push_screen(runtime.SelectionModalScreen(label, options), callback=_complete) + self.push_screen(org.tui.selection.SelectionModalScreen(label, options), callback=_complete) def action_move_up(self) -> None: """Move the selection one row upward.""" @@ -267,7 +275,7 @@ def action_quit_app(self) -> None: def action_show_help(self) -> None: """Open the key bindings help modal.""" self.push_screen( - runtime.HelpModalScreen( + org.tui.help.HelpModalScreen( TASKS_LIST_HELP_ENTRIES, color_enabled=self.session.color_enabled, ), @@ -303,7 +311,7 @@ def action_prompt_capture(self) -> None: self._open_selection( "Capture template", - [runtime.SelectionOption(value=name, label=name) for name in template_names], + [org.tui.selection.SelectionOption(value=name, label=name) for name in template_names], on_submit=lambda template_name: self._run_external( lambda: actions.apply_capture_task(self.session, template_name), ), @@ -321,7 +329,7 @@ def action_prompt_state(self) -> None: self._open_selection( "TODO state", - [runtime.SelectionOption(value=state, label=state) for state in states], + [org.tui.selection.SelectionOption(value=state, label=state) for state in states], on_submit=lambda selected_state: actions.apply_state_change_with_value( self.session, selected_state, diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index cc94a3a..91b704d 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -14,7 +14,6 @@ import org.config.app import org.logging from org.cli_common import load_and_process_data -from org.commands.interactive_common import interactive_help_command_text from org.serde.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, @@ -29,7 +28,7 @@ _prepare_output, print_prepared_output, ) -from org.tui import ( +from org.tui.bits import ( TaskLineConfig, build_console, format_task_line, @@ -37,6 +36,7 @@ processing_status, setup_output, ) +from org.tui.help import interactive_help_command_text from . import actions from .app import run_tasks_list_app diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index a2c6713..5a53d59 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -40,7 +40,7 @@ _prepare_output, print_prepared_output, ) -from org.tui import build_console, processing_status, setup_output +from org.tui.bits import build_console, processing_status, setup_output if TYPE_CHECKING: diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index e012bb3..9f7abee 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -14,12 +14,12 @@ import org.config.app import org.logging from org.cli_common import resolve_input_paths -from org.color import should_use_color from org.commands.tasks.common import ( resolve_headings_by_query, resolve_task_selector_query, save_document, ) +from org.tui.color import should_use_color logger = logging.getLogger("org") diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 634df06..48470e5 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -17,7 +17,6 @@ import org.config.app import org.logging from org.cli_common import resolve_input_paths -from org.color import should_use_color from org.commands.tasks.common import ( iter_descendants, load_document, @@ -33,6 +32,7 @@ resolve_task_selector_query, save_document, ) +from org.tui.color import should_use_color if TYPE_CHECKING: diff --git a/src/org/serde/format.py b/src/org/serde/format.py index 9dd850b..ed5a9e5 100644 --- a/src/org/serde/format.py +++ b/src/org/serde/format.py @@ -19,9 +19,8 @@ from org_parser.time import Clock, Timestamp from rich.syntax import Syntax -from org.analyze import AnalysisResult, Group, Tag, TimeRange -from org.histogram import Histogram -from org.tui import print_output +from org.analyze import AnalysisResult, Distribution, Group, Tag, TimeRange +from org.tui.bits import print_output if TYPE_CHECKING: @@ -353,7 +352,7 @@ def _org_object_to_json_dict(value: object, seen: set[int]) -> dict[str, object] def _analysis_object_to_json_dict( - value: AnalysisResult | Tag | Group | TimeRange | Histogram, + value: AnalysisResult | Tag | Group | TimeRange | Distribution, seen: set[int], ) -> dict[str, object]: """Serialize analysis result dataclass into a JSON object with a type field.""" @@ -395,7 +394,7 @@ def _to_json_compatible(value: object, seen: set[int] | None = None) -> object: elif isinstance(value, Timestamp | Clock | Repeat | Heading | Document | Element): seen.add(obj_id) result = _org_object_to_json_dict(value, seen) - elif isinstance(value, AnalysisResult | Tag | Group | TimeRange | Histogram): + elif isinstance(value, AnalysisResult | Tag | Group | TimeRange | Distribution): seen.add(obj_id) result = _analysis_object_to_json_dict(value, seen) elif isinstance(value, Mapping): diff --git a/src/org/tui/__init__.py b/src/org/tui/__init__.py new file mode 100644 index 0000000..db90284 --- /dev/null +++ b/src/org/tui/__init__.py @@ -0,0 +1 @@ +"""Textual and Rich UI helpers for the org CLI.""" diff --git a/src/org/tui/app.py b/src/org/tui/app.py new file mode 100644 index 0000000..49f0fa9 --- /dev/null +++ b/src/org/tui/app.py @@ -0,0 +1,38 @@ +"""Shared Textual app helpers for interactive command UIs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from textual.app import App, SuspendNotSupported + + +if TYPE_CHECKING: + from collections.abc import Callable + from contextlib import AbstractContextManager + + +class CommandApp(App[None]): + """Shared helpers for Textual-backed interactive command apps.""" + + def suspend_for_external(self, callback: Callable[[], None]) -> None: + """Suspend the app when possible around one blocking callback.""" + suspend = getattr(self, "suspend", None) + if callable(suspend): + try: + with cast("AbstractContextManager[object]", suspend()): + callback() + except SuspendNotSupported: + callback() + else: + callback() + + def run_external_and_refresh( + self, + callback: Callable[[], None], + *, + refresh: Callable[[], None], + ) -> None: + """Suspend around one external action and refresh afterwards.""" + self.suspend_for_external(callback) + refresh() diff --git a/src/org/tui.py b/src/org/tui/bits.py similarity index 84% rename from src/org/tui.py rename to src/org/tui/bits.py index ec5ba26..5c8db5e 100644 --- a/src/org/tui.py +++ b/src/org/tui/bits.py @@ -32,7 +32,7 @@ from rich.text import Text from org.cli_common import get_top_tasks -from org.color import ( +from org.tui.color import ( bright_blue, bright_white, colorize, @@ -42,75 +42,23 @@ magenta, should_use_color, ) -from org.histogram import ( - Histogram, - HistogramRenderConfig, - RenderConfig, - render_histogram, - visual_len, +from org.tui.plot import ( + TimelineFormatConfig, + format_timeline_lines, + select_earliest_date, + select_latest_date, ) -from org.plot import TimelineRenderConfig, render_timeline_chart if TYPE_CHECKING: from collections.abc import Iterator - from datetime import date, datetime + from datetime import datetime from org_parser.document import Heading from org.analyze import Group, Tag, TimeRange -def select_earliest_date( - date_from: datetime | None, - global_timerange: TimeRange, - local_timerange: TimeRange, -) -> date | None: - """Select earliest date using priority: user filter > global > local. - - Args: - date_from: User-provided filter date or None - global_timerange: Global timerange across all tasks - local_timerange: Local timerange for specific item - - Returns: - Selected earliest date or None if no dates available - """ - if date_from: - return date_from.date() - if global_timerange.earliest: - return global_timerange.earliest.date() - if local_timerange.earliest: - return local_timerange.earliest.date() - return None - - -def select_latest_date( - date_until: datetime | None, - global_timerange: TimeRange, - local_timerange: TimeRange, -) -> date | None: - """Select latest date using priority: user filter > global > local. - - Args: - date_until: User-provided filter date or None - global_timerange: Global timerange across all tasks - local_timerange: Local timerange for specific item - - Returns: - Selected latest date or None if no dates available - """ - if date_until: - return date_until.date() - if global_timerange.latest: - return global_timerange.latest.date() - if local_timerange.latest: - return local_timerange.latest.date() - if local_timerange.earliest: - return local_timerange.earliest.date() - return None - - def lines_to_text(lines: list[str]) -> str: """Join rendered lines into one newline-terminated text block.""" if not lines: @@ -164,20 +112,6 @@ def section_header_lines(title: str, color_enabled: bool) -> list[str]: return ["", bright_white(title, color_enabled)] -@dataclass(frozen=True) -class TimelineFormatConfig: - """Configuration for rendering timeline charts.""" - - color_enabled: bool - indent: str - plot_width: int - - -def resolve_timeline_plot_width(config: TimelineFormatConfig) -> int: - """Resolve visual timeline plot width from config.""" - return max(3, config.plot_width) - - @dataclass(frozen=True) class TagBlockConfig: """Configuration for rendering a tag block.""" @@ -204,16 +138,6 @@ class GroupBlockConfig: stats_indent: str -@dataclass(frozen=True) -class HistogramSectionConfig: - """Configuration for rendering histogram sections.""" - - plot_width: int - order: list[str] - render_config: RenderConfig - indent: str - - @dataclass(frozen=True) class TopTasksSectionConfig: """Configuration for rendering the top tasks section.""" @@ -254,6 +178,11 @@ def _truncate_filename(filename: str, width: int) -> str: return _pad_to_visual_width(truncated, width) +def visual_len(text: str) -> int: + """Get visual length of text (excluding Rich markup).""" + return cell_len(Text.from_markup(text).plain) + + def _truncate_to_visual_width(text: str, max_width: int) -> str: """Return text truncated to visual display width.""" if max_width <= 0: @@ -531,26 +460,6 @@ def format_task_line( return line -def format_timeline_lines( - timeline: dict[date, int], - earliest_date: date, - latest_date: date, - config: TimelineFormatConfig, -) -> list[str]: - """Render timeline chart lines and apply configured indentation.""" - plot_width = resolve_timeline_plot_width(config) - date_line, chart_line, underline = render_timeline_chart( - timeline, - earliest_date, - latest_date, - TimelineRenderConfig( - plot_width=plot_width, - color_enabled=config.color_enabled, - ), - ) - return apply_indent([date_line, chart_line, underline], config.indent) - - def format_tag_block(name: str, tag: Tag, config: TagBlockConfig) -> list[str]: """Render one tag block with timeline, stats, and relation lines.""" lines: list[str] = [] @@ -725,23 +634,3 @@ def format_top_tasks_section( ] return lines_to_text(apply_indent(lines, config.indent)) - - -def format_histogram_section( - title: str, - histogram: Histogram, - config: HistogramSectionConfig, -) -> list[str]: - """Render one histogram section as indented output lines.""" - lines = section_header_lines(title, config.render_config.color_enabled) - histogram_plot_width = max(3, config.plot_width - 2) - histogram_lines = render_histogram( - histogram, - HistogramRenderConfig( - plot_width=histogram_plot_width, - category_order=config.order, - style=config.render_config, - ), - ) - lines.extend([f" {line}" for line in histogram_lines]) - return apply_indent(lines, config.indent) diff --git a/src/org/color.py b/src/org/tui/color.py similarity index 65% rename from src/org/color.py rename to src/org/tui/color.py index dca5678..c40d640 100644 --- a/src/org/color.py +++ b/src/org/tui/color.py @@ -51,54 +51,22 @@ def colorize(text: str, style: str, enabled: bool) -> str: def bright_white(text: str, enabled: bool) -> str: - """Apply bright white color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ + """Apply bright white color to text.""" return colorize(text, "bold white", enabled) def dim_white(text: str, enabled: bool) -> str: - """Apply dim white color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ + """Apply dim white color to text.""" return colorize(text, "dim white", enabled) def magenta(text: str, enabled: bool) -> str: - """Apply magenta color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ + """Apply magenta color to text.""" return colorize(text, "magenta", enabled) def bright_blue(text: str, enabled: bool) -> str: - """Apply bright blue color to text. - - Args: - text: Text to colorize - enabled: Whether coloring is enabled - - Returns: - Colored text if enabled, original text otherwise - """ + """Apply bright blue color to text.""" return colorize(text, "bold blue", enabled) @@ -108,17 +76,7 @@ def get_state_color( todo_states: list[str], enabled: bool, ) -> str: - """Get appropriate style for a task state. - - Args: - state: Task state (e.g., "DONE", "TODO", "CANCELLED") - done_states: List of done state keywords - todo_states: List of todo state keywords - enabled: Whether coloring is enabled - - Returns: - Rich style string for the state - """ + """Get appropriate style for a task state.""" if not enabled: return "" diff --git a/src/org/tui/footer.py b/src/org/tui/footer.py new file mode 100644 index 0000000..c034013 --- /dev/null +++ b/src/org/tui/footer.py @@ -0,0 +1,30 @@ +"""Footer helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from rich.table import Table +from rich.text import Text + + +@dataclass +class FooterPromptState: + """Simple prompt label/value state shared by Textual prompt configs.""" + + label: str + value: str = "" + cursor_position: int = 0 + error_message: str = "" + + +def footer_renderable(left_text: str, right_text: str, *, style: str) -> Table: + """Build one footer line with a right-aligned help hint.""" + footer_line = Table.grid(expand=True) + footer_line.add_column(ratio=1, no_wrap=True, overflow="ellipsis") + footer_line.add_column(ratio=4, justify="right", no_wrap=True, overflow="ellipsis") + footer_line.add_row( + Text(left_text, style=style, no_wrap=True, overflow="ellipsis"), + Text(right_text, style=style, no_wrap=True, overflow="ellipsis"), + ) + return footer_line diff --git a/src/org/tui/header.py b/src/org/tui/header.py new file mode 100644 index 0000000..b6dfc77 --- /dev/null +++ b/src/org/tui/header.py @@ -0,0 +1 @@ +"""Header helpers for interactive Textual UIs.""" diff --git a/src/org/tui/help.py b/src/org/tui/help.py new file mode 100644 index 0000000..403557b --- /dev/null +++ b/src/org/tui/help.py @@ -0,0 +1,95 @@ +"""Help text and help modal helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from rich.markup import escape +from rich.text import Text +from textual import events as textual_events +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Static + + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +INTERACTIVE_HELP_CLI_NOTE = ( + "In interactive mode, press ? to open key bindings help (press any key to close)." +) + + +@dataclass(frozen=True) +class InteractiveHelpEntry: + """One key binding entry for interactive help rendering.""" + + key: str + description: str + + +def render_interactive_help_panel_text(entries: list[InteractiveHelpEntry]) -> str: + """Render key bindings as plain help text lines.""" + key_width = max(12, max((len(entry.key) for entry in entries), default=0) + 1) + lines = ["[white not dim]Key bindings:[/]"] + for entry in entries: + key_text = escape(f"{entry.key:<{key_width}}") + description_text = escape(entry.description) + lines.append(f" [bold white not dim]{key_text}[/][white not dim]{description_text}[/]") + return "\n".join(lines) + + +def interactive_help_command_text(base_text: str, entries: list[InteractiveHelpEntry]) -> str: + """Append the shared interactive-help note and key-bindings panel to help text.""" + normalized = " ".join(base_text.split()) + panel_text = render_interactive_help_panel_text(entries) + return f"{normalized} {INTERACTIVE_HELP_CLI_NOTE}\n\n{panel_text}" + + +def _help_body_text(entries: list[InteractiveHelpEntry]) -> Text: + """Build help body text without duplicating the modal title.""" + panel_text = render_interactive_help_panel_text(entries) + body_lines = panel_text.splitlines()[1:] + return Text.from_markup("\n".join(body_lines)) + + +class HelpModalScreen(ModalScreen[None]): + """Generic modal screen that renders command key bindings.""" + + CSS_PATH = "styles/help_modal.tcss" + + def __init__(self, entries: list[InteractiveHelpEntry], *, color_enabled: bool) -> None: + """Store key binding data for modal rendering.""" + super().__init__() + self._entries = entries + self._color_enabled = color_enabled + self._ready_to_close = False + + def compose(self) -> ComposeResult: + """Render the help modal contents.""" + title_text = Text("Key bindings", style="bold") + help_text = _help_body_text(self._entries) + yield Container( + Static(title_text, id="help-title"), + Static(help_text, id="help-content"), + id="help-dialog", + ) + + def on_mount(self) -> None: + """Arm key-to-close after the opening key event finishes.""" + self.call_after_refresh(self._enable_close) + + def _enable_close(self) -> None: + """Allow subsequent key presses to close the help modal.""" + self._ready_to_close = True + + async def on_key(self, event: textual_events.Key) -> None: + """Close and forward the next key press to the underlying app.""" + event.stop() + if not self._ready_to_close: + return + forwarded_event = textual_events.Key(event.key, event.character) + self.dismiss(None) + self.app.call_after_refresh(lambda: self.app.post_message(forwarded_event)) diff --git a/src/org/histogram.py b/src/org/tui/histogram.py similarity index 63% rename from src/org/histogram.py rename to src/org/tui/histogram.py index f8c9d31..c0cb4d0 100644 --- a/src/org/histogram.py +++ b/src/org/tui/histogram.py @@ -1,55 +1,21 @@ -"""Histogram data structure and rendering functions.""" +"""Histogram rendering functions.""" -from dataclasses import dataclass, field - -from rich.cells import cell_len -from rich.text import Text - -from org.color import bright_blue, colorize, dim_white, get_state_color - - -def visual_len(text: str) -> int: - """Get visual length of text (excluding Rich markup). - - Args: - text: Text that may contain Rich markup or ANSI codes - - Returns: - Visual length of the text - """ - return cell_len(Text.from_markup(text).plain) +from __future__ import annotations +from dataclasses import dataclass, field +from typing import TYPE_CHECKING -@dataclass -class Histogram: - """Represents a distribution of values. - - Attributes: - values: Dictionary mapping categories to their counts - """ - - values: dict[str, int] = field(default_factory=dict) +from org.tui.bits import apply_indent, section_header_lines, visual_len +from org.tui.color import bright_blue, colorize, dim_white, get_state_color - def update(self, key: str, amount: int) -> None: - """Update the count for a given key by the specified amount. - Args: - key: The category to update - amount: The amount to add - """ - self.values[key] = self.values.get(key, 0) + amount +if TYPE_CHECKING: + from org.analyze import Distribution @dataclass class RenderConfig: - """Configuration for histogram rendering. - - Attributes: - color_enabled: Whether to apply colors to the output - histogram_type: Type of histogram ("task_states" or "other") - done_states: List of done state keywords (for task_states coloring) - todo_states: List of todo state keywords (for task_states coloring) - """ + """Configuration for histogram rendering.""" color_enabled: bool = False histogram_type: str = "other" @@ -66,6 +32,16 @@ class HistogramRenderConfig: style: RenderConfig = field(default_factory=RenderConfig) +@dataclass(frozen=True) +class HistogramSectionConfig: + """Configuration for rendering histogram sections.""" + + plot_width: int + order: list[str] + render_config: RenderConfig + indent: str + + def _resolve_render_config( config: HistogramRenderConfig | int, category_order: list[str] | None, @@ -85,9 +61,9 @@ def _resolve_render_config( ) -def _resolve_categories(histogram: Histogram, category_order: list[str] | None) -> list[str]: +def _resolve_categories(distribution: Distribution, category_order: list[str] | None) -> list[str]: """Resolve final category sequence for rendering.""" - occurring_categories = set(histogram.values.keys()) + occurring_categories = set(distribution.values.keys()) if category_order is None: return sorted(occurring_categories) @@ -105,32 +81,22 @@ def _resolve_categories(histogram: Histogram, category_order: list[str] | None) def render_histogram( - histogram: Histogram, + distribution: Distribution, config: HistogramRenderConfig | int, category_order: list[str] | None = None, style: RenderConfig | None = None, ) -> list[str]: - """Render histogram as visual bar chart. - - Args: - histogram: Histogram object to render - config: Histogram rendering configuration - category_order: Deprecated legacy argument - style: Deprecated legacy argument - - Returns: - List of formatted strings, one per category - """ + """Render histogram as visual bar chart.""" render_config_input, legacy_total_blocks = _resolve_render_config(config, category_order, style) render_config = render_config_input.style - total_sum = sum(histogram.values.values()) - categories = _resolve_categories(histogram, render_config_input.category_order) + total_sum = sum(distribution.values.values()) + categories = _resolve_categories(distribution, render_config_input.category_order) lines = [] for category in categories: - value = histogram.values.get(category, 0) + value = distribution.values.get(category, 0) display_name = category[:8] + "." if len(category) > 9 else category if render_config.histogram_type == "task_states": state_style = get_state_color( @@ -172,3 +138,23 @@ def render_histogram( lines.append(line) return lines + + +def format_histogram_section( + title: str, + distribution: Distribution, + config: HistogramSectionConfig, +) -> list[str]: + """Render one histogram section as indented output lines.""" + lines = section_header_lines(title, config.render_config.color_enabled) + histogram_plot_width = max(3, config.plot_width - 2) + histogram_lines = render_histogram( + distribution, + HistogramRenderConfig( + plot_width=histogram_plot_width, + category_order=config.order, + style=config.render_config, + ), + ) + lines.extend([f" {line}" for line in histogram_lines]) + return apply_indent(lines, config.indent) diff --git a/src/org/plot.py b/src/org/tui/plot.py similarity index 61% rename from src/org/plot.py rename to src/org/tui/plot.py index bbd4953..7b39f15 100644 --- a/src/org/plot.py +++ b/src/org/tui/plot.py @@ -2,8 +2,24 @@ from dataclasses import dataclass from datetime import date, timedelta +from typing import TYPE_CHECKING -from org.color import bright_blue, dim_white, magenta +from org.tui.color import bright_blue, dim_white, magenta + + +if TYPE_CHECKING: + from datetime import datetime + + from org.analyze import TimeRange + + +@dataclass(frozen=True) +class TimelineFormatConfig: + """Configuration for rendering timeline charts.""" + + color_enabled: bool + indent: str + plot_width: int @dataclass(frozen=True) @@ -14,17 +30,45 @@ class TimelineRenderConfig: color_enabled: bool = False -def expand_timeline(timeline: dict[date, int], earliest: date, latest: date) -> dict[date, int]: - """Fill in missing dates with 0 activity to create a complete timeline. +def select_earliest_date( + date_from: datetime | None, + global_timerange: TimeRange, + local_timerange: TimeRange, +) -> date | None: + """Select earliest date using priority: user filter > global > local.""" + if date_from: + return date_from.date() + if global_timerange.earliest: + return global_timerange.earliest.date() + if local_timerange.earliest: + return local_timerange.earliest.date() + return None + + +def select_latest_date( + date_until: datetime | None, + global_timerange: TimeRange, + local_timerange: TimeRange, +) -> date | None: + """Select latest date using priority: user filter > global > local.""" + if date_until: + return date_until.date() + if global_timerange.latest: + return global_timerange.latest.date() + if local_timerange.latest: + return local_timerange.latest.date() + if local_timerange.earliest: + return local_timerange.earliest.date() + return None + + +def resolve_timeline_plot_width(config: TimelineFormatConfig) -> int: + """Resolve visual timeline plot width from config.""" + return max(3, config.plot_width) - Args: - timeline: Sparse timeline with only days that have activity - earliest: First date in the range - latest: Last date in the range - Returns: - Complete timeline with all dates from earliest to latest, missing dates set to 0 - """ +def expand_timeline(timeline: dict[date, int], earliest: date, latest: date) -> dict[date, int]: + """Fill in missing dates with 0 activity to create a complete timeline.""" expanded: dict[date, int] = {} current = earliest @@ -36,15 +80,7 @@ def expand_timeline(timeline: dict[date, int], earliest: date, latest: date) -> def bucket_timeline(timeline: dict[date, int], num_buckets: int) -> list[int]: - """Group timeline into N equal-sized time buckets and sum activity per bucket. - - Args: - timeline: Complete timeline dict mapping dates to activity counts - num_buckets: Number of buckets to create - - Returns: - List of bucket sums (length = num_buckets) - """ + """Group timeline into N equal-sized time buckets and sum activity per bucket.""" if not timeline: return [0] * num_buckets @@ -66,15 +102,7 @@ def bucket_timeline(timeline: dict[date, int], num_buckets: int) -> list[int]: def _map_value_to_bar(value: int, max_value: int) -> str: - """Map a value to appropriate unicode bar character based on percentage. - - Args: - value: Value to map - max_value: Maximum value for percentage calculation - - Returns: - Unicode bar character representing the percentage - """ + """Map a value to appropriate unicode bar character based on percentage.""" if max_value == 0 or value == 0: return " " @@ -101,17 +129,7 @@ def render_timeline_chart( latest: date, config: TimelineRenderConfig, ) -> tuple[str, str, str]: - """Create ASCII bar chart from timeline data. - - Args: - timeline: Timeline dict mapping dates to activity counts - earliest: First date in the range - latest: Last date in the range - config: Timeline chart rendering configuration - - Returns: - Tuple of (date_line, chart_line, underline) - """ + """Create ASCII bar chart from timeline data.""" expanded = expand_timeline(timeline, earliest, latest) num_buckets = max(1, config.plot_width - 2) buckets = bucket_timeline(expanded, num_buckets) @@ -153,3 +171,26 @@ def render_timeline_chart( underline = dim_white("‾" * chart_width, config.color_enabled) return (date_line, chart_line, underline) + + +def format_timeline_lines( + timeline: dict[date, int], + earliest_date: date, + latest_date: date, + config: TimelineFormatConfig, +) -> list[str]: + """Render timeline chart lines and apply configured indentation.""" + plot_width = resolve_timeline_plot_width(config) + date_line, chart_line, underline = render_timeline_chart( + timeline, + earliest_date, + latest_date, + TimelineRenderConfig( + plot_width=plot_width, + color_enabled=config.color_enabled, + ), + ) + return [ + f"{config.indent}{line}" if config.indent and line else line + for line in [date_line, chart_line, underline] + ] diff --git a/src/org/tui/prompt.py b/src/org/tui/prompt.py new file mode 100644 index 0000000..bf01243 --- /dev/null +++ b/src/org/tui/prompt.py @@ -0,0 +1,65 @@ +"""Prompt modal helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Input, Static + + +if TYPE_CHECKING: + from collections.abc import Callable + + from textual import events as textual_events + from textual.app import ComposeResult + + +class PromptModalScreen(ModalScreen[str | None]): + """Generic modal screen for single-value text prompts.""" + + CSS_PATH = "styles/prompt_modal.tcss" + + def __init__( + self, + label: str, + *, + initial_value: str = "", + on_change: Callable[[str], None] | None = None, + ) -> None: + """Store modal prompt configuration.""" + super().__init__() + self._label = label + self._initial_value = initial_value + self._on_change = on_change + + def compose(self) -> ComposeResult: + """Render the prompt label and input field.""" + yield Container( + Static(Text(self._label, style="bold"), id="prompt-label"), + Static(Text("Enter submits. Esc cancels.", style="dim"), id="prompt-instructions"), + Input(value=self._initial_value, id="prompt-input"), + id="prompt-dialog", + ) + + def on_mount(self) -> None: + """Focus the prompt input after the modal is mounted.""" + self.query_one(Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + """Forward value changes to optional preview callback.""" + if self._on_change is not None: + self._on_change(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Return submitted input value to caller.""" + self.dismiss(event.value) + + def on_key(self, event: textual_events.Key) -> None: + """Consume escape so prompt cancel doesn't fall through to app bindings.""" + if event.key != "escape": + return + event.stop() + self.dismiss(None) diff --git a/src/org/tui/selection.py b/src/org/tui/selection.py new file mode 100644 index 0000000..6a5b99a --- /dev/null +++ b/src/org/tui/selection.py @@ -0,0 +1,74 @@ +"""Selection modal helpers for interactive Textual UIs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import OptionList, Static + + +if TYPE_CHECKING: + from textual import events as textual_events + from textual.app import ComposeResult + + +@dataclass(frozen=True) +class SelectionOption: + """One selectable value shown by the shared selection modal.""" + + value: str + label: str + + +class SelectionModalScreen(ModalScreen[str | None]): + """Generic modal screen for keyboard-only single-value selection.""" + + CSS_PATH = "styles/selection_modal.tcss" + + def __init__( + self, + label: str, + options: list[SelectionOption], + *, + initial_index: int = 0, + ) -> None: + """Store modal selection configuration.""" + super().__init__() + self._label = label + self._options = options + self._initial_index = initial_index + + def compose(self) -> ComposeResult: + """Render the selection label and option list.""" + yield Container( + Static(Text(self._label, style="bold"), id="selection-label"), + Static( + Text("Up/down moves. Enter selects. Esc cancels.", style="dim"), + id="selection-instructions", + ), + OptionList(*(option.label for option in self._options), id="selection-options"), + id="selection-dialog", + ) + + def on_mount(self) -> None: + """Focus the selection list after the modal is mounted.""" + option_list = self.query_one(OptionList) + if self._options: + option_list.highlighted = min(max(self._initial_index, 0), len(self._options) - 1) + option_list.focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Return the selected option value to the caller.""" + event.stop() + self.dismiss(self._options[event.option_index].value) + + def on_key(self, event: textual_events.Key) -> None: + """Consume escape so selection cancel doesn't fall through to app bindings.""" + if event.key != "escape": + return + event.stop() + self.dismiss(None) diff --git a/src/org/tui/styles/help_modal.tcss b/src/org/tui/styles/help_modal.tcss new file mode 100644 index 0000000..6f2986f --- /dev/null +++ b/src/org/tui/styles/help_modal.tcss @@ -0,0 +1,18 @@ +HelpModalScreen { + align: center middle; + background: $surface 60%; +} + +#help-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 80%; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#help-content { + width: 1fr; +} diff --git a/src/org/tui/styles/prompt_modal.tcss b/src/org/tui/styles/prompt_modal.tcss new file mode 100644 index 0000000..44109d5 --- /dev/null +++ b/src/org/tui/styles/prompt_modal.tcss @@ -0,0 +1,26 @@ +PromptModalScreen { + align: center middle; + background: $surface 60%; +} + +#prompt-dialog { + width: 80%; + max-width: 100; + height: auto; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#prompt-label { + margin-bottom: 1; +} + +#prompt-instructions { + margin-bottom: 1; +} + +#prompt-input { + width: 1fr; + min-width: 20; +} diff --git a/src/org/tui/styles/selection_modal.tcss b/src/org/tui/styles/selection_modal.tcss new file mode 100644 index 0000000..dfc398a --- /dev/null +++ b/src/org/tui/styles/selection_modal.tcss @@ -0,0 +1,29 @@ +SelectionModalScreen { + align: center middle; + background: $surface 60%; +} + +#selection-dialog { + width: 80%; + max-width: 100; + height: auto; + max-height: 24; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#selection-label { + margin-bottom: 1; +} + +#selection-instructions { + margin-bottom: 1; +} + +#selection-options { + width: 1fr; + min-width: 20; + height: auto; + max-height: 16; +} diff --git a/tests/color/test_color.py b/tests/color/test_color.py index 4aba40f..9af4b58 100644 --- a/tests/color/test_color.py +++ b/tests/color/test_color.py @@ -5,7 +5,7 @@ import sys from typing import TYPE_CHECKING -from org import color +from org.tui import color if TYPE_CHECKING: diff --git a/tests/commands/stats/test_stats_commands.py b/tests/commands/stats/test_stats_commands.py index 2aaea85..4288908 100644 --- a/tests/commands/stats/test_stats_commands.py +++ b/tests/commands/stats/test_stats_commands.py @@ -8,12 +8,11 @@ import pytest import typer -from org.analyze import AnalysisResult, Group, Tag, TimeRange +from org.analyze import AnalysisResult, Distribution, Group, Tag, TimeRange from org.commands.stats import all as stats_all_command from org.commands.stats import groups as stats_groups from org.commands.stats import summary as stats_summary_command from org.commands.stats import tags as stats_tags -from org.histogram import Histogram FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures") @@ -639,10 +638,10 @@ def test_format_tasks_summary_renders_histograms() -> None: result = AnalysisResult( total_tasks=2, unique_tasks=2, - task_states=Histogram(values={"DONE": 1, "TODO": 1}), - task_categories=Histogram(values={"null": 2}), - task_priorities=Histogram(values={"null": 2}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "TODO": 1}), + task_categories=Distribution(values={"null": 2}), + task_priorities=Distribution(values={"null": 2}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -673,7 +672,7 @@ def test_format_tasks_summary_orders_task_states_by_group_alphabetically() -> No result = AnalysisResult( total_tasks=6, unique_tasks=6, - task_states=Histogram( + task_states=Distribution( values={ "ZDONE": 1, "ADONE": 1, @@ -683,9 +682,9 @@ def test_format_tasks_summary_orders_task_states_by_group_alphabetically() -> No "AAA": 1, }, ), - task_categories=Histogram(values={"null": 6}), - task_priorities=Histogram(values={"null": 6}), - task_days=Histogram(values={}), + task_categories=Distribution(values={"null": 6}), + task_priorities=Distribution(values={"null": 6}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -720,10 +719,10 @@ def test_format_tasks_summary_omits_none_state_when_absent() -> None: result = AnalysisResult( total_tasks=2, unique_tasks=2, - task_states=Histogram(values={"DONE": 2}), - task_categories=Histogram(values={"null": 2}), - task_priorities=Histogram(values={"null": 2}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 2}), + task_categories=Distribution(values={"null": 2}), + task_priorities=Distribution(values={"null": 2}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -756,10 +755,10 @@ def test_format_tasks_summary_keeps_none_state_when_present() -> None: result = AnalysisResult( total_tasks=2, unique_tasks=2, - task_states=Histogram(values={"DONE": 1, "null": 1}), - task_categories=Histogram(values={"null": 2}), - task_priorities=Histogram(values={"null": 2}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "null": 1}), + task_categories=Distribution(values={"null": 2}), + task_priorities=Distribution(values={"null": 2}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 7bddc66..6f6abe1 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -22,8 +22,8 @@ from org.commands.tasks import capture as capture_command from org.commands.tasks.list import actions from org.commands.tasks.list import command as tasks_list -from org.histogram import visual_len from org.serde.format import OutputFormat, OutputFormatError +from org.tui.bits import visual_len from tests.conftest import node_from_org diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index 163726c..0eea150 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -32,7 +32,7 @@ local_now, ) from org.commands.tasks.common import duration_to_org_text, parse_clock_duration -from org.tui import setup_output +from org.tui.bits import setup_output if TYPE_CHECKING: diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index dd2d521..4a9240a 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -21,7 +21,7 @@ from org.commands.board import actions, ui from org.commands.board import command as board_command from org.commands.interactive_common import heading_locator -from org.tui import setup_output +from org.tui.bits import setup_output from tests.conftest import node_from_org diff --git a/tests/commands/test_interactive_common.py b/tests/commands/test_interactive_common.py index 3348568..54d8ab6 100644 --- a/tests/commands/test_interactive_common.py +++ b/tests/commands/test_interactive_common.py @@ -6,12 +6,11 @@ from org.commands.interactive_common import ( HeadingLocator, - InteractiveHelpEntry, heading_locator, - render_interactive_help_panel_text, resolve_heading_locator, ) from org.commands.tasks.common import todo_states_for_heading +from org.tui.help import InteractiveHelpEntry, render_interactive_help_panel_text from tests.conftest import node_from_org diff --git a/tests/commands/test_runtime.py b/tests/commands/test_runtime.py index 2423a2f..604bc42 100644 --- a/tests/commands/test_runtime.py +++ b/tests/commands/test_runtime.py @@ -9,14 +9,15 @@ from textual.containers import Vertical from textual.widgets import OptionList, Static -from org.commands import runtime +import org.tui.app +import org.tui.selection if TYPE_CHECKING: from textual.app import ComposeResult -class _SelectionHarnessApp(runtime.CommandApp): +class _SelectionHarnessApp(org.tui.app.CommandApp): """Minimal app for exercising the shared selection modal.""" BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "quit_app", show=False)] @@ -30,11 +31,11 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.push_screen( - runtime.SelectionModalScreen( + org.tui.selection.SelectionModalScreen( "Choose value", [ - runtime.SelectionOption(value="alpha", label="alpha"), - runtime.SelectionOption(value="beta", label="beta"), + org.tui.selection.SelectionOption(value="alpha", label="alpha"), + org.tui.selection.SelectionOption(value="beta", label="beta"), ], ), callback=self._store_result, diff --git a/tests/test_analysis_result.py b/tests/test_analysis_result.py index 4e0b3f8..de5663c 100644 --- a/tests/test_analysis_result.py +++ b/tests/test_analysis_result.py @@ -1,7 +1,6 @@ """Tests for the AnalysisResult dataclass.""" -from org.analyze import AnalysisResult, Tag, TimeRange -from org.histogram import Histogram +from org.analyze import AnalysisResult, Distribution, Tag, TimeRange def test_analysis_result_initialization() -> None: @@ -17,11 +16,11 @@ def test_analysis_result_initialization() -> None: result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=10, - task_states=Histogram(values={"DONE": 5, "TODO": 5}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 5, "TODO": 5}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -47,11 +46,11 @@ def test_analysis_result_empty_initialization() -> None: """Test AnalysisResult with empty data.""" result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=0, - task_states=Histogram(values={"DONE": 0, "TODO": 0}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 0, "TODO": 0}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -76,11 +75,11 @@ def test_analysis_result_attributes() -> None: """Test that AnalysisResult has all expected attributes.""" result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=1, - task_states=Histogram(values={"DONE": 1, "TODO": 0}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "TODO": 0}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -120,11 +119,11 @@ def test_analysis_result_repr() -> None: result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=2, - task_states=Histogram(values={"DONE": 1, "TODO": 1}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 1, "TODO": 1}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -152,11 +151,11 @@ def test_analysis_result_equality() -> None: result1 = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=5, - task_states=Histogram(values={"DONE": 3, "TODO": 2}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 3, "TODO": 2}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -167,11 +166,11 @@ def test_analysis_result_equality() -> None: result2 = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=5, - task_states=Histogram(values={"DONE": 3, "TODO": 2}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 3, "TODO": 2}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -182,11 +181,11 @@ def test_analysis_result_equality() -> None: result3 = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=10, - task_states=Histogram(values={"DONE": 5, "TODO": 5}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 5, "TODO": 5}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -207,11 +206,11 @@ def test_analysis_result_mutable_fields() -> None: result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=0, - task_states=Histogram(values={"DONE": 0, "TODO": 0}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 0, "TODO": 0}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, @@ -280,11 +279,11 @@ def test_analysis_result_dict_operations() -> None: result = AnalysisResult( unique_tasks=0, - task_priorities=Histogram(values={}), + task_priorities=Distribution(values={}), total_tasks=3, - task_states=Histogram(values={"DONE": 2, "TODO": 1}), - task_categories=Histogram(values={}), - task_days=Histogram(values={}), + task_states=Distribution(values={"DONE": 2, "TODO": 1}), + task_categories=Distribution(values={}), + task_days=Distribution(values={}), timerange=TimeRange(), avg_tasks_per_day=0.0, max_single_day_count=0, diff --git a/tests/test_histogram.py b/tests/test_histogram.py index 2a5b88c..3d8bb1f 100644 --- a/tests/test_histogram.py +++ b/tests/test_histogram.py @@ -1,40 +1,40 @@ -"""Tests for the Histogram class.""" +"""Tests for the Distribution class.""" -from org.histogram import Histogram +from org.analyze import Distribution def test_histogram_initialization_empty() -> None: - """Test Histogram can be initialized empty.""" - hist = Histogram() + """Test Distribution can be initialized empty.""" + hist = Distribution() assert hist.values == {} def test_histogram_initialization_with_values() -> None: - """Test Histogram can be initialized with values.""" - hist = Histogram(values={"TODO": 5, "DONE": 10}) + """Test Distribution can be initialized with values.""" + hist = Distribution(values={"TODO": 5, "DONE": 10}) assert hist.values["TODO"] == 5 assert hist.values["DONE"] == 10 def test_histogram_repr() -> None: - """Test Histogram repr.""" - hist = Histogram(values={"TODO": 5, "DONE": 10}) + """Test Distribution repr.""" + hist = Distribution(values={"TODO": 5, "DONE": 10}) - assert repr(hist) == "Histogram(values={'TODO': 5, 'DONE': 10})" + assert repr(hist) == "Distribution(values={'TODO': 5, 'DONE': 10})" def test_histogram_empty_repr() -> None: - """Test Histogram repr when empty.""" - hist = Histogram() + """Test Distribution repr when empty.""" + hist = Distribution() - assert repr(hist) == "Histogram(values={})" + assert repr(hist) == "Distribution(values={})" def test_histogram_values_mutable() -> None: - """Test that histogram values can be modified.""" - hist = Histogram(values={"TODO": 5}) + """Test that distribution values can be modified.""" + hist = Distribution(values={"TODO": 5}) hist.update("TODO", 1) hist.values["DONE"] = 10 @@ -45,7 +45,7 @@ def test_histogram_values_mutable() -> None: def test_histogram_get_with_default() -> None: """Test getting values with default.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) assert hist.values.get("TODO", 0) == 5 assert hist.values.get("DONE", 0) == 0 @@ -53,7 +53,7 @@ def test_histogram_get_with_default() -> None: def test_histogram_update_existing_key() -> None: """Test update method with existing key.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) hist.update("TODO", 3) @@ -62,7 +62,7 @@ def test_histogram_update_existing_key() -> None: def test_histogram_update_new_key() -> None: """Test update method with new key.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) hist.update("DONE", 10) @@ -72,7 +72,7 @@ def test_histogram_update_new_key() -> None: def test_histogram_update_negative_amount() -> None: """Test update method with negative amount.""" - hist = Histogram(values={"TODO": 10}) + hist = Distribution(values={"TODO": 10}) hist.update("TODO", -3) @@ -81,7 +81,7 @@ def test_histogram_update_negative_amount() -> None: def test_histogram_update_zero_amount() -> None: """Test update method with zero amount.""" - hist = Histogram(values={"TODO": 5}) + hist = Distribution(values={"TODO": 5}) hist.update("TODO", 0) diff --git a/tests/test_output_format.py b/tests/test_output_format.py index 67bce24..eec64ea 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -14,10 +14,9 @@ from org_parser.text import RichText from rich.syntax import Syntax -from org.analyze import AnalysisResult, Group, Tag, TimeRange +from org.analyze import AnalysisResult, Distribution, Group, Tag, TimeRange from org.commands.tasks import query as query_command from org.commands.tasks.list import command as tasks_list_command -from org.histogram import Histogram from org.serde import format as output_format @@ -260,13 +259,13 @@ def __str__(self) -> str: def test_to_json_compatible_serializes_histogram_with_type_field() -> None: - """Histogram should serialize to a JSON object with a type field.""" - histogram = Histogram(values={"DONE": 3, "TODO": 2}) + """Distribution should serialize to a JSON object with a type field.""" + histogram = Distribution(values={"DONE": 3, "TODO": 2}) result = output_format._to_json_compatible(histogram) assert isinstance(result, dict) - assert result["type"] == "Histogram" + assert result["type"] == "Distribution" assert result["values"] == {"DONE": 3, "TODO": 2} @@ -350,10 +349,10 @@ def test_to_json_compatible_serializes_analysis_result_with_type_field() -> None result = AnalysisResult( total_tasks=4, unique_tasks=4, - task_states=Histogram(values={"DONE": 2, "TODO": 2}), - task_categories=Histogram(values={}), - task_priorities=Histogram(values={"A": 1}), - task_days=Histogram(values={"Monday": 2}), + task_states=Distribution(values={"DONE": 2, "TODO": 2}), + task_categories=Distribution(values={}), + task_priorities=Distribution(values={"A": 1}), + task_days=Distribution(values={"Monday": 2}), timerange=tr, avg_tasks_per_day=0.2, max_single_day_count=2, @@ -372,7 +371,7 @@ def test_to_json_compatible_serializes_analysis_result_with_type_field() -> None task_states = serialized["task_states"] assert isinstance(task_states, dict) - assert task_states["type"] == "Histogram" + assert task_states["type"] == "Distribution" assert task_states["values"] == {"DONE": 2, "TODO": 2} timerange = serialized["timerange"] diff --git a/tests/test_priority.py b/tests/test_priority.py index 7886719..a54e882 100644 --- a/tests/test_priority.py +++ b/tests/test_priority.py @@ -5,8 +5,7 @@ from rich.text import Text from org.analyze import compute_priority_histogram -from org.histogram import visual_len -from org.tui import TaskLineConfig, format_task_line +from org.tui.bits import TaskLineConfig, format_task_line, visual_len from tests.conftest import node_from_org diff --git a/tests/test_render_histogram.py b/tests/test_render_histogram.py index d088fe8..11beb4d 100644 --- a/tests/test_render_histogram.py +++ b/tests/test_render_histogram.py @@ -1,11 +1,12 @@ """Tests for the render_histogram() function.""" -from org.histogram import Histogram, render_histogram +from org.analyze import Distribution +from org.tui.histogram import render_histogram def test_render_histogram_empty() -> None: """Test empty histogram with no values.""" - histogram = Histogram(values={}) + histogram = Distribution(values={}) category_order = ["Some", "Value", "unknown"] lines = render_histogram(histogram, 40, category_order) @@ -18,7 +19,7 @@ def test_render_histogram_empty() -> None: def test_render_histogram_single_value_100_percent() -> None: """Test one category with all the count.""" - histogram = Histogram(values={"Value": 100}) + histogram = Distribution(values={"Value": 100}) category_order = ["Some", "Value"] lines = render_histogram(histogram, 40, category_order) @@ -30,7 +31,7 @@ def test_render_histogram_single_value_100_percent() -> None: def test_render_histogram_all_equal_values() -> None: """Test multiple categories with equal counts.""" - histogram = Histogram(values={"Some": 50, "Value": 50}) + histogram = Distribution(values={"Some": 50, "Value": 50}) category_order = ["Some", "Value"] lines = render_histogram(histogram, 40, category_order) @@ -42,7 +43,7 @@ def test_render_histogram_all_equal_values() -> None: def test_render_histogram_normal_distribution() -> None: """Test realistic distribution.""" - histogram = Histogram( + histogram = Distribution( values={ "Monday": 3526, "Tuesday": 3273, @@ -79,7 +80,7 @@ def test_render_histogram_normal_distribution() -> None: def test_render_histogram_zero_values_shown() -> None: """Test explicit categories render even when keys are missing.""" - histogram = Histogram(values={"DONE": 30}) + histogram = Distribution(values={"DONE": 30}) category_order = ["DONE", "TODO", "CANCELLED"] lines = render_histogram(histogram, 40, category_order) @@ -92,7 +93,7 @@ def test_render_histogram_zero_values_shown() -> None: def test_render_histogram_zero_value_key_included() -> None: """Test zero-value key renders when present in histogram.""" - histogram = Histogram(values={"DONE": 30, "TODO": 0}) + histogram = Distribution(values={"DONE": 30, "TODO": 0}) category_order = ["DONE", "TODO", "CANCELLED"] lines = render_histogram(histogram, 40, category_order) @@ -105,7 +106,7 @@ def test_render_histogram_zero_value_key_included() -> None: def test_render_histogram_category_truncation() -> None: """Test long category names truncated.""" - histogram = Histogram(values={"verylongname": 50, "short": 50}) + histogram = Distribution(values={"verylongname": 50, "short": 50}) category_order = ["verylongname", "short"] lines = render_histogram(histogram, 40, category_order) @@ -117,7 +118,7 @@ def test_render_histogram_category_truncation() -> None: def test_render_histogram_category_padding() -> None: """Test short names padded.""" - histogram = Histogram(values={"a": 50, "ab": 25, "abc": 25}) + histogram = Distribution(values={"a": 50, "ab": 25, "abc": 25}) category_order = ["a", "ab", "abc"] lines = render_histogram(histogram, 40, category_order) @@ -130,7 +131,7 @@ def test_render_histogram_category_padding() -> None: def test_render_histogram_custom_order() -> None: """Test respects category_order parameter.""" - histogram = Histogram(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) + histogram = Distribution(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) category_order = ["TODO", "DONE", "CANCELLED"] lines = render_histogram(histogram, 40, category_order) @@ -143,7 +144,7 @@ def test_render_histogram_custom_order() -> None: def test_render_histogram_no_order_alphabetical() -> None: """Test alphabetical when no order given.""" - histogram = Histogram(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) + histogram = Distribution(values={"DONE": 30, "TODO": 10, "CANCELLED": 5}) lines = render_histogram(histogram, 40, None) @@ -155,7 +156,7 @@ def test_render_histogram_no_order_alphabetical() -> None: def test_render_histogram_different_bucket_widths() -> None: """Test various bucket sizes.""" - histogram = Histogram(values={"Value": 100}) + histogram = Distribution(values={"Value": 100}) lines_20 = render_histogram(histogram, 20, ["Value"]) lines_50 = render_histogram(histogram, 50, ["Value"]) @@ -168,7 +169,7 @@ def test_render_histogram_different_bucket_widths() -> None: def test_render_histogram_proper_rounding() -> None: """Test bar lengths round correctly.""" - histogram = Histogram(values={"A": 33, "B": 33, "C": 34}) + histogram = Distribution(values={"A": 33, "B": 33, "C": 34}) lines = render_histogram(histogram, 30, ["A", "B", "C"]) @@ -184,7 +185,7 @@ def test_render_histogram_proper_rounding() -> None: def test_render_histogram_with_unknown_category() -> None: """Test unknown category handling.""" - histogram = Histogram(values={"Monday": 5, "Tuesday": 5, "Wednesday": 7, "unknown": 2}) + histogram = Distribution(values={"Monday": 5, "Tuesday": 5, "Wednesday": 7, "unknown": 2}) category_order = ["Monday", "Tuesday", "Wednesday", "unknown"] lines = render_histogram(histogram, 40, category_order) @@ -195,7 +196,7 @@ def test_render_histogram_with_unknown_category() -> None: def test_render_histogram_wednesday_no_truncation() -> None: """Test Wednesday (9 chars) fits exactly without truncation.""" - histogram = Histogram(values={"Wednesday": 100}) + histogram = Distribution(values={"Wednesday": 100}) lines = render_histogram(histogram, 40, ["Wednesday"]) @@ -204,7 +205,7 @@ def test_render_histogram_wednesday_no_truncation() -> None: def test_render_histogram_exact_nine_chars() -> None: """Test category with exactly 9 characters.""" - histogram = Histogram(values={"ExactNine": 100}) + histogram = Distribution(values={"ExactNine": 100}) lines = render_histogram(histogram, 40, ["ExactNine"]) @@ -213,7 +214,7 @@ def test_render_histogram_exact_nine_chars() -> None: def test_render_histogram_ten_chars_truncated() -> None: """Test category with 10 characters gets truncated.""" - histogram = Histogram(values={"TenCharact": 100}) + histogram = Distribution(values={"TenCharact": 100}) lines = render_histogram(histogram, 40, ["TenCharact"]) @@ -222,7 +223,7 @@ def test_render_histogram_ten_chars_truncated() -> None: def test_render_histogram_proportional_to_sum() -> None: """Test bars are proportional to sum, not max value.""" - histogram = Histogram(values={"A": 10, "B": 90}) + histogram = Distribution(values={"A": 10, "B": 90}) lines = render_histogram(histogram, 100, ["A", "B"]) @@ -232,7 +233,7 @@ def test_render_histogram_proportional_to_sum() -> None: def test_render_histogram_preserves_category_order_with_missing() -> None: """Test that explicit categories render before histogram extras.""" - histogram = Histogram(values={"B": 50}) + histogram = Distribution(values={"B": 50}) category_order = ["A", "B", "C"] lines = render_histogram(histogram, 40, category_order) @@ -245,7 +246,7 @@ def test_render_histogram_preserves_category_order_with_missing() -> None: def test_render_histogram_ordered_categories_then_remaining_alphabetical() -> None: """Render order should prefer provided categories, then append remaining sorted.""" - histogram = Histogram(values={"C": 5, "A": 3, "B": 4, "Z": 1}) + histogram = Distribution(values={"C": 5, "A": 3, "B": 4, "Z": 1}) lines = render_histogram(histogram, 40, ["B", "missing", "A"]) @@ -254,7 +255,7 @@ def test_render_histogram_ordered_categories_then_remaining_alphabetical() -> No def test_render_histogram_empty_with_no_order() -> None: """Test empty histogram with no order produces empty list.""" - histogram = Histogram(values={}) + histogram = Distribution(values={}) lines = render_histogram(histogram, 40, None) @@ -263,7 +264,7 @@ def test_render_histogram_empty_with_no_order() -> None: def test_render_histogram_single_bar_with_small_value() -> None: """Test that small values still show correctly.""" - histogram = Histogram(values={"A": 1, "B": 99}) + histogram = Distribution(values={"A": 1, "B": 99}) lines = render_histogram(histogram, 100, ["A", "B"]) diff --git a/tests/test_timeline_bucketing.py b/tests/test_timeline_bucketing.py index 230f147..39435e6 100644 --- a/tests/test_timeline_bucketing.py +++ b/tests/test_timeline_bucketing.py @@ -2,7 +2,7 @@ from datetime import date -from org.plot import bucket_timeline +from org.tui.plot import bucket_timeline def test_bucket_timeline_evenly_divisible() -> None: diff --git a/tests/test_timeline_chart.py b/tests/test_timeline_chart.py index 809424b..0a50c4b 100644 --- a/tests/test_timeline_chart.py +++ b/tests/test_timeline_chart.py @@ -2,7 +2,7 @@ from datetime import date -from org.plot import TimelineRenderConfig, _map_value_to_bar, render_timeline_chart +from org.tui.plot import TimelineRenderConfig, _map_value_to_bar, render_timeline_chart def test_map_value_to_bar_zero_max() -> None: diff --git a/tests/test_timeline_expansion.py b/tests/test_timeline_expansion.py index b517c09..2d18def 100644 --- a/tests/test_timeline_expansion.py +++ b/tests/test_timeline_expansion.py @@ -2,7 +2,7 @@ from datetime import date -from org.plot import expand_timeline +from org.tui.plot import expand_timeline def test_expand_timeline_single_day() -> None: diff --git a/tests/tui/test_tui_display.py b/tests/tui/test_tui_display.py index b937c53..8c7814b 100644 --- a/tests/tui/test_tui_display.py +++ b/tests/tui/test_tui_display.py @@ -3,7 +3,7 @@ from __future__ import annotations from org.analyze import Group, TimeRange -from org.tui import ( +from org.tui.bits import ( TopTasksSectionConfig, format_groups_section, format_top_tasks_section, From 6f3433dfcda9aed3376f06bd636557ae1abe99fd Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Fri, 12 Jun 2026 20:37:53 +0200 Subject: [PATCH 20/27] Reorganized the common logic modules. --- VIBES.org | 203 ++++++++++- src/org/__init__.py | 7 - src/org/cli.py | 2 +- src/org/commands/agenda/actions.py | 24 +- src/org/commands/agenda/app.py | 2 +- src/org/commands/agenda/command.py | 2 +- src/org/commands/agenda/ui.py | 4 +- src/org/commands/board/actions.py | 17 +- src/org/commands/board/command.py | 2 +- src/org/commands/stats/all.py | 10 +- src/org/commands/stats/command.py | 2 +- src/org/commands/stats/groups.py | 18 +- src/org/commands/stats/summary.py | 4 +- src/org/commands/stats/tags.py | 18 +- src/org/commands/tasks/add.py | 2 +- src/org/commands/tasks/archive.py | 4 +- src/org/commands/tasks/edit.py | 4 +- src/org/commands/tasks/find.py | 2 +- src/org/commands/tasks/list/actions.py | 25 +- src/org/commands/tasks/list/command.py | 2 +- src/org/commands/tasks/query.py | 2 +- src/org/commands/tasks/remove.py | 2 +- src/org/commands/tasks/update.py | 2 +- src/org/logic/__init__.py | 1 + src/org/{commands => logic}/archive.py | 0 src/org/{commands/editor.py => logic/edit.py} | 0 src/org/{cli_common.py => logic/filtering.py} | 65 +--- .../search_common.py => logic/search.py} | 0 src/org/{analyze.py => logic/stats.py} | 325 ++---------------- src/org/logic/tasks.py | 97 ++++++ .../interactive_common.py => logic/time.py} | 130 +++---- src/org/{ => logic}/validation.py | 70 +--- src/org/query_language/runtime.py | 2 +- src/org/serde/format.py | 2 +- src/org/timestamp.py | 87 ----- src/org/tui/bits.py | 4 +- src/org/tui/histogram.py | 2 +- src/org/tui/plot.py | 2 +- tests/cli/test_cli_runner.py | 2 +- tests/cli_common/test_cli_common_helpers.py | 4 +- tests/cli_common/test_filter_construction.py | 46 +-- tests/cli_common/test_top_tasks.py | 2 +- tests/cli_common/test_validation.py | 2 +- tests/commands/stats/test_stats_commands.py | 2 +- tests/commands/tasks/test_tasks_edit.py | 2 +- tests/commands/tasks/test_tasks_list.py | 24 +- tests/commands/test_agenda_command.py | 26 +- tests/commands/test_board.py | 24 +- tests/commands/test_board_app.py | 2 +- tests/commands/test_interactive_common.py | 4 +- tests/test_analysis_result.py | 4 +- tests/test_analyze.py | 2 +- tests/test_clean.py | 4 +- tests/test_combine_time_ranges.py | 2 +- tests/test_compute_category_histogram.py | 2 +- tests/test_compute_day_of_week_histogram.py | 2 +- tests/test_compute_frequencies.py | 2 +- tests/test_compute_global_timerange.py | 2 +- tests/test_compute_groups.py | 2 +- tests/test_compute_helpers.py | 2 +- tests/test_compute_relations.py | 2 +- tests/test_compute_task_state_histogram.py | 2 +- tests/test_compute_task_stats.py | 2 +- tests/test_compute_time_ranges.py | 2 +- tests/test_extract_timestamp.py | 2 +- tests/test_frequency.py | 2 +- tests/test_histogram.py | 2 +- tests/test_integration.py | 2 +- tests/test_mapped.py | 2 +- tests/test_normalize.py | 2 +- tests/test_output_format.py | 2 +- tests/test_priority.py | 2 +- tests/test_relations.py | 2 +- tests/test_render_histogram.py | 2 +- tests/test_time_range.py | 2 +- tests/tui/test_tui_display.py | 2 +- 76 files changed, 555 insertions(+), 788 deletions(-) create mode 100644 src/org/logic/__init__.py rename src/org/{commands => logic}/archive.py (100%) rename src/org/{commands/editor.py => logic/edit.py} (100%) rename src/org/{cli_common.py => logic/filtering.py} (96%) rename src/org/{commands/search_common.py => logic/search.py} (100%) rename src/org/{analyze.py => logic/stats.py} (64%) create mode 100644 src/org/logic/tasks.py rename src/org/{commands/interactive_common.py => logic/time.py} (63%) rename src/org/{ => logic}/validation.py (75%) delete mode 100644 src/org/timestamp.py diff --git a/VIBES.org b/VIBES.org index e9c5134..392c481 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7256,12 +7256,13 @@ When search is enabled in the board, the currently visibla tasks for each column It would be really nice if the search results could be updated live, whenever any input is entered into the search prompt. Update all interactive commands that permit search to live-update during search input prompting. -*** All interactive commands should support the =--width= CLI switch +*** DONE All interactive commands should support the =--width= CLI switch +:LOGBOOK: +- State "DONE" from [2026-06-07 Sun 10:26] +:END: *** Task IDs The tasks IDs of newly created tasks should default to ={{document_category}}-{{count_of_headings_in_file + 1}}= Show the task ID (when present) in the board task panels as well as agenda and task list. In all cases, it should preceed the TODO state. -*** Bulk actions -It should be possible to mark multiple items in agenda, board & task list and apply an action to them in bulk *** Task availability indicators The agenda and board commands should accept a =--workflow NAME= CLI switch that adds the following task availability indicators: @@ -7353,6 +7354,27 @@ Please don't leave unused arguments in functions, if an argument is no longer ne *** Mark as completed should respect repeaters The timestamp advancement should only be used if the new state is a completed state. In such a case, the state should be updated to the first TODO state defined for the document, a logbook repeat should be added to the task (`State {node.todo} to {new_state}`) and the repeaters should be advanced according to its rules. +*** TODO Timestamps without time components should be preserved in math ops :Bug: +Running this query: ~timestamp("<2026-06-06>") + days(1)~ should produce a timestamp without a time component as the original date did not have it. + +*** Show configured filters/orderings/mutators in the command help strings +*** Ability to add extra filter/order-by queries in interactive commands +Commands should support the following key bindings: +- `Shift-f` for filtering +- `Shift-o` for order-by +- `Shift-m` for mutators + +Show a modal with a multi-selection list for all entities in each category. The user should be able to select multiple filters, and each state transition should result in the entity being applied to the org mode files. +*** Mor uniform key bindings +- =Control + f= for search. +- No need for =x= if search can be set to blank. +- Drop =n=, =p=, =b= and =f= for agenda navigation. +- Support =t= for todo and =g= for tags in all commands. +- Support =s=, =c=, =d= for planning in all commands. +- Support =r= for refile and =$= for archive in all commands. +- Support =k= for clocking and =e= for effort estimation. +- Support =C- + ** TODO Codebase improvements :Cleanup: *** DONE Extract the =stats= command wrapper into a separate module :LOGBOOK: @@ -7539,13 +7561,61 @@ The shared logic that the commands use are currently present in multiple modules - =time= - current contents of =org/timestamp.py= and time-related functions from =org/commands/interactive_common.py= - =validation= - current contents of =org/validation.py= +In addition to the above, the =org/tui/bits.py= module now contains a lot of non-UI related functions. These should also be moved to the =org/logic= package to the appropriate submodules. + Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. Make sure to update the commands to use this new logic package. +*** Use Textual toasts for status messages. *** Use Textual textarea for any org-mode syntax highlighting - Limit to just the interactive commands. - Potentially use it for code edits of the files? **** Show a Textual modal when performing external edits, instead of suspending the app +*** TODO Load configuration first and pass the object to the commands +Do not use global state for configuration storage. The goal is for the commands to be stateless. + +The config processing flow should look like this: +- create a well-defined structured object that will hold the configuration, +- populate the config object with code-defaults, +- determine the configuration file to use, +- load the configuration file with well-defined sections, +- update the structured config object with config file values, +- pass the configuration object to the specific command, +- overwrite the configuration object with command CLI switch values where applicable, +- log the effective config object, +- execute the command with all the configuration values taken from the resulting config object + +It might be the case, that the command tree construction will need to be done after the configuration is loaded. That is absolutely fine - update the =main= function to do that. + +The shape of the configuration object: +#+begin_example +config: + defaults: dict[string, string] # for backwards compatibility until we clean this up + + todo_states: list[string], same content as defaults.--todo-states when provided + done_states: list[string], same content as defaults.--done-states when provided + + filters: list[current filter config] + orderings: list[current order-by config] + mutators: list[current with config] + + stats: + exclude: file path to the exclude file (same as defaults.--exclude if provided) + mapping: file path to the mapping file (same as defaults.--mapping if provided) + + capture: + templates: list[current capture templates config] + + agenda: + views: list[current agenda view config] + + board: + views: list[current board vie config] +#+end_example + +Update the commands to use this new structured configuration object (prefer the command-specific sections for the value, not =config.defaults=). +Update the tests not to rely on the global state. + *** CANCELLED Use Textual header & footer widgets in the interactive commands :LOGBOOK: - State "CANCELLED" from [2026-06-11 Thu 09:46] \\ @@ -7877,6 +7947,27 @@ agenda: align: right #+end_src +*** Customizable agenda actions :Config: +Allow defining additional task actions that can be executed in the agenda view. +#+begin_example +agenda: + actions: + - name: Set todo + description: Set todo state of a task + key: t + input: select + options: .document.all_states + action: .todo = $option + - name: Shift date + description: Shift the date of a task + key: S-right_arrow + action: .scheduled.start = .scheduled.start + days(1) +#+end_example + +Built-in task actions that can be expressed in terms of this system, should be refactored to rely on it. +*** Bulk actions +It should be possible to mark multiple items in agenda, board & task list and apply an action to them in bulk + *** CANCELLED Agenda review/standalone review command :LOGBOOK: - State "CANCELLED" from [2026-05-31 Sun 22:10] \\ @@ -7909,7 +8000,7 @@ The action will prompt the user for each step multiple times (empty response mov When typing, the action should propose existing tasks by title. When a newly added task matches an existing recurrent task by title it should be "reused" instead of creating a duplicate (reuse as in, scheduled set to the current day while preserving the repeater if present). -** Query language extensions +** TODO Query language extensions *** Query language improvements - Uniform function calling: =foo | not= is the same as =not(foo)= - Field assignment should return the mutated object: =foo.title = "bar"= evaluates to =foo= after the change. @@ -7942,9 +8033,48 @@ Introduce wrappers for the analysis results functions that can be visualized aut - =histogram()= - histogram plot - =progress()= - progress plot -** Board command improvements :UX: +*** Search sigil +Searches documents for a specific node either by title or by id. Makes use of that documents cache. +#+begin_example +.[] | #"id-or-title" +.[][] | select(.id == "id-or-title" or .title.text == "id-or-title") +#+end_example + +*** TODO sort_by accepting multiple criteria +Currently, =sort_by()= only accepts one criteria for ordering the stream. It should accept multiple criteria as well, so things like the following are possible: +#+begin_example +sort_by(.priority) # sort by priority alone +sort_by(.priority, .latest_timestamp) # sort by priority first, break ties by latest timestamp +sort_by(.priority, .category, .title) # sort by multiple values in order +sort_by(-.property.some_val, .latest_timestamp.start.month) # sort by multiple compound subqueries +#+end_example +** Query command improvements :UX:Query: +*** Saved queries :Config: +Add a configuration file section for saved queries that can be invoked with =org tasks query= in place of the actual query string. + +#+begin_example +query: + - name: all-todos + query: .[][] | select(.todo == "TODO") +#+end_example + +Invocation: +#+begin_example +org tasks query all-todos +#+end_example + +** Board command improvements :UX:Board: *** Board plan command Shows long term plan including completeness of tasks that have children and gannt-style chart for execution. +*** Customizable agenda actions :Config: +Allow defining additional task actions that can be executed in the board view. +#+begin_example +board : + actions: + - key: C-i + description: Set the ID property on a tasks + action: .id = .id or (.category + "-" + str(.document.all_nodes | length)) +#+end_example *** Gantt chart - Show a gantt chart of tasks and their dependencies *** Progress view @@ -7953,6 +8083,14 @@ Shows long term plan including completeness of tasks that have children and gann - List to be oredered by tree order (top-level tasks followed by their children) *** High CPU usage when browsing the board :Bug: +*** Bulk actions +It should be possible to mark multiple items in agenda, board & task list and apply an action to them in bulk + +*** Copy & copy as markdown actions +** Tasks list command improvements +*** Configurable column view +Should work the same as the configurable agenda view columns. + ** Stats command improvements *** =--days= switch for =stats= commands - Same as =--filter-date-from= with date X days ago. @@ -7960,8 +8098,16 @@ Shows long term plan including completeness of tasks that have children and gann - Distribution of task durations - for tasks that have a =capture_time= property and are completed, a time the task was "up" can be computed and put on a chart (needs special handling for recurrent tasks) - Effort distribution - for tasks that have an =effort= property, convert that to minutes and show on chart - Gamify exp progression on various skills -** Per-project config +*** Interactive UI +- Watch input files for changes and recompute stats live +- Use collapsiple sections for summary (open by default), tags and groups +- Allow adjusting filters/analysis parameters live +- Support =--out json/plain= for non-interactive results + +** Per-project config :Config: - Use =.org= directory to store the config file and any other useful bits such as indeces. +- Cache parsed nodes in the =.org= dir. +- Default views config for each command that supports it, no global CLI switch defaults. *** Init command - Initializes the config file with some defaults. @@ -7990,7 +8136,7 @@ Shows long term plan including completeness of tasks that have children and gann *** Config handling is too generic Split config handling to be per-command and have the config file reflect that. *** Refactor config handling so that no global state is mutated -** Dashboard command +** Dashboard command :Dashboard: - Reads a dashboard config and shows a visualization on a server. - Tabbed views for the agenda, board, backlog and stats - Live updating. @@ -8011,7 +8157,7 @@ It should apply to each of the available commands. This parameter should be made - Document the config format - Update the help strings in the CLI commands to better match the usage -** Website for org-cli +*** Website for org-cli - Publish the package - Add a static site for org-cli.idorobots.org on github pages. - Documentation with mkDocs @@ -8039,12 +8185,20 @@ Extend =org tasks find= with =--search-rag=. This is better served with data export & a separate script. :END: To be done as a separate script that get's the analysis result JSON. -*** D3 visualization - tag cloud - -*** D3 visualization - relations graph +*** CANCELLED D3 visualization - tag cloud +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: +*** CANCELLED D3 visualization - relations graph +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Node size is how many tasks are related to a tag. Closeness is how related these are. -*** D3 visualization - tag charts over time +*** CANCELLED D3 visualization - tag charts over time +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Allow selecting specific tags or top tags. ** CANCELLED Agents command @@ -8056,23 +8210,32 @@ Spawn agents based on workflow config, all tasks that can be done in parallel, s Should be able to daemonize and hook up to a running daemon. Show spawned agents, their status, what they are working on, etc. -*** Agents command: multi-session screen UI +*** CANCELLED Agents command: multi-session screen UI :PROPERTIES: :CAPTURE_TIME: <2026-05-05 Tue 09:24> :ID: Vibes-425 :END: +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Implement an Agents command UI that supports running multiple agent harness sessions in the background and browsing them in a split-screen TUI. Add a left sidebar listing open sessions with current task and a clear indicator for sessions waiting for human input (for example permissions). Show the active session view on the right. Initial focus should be on the sidebar; moving focus right should enter session interaction mode; provide a keybinding to detach and return focus to the session list; sessions must remain attachable at any time while continuing to run in the background. -*** Agents command: workflow loop daemon run (--deamon) +*** CANCELLED Agents command: workflow loop daemon run (--deamon) :PROPERTIES: :CAPTURE_TIME: <2026-05-05 Tue 11:45> :ID: Vibes-427 :END: +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Add org agents run --deamon to run the workflow loop using separate agent sessions for each available task until completion or human input is required. Loop: pick a task in the earliest available state, assign the specified agent, create a git worktree and a branch for the task, spawn a session, let the agent do its job, and use skills to push tasks along. -*** Agents command: notify only on human intervention required +*** CANCELLED Agents command: notify only on human intervention required :PROPERTIES: :CAPTURE_TIME: <2026-05-05 Tue 11:45> :ID: Vibes-428 :END: +:LOGBOOK: +- State "CANCELLED" from [2026-06-07 Sun 10:25] +:END: Add a notification command integration (for instance notify-send) when human intervention is required, such as permission approval. Notifications should be sent only when the agent requires human intervention, and not for normal progress updates. ** CANCELLED Spec command :LOGBOOK: @@ -8080,3 +8243,13 @@ Add a notification command integration (for instance notify-send) when human int Should be handled by workflow configuration, no need for a dedicated command. :END: Similar to flow, but for full specifications, easy extension, replacement, validation etc. +* TODO Flicker when attempting to edit a non-task row in the agenda. +:PROPERTIES: +:capture_time: [2026-06-11 Thu 15:54] +:context: +:END: +* TODO Agenda command allows highlighting the section titles. Section titles should be omitted when scrolling the highlight. +:PROPERTIES: +:capture_time: [2026-06-11 Thu 15:54] +:context: +:END: diff --git a/src/org/__init__.py b/src/org/__init__.py index cc56955..b03d00f 100644 --- a/src/org/__init__.py +++ b/src/org/__init__.py @@ -1,18 +1,11 @@ """org - Analyze Emacs Org-mode archive files for task statistics.""" -from org.analyze import AnalysisResult, Frequency, Relations, Tag, TimeRange, analyze from org.cli import main __version__ = "0.1.0" __all__ = [ - "AnalysisResult", - "Frequency", - "Relations", - "Tag", - "TimeRange", "__version__", - "analyze", "main", ] diff --git a/src/org/cli.py b/src/org/cli.py index d89aab9..f56e5f1 100644 --- a/src/org/cli.py +++ b/src/org/cli.py @@ -8,10 +8,10 @@ import org.config.app import org.logging -from org.cli_common import DEFAULT_VERBOSE, resolve_verbose from org.commands import agenda, stats from org.commands import board as board_command from org.commands.tasks import command as tasks_command +from org.logic.filtering import DEFAULT_VERBOSE, resolve_verbose app = typer.Typer( diff --git a/src/org/commands/agenda/actions.py b/src/org/commands/agenda/actions.py index 2b8d7fe..cc66f4b 100644 --- a/src/org/commands/agenda/actions.py +++ b/src/org/commands/agenda/actions.py @@ -11,19 +11,6 @@ import typer from org_parser.time import Clock, Timestamp -from org.cli_common import load_and_process_data -from org.commands.archive import archive_heading_subtree_and_save -from org.commands.editor import edit_heading_subtree_in_external_editor -from org.commands.interactive_common import ( - HeadingLocator, - advance_timestamp_by_repeater, - append_repeat_transition, - heading_locator, - local_now, - resolve_heading_locator, - set_timestamp_fields, -) -from org.commands.search_common import filter_nodes_by_search from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( configured_capture_template_names, @@ -34,6 +21,17 @@ save_document, todo_states_for_heading, ) +from org.logic.archive import archive_heading_subtree_and_save +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.logic.filtering import load_and_process_data +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import ( + HeadingLocator, + append_repeat_transition, + heading_locator, + resolve_heading_locator, +) +from org.logic.time import advance_timestamp_by_repeater, local_now, set_timestamp_fields from .ui import ( AgendaColumnWidths, diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 07779a1..6180a88 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -15,11 +15,11 @@ import org.tui.help import org.tui.prompt import org.tui.selection -from org.cli_common import resolve_input_paths from org.commands.tasks.common import ( clock_duration_prompt_label, configured_capture_template_names, ) +from org.logic.filtering import resolve_input_paths from . import actions, ui from .actions import AgendaSession, create_agenda_session diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index a52ebc6..6e2a5ab 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -11,7 +11,7 @@ import org.config.app import org.logging -from org.cli_common import load_and_process_data +from org.logic.filtering import load_and_process_data from org.tui.bits import build_console, processing_status, setup_output from org.tui.help import interactive_help_command_text diff --git a/src/org/commands/agenda/ui.py b/src/org/commands/agenda/ui.py index 25b2a62..c295698 100644 --- a/src/org/commands/agenda/ui.py +++ b/src/org/commands/agenda/ui.py @@ -12,7 +12,8 @@ from rich.table import Table from rich.text import Text -from org.commands.interactive_common import local_now, shift_datetimes_by_unit +from org.logic.time import local_now, shift_datetimes_by_unit +from org.logic.validation import parse_date_argument from org.query_language import EvalContext, QueryRuntimeError, Stream from org.tui.bits import ( heading_title_to_text, @@ -21,7 +22,6 @@ task_tags_to_text, ) from org.tui.help import InteractiveHelpEntry -from org.validation import parse_date_argument if TYPE_CHECKING: diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index 065e8d8..81faf07 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -10,21 +10,20 @@ import typer import org.config.app -from org.cli_common import load_and_process_data -from org.commands.archive import archive_heading_subtree_and_save -from org.commands.editor import edit_heading_subtree_in_external_editor -from org.commands.interactive_common import ( +from org.commands.tasks.capture import TasksCaptureArgs, capture_task +from org.commands.tasks.common import configured_capture_template_names, save_document +from org.logic.archive import archive_heading_subtree_and_save +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.logic.filtering import load_and_process_data +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import ( HeadingLocator, - advance_timestamp_by_repeater, append_repeat_transition, heading_locator, - local_now, resolve_heading_locator, shift_priority, ) -from org.commands.search_common import filter_nodes_by_search -from org.commands.tasks.capture import TasksCaptureArgs, capture_task -from org.commands.tasks.common import configured_capture_template_names, save_document +from org.logic.time import advance_timestamp_by_repeater, local_now from org.query_language import ( CompiledQuery, EvalContext, diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index f414a2d..b5b4d03 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -11,7 +11,7 @@ import org.config.app import org.logging -from org.cli_common import load_and_process_data +from org.logic.filtering import load_and_process_data from org.tui.bits import build_console, processing_status, setup_output from org.tui.help import interactive_help_command_text diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 5f6e0e7..5c72daa 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -13,9 +13,8 @@ import org.config.app import org.logging -from org.analyze import AnalysisResult, Tag, TimeRange, analyze, clean -from org.analyze import Group as TagGroup -from org.cli_common import ( +from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary +from org.logic.filtering import ( CATEGORY_NAMES, get_top_tasks, load_and_process_data, @@ -23,7 +22,9 @@ resolve_exclude_set, resolve_mapping, ) -from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary +from org.logic.stats import AnalysisResult, Tag, TimeRange, analyze, clean +from org.logic.stats import Group as TagGroup +from org.logic.validation import validate_stats_arguments from org.tui.bits import ( GroupBlockConfig, TagBlockConfig, @@ -37,7 +38,6 @@ setup_output, ) from org.tui.plot import TimelineFormatConfig -from org.validation import validate_stats_arguments if TYPE_CHECKING: diff --git a/src/org/commands/stats/command.py b/src/org/commands/stats/command.py index 99fd35b..086f846 100644 --- a/src/org/commands/stats/command.py +++ b/src/org/commands/stats/command.py @@ -5,10 +5,10 @@ import typer import org.logging -from org.cli_common import DEFAULT_VERBOSE, resolve_verbose from org.commands.stats import all as stats_all from org.commands.stats import groups, tags from org.commands.stats import summary as stats_summary +from org.logic.filtering import DEFAULT_VERBOSE, resolve_verbose stats_app = typer.Typer( diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index a0ccc1e..2f6efec 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -10,7 +10,14 @@ import org.config.app import org.logging -from org.analyze import ( +from org.logic.filtering import ( + load_and_process_data, + resolve_date_filters, + resolve_exclude_set, + resolve_group_values, + resolve_mapping, +) +from org.logic.stats import ( Group, TimeRange, compute_explicit_groups, @@ -21,13 +28,7 @@ compute_relations, compute_time_ranges, ) -from org.cli_common import ( - load_and_process_data, - resolve_date_filters, - resolve_exclude_set, - resolve_group_values, - resolve_mapping, -) +from org.logic.validation import validate_stats_arguments from org.tui.bits import ( GroupBlockConfig, apply_indent, @@ -39,7 +40,6 @@ setup_output, ) from org.tui.plot import TimelineFormatConfig -from org.validation import validate_stats_arguments if TYPE_CHECKING: diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index e43adbb..da663cc 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -10,7 +10,8 @@ import org.config.app import org.logging -from org.analyze import ( +from org.logic.filtering import load_and_process_data, resolve_date_filters +from org.logic.stats import ( AnalysisResult, Distribution, compute_avg_tasks_per_day, @@ -22,7 +23,6 @@ compute_task_state_histogram, compute_task_stats, ) -from org.cli_common import load_and_process_data, resolve_date_filters from org.tui.bits import ( apply_indent, build_console, diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index 5b54c41..64052c3 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -10,7 +10,14 @@ import org.config.app import org.logging -from org.analyze import ( +from org.logic.filtering import ( + load_and_process_data, + normalize_show_value, + resolve_date_filters, + resolve_exclude_set, + resolve_mapping, +) +from org.logic.stats import ( Tag, TimeRange, clean, @@ -20,13 +27,7 @@ compute_relations, compute_time_ranges, ) -from org.cli_common import ( - load_and_process_data, - normalize_show_value, - resolve_date_filters, - resolve_exclude_set, - resolve_mapping, -) +from org.logic.validation import validate_stats_arguments from org.tui.bits import ( TagBlockConfig, apply_indent, @@ -38,7 +39,6 @@ setup_output, ) from org.tui.plot import TimelineFormatConfig -from org.validation import validate_stats_arguments if TYPE_CHECKING: diff --git a/src/org/commands/tasks/add.py b/src/org/commands/tasks/add.py index 19976d2..f15a0dd 100644 --- a/src/org/commands/tasks/add.py +++ b/src/org/commands/tasks/add.py @@ -12,7 +12,6 @@ import org.config.app import org.logging -from org.cli_common import resolve_input_paths from org.commands.tasks.common import ( load_document, normalize_optional_value, @@ -25,6 +24,7 @@ resolve_parent_heading, save_document, ) +from org.logic.filtering import resolve_input_paths _TASK_TEMPLATE = "{heading}\n{planning}{properties}{body}" diff --git a/src/org/commands/tasks/archive.py b/src/org/commands/tasks/archive.py index fc4951b..2677641 100644 --- a/src/org/commands/tasks/archive.py +++ b/src/org/commands/tasks/archive.py @@ -11,13 +11,13 @@ import org.config.app import org.logging -from org.cli_common import resolve_input_paths -from org.commands.archive import archive_heading_subtree, archive_result_documents_to_save from org.commands.tasks.common import ( resolve_headings_by_query, resolve_task_selector_query, save_document, ) +from org.logic.archive import archive_heading_subtree, archive_result_documents_to_save +from org.logic.filtering import resolve_input_paths logger = logging.getLogger("org") diff --git a/src/org/commands/tasks/edit.py b/src/org/commands/tasks/edit.py index f9d7429..6408846 100644 --- a/src/org/commands/tasks/edit.py +++ b/src/org/commands/tasks/edit.py @@ -10,12 +10,12 @@ import org.config.app import org.logging -from org.cli_common import resolve_input_paths -from org.commands.editor import edit_heading_subtree_in_external_editor from org.commands.tasks.common import ( resolve_headings_by_query, resolve_task_selector_query, ) +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.logic.filtering import resolve_input_paths logger = logging.getLogger("org") diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index ae98979..c5fc9d8 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -14,9 +14,9 @@ import org.config.app import org.logging -from org.cli_common import load_root_data from org.commands.tasks.common import normalize_selector from org.commands.tasks.query import get_query_formatter +from org.logic.filtering import load_root_data from org.query_language import ( EvalContext, QueryParseError, diff --git a/src/org/commands/tasks/list/actions.py b/src/org/commands/tasks/list/actions.py index e687def..0e15f5a 100644 --- a/src/org/commands/tasks/list/actions.py +++ b/src/org/commands/tasks/list/actions.py @@ -8,19 +8,6 @@ import typer -from org.cli_common import load_and_process_data -from org.commands.archive import archive_heading_subtree_and_save -from org.commands.editor import edit_heading_subtree_in_external_editor -from org.commands.interactive_common import ( - HeadingLocator, - advance_timestamp_by_repeater, - append_repeat_transition, - heading_locator, - local_now, - resolve_heading_locator, - shift_priority, -) -from org.commands.search_common import filter_nodes_by_search from org.commands.tasks.capture import TasksCaptureArgs, capture_task from org.commands.tasks.common import ( PlanningTimestampField, @@ -30,6 +17,18 @@ save_document, todo_states_for_heading, ) +from org.logic.archive import archive_heading_subtree_and_save +from org.logic.edit import edit_heading_subtree_in_external_editor +from org.logic.filtering import load_and_process_data +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import ( + HeadingLocator, + append_repeat_transition, + heading_locator, + resolve_heading_locator, + shift_priority, +) +from org.logic.time import advance_timestamp_by_repeater, local_now from org.tui.help import InteractiveHelpEntry diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index 91b704d..85d74f6 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -13,7 +13,7 @@ import org.config.app import org.logging -from org.cli_common import load_and_process_data +from org.logic.filtering import load_and_process_data from org.serde.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index 5a53d59..4dbdb9e 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -18,7 +18,7 @@ import org.config.app import org.logging -from org.cli_common import load_root_data +from org.logic.filtering import load_root_data from org.query_language import ( EvalContext, QueryParseError, diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index 9f7abee..0ddddc1 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -13,12 +13,12 @@ import org.config.app import org.logging -from org.cli_common import resolve_input_paths from org.commands.tasks.common import ( resolve_headings_by_query, resolve_task_selector_query, save_document, ) +from org.logic.filtering import resolve_input_paths from org.tui.color import should_use_color diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 48470e5..1cf59f4 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -16,7 +16,6 @@ import org.config.app import org.logging -from org.cli_common import resolve_input_paths from org.commands.tasks.common import ( iter_descendants, load_document, @@ -32,6 +31,7 @@ resolve_task_selector_query, save_document, ) +from org.logic.filtering import resolve_input_paths from org.tui.color import should_use_color diff --git a/src/org/logic/__init__.py b/src/org/logic/__init__.py new file mode 100644 index 0000000..2cef564 --- /dev/null +++ b/src/org/logic/__init__.py @@ -0,0 +1 @@ +"""Core logic modules for org.""" diff --git a/src/org/commands/archive.py b/src/org/logic/archive.py similarity index 100% rename from src/org/commands/archive.py rename to src/org/logic/archive.py diff --git a/src/org/commands/editor.py b/src/org/logic/edit.py similarity index 100% rename from src/org/commands/editor.py rename to src/org/logic/edit.py diff --git a/src/org/cli_common.py b/src/org/logic/filtering.py similarity index 96% rename from src/org/cli_common.py rename to src/org/logic/filtering.py index 074f662..351bf59 100644 --- a/src/org/cli_common.py +++ b/src/org/logic/filtering.py @@ -15,7 +15,15 @@ from org_parser.document import Heading import org.config.app -from org.analyze import TimeRange, normalize +from org.logic.stats import TimeRange, normalize +from org.logic.time import extract_timestamp_any +from org.logic.validation import ( + parse_date_argument, + parse_group_values, + parse_property_filter, + validate_and_parse_keys, + validate_global_arguments, +) from org.query_language import ( EvalContext, QueryParseError, @@ -24,14 +32,6 @@ compile_query_text, ) from org.serde.parse import load_root_nodes -from org.timestamp import extract_timestamp_any -from org.validation import ( - parse_date_argument, - parse_group_values, - parse_property_filter, - validate_and_parse_keys, - validate_global_arguments, -) if TYPE_CHECKING: @@ -180,14 +180,7 @@ def is_valid_regex(pattern: str, *, use_multiline: bool = False) -> bool: def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: - """Extract top day and its count from TimeRange. - - Args: - time_range: TimeRange object or None - - Returns: - Tuple of (date_string, count) or None if no timeline data - """ + """Extract top day and its count from TimeRange.""" if not time_range or not time_range.timeline: return None max_count = max(time_range.timeline.values()) @@ -196,28 +189,13 @@ def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: def get_most_recent_timestamp(node: Heading) -> datetime | None: - """Get the most recent timestamp from a node. - - Args: - node: Org-mode node - - Returns: - Most recent datetime or None if no timestamps found - """ + """Get the most recent timestamp from a node.""" timestamps = extract_timestamp_any(node) return max(timestamps) if timestamps else None def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: - """Get top N nodes sorted by most recent timestamp. - - Args: - nodes: List of org-mode nodes - max_results: Maximum number of results to return - - Returns: - List of nodes sorted by most recent timestamp (descending) - """ + """Get top N nodes sorted by most recent timestamp.""" nodes_with_timestamps: list[tuple[Heading, datetime]] = [] for node in nodes: timestamp = get_most_recent_timestamp(node) @@ -328,12 +306,7 @@ def _required_custom_arg_error(option: str) -> typer.BadParameter: def normalize_cli_files_for_custom_switches(files: list[str] | None) -> list[str] | None: - """Remove configured custom switch tokens from FILE argument values. - - Commands use ``ignore_unknown_options`` to allow config-defined switches. Click still - parses those unknown switch tokens as FILE values first, so this helper strips them - before input path resolution. - """ + """Remove configured custom switch tokens from FILE argument values.""" if files is None: return None @@ -1027,17 +1000,7 @@ def resolve_group_values( def resolve_input_paths(inputs: list[str] | None) -> list[str]: # noqa: C901 - """Resolve CLI inputs into a list of org files to process. - - Args: - inputs: List of CLI path arguments (files and directories) - - Returns: - List of file paths to process - - Raises: - typer.BadParameter: If a path does not exist or no org files are found - """ + """Resolve CLI inputs into a list of org files to process.""" resolved_files: list[str] = [] searched_dirs: list[Path] = [] missing_paths: list[str] = [] diff --git a/src/org/commands/search_common.py b/src/org/logic/search.py similarity index 100% rename from src/org/commands/search_common.py rename to src/org/logic/search.py diff --git a/src/org/analyze.py b/src/org/logic/stats.py similarity index 64% rename from src/org/analyze.py rename to src/org/logic/stats.py index 5f9f7a5..16cdfd8 100644 --- a/src/org/analyze.py +++ b/src/org/logic/stats.py @@ -4,7 +4,7 @@ from datetime import date, datetime from typing import TYPE_CHECKING -from org.timestamp import extract_timestamp_any +from org.logic.time import extract_timestamp_any if TYPE_CHECKING: @@ -13,23 +13,12 @@ @dataclass class Frequency: # noqa: PLW1641 - """Represents frequency statistics for a tag/word. - - Attributes: - total: Total count for this item - """ + """Represents frequency statistics for a tag/word.""" total: int = 0 def __eq__(self, other: object) -> bool: - """Compare with another Frequency or int. - - Args: - other: Object to compare with (Frequency or int) - - Returns: - True if equal, False otherwise - """ + """Compare with another Frequency or int.""" if isinstance(other, Frequency): return self.total == other.total if isinstance(other, int): @@ -37,35 +26,20 @@ def __eq__(self, other: object) -> bool: return NotImplemented def __int__(self) -> int: - """Convert to int for backward compatibility and comparison. - - Returns: - The total count as an integer - """ + """Convert to int for backward compatibility and comparison.""" return self.total @dataclass class TimeRange: - """Represents time range for a tag/word occurrence. - - Attributes: - earliest: Earliest timestamp this tag/word was encountered - latest: Latest timestamp this tag/word was encountered - timeline: Dictionary mapping dates to occurrence counts - """ + """Represents time range for a tag/word occurrence.""" earliest: datetime | None = None latest: datetime | None = None timeline: dict[date, int] = field(default_factory=dict) def __repr__(self) -> str: - """Return string representation of TimeRange including top day. - - Returns: - String representation with earliest, latest, and top_day (most intense day). - Dates are formatted as ISO strings. None values are printed as None. - """ + """Return string representation of TimeRange including top day.""" top_day = None if self.timeline: max_count = max(self.timeline.values()) @@ -80,11 +54,7 @@ def __repr__(self) -> str: ) def update(self, timestamp: datetime | date | None) -> None: - """Update time range with a new timestamp. - - Args: - timestamp: Timestamp to incorporate into the range (datetime, date, or None) - """ + """Update time range with a new timestamp.""" if timestamp is None: return @@ -102,12 +72,7 @@ def update(self, timestamp: datetime | date | None) -> None: @dataclass class Relations: - """Represents pair-wise co-occurrence relationships for a tag/word. - - Attributes: - name: The tag or word name - relations: Dictionary mapping related tags/words to co-occurrence counts - """ + """Represents pair-wise co-occurrence relationships for a tag/word.""" name: str relations: dict[str, int] @@ -126,16 +91,7 @@ def update(self, key: str, amount: int) -> None: @dataclass class Tag: - """Represents complete statistics for a single tag. - - Attributes: - name: Tag name - total_tasks: Total number of tasks with this tag - avg_tasks_per_day: Average tasks per day for this tag - max_single_day_count: Maximum tasks on a single day for this tag - relations: Co-occurrence relationships with other tags - time_range: Temporal data (earliest, latest, timeline) - """ + """Represents complete statistics for a single tag.""" name: str total_tasks: int @@ -147,15 +103,7 @@ class Tag: @dataclass class Group: - """Represents a group of related tags (strongly connected component). - - Attributes: - tags: List of tag names in this group, sorted alphabetically - time_range: Combined time range for all tags in the group - total_tasks: Total number of tasks with any tag in this group - avg_tasks_per_day: Average tasks per day for the group - max_single_day_count: Maximum tasks on a single day for the group - """ + """Represents a group of related tags (strongly connected component).""" tags: list[str] time_range: TimeRange @@ -166,26 +114,7 @@ class Group: @dataclass class AnalysisResult: - """Represents the complete result of analyzing Org-mode nodes. - - Attributes: - total_tasks: Total number of tasks analyzed (including repeats) - unique_tasks: Number of unique nodes (without repeats) - task_states: Distribution of task states - (TODO, DONE, DELEGATED, CANCELLED, SUSPENDED, other) - task_categories: Distribution of task categories by gamify_exp (simple, regular, hard) - task_priorities: Distribution of task priorities (A, B, C, etc.) - task_days: Distribution of tasks by day of week - timerange: Global time range for all tasks - avg_tasks_per_day: Average tasks per day (total tasks / days spanned) - max_single_day_count: Highest number of tasks on a single day - max_repeat_count: Highest number of repeats for any individual task - tags: Dictionary mapping tag/item names to their Tag objects - tag_groups: List of tag groups (strongly connected components) - - Note: Despite the 'tag' naming, tags field holds data for whichever - category was analyzed (tags, heading, or body). - """ + """Represents the complete result of analyzing Org-mode nodes.""" total_tasks: int unique_tasks: int @@ -202,44 +131,20 @@ class AnalysisResult: def weekday_to_string(weekday: int) -> str: - """Map Python weekday integer to capitalized day name. - - Args: - weekday: Integer from datetime.weekday() (0=Monday, 6=Sunday) - - Returns: - Capitalized day name (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday) - """ + """Map Python weekday integer to capitalized day name.""" days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] return days[weekday] def mapped(mapping: dict[str, str], t: str) -> str: - """Map a tag to its canonical form using the provided mapping. - - Args: - mapping: Dictionary mapping tags to their canonical forms - t: Tag to map - - Returns: - Canonical form if found in mapping, otherwise the original tag - """ + """Map a tag to its canonical form using the provided mapping.""" if t in mapping: return mapping[t] return t def normalize(tags: set[str], mapping: dict[str, str]) -> set[str]: - """Normalize tags by lowercasing, stripping whitespace, removing punctuation, - and mapping to canonical forms. - - Args: - tags: Set of tags to normalize - mapping: Dictionary mapping tags to canonical forms - - Returns: - Set of normalized and mapped tags - """ + """Normalize tags by lowercasing, stripping whitespace, removing punctuation.""" norm = { t.lower() .strip() @@ -255,16 +160,7 @@ def normalize(tags: set[str], mapping: dict[str, str]) -> set[str]: def _extract_items(node: Heading, mapping: dict[str, str], category: str) -> set[str]: - """Extract and normalize items from a node based on category. - - Args: - node: Org-mode node to extract items from - mapping: Dictionary mapping items to canonical forms - category: Which datum to extract - "tags", "heading", or "body" - - Returns: - Set of normalized and mapped items (tags are not normalized, only mapped) - """ + """Extract and normalize items from a node based on category.""" if category == "tags": stripped_tags = {t.strip() for t in node.tags} return {mapped(mapping, t) for t in stripped_tags} @@ -274,14 +170,7 @@ def _extract_items(node: Heading, mapping: dict[str, str], category: str) -> set def compute_task_stats(nodes: list[Heading]) -> tuple[int, int]: - """Compute total task count and maximum repeat count. - - Args: - nodes: List of org-mode nodes - - Returns: - Tuple of (total_tasks, max_repeat_count) - """ + """Compute total task count and maximum repeat count.""" total = 0 max_repeat_count = 0 @@ -293,29 +182,14 @@ def compute_task_stats(nodes: list[Heading]) -> tuple[int, int]: def compute_max_single_day(timerange: TimeRange) -> int: - """Get the maximum number of tasks completed on a single day. - - Args: - timerange: TimeRange object with timeline data - - Returns: - Maximum count for any single day, or 0 if no timeline data - """ + """Get the maximum number of tasks completed on a single day.""" if not timerange.timeline: return 0 return max(timerange.timeline.values()) def compute_avg_tasks_per_day(timerange: TimeRange, total_count: int) -> float: - """Compute average tasks per day. - - Args: - timerange: TimeRange with earliest and latest dates - total_count: Total number of tasks - - Returns: - Average tasks per day, or 0.0 if timerange is empty or invalid - """ + """Compute average tasks per day.""" if timerange.earliest is None or timerange.latest is None: return 0.0 @@ -327,16 +201,7 @@ def compute_avg_tasks_per_day(timerange: TimeRange, total_count: int) -> float: def compute_task_state_histogram(nodes: list[Heading]) -> Distribution: - """Compute histogram of task states across all nodes. - - Counts states from repeated tasks if present, otherwise uses node.todo. - - Args: - nodes: List of org-mode nodes - - Returns: - Distribution with counts for each task state - """ + """Compute histogram of task states across all nodes.""" task_states = Distribution(values={}) for node in nodes: @@ -352,17 +217,7 @@ def compute_task_state_histogram(nodes: list[Heading]) -> Distribution: def compute_day_of_week_histogram(nodes: list[Heading]) -> Distribution: - """Compute histogram of task completion days across all tasks. - - Extracts timestamps from all tasks and counts by day of week. - Tasks without timestamps are counted as "unknown". - - Args: - nodes: List of org-mode nodes - - Returns: - Distribution with counts for each day of week (Monday-Sunday, unknown) - """ + """Compute histogram of task completion days across all tasks.""" task_days = Distribution(values={}) for node in nodes: @@ -396,17 +251,7 @@ def compute_category_histogram(nodes: list[Heading]) -> Distribution: def compute_priority_histogram(nodes: list[Heading]) -> Distribution: - """Compute histogram of task priorities across all nodes. - - Counts priorities from all tasks (including repeats). - Tasks without priority are counted as "null". - - Args: - nodes: List of org-mode nodes - - Returns: - Distribution with counts for each priority value - """ + """Compute histogram of task priorities across all nodes.""" task_priorities = Distribution(values={}) for node in nodes: @@ -419,16 +264,7 @@ def compute_priority_histogram(nodes: list[Heading]) -> Distribution: def compute_global_timerange(nodes: list[Heading]) -> TimeRange: - """Compute global time range across all tasks. - - Extracts timestamps from all tasks and builds a unified TimeRange. - - Args: - nodes: List of org-mode nodes - - Returns: - TimeRange spanning all task timestamps - """ + """Compute global time range across all tasks.""" global_timerange = TimeRange() for node in nodes: @@ -444,19 +280,7 @@ def compute_frequencies( mapping: dict[str, str], category: str, ) -> dict[str, Frequency]: - """Compute frequency statistics for all nodes in a given category. - - For each node, extracts items based on category (tags/heading/body), - normalizes them, and counts by the node's repeat count. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - Dictionary mapping items to their Frequency objects - """ + """Compute frequency statistics for all nodes in a given category.""" frequencies: dict[str, Frequency] = {} for node in nodes: @@ -476,19 +300,7 @@ def compute_relations( mapping: dict[str, str], category: str, ) -> dict[str, Relations]: - """Compute pair-wise relations for all nodes in a given category. - - For each node with 2+ items, computes all pair-wise relations - weighted by the node's repeat count. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - Dictionary mapping items to their Relations objects - """ + """Compute pair-wise relations for all nodes in a given category.""" relations_dict: dict[str, Relations] = {} for node in nodes: @@ -521,19 +333,7 @@ def compute_time_ranges( mapping: dict[str, str], category: str, ) -> dict[str, TimeRange]: - """Compute time ranges for all tasks in a given category. - - For each node, extracts items and timestamps, then builds TimeRange - objects tracking earliest/latest timestamps and timeline. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - Dictionary mapping items to their TimeRange objects - """ + """Compute time ranges for all tasks in a given category.""" time_ranges: dict[str, TimeRange] = {} for node in nodes: @@ -554,15 +354,7 @@ def compute_time_ranges( def _combine_time_ranges(tag_time_ranges: dict[str, TimeRange], tags: list[str]) -> TimeRange: - """Combine time ranges from multiple tags into a single TimeRange. - - Args: - tag_time_ranges: Dictionary mapping tags to their TimeRange objects - tags: List of tag names to combine - - Returns: - Combined TimeRange with merged earliest/latest/timeline data - """ + """Combine time ranges from multiple tags into a single TimeRange.""" combined = TimeRange() for tag in tags: @@ -592,21 +384,7 @@ def compute_per_tag_statistics( relations: dict[str, Relations], time_ranges: dict[str, TimeRange], ) -> dict[str, Tag]: - """Compute complete Tag objects with all statistics. - - For each tag: - - total_tasks equals frequency.total (total task count including repeats) - - avg_tasks_per_day computed from time_range and frequency - - max_single_day_count computed from time_range.timeline - - Args: - frequencies: Pre-computed frequency data - relations: Pre-computed relations data - time_ranges: Pre-computed time range data - - Returns: - Dictionary mapping tag names to Tag objects - """ + """Compute complete Tag objects with all statistics.""" tags: dict[str, Tag] = {} for tag_name, frequency in frequencies.items(): @@ -635,18 +413,7 @@ def compute_groups( # noqa: C901 mapping: dict[str, str], category: str, ) -> list[Group]: - """Compute strongly connected components from tag relations using Tarjan's algorithm. - - Args: - tags: Dictionary mapping tag names to their Tag objects - max_relations: Maximum number of relations to consider per tag - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - - Returns: - List of Group objects representing strongly connected components - """ + """Compute strongly connected components from tag relations using Tarjan's algorithm.""" if not tags: return [] @@ -706,7 +473,6 @@ def strongconnect(node: str) -> None: ), ) - # NOTE: Nodes is much larger, so iterate it only once for better performance. for org_node in nodes: node_items = _extract_items(org_node, mapping, category) @@ -730,18 +496,7 @@ def compute_explicit_groups( group_items: list[list[str]], tag_time_ranges: dict[str, TimeRange], ) -> list[Group]: - """Compute group statistics based on explicit tag lists. - - Args: - nodes: List of org-mode nodes to analyze - mapping: Dictionary mapping items to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - group_items: Explicit group item lists to evaluate - tag_time_ranges: Precomputed time ranges for tags/items - - Returns: - List of Group objects representing explicit groups - """ + """Compute group statistics based on explicit tag lists.""" groups: list[Group] = [] for group in group_items: @@ -783,17 +538,7 @@ def analyze( category: str, max_relations: int, ) -> AnalysisResult: - """Analyze org-mode nodes and extract task statistics. - - Args: - nodes: List of org-mode nodes - mapping: Dictionary mapping tags to canonical forms - category: Which datum to analyze - "tags", "heading", or "body" - max_relations: Maximum number of relations to consider for grouping - - Returns: - AnalysisResult containing task counts and Tag objects for the selected category - """ + """Analyze org-mode nodes and extract task statistics.""" tag_frequencies = compute_frequencies(nodes, mapping, category) tag_relations = compute_relations(nodes, mapping, category) tag_time_ranges = compute_time_ranges(nodes, mapping, category) @@ -826,14 +571,6 @@ def analyze( def clean(disallowed: set[str], tags: dict[str, Tag]) -> dict[str, Tag]: - """Remove tags from the disallowed set (stop words). - - Args: - disallowed: Set of tags to filter out (stop words, lowercase) - tags: Dictionary of Tag objects - - Returns: - Dictionary with disallowed tags removed (case-insensitive comparison) - """ + """Remove tags from the disallowed set (stop words).""" disallowed_lower = {d.lower() for d in disallowed} return {t: tags[t] for t in tags if t.lower() not in disallowed_lower} diff --git a/src/org/logic/tasks.py b/src/org/logic/tasks.py new file mode 100644 index 0000000..3526b30 --- /dev/null +++ b/src/org/logic/tasks.py @@ -0,0 +1,97 @@ +"""Shared task manipulation helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from org_parser.element import Repeat +from org_parser.time import Timestamp + + +if TYPE_CHECKING: + from datetime import datetime + + from org_parser.document import Heading + + +@dataclass(frozen=True) +class HeadingLocator: + """Stable heading locator used to restore selection across reloads.""" + + filename: str + heading_id: str | None + title: str + + +def shift_priority(priority: str | None, *, increase: bool) -> str | None: + """Shift priority one step across A/B/C/none.""" + order: list[str | None] = ["A", "B", "C", None] + normalized = priority if priority in {"A", "B", "C"} else None + index = order.index(normalized) + if increase: + return order[max(0, index - 1)] + return order[min(len(order) - 1, index + 1)] + + +def heading_locator(node: Heading) -> HeadingLocator: + """Build a stable heading locator for selection restoration.""" + return HeadingLocator( + filename=node.document.filename or "", + heading_id=node.id, + title=node.title_text, + ) + + +def resolve_heading_locator( + candidates: list[Heading], + locator: HeadingLocator | None, +) -> Heading | None: + """Resolve a preserved heading locator against a candidate heading list.""" + if locator is None or not candidates: + return None + + document = None + for candidate in candidates: + if (candidate.document.filename or "") == locator.filename: + document = candidate.document + break + if document is None: + return None + + resolved = ( + document.heading_by_id(locator.heading_id) + if locator.heading_id is not None + else document.heading_by_title(locator.title) + ) + if resolved is None: + return None + + for candidate in candidates: + if candidate is resolved: + return candidate + return None + + +def append_repeat_transition( + heading: Heading, + before: str | None, + after: str | None, + now: datetime, +) -> None: + """Append one repeat transition entry at current time.""" + transition_time = now.replace(second=0, microsecond=0) + heading.repeats.append( + Repeat( + before=before, + after=after, + timestamp=Timestamp.from_datetime(transition_time, is_active=False), + ), + ) + + +def detail_org_block(node: Heading) -> str: + """Return one heading subtree rendered back to Org text.""" + parts = [str(node)] + parts.extend(detail_org_block(child) for child in node.children) + return "".join(parts) diff --git a/src/org/commands/interactive_common.py b/src/org/logic/time.py similarity index 63% rename from src/org/commands/interactive_common.py rename to src/org/logic/time.py index 6fab6af..b1d66b8 100644 --- a/src/org/commands/interactive_common.py +++ b/src/org/logic/time.py @@ -1,27 +1,15 @@ -"""Shared helpers retained across interactive command implementations.""" +"""Timestamp and time-related logic.""" from __future__ import annotations import calendar -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from typing import TYPE_CHECKING -from org_parser.element import Repeat -from org_parser.time import Timestamp - if TYPE_CHECKING: from org_parser.document import Heading - - -@dataclass(frozen=True) -class HeadingLocator: - """Stable heading locator used to restore selection across reloads.""" - - filename: str - heading_id: str | None - title: str + from org_parser.time import Timestamp def local_now() -> datetime: @@ -29,70 +17,47 @@ def local_now() -> datetime: return datetime.now(tz=UTC).astimezone() -def shift_priority(priority: str | None, *, increase: bool) -> str | None: - """Shift priority one step across A/B/C/none.""" - order: list[str | None] = ["A", "B", "C", None] - normalized = priority if priority in {"A", "B", "C"} else None - index = order.index(normalized) - if increase: - return order[max(0, index - 1)] - return order[min(len(order) - 1, index + 1)] - - -def heading_locator(node: Heading) -> HeadingLocator: - """Build a stable heading locator for selection restoration.""" - return HeadingLocator( - filename=node.document.filename or "", - heading_id=node.id, - title=node.title_text, - ) - - -def resolve_heading_locator( - candidates: list[Heading], - locator: HeadingLocator | None, -) -> Heading | None: - """Resolve a preserved heading locator against a candidate heading list.""" - if locator is None or not candidates: - return None - - document = None - for candidate in candidates: - if (candidate.document.filename or "") == locator.filename: - document = candidate.document - break - if document is None: - return None - - resolved = ( - document.heading_by_id(locator.heading_id) - if locator.heading_id is not None - else document.heading_by_title(locator.title) - ) - if resolved is None: - return None - - for candidate in candidates: - if candidate is resolved: - return candidate - return None - - -def append_repeat_transition( - heading: Heading, - before: str | None, - after: str | None, - now: datetime, -) -> None: - """Append one repeat transition entry at current time.""" - transition_time = now.replace(second=0, microsecond=0) - heading.repeats.append( - Repeat( - before=before, - after=after, - timestamp=Timestamp.from_datetime(transition_time, is_active=False), - ), - ) +def normalize_timestamp(ts: datetime | date) -> datetime: + """Normalize all timestamps to use the datetime format.""" + if not isinstance(ts, datetime): + return datetime.combine(ts, datetime.min.time()) + return ts + + +def extract_timestamp(node: Heading, done_states: list[str]) -> list[datetime]: + """Extract timestamps from a node following priority rules.""" + timestamps = [] + + if node.repeats and any(rt.after in done_states for rt in node.repeats): + timestamps.extend([rt.timestamp.start for rt in node.repeats if rt.after in done_states]) + elif node.closed: + timestamps.append(node.closed.start) + elif node.scheduled: + timestamps.append(node.scheduled.start) + elif node.deadline: + timestamps.append(node.deadline.start) + elif node.timestamps: + timestamps.extend([timestamp.start for timestamp in node.timestamps]) + + return [normalize_timestamp(t) for t in timestamps] + + +def extract_timestamp_any(node: Heading) -> list[datetime]: + """Extract timestamps from a node without filtering by completion state.""" + timestamps = [] + + if node.repeats: + timestamps.extend([rt.timestamp.start for rt in node.repeats]) + elif node.closed: + timestamps.append(node.closed.start) + elif node.scheduled: + timestamps.append(node.scheduled.start) + elif node.deadline: + timestamps.append(node.deadline.start) + elif node.timestamps: + timestamps.extend([timestamp.start for timestamp in node.timestamps]) + + return [normalize_timestamp(t) for t in timestamps] def set_timestamp_fields(timestamp: Timestamp, start: datetime, end: datetime | None) -> None: @@ -213,10 +178,3 @@ def advance_timestamp_by_repeater(timestamp: Timestamp) -> bool: set_timestamp_fields(timestamp, shifted_start, shifted_end) return True - - -def detail_org_block(node: Heading) -> str: - """Return one heading subtree rendered back to Org text.""" - parts = [str(node)] - parts.extend(detail_org_block(child) for child in node.children) - return "".join(parts) diff --git a/src/org/validation.py b/src/org/logic/validation.py similarity index 75% rename from src/org/validation.py rename to src/org/logic/validation.py index 2a20d54..15af641 100644 --- a/src/org/validation.py +++ b/src/org/logic/validation.py @@ -32,25 +32,7 @@ class StatsArgs(Protocol): def parse_date_argument(date_str: str, arg_name: str) -> datetime: - """Parse and validate timestamp argument in multiple supported formats. - - Supported formats: - - YYYY-MM-DD - - YYYY-MM-DDThh:mm - - YYYY-MM-DDThh:mm:ss - - YYYY-MM-DD hh:mm - - YYYY-MM-DD hh:mm:ss - - Args: - date_str: Date/timestamp string to parse - arg_name: Argument name for error messages - - Returns: - Parsed datetime object - - Raises: - typer.BadParameter: If format is invalid - """ + """Parse and validate timestamp argument in multiple supported formats.""" if not date_str or not date_str.strip(): supported_formats = [ "YYYY-MM-DD", @@ -88,19 +70,7 @@ def parse_date_argument(date_str: str, arg_name: str) -> datetime: def parse_property_filter(property_str: str) -> tuple[str, str]: - """Parse property filter argument in KEY=VALUE format. - - Splits on first '=' to support values containing '='. - - Args: - property_str: Property filter string - - Returns: - Tuple of (property_name, property_value) - - Raises: - typer.BadParameter: If format is invalid (no '=' found) - """ + """Parse property filter argument in KEY=VALUE format.""" if "=" not in property_str: raise typer.BadParameter( f"--filter-property must be in KEY=VALUE format, got '{property_str}'", @@ -111,18 +81,7 @@ def parse_property_filter(property_str: str) -> tuple[str, str]: def validate_and_parse_keys(keys_str: str, option_name: str) -> list[str]: - """Parse and validate comma-separated keys. - - Args: - keys_str: Comma-separated string of keys - option_name: Name of the option for error messages - - Returns: - List of validated keys - - Raises: - typer.BadParameter: If validation fails - """ + """Parse and validate comma-separated keys.""" keys = [k.strip() for k in keys_str.split(",") if k.strip()] if not keys: raise typer.BadParameter(f"{option_name} cannot be empty") @@ -140,16 +99,7 @@ def validate_pattern( *, use_multiline: bool = False, ) -> None: - """Validate that a string is a valid regex pattern. - - Args: - pattern: Regex pattern string to validate - option_name: Name of the option for error messages - use_multiline: Whether to validate with re.MULTILINE flag - - Raises: - typer.BadParameter: If pattern is not a valid regex - """ + """Validate that a string is a valid regex pattern.""" try: if use_multiline: re.compile(pattern, re.MULTILINE) @@ -170,17 +120,7 @@ def parse_group_values(value: str) -> list[str]: def validate_global_arguments(args: GlobalArgs) -> tuple[list[str], list[str]]: - """Validate shared command-line arguments. - - Args: - args: Parsed command-line arguments - - Returns: - Tuple of (todo_states, done_states) - - Raises: - typer.BadParameter: If validation fails - """ + """Validate shared command-line arguments.""" todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") done_states = validate_and_parse_keys(args.done_states, "--done-states") diff --git a/src/org/query_language/runtime.py b/src/org/query_language/runtime.py index 4234db3..635e8bf 100644 --- a/src/org/query_language/runtime.py +++ b/src/org/query_language/runtime.py @@ -29,7 +29,7 @@ from org_parser.text import RichText from org_parser.time import Clock, Timestamp -from org.analyze import analyze as _analyze_nodes +from org.logic.stats import analyze as _analyze_nodes from org.query_language.ast import ( AsBinding, BinaryOp, diff --git a/src/org/serde/format.py b/src/org/serde/format.py index ed5a9e5..7187285 100644 --- a/src/org/serde/format.py +++ b/src/org/serde/format.py @@ -19,7 +19,7 @@ from org_parser.time import Clock, Timestamp from rich.syntax import Syntax -from org.analyze import AnalysisResult, Distribution, Group, Tag, TimeRange +from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange from org.tui.bits import print_output diff --git a/src/org/timestamp.py b/src/org/timestamp.py deleted file mode 100644 index 1a74c31..0000000 --- a/src/org/timestamp.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Timestamp extraction and normalization functions.""" - -from datetime import date, datetime -from typing import TYPE_CHECKING - - -if TYPE_CHECKING: - from org_parser.document import Heading - - -def normalize_timestamp(ts: datetime | date) -> datetime: - """Normalize all timestamps to use the datetime format. - - Args: - ts: datetime or date - - Returns: - datetime - """ - if not isinstance(ts, datetime): - return datetime.combine(ts, datetime.min.time()) - return ts - - -def extract_timestamp(node: Heading, done_states: list[str]) -> list[datetime]: - """Extract timestamps from a node following priority rules. - - Priority order: - 1. Repeated tasks (all completed tasks) - 2. Closed timestamp - 3. Scheduled timestamp - 4. Deadline timestamp - 5. Datelist (timestamps in body) - - Args: - node: Org-mode node to extract timestamps from - done_states: List of completion state keywords - - Returns: - List of datetime objects (may be empty if no timestamps found) - """ - timestamps = [] - - if node.repeats and any(rt.after in done_states for rt in node.repeats): - timestamps.extend([rt.timestamp.start for rt in node.repeats if rt.after in done_states]) - elif node.closed: - timestamps.append(node.closed.start) - elif node.scheduled: - timestamps.append(node.scheduled.start) - elif node.deadline: - timestamps.append(node.deadline.start) - elif node.timestamps: - timestamps.extend([timestamp.start for timestamp in node.timestamps]) - - return [normalize_timestamp(t) for t in timestamps] - - -def extract_timestamp_any(node: Heading) -> list[datetime]: - """Extract timestamps from a node without filtering by completion state. - - Priority order: - 1. Repeated tasks (all repeated tasks regardless of state) - 2. Closed timestamp - 3. Scheduled timestamp - 4. Deadline timestamp - 5. Datelist (timestamps in body) - - Args: - node: Org-mode node to extract timestamps from - - Returns: - List of datetime objects (may be empty if no timestamps found) - """ - timestamps = [] - - if node.repeats: - timestamps.extend([rt.timestamp.start for rt in node.repeats]) - elif node.closed: - timestamps.append(node.closed.start) - elif node.scheduled: - timestamps.append(node.scheduled.start) - elif node.deadline: - timestamps.append(node.deadline.start) - elif node.timestamps: - timestamps.extend([timestamp.start for timestamp in node.timestamps]) - - return [normalize_timestamp(t) for t in timestamps] diff --git a/src/org/tui/bits.py b/src/org/tui/bits.py index 5c8db5e..6ed6d69 100644 --- a/src/org/tui/bits.py +++ b/src/org/tui/bits.py @@ -31,7 +31,7 @@ from rich.style import Style from rich.text import Text -from org.cli_common import get_top_tasks +from org.logic.filtering import get_top_tasks from org.tui.color import ( bright_blue, bright_white, @@ -56,7 +56,7 @@ from org_parser.document import Heading - from org.analyze import Group, Tag, TimeRange + from org.logic.stats import Group, Tag, TimeRange def lines_to_text(lines: list[str]) -> str: diff --git a/src/org/tui/histogram.py b/src/org/tui/histogram.py index c0cb4d0..78b1353 100644 --- a/src/org/tui/histogram.py +++ b/src/org/tui/histogram.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: - from org.analyze import Distribution + from org.logic.stats import Distribution @dataclass diff --git a/src/org/tui/plot.py b/src/org/tui/plot.py index 7b39f15..b938845 100644 --- a/src/org/tui/plot.py +++ b/src/org/tui/plot.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from datetime import datetime - from org.analyze import TimeRange + from org.logic.stats import TimeRange @dataclass(frozen=True) diff --git a/tests/cli/test_cli_runner.py b/tests/cli/test_cli_runner.py index feccd61..21140b4 100644 --- a/tests/cli/test_cli_runner.py +++ b/tests/cli/test_cli_runner.py @@ -591,7 +591,7 @@ def _fake_open_at_line(filename: str, line: int) -> int: ) return 0 - monkeypatch.setattr("org.commands.editor._run_editor_at_line", _fake_open_at_line) + monkeypatch.setattr("org.logic.edit._run_editor_at_line", _fake_open_at_line) result = runner.invoke( app, diff --git a/tests/cli_common/test_cli_common_helpers.py b/tests/cli_common/test_cli_common_helpers.py index c5e9f8b..55d3fb3 100644 --- a/tests/cli_common/test_cli_common_helpers.py +++ b/tests/cli_common/test_cli_common_helpers.py @@ -8,13 +8,13 @@ import pytest import typer -from org.cli_common import ( +from org.logic.filtering import ( dedupe_values, normalize_show_value, resolve_group_values, resolve_input_paths, ) -from org.validation import parse_group_values +from org.logic.validation import parse_group_values if TYPE_CHECKING: diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index 68ed3f6..e814760 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -50,7 +50,7 @@ def make_args(**overrides: object) -> FilterArgsStub: def test_build_query_text_filters_only() -> None: """Query text should include filters in command order.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] @@ -62,7 +62,7 @@ def test_build_query_text_filters_only() -> None: def test_build_query_text_with_ordering_and_slice() -> None: """Query text should append order stages then slice.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(filter_tags=["work"], order_by_timestamp_desc=True, offset=5, max_results=10) argv = [ @@ -86,7 +86,7 @@ def test_build_query_text_with_ordering_and_slice() -> None: def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: """timestamp-asc should keep items with no timestamp at the end.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(order_by_timestamp_asc=True) argv = ["org", "tasks", "list", "--order-by-timestamp-asc", "file.org"] @@ -102,7 +102,7 @@ def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: def test_build_query_text_with_priority_ordering() -> None: """priority ordering should sort by node priority value.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(order_by_priority=True) argv = ["org", "tasks", "list", "--order-by-priority", "file.org"] @@ -114,7 +114,7 @@ def test_build_query_text_with_priority_ordering() -> None: def test_build_query_text_with_property_filter() -> None: """Property filters should quote keys and values.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(filter_properties=["priority=A"]) argv = ["org", "stats", "all", "--filter-property", "priority=A", "file.org"] @@ -126,7 +126,7 @@ def test_build_query_text_with_property_filter() -> None: def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: """Built-in with-tags-as-category switch should add its query stage.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(with_tags_as_category=True) argv = ["org", "stats", "summary", "--with-tags-as-category", "file.org"] @@ -140,7 +140,7 @@ def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: def test_build_query_text_with_tags_as_category_default_appended_when_missing_in_argv() -> None: """Default with-tags-as-category should be appended when omitted from argv.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(with_tags_as_category=True, filter_tags=["work"]) argv = ["org", "stats", "summary", "--filter-tag", "work", "file.org"] @@ -155,7 +155,7 @@ def test_build_query_text_with_tags_as_category_default_appended_when_missing_in def test_build_query_text_with_filter_completed() -> None: """Completed filter should include repeated task completion states.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(filter_completed=True) argv = ["org", "tasks", "list", "--filter-completed", "file.org"] @@ -171,7 +171,7 @@ def test_build_query_text_with_filter_completed() -> None: def test_build_query_text_with_filter_not_completed() -> None: """Not-completed filter should include tasks with no done state.""" - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text args = make_args(filter_not_completed=True) argv = ["org", "tasks", "list", "--filter-not-completed", "file.org"] @@ -187,7 +187,7 @@ def test_build_query_text_with_filter_not_completed() -> None: def test_build_query_logs_query_before_compile(caplog: pytest.LogCaptureFixture) -> None: """build_query should log the query text before compilation.""" - from org.cli_common import build_query + from org.logic.filtering import build_query args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] @@ -203,7 +203,7 @@ def test_build_query_text_with_custom_filter_and_optional_arg( ) -> None: """Custom filter switches should bind $arg and support omitted values.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr( org.config.app, @@ -240,7 +240,7 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( ) -> None: """Custom arguments are embedded in query text and not added to context vars.""" import org.config.app - from org.cli_common import collect_custom_context_vars + from org.logic.filtering import collect_custom_context_vars monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) @@ -272,7 +272,7 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyPatch) -> None: """Custom enrichment stages should be applied before filters.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -301,7 +301,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( ) -> None: """Built-in and custom with stages should preserve CLI order.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -329,7 +329,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPatch) -> None: """Custom order-by switches should work even when built-in ordering is disabled.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -345,7 +345,7 @@ def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPa def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixture) -> None: """Data loading should log the query execution context.""" - from org.cli_common import load_and_process_data + from org.logic.filtering import load_and_process_data fixture_path = str((Path(__file__).resolve().parents[1] / "fixtures" / "simple.org").resolve()) args = make_args(files=[fixture_path], offset=0, max_results=1) @@ -362,7 +362,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( ) -> None: """Built-in and custom ordering switches should follow CLI specification order.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -389,7 +389,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.MonkeyPatch) -> None: """Unknown prefixed custom switches should fail validation.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -407,7 +407,7 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( ) -> None: """Custom filters using $arg should require exactly one argument.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr( org.config.app, @@ -432,7 +432,7 @@ def test_build_query_text_custom_order_by_requires_exactly_one_argument( ) -> None: """Custom order-by switches using $arg should require exactly one argument.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) @@ -450,7 +450,7 @@ def test_build_query_text_custom_with_requires_exactly_one_argument( ) -> None: """Custom enrichment switches using $arg should require exactly one argument.""" import org.config.app - from org.cli_common import build_query_text + from org.logic.filtering import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -468,7 +468,7 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( ) -> None: """Required custom arguments should consume path-like values.""" import org.config.app - from org.cli_common import normalize_cli_files_for_custom_switches + from org.logic.filtering import normalize_cli_files_for_custom_switches monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -483,7 +483,7 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( def test_get_top_day_info_none() -> None: """get_top_day_info returns null with missing timeline.""" - from org.cli_common import get_top_day_info + from org.logic.filtering import get_top_day_info result = get_top_day_info(None) diff --git a/tests/cli_common/test_top_tasks.py b/tests/cli_common/test_top_tasks.py index 16e595b..a4b0806 100644 --- a/tests/cli_common/test_top_tasks.py +++ b/tests/cli_common/test_top_tasks.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.cli_common import get_most_recent_timestamp, get_top_tasks +from org.logic.filtering import get_most_recent_timestamp, get_top_tasks from tests.conftest import node_from_org diff --git a/tests/cli_common/test_validation.py b/tests/cli_common/test_validation.py index 22a714b..7a27f62 100644 --- a/tests/cli_common/test_validation.py +++ b/tests/cli_common/test_validation.py @@ -8,7 +8,7 @@ import pytest import typer -from org.validation import ( +from org.logic.validation import ( GlobalArgs, StatsArgs, parse_date_argument, diff --git a/tests/commands/stats/test_stats_commands.py b/tests/commands/stats/test_stats_commands.py index 4288908..c86a53c 100644 --- a/tests/commands/stats/test_stats_commands.py +++ b/tests/commands/stats/test_stats_commands.py @@ -8,11 +8,11 @@ import pytest import typer -from org.analyze import AnalysisResult, Distribution, Group, Tag, TimeRange from org.commands.stats import all as stats_all_command from org.commands.stats import groups as stats_groups from org.commands.stats import summary as stats_summary_command from org.commands.stats import tags as stats_tags +from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures") diff --git a/tests/commands/tasks/test_tasks_edit.py b/tests/commands/tasks/test_tasks_edit.py index 32b0e67..ed0b35e 100644 --- a/tests/commands/tasks/test_tasks_edit.py +++ b/tests/commands/tasks/test_tasks_edit.py @@ -9,8 +9,8 @@ import pytest import typer -from org.commands import editor as editor_command from org.commands.tasks import edit as tasks_edit +from org.logic import edit as editor_command if TYPE_CHECKING: diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 6f6abe1..827f04b 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -15,13 +15,13 @@ from rich.console import Console import org.config.app -from org.commands import archive as archive_command -from org.commands import editor as editor_command -from org.commands.interactive_common import heading_locator -from org.commands.search_common import filter_nodes_by_search from org.commands.tasks import capture as capture_command from org.commands.tasks.list import actions from org.commands.tasks.list import command as tasks_list +from org.logic.archive import ArchiveLocation, ArchiveMoveResult, ArchiveTarget +from org.logic.edit import DocumentEditResult +from org.logic.search import filter_nodes_by_search +from org.logic.tasks import heading_locator from org.serde.format import OutputFormat, OutputFormatError from org.tui.bits import visual_len from tests.conftest import node_from_org @@ -763,8 +763,8 @@ def test_edit_selected_task_in_external_editor_sets_no_changes_status( nodes = node_from_org("* TODO A\n") session = _make_session(nodes) - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=False) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=False) monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) @@ -780,8 +780,8 @@ def test_edit_selected_task_in_external_editor_reloads_using_selected_node_ident session = _make_session(nodes) source_node = nodes[0] - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=True) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=True) reloaded_identity = None @@ -812,18 +812,18 @@ def test_archive_selected_task_archives_selected_task( def _fake_archive( heading: Heading, _cache: dict[str, Document], - ) -> archive_command.ArchiveMoveResult: - location = archive_command.ArchiveLocation( + ) -> ArchiveMoveResult: + location = ArchiveLocation( raw_spec="%s_archive::", file_path="tasks.org_archive", parent_title=None, ) - target = archive_command.ArchiveTarget( + target = ArchiveTarget( location=location, document=heading.document, parent_heading=None, ) - return archive_command.ArchiveMoveResult( + return ArchiveMoveResult( heading=heading, target=target, source_document=heading.document, diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index 0eea150..3198fcc 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -15,8 +15,7 @@ from rich.console import Console import org.config.app -from org.cli_common import load_and_process_data -from org.commands import interactive_common +import org.logic.time from org.commands.agenda import actions, ui from org.commands.agenda import command as agenda_command from org.commands.agenda.views import ( @@ -25,13 +24,10 @@ _fallback_agenda_view, resolve_view_context, ) -from org.commands.interactive_common import ( - advance_timestamp_by_repeater, - detail_org_block, - heading_locator, - local_now, -) from org.commands.tasks.common import duration_to_org_text, parse_clock_duration +from org.logic.filtering import load_and_process_data +from org.logic.tasks import detail_org_block, heading_locator +from org.logic.time import advance_timestamp_by_repeater, local_now from org.tui.bits import setup_output @@ -124,7 +120,7 @@ def _pin_agenda_now(monkeypatch: pytest.MonkeyPatch) -> datetime: pinned_now = datetime(2025, 1, 15, 12, 0) monkeypatch.setattr(actions, "local_now", lambda: pinned_now) monkeypatch.setattr(ui, "local_now", lambda: pinned_now) - monkeypatch.setattr("org.commands.interactive_common.local_now", lambda: pinned_now) + monkeypatch.setattr("org.logic.time.local_now", lambda: pinned_now) return pinned_now @@ -147,7 +143,7 @@ def _render_agenda_output(args: agenda_command.AgendaArgs) -> str: ui.AgendaRenderInput( args=args, nodes=nodes, - now=interactive_common.local_now(), + now=org.logic.time.local_now(), render=ui.RenderContext( color_enabled=color_enabled, done_states=done_states, @@ -696,7 +692,7 @@ def current_now() -> datetime: monkeypatch.setattr(actions, "local_now", current_now) monkeypatch.setattr(ui, "local_now", current_now) - monkeypatch.setattr("org.commands.interactive_common.local_now", current_now) + monkeypatch.setattr("org.logic.time.local_now", current_now) monkeypatch.setattr(sys, "argv", ["org", "agenda", "--date", "2025-01-15"]) output = _render_agenda_output(args).replace("…", "") @@ -783,7 +779,7 @@ def test_advance_timestamp_by_repeater_double_plus_advances_until_future( """'++' repeater should advance repeatedly until timestamp is in the future.""" timestamp = Timestamp.from_source("<2025-01-10 Fri ++1d>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 12, 0), ) @@ -797,7 +793,7 @@ def test_advance_timestamp_by_repeater_double_plus_hourly_uses_datetime( """'++' with hour unit should advance until datetime is after current time.""" timestamp = Timestamp.from_source("<2025-01-15 Wed 09:00 ++1h>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 10, 30), ) @@ -811,7 +807,7 @@ def test_advance_timestamp_by_repeater_double_plus_always_shifts_at_least_once( """'++' should still shift once when timestamp is already in the future.""" timestamp = Timestamp.from_source("<2025-01-15 Wed 23:00 ++1d>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 10, 0), ) @@ -825,7 +821,7 @@ def test_advance_timestamp_by_repeater_dot_plus_uses_current_day( """'.+' repeater should anchor at current day and then shift once by unit.""" timestamp = Timestamp.from_source("<2025-01-10 Fri 09:30 .+2d>") monkeypatch.setattr( - "org.commands.interactive_common.local_now", + "org.logic.time.local_now", lambda: datetime(2025, 1, 15, 18, 45), ) diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index 4a9240a..66c8d20 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -15,12 +15,12 @@ from rich.text import Text import org.config.app -from org.cli_common import load_and_process_data -from org.commands import archive as archive_command -from org.commands import editor as editor_command from org.commands.board import actions, ui from org.commands.board import command as board_command -from org.commands.interactive_common import heading_locator +from org.logic.archive import ArchiveLocation, ArchiveMoveResult, ArchiveTarget +from org.logic.edit import DocumentEditResult +from org.logic.filtering import load_and_process_data +from org.logic.tasks import heading_locator from org.tui.bits import setup_output from tests.conftest import node_from_org @@ -570,8 +570,8 @@ def test_edit_selected_task_in_external_editor_reports_no_changes( status_message="", ) - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=False) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=False) monkeypatch.setattr(actions, "edit_heading_subtree_in_external_editor", _fake_edit) @@ -601,8 +601,8 @@ def test_edit_selected_task_in_external_editor_reloads_with_identity_after_chang ) source_node = nodes[0] - def _fake_edit(_heading: Heading) -> editor_command.DocumentEditResult: - return editor_command.DocumentEditResult(changed=True) + def _fake_edit(_heading: Heading) -> DocumentEditResult: + return DocumentEditResult(changed=True) reloaded_identity = None @@ -642,18 +642,18 @@ def test_archive_selected_task_archives_selected_heading( def _fake_archive( heading: Heading, _cache: dict[str, Document], - ) -> archive_command.ArchiveMoveResult: - location = archive_command.ArchiveLocation( + ) -> ArchiveMoveResult: + location = ArchiveLocation( raw_spec="%s_archive::", file_path="tasks.org_archive", parent_title=None, ) - target = archive_command.ArchiveTarget( + target = ArchiveTarget( location=location, document=heading.document, parent_heading=None, ) - return archive_command.ArchiveMoveResult( + return ArchiveMoveResult( heading=heading, target=target, source_document=heading.document, diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index e460bf7..6610801 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -13,8 +13,8 @@ from org.commands.board import actions from org.commands.board import command as board_command from org.commands.board.app import BoardApp, BoardViewport -from org.commands.interactive_common import heading_locator from org.commands.tasks import capture as capture_command +from org.logic.tasks import heading_locator from tests.commands.test_board import make_board_args from tests.conftest import node_from_org diff --git a/tests/commands/test_interactive_common.py b/tests/commands/test_interactive_common.py index 54d8ab6..3310a4b 100644 --- a/tests/commands/test_interactive_common.py +++ b/tests/commands/test_interactive_common.py @@ -4,12 +4,12 @@ from typing import Any, cast -from org.commands.interactive_common import ( +from org.commands.tasks.common import todo_states_for_heading +from org.logic.tasks import ( HeadingLocator, heading_locator, resolve_heading_locator, ) -from org.commands.tasks.common import todo_states_for_heading from org.tui.help import InteractiveHelpEntry, render_interactive_help_panel_text from tests.conftest import node_from_org diff --git a/tests/test_analysis_result.py b/tests/test_analysis_result.py index de5663c..d9b382b 100644 --- a/tests/test_analysis_result.py +++ b/tests/test_analysis_result.py @@ -1,6 +1,6 @@ """Tests for the AnalysisResult dataclass.""" -from org.analyze import AnalysisResult, Distribution, Tag, TimeRange +from org.logic.stats import AnalysisResult, Distribution, Tag, TimeRange def test_analysis_result_initialization() -> None: @@ -202,7 +202,7 @@ def test_analysis_result_mutable_fields() -> None: """Test that AnalysisResult fields can be modified.""" from datetime import datetime - from org.analyze import Group + from org.logic.stats import Group result = AnalysisResult( unique_tasks=0, diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 6c92ef5..4ae5627 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from org.analyze import analyze +from org.logic.stats import analyze from tests.conftest import node_from_org diff --git a/tests/test_clean.py b/tests/test_clean.py index 3b65991..ac40938 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -1,7 +1,7 @@ """Tests for the clean() function.""" -from org.analyze import Tag, TimeRange, clean -from org.cli_common import DEFAULT_EXCLUDE +from org.logic.filtering import DEFAULT_EXCLUDE +from org.logic.stats import Tag, TimeRange, clean def tag_dict_from_ints(d: dict[str, int]) -> dict[str, Tag]: diff --git a/tests/test_combine_time_ranges.py b/tests/test_combine_time_ranges.py index 6f90736..d5d60b9 100644 --- a/tests/test_combine_time_ranges.py +++ b/tests/test_combine_time_ranges.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import TimeRange, _combine_time_ranges +from org.logic.stats import TimeRange, _combine_time_ranges def test_combine_time_ranges_empty_list() -> None: diff --git a/tests/test_compute_category_histogram.py b/tests/test_compute_category_histogram.py index c535965..9e7803e 100644 --- a/tests/test_compute_category_histogram.py +++ b/tests/test_compute_category_histogram.py @@ -1,6 +1,6 @@ """Tests for the compute_category_histogram() function.""" -from org.analyze import compute_category_histogram +from org.logic.stats import compute_category_histogram from tests.conftest import node_from_org diff --git a/tests/test_compute_day_of_week_histogram.py b/tests/test_compute_day_of_week_histogram.py index 597646a..5d693e6 100644 --- a/tests/test_compute_day_of_week_histogram.py +++ b/tests/test_compute_day_of_week_histogram.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.analyze import compute_day_of_week_histogram, weekday_to_string +from org.logic.stats import compute_day_of_week_histogram, weekday_to_string from tests.conftest import node_from_org diff --git a/tests/test_compute_frequencies.py b/tests/test_compute_frequencies.py index c29ae4f..a90e81f 100644 --- a/tests/test_compute_frequencies.py +++ b/tests/test_compute_frequencies.py @@ -1,6 +1,6 @@ """Tests for the compute_frequencies() function.""" -from org.analyze import compute_frequencies +from org.logic.stats import compute_frequencies from tests.conftest import node_from_org diff --git a/tests/test_compute_global_timerange.py b/tests/test_compute_global_timerange.py index b087664..af32ff8 100644 --- a/tests/test_compute_global_timerange.py +++ b/tests/test_compute_global_timerange.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.analyze import compute_global_timerange +from org.logic.stats import compute_global_timerange from tests.conftest import node_from_org diff --git a/tests/test_compute_groups.py b/tests/test_compute_groups.py index a5bd73e..99a1006 100644 --- a/tests/test_compute_groups.py +++ b/tests/test_compute_groups.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import Tag, TimeRange, compute_groups +from org.logic.stats import Tag, TimeRange, compute_groups def make_tag( diff --git a/tests/test_compute_helpers.py b/tests/test_compute_helpers.py index 667c0d1..fa2ef82 100644 --- a/tests/test_compute_helpers.py +++ b/tests/test_compute_helpers.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.analyze import TimeRange, compute_avg_tasks_per_day, compute_max_single_day +from org.logic.stats import TimeRange, compute_avg_tasks_per_day, compute_max_single_day def test_compute_max_single_day_empty_timerange() -> None: diff --git a/tests/test_compute_relations.py b/tests/test_compute_relations.py index 6117038..a68800b 100644 --- a/tests/test_compute_relations.py +++ b/tests/test_compute_relations.py @@ -1,6 +1,6 @@ """Tests for the compute_relations() function.""" -from org.analyze import compute_relations +from org.logic.stats import compute_relations from tests.conftest import node_from_org diff --git a/tests/test_compute_task_state_histogram.py b/tests/test_compute_task_state_histogram.py index c1cd6f7..2971965 100644 --- a/tests/test_compute_task_state_histogram.py +++ b/tests/test_compute_task_state_histogram.py @@ -1,6 +1,6 @@ """Tests for the compute_task_state_histogram() function.""" -from org.analyze import compute_task_state_histogram +from org.logic.stats import compute_task_state_histogram from tests.conftest import node_from_org diff --git a/tests/test_compute_task_stats.py b/tests/test_compute_task_stats.py index 79dcc73..2fc80a2 100644 --- a/tests/test_compute_task_stats.py +++ b/tests/test_compute_task_stats.py @@ -1,6 +1,6 @@ """Tests for the compute_task_stats() function.""" -from org.analyze import compute_task_stats +from org.logic.stats import compute_task_stats from tests.conftest import node_from_org diff --git a/tests/test_compute_time_ranges.py b/tests/test_compute_time_ranges.py index fcd8e99..f6e2cb4 100644 --- a/tests/test_compute_time_ranges.py +++ b/tests/test_compute_time_ranges.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import compute_time_ranges +from org.logic.stats import compute_time_ranges from tests.conftest import node_from_org diff --git a/tests/test_extract_timestamp.py b/tests/test_extract_timestamp.py index 26eb1db..657c717 100644 --- a/tests/test_extract_timestamp.py +++ b/tests/test_extract_timestamp.py @@ -2,7 +2,7 @@ from datetime import datetime -from org.timestamp import extract_timestamp +from org.logic.time import extract_timestamp from tests.conftest import node_from_org diff --git a/tests/test_frequency.py b/tests/test_frequency.py index 5d8f5c6..d76eb6f 100644 --- a/tests/test_frequency.py +++ b/tests/test_frequency.py @@ -1,6 +1,6 @@ """Tests for the Frequency class.""" -from org.analyze import Frequency +from org.logic.stats import Frequency def test_frequency_default_initialization() -> None: diff --git a/tests/test_histogram.py b/tests/test_histogram.py index 3d8bb1f..413f143 100644 --- a/tests/test_histogram.py +++ b/tests/test_histogram.py @@ -1,6 +1,6 @@ """Tests for the Distribution class.""" -from org.analyze import Distribution +from org.logic.stats import Distribution def test_histogram_initialization_empty() -> None: diff --git a/tests/test_integration.py b/tests/test_integration.py index a852156..be89af4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,7 +5,7 @@ import org_parser -from org.analyze import analyze +from org.logic.stats import analyze if TYPE_CHECKING: diff --git a/tests/test_mapped.py b/tests/test_mapped.py index e2516a4..716ffe6 100644 --- a/tests/test_mapped.py +++ b/tests/test_mapped.py @@ -1,6 +1,6 @@ """Tests for the mapped() function.""" -from org.analyze import mapped +from org.logic.stats import mapped def test_mapped_found_in_mapping() -> None: diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 31c0f4d..aceedd4 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -1,6 +1,6 @@ """Tests for the normalize() function.""" -from org.analyze import normalize +from org.logic.stats import normalize def test_normalize_basic() -> None: diff --git a/tests/test_output_format.py b/tests/test_output_format.py index eec64ea..069a9df 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -14,9 +14,9 @@ from org_parser.text import RichText from rich.syntax import Syntax -from org.analyze import AnalysisResult, Distribution, Group, Tag, TimeRange from org.commands.tasks import query as query_command from org.commands.tasks.list import command as tasks_list_command +from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange from org.serde import format as output_format diff --git a/tests/test_priority.py b/tests/test_priority.py index a54e882..e0dcbb2 100644 --- a/tests/test_priority.py +++ b/tests/test_priority.py @@ -4,7 +4,7 @@ from rich.text import Text -from org.analyze import compute_priority_histogram +from org.logic.stats import compute_priority_histogram from org.tui.bits import TaskLineConfig, format_task_line, visual_len from tests.conftest import node_from_org diff --git a/tests/test_relations.py b/tests/test_relations.py index 1bdb99a..4a883d8 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,6 +1,6 @@ """Tests for the Relations dataclass.""" -from org.analyze import Relations +from org.logic.stats import Relations def test_relations_initialization() -> None: diff --git a/tests/test_render_histogram.py b/tests/test_render_histogram.py index 11beb4d..ee62444 100644 --- a/tests/test_render_histogram.py +++ b/tests/test_render_histogram.py @@ -1,6 +1,6 @@ """Tests for the render_histogram() function.""" -from org.analyze import Distribution +from org.logic.stats import Distribution from org.tui.histogram import render_histogram diff --git a/tests/test_time_range.py b/tests/test_time_range.py index b5e313a..5498d04 100644 --- a/tests/test_time_range.py +++ b/tests/test_time_range.py @@ -2,7 +2,7 @@ from datetime import date, datetime -from org.analyze import TimeRange +from org.logic.stats import TimeRange def test_time_range_initialization() -> None: diff --git a/tests/tui/test_tui_display.py b/tests/tui/test_tui_display.py index 8c7814b..94012fb 100644 --- a/tests/tui/test_tui_display.py +++ b/tests/tui/test_tui_display.py @@ -2,7 +2,7 @@ from __future__ import annotations -from org.analyze import Group, TimeRange +from org.logic.stats import Group, TimeRange from org.tui.bits import ( TopTasksSectionConfig, format_groups_section, From 21eeed41c8153453f159d50350486ebdf8f77856 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sat, 13 Jun 2026 00:44:17 +0200 Subject: [PATCH 21/27] Cleaned up the logic package. --- VIBES.org | 577 ++++++++++++++++ pyproject.toml | 2 +- src/org/cli.py | 7 +- src/org/commands/agenda/actions.py | 2 +- src/org/commands/agenda/app.py | 2 +- src/org/commands/agenda/command.py | 2 +- src/org/commands/agenda/ui.py | 20 +- src/org/commands/agenda/views.py | 11 +- src/org/commands/board/actions.py | 52 +- src/org/commands/board/command.py | 2 +- src/org/commands/stats/all.py | 17 +- src/org/commands/stats/command.py | 6 +- src/org/commands/stats/groups.py | 14 +- src/org/commands/stats/summary.py | 3 +- src/org/commands/stats/tags.py | 14 +- src/org/commands/tasks/add.py | 2 +- src/org/commands/tasks/archive.py | 2 +- src/org/commands/tasks/capture/domain.py | 29 +- src/org/commands/tasks/common.py | 26 +- src/org/commands/tasks/edit.py | 2 +- src/org/commands/tasks/find.py | 28 +- src/org/commands/tasks/list/actions.py | 2 +- src/org/commands/tasks/list/command.py | 4 +- src/org/commands/tasks/query.py | 37 +- src/org/commands/tasks/remove.py | 2 +- src/org/commands/tasks/update.py | 2 +- src/org/config/app.py | 110 +++ src/org/logic/stats.py | 55 ++ src/org/logic/tasks.py | 14 + src/org/logic/time.py | 63 ++ src/org/logic/validation.py | 39 -- src/org/pipeline/__init__.py | 1 + src/org/{serde => pipeline}/format.py | 0 src/org/pipeline/load.py | 217 ++++++ .../{logic/filtering.py => pipeline/query.py} | 637 ++++-------------- src/org/serde/__init__.py | 1 - src/org/serde/parse.py | 77 --- src/org/tui/bits.py | 2 +- tests/cli_common/test_cli_common_helpers.py | 8 +- tests/cli_common/test_file_errors.py | 4 +- tests/cli_common/test_filter_construction.py | 46 +- tests/cli_common/test_top_tasks.py | 3 +- tests/cli_common/test_validation.py | 2 +- tests/commands/tasks/test_tasks_list.py | 2 +- tests/commands/test_agenda_command.py | 2 +- tests/commands/test_board.py | 2 +- tests/commands/test_tasks_query_command.py | 14 +- tests/test_clean.py | 2 +- tests/test_output_format.py | 10 +- 49 files changed, 1341 insertions(+), 837 deletions(-) create mode 100644 src/org/pipeline/__init__.py rename src/org/{serde => pipeline}/format.py (100%) create mode 100644 src/org/pipeline/load.py rename src/org/{logic/filtering.py => pipeline/query.py} (62%) delete mode 100644 src/org/serde/__init__.py delete mode 100644 src/org/serde/parse.py diff --git a/VIBES.org b/VIBES.org index 392c481..572a57d 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7566,11 +7566,588 @@ In addition to the above, the =org/tui/bits.py= module now contains a lot of non Remove the modules that are no longer needed. When refactoring the code, please don't use import renames nor "compatibility" adapters and instead change all the invocations sites both in production and test code. Make sure to update the commands to use this new logic package. +**** DONE src/org/commands/agenda/command.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file src/org/commands/agenda/command.py around line 16, near the ~import typer~ + +#+begin_example ++from org.logic.filtering import load_and_process_data + +#+end_example + +Sounds like that function should be in serde module, not logic filtering. +**** DONE src/org/commands/agenda/ui.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file src/org/commands/agenda/ui.py around line 18, near the ~from rich import box~ + +#+begin_example ++from org.logic.time import local_now, shift_datetimes_by_unit ++from org.logic.validation import parse_date_argument + +#+end_example + +The =parse_date_argument= should either be in serde package, or in =logic.time=. I'm leaning towards =logic.time=. +**** DONE src/org/commands/stats/groups.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file src/org/commands/stats/groups.py around line 20, near the ~import typer~ + +#+begin_example ++from org.logic.filtering import ( ++ load_and_process_data, ++ resolve_date_filters, ++ resolve_exclude_set, ++ resolve_group_values, ++ resolve_mapping, + +#+end_example + +All of these should be elsewhere than the filtering module. +**** DONE tests/cli_common/test_filter_construction.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file tests/cli_common/test_filter_construction.py around line 89, near the ~def test_build_query_text_with_ordering_and_slice() -> None:~ + +#+begin_example +- from org.cli_common import build_query_text ++ from org.logic.filtering import build_query_text + +#+end_example + +Query building should not be a part of the filtering module, it should be in a dedicated module. +**** DONE tests/cli_common/test_top_tasks.py +:PROPERTIES: +:context: +:END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 00:35] +:END: + +In file tests/cli_common/test_top_tasks.py around line 7, near the ~Merge~ + +#+begin_example ++from org.logic.filtering import get_most_recent_timestamp, get_top_tasks + +#+end_example + +=get_most_recent_timestamp= should be in the time module. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/edit.py::def _load_document_from_text(document_text: str, filename: str | None) -> Document: + """Parse full document text and preserve original filename when present.""" + try: + document = org_parser.loads(document_text) + except (TypeError, ValueError) as err: + raise typer.BadParameter(f"Edited document content is invalid: {err}") from err + + document.filename = "" if filename is None else filename + return document]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/edit.py + +#+begin_example + +def _load_document_from_text(document_text: str, filename: str | None) -> Document: + """Parse full document text and preserve original filename when present.""" + try: + document = org_parser.loads(document_text) + except (TypeError, ValueError) as err: + raise typer.BadParameter(f"Edited document content is invalid: {err}") from err + + document.filename = "" if filename is None else filename + return document + +#+end_example + +This should be moved to the serde module. Ideally, one way to load a file should be used accross the whole app. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_top_day_info(time_range: TimeRange | None) -> tuple\[str, int\] | None: + """Extract top day and its count from TimeRange.""" + if not time_range or not time_range.timeline: + return None + max_count = max(time_range.timeline.values()) + top_day = min(d for d, count in time_range.timeline.items() if count == max_count) + return (top_day.isoformat(), max_count)]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: + """Extract top day and its count from TimeRange.""" + if not time_range or not time_range.timeline: + return None + max_count = max(time_range.timeline.values()) + top_day = min(d for d, count in time_range.timeline.items() if count == max_count) + return (top_day.isoformat(), max_count) + +#+end_example + +This should be in the stats module. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def count_filter_values(value: list\[str\] | None) -> int: + """Count filter values for append-style filters.""" + return len(value) if value else 0]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def count_filter_values(value: list[str] | None) -> int: + """Count filter values for append-style filters.""" + return len(value) if value else 0 + +#+end_example + +This function makes no sense, just do this directly wherever this is invoked. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/edit.py::def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: + """Edit the original task document, preferring in-place file editing at task line.""" + return attempt_edit_heading_subtree_in_external_editor(heading)]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/edit.py + +#+begin_example + +def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: + """Edit the original task document, preferring in-place file editing at task line.""" + return attempt_edit_heading_subtree_in_external_editor(heading) + +#+end_example + +This wrapper makes no sense. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_most_recent_timestamp(node: Heading) -> datetime | None: + """Get the most recent timestamp from a node.""" + timestamps = extract_timestamp_any(node) + return max(timestamps) if timestamps else None]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example +def get_most_recent_timestamp(node: Heading) -> datetime | None: + """Get the most recent timestamp from a node.""" + timestamps = extract_timestamp_any(node) + return max(timestamps) if timestamps else None + +#+end_example + +This is available in the =Heading= class as =latest_timestamp=, please remove this function and use the built-in way to fetch the timestamp. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_top_tasks(nodes: list\[Heading\], max_results: int) -> list\[Heading\]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list\[tuple\[Heading, datetime\]\] = \[\] + for node in nodes: + timestamp = get_most_recent_timestamp(node) + if timestamp: + nodes_with_timestamps.append((node, timestamp)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x\[1\], reverse=True) + + return \[node for node, _ in sorted_nodes\[:max_results\]\]]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list[tuple[Heading, datetime]] = [] + for node in nodes: + timestamp = get_most_recent_timestamp(node) + if timestamp: + nodes_with_timestamps.append((node, timestamp)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) + + return [node for node, _ in sorted_nodes[:max_results]] + +#+end_example + +This should be in the stats module. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def validate_custom_switches(argv: list\[str\], include_builtin_ordering: bool) -> None: + """Validate prefixed custom switches against configured/built-in options.""" + builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() + allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( + {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, + ) + allowed_order_options = builtin_order_options.union( + {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, + ) + allowed_with_options = WITH_OPTIONS_FLAGS.union( + {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, + ) + + for index, token in enumerate(argv): + option = _extract_option_token(token) + if option.startswith("--filter-") and option not in allowed_filter_options: + raise click.NoSuchOption(option) + if option.startswith("--order-by-") and option not in allowed_order_options: + raise click.NoSuchOption(option) + if option.startswith("--with-") and option not in allowed_with_options: + raise click.NoSuchOption(option) + + custom_option = _resolve_custom_option(option) + if custom_option is None: + continue + + _, requires_arg = custom_option + if not requires_arg or token.startswith(f"{option}="): + continue + + next_index = index + 1 + if next_index >= len(argv): + raise _required_custom_arg_error(option) + + next_token = argv\[next_index\] + if next_token.startswith("-"): + raise _required_custom_arg_error(option)]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> None: + """Validate prefixed custom switches against configured/built-in options.""" + builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() + allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( + {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, + ) + allowed_order_options = builtin_order_options.union( + {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, + ) + allowed_with_options = WITH_OPTIONS_FLAGS.union( + {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, + ) + + for index, token in enumerate(argv): + option = _extract_option_token(token) + if option.startswith("--filter-") and option not in allowed_filter_options: + raise click.NoSuchOption(option) + if option.startswith("--order-by-") and option not in allowed_order_options: + raise click.NoSuchOption(option) + if option.startswith("--with-") and option not in allowed_with_options: + raise click.NoSuchOption(option) + + custom_option = _resolve_custom_option(option) + if custom_option is None: + continue + + _, requires_arg = custom_option + if not requires_arg or token.startswith(f"{option}="): + continue + + next_index = index + 1 + if next_index >= len(argv): + raise _required_custom_arg_error(option) + + next_token = argv[next_index] + if next_token.startswith("-"): + raise _required_custom_arg_error(option) + +#+end_example + +Probably should be moved to the validation module. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::class DataLoadArgs(QueryBuildArgs, Protocol): + """Protocol for loading and preprocessing data.""" + + files: list\[str\] | None + todo_states: str + done_states: str + width: int | None + + +class SlicedDataLoadArgs(Protocol): + """Protocol for args that support query slicing.""" + + offset: int + max_results: int + + +class RootDataLoadArgs(Protocol): + """Protocol for loading root nodes without filters or enrichment.""" + + files: list\[str\] | None + todo_states: str + done_states: str + width: int | None + + +def _resolve_and_load_roots( + args: RootDataLoadArgs, +) -> tuple\[list\[Document\], list\[str\], list\[str\]\]: + """Resolve inputs and load org roots after validating todo/done keys.""" + todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") + done_states = validate_and_parse_keys(args.done_states, "--done-states") + return _load_roots_for_inputs(args.files, todo_states, done_states) + + +def _load_roots_for_inputs( + files: list\[str\] | None, + todo_states: list\[str\], + done_states: list\[str\], +) -> tuple\[list\[Document\], list\[str\], list\[str\]\]: + """Resolve file inputs and load all org root nodes.""" + filenames = resolve_input_paths(files) + return load_root_nodes(filenames, todo_states, done_states) + + +def load_root_data( + args: RootDataLoadArgs, +) -> tuple\[list\[Document\], list\[str\], list\[str\]\]: + """Load org root nodes without filters, enrichment, or ordering.""" + return _resolve_and_load_roots(args) + + +def load_and_process_data( + args: DataLoadArgs, +) -> tuple\[list\[Heading\], list\[str\], list\[str\]\]: + """Load nodes, preprocess, and apply query-based filters/ordering.""" + include_ordering = hasattr(args, "order_by_level") + validate_custom_switches(sys.argv, include_ordering) + + normalized_files = normalize_cli_files_for_custom_switches(args.files) + args.files = normalized_files + + todo_states, done_states = validate_global_arguments(args) + roots, todo_states, done_states = _load_roots_for_inputs( + normalized_files, + todo_states, + done_states, + ) + nodes = \[node for root in roots for node in list(root)\] + + include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") + query = build_query( + args, + sys.argv, + include_ordering=include_ordering, + include_slice=include_slice, + ) + + context_vars: dict\[str, object\] = { + "todo_states": todo_states, + "done_states": done_states, + } + context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) + if include_slice: + sliced_args = cast("SlicedDataLoadArgs", args) + context_vars\["offset"\] = sliced_args.offset + context_vars\["limit"\] = sliced_args.max_results + + logger.info("Query context: %s", context_vars) + + try: + results = query(Stream(\[nodes\]), EvalContext(context_vars)) + except (QueryParseError, QueryRuntimeError) as exc: + raise typer.BadParameter(str(exc)) from exc + + flattened: list\[object\] + if len(results) == 1 and isinstance(results\[0\], list): + flattened = cast("list\[object\]", results\[0\]) + else: + flattened = list(results) + nodes = \[value for value in flattened if isinstance(value, Heading)\] + + return nodes, todo_states, done_states]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +class DataLoadArgs(QueryBuildArgs, Protocol): + """Protocol for loading and preprocessing data.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +class SlicedDataLoadArgs(Protocol): + """Protocol for args that support query slicing.""" + + offset: int + max_results: int + + +class RootDataLoadArgs(Protocol): + """Protocol for loading root nodes without filters or enrichment.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +def _resolve_and_load_roots( + args: RootDataLoadArgs, +) -> tuple[list[Document], list[str], list[str]]: + """Resolve inputs and load org roots after validating todo/done keys.""" + todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") + done_states = validate_and_parse_keys(args.done_states, "--done-states") + return _load_roots_for_inputs(args.files, todo_states, done_states) + + +def _load_roots_for_inputs( + files: list[str] | None, + todo_states: list[str], + done_states: list[str], +) -> tuple[list[Document], list[str], list[str]]: + """Resolve file inputs and load all org root nodes.""" + filenames = resolve_input_paths(files) + return load_root_nodes(filenames, todo_states, done_states) + + +def load_root_data( + args: RootDataLoadArgs, +) -> tuple[list[Document], list[str], list[str]]: + """Load org root nodes without filters, enrichment, or ordering.""" + return _resolve_and_load_roots(args) + + +def load_and_process_data( + args: DataLoadArgs, +) -> tuple[list[Heading], list[str], list[str]]: + """Load nodes, preprocess, and apply query-based filters/ordering.""" + include_ordering = hasattr(args, "order_by_level") + validate_custom_switches(sys.argv, include_ordering) + + normalized_files = normalize_cli_files_for_custom_switches(args.files) + args.files = normalized_files + + todo_states, done_states = validate_global_arguments(args) + roots, todo_states, done_states = _load_roots_for_inputs( + normalized_files, + todo_states, + done_states, + ) + nodes = [node for root in roots for node in list(root)] + + include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") + query = build_query( + args, + sys.argv, + include_ordering=include_ordering, + include_slice=include_slice, + ) + + context_vars: dict[str, object] = { + "todo_states": todo_states, + "done_states": done_states, + } + context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) + if include_slice: + sliced_args = cast("SlicedDataLoadArgs", args) + context_vars["offset"] = sliced_args.offset + context_vars["limit"] = sliced_args.max_results + + logger.info("Query context: %s", context_vars) + + try: + results = query(Stream([nodes]), EvalContext(context_vars)) + except (QueryParseError, QueryRuntimeError) as exc: + raise typer.BadParameter(str(exc)) from exc + + flattened: list[object] + if len(results) == 1 and isinstance(results[0], list): + flattened = cast("list[object]", results[0]) + else: + flattened = list(results) + nodes = [value for value in flattened if isinstance(value, Heading)] + + return nodes, todo_states, done_states + +#+end_example + +All of this should be moved to the serde module. +**** TODO Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/validation.py]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/validation.py + +#+begin_example + +#+end_example + +Validation module should be placed in serde package. + *** Use Textual toasts for status messages. *** Use Textual textarea for any org-mode syntax highlighting - Limit to just the interactive commands. - Potentially use it for code edits of the files? **** Show a Textual modal when performing external edits, instead of suspending the app +*** TODO Reorganize the query execution engine code +:PROPERTIES: +:capture_time: [2026-06-13 Sat 00:04] +:context: +:END: + +Introduce a new package =org.query= and move the query-execution-related helpers in there: +- Current =org.logic.query= should be moved to that package as =org.query.runner= +- Current =org.query_language= package should be renamed to =org.query.engine= +- The query compiler (=org.query.engine.compiler=) and execution engine (=org.query.engine.interpreter=) should be separated, with all the operator/function definitions also separated to their own module =org.query.engine.defs= +*** TODO Streamline data processing. +:PROPERTIES: +:capture_time: [2026-06-12 Fri 23:54] +:context: +:END: + +The commands use a common ETL pipeline where they load some data from files, apply filters/ordering/mutators and lastly present the results in some way. +It would be nice to make this as streamlined as possible, ideally, =org.logic.query= would expose a function =run_query= that takes a list of documents, a textlist of query stages to run and the environment for the query to run in, and compiles and executes the query and returns the result. +That streamlined query execution should be used in all places that extract or transform the org document data - the data should be loaded, then processed with the =run_query= function and used as needed. +*** TODO Org file repository abstraction +:PROPERTIES: +:capture_time: [2026-06-13 Sat 00:12] +:context: +:END: + +Add a new package =org.db= that will store the org file repository abstraction. +In that package, add a repository-pattern abstraction over one or more org files. The abstraction should automatically load all the files, maintain their modification state and automatically perform saving of the modified files whenever they change. +The abstraction should also support querying the repository using the query language. + +All commands should be updated to instantiate this repository and use its querying capabilities for all of their querying needs. *** TODO Load configuration first and pass the object to the commands Do not use global state for configuration storage. The goal is for the commands to be stateless. diff --git a/pyproject.toml b/pyproject.toml index 6f5abdc..be4237c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ lines-after-imports = 2 "SLF001", # private member access is tolerated in tests/support code ] -"src/org/serde/format.py" = [ +"src/org/pipeline/format.py" = [ "S603", # pandoc subprocess invocation is an intentional feature "S112", # best-effort serialization intentionally skips unreadable attributes ] diff --git a/src/org/cli.py b/src/org/cli.py index f56e5f1..2b1a2c0 100644 --- a/src/org/cli.py +++ b/src/org/cli.py @@ -11,7 +11,6 @@ from org.commands import agenda, stats from org.commands import board as board_command from org.commands.tasks import command as tasks_command -from org.logic.filtering import DEFAULT_VERBOSE, resolve_verbose app = typer.Typer( @@ -30,9 +29,9 @@ def main_callback( ), ) -> None: """Global CLI options.""" - if verbose is None and not DEFAULT_VERBOSE["value"]: + if verbose is None and not org.config.app.DEFAULT_VERBOSE["value"]: return - org.logging.configure_logging(resolve_verbose(verbose)) + org.logging.configure_logging(org.config.app.resolve_verbose(verbose)) agenda.register(app) @@ -46,7 +45,7 @@ def main() -> None: loaded_config = org.config.app.load_cli_config(sys.argv) defaults = loaded_config.defaults if defaults is not None: - DEFAULT_VERBOSE["value"] = bool(defaults.pop("verbose", False)) + org.config.app.DEFAULT_VERBOSE["value"] = bool(defaults.pop("verbose", False)) org.config.app.CONFIG_APPEND_DEFAULTS.clear() org.config.app.CONFIG_APPEND_DEFAULTS.update(loaded_config.append_defaults) org.config.app.CONFIG_INLINE_DEFAULTS.clear() diff --git a/src/org/commands/agenda/actions.py b/src/org/commands/agenda/actions.py index cc66f4b..54c42db 100644 --- a/src/org/commands/agenda/actions.py +++ b/src/org/commands/agenda/actions.py @@ -23,7 +23,6 @@ ) from org.logic.archive import archive_heading_subtree_and_save from org.logic.edit import edit_heading_subtree_in_external_editor -from org.logic.filtering import load_and_process_data from org.logic.search import filter_nodes_by_search from org.logic.tasks import ( HeadingLocator, @@ -32,6 +31,7 @@ resolve_heading_locator, ) from org.logic.time import advance_timestamp_by_repeater, local_now, set_timestamp_fields +from org.pipeline.load import load_and_process_data from .ui import ( AgendaColumnWidths, diff --git a/src/org/commands/agenda/app.py b/src/org/commands/agenda/app.py index 6180a88..cd0c720 100644 --- a/src/org/commands/agenda/app.py +++ b/src/org/commands/agenda/app.py @@ -19,7 +19,7 @@ clock_duration_prompt_label, configured_capture_template_names, ) -from org.logic.filtering import resolve_input_paths +from org.pipeline.load import resolve_input_paths from . import actions, ui from .actions import AgendaSession, create_agenda_session diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index 6e2a5ab..be221c7 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -11,7 +11,7 @@ import org.config.app import org.logging -from org.logic.filtering import load_and_process_data +from org.pipeline.load import load_and_process_data from org.tui.bits import build_console, processing_status, setup_output from org.tui.help import interactive_help_command_text diff --git a/src/org/commands/agenda/ui.py b/src/org/commands/agenda/ui.py index c295698..0c4468f 100644 --- a/src/org/commands/agenda/ui.py +++ b/src/org/commands/agenda/ui.py @@ -12,9 +12,8 @@ from rich.table import Table from rich.text import Text -from org.logic.time import local_now, shift_datetimes_by_unit -from org.logic.validation import parse_date_argument -from org.query_language import EvalContext, QueryRuntimeError, Stream +from org.logic.time import local_now, parse_date_argument, shift_datetimes_by_unit +from org.pipeline.query import execute_query_or_raise from org.tui.bits import ( heading_title_to_text, task_priority_to_text, @@ -788,12 +787,15 @@ def _apply_section_query( view_name: str, ) -> list[Heading]: """Apply section query against nodes with $date in context, returning matched nodes.""" - try: - results = spec.query(Stream(nodes), EvalContext({"date": day})) - except QueryRuntimeError as err: - raise typer.BadParameter( - f"Agenda filter/order-by query failed (section={spec.name}, view={view_name}): {err}", - ) from err + results = execute_query_or_raise( + spec.query, + nodes, + {"date": day}, + lambda message: typer.BadParameter( + "Agenda filter/order-by query failed " + f"(section={spec.name}, view={view_name}): {message}", + ), + ) return [cast("Heading", node) for node in results] diff --git a/src/org/commands/agenda/views.py b/src/org/commands/agenda/views.py index 9c8c271..8a101c8 100644 --- a/src/org/commands/agenda/views.py +++ b/src/org/commands/agenda/views.py @@ -8,10 +8,12 @@ import typer import org.config.app -from org.query_language import CompiledQuery, QueryParseError, compile_query_text +from org.pipeline.query import compile_filter_order_query if TYPE_CHECKING: + from org.query_language.compiler import CompiledQuery + from .command import AgendaArgs @@ -35,10 +37,7 @@ class AgendaViewContext: def _compile_section_query(filter_query: str, order_by: str | None) -> CompiledQuery: """Compile one agenda section filter/order query text.""" - base_query = f"select({filter_query})" - if order_by is None: - return compile_query_text(base_query) - return compile_query_text(f"{base_query} | sort_by({order_by})") + return compile_filter_order_query(filter_query, order_by) def _fallback_agenda_view() -> org.config.app.AgendaViewConfig: @@ -63,7 +62,7 @@ def _compile_view_section_specs(view: org.config.app.AgendaViewConfig) -> list[A for section in view.sections: try: query = _compile_section_query(section.filter, section.order_by) - except QueryParseError as err: + except Exception as err: raise typer.BadParameter( f"Invalid agenda filter/order-by (view={view.name}, section={section.name}): {err}", ) from err diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index 81faf07..80cd66a 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -14,7 +14,6 @@ from org.commands.tasks.common import configured_capture_template_names, save_document from org.logic.archive import archive_heading_subtree_and_save from org.logic.edit import edit_heading_subtree_in_external_editor -from org.logic.filtering import load_and_process_data from org.logic.search import filter_nodes_by_search from org.logic.tasks import ( HeadingLocator, @@ -24,27 +23,38 @@ shift_priority, ) from org.logic.time import advance_timestamp_by_repeater, local_now -from org.query_language import ( - CompiledQuery, - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) +from org.pipeline.load import load_and_process_data +from org.pipeline.query import compile_filter_order_query, execute_query_or_raise if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence from org_parser.document import Document, Heading + from org.query_language.compiler import CompiledQuery + from .command import BoardArgs logger = logging.getLogger("org") +def _board_query_error_builder( + view_name: str, + column_name: str, +) -> Callable[[str], typer.BadParameter]: + """Build one board query runtime error converter.""" + + def _query_error(message: str) -> typer.BadParameter: + return typer.BadParameter( + "Board filter/order-by query failed " + f"(view={view_name}, column={column_name}): {message}", + ) + + return _query_error + + @dataclass class BoardColumn: """One board column with title and assigned tasks.""" @@ -141,10 +151,7 @@ def _compile_column_filter_query( order_by: str | None, ) -> CompiledQuery: """Compile one board column filter/order query text.""" - base_query = f"select({filter_query})" - if order_by is None: - return compile_query_text(base_query) - return compile_query_text(f"{base_query} | sort_by({order_by})") + return compile_filter_order_query(filter_query, order_by) def _fallback_view_config() -> org.config.app.BoardViewConfig: @@ -168,7 +175,7 @@ def compile_view_column_specs(view: org.config.app.BoardViewConfig) -> list[Boar for column in view.columns: try: query = _compile_column_filter_query(column.filter, column.order_by) - except QueryParseError as err: + except Exception as err: raise typer.BadParameter( f"Invalid board filter/order-by (view={view.name}, column={column.name}): {err}", ) from err @@ -218,15 +225,12 @@ def build_selector_board_columns( """Evaluate filter specs against processed task stream.""" columns: list[BoardColumn] = [] for spec in column_specs: - try: - results = spec.query(Stream(nodes), EvalContext({})) - except QueryRuntimeError as err: - raise typer.BadParameter( - ( - "Board filter/order-by query failed " - f"(view={spec.view_name}, column={spec.name}): {err}" - ), - ) from err + results = execute_query_or_raise( + spec.query, + nodes, + {}, + _board_query_error_builder(spec.view_name, spec.name), + ) column_nodes = [cast("Heading", node) for node in results] columns.append(BoardColumn(title=spec.name, nodes=column_nodes)) diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index b5b4d03..9d428a5 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -11,7 +11,7 @@ import org.config.app import org.logging -from org.logic.filtering import load_and_process_data +from org.pipeline.load import load_and_process_data from org.tui.bits import build_console, processing_status, setup_output from org.tui.help import interactive_help_command_text diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 5c72daa..5ca04e6 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -14,17 +14,12 @@ import org.config.app import org.logging from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary -from org.logic.filtering import ( - CATEGORY_NAMES, - get_top_tasks, - load_and_process_data, - resolve_date_filters, - resolve_exclude_set, - resolve_mapping, -) from org.logic.stats import AnalysisResult, Tag, TimeRange, analyze, clean from org.logic.stats import Group as TagGroup +from org.logic.tasks import get_top_tasks +from org.logic.time import resolve_date_filters from org.logic.validation import validate_stats_arguments +from org.pipeline.load import load_and_process_data from org.tui.bits import ( GroupBlockConfig, TagBlockConfig, @@ -253,7 +248,7 @@ def _build_stats_all_panel_sections( ) sections.append( _StatsAllPanelSection( - title=CATEGORY_NAMES[args.use].upper(), + title=org.config.app.CATEGORY_NAMES[args.use].upper(), body=_normalize_panel_body(tags_body), ), ) @@ -535,8 +530,8 @@ def run_stats(args: StatsAllArgs) -> None: layout_height = 0 with processing_status(console, color_enabled): - mapping = resolve_mapping(args) - exclude_set = resolve_exclude_set(args) + mapping = org.config.app.resolve_mapping(args) + exclude_set = org.config.app.resolve_exclude_set(args) nodes, todo_states, done_states = load_and_process_data(args) if nodes: result = analyze(nodes, mapping, args.use, args.max_relations) diff --git a/src/org/commands/stats/command.py b/src/org/commands/stats/command.py index 086f846..175b9e6 100644 --- a/src/org/commands/stats/command.py +++ b/src/org/commands/stats/command.py @@ -4,11 +4,11 @@ import typer +import org.config.app import org.logging from org.commands.stats import all as stats_all from org.commands.stats import groups, tags from org.commands.stats import summary as stats_summary -from org.logic.filtering import DEFAULT_VERBOSE, resolve_verbose stats_app = typer.Typer( @@ -27,9 +27,9 @@ def stats_callback( ), ) -> None: """Global stats CLI options.""" - if verbose is None and not DEFAULT_VERBOSE["value"]: + if verbose is None and not org.config.app.DEFAULT_VERBOSE["value"]: return - org.logging.configure_logging(resolve_verbose(verbose)) + org.logging.configure_logging(org.config.app.resolve_verbose(verbose)) def register(app: typer.Typer) -> None: diff --git a/src/org/commands/stats/groups.py b/src/org/commands/stats/groups.py index 2f6efec..4c296c6 100644 --- a/src/org/commands/stats/groups.py +++ b/src/org/commands/stats/groups.py @@ -10,13 +10,6 @@ import org.config.app import org.logging -from org.logic.filtering import ( - load_and_process_data, - resolve_date_filters, - resolve_exclude_set, - resolve_group_values, - resolve_mapping, -) from org.logic.stats import ( Group, TimeRange, @@ -27,8 +20,11 @@ compute_per_tag_statistics, compute_relations, compute_time_ranges, + resolve_group_values, ) +from org.logic.time import resolve_date_filters from org.logic.validation import validate_stats_arguments +from org.pipeline.load import load_and_process_data from org.tui.bits import ( GroupBlockConfig, apply_indent, @@ -147,8 +143,8 @@ def run_stats_groups(args: GroupsArgs) -> None: max_results = args.max_results if args.max_results is not None else 10 with processing_status(console, color_enabled): - mapping = resolve_mapping(args) - exclude_set = resolve_exclude_set(args) + mapping = org.config.app.resolve_mapping(args) + exclude_set = org.config.app.resolve_exclude_set(args) nodes, _, _ = load_and_process_data(args) if not nodes: diff --git a/src/org/commands/stats/summary.py b/src/org/commands/stats/summary.py index da663cc..170eb4e 100644 --- a/src/org/commands/stats/summary.py +++ b/src/org/commands/stats/summary.py @@ -10,7 +10,6 @@ import org.config.app import org.logging -from org.logic.filtering import load_and_process_data, resolve_date_filters from org.logic.stats import ( AnalysisResult, Distribution, @@ -23,6 +22,8 @@ compute_task_state_histogram, compute_task_stats, ) +from org.logic.time import resolve_date_filters +from org.pipeline.load import load_and_process_data from org.tui.bits import ( apply_indent, build_console, diff --git a/src/org/commands/stats/tags.py b/src/org/commands/stats/tags.py index 64052c3..adff97e 100644 --- a/src/org/commands/stats/tags.py +++ b/src/org/commands/stats/tags.py @@ -10,13 +10,6 @@ import org.config.app import org.logging -from org.logic.filtering import ( - load_and_process_data, - normalize_show_value, - resolve_date_filters, - resolve_exclude_set, - resolve_mapping, -) from org.logic.stats import ( Tag, TimeRange, @@ -26,8 +19,11 @@ compute_per_tag_statistics, compute_relations, compute_time_ranges, + normalize_show_value, ) +from org.logic.time import resolve_date_filters from org.logic.validation import validate_stats_arguments +from org.pipeline.load import load_and_process_data from org.tui.bits import ( TagBlockConfig, apply_indent, @@ -170,8 +166,8 @@ def run_stats_tags(args: TagsArgs) -> None: max_results = args.max_results if args.max_results is not None else 10 with processing_status(console, color_enabled): - mapping = resolve_mapping(args) - exclude_set = resolve_exclude_set(args) + mapping = org.config.app.resolve_mapping(args) + exclude_set = org.config.app.resolve_exclude_set(args) nodes, _, _ = load_and_process_data(args) if not nodes: diff --git a/src/org/commands/tasks/add.py b/src/org/commands/tasks/add.py index f15a0dd..87433e3 100644 --- a/src/org/commands/tasks/add.py +++ b/src/org/commands/tasks/add.py @@ -24,7 +24,7 @@ resolve_parent_heading, save_document, ) -from org.logic.filtering import resolve_input_paths +from org.pipeline.load import resolve_input_paths _TASK_TEMPLATE = "{heading}\n{planning}{properties}{body}" diff --git a/src/org/commands/tasks/archive.py b/src/org/commands/tasks/archive.py index 2677641..46c38a3 100644 --- a/src/org/commands/tasks/archive.py +++ b/src/org/commands/tasks/archive.py @@ -17,7 +17,7 @@ save_document, ) from org.logic.archive import archive_heading_subtree, archive_result_documents_to_save -from org.logic.filtering import resolve_input_paths +from org.pipeline.load import resolve_input_paths logger = logging.getLogger("org") diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index dcad5d6..22ad65a 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -15,14 +15,8 @@ import org.config.app from org.commands.tasks.common import load_document, resolve_parent_heading, save_document -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) -from org.serde.format import DEFAULT_OUTPUT_THEME +from org.pipeline.format import DEFAULT_OUTPUT_THEME +from org.pipeline.query import compile_query_or_raise, execute_query_or_raise from org.tui.help import InteractiveHelpEntry @@ -305,15 +299,16 @@ def _resolve_parent_from_selector(document: Document, parent_selector: str) -> H query_text = f".[] | select({normalized_selector})" - try: - compiled_query = compile_query_text(query_text) - except QueryParseError as err: - raise typer.BadParameter(f"Invalid parent selector: {err}") from err - - try: - results = compiled_query(Stream([document]), EvalContext({})) - except QueryRuntimeError as err: - raise typer.BadParameter(f"Parent selector failed: {err}") from err + compiled_query = compile_query_or_raise( + query_text, + lambda message: typer.BadParameter(f"Invalid parent selector: {message}"), + ) + results = execute_query_or_raise( + compiled_query, + [document], + {}, + lambda message: typer.BadParameter(f"Parent selector failed: {message}"), + ) matches_by_identity: dict[int, Heading] = {} for result in results: diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index ca2c053..13748dd 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -14,13 +14,7 @@ from org_parser.time import Timestamp import org.config.app -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) +from org.pipeline.query import compile_query_or_raise, execute_query_or_raise if TYPE_CHECKING: @@ -368,20 +362,22 @@ def resolve_headings_by_query( selector_query: str, ) -> list[Heading]: """Resolve matching headings across files from selector query.""" - try: - compiled_query = compile_query_text(selector_query) - except QueryParseError as err: - raise typer.BadParameter(f"Invalid task selector query: {err}") from err + compiled_query = compile_query_or_raise( + selector_query, + lambda message: typer.BadParameter(f"Invalid task selector query: {message}"), + ) logger.info("Task selector query: %s", selector_query) matches_by_identity: dict[int, Heading] = {} for filename in filenames: document = load_document(filename) logger.info("Running task selector query against file: %s", filename) - try: - results = compiled_query(Stream([document]), EvalContext({})) - except QueryRuntimeError as err: - raise typer.BadParameter(f"Task selector query failed: {err}") from err + results = execute_query_or_raise( + compiled_query, + [document], + {}, + lambda message: typer.BadParameter(f"Task selector query failed: {message}"), + ) for value in results: if not isinstance(value, Heading): diff --git a/src/org/commands/tasks/edit.py b/src/org/commands/tasks/edit.py index 6408846..e001686 100644 --- a/src/org/commands/tasks/edit.py +++ b/src/org/commands/tasks/edit.py @@ -15,7 +15,7 @@ resolve_task_selector_query, ) from org.logic.edit import edit_heading_subtree_in_external_editor -from org.logic.filtering import resolve_input_paths +from org.pipeline.load import resolve_input_paths logger = logging.getLogger("org") diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index c5fc9d8..2582ff2 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -16,20 +16,14 @@ import org.logging from org.commands.tasks.common import normalize_selector from org.commands.tasks.query import get_query_formatter -from org.logic.filtering import load_root_data -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) -from org.serde.format import ( +from org.pipeline.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, print_prepared_output, ) +from org.pipeline.load import load_root_data +from org.pipeline.query import compile_query_or_raise, execute_query_or_raise from org.tui.bits import build_console, processing_status, setup_output @@ -146,18 +140,16 @@ def run_tasks_find(args: TasksFindArgs) -> None: with processing_status(console, color_enabled): query_text = _build_find_query(args) - try: - compiled_query = compile_query_text(query_text) - except QueryParseError as exc: - raise click.UsageError(str(exc)) from exc + compiled_query = compile_query_or_raise(query_text, click.UsageError) roots, todo_states, done_states = load_root_data(args) nodes = [node for root in roots for node in list(root)] - context = EvalContext({"todo_states": todo_states, "done_states": done_states}) - try: - results = compiled_query(Stream([nodes]), context) - except QueryRuntimeError as exc: - raise click.UsageError(str(exc)) from exc + results = execute_query_or_raise( + compiled_query, + [nodes], + {"todo_states": todo_states, "done_states": done_states}, + click.UsageError, + ) matched_nodes = [value for value in results if isinstance(value, Heading)] output_nodes = _nodes_with_context(matched_nodes, args.include_context) diff --git a/src/org/commands/tasks/list/actions.py b/src/org/commands/tasks/list/actions.py index 0e15f5a..0cf0bcd 100644 --- a/src/org/commands/tasks/list/actions.py +++ b/src/org/commands/tasks/list/actions.py @@ -19,7 +19,6 @@ ) from org.logic.archive import archive_heading_subtree_and_save from org.logic.edit import edit_heading_subtree_in_external_editor -from org.logic.filtering import load_and_process_data from org.logic.search import filter_nodes_by_search from org.logic.tasks import ( HeadingLocator, @@ -29,6 +28,7 @@ shift_priority, ) from org.logic.time import advance_timestamp_by_repeater, local_now +from org.pipeline.load import load_and_process_data from org.tui.help import InteractiveHelpEntry diff --git a/src/org/commands/tasks/list/command.py b/src/org/commands/tasks/list/command.py index 85d74f6..0d124a9 100644 --- a/src/org/commands/tasks/list/command.py +++ b/src/org/commands/tasks/list/command.py @@ -13,8 +13,7 @@ import org.config.app import org.logging -from org.logic.filtering import load_and_process_data -from org.serde.format import ( +from org.pipeline.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, @@ -28,6 +27,7 @@ _prepare_output, print_prepared_output, ) +from org.pipeline.load import load_and_process_data from org.tui.bits import ( TaskLineConfig, build_console, diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index 4dbdb9e..51e2bfe 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -18,15 +18,7 @@ import org.config.app import org.logging -from org.logic.filtering import load_root_data -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) -from org.serde.format import ( +from org.pipeline.format import ( DEFAULT_OUTPUT_THEME, OutputFormat, OutputFormatError, @@ -40,6 +32,8 @@ _prepare_output, print_prepared_output, ) +from org.pipeline.load import load_root_data +from org.pipeline.query import compile_query_or_raise, execute_query_or_raise from org.tui.bits import build_console, processing_status, setup_output @@ -240,26 +234,17 @@ def run_tasks_query(args: TasksQueryArgs) -> None: raise click.UsageError(str(exc)) from exc with processing_status(console, color_enabled): - try: - compiled_query = compile_query_text(args.query) - except QueryParseError as exc: - raise click.UsageError(str(exc)) from exc + compiled_query = compile_query_or_raise(args.query, click.UsageError) roots, todo_states, done_states = load_root_data(args) - context = EvalContext( - { - "offset": args.offset, - "limit": args.max_results, - "todo_states": todo_states, - "done_states": done_states, - }, - ) - try: - stream_nodes = Stream([roots]) - results = compiled_query(stream_nodes, context) - except QueryRuntimeError as exc: - raise click.UsageError(str(exc)) from exc + context = { + "offset": args.offset, + "limit": args.max_results, + "todo_states": todo_states, + "done_states": done_states, + } + results = execute_query_or_raise(compiled_query, [roots], context, click.UsageError) first_result = results[0] if results else None if len(results) == 1 and isinstance(first_result, list | tuple | set): diff --git a/src/org/commands/tasks/remove.py b/src/org/commands/tasks/remove.py index 0ddddc1..85f5288 100644 --- a/src/org/commands/tasks/remove.py +++ b/src/org/commands/tasks/remove.py @@ -18,7 +18,7 @@ resolve_task_selector_query, save_document, ) -from org.logic.filtering import resolve_input_paths +from org.pipeline.load import resolve_input_paths from org.tui.color import should_use_color diff --git a/src/org/commands/tasks/update.py b/src/org/commands/tasks/update.py index 1cf59f4..3970709 100644 --- a/src/org/commands/tasks/update.py +++ b/src/org/commands/tasks/update.py @@ -31,7 +31,7 @@ resolve_task_selector_query, save_document, ) -from org.logic.filtering import resolve_input_paths +from org.pipeline.load import resolve_input_paths from org.tui.color import should_use_color diff --git a/src/org/config/app.py b/src/org/config/app.py index 6271a06..796dcf6 100644 --- a/src/org/config/app.py +++ b/src/org/config/app.py @@ -74,6 +74,91 @@ CONFIG_BOARD_VIEWS: dict[str, BoardViewConfig] = {} CONFIG_AGENDA_VIEWS: dict[str, AgendaViewConfig] = {} +MAP: dict[str, str] = {} + +DEFAULT_VERBOSE: dict[str, bool] = {"value": False} + +TAGS: set[str] = set() + +HEADING = { + "the", + "to", + "a", + "for", + "in", + "of", + "and", + "on", + "with", + "some", + "out", + "&", + "up", + "from", + "an", + "into", + "new", + "why", + "do", + "ways", + "say", + "it", + "this", + "is", + "no", + "not", + "that", + "all", + "but", + "be", + "use", + "now", + "will", + "i", + "as", + "or", + "by", + "did", + "can", + "are", + "was", + "more", + "until", + "using", + "when", + "only", + "at", + "it's", + "have", + "about", + "just", + "get", + "didn't", + "can't", + "my", + "does", + "etc", + "there", + "yet", + "nope", + "should", + "i'll", + "nah", +} + +DEFAULT_EXCLUDE = TAGS.union(HEADING).union( + { + "end", + "logbook", + "cancelled", + "scheduled", + "suspended", + "", + }, +) + +CATEGORY_NAMES = {"tags": "tags", "heading": "heading words", "body": "body words"} + DEST_TO_OPTION_NAME: dict[str, str] = { "date": "--date", @@ -133,6 +218,13 @@ def normalize_exclude_values(values: list[str]) -> set[str]: return {line.strip() for line in values if line.strip()} +def resolve_verbose(verbose: bool | None) -> bool: + """Resolve effective verbose setting from CLI flag and config defaults.""" + if verbose is None: + return DEFAULT_VERBOSE["value"] + return verbose + + def load_exclude_list(filepath: str | None) -> set[str]: """Load exclude list from a file (one word per line). @@ -195,6 +287,24 @@ def load_mapping(filepath: str | None) -> dict[str, str]: return mapping +def resolve_mapping(args: object) -> dict[str, str]: + """Resolve mapping based on inline or file-based configuration.""" + mapping_inline = getattr(args, "mapping_inline", None) + if mapping_inline is not None: + return mapping_inline or MAP + mapping_file = getattr(args, "mapping", None) + return load_mapping(mapping_file) or MAP + + +def resolve_exclude_set(args: object) -> set[str]: + """Resolve exclude set based on inline or file-based configuration.""" + exclude_inline = getattr(args, "exclude_inline", None) + if exclude_inline is not None: + return normalize_exclude_values(exclude_inline) or DEFAULT_EXCLUDE + exclude_file = getattr(args, "exclude", None) + return load_exclude_list(exclude_file) or DEFAULT_EXCLUDE + + @dataclass class ConfigOptions: """Config option mapping metadata.""" diff --git a/src/org/logic/stats.py b/src/org/logic/stats.py index 16cdfd8..912bd2f 100644 --- a/src/org/logic/stats.py +++ b/src/org/logic/stats.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from org.logic.time import extract_timestamp_any +from org.logic.validation import parse_group_values if TYPE_CHECKING: @@ -130,6 +131,15 @@ class AnalysisResult: tag_groups: list[Group] +def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: + """Extract top day and its count from TimeRange.""" + if not time_range or not time_range.timeline: + return None + max_count = max(time_range.timeline.values()) + top_day = min(d for d, count in time_range.timeline.items() if count == max_count) + return (top_day.isoformat(), max_count) + + def weekday_to_string(weekday: int) -> str: """Map Python weekday integer to capitalized day name.""" days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] @@ -574,3 +584,48 @@ def clean(disallowed: set[str], tags: dict[str, Tag]) -> dict[str, Tag]: """Remove tags from the disallowed set (stop words).""" disallowed_lower = {d.lower() for d in disallowed} return {t: tags[t] for t in tags if t.lower() not in disallowed_lower} + + +def normalize_show_value(value: str, mapping: dict[str, str]) -> str: + """Normalize a single show value to match heading/body analysis.""" + normalized = normalize({value}, mapping) + return next(iter(normalized), "") + + +def dedupe_values(values: list[str]) -> list[str]: + """Deduplicate values while preserving order.""" + seen: set[str] = set() + deduped: list[str] = [] + for value in values: + if value in seen: + continue + seen.add(value) + deduped.append(value) + return deduped + + +def resolve_group_values( + groups: list[str] | None, + mapping: dict[str, str], + category: str, +) -> list[list[str]] | None: + """Resolve explicit group values from CLI arguments.""" + if groups is None: + return None + + resolved_groups: list[list[str]] = [] + for group_value in groups: + raw_values = parse_group_values(group_value) + if category == "tags": + group_items = [mapping.get(value, value) for value in raw_values] + else: + group_items = [] + for value in raw_values: + normalized_value = normalize_show_value(value, mapping) + if normalized_value: + group_items.append(normalized_value) + group_items = dedupe_values(group_items) + if group_items: + resolved_groups.append(group_items) + + return resolved_groups diff --git a/src/org/logic/tasks.py b/src/org/logic/tasks.py index 3526b30..3ba4534 100644 --- a/src/org/logic/tasks.py +++ b/src/org/logic/tasks.py @@ -8,6 +8,8 @@ from org_parser.element import Repeat from org_parser.time import Timestamp +from org.logic.time import get_most_recent_timestamp + if TYPE_CHECKING: from datetime import datetime @@ -95,3 +97,15 @@ def detail_org_block(node: Heading) -> str: parts = [str(node)] parts.extend(detail_org_block(child) for child in node.children) return "".join(parts) + + +def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list[tuple[Heading, datetime]] = [] + for node in nodes: + timestamp = get_most_recent_timestamp(node) + if timestamp is not None: + nodes_with_timestamps.append((node, timestamp)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) + return [node for node, _ in sorted_nodes[:max_results]] diff --git a/src/org/logic/time.py b/src/org/logic/time.py index b1d66b8..8f499af 100644 --- a/src/org/logic/time.py +++ b/src/org/logic/time.py @@ -6,6 +6,8 @@ from datetime import UTC, date, datetime, timedelta from typing import TYPE_CHECKING +import typer + if TYPE_CHECKING: from org_parser.document import Heading @@ -17,6 +19,44 @@ def local_now() -> datetime: return datetime.now(tz=UTC).astimezone() +def parse_date_argument(date_str: str, arg_name: str) -> datetime: + """Parse and validate timestamp argument in multiple supported formats.""" + if not date_str or not date_str.strip(): + supported_formats = [ + "YYYY-MM-DD", + "YYYY-MM-DDThh:mm", + "YYYY-MM-DDThh:mm:ss", + "YYYY-MM-DD hh:mm", + "YYYY-MM-DD hh:mm:ss", + ] + formats_str = ", ".join(supported_formats) + raise typer.BadParameter( + f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", + ) + + try: + return datetime.fromisoformat(date_str) + except ValueError: + pass + + try: + return datetime.fromisoformat(date_str.replace(" ", "T")) + except ValueError: + pass + + supported_formats = [ + "YYYY-MM-DD", + "YYYY-MM-DDThh:mm", + "YYYY-MM-DDThh:mm:ss", + "YYYY-MM-DD hh:mm", + "YYYY-MM-DD hh:mm:ss", + ] + formats_str = ", ".join(supported_formats) + raise typer.BadParameter( + f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", + ) + + def normalize_timestamp(ts: datetime | date) -> datetime: """Normalize all timestamps to use the datetime format.""" if not isinstance(ts, datetime): @@ -60,6 +100,29 @@ def extract_timestamp_any(node: Heading) -> list[datetime]: return [normalize_timestamp(t) for t in timestamps] +def get_most_recent_timestamp(node: Heading) -> datetime | None: + """Get the most recent timestamp from a node.""" + timestamps = extract_timestamp_any(node) + return max(timestamps) if timestamps else None + + +def resolve_date_filters(args: object) -> tuple[datetime | None, datetime | None]: + """Resolve date filter arguments into parsed datetime values.""" + date_from_value = getattr(args, "filter_date_from", None) + date_until_value = getattr(args, "filter_date_until", None) + date_from = ( + parse_date_argument(date_from_value, "--filter-date-from") + if date_from_value is not None + else None + ) + date_until = ( + parse_date_argument(date_until_value, "--filter-date-until") + if date_until_value is not None + else None + ) + return date_from, date_until + + def set_timestamp_fields(timestamp: Timestamp, start: datetime, end: datetime | None) -> None: """Set timestamp date/time fields while preserving active/repeater metadata.""" timestamp.start_year = start.year diff --git a/src/org/logic/validation.py b/src/org/logic/validation.py index 15af641..4d0e4e1 100644 --- a/src/org/logic/validation.py +++ b/src/org/logic/validation.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -from datetime import datetime from typing import Protocol import typer @@ -31,44 +30,6 @@ class StatsArgs(Protocol): min_group_size: int -def parse_date_argument(date_str: str, arg_name: str) -> datetime: - """Parse and validate timestamp argument in multiple supported formats.""" - if not date_str or not date_str.strip(): - supported_formats = [ - "YYYY-MM-DD", - "YYYY-MM-DDThh:mm", - "YYYY-MM-DDThh:mm:ss", - "YYYY-MM-DD hh:mm", - "YYYY-MM-DD hh:mm:ss", - ] - formats_str = ", ".join(supported_formats) - raise typer.BadParameter( - f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", - ) - - try: - return datetime.fromisoformat(date_str) - except ValueError: - pass - - try: - return datetime.fromisoformat(date_str.replace(" ", "T")) - except ValueError: - pass - - supported_formats = [ - "YYYY-MM-DD", - "YYYY-MM-DDThh:mm", - "YYYY-MM-DDThh:mm:ss", - "YYYY-MM-DD hh:mm", - "YYYY-MM-DD hh:mm:ss", - ] - formats_str = ", ".join(supported_formats) - raise typer.BadParameter( - f"{arg_name} must be in one of these formats: {formats_str}\nGot: '{date_str}'", - ) - - def parse_property_filter(property_str: str) -> tuple[str, str]: """Parse property filter argument in KEY=VALUE format.""" if "=" not in property_str: diff --git a/src/org/pipeline/__init__.py b/src/org/pipeline/__init__.py new file mode 100644 index 0000000..da9d24e --- /dev/null +++ b/src/org/pipeline/__init__.py @@ -0,0 +1 @@ +"""Data loading, query execution, and output pipeline modules.""" diff --git a/src/org/serde/format.py b/src/org/pipeline/format.py similarity index 100% rename from src/org/serde/format.py rename to src/org/pipeline/format.py diff --git a/src/org/pipeline/load.py b/src/org/pipeline/load.py new file mode 100644 index 0000000..a7f63e7 --- /dev/null +++ b/src/org/pipeline/load.py @@ -0,0 +1,217 @@ +"""Pipeline helpers for loading and preprocessing org-mode data.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, cast + +import typer +from org_parser import loads +from org_parser.document import Heading + +from org.logic.validation import validate_and_parse_keys, validate_global_arguments +from org.pipeline.query import ( + QueryBuildArgs, + build_query, + collect_custom_context_vars, + execute_query_or_raise, + flatten_query_results, + normalize_cli_files_for_custom_switches, + validate_custom_switches, +) +from org.query_language import QueryParseError + + +if TYPE_CHECKING: + from org_parser.document import Document + + +logger = logging.getLogger("org") + + +def _read_org_file(name: str) -> str: + """Read one org file and normalize unsupported time values.""" + path = Path(name) + try: + with path.open(encoding="utf-8") as f: + logger.info("Processing %s...", name) + return f.read().replace("24:00", "00:00") + except FileNotFoundError as err: + raise typer.BadParameter(f"File '{name}' not found") from err + except PermissionError as err: + raise typer.BadParameter(f"Permission denied for '{name}'") from err + + +def _prepend_todo_config(contents: str, todo_states: list[str], done_states: list[str]) -> str: + """Prepend TODO keyword configuration to file contents.""" + todo_config = f"#+TODO: {' '.join(todo_states)} | {' '.join(done_states)}\n\n" + return todo_config + contents + + +def _merge_state_order(existing: list[str], discovered: list[str]) -> list[str]: + """Merge discovered states into existing list while preserving order.""" + merged = list(existing) + seen = set(merged) + for state in discovered: + if state in seen: + continue + merged.append(state) + seen.add(state) + return merged + + +def load_root_nodes( + filenames: list[str], + todo_states: list[str], + done_states: list[str], +) -> tuple[list[Document], list[str], list[str]]: + """Load org-mode files and return root nodes with merged todo/done keys.""" + roots: list[Document] = [] + all_todo_states = list(todo_states) + all_done_states = list(done_states) + + for name in filenames: + contents = _read_org_file(name) + contents = _prepend_todo_config(contents, todo_states, done_states) + root = loads(contents, filename=name) + all_todo_states = _merge_state_order(all_todo_states, list(root.todo_states)) + all_done_states = _merge_state_order(all_done_states, list(root.done_states)) + roots.append(root) + + return roots, all_todo_states, all_done_states + + +def resolve_input_paths(inputs: list[str] | None) -> list[str]: # noqa: C901 + """Resolve CLI inputs into a list of org files to process.""" + resolved_files: list[str] = [] + searched_dirs: list[Path] = [] + missing_paths: list[str] = [] + + targets = inputs or ["."] + for raw_path in targets: + path = Path(raw_path) + if not path.exists(): + missing_paths.append(raw_path) + continue + + if path.is_dir(): + searched_dirs.append(path) + for file_path in sorted(path.glob("*.org")): + if file_path.exists(): + resolved_files.append(str(file_path)) + else: + missing_paths.append(str(file_path)) + continue + + if path.is_file(): + resolved_files.append(str(path)) + continue + + raise typer.BadParameter(f"Path '{raw_path}' is not a file or directory") + + for raw_path in missing_paths: + logger.info("Warning: file '%s' not found", raw_path) + + if not resolved_files: + if missing_paths: + missing_list = ", ".join(missing_paths) + raise typer.BadParameter(f"All input paths are missing: {missing_list}") + if searched_dirs: + searched_list = ", ".join(str(path) for path in searched_dirs) + raise typer.BadParameter(f"No .org files found in: {searched_list}") + raise typer.BadParameter("No .org files found") + + return resolved_files + + +class RootDataLoadArgs(Protocol): + """Protocol for loading root nodes without filters or enrichment.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +class DataLoadArgs(QueryBuildArgs, Protocol): + """Protocol for loading and preprocessing data.""" + + files: list[str] | None + todo_states: str + done_states: str + width: int | None + + +class SlicedDataLoadArgs(Protocol): + """Protocol for args that support query slicing.""" + + offset: int + max_results: int + + +def _load_roots_for_inputs( + files: list[str] | None, + todo_states: list[str], + done_states: list[str], +) -> tuple[list[Document], list[str], list[str]]: + """Resolve file inputs and load all org root nodes.""" + return load_root_nodes(resolve_input_paths(files), todo_states, done_states) + + +def _resolve_and_load_roots(args: RootDataLoadArgs) -> tuple[list[Document], list[str], list[str]]: + """Resolve inputs and load org roots after validating todo/done keys.""" + todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") + done_states = validate_and_parse_keys(args.done_states, "--done-states") + return _load_roots_for_inputs(args.files, todo_states, done_states) + + +def load_root_data(args: RootDataLoadArgs) -> tuple[list[Document], list[str], list[str]]: + """Load org root nodes without filters, enrichment, or ordering.""" + return _resolve_and_load_roots(args) + + +def load_and_process_data(args: DataLoadArgs) -> tuple[list[Heading], list[str], list[str]]: + """Load nodes, preprocess, and apply query-based filters/ordering.""" + include_ordering = hasattr(args, "order_by_level") + validate_custom_switches(sys.argv, include_ordering) + + normalized_files = normalize_cli_files_for_custom_switches(args.files) + args.files = normalized_files + + todo_states, done_states = validate_global_arguments(args) + roots, todo_states, done_states = _load_roots_for_inputs( + normalized_files, + todo_states, + done_states, + ) + nodes = [node for root in roots for node in list(root)] + + include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") + try: + query = build_query( + args, + sys.argv, + include_ordering=include_ordering, + include_slice=include_slice, + ) + except QueryParseError as exc: + raise typer.BadParameter(str(exc)) from exc + + context_vars: dict[str, object] = { + "todo_states": todo_states, + "done_states": done_states, + } + context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) + if include_slice: + sliced_args = cast("SlicedDataLoadArgs", args) + context_vars["offset"] = sliced_args.offset + context_vars["limit"] = sliced_args.max_results + + logger.info("Query context: %s", context_vars) + + results = execute_query_or_raise(query, [nodes], context_vars, typer.BadParameter) + + flattened = flatten_query_results(results) + return [value for value in flattened if isinstance(value, Heading)], todo_states, done_states diff --git a/src/org/logic/filtering.py b/src/org/pipeline/query.py similarity index 62% rename from src/org/logic/filtering.py rename to src/org/pipeline/query.py index 351bf59..cb5f974 100644 --- a/src/org/logic/filtering.py +++ b/src/org/pipeline/query.py @@ -1,29 +1,19 @@ -"""Shared CLI helpers and data processing utilities.""" +"""Query building and execution helpers for command pipelines.""" from __future__ import annotations import json import logging import re -import sys +from collections.abc import Callable, Sequence from dataclasses import dataclass -from pathlib import Path from typing import TYPE_CHECKING, Protocol, cast import click -import typer -from org_parser.document import Heading import org.config.app -from org.logic.stats import TimeRange, normalize -from org.logic.time import extract_timestamp_any -from org.logic.validation import ( - parse_date_argument, - parse_group_values, - parse_property_filter, - validate_and_parse_keys, - validate_global_arguments, -) +from org.logic.time import parse_date_argument +from org.logic.validation import parse_property_filter from org.query_language import ( EvalContext, QueryParseError, @@ -31,181 +21,15 @@ Stream, compile_query_text, ) -from org.serde.parse import load_root_nodes if TYPE_CHECKING: - from datetime import datetime - - from org_parser import Document - from org.query_language.compiler import CompiledQuery -MAP: dict[str, str] = {} - -DEFAULT_VERBOSE: dict[str, bool] = {"value": False} - logger = logging.getLogger("org") -TAGS: set[str] = set() - - -def resolve_verbose(verbose: bool | None) -> bool: - """Resolve effective verbose setting from CLI flag and config defaults.""" - if verbose is None: - return DEFAULT_VERBOSE["value"] - return verbose - - -HEADING = { - "the", - "to", - "a", - "for", - "in", - "of", - "and", - "on", - "with", - "some", - "out", - "&", - "up", - "from", - "an", - "into", - "new", - "why", - "do", - "ways", - "say", - "it", - "this", - "is", - "no", - "not", - "that", - "all", - "but", - "be", - "use", - "now", - "will", - "i", - "as", - "or", - "by", - "did", - "can", - "are", - "was", - "more", - "until", - "using", - "when", - "only", - "at", - "it's", - "have", - "about", - "just", - "get", - "didn't", - "can't", - "my", - "does", - "etc", - "there", - "yet", - "nope", - "should", - "i'll", - "nah", -} - - -DEFAULT_EXCLUDE = TAGS.union(HEADING).union( - { - "end", - "logbook", - "cancelled", - "scheduled", - "suspended", - "", - }, -) - - -CATEGORY_NAMES = {"tags": "tags", "heading": "heading words", "body": "body words"} - - -class FilterArgs(Protocol): - """Protocol for filter-related CLI arguments.""" - - filter_priority: str | None - filter_level: int | None - filter_repeats_above: int | None - filter_repeats_below: int | None - filter_date_from: str | None - filter_date_until: str | None - filter_properties: list[str] | None - filter_tags: list[str] | None - filter_headings: list[str] | None - filter_bodies: list[str] | None - filter_completed: bool - filter_not_completed: bool - - -class WithArgs(Protocol): - """Protocol for built-in enrichment CLI arguments.""" - - with_tags_as_category: bool - - -class QueryBuildArgs(FilterArgs, WithArgs, Protocol): - """Protocol for arguments used to build query pipelines.""" - - -def is_valid_regex(pattern: str, *, use_multiline: bool = False) -> bool: - """Check if a string is a valid regex pattern.""" - try: - if use_multiline: - re.compile(pattern, re.MULTILINE) - else: - re.compile(pattern) - except re.error: - return False - return True - - -def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: - """Extract top day and its count from TimeRange.""" - if not time_range or not time_range.timeline: - return None - max_count = max(time_range.timeline.values()) - top_day = min(d for d, count in time_range.timeline.items() if count == max_count) - return (top_day.isoformat(), max_count) - - -def get_most_recent_timestamp(node: Heading) -> datetime | None: - """Get the most recent timestamp from a node.""" - timestamps = extract_timestamp_any(node) - return max(timestamps) if timestamps else None - - -def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: - """Get top N nodes sorted by most recent timestamp.""" - nodes_with_timestamps: list[tuple[Heading, datetime]] = [] - for node in nodes: - timestamp = get_most_recent_timestamp(node) - if timestamp: - nodes_with_timestamps.append((node, timestamp)) - - sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) - - return [node for node, _ in sorted_nodes[:max_results]] - +ErrorBuilder = Callable[[str], Exception] FILTER_OPTIONS_WITH_VALUE = { "--filter-priority", @@ -248,6 +72,33 @@ def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: } +class FilterArgs(Protocol): + """Protocol for filter-related CLI arguments.""" + + filter_priority: str | None + filter_level: int | None + filter_repeats_above: int | None + filter_repeats_below: int | None + filter_date_from: str | None + filter_date_until: str | None + filter_properties: list[str] | None + filter_tags: list[str] | None + filter_headings: list[str] | None + filter_bodies: list[str] | None + filter_completed: bool + filter_not_completed: bool + + +class WithArgs(Protocol): + """Protocol for built-in enrichment CLI arguments.""" + + with_tags_as_category: bool + + +class QueryBuildArgs(FilterArgs, WithArgs, Protocol): + """Protocol for arguments used to build query pipelines.""" + + @dataclass(frozen=True) class CustomStageInvocation: """One custom switch occurrence parsed from CLI arguments.""" @@ -300,9 +151,9 @@ def _resolve_custom_option(option: str) -> tuple[str, bool] | None: return None -def _required_custom_arg_error(option: str) -> typer.BadParameter: +def _required_custom_arg_error(option: str) -> click.BadParameter: """Return the standard required custom argument error.""" - return typer.BadParameter(f"{option} requires exactly one argument") + return click.BadParameter(f"{option} requires exactly one argument") def normalize_cli_files_for_custom_switches(files: list[str] | None) -> list[str] | None: @@ -379,12 +230,10 @@ def _coerce_custom_arg_value(value: str | None) -> object: return value_map[lowered] if re.fullmatch(r"-?\d+", value): - parsed_value: object = int(value) - elif re.fullmatch(r"-?\d+\.\d+", value): - parsed_value = float(value) - else: - parsed_value = value - return parsed_value + return int(value) + if re.fullmatch(r"-?\d+\.\d+", value): + return float(value) + return value def _build_custom_invocation( @@ -403,8 +252,7 @@ def _build_custom_invocation( def _custom_stage(query: str, arg_value: object) -> str: """Build one custom query stage preserving input item stream values.""" - arg_literal = _query_literal(arg_value) - return f"let {arg_literal} as $arg in ({query})" + return f"let {_query_literal(arg_value)} as $arg in ({query})" def _query_literal(value: object) -> str: @@ -415,8 +263,6 @@ def _query_literal(value: object) -> str: return "true" if value else "false" if isinstance(value, int | float): return str(value) - if isinstance(value, str): - return json.dumps(value) return json.dumps(value) @@ -459,9 +305,7 @@ def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> raise _required_custom_arg_error(option) -def parse_filter_entries_from_argv( - argv: list[str], -) -> list[str | CustomStageInvocation]: +def parse_filter_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: """Parse built-in and custom filter switch occurrences in argv order.""" entries: list[str | CustomStageInvocation] = [] index = 0 @@ -484,11 +328,7 @@ def parse_filter_entries_from_argv( requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=token.split("=", 1)[1], - ), + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), ) index += 1 continue @@ -499,13 +339,7 @@ def parse_filter_entries_from_argv( option, requires_arg, ) - entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=custom_arg, - ), - ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) index = consumed_index + 1 return entries @@ -542,11 +376,7 @@ def parse_order_entries_from_argv( requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=token.split("=", 1)[1], - ), + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), ) index += 1 continue @@ -557,25 +387,16 @@ def parse_order_entries_from_argv( option, requires_arg, ) - entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=custom_arg, - ), - ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) index = consumed_index + 1 return entries -def parse_with_entries_from_argv( - argv: list[str], -) -> list[str | CustomStageInvocation]: +def parse_with_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: """Parse built-in and custom enrichment switch occurrences in argv order.""" entries: list[str | CustomStageInvocation] = [] index = 0 - builtins = WITH_OPTIONS_FLAGS while index < len(argv): token = argv[index] option = _extract_option_token(token) @@ -585,7 +406,7 @@ def parse_with_entries_from_argv( continue name = _custom_option_name(option, "with") - if name is None or name not in org.config.app.CONFIG_CUSTOM_WITH or option in builtins: + if name is None or name not in org.config.app.CONFIG_CUSTOM_WITH: index += 1 continue @@ -593,11 +414,7 @@ def parse_with_entries_from_argv( requires_arg = _query_uses_arg(query) if token.startswith(f"{option}="): entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=token.split("=", 1)[1], - ), + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), ) index += 1 continue @@ -608,13 +425,7 @@ def parse_with_entries_from_argv( option, requires_arg, ) - entries.append( - _build_custom_invocation( - name=name, - query=query, - raw_arg=custom_arg, - ), - ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) index = consumed_index + 1 return entries @@ -626,14 +437,12 @@ def extend_with_entries_with_defaults( ) -> list[str | CustomStageInvocation]: """Extend with-entry order to include config-provided built-in enrichments.""" expected_counts = {"--with-tags-as-category": 1 if args.with_tags_as_category else 0} - full_entries = list(with_entries) for option_name, expected in expected_counts.items(): existing = sum(1 for entry in full_entries if entry == option_name) missing = expected - existing if missing > 0: full_entries.extend([option_name] * missing) - return full_entries @@ -647,8 +456,6 @@ def extend_filter_order_with_defaults( args: FilterArgs, ) -> list[str | CustomStageInvocation]: """Extend filter order to include config-provided filters.""" - filter_headings = getattr(args, "filter_headings", None) - filter_bodies = getattr(args, "filter_bodies", None) expected_counts = { "--filter-priority": 1 if args.filter_priority is not None else 0, "--filter-level": 1 if args.filter_level is not None else 0, @@ -658,8 +465,8 @@ def extend_filter_order_with_defaults( "--filter-date-until": 1 if args.filter_date_until is not None else 0, "--filter-property": count_filter_values(args.filter_properties), "--filter-tag": count_filter_values(args.filter_tags), - "--filter-heading": count_filter_values(filter_headings), - "--filter-body": count_filter_values(filter_bodies), + "--filter-heading": count_filter_values(args.filter_headings), + "--filter-body": count_filter_values(args.filter_bodies), "--filter-completed": 1 if args.filter_completed else 0, "--filter-not-completed": 1 if args.filter_not_completed else 0, } @@ -670,21 +477,9 @@ def extend_filter_order_with_defaults( missing = expected - existing if missing > 0: full_order.extend([arg_name] * missing) - return full_order -def resolve_date_filters(args: FilterArgs) -> tuple[datetime | None, datetime | None]: - """Resolve date filter arguments into parsed datetime values.""" - date_from = None - date_until = None - if args.filter_date_from is not None: - date_from = parse_date_argument(args.filter_date_from, "--filter-date-from") - if args.filter_date_until is not None: - date_until = parse_date_argument(args.filter_date_until, "--filter-date-until") - return date_from, date_until - - def _quote_string(value: str) -> str: """Quote a value as query-language string literal.""" return json.dumps(value) @@ -693,8 +488,7 @@ def _quote_string(value: str) -> str: def _timestamp_source_literal(value: str, arg_name: str) -> str: """Quote one parsed date argument as org timestamp source.""" parsed = parse_date_argument(value, arg_name).replace(second=0, microsecond=0) - source = f"<{parsed:%Y-%m-%d %a %H:%M}>" - return _quote_string(source) + return _quote_string(f"<{parsed:%Y-%m-%d %a %H:%M}>") def _simple_filter_stage(arg_name: str, args: FilterArgs) -> str | None: @@ -705,22 +499,18 @@ def _simple_filter_stage(arg_name: str, args: FilterArgs) -> str | None: elif arg_name == "--filter-level" and args.filter_level is not None: stage = f"select(.level == {args.filter_level})" elif arg_name == "--filter-repeats-above" and args.filter_repeats_above is not None: - threshold = args.filter_repeats_above - stage = f"select(.repeats | length > {threshold})" + stage = f"select(.repeats | length > {args.filter_repeats_above})" elif arg_name == "--filter-repeats-below" and args.filter_repeats_below is not None: - threshold = args.filter_repeats_below - stage = f"select(.repeats | length < {threshold})" + stage = f"select(.repeats | length < {args.filter_repeats_below})" elif arg_name == "--filter-date-from" and args.filter_date_from is not None: - timestamp_value = _timestamp_source_literal(args.filter_date_from, arg_name) stage = ( "select(.repeats + .deadline + .closed + .scheduled " - f"| max >= timestamp({timestamp_value}))" + f"| max >= timestamp({_timestamp_source_literal(args.filter_date_from, arg_name)}))" ) elif arg_name == "--filter-date-until" and args.filter_date_until is not None: - timestamp_value = _timestamp_source_literal(args.filter_date_until, arg_name) stage = ( "select(.repeats + .deadline + .closed + .scheduled " - f"| max <= timestamp({timestamp_value}))" + f"| max <= timestamp({_timestamp_source_literal(args.filter_date_until, arg_name)}))" ) elif arg_name == "--filter-completed" and args.filter_completed: stage = ( @@ -755,9 +545,8 @@ def _indexed_filter_stage( ) index_trackers["property"] += 1 return ( - "select(.properties[" - f"{_quote_string(property_name)}" - f"] == {_quote_string(property_value)})" + f"select(.properties[{_quote_string(property_name)}] == " + f"{_quote_string(property_value)})" ) if ( @@ -809,7 +598,6 @@ def build_filter_stages( if isinstance(entry, CustomStageInvocation): filter_stages.append(_custom_stage(entry.query, entry.arg_value)) continue - stage = _filter_stage(entry, args, index_trackers) if stage is not None: filter_stages.append(stage) @@ -835,17 +623,30 @@ def extend_order_values_with_defaults(order_values: list[str], args: object) -> return full_order -def build_order_stages( - args: object, - argv: list[str], - include_builtin_ordering: bool, -) -> list[str]: +def _builtin_order_stages(value: str) -> list[str]: + """Build query stages for one built-in ordering value.""" + timestamp_key_expr = ".repeats + .deadline + .closed + .scheduled | max" + order_stages: dict[str, list[str]] = { + "file-order": ["."], + "file-order-reversed": ["reverse"], + "priority": ["sort_by(.priority)"], + "level": ["sort_by(.level)"], + "timestamp-asc": [ + f"sort_by({timestamp_key_expr})", + "reverse", + f"sort_by(({timestamp_key_expr}) != null)", + ], + "timestamp-desc": [f"sort_by({timestamp_key_expr})"], + } + return order_stages.get(value, []) + + +def build_order_stages(args: object, argv: list[str], include_builtin_ordering: bool) -> list[str]: """Build query stages for ordering pipeline.""" order_entries = parse_order_entries_from_argv(argv, include_builtin_ordering) order_values: list[str | CustomStageInvocation] if include_builtin_ordering: builtin_order_values = [entry for entry in order_entries if isinstance(entry, str)] - expected_builtins = extend_order_values_with_defaults(builtin_order_values, args) remaining_builtin_counts: dict[str, int] = {} for value in builtin_order_values: @@ -870,29 +671,10 @@ def build_order_stages( if isinstance(order_value, CustomStageInvocation): order_stages.append(_custom_stage(order_value.query, order_value.arg_value)) continue - order_stages.extend(_builtin_order_stages(order_value)) return order_stages -def _builtin_order_stages(value: str) -> list[str]: - """Build query stages for one built-in ordering value.""" - timestamp_key_expr = ".repeats + .deadline + .closed + .scheduled | max" - order_stages: dict[str, list[str]] = { - "file-order": ["."], - "file-order-reversed": ["reverse"], - "priority": ["sort_by(.priority)"], - "level": ["sort_by(.level)"], - "timestamp-asc": [ - f"sort_by({timestamp_key_expr})", - "reverse", - f"sort_by(({timestamp_key_expr}) != null)", - ], - "timestamp-desc": ["sort_by(.repeats + .deadline + .closed + .scheduled | max)"], - } - return order_stages.get(value, []) - - def build_with_stages(args: WithArgs, argv: list[str]) -> list[str]: """Build query stages for built-in and custom enrichment pipeline.""" entries = parse_with_entries_from_argv(argv) @@ -926,20 +708,15 @@ def build_query_text( """Build query text for the configured filter/ordering pipeline.""" validate_custom_switches(argv, include_ordering) - filter_order = parse_filter_entries_from_argv(argv) - filter_order = extend_filter_order_with_defaults(filter_order, args) + filter_order = extend_filter_order_with_defaults(parse_filter_entries_from_argv(argv), args) filter_stages = build_filter_stages(args, filter_order) with_stages = build_with_stages(args, argv) - stages = [*with_stages, *filter_stages] stages.extend(build_order_stages(args, argv, include_builtin_ordering=include_ordering)) pipeline_body = " | ".join(stages) base_query = f"[ .[] | {pipeline_body} ]" if pipeline_body else "[ .[] ]" - - if include_slice: - return f"{base_query}[$offset:($offset + $limit)]" - return base_query + return f"{base_query}[$offset:($offset + $limit)]" if include_slice else base_query def build_query( @@ -954,211 +731,61 @@ def build_query( return compile_query_text(query_text) -def normalize_show_value(value: str, mapping: dict[str, str]) -> str: - """Normalize a single show value to match heading/body analysis.""" - normalized = normalize({value}, mapping) - return next(iter(normalized), "") - - -def dedupe_values(values: list[str]) -> list[str]: - """Deduplicate values while preserving order.""" - seen: set[str] = set() - deduped: list[str] = [] - for value in values: - if value in seen: - continue - seen.add(value) - deduped.append(value) - return deduped - - -def resolve_group_values( - groups: list[str] | None, - mapping: dict[str, str], - category: str, -) -> list[list[str]] | None: - """Resolve explicit group values from CLI arguments.""" - if groups is None: - return None - - resolved_groups: list[list[str]] = [] - for group_value in groups: - raw_values = parse_group_values(group_value) - if category == "tags": - group_items = [mapping.get(value, value) for value in raw_values] - else: - group_items = [] - for value in raw_values: - normalized_value = normalize_show_value(value, mapping) - if normalized_value: - group_items.append(normalized_value) - group_items = dedupe_values(group_items) - if group_items: - resolved_groups.append(group_items) - - return resolved_groups - - -def resolve_input_paths(inputs: list[str] | None) -> list[str]: # noqa: C901 - """Resolve CLI inputs into a list of org files to process.""" - resolved_files: list[str] = [] - searched_dirs: list[Path] = [] - missing_paths: list[str] = [] - - targets = inputs or ["."] - for raw_path in targets: - path = Path(raw_path) - if not path.exists(): - missing_paths.append(raw_path) - continue - - if path.is_dir(): - searched_dirs.append(path) - for file_path in sorted(path.glob("*.org")): - if file_path.exists(): - resolved_files.append(str(file_path)) - else: - missing_paths.append(str(file_path)) - continue - - if path.is_file(): - resolved_files.append(str(path)) - continue - - raise typer.BadParameter(f"Path '{raw_path}' is not a file or directory") - - for raw_path in missing_paths: - logger.info("Warning: file '%s' not found", raw_path) - - if not resolved_files: - if missing_paths: - missing_list = ", ".join(missing_paths) - raise typer.BadParameter(f"All input paths are missing: {missing_list}") - if searched_dirs: - searched_list = ", ".join(str(path) for path in searched_dirs) - raise typer.BadParameter(f"No .org files found in: {searched_list}") - raise typer.BadParameter("No .org files found") - - return resolved_files - - -def resolve_mapping(args: object) -> dict[str, str]: - """Resolve mapping based on inline or file-based configuration.""" - mapping_inline = getattr(args, "mapping_inline", None) - if mapping_inline is not None: - return mapping_inline or MAP - mapping_file = getattr(args, "mapping", None) - return org.config.app.load_mapping(mapping_file) or MAP - - -def resolve_exclude_set(args: object) -> set[str]: - """Resolve exclude set based on inline or file-based configuration.""" - exclude_inline = getattr(args, "exclude_inline", None) - if exclude_inline is not None: - return org.config.app.normalize_exclude_values(exclude_inline) or DEFAULT_EXCLUDE - exclude_file = getattr(args, "exclude", None) - return org.config.app.load_exclude_list(exclude_file) or DEFAULT_EXCLUDE - - -class DataLoadArgs(QueryBuildArgs, Protocol): - """Protocol for loading and preprocessing data.""" - - files: list[str] | None - todo_states: str - done_states: str - width: int | None - - -class SlicedDataLoadArgs(Protocol): - """Protocol for args that support query slicing.""" - - offset: int - max_results: int - - -class RootDataLoadArgs(Protocol): - """Protocol for loading root nodes without filters or enrichment.""" - - files: list[str] | None - todo_states: str - done_states: str - width: int | None - - -def _resolve_and_load_roots( - args: RootDataLoadArgs, -) -> tuple[list[Document], list[str], list[str]]: - """Resolve inputs and load org roots after validating todo/done keys.""" - todo_states = validate_and_parse_keys(args.todo_states, "--todo-states") - done_states = validate_and_parse_keys(args.done_states, "--done-states") - return _load_roots_for_inputs(args.files, todo_states, done_states) - - -def _load_roots_for_inputs( - files: list[str] | None, - todo_states: list[str], - done_states: list[str], -) -> tuple[list[Document], list[str], list[str]]: - """Resolve file inputs and load all org root nodes.""" - filenames = resolve_input_paths(files) - return load_root_nodes(filenames, todo_states, done_states) - - -def load_root_data( - args: RootDataLoadArgs, -) -> tuple[list[Document], list[str], list[str]]: - """Load org root nodes without filters, enrichment, or ordering.""" - return _resolve_and_load_roots(args) - - -def load_and_process_data( - args: DataLoadArgs, -) -> tuple[list[Heading], list[str], list[str]]: - """Load nodes, preprocess, and apply query-based filters/ordering.""" - include_ordering = hasattr(args, "order_by_level") - validate_custom_switches(sys.argv, include_ordering) - - normalized_files = normalize_cli_files_for_custom_switches(args.files) - args.files = normalized_files - - todo_states, done_states = validate_global_arguments(args) - roots, todo_states, done_states = _load_roots_for_inputs( - normalized_files, - todo_states, - done_states, - ) - nodes = [node for root in roots for node in list(root)] - - include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") - query = build_query( - args, - sys.argv, - include_ordering=include_ordering, - include_slice=include_slice, +def compile_query_or_raise(query_text: str, error_builder: ErrorBuilder) -> CompiledQuery: + """Compile query text and convert parse failures to caller-specific errors.""" + try: + return compile_query_text(query_text) + except QueryParseError as exc: + raise error_builder(str(exc)) from exc + + +def compile_filter_order_query(filter_query: str, order_by: str | None) -> CompiledQuery: + """Compile a `select(...)` query with optional sort stage.""" + base_query = f"select({filter_query})" + if order_by is None: + return compile_query_text(base_query) + return compile_query_text(f"{base_query} | sort_by({order_by})") + + +def execute_query( + compiled_query: CompiledQuery, + stream_values: Sequence[object], + context_vars: dict[str, object], +) -> list[object]: + """Execute a compiled query over stream input values.""" + return list(compiled_query(Stream(stream_values), EvalContext(context_vars))) + + +def execute_query_or_raise( + compiled_query: CompiledQuery, + stream_values: Sequence[object], + context_vars: dict[str, object], + error_builder: ErrorBuilder, +) -> list[object]: + """Execute query and convert runtime failures to caller-specific errors.""" + try: + return execute_query(compiled_query, stream_values, context_vars) + except QueryRuntimeError as exc: + raise error_builder(str(exc)) from exc + + +def run_query_text_or_raise( + query_text: str, + stream_values: Sequence[object], + context_vars: dict[str, object], + error_builder: ErrorBuilder, +) -> list[object]: + """Compile and execute query text with caller-specific error conversion.""" + return execute_query_or_raise( + compile_query_or_raise(query_text, error_builder), + stream_values, + context_vars, + error_builder, ) - context_vars: dict[str, object] = { - "todo_states": todo_states, - "done_states": done_states, - } - context_vars.update(collect_custom_context_vars(sys.argv, normalized_files, include_ordering)) - if include_slice: - sliced_args = cast("SlicedDataLoadArgs", args) - context_vars["offset"] = sliced_args.offset - context_vars["limit"] = sliced_args.max_results - - logger.info("Query context: %s", context_vars) - - try: - results = query(Stream([nodes]), EvalContext(context_vars)) - except (QueryParseError, QueryRuntimeError) as exc: - raise typer.BadParameter(str(exc)) from exc - flattened: list[object] +def flatten_query_results(results: list[object]) -> list[object]: + """Flatten common single-list query result shape.""" if len(results) == 1 and isinstance(results[0], list): - flattened = cast("list[object]", results[0]) - else: - flattened = list(results) - nodes = [value for value in flattened if isinstance(value, Heading)] - - return nodes, todo_states, done_states + return cast("list[object]", results[0]) + return list(results) diff --git a/src/org/serde/__init__.py b/src/org/serde/__init__.py deleted file mode 100644 index e614ed6..0000000 --- a/src/org/serde/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Serialization helpers for parsing and output formatting.""" diff --git a/src/org/serde/parse.py b/src/org/serde/parse.py deleted file mode 100644 index 2506ab0..0000000 --- a/src/org/serde/parse.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Parsing utilities for org-mode files.""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -import typer -from org_parser import loads - - -if TYPE_CHECKING: - from org_parser.document import Document, Heading - - -class FilterSpec(Protocol): - """Protocol for filtering node lists.""" - - def filter(self, nodes: list[Heading]) -> list[Heading]: - """Filter nodes and return the filtered list.""" - raise NotImplementedError - - -logger = logging.getLogger("org") - - -def _read_org_file(name: str) -> str: - """Read one org file and normalize unsupported time values.""" - path = Path(name) - try: - with path.open(encoding="utf-8") as f: - logger.info("Processing %s...", name) - return f.read().replace("24:00", "00:00") - except FileNotFoundError as err: - raise typer.BadParameter(f"File '{name}' not found") from err - except PermissionError as err: - raise typer.BadParameter(f"Permission denied for '{name}'") from err - - -def _prepend_todo_config(contents: str, todo_states: list[str], done_states: list[str]) -> str: - """Prepend TODO keyword configuration to file contents.""" - todo_config = f"#+TODO: {' '.join(todo_states)} | {' '.join(done_states)}\n\n" - return todo_config + contents - - -def _merge_state_order(existing: list[str], discovered: list[str]) -> list[str]: - """Merge discovered states into existing list while preserving order.""" - merged = list(existing) - seen = set(merged) - for state in discovered: - if state in seen: - continue - merged.append(state) - seen.add(state) - return merged - - -def load_root_nodes( - filenames: list[str], - todo_states: list[str], - done_states: list[str], -) -> tuple[list[Document], list[str], list[str]]: - """Load org-mode files and return root nodes with merged todo/done keys.""" - roots: list[Document] = [] - all_todo_states = list(todo_states) - all_done_states = list(done_states) - - for name in filenames: - contents = _read_org_file(name) - contents = _prepend_todo_config(contents, todo_states, done_states) - root = loads(contents, filename=name) - all_todo_states = _merge_state_order(all_todo_states, list(root.todo_states)) - all_done_states = _merge_state_order(all_done_states, list(root.done_states)) - roots.append(root) - - return roots, all_todo_states, all_done_states diff --git a/src/org/tui/bits.py b/src/org/tui/bits.py index 6ed6d69..9179bf8 100644 --- a/src/org/tui/bits.py +++ b/src/org/tui/bits.py @@ -31,7 +31,7 @@ from rich.style import Style from rich.text import Text -from org.logic.filtering import get_top_tasks +from org.logic.tasks import get_top_tasks from org.tui.color import ( bright_blue, bright_white, diff --git a/tests/cli_common/test_cli_common_helpers.py b/tests/cli_common/test_cli_common_helpers.py index 55d3fb3..1ee1c8e 100644 --- a/tests/cli_common/test_cli_common_helpers.py +++ b/tests/cli_common/test_cli_common_helpers.py @@ -8,13 +8,9 @@ import pytest import typer -from org.logic.filtering import ( - dedupe_values, - normalize_show_value, - resolve_group_values, - resolve_input_paths, -) +from org.logic.stats import dedupe_values, normalize_show_value, resolve_group_values from org.logic.validation import parse_group_values +from org.pipeline.load import resolve_input_paths if TYPE_CHECKING: diff --git a/tests/cli_common/test_file_errors.py b/tests/cli_common/test_file_errors.py index a1cc22a..2cd8ec8 100644 --- a/tests/cli_common/test_file_errors.py +++ b/tests/cli_common/test_file_errors.py @@ -181,7 +181,7 @@ def test_load_mapping_mixed_non_string_types(tmp_path: Path) -> None: def test_load_root_nodes_valid() -> None: """Test loading valid org files as root nodes.""" - from org.serde.parse import load_root_nodes + from org.pipeline.load import load_root_nodes fixture_path = os.path.join(FIXTURES_DIR, "simple.org") roots, _, _ = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) @@ -192,7 +192,7 @@ def test_load_root_nodes_valid() -> None: def test_load_root_nodes_todo_states() -> None: """Test loading todo keys from root nodes.""" - from org.serde.parse import load_root_nodes + from org.pipeline.load import load_root_nodes fixture_path = os.path.join(FIXTURES_DIR, "todo_states.org") roots, todo_states, done_states = load_root_nodes([fixture_path], ["TODO"], ["DONE"]) diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index e814760..bb2fb76 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -50,7 +50,7 @@ def make_args(**overrides: object) -> FilterArgsStub: def test_build_query_text_filters_only() -> None: """Query text should include filters in command order.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] @@ -62,7 +62,7 @@ def test_build_query_text_filters_only() -> None: def test_build_query_text_with_ordering_and_slice() -> None: """Query text should append order stages then slice.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(filter_tags=["work"], order_by_timestamp_desc=True, offset=5, max_results=10) argv = [ @@ -86,7 +86,7 @@ def test_build_query_text_with_ordering_and_slice() -> None: def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: """timestamp-asc should keep items with no timestamp at the end.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(order_by_timestamp_asc=True) argv = ["org", "tasks", "list", "--order-by-timestamp-asc", "file.org"] @@ -102,7 +102,7 @@ def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: def test_build_query_text_with_priority_ordering() -> None: """priority ordering should sort by node priority value.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(order_by_priority=True) argv = ["org", "tasks", "list", "--order-by-priority", "file.org"] @@ -114,7 +114,7 @@ def test_build_query_text_with_priority_ordering() -> None: def test_build_query_text_with_property_filter() -> None: """Property filters should quote keys and values.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(filter_properties=["priority=A"]) argv = ["org", "stats", "all", "--filter-property", "priority=A", "file.org"] @@ -126,7 +126,7 @@ def test_build_query_text_with_property_filter() -> None: def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: """Built-in with-tags-as-category switch should add its query stage.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(with_tags_as_category=True) argv = ["org", "stats", "summary", "--with-tags-as-category", "file.org"] @@ -140,7 +140,7 @@ def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: def test_build_query_text_with_tags_as_category_default_appended_when_missing_in_argv() -> None: """Default with-tags-as-category should be appended when omitted from argv.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(with_tags_as_category=True, filter_tags=["work"]) argv = ["org", "stats", "summary", "--filter-tag", "work", "file.org"] @@ -155,7 +155,7 @@ def test_build_query_text_with_tags_as_category_default_appended_when_missing_in def test_build_query_text_with_filter_completed() -> None: """Completed filter should include repeated task completion states.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(filter_completed=True) argv = ["org", "tasks", "list", "--filter-completed", "file.org"] @@ -171,7 +171,7 @@ def test_build_query_text_with_filter_completed() -> None: def test_build_query_text_with_filter_not_completed() -> None: """Not-completed filter should include tasks with no done state.""" - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text args = make_args(filter_not_completed=True) argv = ["org", "tasks", "list", "--filter-not-completed", "file.org"] @@ -187,7 +187,7 @@ def test_build_query_text_with_filter_not_completed() -> None: def test_build_query_logs_query_before_compile(caplog: pytest.LogCaptureFixture) -> None: """build_query should log the query text before compilation.""" - from org.logic.filtering import build_query + from org.pipeline.query import build_query args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] @@ -203,7 +203,7 @@ def test_build_query_text_with_custom_filter_and_optional_arg( ) -> None: """Custom filter switches should bind $arg and support omitted values.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr( org.config.app, @@ -240,7 +240,7 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( ) -> None: """Custom arguments are embedded in query text and not added to context vars.""" import org.config.app - from org.logic.filtering import collect_custom_context_vars + from org.pipeline.query import collect_custom_context_vars monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) @@ -272,7 +272,7 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyPatch) -> None: """Custom enrichment stages should be applied before filters.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -301,7 +301,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( ) -> None: """Built-in and custom with stages should preserve CLI order.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -329,7 +329,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPatch) -> None: """Custom order-by switches should work even when built-in ordering is disabled.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -345,7 +345,7 @@ def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPa def test_load_and_process_data_logs_query_context(caplog: pytest.LogCaptureFixture) -> None: """Data loading should log the query execution context.""" - from org.logic.filtering import load_and_process_data + from org.pipeline.load import load_and_process_data fixture_path = str((Path(__file__).resolve().parents[1] / "fixtures" / "simple.org").resolve()) args = make_args(files=[fixture_path], offset=0, max_results=1) @@ -362,7 +362,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( ) -> None: """Built-in and custom ordering switches should follow CLI specification order.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -389,7 +389,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.MonkeyPatch) -> None: """Unknown prefixed custom switches should fail validation.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -407,7 +407,7 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( ) -> None: """Custom filters using $arg should require exactly one argument.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr( org.config.app, @@ -432,7 +432,7 @@ def test_build_query_text_custom_order_by_requires_exactly_one_argument( ) -> None: """Custom order-by switches using $arg should require exactly one argument.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) @@ -450,7 +450,7 @@ def test_build_query_text_custom_with_requires_exactly_one_argument( ) -> None: """Custom enrichment switches using $arg should require exactly one argument.""" import org.config.app - from org.logic.filtering import build_query_text + from org.pipeline.query import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -468,7 +468,7 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( ) -> None: """Required custom arguments should consume path-like values.""" import org.config.app - from org.logic.filtering import normalize_cli_files_for_custom_switches + from org.pipeline.query import normalize_cli_files_for_custom_switches monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -483,7 +483,7 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( def test_get_top_day_info_none() -> None: """get_top_day_info returns null with missing timeline.""" - from org.logic.filtering import get_top_day_info + from org.logic.stats import get_top_day_info result = get_top_day_info(None) diff --git a/tests/cli_common/test_top_tasks.py b/tests/cli_common/test_top_tasks.py index a4b0806..9e3ec71 100644 --- a/tests/cli_common/test_top_tasks.py +++ b/tests/cli_common/test_top_tasks.py @@ -2,7 +2,8 @@ from datetime import datetime -from org.logic.filtering import get_most_recent_timestamp, get_top_tasks +from org.logic.tasks import get_top_tasks +from org.logic.time import get_most_recent_timestamp from tests.conftest import node_from_org diff --git a/tests/cli_common/test_validation.py b/tests/cli_common/test_validation.py index 7a27f62..6a2cf70 100644 --- a/tests/cli_common/test_validation.py +++ b/tests/cli_common/test_validation.py @@ -8,10 +8,10 @@ import pytest import typer +from org.logic.time import parse_date_argument from org.logic.validation import ( GlobalArgs, StatsArgs, - parse_date_argument, parse_property_filter, validate_and_parse_keys, validate_global_arguments, diff --git a/tests/commands/tasks/test_tasks_list.py b/tests/commands/tasks/test_tasks_list.py index 827f04b..acf8ddb 100644 --- a/tests/commands/tasks/test_tasks_list.py +++ b/tests/commands/tasks/test_tasks_list.py @@ -22,7 +22,7 @@ from org.logic.edit import DocumentEditResult from org.logic.search import filter_nodes_by_search from org.logic.tasks import heading_locator -from org.serde.format import OutputFormat, OutputFormatError +from org.pipeline.format import OutputFormat, OutputFormatError from org.tui.bits import visual_len from tests.conftest import node_from_org diff --git a/tests/commands/test_agenda_command.py b/tests/commands/test_agenda_command.py index 3198fcc..ace1204 100644 --- a/tests/commands/test_agenda_command.py +++ b/tests/commands/test_agenda_command.py @@ -25,9 +25,9 @@ resolve_view_context, ) from org.commands.tasks.common import duration_to_org_text, parse_clock_duration -from org.logic.filtering import load_and_process_data from org.logic.tasks import detail_org_block, heading_locator from org.logic.time import advance_timestamp_by_repeater, local_now +from org.pipeline.load import load_and_process_data from org.tui.bits import setup_output diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index 66c8d20..12d6567 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -19,8 +19,8 @@ from org.commands.board import command as board_command from org.logic.archive import ArchiveLocation, ArchiveMoveResult, ArchiveTarget from org.logic.edit import DocumentEditResult -from org.logic.filtering import load_and_process_data from org.logic.tasks import heading_locator +from org.pipeline.load import load_and_process_data from org.tui.bits import setup_output from tests.conftest import node_from_org diff --git a/tests/commands/test_tasks_query_command.py b/tests/commands/test_tasks_query_command.py index c073c74..af16624 100644 --- a/tests/commands/test_tasks_query_command.py +++ b/tests/commands/test_tasks_query_command.py @@ -14,7 +14,7 @@ from org.cli import app from org.commands.tasks.query import TasksQueryArgs, _is_org_object, run_tasks_query -from org.serde.format import OutputFormat, OutputFormatError +from org.pipeline.format import OutputFormat, OutputFormatError FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "fixtures") @@ -95,8 +95,8 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: return ["alpha", "beta"] monkeypatch.setattr( - "org.commands.tasks.query.compile_query_text", - lambda _query: _fake_compiled_query, + "org.commands.tasks.query.compile_query_or_raise", + lambda _query, _error_builder: _fake_compiled_query, ) monkeypatch.setattr( "org.commands.tasks.query.load_root_data", @@ -121,8 +121,8 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: return ["alpha", 1, None] monkeypatch.setattr( - "org.commands.tasks.query.compile_query_text", - lambda _query: _fake_compiled_query, + "org.commands.tasks.query.compile_query_or_raise", + lambda _query, _error_builder: _fake_compiled_query, ) monkeypatch.setattr( "org.commands.tasks.query.load_root_data", @@ -147,8 +147,8 @@ def _fake_compiled_query(_stream: object, _context: object) -> list[object]: return [None] monkeypatch.setattr( - "org.commands.tasks.query.compile_query_text", - lambda _query: _fake_compiled_query, + "org.commands.tasks.query.compile_query_or_raise", + lambda _query, _error_builder: _fake_compiled_query, ) monkeypatch.setattr( "org.commands.tasks.query.load_root_data", diff --git a/tests/test_clean.py b/tests/test_clean.py index ac40938..4800abb 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -1,6 +1,6 @@ """Tests for the clean() function.""" -from org.logic.filtering import DEFAULT_EXCLUDE +from org.config.app import DEFAULT_EXCLUDE from org.logic.stats import Tag, TimeRange, clean diff --git a/tests/test_output_format.py b/tests/test_output_format.py index 069a9df..def7180 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -17,7 +17,7 @@ from org.commands.tasks import query as query_command from org.commands.tasks.list import command as tasks_list_command from org.logic.stats import AnalysisResult, Distribution, Group, Tag, TimeRange -from org.serde import format as output_format +from org.pipeline import format as output_format if TYPE_CHECKING: @@ -47,7 +47,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs return SimpleNamespace(returncode=0, stdout=b"# converted", stderr=b"pandoc warning text\n") - monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) caplog.set_level(logging.INFO, logger="org") markdown_text = output_format._org_to_pandoc_format("* TODO test", "markdown", []) @@ -67,7 +67,7 @@ def _fake_run(command: list[str], **kwargs: object) -> SimpleNamespace: seen["kwargs"] = kwargs return SimpleNamespace(returncode=0, stdout=b"converted", stderr=b"") - monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) rendered = output_format._org_to_pandoc_format( "* TODO test", @@ -94,7 +94,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs return SimpleNamespace(returncode=22, stdout=b"", stderr=b"Unknown output format\n") - monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) with pytest.raises(output_format.OutputFormatError, match="Unknown output format"): output_format._org_to_pandoc_format("* TODO test", "invalid-format", []) @@ -110,7 +110,7 @@ def _fake_run(*args: object, **kwargs: object) -> SimpleNamespace: del kwargs raise FileNotFoundError("pandoc not found") - monkeypatch.setattr("org.serde.format.subprocess.run", _fake_run) + monkeypatch.setattr("org.pipeline.format.subprocess.run", _fake_run) with pytest.raises(output_format.OutputFormatError, match="pandoc not found"): output_format._org_to_pandoc_format("* TODO test", "gfm", []) From ace5413e232f0b7f625ee2a7e3f3bb38ff824a31 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sat, 13 Jun 2026 22:03:12 +0200 Subject: [PATCH 22/27] Further code review cleanup. --- VIBES.org | 145 ++++++++++++++---------- src/org/commands/stats/all.py | 3 +- src/org/logic/edit.py | 27 +---- src/org/logic/stats.py | 13 +++ src/org/logic/tasks.py | 14 --- src/org/logic/time.py | 6 - src/org/pipeline/load.py | 11 ++ src/org/pipeline/query.py | 13 +-- src/org/tui/bits.py | 2 +- tests/cli_common/test_top_tasks.py | 43 ++++--- tests/commands/tasks/test_tasks_edit.py | 33 +----- 11 files changed, 147 insertions(+), 163 deletions(-) diff --git a/VIBES.org b/VIBES.org index 572a57d..8590925 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7653,7 +7653,10 @@ In file tests/cli_common/test_top_tasks.py around line 7, near the ~Merge~ #+end_example =get_most_recent_timestamp= should be in the time module. -**** TODO Code review comment +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] +:END: :PROPERTIES: :context: [[file:~/projects/software/org-cli/src/org/logic/edit.py::def _load_document_from_text(document_text: str, filename: str | None) -> Document: """Parse full document text and preserve original filename when present.""" @@ -7682,8 +7685,11 @@ def _load_document_from_text(document_text: str, filename: str | None) -> Docume #+end_example -This should be moved to the serde module. Ideally, one way to load a file should be used accross the whole app. -**** TODO Code review comment +This should be moved to the =org.pipeline.load=. Ideally, one way to load a file should be used accross the whole app. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] +:END: :PROPERTIES: :context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_top_day_info(time_range: TimeRange | None) -> tuple\[str, int\] | None: """Extract top day and its count from TimeRange.""" @@ -7708,8 +7714,48 @@ def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: #+end_example -This should be in the stats module. -**** TODO Code review comment +This should be in the =org.logic.stats= module. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] +:END: +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_top_tasks(nodes: list\[Heading\], max_results: int) -> list\[Heading\]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list\[tuple\[Heading, datetime\]\] = \[\] + for node in nodes: + timestamp = get_most_recent_timestamp(node) + if timestamp: + nodes_with_timestamps.append((node, timestamp)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x\[1\], reverse=True) + + return \[node for node, _ in sorted_nodes\[:max_results\]\]]] +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py + +#+begin_example + +def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list[tuple[Heading, datetime]] = [] + for node in nodes: + timestamp = get_most_recent_timestamp(node) + if timestamp: + nodes_with_timestamps.append((node, timestamp)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) + + return [node for node, _ in sorted_nodes[:max_results]] + +#+end_example + +This should be in the =org.logic.stats= module. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 21:51] +:END: :PROPERTIES: :context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def count_filter_values(value: list\[str\] | None) -> int: """Count filter values for append-style filters.""" @@ -7727,7 +7773,10 @@ def count_filter_values(value: list[str] | None) -> int: #+end_example This function makes no sense, just do this directly wherever this is invoked. -**** TODO Code review comment +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 21:52] +:END: :PROPERTIES: :context: [[file:~/projects/software/org-cli/src/org/logic/edit.py::def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: """Edit the original task document, preferring in-place file editing at task line.""" @@ -7744,8 +7793,11 @@ def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditRes #+end_example -This wrapper makes no sense. -**** TODO Code review comment +This wrapper makes no sense, please rename the functions and clean this up. +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 21:52] +:END: :PROPERTIES: :context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_most_recent_timestamp(node: Heading) -> datetime | None: """Get the most recent timestamp from a node.""" @@ -7764,41 +7816,10 @@ def get_most_recent_timestamp(node: Heading) -> datetime | None: #+end_example This is available in the =Heading= class as =latest_timestamp=, please remove this function and use the built-in way to fetch the timestamp. -**** TODO Code review comment -:PROPERTIES: -:context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def get_top_tasks(nodes: list\[Heading\], max_results: int) -> list\[Heading\]: - """Get top N nodes sorted by most recent timestamp.""" - nodes_with_timestamps: list\[tuple\[Heading, datetime\]\] = \[\] - for node in nodes: - timestamp = get_most_recent_timestamp(node) - if timestamp: - nodes_with_timestamps.append((node, timestamp)) - - sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x\[1\], reverse=True) - - return \[node for node, _ in sorted_nodes\[:max_results\]\]]] +**** DONE Code review comment +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 20:58] :END: - -Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py - -#+begin_example - -def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: - """Get top N nodes sorted by most recent timestamp.""" - nodes_with_timestamps: list[tuple[Heading, datetime]] = [] - for node in nodes: - timestamp = get_most_recent_timestamp(node) - if timestamp: - nodes_with_timestamps.append((node, timestamp)) - - sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) - - return [node for node, _ in sorted_nodes[:max_results]] - -#+end_example - -This should be in the stats module. -**** TODO Code review comment :PROPERTIES: :context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::def validate_custom_switches(argv: list\[str\], include_builtin_ordering: bool) -> None: """Validate prefixed custom switches against configured/built-in options.""" @@ -7839,8 +7860,6 @@ This should be in the stats module. raise _required_custom_arg_error(option)]] :END: -Filename: /home/k/projects/software/org-cli/src/org/logic/filtering.py - #+begin_example def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> None: @@ -7884,7 +7903,24 @@ def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> #+end_example Probably should be moved to the validation module. -**** TODO Code review comment +**** CANCELLED Code review comment +:PROPERTIES: +:context: [[file:~/projects/software/org-cli/src/org/logic/validation.py]] +:END: +:LOGBOOK: +- State "CANCELLED" from "TODO" [2026-06-13 Sat 20:59] \\ + Meh +:END: + +Filename: /home/k/projects/software/org-cli/src/org/logic/validation.py + +Validation module should be placed in =org.pipeline= package. + +**** CANCELLED Code review comment +:LOGBOOK: +- State "CANCELLED" from "TODO" [2026-06-13 Sat 20:40] \\ + Already done. +:END: :PROPERTIES: :context: [[file:~/projects/software/org-cli/src/org/logic/filtering.py::class DataLoadArgs(QueryBuildArgs, Protocol): """Protocol for loading and preprocessing data.""" @@ -8099,21 +8135,12 @@ def load_and_process_data( #+end_example -All of this should be moved to the serde module. -**** TODO Code review comment -:PROPERTIES: -:context: [[file:~/projects/software/org-cli/src/org/logic/validation.py]] +All of this should be moved to the =org.pipeline.load= module. +*** CANCELLED Use Textual toasts for status messages. +:LOGBOOK: +- State "CANCELLED" from [2026-06-13 Sat 21:02] \\ + Nah, these are annoying. :END: - -Filename: /home/k/projects/software/org-cli/src/org/logic/validation.py - -#+begin_example - -#+end_example - -Validation module should be placed in serde package. - -*** Use Textual toasts for status messages. *** Use Textual textarea for any org-mode syntax highlighting - Limit to just the interactive commands. - Potentially use it for code edits of the files? diff --git a/src/org/commands/stats/all.py b/src/org/commands/stats/all.py index 5ca04e6..e846f03 100644 --- a/src/org/commands/stats/all.py +++ b/src/org/commands/stats/all.py @@ -14,9 +14,8 @@ import org.config.app import org.logging from org.commands.stats.summary import SummaryDisplayConfig, format_tasks_summary -from org.logic.stats import AnalysisResult, Tag, TimeRange, analyze, clean +from org.logic.stats import AnalysisResult, Tag, TimeRange, analyze, clean, get_top_tasks from org.logic.stats import Group as TagGroup -from org.logic.tasks import get_top_tasks from org.logic.time import resolve_date_filters from org.logic.validation import validate_stats_arguments from org.pipeline.load import load_and_process_data diff --git a/src/org/logic/edit.py b/src/org/logic/edit.py index ef1d516..c7806bf 100644 --- a/src/org/logic/edit.py +++ b/src/org/logic/edit.py @@ -8,12 +8,13 @@ from typing import TYPE_CHECKING import click -import org_parser import typer +from org.pipeline.load import load_document_from_text + if TYPE_CHECKING: - from org_parser.document import Document, Heading + from org_parser.document import Heading def _editor_program() -> str: @@ -46,17 +47,6 @@ class DocumentEditResult: changed: bool -def _load_document_from_text(document_text: str, filename: str | None) -> Document: - """Parse full document text and preserve original filename when present.""" - try: - document = org_parser.loads(document_text) - except (TypeError, ValueError) as err: - raise typer.BadParameter(f"Edited document content is invalid: {err}") from err - - document.filename = "" if filename is None else filename - return document - - def _run_editor_at_line(filename: str, line: int) -> int: """Open one file in the configured editor at a given line.""" editor = _editor_program() @@ -85,9 +75,7 @@ def _write_document_text(path: Path, filename: str, document_text: str) -> None: raise typer.BadParameter(f"Permission denied for '{filename}'") from err -def attempt_edit_heading_subtree_in_external_editor( - heading: Heading, -) -> DocumentEditResult: +def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: """Edit one file-backed task subtree in the configured external editor.""" filename = heading.document.filename or None if filename is None: @@ -102,12 +90,7 @@ def attempt_edit_heading_subtree_in_external_editor( edited_text = _read_document_text(path, filename) if edited_text == original_text: return DocumentEditResult(changed=False) - _load_document_from_text(edited_text, filename) + load_document_from_text(edited_text, filename) return DocumentEditResult(changed=True) raise typer.BadParameter("Editor failed to open") - - -def edit_heading_subtree_in_external_editor(heading: Heading) -> DocumentEditResult: - """Edit the original task document, preferring in-place file editing at task line.""" - return attempt_edit_heading_subtree_in_external_editor(heading) diff --git a/src/org/logic/stats.py b/src/org/logic/stats.py index 912bd2f..c36c5df 100644 --- a/src/org/logic/stats.py +++ b/src/org/logic/stats.py @@ -140,6 +140,19 @@ def get_top_day_info(time_range: TimeRange | None) -> tuple[str, int] | None: return (top_day.isoformat(), max_count) +def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: + """Get top N nodes sorted by most recent timestamp.""" + nodes_with_timestamps: list[tuple[Heading, datetime]] = [] + for node in nodes: + latest_timestamp = node.latest_timestamp + if latest_timestamp is None: + continue + nodes_with_timestamps.append((node, latest_timestamp.start)) + + sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) + return [node for node, _ in sorted_nodes[:max_results]] + + def weekday_to_string(weekday: int) -> str: """Map Python weekday integer to capitalized day name.""" days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] diff --git a/src/org/logic/tasks.py b/src/org/logic/tasks.py index 3ba4534..3526b30 100644 --- a/src/org/logic/tasks.py +++ b/src/org/logic/tasks.py @@ -8,8 +8,6 @@ from org_parser.element import Repeat from org_parser.time import Timestamp -from org.logic.time import get_most_recent_timestamp - if TYPE_CHECKING: from datetime import datetime @@ -97,15 +95,3 @@ def detail_org_block(node: Heading) -> str: parts = [str(node)] parts.extend(detail_org_block(child) for child in node.children) return "".join(parts) - - -def get_top_tasks(nodes: list[Heading], max_results: int) -> list[Heading]: - """Get top N nodes sorted by most recent timestamp.""" - nodes_with_timestamps: list[tuple[Heading, datetime]] = [] - for node in nodes: - timestamp = get_most_recent_timestamp(node) - if timestamp is not None: - nodes_with_timestamps.append((node, timestamp)) - - sorted_nodes = sorted(nodes_with_timestamps, key=lambda x: x[1], reverse=True) - return [node for node, _ in sorted_nodes[:max_results]] diff --git a/src/org/logic/time.py b/src/org/logic/time.py index 8f499af..d837474 100644 --- a/src/org/logic/time.py +++ b/src/org/logic/time.py @@ -100,12 +100,6 @@ def extract_timestamp_any(node: Heading) -> list[datetime]: return [normalize_timestamp(t) for t in timestamps] -def get_most_recent_timestamp(node: Heading) -> datetime | None: - """Get the most recent timestamp from a node.""" - timestamps = extract_timestamp_any(node) - return max(timestamps) if timestamps else None - - def resolve_date_filters(args: object) -> tuple[datetime | None, datetime | None]: """Resolve date filter arguments into parsed datetime values.""" date_from_value = getattr(args, "filter_date_from", None) diff --git a/src/org/pipeline/load.py b/src/org/pipeline/load.py index a7f63e7..6a35ecd 100644 --- a/src/org/pipeline/load.py +++ b/src/org/pipeline/load.py @@ -31,6 +31,17 @@ logger = logging.getLogger("org") +def load_document_from_text(document_text: str, filename: str | None) -> Document: + """Parse full document text and preserve original filename when present.""" + try: + document = loads(document_text) + except (TypeError, ValueError) as err: + raise typer.BadParameter(f"Edited document content is invalid: {err}") from err + + document.filename = "" if filename is None else filename + return document + + def _read_org_file(name: str) -> str: """Read one org file and normalize unsupported time values.""" path = Path(name) diff --git a/src/org/pipeline/query.py b/src/org/pipeline/query.py index cb5f974..f4cf1b4 100644 --- a/src/org/pipeline/query.py +++ b/src/org/pipeline/query.py @@ -446,11 +446,6 @@ def extend_with_entries_with_defaults( return full_entries -def count_filter_values(value: list[str] | None) -> int: - """Count filter values for append-style filters.""" - return len(value) if value else 0 - - def extend_filter_order_with_defaults( filter_order: list[str | CustomStageInvocation], args: FilterArgs, @@ -463,10 +458,10 @@ def extend_filter_order_with_defaults( "--filter-repeats-below": 1 if args.filter_repeats_below is not None else 0, "--filter-date-from": 1 if args.filter_date_from is not None else 0, "--filter-date-until": 1 if args.filter_date_until is not None else 0, - "--filter-property": count_filter_values(args.filter_properties), - "--filter-tag": count_filter_values(args.filter_tags), - "--filter-heading": count_filter_values(args.filter_headings), - "--filter-body": count_filter_values(args.filter_bodies), + "--filter-property": len(args.filter_properties) if args.filter_properties else 0, + "--filter-tag": len(args.filter_tags) if args.filter_tags else 0, + "--filter-heading": len(args.filter_headings) if args.filter_headings else 0, + "--filter-body": len(args.filter_bodies) if args.filter_bodies else 0, "--filter-completed": 1 if args.filter_completed else 0, "--filter-not-completed": 1 if args.filter_not_completed else 0, } diff --git a/src/org/tui/bits.py b/src/org/tui/bits.py index 9179bf8..f8f5cb3 100644 --- a/src/org/tui/bits.py +++ b/src/org/tui/bits.py @@ -31,7 +31,7 @@ from rich.style import Style from rich.text import Text -from org.logic.tasks import get_top_tasks +from org.logic.stats import get_top_tasks from org.tui.color import ( bright_blue, bright_white, diff --git a/tests/cli_common/test_top_tasks.py b/tests/cli_common/test_top_tasks.py index 9e3ec71..dbcef82 100644 --- a/tests/cli_common/test_top_tasks.py +++ b/tests/cli_common/test_top_tasks.py @@ -2,25 +2,25 @@ from datetime import datetime -from org.logic.tasks import get_top_tasks -from org.logic.time import get_most_recent_timestamp +from org.logic.stats import get_top_tasks from tests.conftest import node_from_org -def test_get_most_recent_timestamp_with_closed() -> None: - """Test get_most_recent_timestamp with closed timestamp.""" +def test_heading_latest_timestamp_with_closed() -> None: + """Heading.latest_timestamp should expose closed timestamp when present.""" org_text = """* DONE Task CLOSED: [2023-10-15 Sun 14:00] """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp == datetime(2023, 10, 15, 14, 0) + assert latest_timestamp is not None + assert latest_timestamp.start == datetime(2023, 10, 15, 14, 0) -def test_get_most_recent_timestamp_with_repeats() -> None: - """Test get_most_recent_timestamp with repeated tasks.""" +def test_heading_latest_timestamp_with_repeats() -> None: + """Heading.latest_timestamp should expose the latest repeated timestamp.""" org_text = """* DONE Task :LOGBOOK: - State "DONE" from "TODO" [2023-10-10 Tue 09:00] @@ -30,32 +30,39 @@ def test_get_most_recent_timestamp_with_repeats() -> None: """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp == datetime(2023, 10, 15, 14, 0) + assert latest_timestamp is not None + assert latest_timestamp.start == datetime(2023, 10, 15, 14, 0) -def test_get_most_recent_timestamp_with_scheduled() -> None: - """Test get_most_recent_timestamp with scheduled timestamp.""" +def test_heading_latest_timestamp_with_scheduled() -> None: + """Heading.latest_timestamp should expose scheduled timestamp when present.""" org_text = """* TODO Task SCHEDULED: <2023-10-20 Fri 10:00> """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp == datetime(2023, 10, 20, 10, 0) + assert latest_timestamp is not None + assert latest_timestamp.start == datetime(2023, 10, 20, 10, 0) -def test_get_most_recent_timestamp_no_timestamp() -> None: - """Test get_most_recent_timestamp with no timestamp.""" +def test_heading_latest_timestamp_no_timestamp() -> None: + """Heading.latest_timestamp should be None when a task has no timestamps.""" org_text = """* TODO Task """ nodes = node_from_org(org_text) - timestamp = get_most_recent_timestamp(nodes[0]) + latest_timestamp = nodes[0].latest_timestamp - assert timestamp is None + assert latest_timestamp is None + + +def test_get_top_tasks_imports_from_stats() -> None: + """Top tasks helper should remain available from the stats module.""" + assert get_top_tasks([], 10) == [] def test_get_top_tasks_sorted_by_timestamp() -> None: diff --git a/tests/commands/tasks/test_tasks_edit.py b/tests/commands/tasks/test_tasks_edit.py index ed0b35e..80b6546 100644 --- a/tests/commands/tasks/test_tasks_edit.py +++ b/tests/commands/tasks/test_tasks_edit.py @@ -248,19 +248,6 @@ def test_edit_heading_subtree_errors_when_document_has_no_filename() -> None: editor_command.edit_heading_subtree_in_external_editor(heading) -def test_attempt_edit_heading_subtree_errors_when_document_has_no_filename() -> None: - """Non-prompting edit helper should reject tasks without a backing file.""" - root = org_parser.loads("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n") - heading = root.heading_by_id("task-1") - assert heading is not None - - with pytest.raises( - typer.BadParameter, - match=r"This task is not associated with a file and cannot be edited\.", - ): - editor_command.attempt_edit_heading_subtree_in_external_editor(heading) - - def test_edit_heading_subtree_errors_after_line_open_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -278,23 +265,6 @@ def test_edit_heading_subtree_errors_after_line_open_failure( editor_command.edit_heading_subtree_in_external_editor(heading) -def test_attempt_edit_heading_subtree_errors_after_line_open_failure( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Non-prompting edit helper should error after line-open failure.""" - source = tmp_path / "tasks.org" - source.write_text("* TODO Keep\n:PROPERTIES:\n:ID: task-1\n:END:\n", encoding="utf-8") - root = org_parser.load(str(source)) - heading = root.heading_by_id("task-1") - assert heading is not None - - monkeypatch.setattr(editor_command, "_run_editor_at_line", lambda _filename, _line: 7) - - with pytest.raises(typer.BadParameter, match="Editor failed to open"): - editor_command.attempt_edit_heading_subtree_in_external_editor(heading) - - def test_edit_heading_subtree_rejects_invalid_full_document_content( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -312,8 +282,7 @@ def _edit_file(filename: str, _line: int) -> int: monkeypatch.setattr(editor_command, "_run_editor_at_line", _edit_file) monkeypatch.setattr( - org_parser, - "loads", + "org.pipeline.load.loads", lambda _text: (_ for _ in ()).throw(ValueError("boom")), ) From 9b0adb68654d798776e100933986ebd3ce099293 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sat, 13 Jun 2026 23:15:48 +0200 Subject: [PATCH 23/27] Reorginised the query runner code --- VIBES.org | 16 ++++++- src/org/commands/agenda/ui.py | 2 +- src/org/commands/agenda/views.py | 4 +- src/org/commands/board/actions.py | 4 +- src/org/commands/tasks/capture/domain.py | 2 +- src/org/commands/tasks/common.py | 2 +- src/org/commands/tasks/find.py | 2 +- src/org/commands/tasks/query.py | 2 +- src/org/pipeline/load.py | 4 +- src/org/query/__init__.py | 1 + src/org/query/engine/__init__.py | 1 + .../{query_language => query/engine}/ast.py | 0 .../engine}/compiler.py | 6 +-- .../engine}/errors.py | 0 .../engine/interpreter.py} | 4 +- .../engine}/parser.py | 4 +- .../{pipeline/query.py => query/runner.py} | 16 ++----- src/org/query_language/__init__.py | 20 --------- tests/cli_common/test_filter_construction.py | 42 +++++++++---------- tests/test_query_language_compiler.py | 6 +-- tests/test_query_language_parser.py | 6 +-- tests/test_query_language_runtime.py | 14 ++++--- 22 files changed, 74 insertions(+), 84 deletions(-) create mode 100644 src/org/query/__init__.py create mode 100644 src/org/query/engine/__init__.py rename src/org/{query_language => query/engine}/ast.py (100%) rename src/org/{query_language => query/engine}/compiler.py (79%) rename src/org/{query_language => query/engine}/errors.py (100%) rename src/org/{query_language/runtime.py => query/engine/interpreter.py} (99%) rename src/org/{query_language => query/engine}/parser.py (99%) rename src/org/{pipeline/query.py => query/runner.py} (99%) delete mode 100644 src/org/query_language/__init__.py diff --git a/VIBES.org b/VIBES.org index 8590925..4aeab76 100644 --- a/VIBES.org +++ b/VIBES.org @@ -8145,16 +8145,25 @@ All of this should be moved to the =org.pipeline.load= module. - Limit to just the interactive commands. - Potentially use it for code edits of the files? **** Show a Textual modal when performing external edits, instead of suspending the app -*** TODO Reorganize the query execution engine code +*** DONE Reorganize the query execution engine code :PROPERTIES: :capture_time: [2026-06-13 Sat 00:04] :context: :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-13 Sat 23:13] +:END: Introduce a new package =org.query= and move the query-execution-related helpers in there: - Current =org.logic.query= should be moved to that package as =org.query.runner= - Current =org.query_language= package should be renamed to =org.query.engine= - The query compiler (=org.query.engine.compiler=) and execution engine (=org.query.engine.interpreter=) should be separated, with all the operator/function definitions also separated to their own module =org.query.engine.defs= + +*** Streamline query execution +- One way to run a query used by all commands. +- Error handling left to the caller +- Runner accepts a list of files, context and a set of query stages that are all piped together, it performs query building, compilation and execution. + *** TODO Streamline data processing. :PROPERTIES: :capture_time: [2026-06-12 Fri 23:54] @@ -8267,6 +8276,11 @@ In file src/org/commands/tasks/list/command.py around line 663, near the ~def re I absolutely hate this. Please refactor the =out= and =details= switches to be optional and check their existance that way. +*** TODO Extract filter/order/with handling from the query package and into pipeline. +:PROPERTIES: +:capture_time: [2026-06-13 Sat 23:09] +:context: [[file:~/projects/software/org-cli/src/org/query/runner.py::for arg_name, expected in expected_counts.items():]] +:END: ** TODO Agenda usability improvements :UX:Agenda: *** Agenda non-interactive output format - Should be adjusted to "look and feel" just like the interactive one. diff --git a/src/org/commands/agenda/ui.py b/src/org/commands/agenda/ui.py index 0c4468f..454a108 100644 --- a/src/org/commands/agenda/ui.py +++ b/src/org/commands/agenda/ui.py @@ -13,7 +13,7 @@ from rich.text import Text from org.logic.time import local_now, parse_date_argument, shift_datetimes_by_unit -from org.pipeline.query import execute_query_or_raise +from org.query.runner import execute_query_or_raise from org.tui.bits import ( heading_title_to_text, task_priority_to_text, diff --git a/src/org/commands/agenda/views.py b/src/org/commands/agenda/views.py index 8a101c8..d1679ab 100644 --- a/src/org/commands/agenda/views.py +++ b/src/org/commands/agenda/views.py @@ -8,11 +8,11 @@ import typer import org.config.app -from org.pipeline.query import compile_filter_order_query +from org.query.runner import compile_filter_order_query if TYPE_CHECKING: - from org.query_language.compiler import CompiledQuery + from org.query.engine.compiler import CompiledQuery from .command import AgendaArgs diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index 80cd66a..7d68d14 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -24,7 +24,7 @@ ) from org.logic.time import advance_timestamp_by_repeater, local_now from org.pipeline.load import load_and_process_data -from org.pipeline.query import compile_filter_order_query, execute_query_or_raise +from org.query.runner import compile_filter_order_query, execute_query_or_raise if TYPE_CHECKING: @@ -32,7 +32,7 @@ from org_parser.document import Document, Heading - from org.query_language.compiler import CompiledQuery + from org.query.engine.compiler import CompiledQuery from .command import BoardArgs diff --git a/src/org/commands/tasks/capture/domain.py b/src/org/commands/tasks/capture/domain.py index 22ad65a..49b558b 100644 --- a/src/org/commands/tasks/capture/domain.py +++ b/src/org/commands/tasks/capture/domain.py @@ -16,7 +16,7 @@ import org.config.app from org.commands.tasks.common import load_document, resolve_parent_heading, save_document from org.pipeline.format import DEFAULT_OUTPUT_THEME -from org.pipeline.query import compile_query_or_raise, execute_query_or_raise +from org.query.runner import compile_query_or_raise, execute_query_or_raise from org.tui.help import InteractiveHelpEntry diff --git a/src/org/commands/tasks/common.py b/src/org/commands/tasks/common.py index 13748dd..606175b 100644 --- a/src/org/commands/tasks/common.py +++ b/src/org/commands/tasks/common.py @@ -14,7 +14,7 @@ from org_parser.time import Timestamp import org.config.app -from org.pipeline.query import compile_query_or_raise, execute_query_or_raise +from org.query.runner import compile_query_or_raise, execute_query_or_raise if TYPE_CHECKING: diff --git a/src/org/commands/tasks/find.py b/src/org/commands/tasks/find.py index 2582ff2..37b8401 100644 --- a/src/org/commands/tasks/find.py +++ b/src/org/commands/tasks/find.py @@ -23,7 +23,7 @@ print_prepared_output, ) from org.pipeline.load import load_root_data -from org.pipeline.query import compile_query_or_raise, execute_query_or_raise +from org.query.runner import compile_query_or_raise, execute_query_or_raise from org.tui.bits import build_console, processing_status, setup_output diff --git a/src/org/commands/tasks/query.py b/src/org/commands/tasks/query.py index 51e2bfe..c702b5d 100644 --- a/src/org/commands/tasks/query.py +++ b/src/org/commands/tasks/query.py @@ -33,7 +33,7 @@ print_prepared_output, ) from org.pipeline.load import load_root_data -from org.pipeline.query import compile_query_or_raise, execute_query_or_raise +from org.query.runner import compile_query_or_raise, execute_query_or_raise from org.tui.bits import build_console, processing_status, setup_output diff --git a/src/org/pipeline/load.py b/src/org/pipeline/load.py index 6a35ecd..940e915 100644 --- a/src/org/pipeline/load.py +++ b/src/org/pipeline/load.py @@ -12,7 +12,8 @@ from org_parser.document import Heading from org.logic.validation import validate_and_parse_keys, validate_global_arguments -from org.pipeline.query import ( +from org.query.engine.errors import QueryParseError +from org.query.runner import ( QueryBuildArgs, build_query, collect_custom_context_vars, @@ -21,7 +22,6 @@ normalize_cli_files_for_custom_switches, validate_custom_switches, ) -from org.query_language import QueryParseError if TYPE_CHECKING: diff --git a/src/org/query/__init__.py b/src/org/query/__init__.py new file mode 100644 index 0000000..c09a6fb --- /dev/null +++ b/src/org/query/__init__.py @@ -0,0 +1 @@ +"""Query packages and runner helpers.""" diff --git a/src/org/query/engine/__init__.py b/src/org/query/engine/__init__.py new file mode 100644 index 0000000..8d1c6e9 --- /dev/null +++ b/src/org/query/engine/__init__.py @@ -0,0 +1 @@ +"""Query engine implementation.""" diff --git a/src/org/query_language/ast.py b/src/org/query/engine/ast.py similarity index 100% rename from src/org/query_language/ast.py rename to src/org/query/engine/ast.py diff --git a/src/org/query_language/compiler.py b/src/org/query/engine/compiler.py similarity index 79% rename from src/org/query_language/compiler.py rename to src/org/query/engine/compiler.py index b51c161..cfb45b7 100644 --- a/src/org/query_language/compiler.py +++ b/src/org/query/engine/compiler.py @@ -5,12 +5,12 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from org.query_language.parser import parse_query -from org.query_language.runtime import EvalContext, Stream, evaluate_expr +from org.query.engine.interpreter import EvalContext, Stream, evaluate_expr +from org.query.engine.parser import parse_query if TYPE_CHECKING: - from org.query_language.ast import Expr + from org.query.engine.ast import Expr type CompiledQuery = Callable[[Stream, EvalContext], Stream] diff --git a/src/org/query_language/errors.py b/src/org/query/engine/errors.py similarity index 100% rename from src/org/query_language/errors.py rename to src/org/query/engine/errors.py diff --git a/src/org/query_language/runtime.py b/src/org/query/engine/interpreter.py similarity index 99% rename from src/org/query_language/runtime.py rename to src/org/query/engine/interpreter.py index 635e8bf..0fda1ac 100644 --- a/src/org/query_language/runtime.py +++ b/src/org/query/engine/interpreter.py @@ -30,7 +30,7 @@ from org_parser.time import Clock, Timestamp from org.logic.stats import analyze as _analyze_nodes -from org.query_language.ast import ( +from org.query.engine.ast import ( AsBinding, BinaryOp, BoolLiteral, @@ -55,7 +55,7 @@ TupleExpr, Variable, ) -from org.query_language.errors import QueryRuntimeError +from org.query.engine.errors import QueryRuntimeError class Stream(list[object]): diff --git a/src/org/query_language/parser.py b/src/org/query/engine/parser.py similarity index 99% rename from src/org/query_language/parser.py rename to src/org/query/engine/parser.py index 9d05042..68a2d7d 100644 --- a/src/org/query_language/parser.py +++ b/src/org/query/engine/parser.py @@ -7,7 +7,7 @@ from parsy import ParseError, Parser, eof, forward_declaration, generate, regex, seq, string -from org.query_language.ast import ( +from org.query.engine.ast import ( AsBinding, BinaryOp, BoolLiteral, @@ -32,7 +32,7 @@ TupleExpr, Variable, ) -from org.query_language.errors import QueryParseError +from org.query.engine.errors import QueryParseError if TYPE_CHECKING: diff --git a/src/org/pipeline/query.py b/src/org/query/runner.py similarity index 99% rename from src/org/pipeline/query.py rename to src/org/query/runner.py index f4cf1b4..84d50cb 100644 --- a/src/org/pipeline/query.py +++ b/src/org/query/runner.py @@ -7,24 +7,16 @@ import re from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Protocol, cast +from typing import Protocol, cast import click import org.config.app from org.logic.time import parse_date_argument from org.logic.validation import parse_property_filter -from org.query_language import ( - EvalContext, - QueryParseError, - QueryRuntimeError, - Stream, - compile_query_text, -) - - -if TYPE_CHECKING: - from org.query_language.compiler import CompiledQuery +from org.query.engine.compiler import CompiledQuery, compile_query_text +from org.query.engine.errors import QueryParseError, QueryRuntimeError +from org.query.engine.interpreter import EvalContext, Stream logger = logging.getLogger("org") diff --git a/src/org/query_language/__init__.py b/src/org/query_language/__init__.py deleted file mode 100644 index 8ce9139..0000000 --- a/src/org/query_language/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Public API for query language parser/compiler/runtime.""" - -from org.query_language.compiler import CompiledQuery, compile_expr, compile_query_text -from org.query_language.errors import QueryLanguageError, QueryParseError, QueryRuntimeError -from org.query_language.parser import parse_query -from org.query_language.runtime import EvalContext, Stream, evaluate_expr - - -__all__ = [ - "CompiledQuery", - "EvalContext", - "QueryLanguageError", - "QueryParseError", - "QueryRuntimeError", - "Stream", - "compile_expr", - "compile_query_text", - "evaluate_expr", - "parse_query", -] diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index bb2fb76..831ccaf 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -50,7 +50,7 @@ def make_args(**overrides: object) -> FilterArgsStub: def test_build_query_text_filters_only() -> None: """Query text should include filters in command order.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] @@ -62,7 +62,7 @@ def test_build_query_text_filters_only() -> None: def test_build_query_text_with_ordering_and_slice() -> None: """Query text should append order stages then slice.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(filter_tags=["work"], order_by_timestamp_desc=True, offset=5, max_results=10) argv = [ @@ -86,7 +86,7 @@ def test_build_query_text_with_ordering_and_slice() -> None: def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: """timestamp-asc should keep items with no timestamp at the end.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(order_by_timestamp_asc=True) argv = ["org", "tasks", "list", "--order-by-timestamp-asc", "file.org"] @@ -102,7 +102,7 @@ def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: def test_build_query_text_with_priority_ordering() -> None: """priority ordering should sort by node priority value.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(order_by_priority=True) argv = ["org", "tasks", "list", "--order-by-priority", "file.org"] @@ -114,7 +114,7 @@ def test_build_query_text_with_priority_ordering() -> None: def test_build_query_text_with_property_filter() -> None: """Property filters should quote keys and values.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(filter_properties=["priority=A"]) argv = ["org", "stats", "all", "--filter-property", "priority=A", "file.org"] @@ -126,7 +126,7 @@ def test_build_query_text_with_property_filter() -> None: def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: """Built-in with-tags-as-category switch should add its query stage.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(with_tags_as_category=True) argv = ["org", "stats", "summary", "--with-tags-as-category", "file.org"] @@ -140,7 +140,7 @@ def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: def test_build_query_text_with_tags_as_category_default_appended_when_missing_in_argv() -> None: """Default with-tags-as-category should be appended when omitted from argv.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(with_tags_as_category=True, filter_tags=["work"]) argv = ["org", "stats", "summary", "--filter-tag", "work", "file.org"] @@ -155,7 +155,7 @@ def test_build_query_text_with_tags_as_category_default_appended_when_missing_in def test_build_query_text_with_filter_completed() -> None: """Completed filter should include repeated task completion states.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(filter_completed=True) argv = ["org", "tasks", "list", "--filter-completed", "file.org"] @@ -171,7 +171,7 @@ def test_build_query_text_with_filter_completed() -> None: def test_build_query_text_with_filter_not_completed() -> None: """Not-completed filter should include tasks with no done state.""" - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text args = make_args(filter_not_completed=True) argv = ["org", "tasks", "list", "--filter-not-completed", "file.org"] @@ -187,7 +187,7 @@ def test_build_query_text_with_filter_not_completed() -> None: def test_build_query_logs_query_before_compile(caplog: pytest.LogCaptureFixture) -> None: """build_query should log the query text before compilation.""" - from org.pipeline.query import build_query + from org.query.runner import build_query args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] @@ -203,7 +203,7 @@ def test_build_query_text_with_custom_filter_and_optional_arg( ) -> None: """Custom filter switches should bind $arg and support omitted values.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr( org.config.app, @@ -240,7 +240,7 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( ) -> None: """Custom arguments are embedded in query text and not added to context vars.""" import org.config.app - from org.pipeline.query import collect_custom_context_vars + from org.query.runner import collect_custom_context_vars monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) @@ -272,7 +272,7 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyPatch) -> None: """Custom enrichment stages should be applied before filters.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -301,7 +301,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( ) -> None: """Built-in and custom with stages should preserve CLI order.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -329,7 +329,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPatch) -> None: """Custom order-by switches should work even when built-in ordering is disabled.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -362,7 +362,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( ) -> None: """Built-in and custom ordering switches should follow CLI specification order.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -389,7 +389,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.MonkeyPatch) -> None: """Unknown prefixed custom switches should fail validation.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -407,7 +407,7 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( ) -> None: """Custom filters using $arg should require exactly one argument.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr( org.config.app, @@ -432,7 +432,7 @@ def test_build_query_text_custom_order_by_requires_exactly_one_argument( ) -> None: """Custom order-by switches using $arg should require exactly one argument.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) @@ -450,7 +450,7 @@ def test_build_query_text_custom_with_requires_exactly_one_argument( ) -> None: """Custom enrichment switches using $arg should require exactly one argument.""" import org.config.app - from org.pipeline.query import build_query_text + from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -468,7 +468,7 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( ) -> None: """Required custom arguments should consume path-like values.""" import org.config.app - from org.pipeline.query import normalize_cli_files_for_custom_switches + from org.query.runner import normalize_cli_files_for_custom_switches monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) diff --git a/tests/test_query_language_compiler.py b/tests/test_query_language_compiler.py index 54534e6..9b7263c 100644 --- a/tests/test_query_language_compiler.py +++ b/tests/test_query_language_compiler.py @@ -2,9 +2,9 @@ from __future__ import annotations -from org.query_language import EvalContext, Stream, compile_query_text -from org.query_language.ast import NumberLiteral -from org.query_language.compiler import compile_expr +from org.query.engine.ast import NumberLiteral +from org.query.engine.compiler import compile_expr, compile_query_text +from org.query.engine.interpreter import EvalContext, Stream def test_compile_expr_returns_executable_callable() -> None: diff --git a/tests/test_query_language_parser.py b/tests/test_query_language_parser.py index 1a1d686..c37e4ad 100644 --- a/tests/test_query_language_parser.py +++ b/tests/test_query_language_parser.py @@ -4,8 +4,7 @@ import pytest -from org.query_language import parse_query -from org.query_language.ast import ( +from org.query.engine.ast import ( AsBinding, BinaryOp, DictAssignment, @@ -22,7 +21,8 @@ StringLiteral, Variable, ) -from org.query_language.errors import QueryParseError +from org.query.engine.errors import QueryParseError +from org.query.engine.parser import parse_query @pytest.mark.parametrize( diff --git a/tests/test_query_language_runtime.py b/tests/test_query_language_runtime.py index 0629633..3f2bf4f 100644 --- a/tests/test_query_language_runtime.py +++ b/tests/test_query_language_runtime.py @@ -13,8 +13,10 @@ from org_parser.text import RichText from org_parser.time import Clock, Timestamp -import org.query_language.runtime as runtime_module -from org.query_language import EvalContext, QueryRuntimeError, Stream, compile_query_text +import org.query.engine.interpreter as runtime_module +from org.query.engine.compiler import compile_query_text +from org.query.engine.errors import QueryRuntimeError +from org.query.engine.interpreter import EvalContext, Stream from tests.conftest import node_from_org @@ -1222,8 +1224,8 @@ def test_runtime_sort_by_all_null_keys_preserves_input_order() -> None: def test_runtime_unsupported_expression_type_raises_runtime_error() -> None: """Evaluating unknown AST nodes should raise runtime error.""" - from org.query_language.ast import Expr - from org.query_language.compiler import compile_expr + from org.query.engine.ast import Expr + from org.query.engine.compiler import compile_expr compiled = compile_expr(Expr()) with pytest.raises(QueryRuntimeError): @@ -1232,8 +1234,8 @@ def test_runtime_unsupported_expression_type_raises_runtime_error() -> None: def test_runtime_unsupported_function_name_raises_runtime_error() -> None: """Unknown function names in AST should raise runtime error.""" - from org.query_language.ast import FunctionCall - from org.query_language.compiler import compile_expr + from org.query.engine.ast import FunctionCall + from org.query.engine.compiler import compile_expr compiled = compile_expr(FunctionCall("unknown", None)) with pytest.raises(QueryRuntimeError): From acdbb5e97675c3853964890ffd4d83c3b996f3b9 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 14 Jun 2026 11:15:18 +0200 Subject: [PATCH 24/27] Split query runner from CLI filters processing --- VIBES.org | 8 +- src/org/config/cli.py | 677 ++++++++++++++++++ src/org/pipeline/load.py | 21 +- src/org/query/runner.py | 687 +------------------ tests/cli_common/test_filter_construction.py | 99 ++- 5 files changed, 752 insertions(+), 740 deletions(-) create mode 100644 src/org/config/cli.py diff --git a/VIBES.org b/VIBES.org index 4aeab76..b38e392 100644 --- a/VIBES.org +++ b/VIBES.org @@ -8276,11 +8276,17 @@ In file src/org/commands/tasks/list/command.py around line 663, near the ~def re I absolutely hate this. Please refactor the =out= and =details= switches to be optional and check their existance that way. -*** TODO Extract filter/order/with handling from the query package and into pipeline. +*** DONE Extract filter/order/with handling from the query package and into config. :PROPERTIES: :capture_time: [2026-06-13 Sat 23:09] :context: [[file:~/projects/software/org-cli/src/org/query/runner.py::for arg_name, expected in expected_counts.items():]] :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-06-14 Sun 11:14] +:END: + +All the functions dealing with CLI argument processing, ordering of filters, etc should be moved to a new module =org.config.cli=. The =org.query= package should only contain query building and execution. The =org.config= package is a suitable place for the default pipeline configuration and user-provided overrides in the form of CLI switches. + ** TODO Agenda usability improvements :UX:Agenda: *** Agenda non-interactive output format - Should be adjusted to "look and feel" just like the interactive one. diff --git a/src/org/config/cli.py b/src/org/config/cli.py new file mode 100644 index 0000000..400ae86 --- /dev/null +++ b/src/org/config/cli.py @@ -0,0 +1,677 @@ +"""CLI-driven query pipeline configuration and stage planning.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Protocol + +import click + +import org.config.app +from org.logic.time import parse_date_argument +from org.logic.validation import parse_property_filter +from org.query.runner import build_custom_stage + + +FILTER_OPTIONS_WITH_VALUE = { + "--filter-priority", + "--filter-level", + "--filter-repeats-above", + "--filter-repeats-below", + "--filter-date-from", + "--filter-date-until", + "--filter-property", + "--filter-tag", + "--filter-heading", + "--filter-body", +} + +FILTER_OPTIONS_FLAGS = { + "--filter-completed", + "--filter-not-completed", +} + +WITH_OPTIONS_FLAGS = { + "--with-tags-as-category", +} + +ORDER_BY_OPTION_TO_VALUE = { + "--order-by-priority": "priority", + "--order-by-level": "level", + "--order-by-file-order": "file-order", + "--order-by-file-order-reversed": "file-order-reversed", + "--order-by-timestamp-asc": "timestamp-asc", + "--order-by-timestamp-desc": "timestamp-desc", +} + +ORDER_BY_DEST_TO_VALUE = { + "order_by_priority": "priority", + "order_by_level": "level", + "order_by_file_order": "file-order", + "order_by_file_order_reversed": "file-order-reversed", + "order_by_timestamp_asc": "timestamp-asc", + "order_by_timestamp_desc": "timestamp-desc", +} + + +class FilterArgs(Protocol): + """Protocol for filter-related CLI arguments.""" + + filter_priority: str | None + filter_level: int | None + filter_repeats_above: int | None + filter_repeats_below: int | None + filter_date_from: str | None + filter_date_until: str | None + filter_properties: list[str] | None + filter_tags: list[str] | None + filter_headings: list[str] | None + filter_bodies: list[str] | None + filter_completed: bool + filter_not_completed: bool + + +class WithArgs(Protocol): + """Protocol for built-in enrichment CLI arguments.""" + + with_tags_as_category: bool + + +class QueryBuildArgs(FilterArgs, WithArgs, Protocol): + """Protocol for arguments used to build query pipelines.""" + + +@dataclass(frozen=True) +class CustomStageInvocation: + """One custom switch occurrence parsed from CLI arguments.""" + + name: str + query: str + arg_value: object + + +def _extract_option_token(token: str) -> str: + """Return option token without inline assignment suffix.""" + if token.startswith("--") and "=" in token: + return token.split("=", 1)[0] + return token + + +def _custom_option_name(option: str, prefix: str) -> str | None: + """Extract custom option name after a known prefix.""" + option_prefix = f"--{prefix}-" + if not option.startswith(option_prefix): + return None + return option[len(option_prefix) :] + + +def _query_uses_arg(query: str) -> bool: + """Return True when query contains the `$arg` variable.""" + return bool(re.search(r"\$arg\b", query)) + + +def _resolve_custom_option(option: str) -> tuple[str, bool] | None: + """Resolve configured custom option to (query, requires_arg).""" + filter_name = _custom_option_name(option, "filter") + if filter_name is not None: + query = org.config.app.CONFIG_CUSTOM_FILTERS.get(filter_name) + if query is not None: + return (query, _query_uses_arg(query)) + + order_name = _custom_option_name(option, "order-by") + if order_name is not None: + query = org.config.app.CONFIG_CUSTOM_ORDER_BY.get(order_name) + if query is not None: + return (query, _query_uses_arg(query)) + + with_name = _custom_option_name(option, "with") + if with_name is not None: + query = org.config.app.CONFIG_CUSTOM_WITH.get(with_name) + if query is not None: + return (query, _query_uses_arg(query)) + + return None + + +def _required_custom_arg_error(option: str) -> click.BadParameter: + """Return the standard required custom argument error.""" + return click.BadParameter(f"{option} requires exactly one argument") + + +def normalize_cli_files_for_custom_switches(files: list[str] | None) -> list[str] | None: + """Remove configured custom switch tokens from FILE argument values.""" + if files is None: + return None + + normalized: list[str] = [] + index = 0 + while index < len(files): + token = files[index] + option = _extract_option_token(token) + custom_option = _resolve_custom_option(option) + if custom_option is None: + normalized.append(token) + index += 1 + continue + + _, requires_arg = custom_option + + if token.startswith(f"{option}="): + index += 1 + continue + + if requires_arg: + next_index = index + 1 + if next_index >= len(files): + raise _required_custom_arg_error(option) + + next_token = files[next_index] + if next_token.startswith("-"): + raise _required_custom_arg_error(option) + index += 2 + continue + + index += 1 + + return normalized + + +def _consume_custom_optional_arg( + argv: list[str], + index: int, + option: str, + requires_arg: bool, +) -> tuple[str | None, int]: + """Consume custom argument token for one custom switch occurrence.""" + if not requires_arg: + return (None, index) + + next_index = index + 1 + if next_index >= len(argv): + raise _required_custom_arg_error(option) + + next_token = argv[next_index] + if next_token.startswith("-"): + raise _required_custom_arg_error(option) + + return (next_token, next_index) + + +def _coerce_custom_arg_value(value: str | None) -> object: + """Parse optional custom argument into runtime query value.""" + if value is None: + return None + + lowered = value.lower() + value_map: dict[str, object] = { + "null": None, + "true": True, + "false": False, + } + if lowered in value_map: + return value_map[lowered] + + if re.fullmatch(r"-?\d+", value): + return int(value) + if re.fullmatch(r"-?\d+\.\d+", value): + return float(value) + return value + + +def _build_custom_invocation( + *, + name: str, + query: str, + raw_arg: str | None, +) -> CustomStageInvocation: + """Build one parsed custom invocation with typed context binding.""" + return CustomStageInvocation( + name=name, + query=query, + arg_value=_coerce_custom_arg_value(raw_arg), + ) + + +def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> None: + """Validate prefixed custom switches against configured/built-in options.""" + builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() + allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( + {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, + ) + allowed_order_options = builtin_order_options.union( + {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, + ) + allowed_with_options = WITH_OPTIONS_FLAGS.union( + {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, + ) + + for index, token in enumerate(argv): + option = _extract_option_token(token) + if option.startswith("--filter-") and option not in allowed_filter_options: + raise click.NoSuchOption(option) + if option.startswith("--order-by-") and option not in allowed_order_options: + raise click.NoSuchOption(option) + if option.startswith("--with-") and option not in allowed_with_options: + raise click.NoSuchOption(option) + + custom_option = _resolve_custom_option(option) + if custom_option is None: + continue + + _, requires_arg = custom_option + if not requires_arg or token.startswith(f"{option}="): + continue + + next_index = index + 1 + if next_index >= len(argv): + raise _required_custom_arg_error(option) + + next_token = argv[next_index] + if next_token.startswith("-"): + raise _required_custom_arg_error(option) + + +def parse_filter_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: + """Parse built-in and custom filter switch occurrences in argv order.""" + entries: list[str | CustomStageInvocation] = [] + index = 0 + builtins = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS) + while index < len(argv): + token = argv[index] + option = _extract_option_token(token) + + if option in FILTER_OPTIONS_WITH_VALUE or option in FILTER_OPTIONS_FLAGS: + entries.append(option) + index += 1 + continue + + name = _custom_option_name(option, "filter") + if name is None or name not in org.config.app.CONFIG_CUSTOM_FILTERS or option in builtins: + index += 1 + continue + + query = org.config.app.CONFIG_CUSTOM_FILTERS[name] + requires_arg = _query_uses_arg(query) + if token.startswith(f"{option}="): + entries.append( + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), + ) + index += 1 + continue + + custom_arg, consumed_index = _consume_custom_optional_arg( + argv, + index, + option, + requires_arg, + ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) + index = consumed_index + 1 + + return entries + + +def parse_order_entries_from_argv( + argv: list[str], + include_builtin_ordering: bool, +) -> list[str | CustomStageInvocation]: + """Parse built-in and custom ordering switch occurrences in argv order.""" + entries: list[str | CustomStageInvocation] = [] + index = 0 + builtin_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() + while index < len(argv): + token = argv[index] + option = _extract_option_token(token) + + builtin_value = ORDER_BY_OPTION_TO_VALUE.get(option) + if builtin_value is not None and include_builtin_ordering: + entries.append(builtin_value) + index += 1 + continue + + name = _custom_option_name(option, "order-by") + if ( + name is None + or name not in org.config.app.CONFIG_CUSTOM_ORDER_BY + or option in builtin_options + ): + index += 1 + continue + + query = org.config.app.CONFIG_CUSTOM_ORDER_BY[name] + requires_arg = _query_uses_arg(query) + if token.startswith(f"{option}="): + entries.append( + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), + ) + index += 1 + continue + + custom_arg, consumed_index = _consume_custom_optional_arg( + argv, + index, + option, + requires_arg, + ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) + index = consumed_index + 1 + + return entries + + +def parse_with_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: + """Parse built-in and custom enrichment switch occurrences in argv order.""" + entries: list[str | CustomStageInvocation] = [] + index = 0 + while index < len(argv): + token = argv[index] + option = _extract_option_token(token) + if option in WITH_OPTIONS_FLAGS: + entries.append(option) + index += 1 + continue + + name = _custom_option_name(option, "with") + if name is None or name not in org.config.app.CONFIG_CUSTOM_WITH: + index += 1 + continue + + query = org.config.app.CONFIG_CUSTOM_WITH[name] + requires_arg = _query_uses_arg(query) + if token.startswith(f"{option}="): + entries.append( + _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), + ) + index += 1 + continue + + custom_arg, consumed_index = _consume_custom_optional_arg( + argv, + index, + option, + requires_arg, + ) + entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) + index = consumed_index + 1 + + return entries + + +def extend_with_entries_with_defaults( + with_entries: list[str | CustomStageInvocation], + args: WithArgs, +) -> list[str | CustomStageInvocation]: + """Extend with-entry order to include config-provided built-in enrichments.""" + expected_counts = {"--with-tags-as-category": 1 if args.with_tags_as_category else 0} + full_entries = list(with_entries) + for option_name, expected in expected_counts.items(): + existing = sum(1 for entry in full_entries if entry == option_name) + missing = expected - existing + if missing > 0: + full_entries.extend([option_name] * missing) + return full_entries + + +def extend_filter_order_with_defaults( + filter_order: list[str | CustomStageInvocation], + args: FilterArgs, +) -> list[str | CustomStageInvocation]: + """Extend filter order to include config-provided filters.""" + expected_counts = { + "--filter-priority": 1 if args.filter_priority is not None else 0, + "--filter-level": 1 if args.filter_level is not None else 0, + "--filter-repeats-above": 1 if args.filter_repeats_above is not None else 0, + "--filter-repeats-below": 1 if args.filter_repeats_below is not None else 0, + "--filter-date-from": 1 if args.filter_date_from is not None else 0, + "--filter-date-until": 1 if args.filter_date_until is not None else 0, + "--filter-property": len(args.filter_properties) if args.filter_properties else 0, + "--filter-tag": len(args.filter_tags) if args.filter_tags else 0, + "--filter-heading": len(args.filter_headings) if args.filter_headings else 0, + "--filter-body": len(args.filter_bodies) if args.filter_bodies else 0, + "--filter-completed": 1 if args.filter_completed else 0, + "--filter-not-completed": 1 if args.filter_not_completed else 0, + } + + full_order = list(filter_order) + for arg_name, expected in expected_counts.items(): + existing = sum(1 for value in full_order if value == arg_name) + missing = expected - existing + if missing > 0: + full_order.extend([arg_name] * missing) + return full_order + + +def _quote_string(value: str) -> str: + """Quote a value as query-language string literal.""" + return json.dumps(value) + + +def _timestamp_source_literal(value: str, arg_name: str) -> str: + """Quote one parsed date argument as org timestamp source.""" + parsed = parse_date_argument(value, arg_name).replace(second=0, microsecond=0) + return _quote_string(f"<{parsed:%Y-%m-%d %a %H:%M}>") + + +def _simple_filter_stage(arg_name: str, args: FilterArgs) -> str | None: + """Build query stage for non-indexed filter options.""" + stage: str | None = None + if arg_name == "--filter-priority" and args.filter_priority is not None: + stage = f"select(.priority == {_quote_string(args.filter_priority)})" + elif arg_name == "--filter-level" and args.filter_level is not None: + stage = f"select(.level == {args.filter_level})" + elif arg_name == "--filter-repeats-above" and args.filter_repeats_above is not None: + stage = f"select(.repeats | length > {args.filter_repeats_above})" + elif arg_name == "--filter-repeats-below" and args.filter_repeats_below is not None: + stage = f"select(.repeats | length < {args.filter_repeats_below})" + elif arg_name == "--filter-date-from" and args.filter_date_from is not None: + stage = ( + "select(.repeats + .deadline + .closed + .scheduled " + f"| max >= timestamp({_timestamp_source_literal(args.filter_date_from, arg_name)}))" + ) + elif arg_name == "--filter-date-until" and args.filter_date_until is not None: + stage = ( + "select(.repeats + .deadline + .closed + .scheduled " + f"| max <= timestamp({_timestamp_source_literal(args.filter_date_until, arg_name)}))" + ) + elif arg_name == "--filter-completed" and args.filter_completed: + stage = ( + "select(if .repeats | length > 0" + " then .repeats | map(.is_completed) + [.is_completed] | any" + " else .is_completed)" + " | .repeats = [.repeats[] | select(.is_completed)]; . " + ) + elif arg_name == "--filter-not-completed" and args.filter_not_completed: + stage = ( + "select(if .repeats | length > 0" + " then not(.repeats | map(.is_completed) + [.is_completed] | any)" + " else not(.is_completed))" + " | .repeats = [.repeats[] | select(not(.is_completed))]; . " + ) + return stage + + +def _indexed_filter_stage( + arg_name: str, + args: FilterArgs, + index_trackers: dict[str, int], +) -> str | None: + """Build query stage for indexed multi-value filter options.""" + if ( + arg_name == "--filter-property" + and args.filter_properties + and index_trackers["property"] < len(args.filter_properties) + ): + property_name, property_value = parse_property_filter( + args.filter_properties[index_trackers["property"]], + ) + index_trackers["property"] += 1 + return ( + f"select(.properties[{_quote_string(property_name)}] == " + f"{_quote_string(property_value)})" + ) + + if ( + arg_name == "--filter-tag" + and args.filter_tags + and index_trackers["tag"] < len(args.filter_tags) + ): + pattern = args.filter_tags[index_trackers["tag"]] + index_trackers["tag"] += 1 + return f"select(.tags[] matches {_quote_string(pattern)})" + + if ( + arg_name == "--filter-heading" + and args.filter_headings + and index_trackers["heading"] < len(args.filter_headings) + ): + pattern = args.filter_headings[index_trackers["heading"]] + index_trackers["heading"] += 1 + return f"select(.title_text matches {_quote_string(pattern)})" + + if ( + arg_name == "--filter-body" + and args.filter_bodies + and index_trackers["body"] < len(args.filter_bodies) + ): + pattern = f"(?m){args.filter_bodies[index_trackers['body']]}" + index_trackers["body"] += 1 + return f"select(.body_text matches {_quote_string(pattern)})" + + return None + + +def _filter_stage(arg_name: str, args: FilterArgs, index_trackers: dict[str, int]) -> str | None: + """Build one query stage for a filter option occurrence.""" + simple_stage = _simple_filter_stage(arg_name, args) + if simple_stage is not None: + return simple_stage + return _indexed_filter_stage(arg_name, args, index_trackers) + + +def build_filter_stages( + args: FilterArgs, + filter_order: list[str | CustomStageInvocation], +) -> list[str]: + """Build query stages for filter pipeline.""" + filter_stages: list[str] = [] + index_trackers = {"property": 0, "tag": 0, "heading": 0, "body": 0} + for entry in filter_order: + if isinstance(entry, CustomStageInvocation): + filter_stages.append(build_custom_stage(entry.query, entry.arg_value)) + continue + stage = _filter_stage(entry, args, index_trackers) + if stage is not None: + filter_stages.append(stage) + return filter_stages + + +def extend_order_values_with_defaults(order_values: list[str], args: object) -> list[str]: + """Append config-provided orderings not present in argv order.""" + expected_counts: dict[str, int] = dict.fromkeys(ORDER_BY_DEST_TO_VALUE.values(), 0) + for dest_name, value in ORDER_BY_DEST_TO_VALUE.items(): + count = getattr(args, dest_name, 0) + if isinstance(count, bool): + expected_counts[value] += int(count) + elif isinstance(count, int) and count > 0: + expected_counts[value] += count + + full_order = list(order_values) + for value, expected in expected_counts.items(): + existing = full_order.count(value) + missing = expected - existing + if missing > 0: + full_order.extend([value] * missing) + return full_order + + +def _builtin_order_stages(value: str) -> list[str]: + """Build query stages for one built-in ordering value.""" + timestamp_key_expr = ".repeats + .deadline + .closed + .scheduled | max" + order_stages: dict[str, list[str]] = { + "file-order": ["."], + "file-order-reversed": ["reverse"], + "priority": ["sort_by(.priority)"], + "level": ["sort_by(.level)"], + "timestamp-asc": [ + f"sort_by({timestamp_key_expr})", + "reverse", + f"sort_by(({timestamp_key_expr}) != null)", + ], + "timestamp-desc": [f"sort_by({timestamp_key_expr})"], + } + return order_stages.get(value, []) + + +def build_order_stages(args: object, argv: list[str], include_builtin_ordering: bool) -> list[str]: + """Build query stages for ordering pipeline.""" + order_entries = parse_order_entries_from_argv(argv, include_builtin_ordering) + order_values: list[str | CustomStageInvocation] + if include_builtin_ordering: + builtin_order_values = [entry for entry in order_entries if isinstance(entry, str)] + expected_builtins = extend_order_values_with_defaults(builtin_order_values, args) + remaining_builtin_counts: dict[str, int] = {} + for value in builtin_order_values: + remaining_builtin_counts[value] = remaining_builtin_counts.get(value, 0) + 1 + + missing_builtins: list[str] = [] + for value in expected_builtins: + remaining = remaining_builtin_counts.get(value, 0) + if remaining > 0: + remaining_builtin_counts[value] = remaining - 1 + continue + missing_builtins.append(value) + + order_values = [*order_entries, *missing_builtins] + if not order_values: + order_values = ["timestamp-desc"] + else: + order_values = list(order_entries) + + order_stages: list[str] = [] + for order_value in order_values: + if isinstance(order_value, CustomStageInvocation): + order_stages.append(build_custom_stage(order_value.query, order_value.arg_value)) + continue + order_stages.extend(_builtin_order_stages(order_value)) + return order_stages + + +def build_with_stages(args: WithArgs, argv: list[str]) -> list[str]: + """Build query stages for built-in and custom enrichment pipeline.""" + entries = parse_with_entries_from_argv(argv) + entries = extend_with_entries_with_defaults(entries, args) + stages: list[str] = [] + for entry in entries: + if isinstance(entry, CustomStageInvocation): + stages.append(build_custom_stage(entry.query, entry.arg_value)) + continue + if entry == "--with-tags-as-category" and args.with_tags_as_category: + stages.append("(if .tags | length > 0 then .heading_category = .tags[0] else null); .") + return stages + + +def collect_custom_context_vars( + argv: list[str], + files: list[str] | None, + include_builtin_ordering: bool, +) -> dict[str, object]: + """Collect custom switch argument values for query evaluation context.""" + del argv, files, include_builtin_ordering + return {} + + +def build_pipeline_stages( + args: QueryBuildArgs, + argv: list[str], + include_ordering: bool, +) -> list[str]: + """Build the ordered query pipeline stages for CLI/config state.""" + validate_custom_switches(argv, include_ordering) + filter_order = extend_filter_order_with_defaults(parse_filter_entries_from_argv(argv), args) + filter_stages = build_filter_stages(args, filter_order) + with_stages = build_with_stages(args, argv) + stages = [*with_stages, *filter_stages] + stages.extend(build_order_stages(args, argv, include_builtin_ordering=include_ordering)) + return stages diff --git a/src/org/pipeline/load.py b/src/org/pipeline/load.py index 940e915..01add86 100644 --- a/src/org/pipeline/load.py +++ b/src/org/pipeline/load.py @@ -11,16 +11,19 @@ from org_parser import loads from org_parser.document import Heading +from org.config.cli import ( + QueryBuildArgs, + build_pipeline_stages, + collect_custom_context_vars, + normalize_cli_files_for_custom_switches, + validate_custom_switches, +) from org.logic.validation import validate_and_parse_keys, validate_global_arguments from org.query.engine.errors import QueryParseError from org.query.runner import ( - QueryBuildArgs, - build_query, - collect_custom_context_vars, + build_query_from_stages, execute_query_or_raise, flatten_query_results, - normalize_cli_files_for_custom_switches, - validate_custom_switches, ) @@ -201,11 +204,9 @@ def load_and_process_data(args: DataLoadArgs) -> tuple[list[Heading], list[str], include_slice = include_ordering and hasattr(args, "offset") and hasattr(args, "max_results") try: - query = build_query( - args, - sys.argv, - include_ordering=include_ordering, - include_slice=include_slice, + query = build_query_from_stages( + build_pipeline_stages(args, sys.argv, include_ordering), + include_slice, ) except QueryParseError as exc: raise typer.BadParameter(str(exc)) from exc diff --git a/src/org/query/runner.py b/src/org/query/runner.py index 84d50cb..1fb21cc 100644 --- a/src/org/query/runner.py +++ b/src/org/query/runner.py @@ -1,19 +1,12 @@ -"""Query building and execution helpers for command pipelines.""" +"""Query text assembly, compilation, and execution helpers.""" from __future__ import annotations import json import logging -import re from collections.abc import Callable, Sequence -from dataclasses import dataclass -from typing import Protocol, cast +from typing import cast -import click - -import org.config.app -from org.logic.time import parse_date_argument -from org.logic.validation import parse_property_filter from org.query.engine.compiler import CompiledQuery, compile_query_text from org.query.engine.errors import QueryParseError, QueryRuntimeError from org.query.engine.interpreter import EvalContext, Stream @@ -23,226 +16,8 @@ ErrorBuilder = Callable[[str], Exception] -FILTER_OPTIONS_WITH_VALUE = { - "--filter-priority", - "--filter-level", - "--filter-repeats-above", - "--filter-repeats-below", - "--filter-date-from", - "--filter-date-until", - "--filter-property", - "--filter-tag", - "--filter-heading", - "--filter-body", -} - -FILTER_OPTIONS_FLAGS = { - "--filter-completed", - "--filter-not-completed", -} - -WITH_OPTIONS_FLAGS = { - "--with-tags-as-category", -} - -ORDER_BY_OPTION_TO_VALUE = { - "--order-by-priority": "priority", - "--order-by-level": "level", - "--order-by-file-order": "file-order", - "--order-by-file-order-reversed": "file-order-reversed", - "--order-by-timestamp-asc": "timestamp-asc", - "--order-by-timestamp-desc": "timestamp-desc", -} - -ORDER_BY_DEST_TO_VALUE = { - "order_by_priority": "priority", - "order_by_level": "level", - "order_by_file_order": "file-order", - "order_by_file_order_reversed": "file-order-reversed", - "order_by_timestamp_asc": "timestamp-asc", - "order_by_timestamp_desc": "timestamp-desc", -} - - -class FilterArgs(Protocol): - """Protocol for filter-related CLI arguments.""" - - filter_priority: str | None - filter_level: int | None - filter_repeats_above: int | None - filter_repeats_below: int | None - filter_date_from: str | None - filter_date_until: str | None - filter_properties: list[str] | None - filter_tags: list[str] | None - filter_headings: list[str] | None - filter_bodies: list[str] | None - filter_completed: bool - filter_not_completed: bool - - -class WithArgs(Protocol): - """Protocol for built-in enrichment CLI arguments.""" - - with_tags_as_category: bool - - -class QueryBuildArgs(FilterArgs, WithArgs, Protocol): - """Protocol for arguments used to build query pipelines.""" - - -@dataclass(frozen=True) -class CustomStageInvocation: - """One custom switch occurrence parsed from CLI arguments.""" - - name: str - query: str - arg_value: object - - -def _extract_option_token(token: str) -> str: - """Return option token without inline assignment suffix.""" - if token.startswith("--") and "=" in token: - return token.split("=", 1)[0] - return token - - -def _custom_option_name(option: str, prefix: str) -> str | None: - """Extract custom option name after a known prefix.""" - option_prefix = f"--{prefix}-" - if not option.startswith(option_prefix): - return None - return option[len(option_prefix) :] - - -def _query_uses_arg(query: str) -> bool: - """Return True when query contains the `$arg` variable.""" - return bool(re.search(r"\$arg\b", query)) - - -def _resolve_custom_option(option: str) -> tuple[str, bool] | None: - """Resolve configured custom option to (query, requires_arg).""" - filter_name = _custom_option_name(option, "filter") - if filter_name is not None: - query = org.config.app.CONFIG_CUSTOM_FILTERS.get(filter_name) - if query is not None: - return (query, _query_uses_arg(query)) - - order_name = _custom_option_name(option, "order-by") - if order_name is not None: - query = org.config.app.CONFIG_CUSTOM_ORDER_BY.get(order_name) - if query is not None: - return (query, _query_uses_arg(query)) - - with_name = _custom_option_name(option, "with") - if with_name is not None: - query = org.config.app.CONFIG_CUSTOM_WITH.get(with_name) - if query is not None: - return (query, _query_uses_arg(query)) - - return None - - -def _required_custom_arg_error(option: str) -> click.BadParameter: - """Return the standard required custom argument error.""" - return click.BadParameter(f"{option} requires exactly one argument") - -def normalize_cli_files_for_custom_switches(files: list[str] | None) -> list[str] | None: - """Remove configured custom switch tokens from FILE argument values.""" - if files is None: - return None - - normalized: list[str] = [] - index = 0 - while index < len(files): - token = files[index] - option = _extract_option_token(token) - custom_option = _resolve_custom_option(option) - if custom_option is None: - normalized.append(token) - index += 1 - continue - - _, requires_arg = custom_option - - if token.startswith(f"{option}="): - index += 1 - continue - - if requires_arg: - next_index = index + 1 - if next_index >= len(files): - raise _required_custom_arg_error(option) - - next_token = files[next_index] - if next_token.startswith("-"): - raise _required_custom_arg_error(option) - index += 2 - continue - - index += 1 - - return normalized - - -def _consume_custom_optional_arg( - argv: list[str], - index: int, - option: str, - requires_arg: bool, -) -> tuple[str | None, int]: - """Consume custom argument token for one custom switch occurrence.""" - if not requires_arg: - return (None, index) - - next_index = index + 1 - if next_index >= len(argv): - raise _required_custom_arg_error(option) - - next_token = argv[next_index] - if next_token.startswith("-"): - raise _required_custom_arg_error(option) - - return (next_token, next_index) - - -def _coerce_custom_arg_value(value: str | None) -> object: - """Parse optional custom argument into runtime query value.""" - if value is None: - return None - - lowered = value.lower() - value_map: dict[str, object] = { - "null": None, - "true": True, - "false": False, - } - if lowered in value_map: - return value_map[lowered] - - if re.fullmatch(r"-?\d+", value): - return int(value) - if re.fullmatch(r"-?\d+\.\d+", value): - return float(value) - return value - - -def _build_custom_invocation( - *, - name: str, - query: str, - raw_arg: str | None, -) -> CustomStageInvocation: - """Build one parsed custom invocation with typed context binding.""" - return CustomStageInvocation( - name=name, - query=query, - arg_value=_coerce_custom_arg_value(raw_arg), - ) - - -def _custom_stage(query: str, arg_value: object) -> str: +def build_custom_stage(query: str, arg_value: object) -> str: """Build one custom query stage preserving input item stream values.""" return f"let {_query_literal(arg_value)} as $arg in ({query})" @@ -258,462 +33,16 @@ def _query_literal(value: object) -> str: return json.dumps(value) -def validate_custom_switches(argv: list[str], include_builtin_ordering: bool) -> None: - """Validate prefixed custom switches against configured/built-in options.""" - builtin_order_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() - allowed_filter_options = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS).union( - {f"--filter-{name}" for name in org.config.app.CONFIG_CUSTOM_FILTERS}, - ) - allowed_order_options = builtin_order_options.union( - {f"--order-by-{name}" for name in org.config.app.CONFIG_CUSTOM_ORDER_BY}, - ) - allowed_with_options = WITH_OPTIONS_FLAGS.union( - {f"--with-{name}" for name in org.config.app.CONFIG_CUSTOM_WITH}, - ) - - for index, token in enumerate(argv): - option = _extract_option_token(token) - if option.startswith("--filter-") and option not in allowed_filter_options: - raise click.NoSuchOption(option) - if option.startswith("--order-by-") and option not in allowed_order_options: - raise click.NoSuchOption(option) - if option.startswith("--with-") and option not in allowed_with_options: - raise click.NoSuchOption(option) - - custom_option = _resolve_custom_option(option) - if custom_option is None: - continue - - _, requires_arg = custom_option - if not requires_arg or token.startswith(f"{option}="): - continue - - next_index = index + 1 - if next_index >= len(argv): - raise _required_custom_arg_error(option) - - next_token = argv[next_index] - if next_token.startswith("-"): - raise _required_custom_arg_error(option) - - -def parse_filter_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: - """Parse built-in and custom filter switch occurrences in argv order.""" - entries: list[str | CustomStageInvocation] = [] - index = 0 - builtins = FILTER_OPTIONS_WITH_VALUE.union(FILTER_OPTIONS_FLAGS) - while index < len(argv): - token = argv[index] - option = _extract_option_token(token) - - if option in FILTER_OPTIONS_WITH_VALUE or option in FILTER_OPTIONS_FLAGS: - entries.append(option) - index += 1 - continue - - name = _custom_option_name(option, "filter") - if name is None or name not in org.config.app.CONFIG_CUSTOM_FILTERS or option in builtins: - index += 1 - continue - - query = org.config.app.CONFIG_CUSTOM_FILTERS[name] - requires_arg = _query_uses_arg(query) - if token.startswith(f"{option}="): - entries.append( - _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), - ) - index += 1 - continue - - custom_arg, consumed_index = _consume_custom_optional_arg( - argv, - index, - option, - requires_arg, - ) - entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) - index = consumed_index + 1 - - return entries - - -def parse_order_entries_from_argv( - argv: list[str], - include_builtin_ordering: bool, -) -> list[str | CustomStageInvocation]: - """Parse built-in and custom ordering switch occurrences in argv order.""" - entries: list[str | CustomStageInvocation] = [] - index = 0 - builtin_options = set(ORDER_BY_OPTION_TO_VALUE) if include_builtin_ordering else set() - while index < len(argv): - token = argv[index] - option = _extract_option_token(token) - - builtin_value = ORDER_BY_OPTION_TO_VALUE.get(option) - if builtin_value is not None and include_builtin_ordering: - entries.append(builtin_value) - index += 1 - continue - - name = _custom_option_name(option, "order-by") - if ( - name is None - or name not in org.config.app.CONFIG_CUSTOM_ORDER_BY - or option in builtin_options - ): - index += 1 - continue - - query = org.config.app.CONFIG_CUSTOM_ORDER_BY[name] - requires_arg = _query_uses_arg(query) - if token.startswith(f"{option}="): - entries.append( - _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), - ) - index += 1 - continue - - custom_arg, consumed_index = _consume_custom_optional_arg( - argv, - index, - option, - requires_arg, - ) - entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) - index = consumed_index + 1 - - return entries - - -def parse_with_entries_from_argv(argv: list[str]) -> list[str | CustomStageInvocation]: - """Parse built-in and custom enrichment switch occurrences in argv order.""" - entries: list[str | CustomStageInvocation] = [] - index = 0 - while index < len(argv): - token = argv[index] - option = _extract_option_token(token) - if option in WITH_OPTIONS_FLAGS: - entries.append(option) - index += 1 - continue - - name = _custom_option_name(option, "with") - if name is None or name not in org.config.app.CONFIG_CUSTOM_WITH: - index += 1 - continue - - query = org.config.app.CONFIG_CUSTOM_WITH[name] - requires_arg = _query_uses_arg(query) - if token.startswith(f"{option}="): - entries.append( - _build_custom_invocation(name=name, query=query, raw_arg=token.split("=", 1)[1]), - ) - index += 1 - continue - - custom_arg, consumed_index = _consume_custom_optional_arg( - argv, - index, - option, - requires_arg, - ) - entries.append(_build_custom_invocation(name=name, query=query, raw_arg=custom_arg)) - index = consumed_index + 1 - - return entries - - -def extend_with_entries_with_defaults( - with_entries: list[str | CustomStageInvocation], - args: WithArgs, -) -> list[str | CustomStageInvocation]: - """Extend with-entry order to include config-provided built-in enrichments.""" - expected_counts = {"--with-tags-as-category": 1 if args.with_tags_as_category else 0} - full_entries = list(with_entries) - for option_name, expected in expected_counts.items(): - existing = sum(1 for entry in full_entries if entry == option_name) - missing = expected - existing - if missing > 0: - full_entries.extend([option_name] * missing) - return full_entries - - -def extend_filter_order_with_defaults( - filter_order: list[str | CustomStageInvocation], - args: FilterArgs, -) -> list[str | CustomStageInvocation]: - """Extend filter order to include config-provided filters.""" - expected_counts = { - "--filter-priority": 1 if args.filter_priority is not None else 0, - "--filter-level": 1 if args.filter_level is not None else 0, - "--filter-repeats-above": 1 if args.filter_repeats_above is not None else 0, - "--filter-repeats-below": 1 if args.filter_repeats_below is not None else 0, - "--filter-date-from": 1 if args.filter_date_from is not None else 0, - "--filter-date-until": 1 if args.filter_date_until is not None else 0, - "--filter-property": len(args.filter_properties) if args.filter_properties else 0, - "--filter-tag": len(args.filter_tags) if args.filter_tags else 0, - "--filter-heading": len(args.filter_headings) if args.filter_headings else 0, - "--filter-body": len(args.filter_bodies) if args.filter_bodies else 0, - "--filter-completed": 1 if args.filter_completed else 0, - "--filter-not-completed": 1 if args.filter_not_completed else 0, - } - - full_order = list(filter_order) - for arg_name, expected in expected_counts.items(): - existing = sum(1 for value in full_order if value == arg_name) - missing = expected - existing - if missing > 0: - full_order.extend([arg_name] * missing) - return full_order - - -def _quote_string(value: str) -> str: - """Quote a value as query-language string literal.""" - return json.dumps(value) - - -def _timestamp_source_literal(value: str, arg_name: str) -> str: - """Quote one parsed date argument as org timestamp source.""" - parsed = parse_date_argument(value, arg_name).replace(second=0, microsecond=0) - return _quote_string(f"<{parsed:%Y-%m-%d %a %H:%M}>") - - -def _simple_filter_stage(arg_name: str, args: FilterArgs) -> str | None: - """Build query stage for non-indexed filter options.""" - stage: str | None = None - if arg_name == "--filter-priority" and args.filter_priority is not None: - stage = f"select(.priority == {_quote_string(args.filter_priority)})" - elif arg_name == "--filter-level" and args.filter_level is not None: - stage = f"select(.level == {args.filter_level})" - elif arg_name == "--filter-repeats-above" and args.filter_repeats_above is not None: - stage = f"select(.repeats | length > {args.filter_repeats_above})" - elif arg_name == "--filter-repeats-below" and args.filter_repeats_below is not None: - stage = f"select(.repeats | length < {args.filter_repeats_below})" - elif arg_name == "--filter-date-from" and args.filter_date_from is not None: - stage = ( - "select(.repeats + .deadline + .closed + .scheduled " - f"| max >= timestamp({_timestamp_source_literal(args.filter_date_from, arg_name)}))" - ) - elif arg_name == "--filter-date-until" and args.filter_date_until is not None: - stage = ( - "select(.repeats + .deadline + .closed + .scheduled " - f"| max <= timestamp({_timestamp_source_literal(args.filter_date_until, arg_name)}))" - ) - elif arg_name == "--filter-completed" and args.filter_completed: - stage = ( - "select(if .repeats | length > 0" - " then .repeats | map(.is_completed) + [.is_completed] | any" - " else .is_completed)" - " | .repeats = [.repeats[] | select(.is_completed)]; . " - ) - elif arg_name == "--filter-not-completed" and args.filter_not_completed: - stage = ( - "select(if .repeats | length > 0" - " then not(.repeats | map(.is_completed) + [.is_completed] | any)" - " else not(.is_completed))" - " | .repeats = [.repeats[] | select(not(.is_completed))]; . " - ) - return stage - - -def _indexed_filter_stage( - arg_name: str, - args: FilterArgs, - index_trackers: dict[str, int], -) -> str | None: - """Build query stage for indexed multi-value filter options.""" - if ( - arg_name == "--filter-property" - and args.filter_properties - and index_trackers["property"] < len(args.filter_properties) - ): - property_name, property_value = parse_property_filter( - args.filter_properties[index_trackers["property"]], - ) - index_trackers["property"] += 1 - return ( - f"select(.properties[{_quote_string(property_name)}] == " - f"{_quote_string(property_value)})" - ) - - if ( - arg_name == "--filter-tag" - and args.filter_tags - and index_trackers["tag"] < len(args.filter_tags) - ): - pattern = args.filter_tags[index_trackers["tag"]] - index_trackers["tag"] += 1 - return f"select(.tags[] matches {_quote_string(pattern)})" - - if ( - arg_name == "--filter-heading" - and args.filter_headings - and index_trackers["heading"] < len(args.filter_headings) - ): - pattern = args.filter_headings[index_trackers["heading"]] - index_trackers["heading"] += 1 - return f"select(.title_text matches {_quote_string(pattern)})" - - if ( - arg_name == "--filter-body" - and args.filter_bodies - and index_trackers["body"] < len(args.filter_bodies) - ): - pattern = f"(?m){args.filter_bodies[index_trackers['body']]}" - index_trackers["body"] += 1 - return f"select(.body_text matches {_quote_string(pattern)})" - - return None - - -def _filter_stage(arg_name: str, args: FilterArgs, index_trackers: dict[str, int]) -> str | None: - """Build one query stage for a filter option occurrence.""" - simple_stage = _simple_filter_stage(arg_name, args) - if simple_stage is not None: - return simple_stage - return _indexed_filter_stage(arg_name, args, index_trackers) - - -def build_filter_stages( - args: FilterArgs, - filter_order: list[str | CustomStageInvocation], -) -> list[str]: - """Build query stages for filter pipeline.""" - filter_stages: list[str] = [] - index_trackers = {"property": 0, "tag": 0, "heading": 0, "body": 0} - for entry in filter_order: - if isinstance(entry, CustomStageInvocation): - filter_stages.append(_custom_stage(entry.query, entry.arg_value)) - continue - stage = _filter_stage(entry, args, index_trackers) - if stage is not None: - filter_stages.append(stage) - return filter_stages - - -def extend_order_values_with_defaults(order_values: list[str], args: object) -> list[str]: - """Append config-provided orderings not present in argv order.""" - expected_counts: dict[str, int] = dict.fromkeys(ORDER_BY_DEST_TO_VALUE.values(), 0) - for dest_name, value in ORDER_BY_DEST_TO_VALUE.items(): - count = getattr(args, dest_name, 0) - if isinstance(count, bool): - expected_counts[value] += int(count) - elif isinstance(count, int) and count > 0: - expected_counts[value] += count - - full_order = list(order_values) - for value, expected in expected_counts.items(): - existing = full_order.count(value) - missing = expected - existing - if missing > 0: - full_order.extend([value] * missing) - return full_order - - -def _builtin_order_stages(value: str) -> list[str]: - """Build query stages for one built-in ordering value.""" - timestamp_key_expr = ".repeats + .deadline + .closed + .scheduled | max" - order_stages: dict[str, list[str]] = { - "file-order": ["."], - "file-order-reversed": ["reverse"], - "priority": ["sort_by(.priority)"], - "level": ["sort_by(.level)"], - "timestamp-asc": [ - f"sort_by({timestamp_key_expr})", - "reverse", - f"sort_by(({timestamp_key_expr}) != null)", - ], - "timestamp-desc": [f"sort_by({timestamp_key_expr})"], - } - return order_stages.get(value, []) - - -def build_order_stages(args: object, argv: list[str], include_builtin_ordering: bool) -> list[str]: - """Build query stages for ordering pipeline.""" - order_entries = parse_order_entries_from_argv(argv, include_builtin_ordering) - order_values: list[str | CustomStageInvocation] - if include_builtin_ordering: - builtin_order_values = [entry for entry in order_entries if isinstance(entry, str)] - expected_builtins = extend_order_values_with_defaults(builtin_order_values, args) - remaining_builtin_counts: dict[str, int] = {} - for value in builtin_order_values: - remaining_builtin_counts[value] = remaining_builtin_counts.get(value, 0) + 1 - - missing_builtins: list[str] = [] - for value in expected_builtins: - remaining = remaining_builtin_counts.get(value, 0) - if remaining > 0: - remaining_builtin_counts[value] = remaining - 1 - continue - missing_builtins.append(value) - - order_values = [*order_entries, *missing_builtins] - if not order_values: - order_values = ["timestamp-desc"] - else: - order_values = list(order_entries) - - order_stages: list[str] = [] - for order_value in order_values: - if isinstance(order_value, CustomStageInvocation): - order_stages.append(_custom_stage(order_value.query, order_value.arg_value)) - continue - order_stages.extend(_builtin_order_stages(order_value)) - return order_stages - - -def build_with_stages(args: WithArgs, argv: list[str]) -> list[str]: - """Build query stages for built-in and custom enrichment pipeline.""" - entries = parse_with_entries_from_argv(argv) - entries = extend_with_entries_with_defaults(entries, args) - stages: list[str] = [] - for entry in entries: - if isinstance(entry, CustomStageInvocation): - stages.append(_custom_stage(entry.query, entry.arg_value)) - continue - if entry == "--with-tags-as-category" and args.with_tags_as_category: - stages.append("(if .tags | length > 0 then .heading_category = .tags[0] else null); .") - return stages - - -def collect_custom_context_vars( - argv: list[str], - files: list[str] | None, - include_builtin_ordering: bool, -) -> dict[str, object]: - """Collect custom switch argument values for query evaluation context.""" - del argv, files, include_builtin_ordering - return {} - - -def build_query_text( - args: QueryBuildArgs, - argv: list[str], - include_ordering: bool, - include_slice: bool, -) -> str: - """Build query text for the configured filter/ordering pipeline.""" - validate_custom_switches(argv, include_ordering) - - filter_order = extend_filter_order_with_defaults(parse_filter_entries_from_argv(argv), args) - filter_stages = build_filter_stages(args, filter_order) - with_stages = build_with_stages(args, argv) - stages = [*with_stages, *filter_stages] - stages.extend(build_order_stages(args, argv, include_builtin_ordering=include_ordering)) - +def build_query_text_from_stages(stages: Sequence[str], include_slice: bool) -> str: + """Build query text from an ordered list of pipeline stages.""" pipeline_body = " | ".join(stages) base_query = f"[ .[] | {pipeline_body} ]" if pipeline_body else "[ .[] ]" return f"{base_query}[$offset:($offset + $limit)]" if include_slice else base_query -def build_query( - args: QueryBuildArgs, - argv: list[str], - include_ordering: bool, - include_slice: bool, -) -> CompiledQuery: - """Compile query for configured filter/ordering pipeline.""" - query_text = build_query_text(args, argv, include_ordering, include_slice) +def build_query_from_stages(stages: Sequence[str], include_slice: bool) -> CompiledQuery: + """Compile query from an ordered list of pipeline stages.""" + query_text = build_query_text_from_stages(stages, include_slice) logger.info("Query: %s", query_text) return compile_query_text(query_text) diff --git a/tests/cli_common/test_filter_construction.py b/tests/cli_common/test_filter_construction.py index 831ccaf..71f2b7d 100644 --- a/tests/cli_common/test_filter_construction.py +++ b/tests/cli_common/test_filter_construction.py @@ -9,6 +9,34 @@ import typer +def _build_query_text( + args: FilterArgsStub, + argv: list[str], + *, + include_ordering: bool, + include_slice: bool, +) -> str: + from org.config.cli import build_pipeline_stages + from org.query.runner import build_query_text_from_stages + + stages = build_pipeline_stages(args, argv, include_ordering) + return build_query_text_from_stages(stages, include_slice) + + +def _build_query( + args: FilterArgsStub, + argv: list[str], + *, + include_ordering: bool, + include_slice: bool, +) -> object: + from org.config.cli import build_pipeline_stages + from org.query.runner import build_query_from_stages + + stages = build_pipeline_stages(args, argv, include_ordering) + return build_query_from_stages(stages, include_slice) + + @dataclass class FilterArgsStub: """Stub arguments for query construction tests.""" @@ -50,20 +78,16 @@ def make_args(**overrides: object) -> FilterArgsStub: def test_build_query_text_filters_only() -> None: """Query text should include filters in command order.""" - from org.query.runner import build_query_text - args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == '[ .[] | select(.tags[] matches "simple") ]' def test_build_query_text_with_ordering_and_slice() -> None: """Query text should append order stages then slice.""" - from org.query.runner import build_query_text - args = make_args(filter_tags=["work"], order_by_timestamp_desc=True, offset=5, max_results=10) argv = [ "org", @@ -75,7 +99,7 @@ def test_build_query_text_with_ordering_and_slice() -> None: "file.org", ] - query = build_query_text(args, argv, include_ordering=True, include_slice=True) + query = _build_query_text(args, argv, include_ordering=True, include_slice=True) assert query == ( '[ .[] | select(.tags[] matches "work") ' @@ -86,12 +110,10 @@ def test_build_query_text_with_ordering_and_slice() -> None: def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: """timestamp-asc should keep items with no timestamp at the end.""" - from org.query.runner import build_query_text - args = make_args(order_by_timestamp_asc=True) argv = ["org", "tasks", "list", "--order-by-timestamp-asc", "file.org"] - query = build_query_text(args, argv, include_ordering=True, include_slice=False) + query = _build_query_text(args, argv, include_ordering=True, include_slice=False) assert query == ( "[ .[] | sort_by(.repeats + .deadline + .closed + .scheduled | max) " @@ -102,36 +124,30 @@ def test_build_query_text_with_timestamp_asc_keeps_none_last() -> None: def test_build_query_text_with_priority_ordering() -> None: """priority ordering should sort by node priority value.""" - from org.query.runner import build_query_text - args = make_args(order_by_priority=True) argv = ["org", "tasks", "list", "--order-by-priority", "file.org"] - query = build_query_text(args, argv, include_ordering=True, include_slice=False) + query = _build_query_text(args, argv, include_ordering=True, include_slice=False) assert query == "[ .[] | sort_by(.priority) ]" def test_build_query_text_with_property_filter() -> None: """Property filters should quote keys and values.""" - from org.query.runner import build_query_text - args = make_args(filter_properties=["priority=A"]) argv = ["org", "stats", "all", "--filter-property", "priority=A", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == '[ .[] | select(.properties["priority"] == "A") ]' def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: """Built-in with-tags-as-category switch should add its query stage.""" - from org.query.runner import build_query_text - args = make_args(with_tags_as_category=True) argv = ["org", "stats", "summary", "--with-tags-as-category", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | (if .tags | length > 0 then .heading_category = .tags[0] else null); . ]" @@ -140,12 +156,10 @@ def test_build_query_text_with_builtin_with_tags_as_category_stage() -> None: def test_build_query_text_with_tags_as_category_default_appended_when_missing_in_argv() -> None: """Default with-tags-as-category should be appended when omitted from argv.""" - from org.query.runner import build_query_text - args = make_args(with_tags_as_category=True, filter_tags=["work"]) argv = ["org", "stats", "summary", "--filter-tag", "work", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | (if .tags | length > 0 then .heading_category = .tags[0] else null); . " @@ -155,12 +169,10 @@ def test_build_query_text_with_tags_as_category_default_appended_when_missing_in def test_build_query_text_with_filter_completed() -> None: """Completed filter should include repeated task completion states.""" - from org.query.runner import build_query_text - args = make_args(filter_completed=True) argv = ["org", "tasks", "list", "--filter-completed", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | select(if .repeats | length > 0 then .repeats | map(.is_completed) + " @@ -171,12 +183,10 @@ def test_build_query_text_with_filter_completed() -> None: def test_build_query_text_with_filter_not_completed() -> None: """Not-completed filter should include tasks with no done state.""" - from org.query.runner import build_query_text - args = make_args(filter_not_completed=True) argv = ["org", "tasks", "list", "--filter-not-completed", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | select(if .repeats | length > 0 then not(.repeats | map(.is_completed) + " @@ -187,13 +197,11 @@ def test_build_query_text_with_filter_not_completed() -> None: def test_build_query_logs_query_before_compile(caplog: pytest.LogCaptureFixture) -> None: """build_query should log the query text before compilation.""" - from org.query.runner import build_query - args = make_args(filter_tags=["simple"]) argv = ["org", "stats", "all", "--filter-tag", "simple", "file.org"] with caplog.at_level(logging.INFO, logger="org"): - build_query(args, argv, include_ordering=False, include_slice=False) + _build_query(args, argv, include_ordering=False, include_slice=False) assert 'Query: [ .[] | select(.tags[] matches "simple") ]' in caplog.text @@ -203,7 +211,6 @@ def test_build_query_text_with_custom_filter_and_optional_arg( ) -> None: """Custom filter switches should bind $arg and support omitted values.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr( org.config.app, @@ -227,7 +234,7 @@ def test_build_query_text_with_custom_filter_and_optional_arg( "file.org", ] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( "[ .[] | let 3 as $arg in (select(.todo == $arg))" @@ -240,7 +247,7 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( ) -> None: """Custom arguments are embedded in query text and not added to context vars.""" import org.config.app - from org.query.runner import collect_custom_context_vars + from org.config.cli import collect_custom_context_vars monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.v == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by(.priority)"}) @@ -272,7 +279,6 @@ def test_collect_custom_context_vars_returns_empty_for_custom_args( def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyPatch) -> None: """Custom enrichment stages should be applied before filters.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"tagged": "select(.tag != null)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -290,7 +296,7 @@ def test_build_query_text_custom_with_before_filters(monkeypatch: pytest.MonkeyP "file.org", ] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query.startswith('[ .[] | let "one" as $arg in (. + {"x": $arg}) | ') assert "| let null as $arg in (select(.tag != null)) |" in query @@ -301,7 +307,6 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( ) -> None: """Built-in and custom with stages should preserve CLI order.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -318,7 +323,7 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( "file.org", ] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == ( '[ .[] | let "one" as $arg in (. + {"x": $arg}) ' @@ -329,7 +334,6 @@ def test_build_query_text_with_builtin_and_custom_with_stages_follow_argv_order( def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPatch) -> None: """Custom order-by switches should work even when built-in ordering is disabled.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -338,7 +342,7 @@ def test_build_query_text_custom_ordering_for_stats(monkeypatch: pytest.MonkeyPa args = make_args(files=["file.org"]) argv = ["org", "stats", "all", "--order-by-weight", "file.org"] - query = build_query_text(args, argv, include_ordering=False, include_slice=False) + query = _build_query_text(args, argv, include_ordering=False, include_slice=False) assert query == "[ .[] | let null as $arg in (sort_by(.priority)) ]" @@ -362,7 +366,6 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( ) -> None: """Built-in and custom ordering switches should follow CLI specification order.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_WITH", {}) @@ -378,7 +381,7 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( "file.org", ] - query = build_query_text(args, argv, include_ordering=True, include_slice=False) + query = _build_query_text(args, argv, include_ordering=True, include_slice=False) assert query == ( "[ .[] | let null as $arg in (sort_by(.priority))" @@ -389,7 +392,6 @@ def test_build_query_text_preserves_mixed_ordering_cli_order( def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.MonkeyPatch) -> None: """Unknown prefixed custom switches should fail validation.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -399,7 +401,7 @@ def test_build_query_text_rejects_unknown_custom_switch(monkeypatch: pytest.Monk argv = ["org", "tasks", "list", "--filter-unknown", "file.org"] with pytest.raises(click.NoSuchOption, match="No such option"): - build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False) def test_build_query_text_custom_filter_requires_exactly_one_argument( @@ -407,7 +409,6 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( ) -> None: """Custom filters using $arg should require exactly one argument.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr( org.config.app, @@ -424,7 +425,7 @@ def test_build_query_text_custom_filter_requires_exactly_one_argument( typer.BadParameter, match="--filter-level-above requires exactly one argument", ): - build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False) def test_build_query_text_custom_order_by_requires_exactly_one_argument( @@ -432,7 +433,6 @@ def test_build_query_text_custom_order_by_requires_exactly_one_argument( ) -> None: """Custom order-by switches using $arg should require exactly one argument.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {"weight": "sort_by($arg)"}) @@ -442,7 +442,7 @@ def test_build_query_text_custom_order_by_requires_exactly_one_argument( argv = ["org", "tasks", "list", "--order-by-weight"] with pytest.raises(typer.BadParameter, match="--order-by-weight requires exactly one argument"): - build_query_text(args, argv, include_ordering=True, include_slice=False) + _build_query_text(args, argv, include_ordering=True, include_slice=False) def test_build_query_text_custom_with_requires_exactly_one_argument( @@ -450,7 +450,6 @@ def test_build_query_text_custom_with_requires_exactly_one_argument( ) -> None: """Custom enrichment switches using $arg should require exactly one argument.""" import org.config.app - from org.query.runner import build_query_text monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) @@ -460,7 +459,7 @@ def test_build_query_text_custom_with_requires_exactly_one_argument( argv = ["org", "tasks", "list", "--with-mark"] with pytest.raises(typer.BadParameter, match="--with-mark requires exactly one argument"): - build_query_text(args, argv, include_ordering=False, include_slice=False) + _build_query_text(args, argv, include_ordering=False, include_slice=False) def test_normalize_cli_files_consumes_required_custom_path_like_argument( @@ -468,7 +467,7 @@ def test_normalize_cli_files_consumes_required_custom_path_like_argument( ) -> None: """Required custom arguments should consume path-like values.""" import org.config.app - from org.query.runner import normalize_cli_files_for_custom_switches + from org.config.cli import normalize_cli_files_for_custom_switches monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_FILTERS", {"value": "select(.todo == $arg)"}) monkeypatch.setattr(org.config.app, "CONFIG_CUSTOM_ORDER_BY", {}) From 7cceeb74ca0db7a4cad24c66524562ef2e957605 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 14 Jun 2026 11:26:50 +0200 Subject: [PATCH 25/27] Updated the roadmap. --- VIBES.org | 47 ++++++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/VIBES.org b/VIBES.org index b38e392..baa28d9 100644 --- a/VIBES.org +++ b/VIBES.org @@ -7273,7 +7273,6 @@ The agenda list should have a way to indicate that a task is blocked or doesn't To convey that, the task line should be grayed out in the agenda time-breakdown (other sections should not be affected). When hovering a task line in the time-breakdown section of the agenda, if the task is blocked in the =.is_blocked= sense, or some of its workflow requirements aren't met, a suitable message should be displayed in the status bar. Use the =info= property of the requirement or a generic =Task is blocked= string. *** Do not add =#+TODO:= line to file contents, but rather modify done_states. - *** Better heading & body parsing Use stripped =RichText= for body/heading tags. *** Parallelize document processing @@ -8141,10 +8140,6 @@ All of this should be moved to the =org.pipeline.load= module. - State "CANCELLED" from [2026-06-13 Sat 21:02] \\ Nah, these are annoying. :END: -*** Use Textual textarea for any org-mode syntax highlighting -- Limit to just the interactive commands. -- Potentially use it for code edits of the files? -**** Show a Textual modal when performing external edits, instead of suspending the app *** DONE Reorganize the query execution engine code :PROPERTIES: :capture_time: [2026-06-13 Sat 00:04] @@ -8287,6 +8282,12 @@ I absolutely hate this. Please refactor the =out= and =details= switches to be o All the functions dealing with CLI argument processing, ordering of filters, etc should be moved to a new module =org.config.cli=. The =org.query= package should only contain query building and execution. The =org.config= package is a suitable place for the default pipeline configuration and user-provided overrides in the form of CLI switches. +** TODO Built-in editor +*** Use Textual textarea for org-mode syntax highlighting & editing +- Separate config setting for internal/external editor. + +*** Show a Textual modal when performing external edits, instead of suspending the app + ** TODO Agenda usability improvements :UX:Agenda: *** Agenda non-interactive output format - Should be adjusted to "look and feel" just like the interactive one. @@ -8624,7 +8625,18 @@ The action will prompt the user for each step multiple times (empty response mov When typing, the action should propose existing tasks by title. When a newly added task matches an existing recurrent task by title it should be "reused" instead of creating a duplicate (reuse as in, scheduled set to the current day while preserving the repeater if present). -** TODO Query language extensions +*** Flicker when attempting to edit a non-task row in the agenda. :Bug: +:PROPERTIES: +:capture_time: [2026-06-11 Thu 15:54] +:context: +:END: +*** Agenda command allows highlighting the section titles. :Bug: +Section titles should be omitted when scrolling the highlight. +:PROPERTIES: +:capture_time: [2026-06-11 Thu 15:54] +:context: +:END: +** Query language & command improvements :UX:Query: *** Query language improvements - Uniform function calling: =foo | not= is the same as =not(foo)= - Field assignment should return the mutated object: =foo.title = "bar"= evaluates to =foo= after the change. @@ -8672,7 +8684,6 @@ sort_by(.priority, .latest_timestamp) # sort by priority first, break ties by la sort_by(.priority, .category, .title) # sort by multiple values in order sort_by(-.property.some_val, .latest_timestamp.start.month) # sort by multiple compound subqueries #+end_example -** Query command improvements :UX:Query: *** Saved queries :Config: Add a configuration file section for saved queries that can be invoked with =org tasks query= in place of the actual query string. @@ -8687,6 +8698,11 @@ Invocation: org tasks query all-todos #+end_example +*** Query data visualization +- Functions that encode stats into widget objects +- Show stats widgets when query return widget objects +- Support mixed data (title string + histogram widget, etc) + ** Board command improvements :UX:Board: *** Board plan command Shows long term plan including completeness of tasks that have children and gannt-style chart for execution. @@ -8760,6 +8776,13 @@ Should work the same as the configurable agenda view columns. *** Config handling is too generic Split config handling to be per-command and have the config file reflect that. *** Refactor config handling so that no global state is mutated +*** TODO Built-in filters/orderings/mutators should be treated the same as custom ones +:PROPERTIES: +:capture_time: [2026-06-13 Sat 00:01] +:context: +:END: + +After loading the config defaults, these should be populated in their respective config object sections. ** Dashboard command :Dashboard: - Reads a dashboard config and shows a visualization on a server. - Tabbed views for the agenda, board, backlog and stats @@ -8867,13 +8890,3 @@ Add a notification command integration (for instance notify-send) when human int Should be handled by workflow configuration, no need for a dedicated command. :END: Similar to flow, but for full specifications, easy extension, replacement, validation etc. -* TODO Flicker when attempting to edit a non-task row in the agenda. -:PROPERTIES: -:capture_time: [2026-06-11 Thu 15:54] -:context: -:END: -* TODO Agenda command allows highlighting the section titles. Section titles should be omitted when scrolling the highlight. -:PROPERTIES: -:capture_time: [2026-06-11 Thu 15:54] -:context: -:END: From b28eb07ab328846330bf68e0cdb539271bedee37 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 14 Jun 2026 11:46:20 +0200 Subject: [PATCH 26/27] Fixed some minor naming issues. --- src/org/commands/agenda/command.py | 7 ++- src/org/commands/board/__init__.py | 4 +- src/org/commands/board/actions.py | 4 +- src/org/commands/board/app.py | 4 +- src/org/commands/board/command.py | 19 +++++--- src/org/commands/board/ui.py | 4 +- tests/commands/test_board.py | 70 +++++++++++++++--------------- tests/commands/test_board_app.py | 4 +- tests/config/test_config_core.py | 2 +- 9 files changed, 64 insertions(+), 54 deletions(-) diff --git a/src/org/commands/agenda/command.py b/src/org/commands/agenda/command.py index be221c7..901c89a 100644 --- a/src/org/commands/agenda/command.py +++ b/src/org/commands/agenda/command.py @@ -74,6 +74,11 @@ def _resolve_tasks_limit(max_results: int | None) -> int: return max_results +def _validate_agenda_args(args: AgendaArgs) -> None: + """Validate agenda arguments, including date parsing.""" + ui.resolve_agenda_start_date(args.date) + + def run_agenda(args: AgendaArgs) -> None: """Run the agenda command.""" color_enabled = setup_output(args) @@ -87,7 +92,7 @@ def run_agenda(args: AgendaArgs) -> None: raise typer.BadParameter("--days must be at least 1") args.max_results = _resolve_tasks_limit(args.max_results) - ui.resolve_agenda_start_date(args.date) + _validate_agenda_args(args) view_ctx = resolve_view_context(args) diff --git a/src/org/commands/board/__init__.py b/src/org/commands/board/__init__.py index 5c36e46..fcd0cce 100644 --- a/src/org/commands/board/__init__.py +++ b/src/org/commands/board/__init__.py @@ -1,6 +1,6 @@ """Board command package.""" -from .command import BoardArgs, register, run_flow_board +from .command import BoardArgs, register, run_board -__all__ = ["BoardArgs", "register", "run_flow_board"] +__all__ = ["BoardArgs", "register", "run_board"] diff --git a/src/org/commands/board/actions.py b/src/org/commands/board/actions.py index 7d68d14..f3a34b9 100644 --- a/src/org/commands/board/actions.py +++ b/src/org/commands/board/actions.py @@ -338,14 +338,14 @@ def reload_session( refresh_visible_columns(session, preserve_identity) -def create_flow_board_session( +def create_board_session( args: BoardArgs, nodes: list[Heading], todo_states: list[str], done_states: list[str], color_enabled: bool, ) -> BoardSession: - """Create interactive flow board session state.""" + """Create interactive board session state.""" columns = build_selector_board_columns(nodes, resolve_column_specs(args)) selected_column_index = 0 for index, column in enumerate(columns): diff --git a/src/org/commands/board/app.py b/src/org/commands/board/app.py index 73c6d5d..3252633 100644 --- a/src/org/commands/board/app.py +++ b/src/org/commands/board/app.py @@ -22,7 +22,7 @@ ) from . import actions, ui -from .actions import BoardSession, create_flow_board_session +from .actions import BoardSession, create_board_session if TYPE_CHECKING: @@ -422,5 +422,5 @@ def run_board_app( color_enabled: bool, ) -> None: """Run the Textual-backed interactive board app.""" - session = create_flow_board_session(args, nodes, todo_states, done_states, color_enabled) + session = create_board_session(args, nodes, todo_states, done_states, color_enabled) BoardApp(session).run() diff --git a/src/org/commands/board/command.py b/src/org/commands/board/command.py index 9d428a5..0600585 100644 --- a/src/org/commands/board/command.py +++ b/src/org/commands/board/command.py @@ -71,8 +71,13 @@ def _resolve_tasks_limit(max_results: int | None) -> int: return max_results -def run_flow_board(args: BoardArgs) -> None: - """Run the flow board command.""" +def _validate_board_args(args: BoardArgs) -> None: + """Validate board arguments and configured view selection.""" + actions.resolve_column_specs(args) + + +def run_board(args: BoardArgs) -> None: + """Run the board command.""" color_enabled = setup_output(args) console = build_console(color_enabled, args.width) if console.width < 80: @@ -84,7 +89,7 @@ def run_flow_board(args: BoardArgs) -> None: if args.days < 0: raise typer.BadParameter("--days must be non-negative") args.max_results = _resolve_tasks_limit(args.max_results) - actions.resolve_column_specs(args) + _validate_board_args(args) with processing_status(console, color_enabled): nodes, discovered_todo_states, discovered_done_states = load_and_process_data(args) @@ -106,7 +111,7 @@ def run_flow_board(args: BoardArgs) -> None: def register(app: typer.Typer) -> None: - """Register the flow board command.""" + """Register the board command.""" @app.command( "board", @@ -116,7 +121,7 @@ def register(app: typer.Typer) -> None: _BOARD_HELP_ENTRIES, ), ) - def flow_board( # noqa: PLR0913 + def board( # noqa: PLR0913 files: list[str] | None = typer.Argument( # noqa: B008 None, metavar="FILE", @@ -306,7 +311,7 @@ def flow_board( # noqa: PLR0913 help="Preprocess nodes to set category from first tag", ), ) -> None: - """Display tasks as an interactive flow board.""" + """Display tasks as an interactive board.""" args = BoardArgs( files=files, config=config, @@ -345,4 +350,4 @@ def flow_board( # noqa: PLR0913 org.config.app.apply_config_defaults(args) org.logging.log_applied_config_defaults(args, sys.argv[1:], "board") org.logging.log_command_arguments(args, "board") - run_flow_board(args) + run_board(args) diff --git a/src/org/commands/board/ui.py b/src/org/commands/board/ui.py index 15eea16..e53f91b 100644 --- a/src/org/commands/board/ui.py +++ b/src/org/commands/board/ui.py @@ -235,7 +235,7 @@ def _estimate_board_height(columns: Sequence[_BoardColumnLike], panel_content_wi return content_row_height + 3 -def render_static_flow_board( +def render_static_board( console: Console, columns: Sequence[_BoardColumnLike], *, @@ -243,7 +243,7 @@ def render_static_flow_board( todo_states: list[str], color_enabled: bool, ) -> None: - """Render non-interactive flow board output.""" + """Render non-interactive board output.""" table = Table(expand=True, box=box.SQUARE, show_lines=False, show_header=False) for _ in columns: table.add_column(ratio=1) diff --git a/tests/commands/test_board.py b/tests/commands/test_board.py index 12d6567..a263929 100644 --- a/tests/commands/test_board.py +++ b/tests/commands/test_board.py @@ -156,7 +156,7 @@ def _render_board_output(args: board_command.BoardArgs) -> str: if not nodes: return "No results\n" columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) - ui.render_static_flow_board( + ui.render_static_board( console, columns, done_states=done_states, @@ -203,7 +203,7 @@ def test_filter_recent_completed_nodes_respects_days_override( ] -def test_run_flow_board_renders_expected_columns(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_renders_expected_columns(monkeypatch: pytest.MonkeyPatch) -> None: """Board should render built-in selector fallback columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args( @@ -234,7 +234,7 @@ def test_run_flow_board_renders_expected_columns(monkeypatch: pytest.MonkeyPatch assert "DONE" in output -def test_run_flow_board_column_order_follows_document_todo_order( +def test_run_board_column_order_follows_document_todo_order( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -269,7 +269,7 @@ def test_run_flow_board_column_order_follows_document_todo_order( assert pos_todo < pos_done -def test_run_flow_board_preserves_order_in_column_when_priorities_equal( +def test_run_board_preserves_order_in_column_when_priorities_equal( monkeypatch: pytest.MonkeyPatch, ) -> None: """Board should keep stable per-column order when priorities are equal.""" @@ -667,7 +667,7 @@ def _fake_archive( assert session.status_message == "Task archived" -def test_run_flow_board_does_not_hide_unknown_or_empty_states( +def test_run_board_does_not_hide_unknown_or_empty_states( monkeypatch: pytest.MonkeyPatch, ) -> None: """Unknown and empty task states should still be visible on the board.""" @@ -681,7 +681,7 @@ def test_run_flow_board_does_not_hide_unknown_or_empty_states( assert "WAITING Custom todo state" in output -def test_run_flow_board_no_results(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_no_results(monkeypatch: pytest.MonkeyPatch) -> None: """Board should print No results when filters remove all tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], filter_tags=["nomatch$"]) @@ -692,17 +692,17 @@ def test_run_flow_board_no_results(monkeypatch: pytest.MonkeyPatch) -> None: assert output.strip() == "No results" -def test_run_flow_board_rejects_width_below_80(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_rejects_width_below_80(monkeypatch: pytest.MonkeyPatch) -> None: """Board should reject console widths below the minimum.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], width=79) monkeypatch.setattr(sys, "argv", ["org", "board", "--width", "79"]) with pytest.raises(typer.BadParameter, match="--width must be at least 80"): - board_command.run_flow_board(args) + board_command.run_board(args) -def test_run_flow_board_limit_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_limit_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: """Board should respect --limit when selecting processed tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], max_results=1) @@ -714,7 +714,7 @@ def test_run_flow_board_limit_applies_before_grouping(monkeypatch: pytest.Monkey assert "Fix bug in parser" not in output -def test_run_flow_board_offset_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_offset_applies_before_grouping(monkeypatch: pytest.MonkeyPatch) -> None: """Board should respect --offset when selecting processed tasks.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], max_results=1, offset=1, days=100000) @@ -726,7 +726,7 @@ def test_run_flow_board_offset_applies_before_grouping(monkeypatch: pytest.Monke assert "Fix bug in parser" in output -def test_run_flow_board_negative_max_results_raises_bad_parameter( +def test_run_board_negative_max_results_raises_bad_parameter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Board should reject negative --limit values.""" @@ -735,10 +735,10 @@ def test_run_flow_board_negative_max_results_raises_bad_parameter( monkeypatch.setattr(sys, "argv", ["org", "board", "--limit", "-1"]) with pytest.raises(typer.BadParameter, match="--limit must be non-negative"): - board_command.run_flow_board(args) + board_command.run_board(args) -def test_run_flow_board_negative_offset_raises_bad_parameter( +def test_run_board_negative_offset_raises_bad_parameter( monkeypatch: pytest.MonkeyPatch, ) -> None: """Board should reject negative --offset values.""" @@ -747,10 +747,10 @@ def test_run_flow_board_negative_offset_raises_bad_parameter( monkeypatch.setattr(sys, "argv", ["org", "board", "--offset", "-1"]) with pytest.raises(typer.BadParameter, match="--offset must be non-negative"): - board_command.run_flow_board(args) + board_command.run_board(args) -def test_run_flow_board_uses_pager_when_render_exceeds_console_height( +def test_run_board_uses_pager_when_render_exceeds_console_height( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -789,7 +789,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: discovered_done_states, ) columns = actions.build_selector_board_columns(nodes, actions.resolve_column_specs(args)) - ui.render_static_flow_board( + ui.render_static_board( console, columns, done_states=done_states, @@ -800,7 +800,7 @@ def _fake_pager(*args: object, **kwargs: object) -> Iterator[None]: assert pager_called["value"] -def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( +def test_run_board_selector_uses_full_nodes_from_multiple_files( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: @@ -832,7 +832,7 @@ def test_run_flow_board_selector_uses_full_nodes_from_multiple_files( org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_coalesce_completed_true_shows_completed_column( +def test_run_board_coalesce_completed_true_shows_completed_column( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector view should include DONE column for completed tasks.""" @@ -852,7 +852,7 @@ def test_run_flow_board_coalesce_completed_true_shows_completed_column( assert "Completed task" in output -def test_run_flow_board_coalesce_completed_true_prefixes_state_in_panel( +def test_run_board_coalesce_completed_true_prefixes_state_in_panel( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback DONE selector should include all completed state tasks.""" @@ -873,7 +873,7 @@ def test_run_flow_board_coalesce_completed_true_prefixes_state_in_panel( assert "Another done state" in output -def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( +def test_run_board_coalesce_completed_false_shows_individual_done_columns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector view should not render legacy COMPLETED header.""" @@ -895,7 +895,7 @@ def test_run_flow_board_coalesce_completed_false_shows_individual_done_columns( assert "Another done state" in output -def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo_columns( +def test_run_board_coalesce_completed_false_done_columns_ordered_after_todo_columns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback selector headers should stay in Backlog/TODO/DONE order.""" @@ -919,7 +919,7 @@ def test_run_flow_board_coalesce_completed_false_done_columns_ordered_after_todo assert pos_in_progress < pos_done -def test_run_flow_board_coalesce_completed_false_tasks_in_correct_columns( +def test_run_board_coalesce_completed_false_tasks_in_correct_columns( monkeypatch: pytest.MonkeyPatch, ) -> None: """Fallback DONE selector should show all completed-state tasks.""" @@ -940,7 +940,7 @@ def test_run_flow_board_coalesce_completed_false_tasks_in_correct_columns( assert "Another done state" in output -def test_run_flow_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyPatch) -> None: """Requested configured view should render configured columns.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view="kanban", width=150) @@ -970,7 +970,7 @@ def test_run_flow_board_uses_configured_view_columns(monkeypatch: pytest.MonkeyP org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_missing_requested_view_raises(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_missing_requested_view_raises(monkeypatch: pytest.MonkeyPatch) -> None: """Missing requested view should return explicit BadParameter.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], view="missing") @@ -989,13 +989,13 @@ def test_run_flow_board_missing_requested_view_raises(monkeypatch: pytest.Monkey monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="Requested board view not found"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: org.config.app.CONFIG_BOARD_VIEWS.clear() org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_requested_view_without_configured_views_raises( +def test_run_board_requested_view_without_configured_views_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: """Explicit --view should fail when no configured views exist.""" @@ -1007,13 +1007,13 @@ def test_run_flow_board_requested_view_without_configured_views_raises( monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="no board views are configured"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: org.config.app.CONFIG_BOARD_VIEWS.clear() org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_uses_default_view_from_config(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_uses_default_view_from_config(monkeypatch: pytest.MonkeyPatch) -> None: """Config-defaulted view value should drive board view resolution.""" fixture_path = os.path.join(FIXTURES_DIR, "custom_states.org") args = make_board_args([fixture_path], view=None, width=150) @@ -1043,7 +1043,7 @@ def test_run_flow_board_uses_default_view_from_config(monkeypatch: pytest.Monkey org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context( +def test_run_board_invalid_filter_or_order_by_parse_error_has_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """Filter/order-by parse failures should include view and column context.""" @@ -1061,13 +1061,13 @@ def test_run_flow_board_invalid_filter_or_order_by_parse_error_has_context( monkeypatch.setattr(sys.stdin, "isatty", lambda: True) monkeypatch.setattr(sys.stdout, "isatty", lambda: True) with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: org.config.app.CONFIG_BOARD_VIEWS.clear() org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( +def test_run_board_invalid_filter_or_order_by_runtime_error_has_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """Filter/order-by runtime failures should include view and column context.""" @@ -1093,13 +1093,13 @@ def test_run_flow_board_invalid_filter_or_order_by_runtime_error_has_context( monkeypatch.setattr(sys.stdout, "isatty", lambda: True) try: with pytest.raises(typer.BadParameter, match="view=kanban, column=Broken"): - board_command.run_flow_board(args) + board_command.run_board(args) finally: org.config.app.CONFIG_BOARD_VIEWS.clear() org.config.app.CONFIG_BOARD_VIEWS.update(original_views) -def test_run_flow_board_invalid_order_by_parse_error_has_context( +def test_run_board_invalid_order_by_parse_error_has_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """Invalid order-by parse should include view and column context.""" @@ -1124,7 +1124,7 @@ def test_run_flow_board_invalid_order_by_parse_error_has_context( typer.BadParameter, match=r"Invalid board filter/order-by \(view=kanban, column=Broken\)", ): - board_command.run_flow_board(args) + board_command.run_board(args) finally: org.config.app.CONFIG_BOARD_VIEWS.clear() org.config.app.CONFIG_BOARD_VIEWS.update(original_views) @@ -1236,7 +1236,7 @@ def test_build_task_panel_renders_rich_title_content() -> None: assert "call_fn(1)" in plain -def test_run_flow_board_renders_rich_title_plain_output( +def test_run_board_renders_rich_title_plain_output( monkeypatch: pytest.MonkeyPatch, tmp_path: os.PathLike[str], ) -> None: diff --git a/tests/commands/test_board_app.py b/tests/commands/test_board_app.py index 6610801..bbf7289 100644 --- a/tests/commands/test_board_app.py +++ b/tests/commands/test_board_app.py @@ -268,7 +268,7 @@ async def _run() -> None: asyncio.run(_run()) -def test_run_flow_board_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: +def test_run_board_interactive_uses_app_runner(monkeypatch: pytest.MonkeyPatch) -> None: """Interactive board path should delegate to the Textual app runner.""" fixture_path = os.path.join(FIXTURES_DIR, "multiple_tags.org") args = make_board_args([fixture_path], days=100000) @@ -292,6 +292,6 @@ def _fake_run( monkeypatch.setattr(sys.stdout, "isatty", lambda: True) monkeypatch.setattr("org.commands.board.command.run_board_app", _fake_run) - board_command.run_flow_board(args) + board_command.run_board(args) assert called["value"] is True diff --git a/tests/config/test_config_core.py b/tests/config/test_config_core.py index 555eded..e2fa2ba 100644 --- a/tests/config/test_config_core.py +++ b/tests/config/test_config_core.py @@ -770,7 +770,7 @@ def test_build_default_map_keeps_ordering_boolean_defaults() -> None: assert tasks_query_defaults["order_by_timestamp_desc"] is False -def test_build_default_map_strips_flow_board_unsupported_defaults() -> None: +def test_build_default_map_strips_board_unsupported_defaults() -> None: """Board default map should omit list-only output options.""" default_map = org.config.app.build_default_map( { From e1d95a272d7ae97e7e97b45c84333d1cb3d51417 Mon Sep 17 00:00:00 2001 From: Kajetan Rzepecki Date: Sun, 14 Jun 2026 12:09:30 +0200 Subject: [PATCH 27/27] Updated the README and docs --- README.md | 191 ++++++-------------------------------------------- docs/index.md | 7 +- 2 files changed, 27 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index 29aa259..cf7b6b6 100644 --- a/README.md +++ b/README.md @@ -22,187 +22,40 @@ Top-level config sections: - `filter` for custom `--filter-` query snippets - `order-by` for custom `--order-by-` query snippets - `with` for custom `--with-` query snippets +- `capture` for named task capture templates +- `agenda` for named agenda views +- `board` for named board views -Custom switch argument handling: - -- If a custom query contains `$arg`, the generated CLI switch requires exactly one argument. -- If `$arg` is not present, the generated switch does not require an argument. - -```yaml -defaults: - --done-states: DONE,CANCELLED,DELEGATED - --buckets: 80 - --filter-priority: A - --mapping: examples/mapping_example.json - --exclude: examples/exclude_example.txt -filter: - level-above: select(.level > $arg) - has-todo: select(.todo != none) -order-by: - recent-first: sort_by(.repeats + .deadline + .closed + .scheduled | max) -with: - priority-value: .properties.priority_value = .priority -``` +See [docs/index.md](docs/index.md) for the full config layout. ## Commands -### `org tasks query` - -Run jq-style queries over your Org Mode tasks. - -```bash -# Expand all nodes, select completed tasks, extract heading, return a page of results -poetry run org tasks query '[ .[][] | select(.todo in $done_states) | .title_text ][$offset: $offset + $limit]' \ - --done-states DONE,CANCELLED \ - --max-results 10 \ - --offset 10 \ - examples/ARCHIVE_small -``` - -```text -Fix broken data endpoint in spartan sensor mesh on ESP8266 when there's too large JSON to stringify. -Replace the humidity/temperature sensors on the inside sensor with CO sensor. -... -``` +### `tasks` -Query language reference: [docs/query_language.md](docs/query_language.md) +- `org tasks add` - Create a new task heading and insert it into an Org file. +- `org tasks update` - Update one or more matched task headings and save the changes. +- `org tasks remove` - Delete one or more matched task headings and their subtrees. +- `org tasks capture` - Create a task from a configured capture template. +- `org tasks list` - List matching tasks in interactive, static, or exported form. +- `org tasks query` - Run jq-style queries over loaded Org task data. + Query language reference: [docs/query_language.md](docs/query_language.md) -### `org stats all` - -Show a full overview: global task stats, top tasks, top tags, and groups. - -```bash -poetry run org stats all \ - --use tags \ - --max-results 5 \ - --max-tags 3 \ - --max-groups 2 \ - --max-relations 3 \ - examples/ARCHIVE_small -``` - -```text -2023-10-19 2023-11-14 -┊▂ ▂ ▂ ▂ ▆ █ ▄ ▄ ▂ ▄ █ ▄ ▄ ▂ ▄ █ ┊ 4 (2023-10-26) -‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -Total tasks: 33 -Unique tasks: 32 -Average tasks per day: 1.22 -Max tasks on a single day: 4 -Max repeats of a single task: 2 - -Task states: - DONE ┊████████████████████████████████████████████████████████████████████████ 30 - CANCELLED┊██ 1 - DELEGATED┊ 0 - TODO ┊██ 1 - SUSPENDED┊██ 1 -... -``` +### `stats` -### `org stats summary` +- `org stats summary` - Show task-only summary metrics and histograms. +- `org stats tags` - Show statistics for selected tags or top tag results. +- `org stats groups` - Show statistics for explicit or discovered tag groups. +- `org stats all` - Show the combined overview of tasks, tags, and groups. -Show task-only metrics and histograms. +### `agenda` -```bash -poetry run org stats summary \ - --with-tags-as-category \ - --filter-date-from 2023-10-20 \ - --filter-date-until 2023-11-15 \ - examples/ARCHIVE_small -``` - -### `org stats tags` - -Show focused stats for selected tags or top tags. - -```bash -poetry run org stats tags \ - --show Debugging,Jira \ - --max-relations 3 \ - --filter-not-completed \ - examples/ARCHIVE_small -``` - -### `org stats groups` - -Show stats for explicit or discovered tag groups. - -```bash -poetry run org stats groups \ - --group Debugging,Erlang \ - --group ProjectManagement,Jira \ - --max-results 2 \ - examples/ARCHIVE_small -``` +- `org agenda` - Show a day-oriented interactive agenda with scheduling and deadline views. -### `org tasks list` +### `board` -List matching tasks with filters and ordering. +- `org board` - Show an interactive board view of tasks using configured or fallback columns. -```bash -poetry run org tasks list \ - --filter-completed \ - --order-by-level \ - --order-by-timestamp-asc \ - --max-results 5 \ - --offset 20 \ - examples/ARCHIVE_small -``` - -```text -examples/ARCHIVE_small: * DONE Prepare some stories for refinement. :Jira:ProjectManagement: -examples/ARCHIVE_small: * DONE Reorganize tickets on the CHAT project board.:Jira:ProjectManagement: -... -``` - -### `org board` - -Display matching tasks as a workflow-style board with one column per todo state plus `COMPLETED`. - -```bash -poetry run org board \ - --todo-states TODO,WAITING,INPROGRESS \ - --done-states DONE,CANCELLED \ - examples/ARCHIVE_small -``` - -### `org tasks add` - -Create a new heading in an Org file. - -```bash -poetry run org tasks add \ - --todo TODO \ - --title "Update docs" \ - --tag Docs \ - --body "Refresh command documentation" \ - ROADMAP.org -``` - -### `org tasks update` - -Update one task heading selected by query ID or title. - -```bash -poetry run org tasks update \ - --query-id task-1 \ - --todo DONE \ - --closed "<2026-04-13>" \ - ROADMAP.org -``` - -### `org tasks remove` - -Delete one task heading (including its subtree) by title or ID. - -```bash -poetry run org tasks remove \ - --title "Update docs" \ - ROADMAP.org -``` -## Notes +## Roadmap -- Command docs and defaults: [docs/index.md](docs/index.md) - Current roadmap: [VIBES.org](VIBES.org) diff --git a/docs/index.md b/docs/index.md index 2acc4a2..fd32fe3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,13 +28,14 @@ For query syntax details, use [query_language.md](query_language.md). ### Config file layout -Config uses six top-level sections: +Config uses seven top-level sections: - `defaults`: built-in option defaults (for example `--done-states`, `--limit`, `--filter-priority`, `--order-by-priority`). - `filter`: custom `--filter-` query snippets. - `order-by`: custom `--order-by-` query snippets. - `with`: custom `--with-` query snippets. - `capture`: named capture templates under `capture.templates`. +- `agenda`: named agenda views under `agenda.views` with filter-based sections. - `board`: named board views under `board.views` with filter-based columns. Custom switch argument handling: @@ -64,7 +65,9 @@ Most analysis commands accept many `--filter-*` switches. Ordering controls are Built-in argument defaults: -- Global: `--limit 10`, `--offset 0`, `--todo-states TODO`, `--done-states DONE`. +- Shared defaults: `--offset 0`, `--todo-states TODO`, `--done-states DONE`. +- Limit defaults are command-specific: many query/list/stats commands default `--limit` to `10`, while + `org agenda` and `org board` default to all results. - Stats: `--use tags`, `--max-tags 5` (all), `--max-relations 5`, `--max-groups 5` (all), `--min-group-size 2` (all). - Built-in filter additions: `--filter-priority P`. - Tasks list/board built-in ordering: `--order-by-priority`, `--order-by-level`, `--order-by-file-order`, `--order-by-file-order-reversed`, `--order-by-timestamp-asc`, `--order-by-timestamp-desc`.