Files
cleonos/scripts/menuconfig.py

1788 lines
62 KiB
Python
Raw Normal View History

2026-04-18 15:37:24 +08:00
#!/usr/bin/env python3
"""
CLeonOS menuconfig
Interactive feature selector that writes:
- configs/menuconfig/.config.json
- configs/menuconfig/config.cmake
Design:
- CLKS options come from configs/menuconfig/clks_features.json
- User-space app options are discovered dynamically from *_main.c / *_kmain.c
"""
from __future__ import annotations
import argparse
import os
import json
import re
import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
2026-04-19 16:07:44 +08:00
from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
2026-04-18 15:37:24 +08:00
try:
import curses
except Exception:
curses = None
2026-04-18 19:22:25 +08:00
try:
from PySide6 import QtCore, QtWidgets
except Exception:
try:
from PySide2 import QtCore, QtWidgets
except Exception:
QtCore = None
QtWidgets = None
2026-04-18 15:37:24 +08:00
ROOT_DIR = Path(__file__).resolve().parent.parent
APPS_DIR = ROOT_DIR / "cleonos" / "c" / "apps"
MENUCONFIG_DIR = ROOT_DIR / "configs" / "menuconfig"
CLKS_FEATURES_PATH = MENUCONFIG_DIR / "clks_features.json"
CONFIG_JSON_PATH = MENUCONFIG_DIR / ".config.json"
CONFIG_CMAKE_PATH = MENUCONFIG_DIR / "config.cmake"
@dataclass(frozen=True)
class OptionItem:
key: str
title: str
description: str
2026-04-19 16:07:44 +08:00
kind: str
default: int
depends_on: str
selects: Tuple[str, ...]
implies: Tuple[str, ...]
2026-04-18 15:37:24 +08:00
2026-04-19 16:07:44 +08:00
@dataclass
class EvalResult:
effective: Dict[str, int]
visible: Dict[str, bool]
min_required: Dict[str, int]
max_selectable: Dict[str, int]
selected_by: Dict[str, List[str]]
implied_by: Dict[str, List[str]]
depends_symbols: Dict[str, List[str]]
TRI_N = 0
TRI_M = 1
TRI_Y = 2
def tri_char(value: int) -> str:
if value >= TRI_Y:
return "y"
if value >= TRI_M:
return "m"
return "n"
def tri_text(value: int) -> str:
ch = tri_char(value)
if ch == "y":
return "Y"
if ch == "m":
return "M"
return "N"
def normalize_kind(raw: object) -> str:
text = str(raw or "bool").strip().lower()
if text in {"tristate", "tri"}:
return "tristate"
return "bool"
def _tri_from_bool(value: bool) -> int:
return TRI_Y if value else TRI_N
def normalize_tri(raw: object, default: int, kind: str) -> int:
2026-04-18 15:37:24 +08:00
if isinstance(raw, bool):
2026-04-19 16:07:44 +08:00
value = TRI_Y if raw else TRI_N
elif isinstance(raw, (int, float)):
iv = int(raw)
if iv <= 0:
value = TRI_N
elif iv == 1:
value = TRI_M
else:
value = TRI_Y
elif isinstance(raw, str):
2026-04-18 15:37:24 +08:00
text = raw.strip().lower()
if text in {"1", "on", "true", "yes", "y"}:
2026-04-19 16:07:44 +08:00
value = TRI_Y
elif text in {"m", "mod", "module"}:
value = TRI_M
elif text in {"0", "off", "false", "no", "n"}:
value = TRI_N
else:
value = default
else:
value = default
if kind == "bool":
return TRI_Y if value == TRI_Y else TRI_N
if value < TRI_N:
return TRI_N
if value > TRI_Y:
return TRI_Y
return value
def _tokenize_dep_expr(expr: str) -> List[str]:
tokens: List[str] = []
i = 0
n = len(expr)
while i < n:
ch = expr[i]
if ch.isspace():
i += 1
continue
if ch in "()!":
tokens.append(ch)
i += 1
continue
if i + 1 < n:
pair = expr[i : i + 2]
if pair in {"&&", "||"}:
tokens.append(pair)
i += 2
continue
m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", expr[i:])
if m:
tok = m.group(0)
tokens.append(tok)
i += len(tok)
continue
raise RuntimeError(f"invalid token in depends expression: {expr!r}")
return tokens
class _DepExprParser:
def __init__(self, tokens: List[str], resolver: Callable[[str], int]):
self.tokens = tokens
self.pos = 0
self.resolver = resolver
def _peek(self) -> Optional[str]:
if self.pos >= len(self.tokens):
return None
return self.tokens[self.pos]
def _take(self) -> str:
if self.pos >= len(self.tokens):
raise RuntimeError("unexpected end of expression")
tok = self.tokens[self.pos]
self.pos += 1
return tok
def parse(self) -> int:
value = self._parse_or()
if self._peek() is not None:
raise RuntimeError("unexpected token in expression")
return value
def _parse_or(self) -> int:
value = self._parse_and()
while self._peek() == "||":
self._take()
value = max(value, self._parse_and())
return value
def _parse_and(self) -> int:
value = self._parse_unary()
while self._peek() == "&&":
self._take()
value = min(value, self._parse_unary())
return value
def _parse_unary(self) -> int:
tok = self._peek()
if tok == "!":
self._take()
value = self._parse_unary()
if value == TRI_Y:
return TRI_N
if value == TRI_N:
return TRI_Y
return TRI_M
return self._parse_primary()
def _parse_primary(self) -> int:
tok = self._take()
if tok == "(":
value = self._parse_or()
if self._take() != ")":
raise RuntimeError("missing ')' in expression")
return value
lowered = tok.lower()
if lowered == "y":
return TRI_Y
if lowered == "m":
return TRI_M
if lowered == "n":
return TRI_N
return self.resolver(tok)
def eval_dep_expr(expr: str, resolver: Callable[[str], int]) -> int:
text = (expr or "").strip()
if not text:
return TRI_Y
tokens = _tokenize_dep_expr(text)
parser = _DepExprParser(tokens, resolver)
return parser.parse()
def extract_dep_symbols(expr: str) -> List[str]:
text = (expr or "").strip()
if not text:
return []
out: List[str] = []
seen: Set[str] = set()
for tok in _tokenize_dep_expr(text):
if tok in {"&&", "||", "!", "(", ")"}:
continue
low = tok.lower()
if low in {"y", "m", "n"}:
continue
if tok in seen:
continue
seen.add(tok)
out.append(tok)
return out
def normalize_bool(raw: object, default: bool) -> bool:
tri_default = TRI_Y if default else TRI_N
return normalize_tri(raw, tri_default, "bool") == TRI_Y
def _normalize_key_list(raw: object) -> Tuple[str, ...]:
items: List[str] = []
if isinstance(raw, str):
source = re.split(r"[,\s]+", raw.strip())
elif isinstance(raw, (list, tuple)):
source = [str(x) for x in raw]
else:
return ()
for item in source:
token = str(item).strip()
if not token:
continue
items.append(token)
return tuple(items)
2026-04-18 15:37:24 +08:00
def sanitize_token(name: str) -> str:
token = re.sub(r"[^A-Za-z0-9]+", "_", name.strip().upper())
token = token.strip("_")
return token or "UNKNOWN"
def load_clks_options() -> List[OptionItem]:
if not CLKS_FEATURES_PATH.exists():
raise RuntimeError(f"missing CLKS feature file: {CLKS_FEATURES_PATH}")
raw = json.loads(CLKS_FEATURES_PATH.read_text(encoding="utf-8"))
if not isinstance(raw, dict) or "features" not in raw or not isinstance(raw["features"], list):
raise RuntimeError(f"invalid feature format in {CLKS_FEATURES_PATH}")
options: List[OptionItem] = []
for entry in raw["features"]:
if not isinstance(entry, dict):
continue
key = str(entry.get("key", "")).strip()
title = str(entry.get("title", key)).strip()
description = str(entry.get("description", "")).strip()
2026-04-19 16:07:44 +08:00
kind = normalize_kind(entry.get("type", "bool"))
default = normalize_tri(entry.get("default", TRI_Y), TRI_Y, kind)
depends_on = str(entry.get("depends_on", entry.get("depends", ""))).strip()
selects = _normalize_key_list(entry.get("select", entry.get("selects", ())))
implies = _normalize_key_list(entry.get("imply", entry.get("implies", ())))
2026-04-18 15:37:24 +08:00
if not key:
continue
2026-04-19 16:07:44 +08:00
options.append(
OptionItem(
key=key,
title=title,
description=description,
kind=kind,
default=default,
depends_on=depends_on,
selects=selects,
implies=implies,
)
)
2026-04-18 15:37:24 +08:00
if not options:
raise RuntimeError(f"no CLKS feature options in {CLKS_FEATURES_PATH}")
return options
def discover_user_apps() -> List[OptionItem]:
main_paths = sorted(APPS_DIR.glob("*_main.c"))
kmain_paths = sorted(APPS_DIR.glob("*_kmain.c"))
kmain_names = set()
for path in kmain_paths:
name = path.stem
if name.endswith("_kmain"):
kmain_names.add(name[:-6])
final_apps: List[Tuple[str, str]] = []
for path in main_paths:
name = path.stem
if not name.endswith("_main"):
continue
app = name[:-5]
if app in kmain_names:
continue
if app.endswith("drv"):
section = "driver"
elif app == "hello":
section = "root"
else:
section = "shell"
final_apps.append((app, section))
for app in sorted(kmain_names):
final_apps.append((app, "system"))
final_apps.sort(key=lambda item: (item[1], item[0]))
options: List[OptionItem] = []
for app, section in final_apps:
key = f"CLEONOS_USER_APP_{sanitize_token(app)}"
title = f"{app}.elf [{section}]"
description = f"Build and package user app '{app}' into ramdisk/{section}."
2026-04-19 16:07:44 +08:00
options.append(
OptionItem(
key=key,
title=title,
description=description,
kind="bool",
default=TRI_Y,
depends_on="",
selects=(),
implies=(),
)
)
2026-04-18 15:37:24 +08:00
return options
2026-04-19 16:07:44 +08:00
def load_previous_values() -> Dict[str, int]:
2026-04-18 15:37:24 +08:00
if not CONFIG_JSON_PATH.exists():
return {}
try:
raw = json.loads(CONFIG_JSON_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(raw, dict):
return {}
2026-04-19 16:07:44 +08:00
out: Dict[str, int] = {}
2026-04-18 15:37:24 +08:00
for key, value in raw.items():
if not isinstance(key, str):
continue
2026-04-19 16:07:44 +08:00
out[key] = normalize_tri(value, TRI_N, "tristate")
2026-04-18 15:37:24 +08:00
return out
2026-04-19 16:07:44 +08:00
def init_values(options: Iterable[OptionItem], previous: Dict[str, int], use_defaults: bool) -> Dict[str, int]:
values: Dict[str, int] = {}
2026-04-18 15:37:24 +08:00
for item in options:
if not use_defaults and item.key in previous:
2026-04-19 16:07:44 +08:00
values[item.key] = normalize_tri(previous[item.key], item.default, item.kind)
2026-04-18 15:37:24 +08:00
else:
values[item.key] = item.default
return values
2026-04-19 16:07:44 +08:00
def _build_index(options: Iterable[OptionItem]) -> Dict[str, OptionItem]:
return {item.key: item for item in options}
def _set_option_if_exists(values: Dict[str, int], option_index: Dict[str, OptionItem], key: str, level: int) -> None:
2026-04-18 19:31:19 +08:00
if key in values:
2026-04-19 16:07:44 +08:00
item = option_index.get(key)
if item is None:
return
values[key] = normalize_tri(level, item.default, item.kind)
2026-04-18 19:31:19 +08:00
2026-04-19 16:07:44 +08:00
def _set_all_options(values: Dict[str, int], options: List[OptionItem], level: int) -> None:
2026-04-18 19:31:19 +08:00
for item in options:
2026-04-19 16:07:44 +08:00
values[item.key] = normalize_tri(level, item.default, item.kind)
2026-04-18 19:31:19 +08:00
2026-04-19 16:07:44 +08:00
def apply_preset(preset: str, clks_options: List[OptionItem], user_options: List[OptionItem], values: Dict[str, int]) -> None:
option_index = _build_index(clks_options + user_options)
2026-04-18 19:31:19 +08:00
preset_name = preset.strip().lower()
if preset_name == "full":
2026-04-19 16:07:44 +08:00
_set_all_options(values, clks_options, TRI_Y)
_set_all_options(values, user_options, TRI_Y)
2026-04-18 19:31:19 +08:00
return
if preset_name == "dev":
2026-04-19 16:07:44 +08:00
_set_all_options(values, clks_options, TRI_Y)
_set_all_options(values, user_options, TRI_Y)
_set_option_if_exists(values, option_index, "CLEONOS_CLKS_ENABLE_USERLAND_AUTO_EXEC", TRI_N)
_set_option_if_exists(values, option_index, "CLEONOS_CLKS_ENABLE_EXEC_SERIAL_LOG", TRI_Y)
_set_option_if_exists(values, option_index, "CLEONOS_CLKS_ENABLE_PROCFS", TRI_Y)
_set_option_if_exists(values, option_index, "CLEONOS_CLKS_ENABLE_IDLE_DEBUG_LOG", TRI_Y)
2026-04-18 19:31:19 +08:00
return
if preset_name == "minimal":
2026-04-19 16:07:44 +08:00
_set_all_options(values, clks_options, TRI_Y)
_set_all_options(values, user_options, TRI_N)
2026-04-18 19:31:19 +08:00
clks_disable = [
"CLEONOS_CLKS_ENABLE_AUDIO",
"CLEONOS_CLKS_ENABLE_MOUSE",
"CLEONOS_CLKS_ENABLE_DESKTOP",
"CLEONOS_CLKS_ENABLE_DRIVER_MANAGER",
"CLEONOS_CLKS_ENABLE_KELF",
"CLEONOS_CLKS_ENABLE_EXTERNAL_PSF",
"CLEONOS_CLKS_ENABLE_ELFRUNNER_PROBE",
"CLEONOS_CLKS_ENABLE_KLOGD_TASK",
"CLEONOS_CLKS_ENABLE_KWORKER_TASK",
"CLEONOS_CLKS_ENABLE_BOOT_VIDEO_LOG",
"CLEONOS_CLKS_ENABLE_PMM_STATS_LOG",
"CLEONOS_CLKS_ENABLE_HEAP_STATS_LOG",
"CLEONOS_CLKS_ENABLE_FS_ROOT_LOG",
"CLEONOS_CLKS_ENABLE_ELFRUNNER_INIT",
"CLEONOS_CLKS_ENABLE_SYSCALL_TICK_QUERY",
"CLEONOS_CLKS_ENABLE_TTY_READY_LOG",
"CLEONOS_CLKS_ENABLE_IDLE_DEBUG_LOG",
"CLEONOS_CLKS_ENABLE_USER_SYSTEM_APP_PROBE",
"CLEONOS_CLKS_ENABLE_SCHED_TASK_COUNT_LOG",
]
for key in clks_disable:
2026-04-19 16:07:44 +08:00
_set_option_if_exists(values, option_index, key, TRI_N)
2026-04-18 19:31:19 +08:00
clks_enable = [
"CLEONOS_CLKS_ENABLE_KEYBOARD",
"CLEONOS_CLKS_ENABLE_USRD_TASK",
"CLEONOS_CLKS_ENABLE_USERLAND_AUTO_EXEC",
"CLEONOS_CLKS_ENABLE_HEAP_SELFTEST",
"CLEONOS_CLKS_ENABLE_SYSTEM_DIR_CHECK",
"CLEONOS_CLKS_ENABLE_PROCFS",
"CLEONOS_CLKS_ENABLE_EXEC_SERIAL_LOG",
"CLEONOS_CLKS_ENABLE_KBD_TTY_SWITCH_HOTKEY",
"CLEONOS_CLKS_ENABLE_KBD_CTRL_SHORTCUTS",
"CLEONOS_CLKS_ENABLE_KBD_FORCE_STOP_HOTKEY",
"CLEONOS_CLKS_ENABLE_USER_INIT_SCRIPT_PROBE",
"CLEONOS_CLKS_ENABLE_INTERRUPT_READY_LOG",
"CLEONOS_CLKS_ENABLE_SHELL_MODE_LOG",
]
for key in clks_enable:
2026-04-19 16:07:44 +08:00
_set_option_if_exists(values, option_index, key, TRI_Y)
2026-04-18 19:31:19 +08:00
user_enable_tokens = [
"SHELL",
"HELP",
"LS",
"CD",
"PWD",
"CAT",
"CLEAR",
"EXIT",
"EXEC",
"DMESG",
"TTY",
"PID",
"PS",
"KILL",
"JOBS",
"FG",
"BG",
"RESTART",
"SHUTDOWN",
"TTYDRV",
]
for token in user_enable_tokens:
2026-04-19 16:07:44 +08:00
_set_option_if_exists(values, option_index, f"CLEONOS_USER_APP_{token}", TRI_Y)
2026-04-18 19:31:19 +08:00
return
raise RuntimeError(f"unknown preset: {preset}")
2026-04-19 16:07:44 +08:00
def _allowed_values(item: OptionItem, min_required: int, max_selectable: int) -> List[int]:
upper = normalize_tri(max_selectable, TRI_N, item.kind)
lower = normalize_tri(min_required, TRI_N, item.kind)
if lower > upper:
lower = upper
if item.kind == "tristate":
base = [TRI_N, TRI_M, TRI_Y]
else:
base = [TRI_N, TRI_Y]
values = [v for v in base if lower <= v <= upper]
if values:
return values
return [lower]
def _choose_select_level(src_value: int, dst_kind: str, is_imply: bool) -> int:
if src_value <= TRI_N:
return TRI_N
if dst_kind == "bool":
if is_imply:
return TRI_Y if src_value == TRI_Y else TRI_N
return TRI_Y
return src_value
def evaluate_config(options: List[OptionItem], values: Dict[str, int]) -> EvalResult:
option_index = _build_index(options)
depends_symbols = {item.key: extract_dep_symbols(item.depends_on) for item in options}
effective: Dict[str, int] = {}
for item in options:
effective[item.key] = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
visible = {item.key: True for item in options}
min_required = {item.key: TRI_N for item in options}
max_selectable = {item.key: TRI_Y for item in options}
selected_by = {item.key: [] for item in options}
implied_by = {item.key: [] for item in options}
max_rounds = max(1, len(options) * 8)
for _ in range(max_rounds):
dep_value: Dict[str, int] = {}
for item in options:
def _resolver(symbol: str) -> int:
return effective.get(symbol, TRI_N)
try:
dep_value[item.key] = normalize_tri(eval_dep_expr(item.depends_on, _resolver), TRI_Y, "tristate")
except Exception:
dep_value[item.key] = TRI_N
new_visible: Dict[str, bool] = {}
new_max: Dict[str, int] = {}
for item in options:
dep = dep_value[item.key]
new_visible[item.key] = dep > TRI_N
if item.kind == "bool":
new_max[item.key] = TRI_Y if dep == TRI_Y else TRI_N
else:
new_max[item.key] = dep
new_min = {item.key: TRI_N for item in options}
new_selected_by = {item.key: [] for item in options}
new_implied_by = {item.key: [] for item in options}
for src in options:
src_value = effective[src.key]
if src_value <= TRI_N:
continue
for dst_key in src.selects:
dst = option_index.get(dst_key)
if dst is None:
continue
level = _choose_select_level(src_value, dst.kind, is_imply=False)
if level > new_min[dst_key]:
new_min[dst_key] = level
new_selected_by[dst_key].append(f"{src.key}={tri_char(src_value)}")
for dst_key in src.implies:
dst = option_index.get(dst_key)
if dst is None:
continue
new_implied_by[dst_key].append(f"{src.key}={tri_char(src_value)}")
changed = False
next_effective: Dict[str, int] = {}
for item in options:
req = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
upper = normalize_tri(new_max[item.key], TRI_N, item.kind)
lower = normalize_tri(new_min[item.key], TRI_N, item.kind)
if lower > upper:
lower = upper
eff = req
if eff < lower:
eff = lower
if eff > upper:
eff = upper
eff = normalize_tri(eff, item.default, item.kind)
next_effective[item.key] = eff
if eff != effective[item.key]:
changed = True
effective = next_effective
visible = new_visible
min_required = new_min
max_selectable = new_max
selected_by = new_selected_by
implied_by = new_implied_by
if not changed:
break
for key in selected_by:
selected_by[key].sort()
implied_by[key].sort()
return EvalResult(
effective=effective,
visible=visible,
min_required=min_required,
max_selectable=max_selectable,
selected_by=selected_by,
implied_by=implied_by,
depends_symbols=depends_symbols,
)
def _option_on(value: int) -> bool:
return value > TRI_N
def _set_all(values: Dict[str, int], options: List[OptionItem], level: int) -> None:
for item in options:
values[item.key] = normalize_tri(level, item.default, item.kind)
2026-04-18 15:37:24 +08:00
2026-04-19 16:07:44 +08:00
def _set_option_value(values: Dict[str, int], item: OptionItem, level: int) -> None:
values[item.key] = normalize_tri(level, item.default, item.kind)
def _cycle_option_value(values: Dict[str, int], item: OptionItem, evaluation: EvalResult, step: int = 1) -> None:
allowed = _allowed_values(item, evaluation.min_required[item.key], evaluation.max_selectable[item.key])
if not allowed:
return
current = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
if current not in allowed:
current = evaluation.effective.get(item.key, item.default)
if current not in allowed:
current = allowed[0]
pos = allowed.index(current)
values[item.key] = allowed[(pos + step) % len(allowed)]
def _detail_lines(item: OptionItem, values: Dict[str, int], ev: EvalResult) -> List[str]:
req = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
eff = ev.effective.get(item.key, item.default)
visible = ev.visible.get(item.key, True)
floor_val = ev.min_required.get(item.key, TRI_N)
ceil_val = ev.max_selectable.get(item.key, TRI_Y)
allowed = ",".join(tri_char(v) for v in _allowed_values(item, floor_val, ceil_val))
lines = [
f"kind: {item.kind}",
f"requested: {tri_char(req)}",
f"effective: {tri_char(eff)}",
f"visible: {'yes' if visible else 'no'}",
f"allowed: {allowed}",
f"depends: {item.depends_on or '<none>'}",
]
symbols = ev.depends_symbols.get(item.key, [])
if symbols:
parts = [f"{sym}={tri_char(ev.effective.get(sym, TRI_N))}" for sym in symbols]
lines.append("depends values: " + ", ".join(parts))
else:
lines.append("depends values: <none>")
sel = ev.selected_by.get(item.key, [])
imp = ev.implied_by.get(item.key, [])
lines.append("selected by: " + (", ".join(sel) if sel else "<none>"))
lines.append("implied by: " + (", ".join(imp) if imp else "<none>"))
return lines
def _tri_word(value: int) -> str:
if value >= TRI_Y:
return "Enabled"
if value >= TRI_M:
return "Module"
return "Disabled"
def _detail_lines_human(item: OptionItem, values: Dict[str, int], ev: EvalResult) -> List[str]:
req = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
eff = ev.effective.get(item.key, item.default)
visible = ev.visible.get(item.key, True)
floor_val = ev.min_required.get(item.key, TRI_N)
ceil_val = ev.max_selectable.get(item.key, TRI_Y)
allowed_vals = _allowed_values(item, floor_val, ceil_val)
symbols = ev.depends_symbols.get(item.key, [])
if symbols:
dep_values = ", ".join(f"{sym}={tri_char(ev.effective.get(sym, TRI_N))}" for sym in symbols)
else:
dep_values = "<none>"
selected_chain = ", ".join(ev.selected_by.get(item.key, [])) or "<none>"
implied_chain = ", ".join(ev.implied_by.get(item.key, [])) or "<none>"
allowed_text = "/".join(f"{tri_char(v)}({_tri_word(v)})" for v in allowed_vals)
return [
"State:",
f" Running now : {tri_char(eff)} ({_tri_word(eff)})",
f" Your choice : {tri_char(req)} ({_tri_word(req)})",
f" Type : {item.kind}",
f" Visible : {'yes' if visible else 'no'}",
f" Allowed now : {allowed_text}",
"Why:",
f" depends on : {item.depends_on or '<none>'}",
f" depends value : {dep_values}",
f" selected by : {selected_chain}",
f" implied by : {implied_chain}",
"Notes:",
" [a/b] in list means [effective/requested].",
f" {item.description}",
]
def print_section(title: str, section_options: List[OptionItem], all_options: List[OptionItem], values: Dict[str, int]) -> None:
ev = evaluate_config(all_options, values)
print()
print(f"== {title} ==")
for idx, item in enumerate(section_options, start=1):
req = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
eff = ev.effective.get(item.key, item.default)
visible = ev.visible.get(item.key, True)
floor_val = ev.min_required.get(item.key, TRI_N)
ceil_val = ev.max_selectable.get(item.key, TRI_Y)
locked = len(_allowed_values(item, floor_val, ceil_val)) <= 1
flags: List[str] = []
if not visible:
flags.append("hidden")
if locked:
flags.append("locked")
if ev.selected_by.get(item.key):
flags.append("selected")
if ev.implied_by.get(item.key):
flags.append("implied")
flag_text = f" ({', '.join(flags)})" if flags else ""
print(f"{idx:3d}. [{tri_char(eff)}|{tri_char(req)}] {item.title}{flag_text}")
print("Commands: <number> cycle, a all->y, n all->n, m all->m, i <n> info, b back")
print("Legend: [effective|requested], states: y/m/n")
def section_loop(title: str, section_options: List[OptionItem], all_options: List[OptionItem], values: Dict[str, int]) -> None:
2026-04-18 15:37:24 +08:00
while True:
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
print_section(title, section_options, all_options, values)
2026-04-18 15:37:24 +08:00
raw = input(f"{title}> ").strip()
if not raw:
continue
lower = raw.lower()
if lower in {"b", "back", "q", "quit"}:
return
if lower in {"a", "all", "on"}:
2026-04-19 16:07:44 +08:00
for item in section_options:
_set_option_value(values, item, TRI_Y)
2026-04-18 15:37:24 +08:00
continue
if lower in {"n", "none", "off"}:
2026-04-19 16:07:44 +08:00
for item in section_options:
_set_option_value(values, item, TRI_N)
continue
if lower in {"m", "mod", "module"}:
for item in section_options:
_set_option_value(values, item, TRI_M)
2026-04-18 15:37:24 +08:00
continue
if lower.startswith("i "):
token = lower[2:].strip()
if token.isdigit():
idx = int(token)
2026-04-19 16:07:44 +08:00
if 1 <= idx <= len(section_options):
item = section_options[idx - 1]
2026-04-18 15:37:24 +08:00
print()
print(f"[{idx}] {item.title}")
print(f"key: {item.key}")
2026-04-19 16:07:44 +08:00
for line in _detail_lines(item, values, ev):
print(line)
2026-04-18 15:37:24 +08:00
print(f"desc: {item.description}")
continue
print("invalid info index")
continue
if raw.isdigit():
idx = int(raw)
2026-04-19 16:07:44 +08:00
if 1 <= idx <= len(section_options):
item = section_options[idx - 1]
_cycle_option_value(values, item, ev)
2026-04-18 15:37:24 +08:00
else:
print("invalid index")
continue
print("unknown command")
def _safe_addnstr(stdscr, y: int, x: int, text: str, attr: int = 0) -> None:
h, w = stdscr.getmaxyx()
if y < 0 or y >= h or x >= w:
return
max_len = max(0, w - x - 1)
if max_len <= 0:
return
try:
stdscr.addnstr(y, x, text, max_len, attr)
except Exception:
pass
2026-04-18 15:56:15 +08:00
def _safe_addch(stdscr, y: int, x: int, ch, attr: int = 0) -> None:
h, w = stdscr.getmaxyx()
if y < 0 or y >= h or x < 0 or x >= w:
return
try:
stdscr.addch(y, x, ch, attr)
except Exception:
pass
def _curses_theme() -> Dict[str, int]:
# Reasonable monochrome fallback first.
theme = {
"header": curses.A_BOLD,
"subtitle": curses.A_DIM,
"panel_border": curses.A_DIM,
"panel_title": curses.A_BOLD,
"selected": curses.A_REVERSE | curses.A_BOLD,
"enabled": curses.A_BOLD,
"disabled": curses.A_DIM,
"value_key": curses.A_DIM,
"value_label": curses.A_BOLD,
"help": curses.A_DIM,
"status_ok": curses.A_BOLD,
"status_warn": curses.A_BOLD,
"progress_on": curses.A_REVERSE,
"progress_off": curses.A_DIM,
"scroll_track": curses.A_DIM,
"scroll_thumb": curses.A_BOLD,
}
if not curses.has_colors():
return theme
try:
curses.start_color()
except Exception:
return theme
try:
curses.use_default_colors()
except Exception:
pass
# Pair index map
# 1: Header
# 2: Subtitle
# 3: Panel border/title
# 4: Selected row
# 5: Enabled accent
# 6: Disabled accent
# 7: Footer/help
# 8: Success/status
# 9: Warning/status
# 10: Scroll thumb
try:
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(2, curses.COLOR_CYAN, -1)
curses.init_pair(3, curses.COLOR_BLUE, -1)
curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLUE)
curses.init_pair(5, curses.COLOR_GREEN, -1)
curses.init_pair(6, curses.COLOR_RED, -1)
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_GREEN)
curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(10, curses.COLOR_MAGENTA, -1)
except Exception:
return theme
theme.update(
{
"header": curses.color_pair(1) | curses.A_BOLD,
"subtitle": curses.color_pair(2) | curses.A_DIM,
"panel_border": curses.color_pair(3),
"panel_title": curses.color_pair(3) | curses.A_BOLD,
"selected": curses.color_pair(4) | curses.A_BOLD,
"enabled": curses.color_pair(5) | curses.A_BOLD,
"disabled": curses.color_pair(6) | curses.A_DIM,
"value_key": curses.color_pair(2) | curses.A_DIM,
"value_label": curses.A_BOLD,
"help": curses.color_pair(7),
"status_ok": curses.color_pair(8) | curses.A_BOLD,
"status_warn": curses.color_pair(9) | curses.A_BOLD,
"progress_on": curses.color_pair(5) | curses.A_REVERSE,
"progress_off": curses.A_DIM,
"scroll_track": curses.A_DIM,
"scroll_thumb": curses.color_pair(10) | curses.A_BOLD,
}
)
return theme
def _draw_box(stdscr, y: int, x: int, h: int, w: int, title: str, border_attr: int, title_attr: int) -> None:
if h < 2 or w < 4:
return
right = x + w - 1
bottom = y + h - 1
_safe_addch(stdscr, y, x, curses.ACS_ULCORNER, border_attr)
_safe_addch(stdscr, y, right, curses.ACS_URCORNER, border_attr)
_safe_addch(stdscr, bottom, x, curses.ACS_LLCORNER, border_attr)
_safe_addch(stdscr, bottom, right, curses.ACS_LRCORNER, border_attr)
for col in range(x + 1, right):
_safe_addch(stdscr, y, col, curses.ACS_HLINE, border_attr)
_safe_addch(stdscr, bottom, col, curses.ACS_HLINE, border_attr)
for row in range(y + 1, bottom):
_safe_addch(stdscr, row, x, curses.ACS_VLINE, border_attr)
_safe_addch(stdscr, row, right, curses.ACS_VLINE, border_attr)
if title:
_safe_addnstr(stdscr, y, x + 2, f" {title} ", title_attr)
def _draw_progress_bar(
stdscr,
y: int,
x: int,
width: int,
enabled_count: int,
total_count: int,
on_attr: int,
off_attr: int,
) -> None:
if width < 8 or total_count <= 0:
return
bar_w = width - 8
if bar_w < 4:
return
fill = int((enabled_count * bar_w) / total_count)
for i in range(bar_w):
ch = "#" if i < fill else "-"
attr = on_attr if i < fill else off_attr
_safe_addch(stdscr, y, x + i, ch, attr)
_safe_addnstr(stdscr, y, x + bar_w + 1, f"{enabled_count:>3}/{total_count:<3}", off_attr | curses.A_BOLD)
2026-04-19 16:07:44 +08:00
def _option_enabled(ev: EvalResult, item: OptionItem) -> bool:
return ev.effective.get(item.key, item.default) > TRI_N
2026-04-18 15:37:24 +08:00
2026-04-19 16:07:44 +08:00
def _option_flags(item: OptionItem, ev: EvalResult) -> str:
flags: List[str] = []
if not ev.visible.get(item.key, True):
flags.append("hidden")
if len(_allowed_values(item, ev.min_required.get(item.key, TRI_N), ev.max_selectable.get(item.key, TRI_Y))) <= 1:
flags.append("locked")
if ev.selected_by.get(item.key):
flags.append("selected")
if ev.implied_by.get(item.key):
flags.append("implied")
if not flags:
return "-"
return ",".join(flags)
2026-04-18 15:37:24 +08:00
2026-04-18 15:56:15 +08:00
def _draw_scrollbar(stdscr, y: int, x: int, height: int, total: int, top: int, visible: int, track_attr: int, thumb_attr: int) -> None:
if height <= 0:
return
for r in range(height):
_safe_addch(stdscr, y + r, x, "|", track_attr)
if total <= 0 or visible <= 0 or total <= visible:
for r in range(height):
_safe_addch(stdscr, y + r, x, "|", thumb_attr)
return
thumb_h = max(1, int((visible * height) / total))
if thumb_h > height:
thumb_h = height
max_top = max(1, total - visible)
max_pos = max(0, height - thumb_h)
thumb_y = int((top * max_pos) / max_top)
for r in range(thumb_h):
_safe_addch(stdscr, y + thumb_y + r, x, "#", thumb_attr)
2026-04-19 16:07:44 +08:00
def _run_ncurses_section(
stdscr,
theme: Dict[str, int],
title: str,
section_options: List[OptionItem],
all_options: List[OptionItem],
values: Dict[str, int],
) -> None:
2026-04-18 15:37:24 +08:00
selected = 0
top = 0
while True:
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
2026-04-18 15:37:24 +08:00
stdscr.erase()
h, w = stdscr.getmaxyx()
2026-04-18 15:56:15 +08:00
if h < 14 or w < 70:
_safe_addnstr(stdscr, 0, 0, "Terminal too small for rich UI (need >= 70x14).", theme["status_warn"])
2026-04-18 15:37:24 +08:00
_safe_addnstr(stdscr, 2, 0, "Resize terminal then press any key, or ESC to go back.")
key = stdscr.getch()
if key in (27,):
return
continue
2026-04-18 15:56:15 +08:00
left_w = max(38, int(w * 0.58))
right_w = w - left_w
if right_w < 24:
left_w = w - 24
right_w = 24
list_box_y = 2
list_box_x = 0
list_box_h = h - 4
list_box_w = left_w
detail_box_y = 2
detail_box_x = left_w
detail_box_h = h - 4
detail_box_w = w - left_w
_safe_addnstr(stdscr, 0, 0, f" CLeonOS menuconfig / {title} ", theme["header"])
2026-04-19 16:07:44 +08:00
enabled_count = sum(1 for item in section_options if _option_enabled(ev, item))
module_count = sum(1 for item in section_options if ev.effective.get(item.key, TRI_N) == TRI_M)
2026-04-18 15:56:15 +08:00
_safe_addnstr(
stdscr,
1,
0,
2026-04-19 16:07:44 +08:00
f" on:{enabled_count} mod:{module_count} total:{len(section_options)} | Space cycle a/n/m all Enter/ESC back ",
2026-04-18 15:56:15 +08:00
theme["subtitle"],
)
_draw_box(stdscr, list_box_y, list_box_x, list_box_h, list_box_w, "Options", theme["panel_border"], theme["panel_title"])
_draw_box(stdscr, detail_box_y, detail_box_x, detail_box_h, detail_box_w, "Details", theme["panel_border"], theme["panel_title"])
list_inner_y = list_box_y + 1
list_inner_x = list_box_x + 1
list_inner_h = list_box_h - 2
list_inner_w = list_box_w - 2
visible = max(1, list_inner_h)
2026-04-18 15:37:24 +08:00
if selected < 0:
selected = 0
2026-04-19 16:07:44 +08:00
if selected >= len(section_options):
selected = max(0, len(section_options) - 1)
2026-04-18 15:37:24 +08:00
if selected < top:
top = selected
if selected >= top + visible:
top = selected - visible + 1
if top < 0:
top = 0
for row in range(visible):
idx = top + row
2026-04-19 16:07:44 +08:00
if idx >= len(section_options):
2026-04-18 15:37:24 +08:00
break
2026-04-19 16:07:44 +08:00
item = section_options[idx]
req = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
eff = ev.effective.get(item.key, item.default)
flags = _option_flags(item, ev)
2026-04-18 15:56:15 +08:00
prefix = ">" if idx == selected else " "
2026-04-19 16:07:44 +08:00
line = f"{prefix} {idx + 1:03d} [{tri_char(eff)}|{tri_char(req)}] {item.title}"
if flags != "-":
line += f" [{flags}]"
base_attr = theme["enabled"] if eff > TRI_N else theme["disabled"]
2026-04-18 15:56:15 +08:00
attr = theme["selected"] if idx == selected else base_attr
_safe_addnstr(stdscr, list_inner_y + row, list_inner_x, line, attr)
_draw_scrollbar(
stdscr,
list_inner_y,
list_box_x + list_box_w - 2,
list_inner_h,
2026-04-19 16:07:44 +08:00
len(section_options),
2026-04-18 15:56:15 +08:00
top,
visible,
theme["scroll_track"],
theme["scroll_thumb"],
)
2026-04-18 15:37:24 +08:00
2026-04-19 16:07:44 +08:00
if section_options:
cur = section_options[selected]
2026-04-18 15:56:15 +08:00
detail_inner_y = detail_box_y + 1
detail_inner_x = detail_box_x + 2
detail_inner_w = detail_box_w - 4
detail_inner_h = detail_box_h - 2
2026-04-19 16:07:44 +08:00
eff = ev.effective.get(cur.key, cur.default)
req = normalize_tri(values.get(cur.key, cur.default), cur.default, cur.kind)
state_text = f"effective={tri_char(eff)} requested={tri_char(req)} kind={cur.kind}"
state_attr = theme["status_ok"] if eff > TRI_N else theme["status_warn"]
2026-04-18 15:56:15 +08:00
_safe_addnstr(stdscr, detail_inner_y + 0, detail_inner_x, cur.title, theme["value_label"])
_safe_addnstr(stdscr, detail_inner_y + 1, detail_inner_x, cur.key, theme["value_key"])
_safe_addnstr(stdscr, detail_inner_y + 2, detail_inner_x, f"State: {state_text}", state_attr)
_safe_addnstr(
stdscr,
detail_inner_y + 3,
detail_inner_x,
2026-04-19 16:07:44 +08:00
f"Item: {selected + 1}/{len(section_options)} flags: {_option_flags(cur, ev)}",
2026-04-18 15:56:15 +08:00
theme["value_label"],
)
_draw_progress_bar(
stdscr,
detail_inner_y + 4,
detail_inner_x,
max(12, detail_inner_w),
enabled_count,
2026-04-19 16:07:44 +08:00
max(1, len(section_options)),
2026-04-18 15:56:15 +08:00
theme["progress_on"],
theme["progress_off"],
)
desc_title_y = detail_inner_y + 6
2026-04-19 16:07:44 +08:00
_safe_addnstr(stdscr, desc_title_y, detail_inner_x, "Details:", theme["value_label"])
raw_lines = _detail_lines_human(cur, values, ev)
wrapped_lines: List[str] = []
wrap_width = max(12, detail_inner_w)
for raw_line in raw_lines:
if not raw_line:
wrapped_lines.append("")
continue
chunks = textwrap.wrap(raw_line, wrap_width)
if chunks:
wrapped_lines.extend(chunks)
else:
wrapped_lines.append(raw_line)
2026-04-18 15:56:15 +08:00
max_desc_lines = max(1, detail_inner_h - 8)
2026-04-19 16:07:44 +08:00
for i, part in enumerate(wrapped_lines[:max_desc_lines]):
2026-04-18 15:56:15 +08:00
_safe_addnstr(stdscr, desc_title_y + 1 + i, detail_inner_x, part, 0)
2026-04-19 16:07:44 +08:00
_safe_addnstr(stdscr, h - 1, 0, " Space:cycle a:all-y n:all-n m:all-m Enter/ESC:back ", theme["help"])
2026-04-18 15:37:24 +08:00
stdscr.refresh()
key = stdscr.getch()
if key in (27, ord("q"), ord("Q"), curses.KEY_LEFT, curses.KEY_ENTER, 10, 13):
return
if key in (curses.KEY_UP, ord("k"), ord("K")):
selected -= 1
continue
if key in (curses.KEY_DOWN, ord("j"), ord("J")):
selected += 1
continue
if key == curses.KEY_PPAGE:
selected -= visible
continue
if key == curses.KEY_NPAGE:
selected += visible
continue
if key == curses.KEY_HOME:
selected = 0
continue
if key == curses.KEY_END:
2026-04-19 16:07:44 +08:00
selected = max(0, len(section_options) - 1)
2026-04-18 15:37:24 +08:00
continue
if key == ord(" "):
2026-04-19 16:07:44 +08:00
if section_options:
item = section_options[selected]
_cycle_option_value(values, item, ev)
2026-04-18 15:37:24 +08:00
continue
if key in (ord("a"), ord("A")):
2026-04-19 16:07:44 +08:00
_set_all(values, section_options, TRI_Y)
2026-04-18 15:37:24 +08:00
continue
if key in (ord("n"), ord("N")):
2026-04-19 16:07:44 +08:00
_set_all(values, section_options, TRI_N)
continue
if key in (ord("m"), ord("M")):
_set_all(values, section_options, TRI_M)
2026-04-18 15:37:24 +08:00
continue
2026-04-19 16:07:44 +08:00
def _run_ncurses_main(stdscr, clks_options: List[OptionItem], user_options: List[OptionItem], values: Dict[str, int]) -> bool:
2026-04-18 15:56:15 +08:00
theme = _curses_theme()
2026-04-19 16:07:44 +08:00
all_options = clks_options + user_options
2026-04-18 15:37:24 +08:00
try:
curses.curs_set(0)
except Exception:
pass
stdscr.keypad(True)
selected = 0
while True:
stdscr.erase()
2026-04-18 15:56:15 +08:00
h, w = stdscr.getmaxyx()
2026-04-18 15:37:24 +08:00
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
clks_on = sum(1 for item in clks_options if _option_enabled(ev, item))
user_on = sum(1 for item in user_options if _option_enabled(ev, item))
2026-04-18 15:56:15 +08:00
total_items = len(clks_options) + len(user_options)
total_on = clks_on + user_on
2026-04-18 15:37:24 +08:00
items = [
f"CLKS features ({clks_on}/{len(clks_options)} enabled)",
f"User apps ({user_on}/{len(user_options)} enabled)",
"Save and Exit",
"Quit without Saving",
]
2026-04-18 15:56:15 +08:00
if h < 12 or w < 58:
_safe_addnstr(stdscr, 0, 0, "Terminal too small for menuconfig (need >= 58x12).", theme["status_warn"])
_safe_addnstr(stdscr, 2, 0, "Resize terminal then press any key.")
stdscr.getch()
continue
_safe_addnstr(stdscr, 0, 0, " CLeonOS menuconfig ", theme["header"])
_safe_addnstr(stdscr, 1, 0, " Stylish ncurses UI | Enter: open/select s: save q: quit ", theme["subtitle"])
_draw_box(stdscr, 2, 0, h - 5, w, "Main", theme["panel_border"], theme["panel_title"])
2026-04-18 15:37:24 +08:00
2026-04-18 15:56:15 +08:00
base = 4
2026-04-18 15:37:24 +08:00
for i, text in enumerate(items):
2026-04-18 15:56:15 +08:00
prefix = ">" if i == selected else " "
row_text = f"{prefix} {text}"
attr = theme["selected"] if i == selected else theme["value_label"]
_safe_addnstr(stdscr, base + i, 2, row_text, attr)
_safe_addnstr(stdscr, base + 6, 2, "Global Progress:", theme["value_label"])
_draw_progress_bar(
stdscr,
base + 7,
2,
max(18, w - 6),
total_on,
max(1, total_items),
theme["progress_on"],
theme["progress_off"],
)
2026-04-18 15:37:24 +08:00
2026-04-18 15:56:15 +08:00
_safe_addnstr(stdscr, h - 2, 0, " Arrows/jk move Enter select s save q quit ", theme["help"])
_safe_addnstr(stdscr, h - 1, 0, " Tip: open CLKS/USER section then use Space to toggle options. ", theme["help"])
2026-04-18 15:37:24 +08:00
stdscr.refresh()
key = stdscr.getch()
if key in (ord("q"), ord("Q"), 27):
return False
if key in (ord("s"), ord("S")):
return True
if key in (curses.KEY_UP, ord("k"), ord("K")):
selected = (selected - 1) % len(items)
continue
if key in (curses.KEY_DOWN, ord("j"), ord("J")):
selected = (selected + 1) % len(items)
continue
if key in (curses.KEY_ENTER, 10, 13):
if selected == 0:
2026-04-19 16:07:44 +08:00
_run_ncurses_section(stdscr, theme, "CLKS", clks_options, all_options, values)
2026-04-18 15:37:24 +08:00
elif selected == 1:
2026-04-19 16:07:44 +08:00
_run_ncurses_section(stdscr, theme, "USER", user_options, all_options, values)
2026-04-18 15:37:24 +08:00
elif selected == 2:
return True
else:
return False
continue
2026-04-19 16:07:44 +08:00
def interactive_menu_ncurses(clks_options: List[OptionItem], user_options: List[OptionItem], values: Dict[str, int]) -> bool:
2026-04-18 15:37:24 +08:00
if curses is None:
raise RuntimeError("python curses module unavailable (install python3-curses / ncurses)")
if "TERM" not in os.environ or not os.environ["TERM"]:
raise RuntimeError("TERM is not set; cannot start ncurses UI")
return bool(curses.wrapper(lambda stdscr: _run_ncurses_main(stdscr, clks_options, user_options, values)))
2026-04-19 16:07:44 +08:00
def interactive_menu_gui(clks_options: List[OptionItem], user_options: List[OptionItem], values: Dict[str, int]) -> bool:
2026-04-18 19:22:25 +08:00
if QtWidgets is None or QtCore is None:
raise RuntimeError("python PySide unavailable (install PySide6, or use --plain)")
if os.name != "nt" and not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
raise RuntimeError("GUI mode requires a desktop display (DISPLAY/WAYLAND_DISPLAY)")
app = QtWidgets.QApplication.instance()
owns_app = False
if app is None:
app = QtWidgets.QApplication(["menuconfig-gui"])
owns_app = True
qt_horizontal = getattr(QtCore.Qt, "Horizontal", QtCore.Qt.Orientation.Horizontal)
qt_item_enabled = getattr(QtCore.Qt, "ItemIsEnabled", QtCore.Qt.ItemFlag.ItemIsEnabled)
qt_item_selectable = getattr(QtCore.Qt, "ItemIsSelectable", QtCore.Qt.ItemFlag.ItemIsSelectable)
resize_to_contents = getattr(
QtWidgets.QHeaderView,
"ResizeToContents",
QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
stretch_mode = getattr(
QtWidgets.QHeaderView,
"Stretch",
QtWidgets.QHeaderView.ResizeMode.Stretch,
)
select_rows = getattr(
QtWidgets.QAbstractItemView,
"SelectRows",
QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows,
)
extended_selection = getattr(
QtWidgets.QAbstractItemView,
"ExtendedSelection",
QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection,
)
result = {"save": False}
dialog = QtWidgets.QDialog()
dialog.setWindowTitle("CLeonOS menuconfig (PySide)")
dialog.resize(1180, 760)
dialog.setMinimumSize(920, 560)
if os.name == "nt":
dialog.setWindowState(dialog.windowState() | QtCore.Qt.WindowMaximized)
root_layout = QtWidgets.QVBoxLayout(dialog)
root_layout.setContentsMargins(12, 10, 12, 12)
root_layout.setSpacing(8)
header_title = QtWidgets.QLabel("CLeonOS menuconfig")
header_font = header_title.font()
header_font.setPointSize(header_font.pointSize() + 4)
header_font.setBold(True)
header_title.setFont(header_font)
root_layout.addWidget(header_title)
root_layout.addWidget(QtWidgets.QLabel("Window mode (PySide): configure CLKS features and user apps, then save."))
summary_label = QtWidgets.QLabel("")
root_layout.addWidget(summary_label)
tabs = QtWidgets.QTabWidget()
root_layout.addWidget(tabs, 1)
2026-04-19 16:07:44 +08:00
all_options = clks_options + user_options
2026-04-18 19:22:25 +08:00
def update_summary() -> None:
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
clks_on = sum(1 for item in clks_options if ev.effective.get(item.key, item.default) > TRI_N)
user_on = sum(1 for item in user_options if ev.effective.get(item.key, item.default) > TRI_N)
2026-04-18 19:22:25 +08:00
total = len(clks_options) + len(user_options)
summary_label.setText(
2026-04-19 16:07:44 +08:00
f"CLKS: {clks_on}/{len(clks_options)} on "
f"User: {user_on}/{len(user_options)} on "
2026-04-18 19:22:25 +08:00
f"Total: {clks_on + user_on}/{total}"
)
class _SectionPanel(QtWidgets.QWidget):
def __init__(self, title: str, options: List[OptionItem]):
super().__init__()
self.options = options
self._updating = False
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
toolbar = QtWidgets.QHBoxLayout()
title_label = QtWidgets.QLabel(title)
title_font = title_label.font()
title_font.setBold(True)
title_label.setFont(title_font)
toolbar.addWidget(title_label)
toolbar.addStretch(1)
2026-04-19 16:07:44 +08:00
toggle_btn = QtWidgets.QPushButton("Cycle Selected")
set_y_btn = QtWidgets.QPushButton("Set Y")
set_m_btn = QtWidgets.QPushButton("Set M")
set_n_btn = QtWidgets.QPushButton("Set N")
enable_all_btn = QtWidgets.QPushButton("All Y")
disable_all_btn = QtWidgets.QPushButton("All N")
2026-04-18 19:22:25 +08:00
toolbar.addWidget(enable_all_btn)
toolbar.addWidget(disable_all_btn)
2026-04-19 16:07:44 +08:00
toolbar.addWidget(set_m_btn)
toolbar.addWidget(set_y_btn)
toolbar.addWidget(set_n_btn)
2026-04-18 19:22:25 +08:00
toolbar.addWidget(toggle_btn)
layout.addLayout(toolbar)
splitter = QtWidgets.QSplitter(qt_horizontal)
layout.addWidget(splitter, 1)
left = QtWidgets.QWidget()
left_layout = QtWidgets.QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
2026-04-19 16:07:44 +08:00
self.table = QtWidgets.QTableWidget(len(options), 3)
self.table.setHorizontalHeaderLabels(["Value", "Option", "Status"])
2026-04-18 19:22:25 +08:00
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setSectionResizeMode(0, resize_to_contents)
self.table.horizontalHeader().setSectionResizeMode(1, stretch_mode)
2026-04-19 16:07:44 +08:00
self.table.horizontalHeader().setSectionResizeMode(2, resize_to_contents)
2026-04-18 19:22:25 +08:00
self.table.setSelectionBehavior(select_rows)
self.table.setSelectionMode(extended_selection)
self.table.setAlternatingRowColors(True)
left_layout.addWidget(self.table)
splitter.addWidget(left)
right = QtWidgets.QWidget()
right_layout = QtWidgets.QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
self.state_label = QtWidgets.QLabel("State: -")
self.key_label = QtWidgets.QLabel("Key: -")
self.detail_text = QtWidgets.QPlainTextEdit()
self.detail_text.setReadOnly(True)
right_layout.addWidget(self.state_label)
right_layout.addWidget(self.key_label)
right_layout.addWidget(self.detail_text, 1)
splitter.addWidget(right)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 2)
toggle_btn.clicked.connect(self.toggle_selected)
2026-04-19 16:07:44 +08:00
set_y_btn.clicked.connect(lambda: self.set_selected(TRI_Y))
set_m_btn.clicked.connect(lambda: self.set_selected(TRI_M))
set_n_btn.clicked.connect(lambda: self.set_selected(TRI_N))
2026-04-18 19:22:25 +08:00
enable_all_btn.clicked.connect(self.enable_all)
disable_all_btn.clicked.connect(self.disable_all)
self.table.itemSelectionChanged.connect(self._on_selection_changed)
2026-04-19 16:07:44 +08:00
self.table.itemDoubleClicked.connect(self._on_item_activated)
2026-04-18 19:22:25 +08:00
self.refresh(keep_selection=False)
if self.options:
self.table.selectRow(0)
self._show_detail(0)
def _selected_rows(self) -> List[int]:
rows = []
model = self.table.selectionModel()
if model is None:
return rows
for idx in model.selectedRows():
row = idx.row()
if row not in rows:
rows.append(row)
rows.sort()
return rows
def _show_detail(self, row: int) -> None:
if row < 0 or row >= len(self.options):
self.state_label.setText("State: -")
self.key_label.setText("Key: -")
self.detail_text.setPlainText("")
return
item = self.options[row]
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
eff = ev.effective.get(item.key, item.default)
req = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
self.state_label.setText(
f"State: eff={tri_char(eff)} req={tri_char(req)} kind={item.kind} flags={_option_flags(item, ev)}"
)
2026-04-18 19:22:25 +08:00
self.key_label.setText(f"Key: {item.key}")
2026-04-19 16:07:44 +08:00
self.detail_text.setPlainText("\n".join([item.title, ""] + _detail_lines(item, values, ev) + ["", item.description]))
2026-04-18 19:22:25 +08:00
def _on_selection_changed(self) -> None:
rows = self._selected_rows()
if len(rows) == 1:
self._show_detail(rows[0])
return
if len(rows) > 1:
self.state_label.setText(f"State: {len(rows)} items selected")
self.key_label.setText("Key: <multiple>")
2026-04-19 16:07:44 +08:00
self.detail_text.setPlainText("Multiple options selected.\nUse Cycle/Set buttons to update selected entries.")
2026-04-18 19:22:25 +08:00
return
self._show_detail(-1)
2026-04-19 16:07:44 +08:00
def _on_item_activated(self, changed_item) -> None:
2026-04-18 19:22:25 +08:00
if self._updating or changed_item is None:
return
row = changed_item.row()
if row < 0 or row >= len(self.options):
return
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
_cycle_option_value(values, self.options[row], ev)
self.refresh(keep_selection=True)
2026-04-18 19:22:25 +08:00
def refresh(self, keep_selection: bool = True) -> None:
prev_rows = self._selected_rows() if keep_selection else []
self._updating = True
self.table.setRowCount(len(self.options))
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
2026-04-18 19:22:25 +08:00
for row, item in enumerate(self.options):
2026-04-19 16:07:44 +08:00
req = normalize_tri(values.get(item.key, item.default), item.default, item.kind)
eff = ev.effective.get(item.key, item.default)
value_item = self.table.item(row, 0)
if value_item is None:
value_item = QtWidgets.QTableWidgetItem("")
value_item.setFlags(qt_item_enabled | qt_item_selectable)
self.table.setItem(row, 0, value_item)
value_item.setText(f"{tri_char(eff)} (req:{tri_char(req)})")
2026-04-18 19:22:25 +08:00
title_item = self.table.item(row, 1)
if title_item is None:
title_item = QtWidgets.QTableWidgetItem(item.title)
title_item.setFlags(qt_item_enabled | qt_item_selectable)
self.table.setItem(row, 1, title_item)
else:
title_item.setText(item.title)
2026-04-19 16:07:44 +08:00
status_item = self.table.item(row, 2)
if status_item is None:
status_item = QtWidgets.QTableWidgetItem("")
status_item.setFlags(qt_item_enabled | qt_item_selectable)
self.table.setItem(row, 2, status_item)
status_item.setText(_option_flags(item, ev))
2026-04-18 19:22:25 +08:00
self._updating = False
self.table.clearSelection()
if keep_selection:
for row in prev_rows:
if 0 <= row < len(self.options):
self.table.selectRow(row)
self._on_selection_changed()
update_summary()
def toggle_selected(self) -> None:
rows = self._selected_rows()
if not rows:
return
for row in rows:
item = self.options[row]
2026-04-19 16:07:44 +08:00
ev = evaluate_config(all_options, values)
_cycle_option_value(values, item, ev)
self.refresh(keep_selection=True)
def set_selected(self, state: int) -> None:
rows = self._selected_rows()
if not rows:
return
for row in rows:
item = self.options[row]
_set_option_value(values, item, state)
self.refresh(keep_selection=True)
2026-04-18 19:22:25 +08:00
def enable_all(self) -> None:
2026-04-19 16:07:44 +08:00
for item in self.options:
_set_option_value(values, item, TRI_Y)
self.refresh(keep_selection=False)
2026-04-18 19:22:25 +08:00
def disable_all(self) -> None:
2026-04-19 16:07:44 +08:00
for item in self.options:
_set_option_value(values, item, TRI_N)
self.refresh(keep_selection=False)
2026-04-18 19:22:25 +08:00
clks_panel = _SectionPanel("CLKS Features", clks_options)
user_panel = _SectionPanel("User Apps", user_options)
tabs.addTab(clks_panel, "CLKS")
tabs.addTab(user_panel, "USER")
update_summary()
footer = QtWidgets.QHBoxLayout()
2026-04-19 16:07:44 +08:00
footer.addWidget(QtWidgets.QLabel("Tip: double-click a row to cycle, or use Set/Cycle buttons."))
2026-04-18 19:22:25 +08:00
footer.addStretch(1)
save_btn = QtWidgets.QPushButton("Save and Exit")
quit_btn = QtWidgets.QPushButton("Quit without Saving")
footer.addWidget(save_btn)
footer.addWidget(quit_btn)
root_layout.addLayout(footer)
def _on_save() -> None:
result["save"] = True
dialog.accept()
def _on_quit() -> None:
result["save"] = False
dialog.reject()
save_btn.clicked.connect(_on_save)
quit_btn.clicked.connect(_on_quit)
dialog.exec()
if owns_app:
app.quit()
return result["save"]
2026-04-19 16:07:44 +08:00
def write_outputs(all_values: Dict[str, int], ordered_options: List[OptionItem]) -> None:
2026-04-18 15:37:24 +08:00
MENUCONFIG_DIR.mkdir(parents=True, exist_ok=True)
2026-04-19 16:07:44 +08:00
output_values: Dict[str, object] = {}
for item in ordered_options:
if item.key not in all_values:
continue
value = normalize_tri(all_values[item.key], item.default, item.kind)
if item.kind == "bool":
output_values[item.key] = value == TRI_Y
else:
output_values[item.key] = tri_char(value)
2026-04-18 15:37:24 +08:00
CONFIG_JSON_PATH.write_text(
json.dumps(output_values, ensure_ascii=True, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
lines = [
"# Auto-generated by scripts/menuconfig.py",
"# Do not edit manually unless you know what you are doing.",
'set(CLEONOS_MENUCONFIG_LOADED ON CACHE BOOL "CLeonOS menuconfig loaded" FORCE)',
]
for item in ordered_options:
2026-04-19 16:07:44 +08:00
value = normalize_tri(all_values.get(item.key, item.default), item.default, item.kind)
if item.kind == "bool":
cmake_value = "ON" if value == TRI_Y else "OFF"
lines.append(f'set({item.key} {cmake_value} CACHE BOOL "{item.title}" FORCE)')
else:
cmake_value = tri_char(value).upper()
lines.append(f'set({item.key} "{cmake_value}" CACHE STRING "{item.title}" FORCE)')
lines.append(
f'set({item.key}_IS_ENABLED {"ON" if value > TRI_N else "OFF"} '
f'CACHE BOOL "{item.title} enabled(y|m)" FORCE)'
)
2026-04-18 15:37:24 +08:00
CONFIG_CMAKE_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
2026-04-19 16:07:44 +08:00
def show_summary(clks_options: List[OptionItem], user_options: List[OptionItem], values: Dict[str, int]) -> None:
all_options = clks_options + user_options
ev = evaluate_config(all_options, values)
clks_on = sum(1 for item in clks_options if ev.effective.get(item.key, item.default) > TRI_N)
user_on = sum(1 for item in user_options if ev.effective.get(item.key, item.default) > TRI_N)
clks_m = sum(1 for item in clks_options if ev.effective.get(item.key, item.default) == TRI_M)
user_m = sum(1 for item in user_options if ev.effective.get(item.key, item.default) == TRI_M)
2026-04-18 15:37:24 +08:00
print()
print("========== CLeonOS menuconfig ==========")
2026-04-19 16:07:44 +08:00
print(f"1) CLKS features : on={clks_on} m={clks_m} total={len(clks_options)}")
print(f"2) User features : on={user_on} m={user_m} total={len(user_options)}")
2026-04-18 15:37:24 +08:00
print("s) Save and exit")
print("q) Quit without saving")
2026-04-19 16:07:44 +08:00
def interactive_menu(clks_options: List[OptionItem], user_options: List[OptionItem], values: Dict[str, int]) -> bool:
all_options = clks_options + user_options
2026-04-18 15:37:24 +08:00
while True:
show_summary(clks_options, user_options, values)
choice = input("Select> ").strip().lower()
if choice == "1":
2026-04-19 16:07:44 +08:00
section_loop("CLKS", clks_options, all_options, values)
2026-04-18 15:37:24 +08:00
continue
if choice == "2":
2026-04-19 16:07:44 +08:00
section_loop("USER", user_options, all_options, values)
2026-04-18 15:37:24 +08:00
continue
if choice in {"s", "save"}:
return True
if choice in {"q", "quit"}:
return False
print("unknown selection")
2026-04-19 16:07:44 +08:00
def parse_set_overrides(values: Dict[str, int], option_index: Dict[str, OptionItem], kv_pairs: List[str]) -> None:
2026-04-18 15:37:24 +08:00
for pair in kv_pairs:
if "=" not in pair:
2026-04-19 16:07:44 +08:00
raise RuntimeError(f"invalid --set entry: {pair!r}, expected KEY=Y|M|N")
2026-04-18 15:37:24 +08:00
key, raw = pair.split("=", 1)
key = key.strip()
if not key:
raise RuntimeError(f"invalid --set entry: {pair!r}, empty key")
2026-04-19 16:07:44 +08:00
item = option_index.get(key)
if item is None:
values[key] = normalize_tri(raw, TRI_N, "tristate")
else:
values[key] = normalize_tri(raw, item.default, item.kind)
2026-04-18 15:37:24 +08:00
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="CLeonOS menuconfig")
parser.add_argument("--defaults", action="store_true", help="ignore previous .config and use defaults")
parser.add_argument("--non-interactive", action="store_true", help="save config without opening interactive menu")
parser.add_argument("--plain", action="store_true", help="use legacy plain-text menu instead of ncurses")
2026-04-18 19:22:25 +08:00
parser.add_argument("--gui", action="store_true", help="use GUI window mode (PySide)")
2026-04-18 19:31:19 +08:00
parser.add_argument(
"--preset",
choices=["full", "minimal", "dev"],
help="apply a built-in preset before interactive edit or save",
)
2026-04-18 15:37:24 +08:00
parser.add_argument(
"--set",
action="append",
default=[],
2026-04-19 16:07:44 +08:00
metavar="KEY=Y|M|N",
2026-04-18 15:37:24 +08:00
help="override one option before save (can be repeated)",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
2026-04-18 19:22:25 +08:00
if args.gui and args.plain:
raise RuntimeError("--gui and --plain cannot be used together")
2026-04-18 15:37:24 +08:00
clks_options = load_clks_options()
user_options = discover_user_apps()
all_options = clks_options + user_options
previous = load_previous_values()
values = init_values(all_options, previous, use_defaults=args.defaults)
2026-04-18 19:31:19 +08:00
if args.preset:
apply_preset(args.preset, clks_options, user_options, values)
2026-04-19 16:07:44 +08:00
option_index = _build_index(all_options)
parse_set_overrides(values, option_index, args.set)
2026-04-18 15:37:24 +08:00
should_save = args.non_interactive
if not args.non_interactive:
2026-04-18 19:22:25 +08:00
if args.gui:
should_save = interactive_menu_gui(clks_options, user_options, values)
2026-04-18 15:37:24 +08:00
else:
2026-04-18 19:22:25 +08:00
if not sys.stdin.isatty():
raise RuntimeError("menuconfig requires interactive tty (or use --non-interactive or --gui)")
if args.plain:
should_save = interactive_menu(clks_options, user_options, values)
else:
should_save = interactive_menu_ncurses(clks_options, user_options, values)
2026-04-18 15:37:24 +08:00
if not should_save:
print("menuconfig: no changes saved")
return 0
2026-04-19 16:07:44 +08:00
final_eval = evaluate_config(all_options, values)
write_outputs(final_eval.effective, all_options)
2026-04-18 15:37:24 +08:00
print(f"menuconfig: wrote {CONFIG_JSON_PATH}")
print(f"menuconfig: wrote {CONFIG_CMAKE_PATH}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except RuntimeError as exc:
print(f"menuconfig error: {exc}", file=sys.stderr)
raise SystemExit(1)