From c873cbfd259d3c656ad809eea2ce7a67a4d45156 Mon Sep 17 00:00:00 2001 From: oir Date: Mon, 16 Mar 2026 19:33:14 -0400 Subject: [PATCH] tabularize usage line in help text --- startle/_help.py | 33 ++++++++++++++++++++ startle/args.py | 13 ++++++-- tests/test_help/test_help.py | 3 +- tests/test_recursive/test_recursive_parse.py | 6 ++-- tests/test_recursive/test_recursive_start.py | 6 ++-- 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/startle/_help.py b/startle/_help.py index 0efea42..50bb7c2 100644 --- a/startle/_help.py +++ b/startle/_help.py @@ -5,6 +5,7 @@ from enum import Enum from typing import Any, Literal +from rich.cells import cell_len from rich.text import Text from .arg import Arg, Name @@ -155,3 +156,35 @@ def var_args_usage_line(arg: Arg) -> Text: def var_kwargs_usage_line(arg: Arg) -> Text: return Text.assemble("[", _repeated(_opt_usage(arg, "usage line")), "]") + + +def wrap_usage(name: str, components: list[Text], width: int) -> Text: + """ + Custom word-wrapping for usage lines that avoids splitting individual components + (e.g. we want "--foo bar" or "[--foo bar]" stay together). + Continuation lines are indented to align after the program name. + + This was needed because (afaik) there is no way to declare non-breaking spaces in rich, + and even in that case I would prefer to use actual space char. + """ + + indent = len(name) + 1 + lines: list[Text] = [] + current = Text(f"{name} ") + current_len = indent + + for comp in components: + comp_len = cell_len(comp.plain) + if current_len > indent and current_len + comp_len + 1 > width: + lines.append(current) + current = Text(" " * indent) + comp + current_len = indent + comp_len + else: + if current_len > indent: + current.append(" ") + current_len += 1 + current = Text.assemble(current, comp) + current_len += comp_len + + lines.append(current) + return Text("\n").join(lines) diff --git a/startle/args.py b/startle/args.py index 6087dfc..a9931c9 100644 --- a/startle/args.py +++ b/startle/args.py @@ -3,7 +3,14 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Literal -from ._help import Sty, help, usage, var_args_usage_line, var_kwargs_usage_line +from ._help import ( + Sty, + help, + usage, + var_args_usage_line, + var_kwargs_usage_line, + wrap_usage, +) from .arg import Arg, Name from .error import ( DuplicateOptionError, @@ -539,7 +546,7 @@ def print_help( # (2) then print usage line console.print(Text("Usage:", style=Sty.title)) - usage_components = [Text(f" {name}")] + usage_components: list[Text] = [] pos_only_str = Text(" ").join([ usage(arg, "usage line") for arg in positional_only ]) @@ -554,7 +561,7 @@ def print_help( if self._var_kwargs: usage_components.append(var_kwargs_usage_line(self._var_kwargs)) - console.print(Text(" ").join(usage_components)) + console.print(wrap_usage(f" {name}", usage_components, console.width or 80)) if usage_only: console.print() diff --git a/tests/test_help/test_help.py b/tests/test_help/test_help.py index 2ec7d54..012571f 100644 --- a/tests/test_help/test_help.py +++ b/tests/test_help/test_help.py @@ -181,7 +181,8 @@ class FusionConfig: Fuse two monsters with polymerization. [{TS}]Usage:[/] - fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] [[{NS} {OS}]--alpha[/] [{VS}][/]] + fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] + [[{NS} {OS}]--alpha[/] [{VS}][/]] [{TS}]where[/] [dim](pos. or opt.)[/] [{NS} {OS}]-l[/][{OS} dim]|[/][{NS} {OS}]--left-path[/] [{VS}][/] [i]Path to the first monster.[/] [yellow](required)[/] diff --git a/tests/test_recursive/test_recursive_parse.py b/tests/test_recursive/test_recursive_parse.py index ba0cf2a..3909529 100644 --- a/tests/test_recursive/test_recursive_parse.py +++ b/tests/test_recursive/test_recursive_parse.py @@ -671,7 +671,8 @@ def test_recursive_dataclass_help( Fuse two monsters with polymerization. [{TS}]Usage:[/] - fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] [[{NS} {OS}]--alpha[/] [{VS}][/]] + fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] + [[{NS} {OS}]--alpha[/] [{VS}][/]] [{TS}]where[/] [dim](option)[/] [{NS} {OS}]-l[/][{OS} dim]|[/][{NS} {OS}]--left-path[/] [{VS}][/] [i]Path to the first monster.[/] [yellow](required)[/] @@ -760,7 +761,8 @@ def test_recursive_dataclass_help_2() -> None: Fuse two monsters with polymerization. [{TS}]Usage:[/] - fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] [[{NS} {OS}]--alpha[/] [{VS}][/]] + fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] + [[{NS} {OS}]--alpha[/] [{VS}][/]] [{TS}]where[/] [dim](option)[/] [{NS} {OS}]--left-path[/] [{VS}][/] [i]Path to the first monster.[/] [yellow](required)[/] diff --git a/tests/test_recursive/test_recursive_start.py b/tests/test_recursive/test_recursive_start.py index dd5852c..d4cdf3a 100644 --- a/tests/test_recursive/test_recursive_start.py +++ b/tests/test_recursive/test_recursive_start.py @@ -664,7 +664,8 @@ def test_recursive_dataclass_help(fuse: Callable[..., Any]) -> None: Fuse two monsters with polymerization. [{TS}]Usage:[/] - fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] [[{NS} {OS}]--alpha[/] [{VS}][/]] + fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] + [[{NS} {OS}]--alpha[/] [{VS}][/]] [{TS}]where[/] [dim](option)[/] [{NS} {OS}]-l[/][{OS} dim]|[/][{NS} {OS}]--left-path[/] [{VS}][/] [i]Path to the first monster.[/] [yellow](required)[/] @@ -741,7 +742,8 @@ def test_recursive_dataclass_help_2() -> None: Fuse two monsters with polymerization. [{TS}]Usage:[/] - fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] [[{NS} {OS}]--alpha[/] [{VS}][/]] + fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] + [[{NS} {OS}]--alpha[/] [{VS}][/]] [{TS}]where[/] [dim](option)[/] [{NS} {OS}]--left-path[/] [{VS}][/] [i]Path to the first monster.[/] [yellow](required)[/]