MNutils 1.2.0__py3-none-any.whl
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.
- mnutils/GEExam.py +495 -0
- mnutils/GESeries.py +1578 -0
- mnutils/__init__.py +54 -0
- mnutils/_scripts.py +177 -0
- mnutils/fitting/AMARES.py +831 -0
- mnutils/fitting/__init__.py +5 -0
- mnutils/matlab/__init__.py +13 -0
- mnutils/matlab/setup_matlab.py +116 -0
- mnutils/plotting/__init__.py +38 -0
- mnutils/plotting/_widgets/__init__.py +5 -0
- mnutils/plotting/_widgets/_html.py +34 -0
- mnutils/plotting/_widgets/_shared/__init__.py +50 -0
- mnutils/plotting/_widgets/_shared/dom.js +124 -0
- mnutils/plotting/_widgets/_shared/theme.css +140 -0
- mnutils/plotting/_widgets/image_grid.css +202 -0
- mnutils/plotting/_widgets/image_grid.js +212 -0
- mnutils/plotting/_widgets/image_grid.py +86 -0
- mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
- mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
- mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
- mnutils/plotting/_widgets/slice_viewer.css +38 -0
- mnutils/plotting/_widgets/slice_viewer.js +41 -0
- mnutils/plotting/_widgets/slice_viewer.py +35 -0
- mnutils/plotting/images.py +1399 -0
- mnutils/plotting/saving.py +138 -0
- mnutils/plotting/spectra.py +357 -0
- mnutils/py.typed +0 -0
- mnutils/testing/__init__.py +3 -0
- mnutils/testing/_spectra.py +338 -0
- mnutils/testing/synthetic.py +373 -0
- mnutils/utils/__init__.py +25 -0
- mnutils/utils/data_loaders.py +218 -0
- mnutils/utils/file_helpers.py +708 -0
- mnutils/utils/images.py +202 -0
- mnutils/utils/nifti.py +379 -0
- mnutils/utils/spectra.py +149 -0
- mnutils-1.2.0.dist-info/METADATA +161 -0
- mnutils-1.2.0.dist-info/RECORD +41 -0
- mnutils-1.2.0.dist-info/WHEEL +4 -0
- mnutils-1.2.0.dist-info/entry_points.txt +7 -0
- mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MATLAB Engine helpers (requires the optional `matlabengine` dependency)."""
|
|
2
|
+
|
|
3
|
+
from .setup_matlab import (
|
|
4
|
+
add_matlablatest_path,
|
|
5
|
+
connect_to_matlab,
|
|
6
|
+
setup_util_path,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"add_matlablatest_path",
|
|
11
|
+
"connect_to_matlab",
|
|
12
|
+
"setup_util_path",
|
|
13
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import matlab.engine
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def connect_to_matlab() -> matlab.engine.MatlabEngine:
|
|
8
|
+
"""Connect to an existing shared MATLAB session, or start a new one.
|
|
9
|
+
|
|
10
|
+
Make sure to only call this function once per session — a second call
|
|
11
|
+
to connect to the same shared engine raises an `EngineError`.
|
|
12
|
+
|
|
13
|
+
Returns
|
|
14
|
+
-------
|
|
15
|
+
matlab.engine.MatlabEngine
|
|
16
|
+
The connected (or newly started) MATLAB engine instance.
|
|
17
|
+
"""
|
|
18
|
+
engines = matlab.engine.find_matlab()
|
|
19
|
+
if engines:
|
|
20
|
+
logger.debug(f"Found running shared MATLAB engines. Connecting to engine {engines[0]}")
|
|
21
|
+
try:
|
|
22
|
+
eng = matlab.engine.connect_matlab(engines[0])
|
|
23
|
+
except matlab.engine.EngineError as e: # type: ignore
|
|
24
|
+
logger.error(f"EngineError while connecting to MATLAB engine {engines[0]}: {e}")
|
|
25
|
+
logger.error("This may be due to multiple connections. Only one connection is allowed.")
|
|
26
|
+
raise
|
|
27
|
+
except (RuntimeError, TypeError, OSError) as e:
|
|
28
|
+
logger.error(f"Failed to connect to MATLAB engine {engines[0]}: {e}")
|
|
29
|
+
raise
|
|
30
|
+
else:
|
|
31
|
+
logger.debug(
|
|
32
|
+
"No running MATLAB engine found. Starting a new MATLAB engine. "
|
|
33
|
+
"This may take a second..."
|
|
34
|
+
)
|
|
35
|
+
eng = matlab.engine.start_matlab()
|
|
36
|
+
|
|
37
|
+
if not isinstance(eng, matlab.engine.MatlabEngine):
|
|
38
|
+
raise TypeError(f"Expected eng to be of type matlab.engine.MatlabEngine, got {type(eng)}")
|
|
39
|
+
logger.debug("Connected to MATLAB engine successfully.")
|
|
40
|
+
return eng
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setup_util_path(
|
|
44
|
+
eng: matlab.engine.MatlabEngine,
|
|
45
|
+
matlab_utils_path: str | None = os.environ.get("MATLAB_UTILS"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Add the MATLAB utils path to the MATLAB engine's path, if not already present.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
eng : matlab.engine.MatlabEngine
|
|
52
|
+
MATLAB engine instance.
|
|
53
|
+
matlab_utils_path : str | None
|
|
54
|
+
Path to the MATLAB utils folder. Defaults to the `MATLAB_UTILS` environment
|
|
55
|
+
variable.
|
|
56
|
+
"""
|
|
57
|
+
if matlab_utils_path and os.path.exists(matlab_utils_path):
|
|
58
|
+
logger.debug(f"Adding MATLAB utils path: {matlab_utils_path}")
|
|
59
|
+
currentMatlabPath = str(eng.path())
|
|
60
|
+
if matlab_utils_path not in currentMatlabPath.split(":"):
|
|
61
|
+
eng.addpath(matlab_utils_path)
|
|
62
|
+
else:
|
|
63
|
+
logger.debug(f"MATLAB utils path already exists: {matlab_utils_path}")
|
|
64
|
+
else:
|
|
65
|
+
logger.warning(f"MATLAB_UTILS path is not set or does not exist: {matlab_utils_path}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def add_matlablatest_path(eng: matlab.engine.MatlabEngine) -> None:
|
|
69
|
+
"""Add the 'matlablatest' path to the MATLAB environment using `manage_paths`.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
eng : matlab.engine.MatlabEngine
|
|
74
|
+
MATLAB engine instance.
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
ValueError
|
|
79
|
+
If the MATLAB engine instance is invalid, or no 'matlabfiles' paths
|
|
80
|
+
are found after adding 'matlablatest'.
|
|
81
|
+
RuntimeError
|
|
82
|
+
If the 'manage_paths' function is not available in MATLAB.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
if not isinstance(eng, matlab.engine.MatlabEngine):
|
|
86
|
+
raise ValueError("Invalid MATLAB engine instance provided.")
|
|
87
|
+
|
|
88
|
+
logger.debug("Attempting to add 'matlablatest' path using manage_paths...")
|
|
89
|
+
|
|
90
|
+
# Check if the 'manage_paths' function exists in MATLAB
|
|
91
|
+
if not eng.which("manage_paths"):
|
|
92
|
+
raise RuntimeError("'manage_paths' function not found in MATLAB environment.")
|
|
93
|
+
|
|
94
|
+
# Add the 'matlablatest' path
|
|
95
|
+
eng.manage_paths("matlablatest", True, nargout=0)
|
|
96
|
+
|
|
97
|
+
# Retrieve and log paths containing 'matlabfiles'
|
|
98
|
+
matlabPath = str(eng.path())
|
|
99
|
+
matlabfiles_paths = [p for p in matlabPath.split(":") if "matlabfiles" in p.lower()]
|
|
100
|
+
if not matlabfiles_paths:
|
|
101
|
+
matlab_sources = eng.eval("getenv('MATLAB_SOURCES')", nargout=1)
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"No 'matlabfiles' paths found after adding 'matlablatest'. "
|
|
104
|
+
f"Check if MATLAB_SOURCES is set correctly. Current value: {matlab_sources}"
|
|
105
|
+
)
|
|
106
|
+
logger.debug("Successfully added 'matlablatest' path.")
|
|
107
|
+
|
|
108
|
+
except ValueError as ve:
|
|
109
|
+
logger.error(f"ValueError: {ve}")
|
|
110
|
+
raise
|
|
111
|
+
except RuntimeError as re:
|
|
112
|
+
logger.error(f"RuntimeError: {re}")
|
|
113
|
+
raise
|
|
114
|
+
except (AttributeError, TypeError) as e:
|
|
115
|
+
logger.error(f"An unexpected error occurred: {e}")
|
|
116
|
+
raise
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Plotting for images and spectra, plus figure-saving helpers.
|
|
2
|
+
|
|
3
|
+
Importing this package applies the MNUtils matplotlib defaults (sans-serif
|
|
4
|
+
font stack, bold axis labels, constrained layout).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
|
|
9
|
+
from . import images, saving, spectra
|
|
10
|
+
from .saving import (
|
|
11
|
+
DEFAULT_SAVE_PARAMS,
|
|
12
|
+
PAPER_SAVE_PARAMS,
|
|
13
|
+
POSTER_SAVE_PARAMS,
|
|
14
|
+
TALK_SAVE_PARAMS,
|
|
15
|
+
contexts,
|
|
16
|
+
save_current_figure,
|
|
17
|
+
save_figure,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
plt.rcParams["font.family"] = "sans-serif"
|
|
21
|
+
plt.rcParams["font.sans-serif"] = ["Helvetica", "Liberation Sans", "Arial", "sans-serif"]
|
|
22
|
+
plt.rcParams["axes.labelweight"] = "bold"
|
|
23
|
+
plt.rcParams["figure.constrained_layout.use"] = True
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# modules
|
|
27
|
+
"images",
|
|
28
|
+
"saving",
|
|
29
|
+
"spectra",
|
|
30
|
+
# figure saving
|
|
31
|
+
"DEFAULT_SAVE_PARAMS",
|
|
32
|
+
"POSTER_SAVE_PARAMS",
|
|
33
|
+
"TALK_SAVE_PARAMS",
|
|
34
|
+
"PAPER_SAVE_PARAMS",
|
|
35
|
+
"contexts",
|
|
36
|
+
"save_figure",
|
|
37
|
+
"save_current_figure",
|
|
38
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render_html(js: str, css: str, data: dict[str, Any]) -> str:
|
|
7
|
+
"""Build a self-contained HTML/JS output embedding `data` for a widget's JS.
|
|
8
|
+
|
|
9
|
+
Deliberately not the ipywidgets/anywidget comm protocol: every widget in
|
|
10
|
+
this package is one-way (all data is baked in at construction, no
|
|
11
|
+
Python-side callback is ever needed after display), so there's nothing
|
|
12
|
+
to gain from a live model-sync channel -- and mystmd's execution engine
|
|
13
|
+
doesn't support it anyway (it has no registered `jupyter.widget` comm
|
|
14
|
+
target, so `widget-state+json` never gets captured into a static build,
|
|
15
|
+
only a dangling `widget-view+json` reference). A plain HTML/JS output
|
|
16
|
+
has no such dependency: it works identically in a live kernel, in myst's
|
|
17
|
+
live preview, and in a static `myst build --html` site. See
|
|
18
|
+
`docs/diary/2026-08-14-anywidget-slice-viewer.md` for the measurements.
|
|
19
|
+
|
|
20
|
+
`js` must define a function `renderWidget(data, el)` in module scope --
|
|
21
|
+
called directly by the trailing line below, so no `export` is involved --
|
|
22
|
+
and read every value it needs from `data`.
|
|
23
|
+
"""
|
|
24
|
+
container_id = f"mnutils-widget-{uuid.uuid4().hex}"
|
|
25
|
+
# `</script>` inside the JSON payload would terminate the script block early.
|
|
26
|
+
data_json = json.dumps(data).replace("<", "\\u003c")
|
|
27
|
+
return f"""\
|
|
28
|
+
<div id="{container_id}"></div>
|
|
29
|
+
<style>{css}</style>
|
|
30
|
+
<script type="module">
|
|
31
|
+
{js}
|
|
32
|
+
renderWidget({data_json}, document.getElementById("{container_id}"));
|
|
33
|
+
</script>
|
|
34
|
+
"""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared frontend assets for MNUtils plotting widgets.
|
|
2
|
+
|
|
3
|
+
Every widget's browser code is the concatenation of a common layer
|
|
4
|
+
(``dom.js`` / ``theme.css``) and the widget's own ``<name>.js`` / ``<name>.css``.
|
|
5
|
+
Because there is no JS bundler in this project, the sharing happens in Python:
|
|
6
|
+
:func:`load_esm` / :func:`load_css` read both files and join them into a single
|
|
7
|
+
source string that feeds `_widgets._html.render_html`.
|
|
8
|
+
|
|
9
|
+
The shared JS is deliberately ``import``/``export``-free so it can sit in the
|
|
10
|
+
same module scope as the widget's own ``renderWidget``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pathlib
|
|
14
|
+
|
|
15
|
+
_SHARED = pathlib.Path(__file__).parent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_esm(widget_js: pathlib.Path) -> str:
|
|
19
|
+
"""Return the shared JS helpers concatenated ahead of a widget's own JS.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
widget_js : pathlib.Path
|
|
24
|
+
Path to the widget's ``<name>.js`` (owns the ``renderWidget`` entry
|
|
25
|
+
point that `render_html` calls).
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
str
|
|
30
|
+
Combined ESM source: ``dom.js`` followed by the widget's module.
|
|
31
|
+
"""
|
|
32
|
+
shared = (_SHARED / "dom.js").read_text(encoding="utf-8")
|
|
33
|
+
return shared + "\n" + widget_js.read_text(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_css(widget_css: pathlib.Path) -> str:
|
|
37
|
+
"""Return the shared theme concatenated ahead of a widget's own CSS.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
widget_css : pathlib.Path
|
|
42
|
+
Path to the widget's ``<name>.css`` (widget-specific / layout classes).
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
str
|
|
47
|
+
Combined stylesheet: ``theme.css`` followed by the widget's overrides.
|
|
48
|
+
"""
|
|
49
|
+
shared = (_SHARED / "theme.css").read_text(encoding="utf-8")
|
|
50
|
+
return shared + "\n" + widget_css.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared frontend helpers for MNUtils plotting widgets.
|
|
3
|
+
*
|
|
4
|
+
* This module intentionally uses NO `import`/`export` statements: the Python
|
|
5
|
+
* asset loader (`_shared/__init__.py::load_esm`) concatenates it *ahead* of a
|
|
6
|
+
* widget's own `<name>.js`, so both end up in one module scope and every
|
|
7
|
+
* widget can call these helpers directly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Decode a base64 string (as produced by Python's base64.b64encode) to bytes. */
|
|
11
|
+
function b64ToBytes(b64) {
|
|
12
|
+
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inflate a zlib stream produced by Python's `zlib.compress`.
|
|
17
|
+
*
|
|
18
|
+
* Uses the platform's own DecompressionStream, so decompressing the spectra
|
|
19
|
+
* buffer costs no JS dependency. `zlib.compress` emits the zlib wrapper, which
|
|
20
|
+
* is what the "deflate" format expects ("deflate-raw" would be the headerless
|
|
21
|
+
* variant).
|
|
22
|
+
*/
|
|
23
|
+
async function inflate(bytes) {
|
|
24
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate"));
|
|
25
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert one IEEE-754 half-precision bit pattern to a JS number.
|
|
30
|
+
*
|
|
31
|
+
* Spectra travel as float16 to halve the payload. They are decoded a single
|
|
32
|
+
* voxel at a time rather than a whole grid at a time, so this stays cheap and
|
|
33
|
+
* there is no reason to depend on `Float16Array` (only Baseline 2025, and
|
|
34
|
+
* these widgets have to render on whatever browser opens the docs page).
|
|
35
|
+
*/
|
|
36
|
+
function halfToFloat(half) {
|
|
37
|
+
const sign = (half & 0x8000) ? -1 : 1;
|
|
38
|
+
const exponent = (half & 0x7c00) >> 10;
|
|
39
|
+
const fraction = half & 0x03ff;
|
|
40
|
+
if (exponent === 0) return sign * Math.pow(2, -14) * (fraction / 1024);
|
|
41
|
+
if (exponent === 0x1f) return fraction ? NaN : sign * Infinity;
|
|
42
|
+
return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Clamp `value` into the inclusive [lo, hi] range. */
|
|
46
|
+
function clamp(value, lo, hi) {
|
|
47
|
+
return Math.min(hi, Math.max(lo, value));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build a `<div class="mnu-bar">` control row from a list of child elements. */
|
|
51
|
+
function makeBar(...children) {
|
|
52
|
+
const bar = document.createElement("div");
|
|
53
|
+
bar.className = "mnu-bar";
|
|
54
|
+
bar.append(...children);
|
|
55
|
+
return bar;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build a `<div class="mnu-grp">` grouping a related set of controls. */
|
|
59
|
+
function makeGroup(...children) {
|
|
60
|
+
const grp = document.createElement("div");
|
|
61
|
+
grp.className = "mnu-grp";
|
|
62
|
+
grp.append(...children);
|
|
63
|
+
return grp;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a labeled slice slider: `<span class="mnu-lbl">` + `<input
|
|
68
|
+
* type="range" class="mnu-slider">` + `<span class="mnu-readout">`, wired so
|
|
69
|
+
* moving the slider calls `onInput(index)` and updates the readout text via
|
|
70
|
+
* `formatReadout(index)`.
|
|
71
|
+
*/
|
|
72
|
+
function makeSliceSlider({ min, max, value, step = 1, label, formatReadout, onInput }) {
|
|
73
|
+
const lbl = document.createElement("span");
|
|
74
|
+
lbl.className = "mnu-lbl";
|
|
75
|
+
lbl.textContent = label;
|
|
76
|
+
|
|
77
|
+
const slider = document.createElement("input");
|
|
78
|
+
slider.type = "range";
|
|
79
|
+
slider.className = "mnu-slider";
|
|
80
|
+
slider.min = String(min);
|
|
81
|
+
slider.max = String(max);
|
|
82
|
+
slider.step = String(step);
|
|
83
|
+
slider.value = String(value);
|
|
84
|
+
slider.disabled = max <= min;
|
|
85
|
+
|
|
86
|
+
const readout = document.createElement("span");
|
|
87
|
+
readout.className = "mnu-readout";
|
|
88
|
+
readout.textContent = formatReadout(value);
|
|
89
|
+
|
|
90
|
+
slider.addEventListener("input", () => {
|
|
91
|
+
const idx = Number(slider.value);
|
|
92
|
+
readout.textContent = formatReadout(idx);
|
|
93
|
+
onInput(idx);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { lbl, slider, readout };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a pill-shaped checkbox: a `<label class="mnu-toggle">` wrapping an
|
|
101
|
+
* `<input type="checkbox">` and its text, wired so ticking it calls
|
|
102
|
+
* `onChange(checked)`.
|
|
103
|
+
*
|
|
104
|
+
* A label-wrapped checkbox rather than a styled button: it stays a real
|
|
105
|
+
* checkbox for assistive technology and for keyboard users, and the whole
|
|
106
|
+
* pill becomes the hit target because the input sits inside the label.
|
|
107
|
+
*/
|
|
108
|
+
function makeToggle({ label, checked = false, onChange }) {
|
|
109
|
+
const toggle = document.createElement("label");
|
|
110
|
+
toggle.className = "mnu-toggle";
|
|
111
|
+
|
|
112
|
+
const input = document.createElement("input");
|
|
113
|
+
input.type = "checkbox";
|
|
114
|
+
input.checked = checked;
|
|
115
|
+
|
|
116
|
+
const text = document.createElement("span");
|
|
117
|
+
text.textContent = label;
|
|
118
|
+
|
|
119
|
+
input.addEventListener("change", () => onChange(input.checked));
|
|
120
|
+
toggle.append(input, text);
|
|
121
|
+
|
|
122
|
+
return { toggle, input };
|
|
123
|
+
}
|
|
124
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* =========================================================================
|
|
2
|
+
MNUtils shared widget theme.
|
|
3
|
+
Concatenated ahead of each widget's own <name>.css by the Python asset
|
|
4
|
+
loader (_shared/__init__.py::load_css). Holds the `--mnu-*` design tokens
|
|
5
|
+
(light + dark) and the invariant `mnu-*` chrome shared by every widget.
|
|
6
|
+
Widget-specific / layout classes live in the widget's own CSS and override
|
|
7
|
+
these defaults (concatenated afterwards, so they win at equal specificity).
|
|
8
|
+
========================================================================= */
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
--mnu-bg: #ffffff;
|
|
12
|
+
--mnu-panel: #fafafa;
|
|
13
|
+
--mnu-border: #e0e0e0;
|
|
14
|
+
--mnu-border-soft: #eeeeee;
|
|
15
|
+
--mnu-fg: #333333;
|
|
16
|
+
--mnu-fg-strong: #111111;
|
|
17
|
+
--mnu-muted: #666666;
|
|
18
|
+
--mnu-muted-2: #888888;
|
|
19
|
+
--mnu-label: #444444;
|
|
20
|
+
--mnu-accent: #0055aa;
|
|
21
|
+
--mnu-accent-strong: #004488;
|
|
22
|
+
--mnu-shadow: rgba(0, 0, 0, 0.05);
|
|
23
|
+
--mnu-focus-ring: rgba(100, 150, 200, 0.15);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@media (prefers-color-scheme: dark) {
|
|
27
|
+
:root {
|
|
28
|
+
--mnu-bg: #1e1e1e;
|
|
29
|
+
--mnu-panel: #252526;
|
|
30
|
+
--mnu-border: #3c3c3c;
|
|
31
|
+
--mnu-border-soft: #333333;
|
|
32
|
+
--mnu-fg: #d4d4d4;
|
|
33
|
+
--mnu-fg-strong: #f0f0f0;
|
|
34
|
+
--mnu-muted: #a0a0a0;
|
|
35
|
+
--mnu-muted-2: #8a8a8a;
|
|
36
|
+
--mnu-label: #c8c8c8;
|
|
37
|
+
--mnu-accent: #4ea1ff;
|
|
38
|
+
--mnu-accent-strong: #3b82d6;
|
|
39
|
+
--mnu-shadow: rgba(0, 0, 0, 0.35);
|
|
40
|
+
--mnu-focus-ring: rgba(120, 170, 220, 0.25);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* =========================================
|
|
45
|
+
Container & control-bar chrome, shared by every widget.
|
|
46
|
+
========================================= */
|
|
47
|
+
.mnu-viewer {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
51
|
+
color: var(--mnu-fg);
|
|
52
|
+
background: var(--mnu-bg);
|
|
53
|
+
border: 1px solid var(--mnu-border);
|
|
54
|
+
border-radius: 8px;
|
|
55
|
+
box-shadow: 0 4px 12px var(--mnu-shadow);
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.mnu-panel {
|
|
60
|
+
position: relative;
|
|
61
|
+
background: var(--mnu-panel);
|
|
62
|
+
border-bottom: 1px solid var(--mnu-border-soft);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.mnu-bar {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 0.6em;
|
|
69
|
+
padding: 0.5em 0.7em;
|
|
70
|
+
background: var(--mnu-bg);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.mnu-grp {
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 0.6em;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.mnu-lbl {
|
|
80
|
+
font-size: 0.8em;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
color: var(--mnu-label);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.mnu-readout {
|
|
86
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
87
|
+
font-size: 0.8em;
|
|
88
|
+
color: var(--mnu-muted);
|
|
89
|
+
min-width: 5em;
|
|
90
|
+
text-align: right;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.mnu-slider {
|
|
94
|
+
flex: 1;
|
|
95
|
+
accent-color: var(--mnu-accent);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.mnu-slider:focus-visible {
|
|
99
|
+
outline: 2px solid var(--mnu-focus-ring);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Pill checkbox for a control bar, built by `dom.js::makeToggle`. */
|
|
103
|
+
.mnu-toggle {
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 0.45em;
|
|
107
|
+
padding: 0.25em 0.7em;
|
|
108
|
+
border: 1px solid var(--mnu-border);
|
|
109
|
+
border-radius: 999px;
|
|
110
|
+
background: var(--mnu-panel);
|
|
111
|
+
font-size: 0.8em;
|
|
112
|
+
font-weight: 600;
|
|
113
|
+
color: var(--mnu-label);
|
|
114
|
+
line-height: 1.4;
|
|
115
|
+
opacity: 0.75;
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
user-select: none;
|
|
118
|
+
transition:
|
|
119
|
+
opacity 0.12s ease,
|
|
120
|
+
border-color 0.12s ease;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.mnu-toggle:hover,
|
|
124
|
+
.mnu-toggle:focus-within {
|
|
125
|
+
opacity: 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.mnu-toggle:has(input:checked) {
|
|
129
|
+
opacity: 1;
|
|
130
|
+
border-color: var(--mnu-accent);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.mnu-toggle input {
|
|
134
|
+
width: 1.1em;
|
|
135
|
+
height: 1.1em;
|
|
136
|
+
margin: 0;
|
|
137
|
+
accent-color: var(--mnu-accent);
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
}
|
|
140
|
+
|