termflow-md 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {termflow_md-0.2.2 → termflow_md-0.2.4}/CHANGELOG.md +19 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/PKG-INFO +1 -1
- {termflow_md-0.2.2 → termflow_md-0.2.4}/pyproject.toml +1 -1
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/__init__.py +1 -1
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/style.py +35 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/tui/__init__.py +7 -1
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/tui/terminal.py +45 -0
- termflow_md-0.2.4/tests/test_style.py +28 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_tui.py +46 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/uv.lock +1 -1
- {termflow_md-0.2.2 → termflow_md-0.2.4}/.github/workflows/ci.yml +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/.github/workflows/publish.yml +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/.gitignore +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/LICENSE +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/README.md +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/demo.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/examples/config.toml +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/__main__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/ansi/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/ansi/codes.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/ansi/color.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/ansi/style.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/ansi/utils.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/cli.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/config/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/config/config.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/core/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/core/enums.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/core/state.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/parser/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/parser/entities.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/parser/events.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/parser/inline.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/parser/parser.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/code.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/heading.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/list.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/renderer.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/table.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/render/text.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/stream/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/stream/smooth.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/syntax/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/syntax/highlighter.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/themes/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/themes/osc.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/themes/palette.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/tui/keys.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/termflow/tui/menu.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/test_document.md +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/__init__.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/conftest.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_ansi.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_config.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_inline.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_integration.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_parser.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_renderer.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_smooth.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_syntax.py +0 -0
- {termflow_md-0.2.2 → termflow_md-0.2.4}/tests/test_themes.py +0 -0
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.4] - 2026-08-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `RenderStyle.from_palette()` — derive rendering accents from a
|
|
15
|
+
terminal palette's ANSI slots (Palette object or plain dict), so
|
|
16
|
+
menus and markdown output stay on-theme when the app has remapped
|
|
17
|
+
terminal colors via OSC instead of using the hardcoded defaults.
|
|
18
|
+
|
|
19
|
+
## [0.2.3] - 2026-08-22
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `termflow.tui.terminal_session()` — refcounted, thread-safe context
|
|
24
|
+
manager holding raw mode + ONE alternate screen across multiple TUI
|
|
25
|
+
components. Chained menu flows (a picker opening sub-pickers) run each
|
|
26
|
+
menu with `alt_screen=False` inside a session and never flash the
|
|
27
|
+
primary screen between menus. Nested sessions share the outermost one.
|
|
28
|
+
|
|
10
29
|
## [0.2.2] - 2026-08-22
|
|
11
30
|
|
|
12
31
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: termflow-md
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: A streaming markdown renderer for modern terminals
|
|
5
5
|
Project-URL: Homepage, https://github.com/mpfaffenberger/termflow
|
|
6
6
|
Project-URL: Repository, https://github.com/mpfaffenberger/termflow
|
|
@@ -43,6 +43,41 @@ class RenderStyle:
|
|
|
43
43
|
"""Create default style."""
|
|
44
44
|
return cls()
|
|
45
45
|
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_palette(cls, palette) -> RenderStyle:
|
|
48
|
+
"""Derive a style from a terminal palette's ANSI slots.
|
|
49
|
+
|
|
50
|
+
Accepts a :class:`termflow.themes.Palette` or any mapping/object
|
|
51
|
+
exposing ``ansi`` (16 #rrggbb strings, slots 0-15) and optionally
|
|
52
|
+
``bg``. Keeps rendered output on-theme when the application has
|
|
53
|
+
remapped the terminal colors (OSC 4/10/11): accents come from the
|
|
54
|
+
palette's bright slots instead of the hardcoded defaults.
|
|
55
|
+
|
|
56
|
+
Slots with missing colors fall back to the default style's value.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(palette, dict):
|
|
59
|
+
ansi = list(palette.get("ansi") or [])
|
|
60
|
+
bg = palette.get("bg")
|
|
61
|
+
else:
|
|
62
|
+
ansi = list(getattr(palette, "ansi", ()) or ())
|
|
63
|
+
bg = getattr(palette, "bg", None)
|
|
64
|
+
base = cls()
|
|
65
|
+
|
|
66
|
+
def slot(i: int, fallback: str) -> str:
|
|
67
|
+
return ansi[i] if i < len(ansi) and ansi[i] else fallback
|
|
68
|
+
|
|
69
|
+
return cls(
|
|
70
|
+
bright=slot(12, base.bright), # bright blue - primary accent
|
|
71
|
+
head=slot(10, base.head), # bright green - secondary
|
|
72
|
+
symbol=slot(5, base.symbol), # magenta - markers, borders
|
|
73
|
+
grey=slot(8, base.grey), # bright black - dim text
|
|
74
|
+
dark=bg or slot(0, base.dark), # background-adjacent
|
|
75
|
+
mid=slot(0, base.mid), # block backgrounds
|
|
76
|
+
light=slot(14, base.light), # bright cyan - light accent
|
|
77
|
+
link=slot(12, base.link), # match primary accent
|
|
78
|
+
error=slot(9, base.error), # bright red
|
|
79
|
+
)
|
|
80
|
+
|
|
46
81
|
@classmethod
|
|
47
82
|
def from_hue(cls, hue: float) -> RenderStyle:
|
|
48
83
|
"""Generate a style from a base hue (0.0-1.0).
|
|
@@ -10,7 +10,12 @@ prompt_toolkit -- just escape sequences and small, injectable pieces.
|
|
|
10
10
|
|
|
11
11
|
from termflow.tui.keys import Key, parse_key, read_key
|
|
12
12
|
from termflow.tui.menu import Menu, MenuBuilder, MenuItem, MenuResult
|
|
13
|
-
from termflow.tui.terminal import
|
|
13
|
+
from termflow.tui.terminal import (
|
|
14
|
+
alt_screen,
|
|
15
|
+
raw_mode,
|
|
16
|
+
terminal_session,
|
|
17
|
+
terminal_size,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
__all__ = [
|
|
16
21
|
"Key",
|
|
@@ -22,5 +27,6 @@ __all__ = [
|
|
|
22
27
|
"parse_key",
|
|
23
28
|
"raw_mode",
|
|
24
29
|
"read_key",
|
|
30
|
+
"terminal_session",
|
|
25
31
|
"terminal_size",
|
|
26
32
|
]
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import contextlib
|
|
13
13
|
import shutil
|
|
14
14
|
import sys
|
|
15
|
+
import threading
|
|
15
16
|
from typing import IO, TYPE_CHECKING
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
@@ -75,6 +76,50 @@ def raw_mode(stream: IO[str] | None = None) -> Iterator[None]:
|
|
|
75
76
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
_session_lock = threading.Lock()
|
|
80
|
+
_session_depth = 0
|
|
81
|
+
_session_stack: contextlib.ExitStack | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextlib.contextmanager
|
|
85
|
+
def terminal_session(output: IO[str] | None = None) -> Iterator[None]:
|
|
86
|
+
"""Hold raw mode + ONE alternate screen across multiple TUI components.
|
|
87
|
+
|
|
88
|
+
Menus normally enter/exit the alternate screen per ``run()``; chained
|
|
89
|
+
flows (a picker opening sub-pickers) would flash the primary screen
|
|
90
|
+
between every menu. Wrap the whole flow in a ``terminal_session`` and
|
|
91
|
+
run each menu with ``alt_screen=False``: the session owns the screen
|
|
92
|
+
and raw mode, the menus just paint.
|
|
93
|
+
|
|
94
|
+
Reentrant and refcounted (thread-safe): nested sessions share the
|
|
95
|
+
outermost one, so components can wrap themselves defensively without
|
|
96
|
+
caring whether a parent already holds the terminal.
|
|
97
|
+
"""
|
|
98
|
+
global _session_depth, _session_stack
|
|
99
|
+
with _session_lock:
|
|
100
|
+
_session_depth += 1
|
|
101
|
+
if _session_depth == 1:
|
|
102
|
+
stack = contextlib.ExitStack()
|
|
103
|
+
try:
|
|
104
|
+
stack.enter_context(raw_mode())
|
|
105
|
+
stack.enter_context(alt_screen(output))
|
|
106
|
+
except Exception:
|
|
107
|
+
_session_depth -= 1
|
|
108
|
+
with contextlib.suppress(Exception):
|
|
109
|
+
stack.close()
|
|
110
|
+
raise
|
|
111
|
+
_session_stack = stack
|
|
112
|
+
try:
|
|
113
|
+
yield
|
|
114
|
+
finally:
|
|
115
|
+
with _session_lock:
|
|
116
|
+
_session_depth -= 1
|
|
117
|
+
if _session_depth == 0 and _session_stack is not None:
|
|
118
|
+
stack, _session_stack = _session_stack, None
|
|
119
|
+
with contextlib.suppress(Exception):
|
|
120
|
+
stack.close()
|
|
121
|
+
|
|
122
|
+
|
|
78
123
|
@contextlib.contextmanager
|
|
79
124
|
def alt_screen(output: IO[str] | None = None, hide_cursor: bool = True) -> Iterator[None]:
|
|
80
125
|
"""Switch to the alternate screen buffer (like vim/less) for the duration.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class TestFromPalette:
|
|
2
|
+
def test_derives_accents_from_ansi_slots(self):
|
|
3
|
+
from termflow.render.style import RenderStyle
|
|
4
|
+
from termflow.themes import get_palette
|
|
5
|
+
|
|
6
|
+
pal = get_palette("purple_puppy")
|
|
7
|
+
style = RenderStyle.from_palette(pal)
|
|
8
|
+
assert style.bright == pal.ansi[12]
|
|
9
|
+
assert style.symbol == pal.ansi[5]
|
|
10
|
+
assert style.error == pal.ansi[9]
|
|
11
|
+
assert style.dark == pal.bg
|
|
12
|
+
|
|
13
|
+
def test_accepts_plain_dict(self):
|
|
14
|
+
from termflow.render.style import RenderStyle
|
|
15
|
+
|
|
16
|
+
ansi = [f"#0000{i:02x}" for i in range(16)]
|
|
17
|
+
style = RenderStyle.from_palette({"ansi": ansi, "bg": "#101010"})
|
|
18
|
+
assert style.bright == ansi[12]
|
|
19
|
+
assert style.grey == ansi[8]
|
|
20
|
+
assert style.dark == "#101010"
|
|
21
|
+
|
|
22
|
+
def test_missing_slots_fall_back_to_defaults(self):
|
|
23
|
+
from termflow.render.style import RenderStyle
|
|
24
|
+
|
|
25
|
+
base = RenderStyle.default()
|
|
26
|
+
style = RenderStyle.from_palette({"ansi": []})
|
|
27
|
+
assert style.bright == base.bright
|
|
28
|
+
assert style.error == base.error
|
|
@@ -9,6 +9,52 @@ from termflow.tui import Key, MenuBuilder, MenuItem, parse_key
|
|
|
9
9
|
# =============================================================================
|
|
10
10
|
# Key parsing
|
|
11
11
|
# =============================================================================
|
|
12
|
+
class TestTerminalSession:
|
|
13
|
+
"""Refcounted raw-mode + alt-screen session for chained menus."""
|
|
14
|
+
|
|
15
|
+
def test_single_session_enters_and_exits_once(self):
|
|
16
|
+
from io import StringIO
|
|
17
|
+
|
|
18
|
+
from termflow.tui import terminal_session
|
|
19
|
+
|
|
20
|
+
out = StringIO()
|
|
21
|
+
with terminal_session(out):
|
|
22
|
+
pass
|
|
23
|
+
assert out.getvalue().count("\x1b[?1049h") == 1
|
|
24
|
+
assert out.getvalue().count("\x1b[?1049l") == 1
|
|
25
|
+
|
|
26
|
+
def test_nested_sessions_share_one_alt_screen(self):
|
|
27
|
+
from io import StringIO
|
|
28
|
+
|
|
29
|
+
from termflow.tui import terminal_session
|
|
30
|
+
|
|
31
|
+
out = StringIO()
|
|
32
|
+
with terminal_session(out):
|
|
33
|
+
with terminal_session(out):
|
|
34
|
+
pass
|
|
35
|
+
# Inner exit must NOT drop back to the primary screen.
|
|
36
|
+
assert out.getvalue().count("\x1b[?1049l") == 0
|
|
37
|
+
assert out.getvalue().count("\x1b[?1049h") == 1
|
|
38
|
+
assert out.getvalue().count("\x1b[?1049l") == 1
|
|
39
|
+
|
|
40
|
+
def test_session_releases_on_exception(self):
|
|
41
|
+
from io import StringIO
|
|
42
|
+
|
|
43
|
+
from termflow.tui import terminal_session
|
|
44
|
+
|
|
45
|
+
out = StringIO()
|
|
46
|
+
try:
|
|
47
|
+
with terminal_session(out):
|
|
48
|
+
raise RuntimeError("boom")
|
|
49
|
+
except RuntimeError:
|
|
50
|
+
pass
|
|
51
|
+
assert out.getvalue().count("\x1b[?1049l") == 1
|
|
52
|
+
# Depth back to zero: a fresh session works.
|
|
53
|
+
with terminal_session(out):
|
|
54
|
+
pass
|
|
55
|
+
assert out.getvalue().count("\x1b[?1049h") == 2
|
|
56
|
+
|
|
57
|
+
|
|
12
58
|
class TestParseKey:
|
|
13
59
|
def test_printable_passthrough(self):
|
|
14
60
|
assert parse_key("a") == "a"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|