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
*/
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 = () => {
return div(
{ class: "flex flex-col" },
@@ -251,10 +263,12 @@ const AccessControlTUI = () => {
label({}, "Detect Interval (ms):"),
input({
type: "text",
value: () => config().detectInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
value: () => getDetectInterval(),
onInput: (value) => setDetectInterval(value),
onFocusChanged: () => {
const num = validateNumber(getDetectInterval());
if (num !== null) setConfig("detectInterval", num);
else setDetectInterval(config().detectInterval.toString());
},
}),
),
@@ -263,10 +277,12 @@ const AccessControlTUI = () => {
label({}, "Watch Interval (ms):"),
input({
type: "text",
value: () => config().watchInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
value: () => getWatchInterval(),
onInput: (value) => setWatchInterval(value),
onFocusChanged: () => {
const num = validateNumber(getWatchInterval());
if (num !== null) setConfig("watchInterval", num);
else setWatchInterval(config().watchInterval.toString());
},
}),
),
@@ -275,10 +291,12 @@ const AccessControlTUI = () => {
label({}, "Notice Times:"),
input({
type: "text",
value: () => config().noticeTimes?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
value: () => getNoticeTimes(),
onInput: (value) => setNoticeTimes(value),
onFocusChanged: () => {
const num = validateNumber(getNoticeTimes());
if (num !== null) setConfig("noticeTimes", num);
else setNoticeTimes(config().noticeTimes.toString());
},
}),
),
@@ -287,10 +305,12 @@ const AccessControlTUI = () => {
label({}, "Detect Range:"),
input({
type: "text",
value: () => config().detectRange?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
value: () => getDetectRange(),
onInput: (value) => setDetectRange(value),
onFocusChanged: () => {
const num = validateNumber(getDetectRange());
if (num !== null) setConfig("detectRange", num);
else setDetectRange(config().detectRange.toString());
},
}),
),
@@ -436,6 +456,13 @@ const AccessControlTUI = () => {
) => {
return () => {
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(
{ class: "flex flex-col w-full" },
@@ -443,20 +470,34 @@ const AccessControlTUI = () => {
input({
class: "w-full",
type: "text",
value: () => textutils.serialiseJSON(toastConfig?.title) ?? "",
onInput: (value) => {
value: () => getTempToastConfig().title,
onInput: (value) =>
setTempToastConfig({
...getTempToastConfig(),
title: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
try {
const parsed = textutils.unserialiseJSON(value);
if (parsed != undefined && typeof parsed === "object") {
const currentConfig = config();
const currentToast = currentConfig[toastType];
const parsed = textutils.unserialiseJSON(
getTempToastConfig().title,
) as MinecraftTextComponent;
if (
typeof parsed === "object" &&
parsed.text !== undefined &&
parsed.color !== undefined
) {
setConfig(toastType, {
...currentToast,
title: parsed as MinecraftTextComponent,
...currentToastConfig,
title: parsed,
});
}
} else throw new Error("Invalid JSON");
} catch {
// Invalid JSON, ignore
setTempToastConfig({
...getTempToastConfig(),
title: textutils.serialiseJSON(currentToastConfig.title),
});
}
},
}),
@@ -465,19 +506,31 @@ const AccessControlTUI = () => {
input({
class: "w-full",
type: "text",
value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "",
onInput: (value) => {
value: () => getTempToastConfig().msg,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), msg: value }),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
try {
const parsed = textutils.unserialiseJSON(value);
if (parsed != undefined && typeof parsed === "object") {
const currentConfig = config();
const currentToast = currentConfig[toastType];
const parsed = textutils.unserialiseJSON(
getTempToastConfig().msg,
) as MinecraftTextComponent;
if (
typeof parsed === "object" &&
parsed.text !== undefined &&
parsed.color !== undefined
) {
setConfig(toastType, {
...currentToast,
msg: parsed as MinecraftTextComponent,
...currentToastConfig,
msg: parsed,
});
}
} else throw new Error("Invalid JSON");
} catch {
setTempToastConfig({
...getTempToastConfig(),
msg: textutils.serialiseJSON(currentToastConfig.msg),
});
// Invalid JSON, ignore
}
},
@@ -488,11 +541,15 @@ const AccessControlTUI = () => {
label({}, "Prefix:"),
input({
type: "text",
value: () => toastConfig?.prefix ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, prefix: value });
value: () => getTempToastConfig().prefix,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), prefix: value }),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
prefix: getTempToastConfig().prefix,
});
},
}),
),
@@ -502,11 +559,15 @@ const AccessControlTUI = () => {
label({}, "Brackets:"),
input({
type: "text",
value: () => toastConfig?.brackets ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, brackets: value });
value: () => getTempToastConfig().brackets,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), brackets: value }),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
brackets: getTempToastConfig().brackets,
});
},
}),
),
@@ -516,11 +577,18 @@ const AccessControlTUI = () => {
label({}, "Bracket Color:"),
input({
type: "text",
value: () => toastConfig?.bracketColor ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, bracketColor: value });
value: () => getTempToastConfig().bracketColor,
onInput: (value) =>
setTempToastConfig({
...getTempToastConfig(),
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
*/
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
@@ -34,7 +36,7 @@ export interface StyleProps {
/**
* Scroll properties for scroll containers
*/
export interface ScrollProps {
export interface ScrollProps extends BaseProps {
/** Current horizontal scroll position */
scrollX: number;
/** Current vertical scroll position */
@@ -69,6 +71,9 @@ export interface ComputedLayout {
export interface BaseProps {
/** CSS-like class names for layout (e.g., "flex flex-col") */
class?: string;
width?: number;
height?: number;
onFocusChanged?: Setter<boolean> | ((value: boolean) => void);
}
/**
@@ -90,6 +95,14 @@ export type UIObjectType =
| "fragment"
| "scroll-container";
export type UIObjectProps =
| DivProps
| LabelProps
| InputProps
| ButtonProps
| ScrollProps
| ScrollContainerProps;
/**
* UIObject represents a node in the UI tree
* It can be a component, text, or a control flow element
@@ -99,7 +112,7 @@ export class UIObject {
type: UIObjectType;
/** Props passed to the component */
props: Record<string, unknown>;
props: UIObjectProps;
/** Children UI objects */
children: UIObject[];
@@ -136,7 +149,7 @@ export class UIObject {
constructor(
type: UIObjectType,
props: Record<string, unknown> = {},
props: UIObjectProps = {},
children: UIObject[] = [],
) {
this.type = type;
@@ -155,7 +168,7 @@ export class UIObject {
this.extractHandlers();
// Initialize cursor position for text inputs
if (type === "input" && props.type !== "checkbox") {
if (type === "input" && (props as InputProps).type !== "checkbox") {
this.cursorPos = 0;
}
@@ -168,9 +181,9 @@ export class UIObject {
maxScrollY: 0,
contentWidth: 0,
contentHeight: 0,
showScrollbar: props.showScrollbar !== false,
viewportWidth: (props.width as number) ?? 10,
viewportHeight: (props.height as number) ?? 10,
showScrollbar: (props as ScrollProps).showScrollbar !== false,
viewportWidth: props.width ?? 10,
viewportHeight: props.height ?? 10,
};
}
}
@@ -208,7 +221,7 @@ export class UIObject {
* Parse CSS-like class string into layout and style properties
*/
private parseClassNames(): void {
const className = this.props.class as string | undefined;
const className = this.props.class;
if (className === undefined) return;
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 { CCLog, HOUR } from "../ccLog";
import { setLogger } from "./context";
import { InputProps } from "./components";
import { Setter } from "./reactivity";
/**
* Main application class
@@ -145,7 +147,7 @@ export class Application {
if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input" &&
this.focusedNode.props.type !== "checkbox"
(this.focusedNode.props as InputProps).type !== "checkbox"
) {
this.needsRender = true;
}
@@ -213,11 +215,13 @@ export class Application {
this.needsRender = true;
}
} 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") {
// Toggle checkbox
const onChangeProp = this.focusedNode.props.onChange;
const checkedProp = this.focusedNode.props.checked;
const onChangeProp = (this.focusedNode.props as InputProps).onChange;
const checkedProp = (this.focusedNode.props as InputProps).checked;
if (
typeof onChangeProp === "function" &&
@@ -234,7 +238,9 @@ export class Application {
this.focusedNode.type === "input"
) {
// 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") {
this.handleTextInputKey(key);
}
@@ -247,8 +253,8 @@ export class Application {
private handleTextInputKey(key: number): void {
if (this.focusedNode === undefined) return;
const valueProp = this.focusedNode.props.value;
const onInputProp = this.focusedNode.props.onInput;
const valueProp = (this.focusedNode.props as InputProps).value;
const onInputProp = (this.focusedNode.props as InputProps).onInput;
if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
return;
@@ -292,11 +298,11 @@ export class Application {
*/
private handleCharEvent(char: string): void {
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") {
// Insert character at cursor position
const onInputProp = this.focusedNode.props.onInput;
const valueProp = this.focusedNode.props.value;
const onInputProp = (this.focusedNode.props as InputProps).onInput;
const valueProp = (this.focusedNode.props as InputProps).value;
if (
typeof onInputProp === "function" &&
@@ -331,11 +337,26 @@ export class Application {
string.format("handleMouseClick: Found node of type %s.", clicked.type),
);
// 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;
if (typeof clicked.props.onFocusChanged === "function") {
const onFocusChanged = clicked.props.onFocusChanged as Setter<boolean>;
onFocusChanged(true);
}
// Initialize cursor position for text inputs on focus
if (clicked.type === "input" && clicked.props.type !== "checkbox") {
const valueProp = clicked.props.value;
if (
clicked.type === "input" &&
(clicked.props as InputProps).type !== "checkbox"
) {
const valueProp = (clicked.props as InputProps).value;
if (typeof valueProp === "function") {
const currentValue = (valueProp as () => string)();
clicked.cursorPos = currentValue.length;
@@ -354,10 +375,10 @@ export class Application {
this.needsRender = true;
}
} 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") {
const onChangeProp = clicked.props.onChange;
const checkedProp = clicked.props.checked;
const onChangeProp = (clicked.props as InputProps).onChange;
const checkedProp = (clicked.props as InputProps).checked;
if (
typeof onChangeProp === "function" &&
@@ -424,6 +445,14 @@ export class Application {
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) {
this.focusedNode = undefined;
return;

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,10 @@ const Counter = () => {
div(
{ class: "flex flex-row" },
button({ onClick: () => setCount(count() - 1), class: "text-red" }, "-"),
button({ onClick: () => setCount(count() + 1), class: "text-green" }, "+"),
button(
{ onClick: () => setCount(count() + 1), class: "text-green" },
"+",
),
),
);
};
@@ -81,10 +84,10 @@ const TodosApp = () => {
},
}),
label(
{
class: todo.completed ? "ml-1 text-gray" : "ml-1 text-white"
},
() => todo.title
{
class: todo.completed ? "ml-1 text-gray" : "ml-1 text-white",
},
() => todo.title,
),
button(
{
@@ -308,7 +311,7 @@ const App = () => {
{
when: () => tabIndex() === 0,
fallback: Show(
{
{
when: () => tabIndex() === 1,
fallback: Show(
{
@@ -318,13 +321,13 @@ const App = () => {
when: () => tabIndex() === 3,
fallback: MultiScrollExample(),
},
StaticScrollExample()
)
StaticScrollExample(),
),
},
SimpleScrollExample()
)
},
TodosApp()
SimpleScrollExample(),
),
},
TodosApp(),
),
},
Counter(),
@@ -347,4 +350,4 @@ try {
print("Error running application:");
printError(e);
}
}
}