some fixes
This commit is contained in:
@@ -192,18 +192,22 @@ auto build_payload_json(
|
||||
});
|
||||
}
|
||||
|
||||
// Fix #48: stamp every packet with a numeric build-time generation
|
||||
// (`gen`, epoch ms). A cached snapshot re-sent verbatim to a new client
|
||||
// keeps its original `gen`, so a consumer can compute the snapshot's true
|
||||
// age and flag a stalled pipeline (which `tim` alone cannot express, having
|
||||
// no date and rolling over at midnight).
|
||||
const Json root{
|
||||
Json root{
|
||||
{"ver", protocol_version},
|
||||
{"tim", format_timestamp_now()},
|
||||
{"gen", epoch_millis_now()},
|
||||
{"sts", status},
|
||||
{"obs", std::move(obs_array)},
|
||||
};
|
||||
|
||||
// `gen` is a build-time generation stamp (epoch ms) that lets a consumer compute
|
||||
// a packet's true age and flag a cached/stalled snapshot — something `tim` cannot
|
||||
// express, having no date and rolling over at midnight. It is currently NOT sent;
|
||||
// set this flag to true to re-enable it (epoch_millis_now() is kept ready for that).
|
||||
constexpr bool kEmitGenerationStamp = false;
|
||||
if (kEmitGenerationStamp) {
|
||||
root["gen"] = epoch_millis_now();
|
||||
}
|
||||
|
||||
return root.dump();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
HOST = "192.168.8.2"
|
||||
HOST = "192.168.2.6"
|
||||
PORT = 8888
|
||||
CLIENT_DEVICE_ID = 0
|
||||
MIN_TEST_VLC = 5.0
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QSpinBox,
|
||||
)
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
@@ -63,6 +70,10 @@ _WEB_LIVE_SCHEMA = [
|
||||
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
||||
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
|
||||
("legacy_gpr_min_visible_pair_count", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_min_visible_pair_count")),
|
||||
("gpr_visible_x_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_min_m", "_legacy_gpr_visible_x_min_m")),
|
||||
("gpr_visible_x_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_max_m", "_legacy_gpr_visible_x_max_m")),
|
||||
("gpr_visible_z_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_min_m", "_legacy_gpr_visible_z_min_m")),
|
||||
("gpr_visible_z_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_max_m", "_legacy_gpr_visible_z_max_m")),
|
||||
("gpr_look_angle_deg", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_look_angle_deg")),
|
||||
("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_apply_freq_phase_correction")),
|
||||
("gpr_reference_mode", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_reference_mode")),
|
||||
@@ -71,6 +82,50 @@ _WEB_LIVE_SCHEMA = [
|
||||
]
|
||||
_WEB_LIVE_GETTERS = {field: getter for field, _group, _modes, getter in _WEB_LIVE_SCHEMA}
|
||||
|
||||
# ProcessingLiveConfig fields that are NOT backed by a settings widget and are set
|
||||
# directly in `_live_processing_config`: the fixed acquisition channels, the runtime
|
||||
# command fields, and a constant default. Together with the schema's fields these must
|
||||
# cover every config field (guarded by a unit test), so the desktop config and the web
|
||||
# form can never drift apart.
|
||||
_NON_WIDGET_LIVE_FIELDS = frozenset({
|
||||
"pass_through_channel",
|
||||
"bscan_channel",
|
||||
"reprocess_current_result",
|
||||
"history_command",
|
||||
"history_command_seq",
|
||||
"gpr_direction_sign",
|
||||
})
|
||||
|
||||
# Display toggles: GUI-only rendering choices (kept in the GUI profile, not the live
|
||||
# config). They apply immediately through the same redraw as a live edit but write
|
||||
# nothing to processing_live.json.
|
||||
_WEB_DISPLAY_SCHEMA = [
|
||||
("render_mode", "Display", _GPR_MODES, _dual("_gpr_render_mode", "_legacy_gpr_render_mode")),
|
||||
("subtract_mean_ascan", "Display", ("bscan",), _attr("_bscan_subtract_mean_ascan")),
|
||||
("combo_filter", "Display", ("pass_through",), _attr("_pass_through_combo_filter_input")),
|
||||
]
|
||||
|
||||
# Stable run_config fields (GPR medium and antenna geometry). They live in run_config and
|
||||
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
|
||||
# "applies on Start" and editing them just updates the widget for the next start.
|
||||
_WEB_STABLE_SCHEMA = [
|
||||
("relative_permittivity", "Geometry & medium", _GPR_MODES, _attr("_gpr_relative_permittivity")),
|
||||
("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")),
|
||||
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
|
||||
]
|
||||
|
||||
# Every field the web form renders and may write back, across all three categories.
|
||||
_WEB_FORM_GETTERS = {
|
||||
field: getter
|
||||
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA, _WEB_STABLE_SCHEMA)
|
||||
for field, _group, _modes, getter in source
|
||||
}
|
||||
|
||||
|
||||
def web_apply_field_names() -> frozenset[str]:
|
||||
"""Return the field names a web client may apply (form fields plus runtime commands)."""
|
||||
return frozenset(_WEB_FORM_GETTERS) | {"history_command"}
|
||||
|
||||
|
||||
def _web_field_schema(field: str, group: str, widget) -> dict | None:
|
||||
"""Describe one widget for the web form (type, options/range, value, enabled)."""
|
||||
@@ -87,27 +142,58 @@ def _web_field_schema(field: str, group: str, widget) -> dict | None:
|
||||
return {**base, "kind": "float", "value": float(widget.value()),
|
||||
"min": float(widget.minimum()), "max": float(widget.maximum()),
|
||||
"step": float(widget.singleStep()), "decimals": int(widget.decimals())}
|
||||
if isinstance(widget, QPlainTextEdit):
|
||||
return {**base, "kind": "textarea", "value": widget.toPlainText()}
|
||||
if isinstance(widget, QLineEdit):
|
||||
return {**base, "kind": "text", "value": widget.text()}
|
||||
return None
|
||||
|
||||
|
||||
def _read_live_widget_value(widget):
|
||||
"""Return a widget's current value for the live config, by widget type.
|
||||
|
||||
Shares the same type dispatch as the web schema/setter, so the desktop config
|
||||
build and the browser form read every widget identically.
|
||||
"""
|
||||
if isinstance(widget, QComboBox):
|
||||
return widget.currentText()
|
||||
if isinstance(widget, QCheckBox):
|
||||
return bool(widget.isChecked())
|
||||
if isinstance(widget, QSpinBox):
|
||||
return int(widget.value())
|
||||
if isinstance(widget, QDoubleSpinBox):
|
||||
return float(widget.value())
|
||||
if isinstance(widget, QLineEdit):
|
||||
return widget.text()
|
||||
raise TypeError(f"Unsupported live-settings widget: {type(widget).__name__}")
|
||||
|
||||
|
||||
def _build_web_settings_schema(window) -> list[dict]:
|
||||
"""Build the settings schema for the active mode straight from the Qt widgets."""
|
||||
"""Build the settings schema for the active mode straight from the Qt widgets.
|
||||
|
||||
Covers all three widget categories — live settings, GUI display toggles, and stable
|
||||
run_config fields — so the web form shows exactly what the desktop processing panel
|
||||
does. Stable fields are tagged ``applies_on_start`` (they take effect on the next run).
|
||||
"""
|
||||
mode = window._processing_mode.currentText()
|
||||
schema: list[dict] = []
|
||||
for field, group, modes, getter in _WEB_LIVE_SCHEMA:
|
||||
if modes is not None and mode not in modes:
|
||||
continue
|
||||
entry = _web_field_schema(field, group, getter(window))
|
||||
if entry is not None:
|
||||
schema.append(entry)
|
||||
for source, extra in (
|
||||
(_WEB_LIVE_SCHEMA, {}),
|
||||
(_WEB_DISPLAY_SCHEMA, {}),
|
||||
(_WEB_STABLE_SCHEMA, {"applies_on_start": True}),
|
||||
):
|
||||
for field, group, modes, getter in source:
|
||||
if modes is not None and mode not in modes:
|
||||
continue
|
||||
entry = _web_field_schema(field, group, getter(window))
|
||||
if entry is not None:
|
||||
schema.append({**entry, **extra})
|
||||
return schema
|
||||
|
||||
|
||||
def _set_web_live_field(window, field: str, value) -> None:
|
||||
"""Write one web value into the desktop widget that feeds ``field``."""
|
||||
getter = _WEB_LIVE_GETTERS.get(field)
|
||||
"""Write one web value into the desktop widget that feeds ``field`` (any category)."""
|
||||
getter = _WEB_FORM_GETTERS.get(field)
|
||||
if getter is None:
|
||||
return
|
||||
widget = getter(window)
|
||||
@@ -119,6 +205,8 @@ def _set_web_live_field(window, field: str, value) -> None:
|
||||
widget.setValue(int(float(value)))
|
||||
elif isinstance(widget, QDoubleSpinBox):
|
||||
widget.setValue(float(value))
|
||||
elif isinstance(widget, QPlainTextEdit):
|
||||
widget.setPlainText(str(value))
|
||||
elif isinstance(widget, QLineEdit):
|
||||
widget.setText(",".join(str(int(v)) for v in value) if isinstance(value, list) else str(value))
|
||||
|
||||
@@ -145,82 +233,46 @@ class AppWindowLiveProcessingMixin:
|
||||
history_command: str = "none",
|
||||
reprocess_current_result: bool = True,
|
||||
) -> ProcessingLiveConfig:
|
||||
"""Build live processing config from current processing widgets."""
|
||||
"""Build the live processing config from the current processing widgets.
|
||||
|
||||
Widget-backed fields are read through the single ``_WEB_LIVE_SCHEMA`` map — the
|
||||
same source the embedded web form uses — so the desktop config and the browser
|
||||
form can never expose different settings. The few fields that need
|
||||
post-processing (position lists, the ordered pass-through Y range, the
|
||||
normalized visible window) or are not widget-backed (the fixed acquisition
|
||||
channels, the history command, the reprocess flag) are applied explicitly here.
|
||||
"""
|
||||
self._sync_bscan_frequency_limits_with_radar()
|
||||
self._sync_gpr_frequency_limits_with_radar()
|
||||
mode = self._processing_mode.currentText()
|
||||
if mode == "legacy_gpr":
|
||||
gpr_input_positions_text = self._legacy_gpr_input_positions_input.text()
|
||||
gpr_output_positions_text = self._legacy_gpr_output_positions_input.text()
|
||||
gpr_min_depth_m = float(self._legacy_gpr_min_depth_m.value())
|
||||
gpr_max_depth_m = float(self._legacy_gpr_max_depth_m.value())
|
||||
gpr_start_freq_mhz = float(self._legacy_gpr_start_freq_mhz.value())
|
||||
gpr_stop_freq_mhz = float(self._legacy_gpr_stop_freq_mhz.value())
|
||||
gpr_background_enabled = bool(self._legacy_gpr_background_subtract_enabled.isChecked())
|
||||
gpr_background_mean_count = int(self._legacy_gpr_background_mean_count.value())
|
||||
else:
|
||||
gpr_input_positions_text = self._gpr_input_positions_input.text()
|
||||
gpr_output_positions_text = self._gpr_output_positions_input.text()
|
||||
gpr_min_depth_m = float(self._gpr_min_depth_m.value())
|
||||
gpr_max_depth_m = float(self._gpr_max_depth_m.value())
|
||||
gpr_start_freq_mhz = float(self._gpr_start_freq_mhz.value())
|
||||
gpr_stop_freq_mhz = float(self._gpr_stop_freq_mhz.value())
|
||||
gpr_background_enabled = bool(self._gpr_background_subtract_enabled.isChecked())
|
||||
gpr_background_mean_count = int(self._gpr_background_mean_count.value())
|
||||
|
||||
y_min_db = float(self._pass_through_y_min_db.value())
|
||||
y_max_db = float(self._pass_through_y_max_db.value())
|
||||
visible_x_min, visible_x_max, visible_z_min, visible_z_max = self._gpr_visible_bounds()
|
||||
config = ProcessingLiveConfig(
|
||||
processor_mode=mode,
|
||||
values = {
|
||||
field: _read_live_widget_value(getter(self))
|
||||
for field, _group, _modes, getter in _WEB_LIVE_SCHEMA
|
||||
}
|
||||
|
||||
# Switch-position CSV text -> integer lists.
|
||||
values["gpr_input_positions"] = self._parse_csv_int_list(values["gpr_input_positions"])
|
||||
values["gpr_output_positions"] = self._parse_csv_int_list(values["gpr_output_positions"])
|
||||
# Order-normalize the pass-through Y range so a reversed entry still works.
|
||||
y_min_db, y_max_db = values["pass_through_y_min_db"], values["pass_through_y_max_db"]
|
||||
values["pass_through_y_min_db"] = min(y_min_db, y_max_db)
|
||||
values["pass_through_y_max_db"] = max(y_min_db, y_max_db)
|
||||
# Visible window: ordered with a non-zero span, for the active mode's widgets.
|
||||
(
|
||||
values["gpr_visible_x_min_m"],
|
||||
values["gpr_visible_x_max_m"],
|
||||
values["gpr_visible_z_min_m"],
|
||||
values["gpr_visible_z_max_m"],
|
||||
) = self._gpr_visible_bounds()
|
||||
|
||||
return ProcessingLiveConfig(
|
||||
pass_through_channel="s21",
|
||||
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||
pass_through_y_min_db=min(y_min_db, y_max_db),
|
||||
pass_through_y_max_db=max(y_min_db, y_max_db),
|
||||
bscan_axis=self._bscan_axis.currentText(),
|
||||
bscan_channel="s21",
|
||||
bscan_cut_m=float(self._bscan_cut_m.value()),
|
||||
bscan_max_depth_m=float(self._bscan_max_depth_m.value()),
|
||||
bscan_gain=float(self._bscan_gain.value()),
|
||||
bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||
bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||
legacy_gpr_mode=self._legacy_gpr_config_mode.currentText(),
|
||||
gpr_input_positions=self._parse_csv_int_list(gpr_input_positions_text),
|
||||
gpr_output_positions=self._parse_csv_int_list(gpr_output_positions_text),
|
||||
gpr_min_depth_m=gpr_min_depth_m,
|
||||
gpr_max_depth_m=gpr_max_depth_m,
|
||||
gpr_range_comp_power=float(self._gpr_range_comp_power.value()),
|
||||
gpr_angle_comp_power=float(self._gpr_angle_comp_power.value()),
|
||||
gpr_comp_power=float(self._legacy_gpr_comp_power.value()),
|
||||
gpr_score_mode=self._gpr_score_mode.currentText(),
|
||||
gpr_max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
|
||||
gpr_draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
|
||||
gpr_speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
|
||||
gpr_look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
|
||||
gpr_apply_freq_phase_correction=bool(
|
||||
self._legacy_gpr_apply_freq_phase_correction.isChecked()
|
||||
),
|
||||
gpr_reference_mode=self._legacy_gpr_reference_mode.currentText(),
|
||||
gpr_snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
|
||||
gpr_snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()),
|
||||
gpr_start_freq_mhz=gpr_start_freq_mhz,
|
||||
gpr_stop_freq_mhz=gpr_stop_freq_mhz,
|
||||
gpr_background_subtract_enabled=gpr_background_enabled,
|
||||
gpr_background_mean_count=gpr_background_mean_count,
|
||||
gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
|
||||
gpr_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
||||
gpr_min_visible_score=float(self._gpr_min_visible_score.value()),
|
||||
legacy_gpr_min_visible_pair_count=float(self._legacy_gpr_min_visible_pair_count.value()),
|
||||
gpr_visible_x_min_m=visible_x_min,
|
||||
gpr_visible_x_max_m=visible_x_max,
|
||||
gpr_visible_z_min_m=visible_z_min,
|
||||
gpr_visible_z_max_m=visible_z_max,
|
||||
ignore_socket_speed=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
|
||||
reprocess_current_result=bool(reprocess_current_result),
|
||||
history_command_seq=int(self._history_command_seq),
|
||||
history_command=str(history_command),
|
||||
history_command_seq=int(self._history_command_seq),
|
||||
**values,
|
||||
)
|
||||
return config
|
||||
|
||||
def _write_live_processing_config(
|
||||
self,
|
||||
|
||||
@@ -17,13 +17,12 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import os
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
from python_app.gui.controllers.app_window_config.live_processing_mixin import web_apply_field_names
|
||||
|
||||
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
|
||||
_DEFAULT_PORT = 8080
|
||||
@@ -32,7 +31,9 @@ _DEFAULT_PORT = 8080
|
||||
_HEADLESS_PLOT_SIZE = (1600, 900)
|
||||
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
|
||||
_GRAB_INTERVAL_S = 0.2
|
||||
_LIVE_FIELD_NAMES = frozenset(field.name for field in dataclasses.fields(ProcessingLiveConfig))
|
||||
# Field names a web client may apply: the form's live/display/stable fields plus the
|
||||
# history command. Derived from the single schema so it can never drift from the form.
|
||||
_LIVE_FIELD_NAMES = web_apply_field_names()
|
||||
|
||||
|
||||
class AppWindowWebController(QObject):
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Guard tests keeping the live-settings schema and the config builder in lock-step.
|
||||
|
||||
`_live_processing_config` builds ProcessingLiveConfig by reading every widget through
|
||||
`_WEB_LIVE_SCHEMA` (the same map the embedded web form renders from), plus a small set
|
||||
of fields set explicitly (`_NON_WIDGET_LIVE_FIELDS`). These tests fail the moment a new
|
||||
config field is added without being placed in one of those two sets — which is exactly
|
||||
the drift that previously hid GPR settings from the web form.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses as dc
|
||||
import unittest
|
||||
|
||||
from python_app.gui.controllers.app_window_config.live_processing_mixin import (
|
||||
_NON_WIDGET_LIVE_FIELDS,
|
||||
_WEB_DISPLAY_SCHEMA,
|
||||
_WEB_LIVE_GETTERS,
|
||||
_WEB_STABLE_SCHEMA,
|
||||
web_apply_field_names,
|
||||
)
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
|
||||
class LiveSettingsSchemaCoverageTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _config_fields() -> set[str]:
|
||||
return {field.name for field in dc.fields(ProcessingLiveConfig)}
|
||||
|
||||
def test_schema_plus_non_widget_covers_every_config_field(self) -> None:
|
||||
schema_fields = set(_WEB_LIVE_GETTERS)
|
||||
covered = schema_fields | _NON_WIDGET_LIVE_FIELDS
|
||||
config_fields = self._config_fields()
|
||||
self.assertEqual(
|
||||
covered,
|
||||
config_fields,
|
||||
msg=(
|
||||
f"uncovered config fields: {sorted(config_fields - covered)}; "
|
||||
f"stray names: {sorted(covered - config_fields)}"
|
||||
),
|
||||
)
|
||||
|
||||
def test_widget_and_non_widget_sets_are_disjoint(self) -> None:
|
||||
# A field is either widget-backed (in the schema) or explicitly non-widget — never both.
|
||||
self.assertEqual(set(_WEB_LIVE_GETTERS) & _NON_WIDGET_LIVE_FIELDS, set())
|
||||
|
||||
def test_display_and_stable_fields_are_not_live_config(self) -> None:
|
||||
# Display toggles (GUI profile) and stable fields (run_config) must never collide
|
||||
# with live ProcessingLiveConfig fields — they take different write paths.
|
||||
extra = {f for f, *_ in _WEB_DISPLAY_SCHEMA} | {f for f, *_ in _WEB_STABLE_SCHEMA}
|
||||
self.assertEqual(extra & self._config_fields(), set())
|
||||
self.assertEqual(extra & set(_WEB_LIVE_GETTERS), set())
|
||||
|
||||
def test_web_apply_names_cover_the_whole_form(self) -> None:
|
||||
form_fields = (
|
||||
set(_WEB_LIVE_GETTERS)
|
||||
| {f for f, *_ in _WEB_DISPLAY_SCHEMA}
|
||||
| {f for f, *_ in _WEB_STABLE_SCHEMA}
|
||||
)
|
||||
# The web may apply every form field plus the history command, and nothing else.
|
||||
self.assertEqual(web_apply_field_names(), form_fields | {"history_command"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -89,6 +89,14 @@ function makeFieldRow(entry) {
|
||||
const label = document.createElement("label");
|
||||
label.textContent = entry.name;
|
||||
label.htmlFor = "f_" + entry.name;
|
||||
if (entry.applies_on_start) {
|
||||
// Stable run_config field: editing it here takes effect on the next pipeline start.
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "on-start-hint";
|
||||
hint.textContent = " (on Start)";
|
||||
hint.title = "Applied when the pipeline next starts";
|
||||
label.appendChild(hint);
|
||||
}
|
||||
row.appendChild(label);
|
||||
|
||||
let el;
|
||||
@@ -112,6 +120,10 @@ function makeFieldRow(entry) {
|
||||
if (entry.max != null) el.max = entry.max;
|
||||
el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1);
|
||||
el.value = entry.value;
|
||||
} else if (entry.kind === "textarea") {
|
||||
el = document.createElement("textarea");
|
||||
el.rows = 3;
|
||||
el.value = entry.value == null ? "" : String(entry.value);
|
||||
} else {
|
||||
el = document.createElement("input");
|
||||
el.type = "text";
|
||||
|
||||
@@ -208,6 +208,21 @@ body {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.field textarea {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
min-height: 52px;
|
||||
resize: vertical;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 4px 7px;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.field textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.on-start-hint { color: var(--muted); font-size: 11px; font-style: italic; }
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user