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.
- ouro_tui_themes-0.3.6/.gitignore +38 -0
- ouro_tui_themes-0.3.6/LICENSE +21 -0
- ouro_tui_themes-0.3.6/PKG-INFO +33 -0
- ouro_tui_themes-0.3.6/README.md +22 -0
- ouro_tui_themes-0.3.6/pyproject.toml +19 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/__init__.py +1 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/dot_matrix/__init__.py +1 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/dot_matrix/field.py +131 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/dot_matrix/raster.py +169 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/dot_matrix/requirements.py +29 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/dot_matrix/widget.py +187 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/py.typed +0 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/styles/__init__.py +41 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/styles/clifford.py +133 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/styles/crystal.py +103 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/styles/electron_bloom.py +104 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/styles/event_horizon.py +122 -0
- ouro_tui_themes-0.3.6/src/ouro_tui_themes/styles/interference.py +100 -0
|
@@ -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()
|