feature: tui onFocusChanged event

feature: add focus event for tui
reconstruct: tui props and accesscontrol parse logic
This commit is contained in:
2025-10-14 22:29:17 +08:00
parent d41117cecc
commit c85c072376
7 changed files with 211 additions and 98 deletions

View File

@@ -243,6 +243,18 @@ const AccessControlTUI = () => {
/** /**
* Basic Configuration Tab * Basic Configuration Tab
*/ */
const [getDetectInterval, setDetectInterval] = createSignal(
config().detectInterval.toString(),
);
const [getWatchInterval, setWatchInterval] = createSignal(
config().watchInterval.toString(),
);
const [getNoticeTimes, setNoticeTimes] = createSignal(
config().noticeTimes.toString(),
);
const [getDetectRange, setDetectRange] = createSignal(
config().detectRange.toString(),
);
const BasicTab = () => { const BasicTab = () => {
return div( return div(
{ class: "flex flex-col" }, { class: "flex flex-col" },
@@ -251,10 +263,12 @@ const AccessControlTUI = () => {
label({}, "Detect Interval (ms):"), label({}, "Detect Interval (ms):"),
input({ input({
type: "text", type: "text",
value: () => config().detectInterval?.toString() ?? "", value: () => getDetectInterval(),
onInput: (value) => { onInput: (value) => setDetectInterval(value),
const num = validateNumber(value); onFocusChanged: () => {
const num = validateNumber(getDetectInterval());
if (num !== null) setConfig("detectInterval", num); if (num !== null) setConfig("detectInterval", num);
else setDetectInterval(config().detectInterval.toString());
}, },
}), }),
), ),
@@ -263,10 +277,12 @@ const AccessControlTUI = () => {
label({}, "Watch Interval (ms):"), label({}, "Watch Interval (ms):"),
input({ input({
type: "text", type: "text",
value: () => config().watchInterval?.toString() ?? "", value: () => getWatchInterval(),
onInput: (value) => { onInput: (value) => setWatchInterval(value),
const num = validateNumber(value); onFocusChanged: () => {
const num = validateNumber(getWatchInterval());
if (num !== null) setConfig("watchInterval", num); if (num !== null) setConfig("watchInterval", num);
else setWatchInterval(config().watchInterval.toString());
}, },
}), }),
), ),
@@ -275,10 +291,12 @@ const AccessControlTUI = () => {
label({}, "Notice Times:"), label({}, "Notice Times:"),
input({ input({
type: "text", type: "text",
value: () => config().noticeTimes?.toString() ?? "", value: () => getNoticeTimes(),
onInput: (value) => { onInput: (value) => setNoticeTimes(value),
const num = validateNumber(value); onFocusChanged: () => {
const num = validateNumber(getNoticeTimes());
if (num !== null) setConfig("noticeTimes", num); if (num !== null) setConfig("noticeTimes", num);
else setNoticeTimes(config().noticeTimes.toString());
}, },
}), }),
), ),
@@ -287,10 +305,12 @@ const AccessControlTUI = () => {
label({}, "Detect Range:"), label({}, "Detect Range:"),
input({ input({
type: "text", type: "text",
value: () => config().detectRange?.toString() ?? "", value: () => getDetectRange(),
onInput: (value) => { onInput: (value) => setDetectRange(value),
const num = validateNumber(value); onFocusChanged: () => {
const num = validateNumber(getDetectRange());
if (num !== null) setConfig("detectRange", num); if (num !== null) setConfig("detectRange", num);
else setDetectRange(config().detectRange.toString());
}, },
}), }),
), ),
@@ -436,6 +456,13 @@ const AccessControlTUI = () => {
) => { ) => {
return () => { return () => {
const toastConfig = config()[toastType]; const toastConfig = config()[toastType];
const [getTempToastConfig, setTempToastConfig] = createSignal({
title: textutils.serialiseJSON(toastConfig.title),
msg: textutils.serialiseJSON(toastConfig.msg),
prefix: toastConfig.prefix ?? "",
brackets: toastConfig.brackets ?? "",
bracketColor: toastConfig.bracketColor ?? "",
});
return div( return div(
{ class: "flex flex-col w-full" }, { class: "flex flex-col w-full" },
@@ -443,20 +470,34 @@ const AccessControlTUI = () => {
input({ input({
class: "w-full", class: "w-full",
type: "text", type: "text",
value: () => textutils.serialiseJSON(toastConfig?.title) ?? "", value: () => getTempToastConfig().title,
onInput: (value) => { onInput: (value) =>
setTempToastConfig({
...getTempToastConfig(),
title: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
try { try {
const parsed = textutils.unserialiseJSON(value); const parsed = textutils.unserialiseJSON(
if (parsed != undefined && typeof parsed === "object") { getTempToastConfig().title,
const currentConfig = config(); ) as MinecraftTextComponent;
const currentToast = currentConfig[toastType]; if (
typeof parsed === "object" &&
parsed.text !== undefined &&
parsed.color !== undefined
) {
setConfig(toastType, { setConfig(toastType, {
...currentToast, ...currentToastConfig,
title: parsed as MinecraftTextComponent, title: parsed,
}); });
} } else throw new Error("Invalid JSON");
} catch { } catch {
// Invalid JSON, ignore setTempToastConfig({
...getTempToastConfig(),
title: textutils.serialiseJSON(currentToastConfig.title),
});
} }
}, },
}), }),
@@ -465,19 +506,31 @@ const AccessControlTUI = () => {
input({ input({
class: "w-full", class: "w-full",
type: "text", type: "text",
value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "", value: () => getTempToastConfig().msg,
onInput: (value) => { onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), msg: value }),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
try { try {
const parsed = textutils.unserialiseJSON(value); const parsed = textutils.unserialiseJSON(
if (parsed != undefined && typeof parsed === "object") { getTempToastConfig().msg,
const currentConfig = config(); ) as MinecraftTextComponent;
const currentToast = currentConfig[toastType]; if (
typeof parsed === "object" &&
parsed.text !== undefined &&
parsed.color !== undefined
) {
setConfig(toastType, { setConfig(toastType, {
...currentToast, ...currentToastConfig,
msg: parsed as MinecraftTextComponent, msg: parsed,
}); });
} } else throw new Error("Invalid JSON");
} catch { } catch {
setTempToastConfig({
...getTempToastConfig(),
msg: textutils.serialiseJSON(currentToastConfig.msg),
});
// Invalid JSON, ignore // Invalid JSON, ignore
} }
}, },
@@ -488,11 +541,15 @@ const AccessControlTUI = () => {
label({}, "Prefix:"), label({}, "Prefix:"),
input({ input({
type: "text", type: "text",
value: () => toastConfig?.prefix ?? "", value: () => getTempToastConfig().prefix,
onInput: (value) => { onInput: (value) =>
const currentConfig = config(); setTempToastConfig({ ...getTempToastConfig(), prefix: value }),
const currentToast = currentConfig[toastType]; onFocusChanged: () => {
setConfig(toastType, { ...currentToast, prefix: value }); const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
prefix: getTempToastConfig().prefix,
});
}, },
}), }),
), ),
@@ -502,11 +559,15 @@ const AccessControlTUI = () => {
label({}, "Brackets:"), label({}, "Brackets:"),
input({ input({
type: "text", type: "text",
value: () => toastConfig?.brackets ?? "", value: () => getTempToastConfig().brackets,
onInput: (value) => { onInput: (value) =>
const currentConfig = config(); setTempToastConfig({ ...getTempToastConfig(), brackets: value }),
const currentToast = currentConfig[toastType]; onFocusChanged: () => {
setConfig(toastType, { ...currentToast, brackets: value }); const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
brackets: getTempToastConfig().brackets,
});
}, },
}), }),
), ),
@@ -516,11 +577,18 @@ const AccessControlTUI = () => {
label({}, "Bracket Color:"), label({}, "Bracket Color:"),
input({ input({
type: "text", type: "text",
value: () => toastConfig?.bracketColor ?? "", value: () => getTempToastConfig().bracketColor,
onInput: (value) => { onInput: (value) =>
const currentConfig = config(); setTempToastConfig({
const currentToast = currentConfig[toastType]; ...getTempToastConfig(),
setConfig(toastType, { ...currentToast, bracketColor: value }); bracketColor: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
bracketColor: getTempToastConfig().bracketColor,
});
}, },
}), }),
), ),

