ouro-tui-themes 0.3.6__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.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.egg-info/
6
+ dist/
7
+ dist-linux/
8
+ build/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ .venv-*/
13
+ venv/
14
+
15
+ # Testing / coverage
16
+ .coverage
17
+ .coverage.*
18
+ .pytest_cache/
19
+ htmlcov/
20
+
21
+ # Tool caches
22
+ .ruff_cache/
23
+ .mypy_cache/
24
+
25
+ # IDE
26
+ .idea/
27
+ .vscode/
28
+ .claude/
29
+ *.swp
30
+ *.swo
31
+
32
+ # OS
33
+ .DS_Store
34
+ Thumbs.db
35
+
36
+ # User
37
+ .ouro
38
+ .ouro-dev
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OURO contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.5
2
+ Name: ouro-tui-themes
3
+ Version: 0.3.6
4
+ Summary: Optional OURO welcome styles and shared dot-matrix rendering engine.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: numpy<3,>=2.3.3
9
+ Requires-Dist: ouro-agent==0.3.6
10
+ Description-Content-Type: text/markdown
11
+
12
+ # OURO TUI themes
13
+
14
+ Install the complete optional welcome extension in the OURO environment:
15
+
16
+ ```sh
17
+ pip install "ouro-agent[themes]"
18
+ ```
19
+
20
+ This distribution contains both the shared `ouro_tui_themes.dot_matrix` engine and
21
+ the official `ouro_tui_themes.styles` collection. The engine owns numerical fields,
22
+ terminal rasterization and widget scheduling; styles own artwork and page composition.
23
+ The engine does not depend on style implementations or welcome-page contracts.
24
+
25
+ OURO's core provides the welcome API, persistent input surface, shared information
26
+ components and the `basic` fallback page. It runs without this distribution or NumPy.
27
+ The application loads style metadata from the installed matching release and imports
28
+ renderers only when selected. Missing extensions or unavailable dependencies preserve
29
+ the saved selection while displaying `basic`; restarting after installation restores it.
30
+
31
+ Core and extension releases use the same version and are built together from the OURO
32
+ workspace with `uv build --all-packages`. Both wheels must be available when installing
33
+ the `themes` extra. The extension declares its compatible core version and NumPy requirement.
@@ -0,0 +1,22 @@
1
+ # OURO TUI themes
2
+
3
+ Install the complete optional welcome extension in the OURO environment:
4
+
5
+ ```sh
6
+ pip install "ouro-agent[themes]"
7
+ ```
8
+
9
+ This distribution contains both the shared `ouro_tui_themes.dot_matrix` engine and
10
+ the official `ouro_tui_themes.styles` collection. The engine owns numerical fields,
11
+ terminal rasterization and widget scheduling; styles own artwork and page composition.
12
+ The engine does not depend on style implementations or welcome-page contracts.
13
+
14
+ OURO's core provides the welcome API, persistent input surface, shared information
15
+ components and the `basic` fallback page. It runs without this distribution or NumPy.
16
+ The application loads style metadata from the installed matching release and imports
17
+ renderers only when selected. Missing extensions or unavailable dependencies preserve
18
+ the saved selection while displaying `basic`; restarting after installation restores it.
19
+
20
+ Core and extension releases use the same version and are built together from the OURO
21
+ workspace with `uv build --all-packages`. Both wheels must be available when installing
22
+ the `themes` extra. The extension declares its compatible core version and NumPy requirement.
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "ouro-tui-themes"
3
+ version = "0.3.6"
4
+ description = "Optional OURO welcome styles and shared dot-matrix rendering engine."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ dependencies = [
10
+ "ouro-agent==0.3.6",
11
+ "numpy>=2.3.3,<3",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/ouro_tui_themes"]
@@ -0,0 +1 @@
1
+ """Optional welcome extensions, installed together through ouro-agent[themes]."""
@@ -0,0 +1 @@
1
+ """Shared numerical and terminal primitives used by the particle themes."""
@@ -0,0 +1,131 @@
1
+ """Shared numerical engine used by the bundled particle themes.
2
+
3
+ Grid, sampler contract, array operations and the default backend live together.
4
+ No UI objects, terminal geometry, theme registry or scheduling enter this module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from math import exp
12
+ from typing import Protocol
13
+
14
+ import numpy as np
15
+ from numpy.typing import NDArray
16
+
17
+ type Field = NDArray[np.float64]
18
+ type Radiance = tuple[Field, Field, Field]
19
+ type Sampler = Callable[[float], Radiance]
20
+ type Prepare = Callable[[ParticleGrid], Sampler]
21
+ type EngineFactory = Callable[[ParticleGrid, Prepare], ParticleEngine]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ParticleGrid:
26
+ """Fixed square-pitch coordinates in row-major order, independent of display cells."""
27
+
28
+ width: int
29
+ height: int
30
+ x: Field
31
+ y: Field
32
+
33
+ @classmethod
34
+ def create(cls, width: int, height: int, *, span: tuple[float, float]) -> ParticleGrid:
35
+ if width < 1 or height < 1:
36
+ raise ValueError("a particle grid needs positive width and height")
37
+ pitch = max(span[0] / width, span[1] / height)
38
+ xs = (np.arange(width, dtype=np.float64) - (width - 1) / 2) * pitch
39
+ ys = (np.arange(height, dtype=np.float64) - (height - 1) / 2) * pitch
40
+ x, y = np.tile(xs, height), np.repeat(ys, width)
41
+ x.flags.writeable = y.flags.writeable = False
42
+ return cls(width, height, x, y)
43
+
44
+
45
+ class ParticleEngine(Protocol):
46
+ """One owner, one size, explicit time, borrowed planar RGB output.
47
+
48
+ Each channel has width * height finite values in [0, 1]. Output is valid until
49
+ the next call and must not be mutated by consumers. Factories create independent
50
+ state. Engines receive explicit time and samplers, never widgets or concrete themes.
51
+ Raw samplers supply finite non-negative radiance, with no upper bound.
52
+ """
53
+
54
+ def frame(self, seconds: float, elapsed: float) -> Radiance: ...
55
+
56
+
57
+ def buffer(size: int, value: float = 0.0) -> Field:
58
+ return np.full(size, value, dtype=np.float64)
59
+
60
+
61
+ def clip(values: Field, low: float, high: float) -> None:
62
+ """Clamp in one native pass without cancellation at the bounds."""
63
+ np.clip(values, low, high, out=values)
64
+
65
+
66
+ def band(values: Field, width: float) -> None:
67
+ """Replace signed distances with a Gaussian light band."""
68
+ np.divide(values, width, out=values)
69
+ np.square(values, out=values)
70
+ np.negative(values, out=values)
71
+ np.exp(values, out=values)
72
+
73
+
74
+ class Spectrum:
75
+ """Reusable piecewise-linear RGB interpolation buffers for one sampler."""
76
+
77
+ def __init__(self, size: int, stops: tuple[tuple[float, float, float], ...]) -> None:
78
+ self.stops = stops
79
+ self.channels: Radiance = (buffer(size), buffer(size), buffer(size))
80
+ self._knots = np.linspace(0.0, 1.0, len(stops))
81
+ self._colors = np.asarray(stops).T.copy()
82
+
83
+ def sample(self, position: Field, light: Field) -> Radiance:
84
+ for channel, colors in zip(self.channels, self._colors, strict=True):
85
+ np.multiply(np.interp(position, self._knots, colors), light, out=channel)
86
+ return self.channels
87
+
88
+
89
+ class PhosphorField:
90
+ """Size-owned buffers; no terminal encoding, UI objects or theme knowledge."""
91
+
92
+ def __init__(self, grid: ParticleGrid, prepare: Prepare) -> None:
93
+ self.grid = grid
94
+ self._sample = prepare(grid)
95
+ dots = grid.width * grid.height
96
+ self._emission: Radiance = (buffer(dots), buffer(dots), buffer(dots))
97
+ self._mapped: Radiance = (buffer(dots), buffer(dots), buffer(dots))
98
+ self._root = buffer(dots)
99
+
100
+ def _map_light(self, light: Field, emission: Field, mapped: Field, decay: float) -> None:
101
+ np.multiply(emission, decay, out=emission)
102
+ np.maximum(light, emission, out=emission)
103
+ shape = (self.grid.height, self.grid.width)
104
+ source, target = emission.reshape(shape), mapped.reshape(shape)
105
+ # A four-neighbour stencil on views: no padding copies or row wraparound.
106
+ target.fill(0.0)
107
+ target[1:] += source[:-1]
108
+ target[:-1] += source[1:]
109
+ target[:, 1:] += source[:, :-1]
110
+ target[:, :-1] += source[:, 1:]
111
+ np.multiply(mapped, 0.075, out=mapped)
112
+ np.add(mapped, emission, out=mapped)
113
+ np.multiply(mapped, -1.5, out=mapped)
114
+ np.expm1(mapped, out=mapped)
115
+ np.negative(mapped, out=mapped)
116
+ # x**(3/4) uses two hardware square roots instead of a general power.
117
+ # Multiplying the roots last also preserves subnormal input light.
118
+ np.sqrt(mapped, out=self._root)
119
+ np.sqrt(self._root, out=mapped)
120
+ np.multiply(mapped, self._root, out=mapped)
121
+
122
+ def frame(self, seconds: float, elapsed: float) -> Radiance:
123
+ decay = exp(-max(0, elapsed) / 0.085)
124
+ # Numerical faults still reach the owning worker. Underflow is normal in
125
+ # Gaussian tails; do not alter the application's global NumPy policy.
126
+ with np.errstate(over="raise", invalid="raise", divide="raise", under="ignore"):
127
+ for light, emission, mapped in zip(
128
+ self._sample(seconds), self._emission, self._mapped, strict=True
129
+ ):
130
+ self._map_light(light, emission, mapped, decay)
131
+ return self._mapped
@@ -0,0 +1,169 @@
1
+ """Adapt a numerical particle engine to Braille cells and damaged terminal rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+
9
+ import numpy as np
10
+ from rich.color import Color
11
+ from rich.segment import Segment
12
+ from rich.style import Style
13
+ from textual.geometry import Region
14
+ from textual.strip import Strip
15
+
16
+ from ouro_tui_themes.dot_matrix.field import (
17
+ EngineFactory,
18
+ Field,
19
+ ParticleGrid,
20
+ PhosphorField,
21
+ Prepare,
22
+ )
23
+
24
+ _BRAILLE_BITS = (1, 8, 2, 16, 4, 32, 64, 128)
25
+ _THRESHOLDS = (0.08, 0.48, 0.68, 0.28, 0.23, 0.63, 0.43, 0.13)
26
+ _COLOR_THRESHOLDS = tuple(value * 0.25 for value in _THRESHOLDS)
27
+ _GLYPHS = (" ",) + tuple(chr(0x2800 + mask) for mask in range(1, 256))
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DotArtwork:
32
+ """Theme sampler, sampling span and replaceable numerical backend."""
33
+
34
+ prepare: Prepare
35
+ span: tuple[float, float]
36
+ engine_factory: EngineFactory = PhosphorField
37
+
38
+
39
+ class FrameStrip(Strip):
40
+ """Transient rows without self-referential no-op division caches.
41
+
42
+ Textual's full-width divide caches [self], retaining dynamic rows until cyclic
43
+ GC. A no-op needs no cache; real divisions retain Textual's implementation.
44
+ """
45
+
46
+ __slots__ = ("__weakref__",)
47
+
48
+ def divide(self, cuts: Iterable[int]) -> Sequence[Strip]:
49
+ cuts = tuple(cut for cut in cuts if cut <= self.cell_length)
50
+ if cuts == (self.cell_length,):
51
+ return (self,)
52
+ return super().divide(cuts)
53
+
54
+
55
+ @lru_cache(maxsize=4096)
56
+ def _led_style(rgb: int | None) -> Style:
57
+ return (
58
+ Style()
59
+ if rgb is None
60
+ else Style(color=Color.from_rgb(rgb >> 16, (rgb >> 8) & 255, rgb & 255))
61
+ )
62
+
63
+
64
+ class PhosphorRaster:
65
+ """One numerical engine and reusable terminal encoding buffers per size."""
66
+
67
+ def __init__(self, style: DotArtwork, columns: int, rows: int) -> None:
68
+ self.columns, self.rows = columns, rows
69
+ grid = ParticleGrid.create(columns * 2, rows * 4, span=style.span)
70
+ self._engine = style.engine_factory(grid, style.prepare)
71
+ self._luminance = np.empty(grid.width * grid.height, dtype=np.float64)
72
+ self._lanes = self._dot_views(self._luminance)
73
+ shape = (rows, columns)
74
+ self._peak = np.empty(shape, dtype=np.float64)
75
+ self._color = np.empty(shape, dtype=np.float64)
76
+ self._above = np.empty(shape, dtype=np.bool_)
77
+ self._visible = np.empty(shape, dtype=np.bool_)
78
+ self._masks = np.empty(shape, dtype=np.uint32)
79
+ self._channel = np.empty(shape, dtype=np.uint32)
80
+ self._packed = np.empty(shape, dtype=np.uint32)
81
+ self._cells = np.zeros(shape, dtype=np.uint32)
82
+ self._changed = np.empty(shape, dtype=np.bool_)
83
+ self._lines: tuple[Strip, ...] = ()
84
+ self.damage: tuple[Region, ...] = ()
85
+
86
+ def invalidate_frame(self) -> None:
87
+ """Require a full repaint while retaining the size-owned numerical engine."""
88
+ self._lines = ()
89
+ self.damage = ()
90
+
91
+ def _dot_views(self, field: Field) -> tuple[Field, ...]:
92
+ """Eight strided views of row-major dots; no dot-to-cell transpose/copy."""
93
+ blocks = np.asarray(field).reshape(self.rows, 4, self.columns, 2)
94
+ return tuple(blocks[:, dy, :, dx] for dy in range(4) for dx in range(2))
95
+
96
+ def frame(
97
+ self,
98
+ seconds: float,
99
+ elapsed: float,
100
+ *,
101
+ monochrome: bool = False,
102
+ background: tuple[int, int, int] = (0, 0, 0),
103
+ ) -> tuple[Strip, ...]:
104
+ mapped_channels = self._engine.frame(seconds, elapsed)
105
+ np.maximum(mapped_channels[0], mapped_channels[1], out=self._luminance)
106
+ np.maximum(self._luminance, mapped_channels[2], out=self._luminance)
107
+ self._peak[:] = self._lanes[0]
108
+ for lane in self._lanes[1:]:
109
+ np.maximum(self._peak, lane, out=self._peak)
110
+ if not monochrome:
111
+ self._peak *= 0.30
112
+ self._masks.fill(0)
113
+ thresholds = _THRESHOLDS if monochrome else _COLOR_THRESHOLDS
114
+ for lane, bit, threshold in zip(self._lanes, _BRAILLE_BITS, thresholds, strict=True):
115
+ np.greater(lane, threshold, out=self._above)
116
+ if not monochrome:
117
+ np.greater_equal(lane, self._peak, out=self._visible)
118
+ self._above &= self._visible
119
+ # Fade by coverage as well as color. Dark foregrounds may be raised
120
+ # by terminals, but absent dots cannot be brightened.
121
+ np.bitwise_or(self._masks, bit, out=self._masks, where=self._above)
122
+ self._packed[:] = self._masks
123
+ for mapped, base, shift in zip(mapped_channels, background, (24, 16, 8), strict=True):
124
+ lanes = self._dot_views(mapped)
125
+ self._color[:] = lanes[0]
126
+ for lane in lanes[1:]:
127
+ self._color += lane
128
+ self._color *= 1.25 / 8
129
+ np.clip(self._color, 0.0, 1.0, out=self._color)
130
+ # Screen-compose emitted light over the actual canvas. Zero light
131
+ # leaves the background unchanged, including in individual channels.
132
+ self._color *= 255 - base
133
+ self._color += base
134
+ np.copyto(self._channel, self._color, casting="unsafe")
135
+ np.left_shift(self._channel, shift, out=self._channel)
136
+ self._packed |= self._channel
137
+ np.equal(self._masks, 0, out=self._above)
138
+ np.copyto(self._packed, 0, where=self._above)
139
+ np.not_equal(self._packed, self._cells, out=self._changed)
140
+ if not self._lines:
141
+ self._changed.fill(True)
142
+ changed_rows = np.flatnonzero(self._changed.any(axis=1))
143
+ if not len(changed_rows):
144
+ self.damage = ()
145
+ return self._lines
146
+ self._cells[:] = self._packed
147
+ rows = list(self._lines) if self._lines else [Strip.blank(self.columns)] * self.rows
148
+ damage = []
149
+ for row in changed_rows.tolist():
150
+ changed = np.flatnonzero(self._changed[row])
151
+ first, last = int(changed[0]), int(changed[-1])
152
+ segments: list[Segment] = []
153
+ run: list[str] = []
154
+ previous: int | None = None
155
+ # Cross the Python/Rich boundary only for rows with changed final cells.
156
+ for cell in self._packed[row].tolist():
157
+ key = cell >> 8 if cell else None
158
+ if run and key != previous:
159
+ segments.append(Segment("".join(run), _led_style(previous)))
160
+ run.clear()
161
+ run.append(_GLYPHS[cell & 255])
162
+ previous = key
163
+ if run:
164
+ segments.append(Segment("".join(run), _led_style(previous)))
165
+ rows[row] = Strip(segments, self.columns)
166
+ damage.append(Region(first, row, last - first + 1, 1))
167
+ self._lines = tuple(rows)
168
+ self.damage = tuple(damage)
169
+ return self._lines
@@ -0,0 +1,29 @@
1
+ """Availability of the optional numerical backend; no renderer imports."""
2
+
3
+ from importlib import import_module
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ from packaging.specifiers import SpecifierSet
7
+ from packaging.version import InvalidVersion
8
+
9
+ _NUMPY_VERSIONS = SpecifierSet(">=2.3.3,<3")
10
+ _INSTALL_HINT = "Install ouro-agent[themes] in the OURO environment and restart the TUI."
11
+
12
+
13
+ def check_requirements() -> str | None:
14
+ """Check the actual extension, not just the presence of package metadata."""
15
+ try:
16
+ installed = version("numpy")
17
+ except PackageNotFoundError:
18
+ return f"numpy is not installed. {_INSTALL_HINT}"
19
+ try:
20
+ compatible = installed in _NUMPY_VERSIONS
21
+ except InvalidVersion:
22
+ compatible = False
23
+ if not compatible:
24
+ return f"numpy {installed} is incompatible; requires {_NUMPY_VERSIONS}. {_INSTALL_HINT}"
25
+ try:
26
+ import_module("numpy")
27
+ except (ImportError, OSError) as exc:
28
+ return f"numpy could not load: {exc}. {_INSTALL_HINT}"
29
+ return None
@@ -0,0 +1,187 @@
1
+ """Textual presentation and lifecycle for the selected welcome artwork."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from time import monotonic
7
+
8
+ from textual import events
9
+ from textual.geometry import Region, Size
10
+ from textual.strip import Strip
11
+ from textual.widget import Widget
12
+ from textual.worker import Worker
13
+
14
+ from ouro_tui_themes.dot_matrix.raster import DotArtwork, FrameStrip, PhosphorRaster
15
+
16
+
17
+ def _render_frame(
18
+ artwork: DotArtwork,
19
+ raster: PhosphorRaster | None,
20
+ size: Size,
21
+ seconds: float,
22
+ delta: float,
23
+ monochrome: bool,
24
+ background: tuple[int, int, int],
25
+ ) -> tuple[PhosphorRaster, tuple[Strip, ...], tuple[Region, ...]]:
26
+ """Compute with exclusive raster ownership and no access to mounted widgets."""
27
+ if raster is None or (raster.columns, raster.rows) != (size.width, size.height):
28
+ raster = PhosphorRaster(artwork, size.width, size.height)
29
+ lines = raster.frame(seconds, delta, monochrome=monochrome, background=background)
30
+ return raster, lines, raster.damage
31
+
32
+
33
+ class DotMatrix(Widget):
34
+ """Sample off-loop at up to 30 Hz, publishing only the current viewport."""
35
+
36
+ DEFAULT_CSS = """
37
+ DotMatrix { width: 100%; height: 1fr; min-height: 0; overflow: hidden; }
38
+ """
39
+
40
+ def __init__(self, style: DotArtwork) -> None:
41
+ super().__init__()
42
+ self.artwork = style
43
+ self.active = False
44
+ self.elapsed = 0.0
45
+ self._last_tick: float | None = None
46
+ self._frame_requested = asyncio.Event()
47
+ self._raster: PhosphorRaster | None = None
48
+ self._lines: tuple[Strip, ...] = ()
49
+ self._rendering = False
50
+ self._render_revision = 0
51
+ self._pending_frame: tuple[Size, float, float, bool, tuple[int, int, int]] | None = None
52
+
53
+ def on_mount(self) -> None:
54
+ _worker: Worker[None] = self.run_worker(self._render_frames, group="welcome-artwork")
55
+ self._paint(0)
56
+
57
+ def on_unmount(self) -> None:
58
+ self._render_revision += 1
59
+ self._pending_frame = None
60
+ self._frame_requested.set()
61
+ self._raster = None
62
+ self._lines = ()
63
+
64
+ def set_active(self, active: bool) -> None:
65
+ if active == self.active:
66
+ return
67
+ self.active = active
68
+ self._render_revision += 1
69
+ self._last_tick = None
70
+ self._pending_frame = None
71
+ self._frame_requested.set()
72
+ if self.is_mounted and (
73
+ active
74
+ or self._raster is None
75
+ or (self._raster.columns, self._raster.rows) != (self.size.width, self.size.height)
76
+ ):
77
+ self._paint(0)
78
+
79
+ def _tick(self) -> None:
80
+ now = monotonic()
81
+ delta = 0.0 if self._last_tick is None else now - self._last_tick
82
+ self._last_tick = now
83
+ self.elapsed += delta
84
+ self._paint(delta)
85
+
86
+ def on_resize(self, event: events.Resize) -> None:
87
+ # A new size needs a frame even when animation playback is paused.
88
+ self._paint(0)
89
+
90
+ def refresh_theme(self) -> None:
91
+ if self.is_mounted:
92
+ self._paint(0)
93
+
94
+ def _paint(self, delta: float) -> None:
95
+ self._render_revision += 1
96
+ self._pending_frame = None
97
+ size = self.size
98
+ columns, rows = size
99
+ if columns < 1 or rows < 1:
100
+ return
101
+ self._pending_frame = (
102
+ size,
103
+ self.elapsed,
104
+ delta,
105
+ self.app.no_color,
106
+ self.background_colors[1].rgb,
107
+ )
108
+ self._frame_requested.set()
109
+
110
+ async def _render_frames(self) -> None:
111
+ """One lifetime-owned worker; no frame queue or per-frame Textual workers.
112
+
113
+ Deadlines are measured from the start of computation. Slow frames yield
114
+ before continuing at current playback time, without waiting an extra tick
115
+ or replaying missed frames. Geometry and static repaint requests wake the
116
+ same loop, including when playback is paused.
117
+ """
118
+ deadline = 0.0
119
+ try:
120
+ # A worker can start before the Mount handler has completed.
121
+ # Textual cancels this owned worker when the widget is removed.
122
+ while True:
123
+ if self._pending_frame is None:
124
+ if self.active and self.size.area:
125
+ delay = max(0.0, deadline - monotonic())
126
+ if delay:
127
+ try:
128
+ await asyncio.wait_for(self._frame_requested.wait(), delay)
129
+ except TimeoutError:
130
+ pass
131
+ else:
132
+ await asyncio.sleep(0)
133
+ if self._pending_frame is None and self.active:
134
+ self._tick()
135
+ else:
136
+ await self._frame_requested.wait()
137
+ self._frame_requested.clear()
138
+ if self._pending_frame is None:
139
+ continue
140
+ revision = self._render_revision
141
+ size, seconds, delta, monochrome, background = self._pending_frame
142
+ self._pending_frame = None
143
+ self._rendering = True
144
+ started = monotonic()
145
+ # Keep cadence through timer jitter, but do not queue missed frames.
146
+ deadline = (deadline if 0 <= started - deadline < 1 / 30 else started) + 1 / 30
147
+ raster, lines, damage = await asyncio.to_thread(
148
+ _render_frame,
149
+ self.artwork,
150
+ self._raster,
151
+ size,
152
+ seconds,
153
+ delta,
154
+ monochrome,
155
+ background,
156
+ )
157
+ self._rendering = False
158
+ if not self.is_mounted:
159
+ continue
160
+ if size != self.size:
161
+ self._raster = None
162
+ continue
163
+ self._raster = raster
164
+ if revision != self._render_revision:
165
+ # Numerical state belongs to this size; the terminal baseline
166
+ # is valid only for frames that were actually published.
167
+ raster.invalidate_frame()
168
+ continue
169
+ self._lines = lines
170
+ if damage:
171
+ self.refresh(*damage)
172
+ finally:
173
+ self._rendering = False
174
+
175
+ def render_line(self, y: int) -> Strip:
176
+ if y < len(self._lines):
177
+ return (
178
+ self._lines[y]
179
+ .apply_style(self.rich_style)
180
+ .adjust_cell_length(self.size.width, self.rich_style)
181
+ )
182
+ return Strip.blank(self.size.width, self.rich_style)
183
+
184
+ def render_lines(self, crop: Region) -> list[Strip]:
185
+ # Preserve Textual CSS, opacity, filters and cropping before transferring
186
+ # disposable frame rows to the compositor.
187
+ return [FrameStrip(line, line.cell_length) for line in super().render_lines(crop)]
File without changes
@@ -0,0 +1,41 @@
1
+ """Bundled theme metadata and lazy loading of each theme-owned page."""
2
+
3
+ from functools import partial
4
+ from importlib import import_module
5
+ from typing import cast
6
+
7
+ from ouro.tui.welcome.model import WelcomePage, WelcomeStyle
8
+ from ouro_tui_themes.dot_matrix.requirements import check_requirements
9
+
10
+
11
+ def _create_page(module: str) -> WelcomePage:
12
+ # Only the selected theme loads its page and optional computation dependencies.
13
+ theme = import_module(f"ouro_tui_themes.styles.{module}")
14
+ return cast(WelcomePage, theme.create_page())
15
+
16
+
17
+ def _particle_style(module: str, style_id: str, label: str, description: str) -> WelcomeStyle:
18
+ return WelcomeStyle(
19
+ style_id,
20
+ label,
21
+ description,
22
+ partial(_create_page, module),
23
+ check_requirements=check_requirements,
24
+ )
25
+
26
+
27
+ STYLES = (
28
+ _particle_style(
29
+ "event_horizon", "event-horizon", "Horizon", "An ember disk folded around a dark sun."
30
+ ),
31
+ _particle_style(
32
+ "electron_bloom", "electron-bloom", "Bloom", "Ice and copper petals exchanging light."
33
+ ),
34
+ _particle_style(
35
+ "interference", "interference", "Tide", "Jade light woven into a breathing caustic."
36
+ ),
37
+ _particle_style("clifford", "clifford-torus", "Iris", "An iris of folded ultraviolet light."),
38
+ _particle_style(
39
+ "crystal", "quasicrystal", "Prism", "An aperiodic rose cut from copper and light."
40
+ ),
41
+ )
@@ -0,0 +1,133 @@
1
+ """A Clifford torus rotated in four dimensions, sampled by inverse projection."""
2
+
3
+ from math import cos, exp, sin
4
+
5
+ import numpy as np
6
+ from rich.text import Text
7
+ from textual.containers import Vertical
8
+ from textual.widgets import Static
9
+
10
+ from ouro.tui.welcome.centered import CenteredWelcomePage
11
+ from ouro_tui_themes.dot_matrix.field import (
12
+ ParticleGrid,
13
+ Radiance,
14
+ Sampler,
15
+ Spectrum,
16
+ band,
17
+ buffer,
18
+ )
19
+ from ouro_tui_themes.dot_matrix.raster import DotArtwork
20
+ from ouro_tui_themes.dot_matrix.widget import DotMatrix
21
+
22
+ _SPECTRUM = ((0.21, 0.12, 0.58), (0.51, 0.34, 1.0), (0.38, 0.86, 1.0), (0.92, 0.98, 1.0))
23
+ _DEPTHS = tuple(-2.2 + index * (4.4 / 13) for index in range(13)) + (2.2,)
24
+
25
+
26
+ def prepare(grid: ParticleGrid) -> Sampler:
27
+ x, y = grid.x.copy(), grid.y.copy()
28
+ np.multiply(x, 2.5, out=x)
29
+ np.multiply(y, 2.5, out=y)
30
+ (
31
+ radial,
32
+ angle,
33
+ rotated_x,
34
+ rotated_y,
35
+ rx,
36
+ ry,
37
+ work,
38
+ surface,
39
+ weave,
40
+ density,
41
+ light,
42
+ transmission,
43
+ position,
44
+ falloff,
45
+ ) = (buffer(len(x)) for _ in range(14))
46
+ np.multiply(x, x, out=radial)
47
+ np.multiply(y, y, out=work)
48
+ np.add(radial, work, out=radial)
49
+ np.arctan2(y, x, out=angle)
50
+ np.multiply(radial, -0.32, out=falloff)
51
+ np.exp(falloff, out=falloff)
52
+ # The inverse stereographic denominator depends only on the fixed grid/depth.
53
+ # Keep one reciprocal plane per ray sample, not four projected coordinates.
54
+ depths = tuple(
55
+ (depth, exp(-depth * depth * 0.32) * 0.65, 1.0 / (radial + depth * depth + 1.0))
56
+ for depth in _DEPTHS
57
+ )
58
+ spectrum = Spectrum(len(x), _SPECTRUM)
59
+
60
+ def sample(seconds: float) -> Radiance:
61
+ a = 0.18 * sin(seconds * 0.19)
62
+ b = 0.55 + 0.35 * sin(seconds * 0.13)
63
+ ca, sa, cb, sb = cos(a), sin(a), cos(b), sin(b)
64
+ light.fill(0.0)
65
+ transmission.fill(1.0)
66
+ np.multiply(x, 2.0 * ca, out=rotated_x)
67
+ np.add(rotated_x, 2.0 * sa, out=rotated_x)
68
+ np.multiply(y, 2.0 * cb, out=rotated_y)
69
+ # Integrate fixed view rays through S3; rotate the projected surface,
70
+ # never the receiving LED grid.
71
+ for depth, attenuation, reciprocal in depths:
72
+ np.multiply(rotated_x, reciprocal, out=rx)
73
+ np.subtract(rx, sa, out=rx)
74
+ np.subtract(rotated_y, 2 * depth * sb, out=ry)
75
+ np.multiply(ry, reciprocal, out=ry)
76
+ np.multiply(rx, rx, out=surface)
77
+ np.multiply(ry, ry, out=work)
78
+ np.add(surface, work, out=surface)
79
+ np.subtract(surface, 0.5, out=surface)
80
+ np.add(rx, ry, out=weave)
81
+ np.multiply(weave, 28.0, out=weave)
82
+ np.subtract(weave, seconds * 0.5, out=weave)
83
+ np.cos(weave, out=weave)
84
+ np.multiply(weave, weave, out=weave)
85
+ np.multiply(weave, 0.35, out=weave)
86
+ np.add(weave, 0.65, out=weave)
87
+ density[:] = surface
88
+ band(density, 0.07)
89
+ np.multiply(density, falloff, out=density)
90
+ np.multiply(density, attenuation, out=density)
91
+ np.multiply(density, weave, out=work)
92
+ np.multiply(work, transmission, out=work)
93
+ np.multiply(work, 1 - depth * 0.18, out=work)
94
+ np.add(light, work, out=light)
95
+ np.multiply(density, 0.55, out=work)
96
+ np.subtract(1.0, work, out=work)
97
+ np.multiply(transmission, work, out=transmission)
98
+ np.add(angle, seconds * 0.17, out=position)
99
+ np.sin(position, out=position)
100
+ np.multiply(position, 0.5, out=position)
101
+ np.add(position, 0.5, out=position)
102
+ np.multiply(light, 1.8, out=light)
103
+ return spectrum.sample(position, light)
104
+
105
+ return sample
106
+
107
+
108
+ class IrisPage(CenteredWelcomePage):
109
+ DEFAULT_CSS = """
110
+ IrisPage .phosphor-art {
111
+ layout: vertical; width: 100%; height: 1fr; min-height: 0; margin-bottom: 1;
112
+ }
113
+ IrisPage .art-caption { width: 100%; height: 1; text-align: center; }
114
+ """
115
+
116
+ def __init__(self) -> None:
117
+ self.artwork = DotArtwork(prepare, span=(5.8, 2.7))
118
+ self.matrix = DotMatrix(self.artwork)
119
+ self._caption = Static(classes="art-caption")
120
+ super().__init__(Vertical(self.matrix, self._caption, classes="phosphor-art"))
121
+
122
+ def set_active(self, active: bool) -> None:
123
+ self.matrix.set_active(active)
124
+
125
+ def refresh_theme(self) -> None:
126
+ theme = self.tui_theme
127
+ caption = Text("O U R O", style=theme.text_emphasis)
128
+ caption.append(" / IRIS", style=theme.text_lowlight)
129
+ self._caption.update(caption)
130
+
131
+
132
+ def create_page() -> IrisPage:
133
+ return IrisPage()
@@ -0,0 +1,103 @@
1
+ """Five reciprocal-lattice waves reveal a nonperiodic diffraction rosette."""
2
+
3
+ from math import cos, pi, sin
4
+
5
+ import numpy as np
6
+ from rich.text import Text
7
+ from textual.containers import Vertical
8
+ from textual.widgets import Static
9
+
10
+ from ouro.tui.welcome.centered import CenteredWelcomePage
11
+ from ouro_tui_themes.dot_matrix.field import (
12
+ ParticleGrid,
13
+ Radiance,
14
+ Sampler,
15
+ Spectrum,
16
+ band,
17
+ buffer,
18
+ clip,
19
+ )
20
+ from ouro_tui_themes.dot_matrix.raster import DotArtwork
21
+ from ouro_tui_themes.dot_matrix.widget import DotMatrix
22
+
23
+ _SPECTRUM = ((0.29, 0.05, 0.09), (0.86, 0.21, 0.19), (1.0, 0.64, 0.39), (1.0, 0.97, 0.82))
24
+
25
+
26
+ def prepare(grid: ParticleGrid) -> Sampler:
27
+ x, y = grid.x, grid.y
28
+ work, aperture = buffer(len(x)), buffer(len(x))
29
+ projections = []
30
+ for index in range(5):
31
+ angle = index * pi / 5
32
+ projection = buffer(len(x))
33
+ np.multiply(x, cos(angle), out=projection)
34
+ np.multiply(y, sin(angle), out=work)
35
+ np.add(projection, work, out=projection)
36
+ np.multiply(projection, 15.0, out=projection)
37
+ projections.append((np.cos(projection), np.sin(projection)))
38
+ np.hypot(x, y, out=aperture)
39
+ np.divide(aperture, 1.05, out=aperture)
40
+ np.power(aperture, 8.0, out=aperture)
41
+ np.negative(aperture, out=aperture)
42
+ np.exp(aperture, out=aperture)
43
+ wave, quadrature, light, position = (buffer(len(x)) for _ in range(4))
44
+ spectrum = Spectrum(len(x), _SPECTRUM)
45
+
46
+ def sample(seconds: float) -> Radiance:
47
+ wave.fill(0.0)
48
+ quadrature.fill(0.0)
49
+ for index, (spatial_cos, spatial_sin) in enumerate(projections):
50
+ phase = sin(seconds * 0.17 + index * 2.4) * 1.6
51
+ cp, sp = cos(phase), sin(phase)
52
+ np.multiply(spatial_cos, cp, out=work)
53
+ np.add(wave, work, out=wave)
54
+ np.multiply(spatial_sin, sp, out=work)
55
+ np.subtract(wave, work, out=wave)
56
+ np.multiply(spatial_sin, cp, out=work)
57
+ np.add(quadrature, work, out=quadrature)
58
+ np.multiply(spatial_cos, sp, out=work)
59
+ np.subtract(quadrature, work, out=quadrature)
60
+ np.absolute(wave, out=wave)
61
+ np.subtract(wave, 0.6, out=light)
62
+ np.divide(light, 4.4, out=light)
63
+ clip(light, 0.0, 1.0)
64
+ np.power(light, 1.35, out=light)
65
+ np.multiply(light, 1.7, out=light)
66
+ band(quadrature, 0.35)
67
+ np.multiply(quadrature, 0.3, out=quadrature)
68
+ np.add(light, quadrature, out=light)
69
+ np.multiply(light, aperture, out=light)
70
+ np.multiply(light, 1.8, out=light)
71
+ np.multiply(wave, 0.12, out=position)
72
+ np.add(position, 0.4, out=position)
73
+ return spectrum.sample(position, light)
74
+
75
+ return sample
76
+
77
+
78
+ class PrismPage(CenteredWelcomePage):
79
+ DEFAULT_CSS = """
80
+ PrismPage .phosphor-art {
81
+ layout: vertical; width: 100%; height: 1fr; min-height: 0; margin-bottom: 1;
82
+ }
83
+ PrismPage .art-caption { width: 100%; height: 1; text-align: center; }
84
+ """
85
+
86
+ def __init__(self) -> None:
87
+ self.artwork = DotArtwork(prepare, span=(5.8, 2.7))
88
+ self.matrix = DotMatrix(self.artwork)
89
+ self._caption = Static(classes="art-caption")
90
+ super().__init__(Vertical(self.matrix, self._caption, classes="phosphor-art"))
91
+
92
+ def set_active(self, active: bool) -> None:
93
+ self.matrix.set_active(active)
94
+
95
+ def refresh_theme(self) -> None:
96
+ theme = self.tui_theme
97
+ caption = Text("O U R O", style=theme.text_emphasis)
98
+ caption.append(" / PRISM", style=theme.text_lowlight)
99
+ self._caption.update(caption)
100
+
101
+
102
+ def create_page() -> PrismPage:
103
+ return PrismPage()
@@ -0,0 +1,104 @@
1
+ """Phase beating between angular orbital modes and their radial nodes."""
2
+
3
+ from math import cos, sin
4
+
5
+ import numpy as np
6
+ from rich.text import Text
7
+ from textual.containers import Vertical
8
+ from textual.widgets import Static
9
+
10
+ from ouro.tui.welcome.centered import CenteredWelcomePage
11
+ from ouro_tui_themes.dot_matrix.field import (
12
+ ParticleGrid,
13
+ Radiance,
14
+ Sampler,
15
+ Spectrum,
16
+ band,
17
+ buffer,
18
+ )
19
+ from ouro_tui_themes.dot_matrix.raster import DotArtwork
20
+ from ouro_tui_themes.dot_matrix.widget import DotMatrix
21
+
22
+ _SPECTRUM = ((0.11, 0.49, 0.83), (0.62, 0.91, 1.0), (1.0, 0.92, 0.72), (1.0, 0.37, 0.08))
23
+
24
+
25
+ def prepare(grid: ParticleGrid) -> Sampler:
26
+ x, y = grid.x, grid.y
27
+ radius, angle, radial, overtone, envelope, nucleus = (buffer(len(x)) for _ in range(6))
28
+ np.hypot(x, y, out=radius)
29
+ np.arctan2(y, x, out=angle)
30
+ np.multiply(radius, 3.6, out=radial)
31
+ np.subtract(radial, 0.4, out=radial)
32
+ np.sin(radial, out=radial)
33
+ np.multiply(radius, 5.3, out=overtone)
34
+ np.sin(overtone, out=overtone)
35
+ np.multiply(overtone, 0.32, out=overtone)
36
+ np.divide(radius, 1.02, out=envelope)
37
+ np.power(envelope, 4.0, out=envelope)
38
+ np.negative(envelope, out=envelope)
39
+ np.exp(envelope, out=envelope)
40
+ nucleus[:] = radius
41
+ band(nucleus, 0.105)
42
+ np.multiply(nucleus, 0.65, out=nucleus)
43
+ # Separate the fixed angular modes from their scalar time phases exactly.
44
+ radial_cos = np.cos(angle * 3.0) * radial
45
+ radial_sin = np.sin(angle * 3.0) * radial
46
+ overtone_sin = np.sin(angle * 2.0) * overtone
47
+ overtone_cos = np.cos(angle * 2.0) * overtone
48
+ amplitude, work, light, position = (buffer(len(x)) for _ in range(4))
49
+ spectrum = Spectrum(len(x), _SPECTRUM)
50
+
51
+ def sample(seconds: float) -> Radiance:
52
+ phase = seconds * 0.24
53
+ np.multiply(radial_cos, cos(phase), out=amplitude)
54
+ np.multiply(radial_sin, sin(phase), out=work)
55
+ np.subtract(amplitude, work, out=amplitude)
56
+ np.multiply(overtone_sin, cos(phase * 0.7), out=work)
57
+ np.add(amplitude, work, out=amplitude)
58
+ np.multiply(overtone_cos, sin(phase * 0.7), out=work)
59
+ np.subtract(amplitude, work, out=amplitude)
60
+ np.multiply(amplitude, amplitude, out=light)
61
+ np.multiply(light, envelope, out=light)
62
+ np.multiply(light, 1.65, out=light)
63
+ np.absolute(amplitude, out=work)
64
+ np.subtract(work, 0.65, out=work)
65
+ band(work, 0.06)
66
+ np.multiply(work, envelope, out=work)
67
+ np.multiply(work, 0.25, out=work)
68
+ np.add(light, work, out=light)
69
+ np.add(light, nucleus, out=light)
70
+ np.multiply(amplitude, 2.4, out=position)
71
+ np.tanh(position, out=position)
72
+ np.multiply(position, 0.5, out=position)
73
+ np.add(position, 0.5, out=position)
74
+ return spectrum.sample(position, light)
75
+
76
+ return sample
77
+
78
+
79
+ class BloomPage(CenteredWelcomePage):
80
+ DEFAULT_CSS = """
81
+ BloomPage .phosphor-art {
82
+ layout: vertical; width: 100%; height: 1fr; min-height: 0; margin-bottom: 1;
83
+ }
84
+ BloomPage .art-caption { width: 100%; height: 1; text-align: center; }
85
+ """
86
+
87
+ def __init__(self) -> None:
88
+ self.artwork = DotArtwork(prepare, span=(5.8, 2.7))
89
+ self.matrix = DotMatrix(self.artwork)
90
+ self._caption = Static(classes="art-caption")
91
+ super().__init__(Vertical(self.matrix, self._caption, classes="phosphor-art"))
92
+
93
+ def set_active(self, active: bool) -> None:
94
+ self.matrix.set_active(active)
95
+
96
+ def refresh_theme(self) -> None:
97
+ theme = self.tui_theme
98
+ caption = Text("O U R O", style=theme.text_emphasis)
99
+ caption.append(" / BLOOM", style=theme.text_lowlight)
100
+ self._caption.update(caption)
101
+
102
+
103
+ def create_page() -> BloomPage:
104
+ return BloomPage()
@@ -0,0 +1,122 @@
1
+ """An occulted accretion disk and its bent upper and lower light paths."""
2
+
3
+ from math import sin
4
+
5
+ import numpy as np
6
+ from rich.text import Text
7
+ from textual.containers import Vertical
8
+ from textual.widgets import Static
9
+
10
+ from ouro.tui.welcome.centered import CenteredWelcomePage
11
+ from ouro_tui_themes.dot_matrix.field import (
12
+ ParticleGrid,
13
+ Radiance,
14
+ Sampler,
15
+ Spectrum,
16
+ band,
17
+ buffer,
18
+ clip,
19
+ )
20
+ from ouro_tui_themes.dot_matrix.raster import DotArtwork
21
+ from ouro_tui_themes.dot_matrix.widget import DotMatrix
22
+
23
+ _SPECTRUM = ((0.30, 0.045, 0.008), (1.0, 0.27, 0.045), (1.0, 0.78, 0.38), (1.0, 0.98, 0.86))
24
+
25
+
26
+ def prepare(grid: ParticleGrid) -> Sampler:
27
+ x, y = grid.x, grid.y
28
+ (tilted, radius, angle, disk_radius, turbulence, work, disk, light, position, beaming) = (
29
+ buffer(len(x)) for _ in range(10)
30
+ )
31
+ np.divide(x, -1.6, out=beaming)
32
+ clip(beaming, -0.7, 1.0)
33
+ np.multiply(beaming, 0.48, out=beaming)
34
+ np.add(beaming, 0.65, out=beaming)
35
+ spectrum = Spectrum(len(x), _SPECTRUM)
36
+
37
+ def sample(seconds: float) -> Radiance:
38
+ tilt = 0.10 + 0.055 * sin(seconds * 0.13)
39
+ np.multiply(x, tilt, out=tilted)
40
+ np.subtract(y, tilted, out=tilted)
41
+ np.hypot(x, tilted, out=radius)
42
+ np.arctan2(tilted, x, out=angle)
43
+ np.divide(tilted, 0.24, out=work)
44
+ np.hypot(x, work, out=disk_radius)
45
+ np.multiply(disk_radius, 21.0, out=turbulence)
46
+ np.subtract(turbulence, seconds * 1.7, out=turbulence)
47
+ np.multiply(angle, 3.0, out=work)
48
+ np.add(turbulence, work, out=turbulence)
49
+ np.sin(turbulence, out=turbulence)
50
+ np.multiply(turbulence, 0.16, out=turbulence)
51
+ np.add(turbulence, 0.76, out=turbulence)
52
+ np.multiply(disk_radius, 43.0, out=work)
53
+ np.multiply(angle, 7.0, out=disk)
54
+ np.add(work, disk, out=work)
55
+ np.add(work, seconds * 0.8, out=work)
56
+ np.sin(work, out=work)
57
+ np.multiply(work, 0.08, out=work)
58
+ np.add(turbulence, work, out=turbulence)
59
+ np.subtract(disk_radius, 1.13, out=disk)
60
+ band(disk, 0.62)
61
+ np.multiply(disk, turbulence, out=disk)
62
+ np.multiply(disk, beaming, out=light)
63
+ # The far-side disk is seen over the shadow; the near side hides its base.
64
+ np.add(angle, 1.1, out=work)
65
+ np.cos(work, out=work)
66
+ np.multiply(work, 0.36, out=work)
67
+ np.add(work, 0.64, out=work)
68
+ np.subtract(radius, 0.64, out=disk)
69
+ band(disk, 0.085)
70
+ np.multiply(disk, work, out=disk)
71
+ np.add(light, disk, out=light)
72
+ np.subtract(radius, 0.70, out=work)
73
+ band(work, 0.20)
74
+ np.multiply(work, 0.17, out=work)
75
+ np.add(light, work, out=light)
76
+ np.subtract(radius, 0.535, out=work)
77
+ band(work, 0.018)
78
+ np.multiply(work, 0.9, out=work)
79
+ np.add(light, work, out=light)
80
+ np.subtract(radius, 0.49, out=work)
81
+ np.multiply(work, 55.0, out=work)
82
+ clip(work, 0.0, 1.0)
83
+ np.multiply(light, work, out=light)
84
+ np.subtract(radius, 1.9, out=work)
85
+ np.absolute(work, out=disk)
86
+ np.add(work, disk, out=work)
87
+ np.multiply(work, -1.5, out=work)
88
+ np.exp(work, out=work)
89
+ np.multiply(light, work, out=light)
90
+ np.multiply(light, 1.15, out=position)
91
+ np.multiply(light, 1.65, out=light)
92
+ return spectrum.sample(position, light)
93
+
94
+ return sample
95
+
96
+
97
+ class HorizonPage(CenteredWelcomePage):
98
+ DEFAULT_CSS = """
99
+ HorizonPage .phosphor-art {
100
+ layout: vertical; width: 100%; height: 1fr; min-height: 0; margin-bottom: 1;
101
+ }
102
+ HorizonPage .art-caption { width: 100%; height: 1; text-align: center; }
103
+ """
104
+
105
+ def __init__(self) -> None:
106
+ self.artwork = DotArtwork(prepare, span=(5.8, 2.7))
107
+ self.matrix = DotMatrix(self.artwork)
108
+ self._caption = Static(classes="art-caption")
109
+ super().__init__(Vertical(self.matrix, self._caption, classes="phosphor-art"))
110
+
111
+ def set_active(self, active: bool) -> None:
112
+ self.matrix.set_active(active)
113
+
114
+ def refresh_theme(self) -> None:
115
+ theme = self.tui_theme
116
+ caption = Text("O U R O", style=theme.text_emphasis)
117
+ caption.append(" / HORIZON", style=theme.text_lowlight)
118
+ self._caption.update(caption)
119
+
120
+
121
+ def create_page() -> HorizonPage:
122
+ return HorizonPage()
@@ -0,0 +1,100 @@
1
+ """A caustic aperture formed by coherent sources and a slowly drifting phase."""
2
+
3
+ from math import cos, sin
4
+
5
+ import numpy as np
6
+ from rich.text import Text
7
+ from textual.containers import Vertical
8
+ from textual.widgets import Static
9
+
10
+ from ouro.tui.welcome.centered import CenteredWelcomePage
11
+ from ouro_tui_themes.dot_matrix.field import (
12
+ ParticleGrid,
13
+ Radiance,
14
+ Sampler,
15
+ Spectrum,
16
+ band,
17
+ buffer,
18
+ )
19
+ from ouro_tui_themes.dot_matrix.raster import DotArtwork
20
+ from ouro_tui_themes.dot_matrix.widget import DotMatrix
21
+
22
+ _SPECTRUM = ((0.025, 0.19, 0.25), (0.07, 0.66, 0.63), (0.49, 0.95, 0.77), (0.95, 1.0, 0.86))
23
+
24
+
25
+ def prepare(grid: ParticleGrid) -> Sampler:
26
+ x, y = grid.x, grid.y.copy()
27
+ left, right, lower, aperture, center = (buffer(len(x)) for _ in range(5))
28
+ np.add(x, 0.83, out=left)
29
+ np.hypot(left, y, out=left)
30
+ np.subtract(x, 0.83, out=right)
31
+ np.hypot(right, y, out=right)
32
+ np.subtract(y, 1.2, out=lower)
33
+ np.hypot(x, lower, out=lower)
34
+ np.subtract(left, right, out=center)
35
+ band(center, 0.05)
36
+ np.multiply(center, 0.1, out=center)
37
+ np.multiply(left, 18.0, out=left)
38
+ np.multiply(right, 18.0, out=right)
39
+ np.multiply(lower, 15.0, out=lower)
40
+ np.divide(x, 2.1, out=aperture)
41
+ np.power(aperture, 6.0, out=aperture)
42
+ np.divide(y, 0.9, out=y)
43
+ np.power(y, 6.0, out=y)
44
+ np.add(aperture, y, out=aperture)
45
+ np.negative(aperture, out=aperture)
46
+ np.exp(aperture, out=aperture)
47
+ # Both side sources share a time frequency, so sum their spatial bases once.
48
+ side_cos = np.cos(left) + np.cos(right)
49
+ side_sin = np.sin(left) - np.sin(right)
50
+ lower_cos, lower_sin = np.cos(lower) * 0.65, np.sin(lower) * 0.65
51
+ wave, work, position, light = (buffer(len(x)) for _ in range(4))
52
+ spectrum = Spectrum(len(x), _SPECTRUM)
53
+
54
+ def sample(seconds: float) -> Radiance:
55
+ phase = seconds * 0.55
56
+ np.multiply(side_cos, cos(phase), out=wave)
57
+ np.multiply(side_sin, sin(phase), out=work)
58
+ np.add(wave, work, out=wave)
59
+ np.multiply(lower_cos, cos(phase * 0.6), out=work)
60
+ np.add(wave, work, out=wave)
61
+ np.multiply(lower_sin, sin(phase * 0.6), out=work)
62
+ np.subtract(wave, work, out=wave)
63
+ np.absolute(wave, out=position)
64
+ np.divide(position, 2.65, out=position)
65
+ np.square(position, out=light)
66
+ np.multiply(light, position, out=light)
67
+ np.multiply(light, 1.7, out=light)
68
+ np.add(light, center, out=light)
69
+ np.multiply(light, aperture, out=light)
70
+ return spectrum.sample(position, light)
71
+
72
+ return sample
73
+
74
+
75
+ class TidePage(CenteredWelcomePage):
76
+ DEFAULT_CSS = """
77
+ TidePage .phosphor-art {
78
+ layout: vertical; width: 100%; height: 1fr; min-height: 0; margin-bottom: 1;
79
+ }
80
+ TidePage .art-caption { width: 100%; height: 1; text-align: center; }
81
+ """
82
+
83
+ def __init__(self) -> None:
84
+ self.artwork = DotArtwork(prepare, span=(5.8, 2.7))
85
+ self.matrix = DotMatrix(self.artwork)
86
+ self._caption = Static(classes="art-caption")
87
+ super().__init__(Vertical(self.matrix, self._caption, classes="phosphor-art"))
88
+
89
+ def set_active(self, active: bool) -> None:
90
+ self.matrix.set_active(active)
91
+
92
+ def refresh_theme(self) -> None:
93
+ theme = self.tui_theme
94
+ caption = Text("O U R O", style=theme.text_emphasis)
95
+ caption.append(" / TIDE", style=theme.text_lowlight)
96
+ self._caption.update(caption)
97
+
98
+
99
+ def create_page() -> TidePage:
100
+ return TidePage()