View File

@@ -3,7 +3,9 @@
* Represents a node in the UI tree * Represents a node in the UI tree
*/ */
import { Accessor } from "./reactivity"; import { ButtonProps, DivProps, InputProps, LabelProps } from "./components";
import { Accessor, Setter } from "./reactivity";
import { ScrollContainerProps } from "./scrollContainer";
/** /**
* Layout properties for flexbox layout * Layout properties for flexbox layout
@@ -34,7 +36,7 @@ export interface StyleProps {
/** /**
* Scroll properties for scroll containers * Scroll properties for scroll containers
*/ */
export interface ScrollProps { export interface ScrollProps extends BaseProps {
/** Current horizontal scroll position */ /** Current horizontal scroll position */
scrollX: number; scrollX: number;
/** Current vertical scroll position */ /** Current vertical scroll position */
@@ -69,6 +71,9 @@ export interface ComputedLayout {
export interface BaseProps { export interface BaseProps {
/** CSS-like class names for layout (e.g., "flex flex-col") */ /** CSS-like class names for layout (e.g., "flex flex-col") */
class?: string; class?: string;
width?: number;
height?: number;
onFocusChanged?: Setter<boolean> | ((value: boolean) => void);
} }
/** /**
@@ -90,6 +95,14 @@ export type UIObjectType =
| "fragment" | "fragment"
| "scroll-container"; | "scroll-container";
export type UIObjectProps =
| DivProps
| LabelProps
| InputProps
| ButtonProps
| ScrollProps
| ScrollContainerProps;
/** /**
* UIObject represents a node in the UI tree * UIObject represents a node in the UI tree
* It can be a component, text, or a control flow element * It can be a component, text, or a control flow element
@@ -99,7 +112,7 @@ export class UIObject {
type: UIObjectType; type: UIObjectType;
/** Props passed to the component */ /** Props passed to the component */
props: Record<string, unknown>; props: UIObjectProps;
/** Children UI objects */ /** Children UI objects */
children: UIObject[]; children: UIObject[];
@@ -136,7 +149,7 @@ export class UIObject {
constructor( constructor(
type: UIObjectType, type: UIObjectType,
props: Record<string, unknown> = {}, props: UIObjectProps = {},
children: UIObject[] = [], children: UIObject[] = [],
) { ) {
this.type = type; this.type = type;
@@ -155,7 +168,7 @@ export class UIObject {
this.extractHandlers(); this.extractHandlers();
// Initialize cursor position for text inputs // Initialize cursor position for text inputs
if (type === "input" && props.type !== "checkbox") { if (type === "input" && (props as InputProps).type !== "checkbox") {
this.cursorPos = 0; this.cursorPos = 0;
} }
@@ -168,9 +181,9 @@ export class UIObject {
maxScrollY: 0, maxScrollY: 0,
contentWidth: 0, contentWidth: 0,
contentHeight: 0, contentHeight: 0,
showScrollbar: props.showScrollbar !== false, showScrollbar: (props as ScrollProps).showScrollbar !== false,
viewportWidth: (props.width as number) ?? 10, viewportWidth: props.width ?? 10,
viewportHeight: (props.height as number) ?? 10, viewportHeight: props.height ?? 10,
}; };
} }
} }
@@ -208,7 +221,7 @@ export class UIObject {
* Parse CSS-like class string into layout and style properties * Parse CSS-like class string into layout and style properties
*/ */
private parseClassNames(): void { private parseClassNames(): void {
const className = this.props.class as string | undefined; const className = this.props.class;
if (className === undefined) return; if (className === undefined) return;
const classes = className.split(" ").filter((c) => c.length > 0); const classes = className.split(" ").filter((c) => c.length > 0);

View File

@@ -7,6 +7,8 @@ import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer"; import { render as renderTree, clearScreen } from "./renderer";
import { CCLog, HOUR } from "../ccLog"; import { CCLog, HOUR } from "../ccLog";
import { setLogger } from "./context"; import { setLogger } from "./context";
import { InputProps } from "./components";
import { Setter } from "./reactivity";
/** /**
* Main application class * Main application class
@@ -145,7 +147,7 @@ export class Application {
if ( if (
this.focusedNode !== undefined && this.focusedNode !== undefined &&
this.focusedNode.type === "input" && this.focusedNode.type === "input" &&
this.focusedNode.props.type !== "checkbox" (this.focusedNode.props as InputProps).type !== "checkbox"
) { ) {
this.needsRender = true; this.needsRender = true;
} }
@@ -213,11 +215,13 @@ export class Application {
this.needsRender = true; this.needsRender = true;
} }
} else if (this.focusedNode.type === "input") { } else if (this.focusedNode.type === "input") {
const type = this.focusedNode.props.type as string | undefined; const type = (this.focusedNode.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") { if (type === "checkbox") {
// Toggle checkbox // Toggle checkbox
const onChangeProp = this.focusedNode.props.onChange; const onChangeProp = (this.focusedNode.props as InputProps).onChange;
const checkedProp = this.focusedNode.props.checked; const checkedProp = (this.focusedNode.props as InputProps).checked;
if ( if (
typeof onChangeProp === "function" && typeof onChangeProp === "function" &&
@@ -234,7 +238,9 @@ export class Application {
this.focusedNode.type === "input" this.focusedNode.type === "input"
) { ) {
// Handle text input key events // Handle text input key events
const type = this.focusedNode.props.type as string | undefined; const type = (this.focusedNode.props as InputProps).type as
| string
| undefined;
if (type !== "checkbox") { if (type !== "checkbox") {
this.handleTextInputKey(key); this.handleTextInputKey(key);
} }
@@ -247,8 +253,8 @@ export class Application {
private handleTextInputKey(key: number): void { private handleTextInputKey(key: number): void {
if (this.focusedNode === undefined) return; if (this.focusedNode === undefined) return;
const valueProp = this.focusedNode.props.value; const valueProp = (this.focusedNode.props as InputProps).value;
const onInputProp = this.focusedNode.props.onInput; const onInputProp = (this.focusedNode.props as InputProps).onInput;
if (typeof valueProp !== "function" || typeof onInputProp !== "function") { if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
return; return;
@@ -292,11 +298,11 @@ export class Application {
*/ */
private handleCharEvent(char: string): void { private handleCharEvent(char: string): void {
if (this.focusedNode !== undefined && this.focusedNode.type === "input") { if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
const type = this.focusedNode.props.type as string | undefined; const type = (this.focusedNode.props as InputProps).type;
if (type !== "checkbox") { if (type !== "checkbox") {
// Insert character at cursor position // Insert character at cursor position
const onInputProp = this.focusedNode.props.onInput; const onInputProp = (this.focusedNode.props as InputProps).onInput;
const valueProp = this.focusedNode.props.value; const valueProp = (this.focusedNode.props as InputProps).value;
if ( if (
typeof onInputProp === "function" && typeof onInputProp === "function" &&
@@ -331,11 +337,26 @@ export class Application {
string.format("handleMouseClick: Found node of type %s.", clicked.type), string.format("handleMouseClick: Found node of type %s.", clicked.type),
); );
// Set focus // Set focus
if (
this.focusedNode !== undefined &&
typeof this.focusedNode.props.onFocusChanged === "function"
) {
const onFocusChanged = this.focusedNode.props
.onFocusChanged as Setter<boolean>;
onFocusChanged(false);
}
this.focusedNode = clicked; this.focusedNode = clicked;
if (typeof clicked.props.onFocusChanged === "function") {
const onFocusChanged = clicked.props.onFocusChanged as Setter<boolean>;
onFocusChanged(true);
}
// Initialize cursor position for text inputs on focus // Initialize cursor position for text inputs on focus
if (clicked.type === "input" && clicked.props.type !== "checkbox") { if (
const valueProp = clicked.props.value; clicked.type === "input" &&
(clicked.props as InputProps).type !== "checkbox"
) {
const valueProp = (clicked.props as InputProps).value;
if (typeof valueProp === "function") { if (typeof valueProp === "function") {
const currentValue = (valueProp as () => string)(); const currentValue = (valueProp as () => string)();
clicked.cursorPos = currentValue.length; clicked.cursorPos = currentValue.length;
@@ -354,10 +375,10 @@ export class Application {
this.needsRender = true; this.needsRender = true;
} }
} else if (clicked.type === "input") { } else if (clicked.type === "input") {
const type = clicked.props.type as string | undefined; const type = (clicked.props as InputProps).type as string | undefined;
if (type === "checkbox") { if (type === "checkbox") {
const onChangeProp = clicked.props.onChange; const onChangeProp = (clicked.props as InputProps).onChange;
const checkedProp = clicked.props.checked; const checkedProp = (clicked.props as InputProps).checked;
if ( if (
typeof onChangeProp === "function" && typeof onChangeProp === "function" &&
@@ -424,6 +445,14 @@ export class Application {
const interactive = this.collectInteractive(this.root); const interactive = this.collectInteractive(this.root);
if (
this.focusedNode !== undefined &&
typeof this.focusedNode.props.onFocusChanged === "function"
) {
const onFocusChanged = this.focusedNode.props
.onFocusChanged as Setter<boolean>;
onFocusChanged(false);
}
if (interactive.length === 0) { if (interactive.length === 0) {
this.focusedNode = undefined; this.focusedNode = undefined;
return; return;

View File

@@ -12,7 +12,7 @@ import { concatSentence } from "../common";
/** /**
* Props for div component * Props for div component
*/ */
export type DivProps = BaseProps & Record<string, unknown>; export type DivProps = BaseProps;
/** /**
* Props for label component * Props for label component
@@ -20,7 +20,7 @@ export type DivProps = BaseProps & Record<string, unknown>;
export type LabelProps = BaseProps & { export type LabelProps = BaseProps & {
/** Whether to automatically wrap long text. Defaults to false. */ /** Whether to automatically wrap long text. Defaults to false. */
wordWrap?: boolean; wordWrap?: boolean;
} & Record<string, unknown>; };
/** /**
* Props for button component * Props for button component
@@ -28,7 +28,7 @@ export type LabelProps = BaseProps & {
export type ButtonProps = BaseProps & { export type ButtonProps = BaseProps & {
/** Click handler */ /** Click handler */
onClick?: () => void; onClick?: () => void;
} & Record<string, unknown>; };
/** /**
* Props for input component * Props for input component
@@ -46,7 +46,7 @@ export type InputProps = BaseProps & {
onChange?: Setter<boolean> | ((checked: boolean) => void); onChange?: Setter<boolean> | ((checked: boolean) => void);
/** Placeholder text */ /** Placeholder text */
placeholder?: string; placeholder?: string;
} & Record<string, unknown>; };
/** /**
* Props for form component * Props for form component
@@ -54,7 +54,7 @@ export type InputProps = BaseProps & {
export type FormProps = BaseProps & { export type FormProps = BaseProps & {
/** Submit handler */ /** Submit handler */
onSubmit?: () => void; onSubmit?: () => void;
} & Record<string, unknown>; };
/** /**
* Generic container component for layout * Generic container component for layout

View File

@@ -3,6 +3,7 @@
* Calculates positions and sizes for UI elements based on flexbox rules * Calculates positions and sizes for UI elements based on flexbox rules
*/ */
import { InputProps } from "./components";
import { UIObject } from "./UIObject"; import { UIObject } from "./UIObject";
/** /**
@@ -109,7 +110,7 @@ function measureNode(
} }
case "input": { case "input": {
const type = node.props.type as string | undefined; const type = (node.props as InputProps).type as string | undefined;
if (type === "checkbox") { if (type === "checkbox") {
const naturalWidth = 3; // [X] or [ ] const naturalWidth = 3; // [X] or [ ]
const naturalHeight = 1; const naturalHeight = 1;
@@ -119,7 +120,7 @@ function measureNode(
}; };
} }
// Text input - use a default width or from props // Text input - use a default width or from props
const defaultWidth = (node.props.width as number | undefined) ?? 20; const defaultWidth = node.props.width ?? 20;
const naturalHeight = 1; const naturalHeight = 1;
return { return {
width: measuredWidth ?? defaultWidth, width: measuredWidth ?? defaultWidth,

View File

@@ -3,7 +3,7 @@
*/ */
import { UIObject } from "./UIObject"; import { UIObject } from "./UIObject";
import { Accessor } from "./reactivity"; import { InputProps } from "./components";
import { isScrollContainer } from "./scrollContainer"; import { isScrollContainer } from "./scrollContainer";
/** /**
@@ -189,14 +189,14 @@ function drawNode(
} }
case "input": { case "input": {
const type = node.props.type as string | undefined; const type = (node.props as InputProps).type as string | undefined;
if (type === "checkbox") { if (type === "checkbox") {
// Draw checkbox // Draw checkbox
let isChecked = false; let isChecked = false;
const checkedProp = node.props.checked; const checkedProp = (node.props as InputProps).checked;
if (typeof checkedProp === "function") { if (typeof checkedProp === "function") {
isChecked = (checkedProp as Accessor<boolean>)(); isChecked = checkedProp();
} }
if (focused) { if (focused) {
@@ -212,12 +212,11 @@ function drawNode(
} else { } else {
// Draw text input // Draw text input
let displayText = ""; let displayText = "";
const valueProp = node.props.value; const valueProp = (node.props as InputProps).value;
if (typeof valueProp === "function") { if (typeof valueProp === "function") {
displayText = (valueProp as Accessor<string>)(); displayText = valueProp();
} }
const placeholder = (node.props as InputProps).placeholder;
const placeholder = node.props.placeholder as string | undefined;
const cursorPos = node.cursorPos ?? 0; const cursorPos = node.cursorPos ?? 0;
let currentTextColor = textColor; let currentTextColor = textColor;
let showPlaceholder = false; let showPlaceholder = false;

View File

@@ -31,7 +31,10 @@ const Counter = () => {
div( div(
{ class: "flex flex-row" }, { class: "flex flex-row" },
button({ onClick: () => setCount(count() - 1), class: "text-red" }, "-"), button({ onClick: () => setCount(count() - 1), class: "text-red" }, "-"),
button({ onClick: () => setCount(count() + 1), class: "text-green" }, "+"), button(
{ onClick: () => setCount(count() + 1), class: "text-green" },
"+",
),
), ),
); );
}; };
@@ -82,9 +85,9 @@ const TodosApp = () => {
}), }),
label( label(
{ {
class: todo.completed ? "ml-1 text-gray" : "ml-1 text-white" class: todo.completed ? "ml-1 text-gray" : "ml-1 text-white",
}, },
() => todo.title () => todo.title,
), ),
button( button(
{ {
@@ -318,13 +321,13 @@ const App = () => {
when: () => tabIndex() === 3, when: () => tabIndex() === 3,
fallback: MultiScrollExample(), fallback: MultiScrollExample(),
}, },
StaticScrollExample() StaticScrollExample(),
) ),
}, },
SimpleScrollExample() SimpleScrollExample(),
) ),
}, },
TodosApp() TodosApp(),
), ),
}, },
Counter(), Counter(),