Compare commits

...

7 Commits

Author SHA1 Message Date
SikongJueluo
d41117cecc adjust input render 2025-10-14 19:41:56 +08:00
SikongJueluo
5a3a404bff merge readme 2025-10-14 19:27:34 +08:00
SikongJueluo
039ff7ff8d fix: long string only display the beginning 2025-10-14 19:26:22 +08:00
SikongJueluo
d02874da73 fix: save failed 2025-10-14 12:59:52 +08:00
SikongJueluo
7f2a51c5aa add word wrap for label 2025-10-14 12:41:32 +08:00
SikongJueluo
0ccafa2e2e try to wordwrap, but failed 2025-10-12 22:31:35 +08:00
SikongJueluo
bd8e1f9b8d finish basic tui for accesscontrol, add scroll for tui 2025-10-12 20:23:08 +08:00
16 changed files with 1656 additions and 418 deletions

View File

@@ -1,7 +1,4 @@
import { CCLog } from "@/lib/ccLog";
import * as dkjson from "@sikongjueluo/dkjson-types";
let log: CCLog | undefined;
// import * as dkjson from "@sikongjueluo/dkjson-types";
interface ToastConfig {
title: MinecraftTextComponent;
@@ -104,10 +101,6 @@ const defaultConfig: AccessConfig = {
},
};
function setLog(newLog: CCLog) {
log = newLog;
}
function loadConfig(filepath: string): AccessConfig {
const [fp] = io.open(filepath, "r");
if (fp == undefined) {
@@ -121,26 +114,29 @@ function loadConfig(filepath: string): AccessConfig {
return defaultConfig;
}
const [config, pos, err] = dkjson.decode(configJson);
if (config == undefined) {
log?.warn(
`Config decode failed at ${pos}, use default instead. Error :${err}`,
);
return defaultConfig;
}
// const [config, pos, err] = dkjson.decode(configJson);
// if (config == undefined) {
// log?.warn(
// `Config decode failed at ${pos}, use default instead. Error :${err}`,
// );
// return defaultConfig;
// }
// Not use external lib
// const config = textutils.unserialiseJSON(configJson, {
// parse_empty_array: true,
// });
const config = textutils.unserialiseJSON(configJson, {
parse_empty_array: true,
});
return config as AccessConfig;
}
function saveConfig(config: AccessConfig, filepath: string) {
const configJson = dkjson.encode(config, { indent: true }) as string;
// const configJson = dkjson.encode(config, { indent: true }) as string;
// Not use external lib
// const configJson = textutils.serializeJSON(config, { unicode_strings: true });
const configJson = textutils.serializeJSON(config, {
allow_repetitions: true,
unicode_strings: true,
});
if (configJson == undefined) {
print("Failed to save config");
}
@@ -155,11 +151,4 @@ function saveConfig(config: AccessConfig, filepath: string) {
fp.close();
}
export {
ToastConfig,
UserGroupConfig,
AccessConfig,
loadConfig,
saveConfig,
setLog,
};
export { ToastConfig, UserGroupConfig, AccessConfig, loadConfig, saveConfig };

View File

@@ -1,5 +1,5 @@
import { CCLog, DAY } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig, setLog } from "./config";
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
import { createAccessControlCLI } from "./cli";
import { launchAccessControlTUI } from "./tui";
import * as peripheralManager from "../lib/PeripheralManager";
@@ -9,7 +9,6 @@ const args = [...$vararg];
// Init Log
const log = new CCLog("accesscontrol.log", true, DAY);
setLog(log);
// Load Config
const configFilepath = `${shell.dir()}/access.config.json`;
@@ -223,6 +222,7 @@ function main(args: string[]) {
return;
} else if (args[0] == "config") {
log.info("Launching Access Control TUI...");
log.setInTerminal(false);
try {
launchAccessControlTUI();
} catch (error) {

View File

@@ -3,6 +3,7 @@
* A text-based user interface for configuring access control settings
*/
import { context } from "@/lib/ccTUI/context";
import {
createSignal,
createStore,
@@ -14,6 +15,9 @@ import {
render,
Show,
For,
Switch,
Match,
ScrollContainer,
} from "../lib/ccTUI";
import {
AccessConfig,
@@ -43,8 +47,11 @@ interface ErrorState {
* Main TUI Application Component
*/
const AccessControlTUI = () => {
// Load configuration on initialization
const configFilepath = `${shell.dir()}/access.config.json`;
const loadedConfig = loadConfig(configFilepath);
// Configuration state
const [config, setConfig] = createStore<AccessConfig>({} as AccessConfig);
const [config, setConfig] = createStore<AccessConfig>(loadedConfig);
// UI state
const [currentTab, setCurrentTab] = createSignal<TabIndex>(TABS.BASIC);
@@ -57,19 +64,8 @@ const AccessControlTUI = () => {
// New user input for group management
const [newUserName, setNewUserName] = createSignal("");
// Load configuration on initialization
const configFilepath = `${shell.dir()}/access.config.json`;
const loadedConfig = loadConfig(configFilepath);
setConfig(() => loadedConfig);
// Tab navigation functions
const tabNames = [
"Basic",
"Groups",
"Welcome Toast",
"Warn Toast",
"Notice Toast",
];
const tabNames = ["Basic", "Groups", "Welcome", "Warn", "Notice"];
const showError = (message: string) => {
setErrorState("show", true);
@@ -168,6 +164,9 @@ const AccessControlTUI = () => {
}
// Save configuration
context.logger?.debug(
`Configuration : ${textutils.serialise(currentConfig, { allow_repetitions: true })}`,
);
saveConfig(currentConfig, configFilepath);
showError("Configuration saved successfully!");
} catch (error) {
@@ -247,52 +246,63 @@ const AccessControlTUI = () => {
const BasicTab = () => {
return div(
{ class: "flex flex-col" },
label({}, "Detect Interval (ms):"),
input({
type: "text",
value: () => config().detectInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("detectInterval", num);
},
}),
label({}, "Watch Interval (ms):"),
input({
type: "text",
value: () => config().watchInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("watchInterval", num);
},
}),
label({}, "Notice Times:"),
input({
type: "text",
value: () => config().noticeTimes?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("noticeTimes", num);
},
}),
label({}, "Detect Range:"),
input({
type: "text",
value: () => config().detectRange?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("detectRange", num);
},
}),
label({}, "Is Warn:"),
input({
type: "checkbox",
checked: () => config().isWarn ?? false,
onChange: (checked) => setConfig("isWarn", checked),
}),
div(
{ class: "flex flex-row" },
label({}, "Detect Interval (ms):"),
input({
type: "text",
value: () => config().detectInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("detectInterval", num);
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Watch Interval (ms):"),
input({
type: "text",
value: () => config().watchInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("watchInterval", num);
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Notice Times:"),
input({
type: "text",
value: () => config().noticeTimes?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("noticeTimes", num);
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Detect Range:"),
input({
type: "text",
value: () => config().detectRange?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("detectRange", num);
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Is Warn:"),
input({
type: "checkbox",
checked: () => config().isWarn ?? false,
onChange: (checked) => setConfig("isWarn", checked),
}),
),
);
};
@@ -301,7 +311,6 @@ const AccessControlTUI = () => {
*/
const GroupsTab = () => {
const groups = getAllGroups();
const selectedGroup = getSelectedGroup();
return div(
{ class: "flex flex-row" },
@@ -309,7 +318,7 @@ const AccessControlTUI = () => {
div(
{ class: "flex flex-col" },
label({}, "Groups:"),
For({ each: () => groups }, (group, index) =>
For({ each: () => groups, class: "flex flex-col" }, (group, index) =>
button(
{
class:
@@ -324,59 +333,64 @@ const AccessControlTUI = () => {
// Right side - Group details
div(
{ class: "flex flex-col ml-2" },
label({}, () => `Group: ${selectedGroup.groupName}`),
label({}, () => `Group: ${getSelectedGroup().groupName}`),
label({}, "Is Allowed:"),
input({
type: "checkbox",
checked: () => selectedGroup.isAllowed,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isAllowed: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isAllowed: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
label({}, "Is Notice:"),
input({
type: "checkbox",
checked: () => selectedGroup.isNotice,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isNotice: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isNotice: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
div(
{ class: "flex flex-row" },
label({}, "Is Allowed:"),
input({
type: "checkbox",
checked: () => getSelectedGroup().isAllowed,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isAllowed: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isAllowed: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Is Notice:"),
input({
type: "checkbox",
checked: () => getSelectedGroup().isNotice,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isNotice: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isNotice: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
),
label({}, "Group Users:"),
// User management
@@ -392,18 +406,23 @@ const AccessControlTUI = () => {
),
// Users list
For({ each: () => selectedGroup.groupUsers ?? [] }, (user) =>
div(
{ class: "flex flex-row items-center" },
label({}, user),
button(
{
class: "ml-1 bg-red text-white",
onClick: () => removeUser(user),
},
"Remove",
For(
{
class: "flex flex-col",
each: () => getSelectedGroup().groupUsers ?? [],
},
(user) =>
div(
{ class: "flex flex-row items-center" },
label({}, user),
button(
{
class: "ml-1 bg-red text-white",
onClick: () => removeUser(user),
},
"X",
),
),
),
),
),
);
@@ -419,9 +438,10 @@ const AccessControlTUI = () => {
const toastConfig = config()[toastType];
return div(
{ class: "flex flex-col" },
{ class: "flex flex-col w-full" },
label({}, "Title (JSON):"),
input({
class: "w-full",
type: "text",
value: () => textutils.serialiseJSON(toastConfig?.title) ?? "",
onInput: (value) => {
@@ -443,6 +463,7 @@ const AccessControlTUI = () => {
label({}, "Message (JSON):"),
input({
class: "w-full",
type: "text",
value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "",
onInput: (value) => {
@@ -462,38 +483,47 @@ const AccessControlTUI = () => {
},
}),
label({}, "Prefix:"),
input({
type: "text",
value: () => toastConfig?.prefix ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, prefix: value });
},
}),
div(
{ class: "flex flex-row" },
label({}, "Prefix:"),
input({
type: "text",
value: () => toastConfig?.prefix ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, prefix: value });
},
}),
),
label({}, "Brackets:"),
input({
type: "text",
value: () => toastConfig?.brackets ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, brackets: value });
},
}),
div(
{ class: "flex flex-row" },
label({}, "Brackets:"),
input({
type: "text",
value: () => toastConfig?.brackets ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, brackets: value });
},
}),
),
label({}, "Bracket Color:"),
input({
type: "text",
value: () => toastConfig?.bracketColor ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, bracketColor: value });
},
}),
div(
{ class: "flex flex-row" },
label({}, "Bracket Color:"),
input({
type: "text",
value: () => toastConfig?.bracketColor ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, bracketColor: value });
},
}),
),
);
};
};
@@ -510,20 +540,17 @@ const AccessControlTUI = () => {
return Show(
{ when: () => errorState().show },
div(
{
class:
"fixed top-1/4 left-1/4 right-1/4 bottom-1/4 bg-red text-white border",
},
div(
{ class: "flex flex-col p-2" },
label({}, () => errorState().message),
button(
{
class: "mt-2 bg-white text-black",
onClick: hideError,
},
"OK",
),
{ class: "flex flex-col" },
label(
{ class: "w-50 text-white bg-red", wordWrap: true },
() => errorState().message,
),
button(
{
class: "bg-white text-black",
onClick: hideError,
},
"OK",
),
),
);
@@ -533,13 +560,20 @@ const AccessControlTUI = () => {
* Tab Content Renderer
*/
const TabContent = () => {
const tab = currentTab();
if (tab === TABS.BASIC) return BasicTab();
if (tab === TABS.GROUPS) return GroupsTab();
if (tab === TABS.WELCOME_TOAST) return WelcomeToastTab();
if (tab === TABS.WARN_TOAST) return WarnToastTab();
if (tab === TABS.NOTICE_TOAST) return NoticeToastTab();
return BasicTab(); // fallback
return Switch(
{ fallback: BasicTab() },
Match({ when: () => currentTab() === TABS.BASIC }, BasicTab()),
Match({ when: () => currentTab() === TABS.GROUPS }, GroupsTab()),
Match(
{ when: () => currentTab() === TABS.WELCOME_TOAST },
WelcomeToastTab(),
),
Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()),
Match(
{ when: () => currentTab() === TABS.NOTICE_TOAST },
NoticeToastTab(),
),
);
};
/**
@@ -548,7 +582,10 @@ const AccessControlTUI = () => {
return div(
{ class: "flex flex-col h-full" },
// Header
h1("Access Control Configuration"),
div(
{ class: "flex flex-row justify-center" },
h1("Access Control Configuration"),
),
// Tab bar
div(
@@ -565,27 +602,36 @@ const AccessControlTUI = () => {
),
// Content area
div({ class: "flex-1 p-2" }, TabContent()),
Show(
{ when: () => !errorState().show },
div(
{ class: "flex flex-col" },
ScrollContainer(
{ class: "flex-1 p-2", width: 50, showScrollbar: true },
TabContent(),
),
// Action buttons
div(
{ class: "flex flex-row justify-center p-2" },
button(
{
class: "bg-green text-white mr-2",
onClick: handleSave,
},
"Save",
),
button(
{
class: "bg-gray text-white",
onClick: () => {
// Close TUI - this will be handled by the application framework
error("TUI_CLOSE");
},
},
"Close",
// Action buttons
div(
{ class: "flex flex-row justify-center p-2" },
button(
{
class: "bg-green text-white mr-2",
onClick: handleSave,
},
"Save",
),
button(
{
class: "bg-gray text-white",
onClick: () => {
// Close TUI - this will be handled by the application framework
error("TUI_CLOSE");
},
},
"Close",
),
),
),
),

View File

@@ -49,31 +49,30 @@ export class CCLog {
* For SECOND interval: YYYY-MM-DD-HH-MM-SS
*/
private getTimePeriodString(time: number): string {
// Calculate which time period this timestamp falls into
const periodStart = Math.floor(time / this.interval) * this.interval;
const periodDate = os.date("*t", periodStart);
const d = os.date("*t", periodStart);
if (this.interval >= DAY) {
return `${periodDate.year}-${String(periodDate.month).padStart(2, "0")}-${String(periodDate.day).padStart(2, "0")}`;
} else {
return `[${periodDate.year}-${String(periodDate.month).padStart(2, "0")}-${String(periodDate.day).padStart(2, "0")}] - [${String(periodDate.hour).padStart(2, "0")}-${String(periodDate.min).padStart(2, "0")}-${String(periodDate.sec).padStart(2, "0")}]`;
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}`;
} else if (this.interval >= HOUR) {
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}`;
} else if (this.interval >= MINUTE) {
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}-${string.format("%02d", d.min)}`;
}
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}-${string.format("%02d", d.min)}-${string.format("%02d", d.sec)}`;
}
private generateFilePath(baseFilename: string, timePeriod: string): string {
// Extract file extension if present
const fileNameSubStrings = baseFilename.split(".");
let filenameWithoutExt: string;
let extension = "";
const scriptDir = shell.dir() ?? "";
if (fileNameSubStrings.length > 1) {
filenameWithoutExt = fileNameSubStrings[0];
extension = fileNameSubStrings[1];
} else {
filenameWithoutExt = baseFilename;
}
const [filenameWithoutExt, extension] = baseFilename.includes(".")
? baseFilename.split(".")
: [baseFilename, "log"];
return `${shell.dir()}/${filenameWithoutExt}[${timePeriod}].${extension}`;
return fs.combine(
scriptDir,
`${filenameWithoutExt}_${timePeriod}.${extension}`,
);
}
private checkAndRotateLogFile() {
@@ -154,6 +153,10 @@ export class CCLog {
this.writeLine(this.getFormatMsg(msg, LogLevel.Error), colors.red);
}
public setInTerminal(value: boolean) {
this.inTerm = value;
}
public close() {
if (this.fp !== undefined) {
this.fp.close();

View File

@@ -25,6 +25,32 @@ export interface StyleProps {
textColor?: number;
/** Background color */
backgroundColor?: number;
/** Width - can be a number (fixed), "full" (100% of parent), or "screen" (terminal width) */
width?: number | "full" | "screen";
/** Height - can be a number (fixed), "full" (100% of parent), or "screen" (terminal height) */
height?: number | "full" | "screen";
}
/**
* Scroll properties for scroll containers
*/
export interface ScrollProps {
/** Current horizontal scroll position */
scrollX: number;
/** Current vertical scroll position */
scrollY: number;
/** Maximum horizontal scroll (content width - viewport width) */
maxScrollX: number;
/** Maximum vertical scroll (content height - viewport height) */
maxScrollY: number;
/** Content dimensions */
contentWidth: number;
contentHeight: number;
/** Whether to show scrollbars */
showScrollbar?: boolean;
/** Viewport dimensions (visible area) */
viewportWidth: number;
viewportHeight: number;
}
/**
@@ -48,18 +74,21 @@ export interface BaseProps {
/**
* UIObject node type
*/
export type UIObjectType =
| "div"
| "label"
| "button"
| "input"
export type UIObjectType =
| "div"
| "label"
| "button"
| "input"
| "form"
| "h1"
| "h2"
| "h3"
| "for"
| "show"
| "fragment";
| "switch"
| "match"
| "fragment"
| "scroll-container";
/**
* UIObject represents a node in the UI tree
@@ -68,44 +97,47 @@ export type UIObjectType =
export class UIObject {
/** Type of the UI object */
type: UIObjectType;
/** Props passed to the component */
props: Record<string, unknown>;
/** Children UI objects */
children: UIObject[];
/** Parent UI object */
parent?: UIObject;
/** Computed layout after flexbox calculation */
layout?: ComputedLayout;
/** Layout properties parsed from class string */
layoutProps: LayoutProps;
/** Style properties parsed from class string */
styleProps: StyleProps;
/** Whether this component is currently mounted */
mounted: boolean;
/** Cleanup functions to call when unmounting */
cleanupFns: (() => void)[];
/** For text nodes - the text content (can be reactive) */
textContent?: string | Accessor<string>;
/** Event handlers */
handlers: Record<string, ((...args: unknown[]) => void) | undefined>;
/** For input text components - cursor position */
cursorPos?: number;
/** For scroll containers - scroll state */
scrollProps?: ScrollProps;
constructor(
type: UIObjectType,
props: Record<string, unknown> = {},
children: UIObject[] = []
children: UIObject[] = [],
) {
this.type = type;
this.props = props;
@@ -115,45 +147,60 @@ export class UIObject {
this.mounted = false;
this.cleanupFns = [];
this.handlers = {};
// Parse layout and styles from class prop
this.parseClassNames();
// Extract event handlers
this.extractHandlers();
// Initialize cursor position for text inputs
if (type === "input" && props.type !== "checkbox") {
this.cursorPos = 0;
}
// Initialize scroll properties for scroll containers
if (type === "scroll-container") {
this.scrollProps = {
scrollX: 0,
scrollY: 0,
maxScrollX: 0,
maxScrollY: 0,
contentWidth: 0,
contentHeight: 0,
showScrollbar: props.showScrollbar !== false,
viewportWidth: (props.width as number) ?? 10,
viewportHeight: (props.height as number) ?? 10,
};
}
}
/**
* Map color name to ComputerCraft colors API value
*
*
* @param colorName - The color name from class (e.g., "white", "red")
* @returns The color value from colors API, or undefined if invalid
*/
private parseColor(colorName: string): number | undefined {
const colorMap: Record<string, number> = {
"white": colors.white,
"orange": colors.orange,
"magenta": colors.magenta,
"lightBlue": colors.lightBlue,
"yellow": colors.yellow,
"lime": colors.lime,
"pink": colors.pink,
"gray": colors.gray,
"lightGray": colors.lightGray,
"cyan": colors.cyan,
"purple": colors.purple,
"blue": colors.blue,
"brown": colors.brown,
"green": colors.green,
"red": colors.red,
"black": colors.black,
white: colors.white,
orange: colors.orange,
magenta: colors.magenta,
lightBlue: colors.lightBlue,
yellow: colors.yellow,
lime: colors.lime,
pink: colors.pink,
gray: colors.gray,
lightGray: colors.lightGray,
cyan: colors.cyan,
purple: colors.purple,
blue: colors.blue,
brown: colors.brown,
green: colors.green,
red: colors.red,
black: colors.black,
};
return colorMap[colorName];
}
@@ -164,8 +211,8 @@ export class UIObject {
const className = this.props.class as string | undefined;
if (className === undefined) return;
const classes = className.split(" ").filter(c => c.length > 0);
const classes = className.split(" ").filter((c) => c.length > 0);
for (const cls of classes) {
// Flex direction
if (cls === "flex-row") {
@@ -173,7 +220,7 @@ export class UIObject {
} else if (cls === "flex-col") {
this.layoutProps.flexDirection = "column";
}
// Justify content
else if (cls === "justify-start") {
this.layoutProps.justifyContent = "start";
@@ -184,7 +231,7 @@ export class UIObject {
} else if (cls === "justify-between") {
this.layoutProps.justifyContent = "between";
}
// Align items
else if (cls === "items-start") {
this.layoutProps.alignItems = "start";
@@ -193,7 +240,7 @@ export class UIObject {
} else if (cls === "items-end") {
this.layoutProps.alignItems = "end";
}
// Text color (text-<color>)
else if (cls.startsWith("text-")) {
const colorName = cls.substring(5); // Remove "text-" prefix
@@ -202,7 +249,7 @@ export class UIObject {
this.styleProps.textColor = color;
}
}
// Background color (bg-<color>)
else if (cls.startsWith("bg-")) {
const colorName = cls.substring(3); // Remove "bg-" prefix
@@ -211,8 +258,38 @@ export class UIObject {
this.styleProps.backgroundColor = color;
}
}
// Width sizing (w-<size>)
else if (cls.startsWith("w-")) {
const sizeValue = cls.substring(2); // Remove "w-" prefix
if (sizeValue === "full") {
this.styleProps.width = "full";
} else if (sizeValue === "screen") {
this.styleProps.width = "screen";
} else {
const numValue = tonumber(sizeValue);
if (numValue !== undefined) {
this.styleProps.width = numValue;
}
}
}
// Height sizing (h-<size>)
else if (cls.startsWith("h-")) {
const sizeValue = cls.substring(2); // Remove "h-" prefix
if (sizeValue === "full") {
this.styleProps.height = "full";
} else if (sizeValue === "screen") {
this.styleProps.height = "screen";
} else {
const numValue = tonumber(sizeValue);
if (numValue !== undefined) {
this.styleProps.height = numValue;
}
}
}
}
// Set defaults
if (this.type === "div") {
this.layoutProps.flexDirection ??= "row";
@@ -226,7 +303,11 @@ export class UIObject {
*/
private extractHandlers(): void {
for (const [key, value] of pairs(this.props)) {
if (typeof key === "string" && key.startsWith("on") && typeof value === "function") {
if (
typeof key === "string" &&
key.startsWith("on") &&
typeof value === "function"
) {
this.handlers[key] = value as (...args: unknown[]) => void;
}
}
@@ -257,7 +338,7 @@ export class UIObject {
mount(): void {
if (this.mounted) return;
this.mounted = true;
// Mount all children
for (const child of this.children) {
child.mount();
@@ -270,12 +351,12 @@ export class UIObject {
unmount(): void {
if (!this.mounted) return;
this.mounted = false;
// Unmount all children first
for (const child of this.children) {
child.unmount();
}
// Run cleanup functions
for (const cleanup of this.cleanupFns) {
try {
@@ -293,6 +374,75 @@ export class UIObject {
onCleanup(fn: () => void): void {
this.cleanupFns.push(fn);
}
/**
* Scroll the container by the given amount
* @param deltaX - Horizontal scroll delta
* @param deltaY - Vertical scroll delta
*/
scrollBy(deltaX: number, deltaY: number): void {
if (this.type !== "scroll-container" || !this.scrollProps) return;
const newScrollX = Math.max(
0,
Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX),
);
const newScrollY = Math.max(
0,
Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY),
);
this.scrollProps.scrollX = newScrollX;
this.scrollProps.scrollY = newScrollY;
}
/**
* Scroll to a specific position
* @param x - Horizontal scroll position
* @param y - Vertical scroll position
*/
scrollTo(x: number, y: number): void {
if (this.type !== "scroll-container" || !this.scrollProps) return;
this.scrollProps.scrollX = Math.max(
0,
Math.min(this.scrollProps.maxScrollX, x),
);
this.scrollProps.scrollY = Math.max(
0,
Math.min(this.scrollProps.maxScrollY, y),
);
}
/**
* Update scroll bounds based on content size
* @param contentWidth - Total content width
* @param contentHeight - Total content height
*/
updateScrollBounds(contentWidth: number, contentHeight: number): void {
if (this.type !== "scroll-container" || !this.scrollProps) return;
this.scrollProps.contentWidth = contentWidth;
this.scrollProps.contentHeight = contentHeight;
this.scrollProps.maxScrollX = Math.max(
0,
contentWidth - this.scrollProps.viewportWidth,
);
this.scrollProps.maxScrollY = Math.max(
0,
contentHeight - this.scrollProps.viewportHeight,
);
// Clamp current scroll position to new bounds
this.scrollProps.scrollX = Math.max(
0,
Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX),
);
this.scrollProps.scrollY = Math.max(
0,
Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY),
);
}
}
/**

View File

@@ -5,7 +5,8 @@
import { UIObject } from "./UIObject";
import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer";
import { CCLog } from "../ccLog";
import { CCLog, HOUR } from "../ccLog";
import { setLogger } from "./context";
/**
* Main application class
@@ -27,7 +28,8 @@ export class Application {
const [width, height] = term.getSize();
this.termWidth = width;
this.termHeight = height;
this.logger = new CCLog("tui_debug.log", false);
this.logger = new CCLog("tui_debug.log", false, HOUR);
setLogger(this.logger);
this.logger.debug("Application constructed.");
}
@@ -138,7 +140,7 @@ export class Application {
if (currentTime - this.lastBlinkTime >= this.BLINK_INTERVAL) {
this.lastBlinkTime = currentTime;
this.cursorBlinkState = !this.cursorBlinkState;
// Only trigger render if we have a focused text input
if (
this.focusedNode !== undefined &&
@@ -176,6 +178,20 @@ export class Application {
eventData[1] as number,
eventData[2] as number,
);
} else if (eventType === "mouse_scroll") {
this.logger.debug(
string.format(
"eventLoop: Mouse scroll detected at (%d, %d) direction %d",
eventData[1],
eventData[2],
eventData[0],
),
);
this.handleMouseScroll(
eventData[0] as number,
eventData[1] as number,
eventData[2] as number,
);
}
}
}
@@ -213,7 +229,10 @@ export class Application {
}
}
}
} else if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
} else if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input"
) {
// Handle text input key events
const type = this.focusedNode.props.type as string | undefined;
if (type !== "checkbox") {
@@ -231,10 +250,7 @@ export class Application {
const valueProp = this.focusedNode.props.value;
const onInputProp = this.focusedNode.props.onInput;
if (
typeof valueProp !== "function" ||
typeof onInputProp !== "function"
) {
if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
return;
}
@@ -422,6 +438,72 @@ export class Application {
}
}
/**
* Find the scrollable UI node at a specific screen position
*/
private findScrollableNodeAt(
node: UIObject,
x: number,
y: number,
): UIObject | undefined {
// Check children first (depth-first)
for (const child of node.children) {
const found = this.findScrollableNodeAt(child, x, y);
if (found !== undefined) {
return found;
}
}
// Check this node
if (node.layout !== undefined) {
const { x: nx, y: ny, width, height } = node.layout;
const hit = x >= nx && x < nx + width && y >= ny && y < ny + height;
if (hit) {
this.logger.debug(
string.format(
"findNodeAt: Hit test TRUE for %s at (%d, %d)",
node.type,
nx,
ny,
),
);
// Only return scrollable elements
if (node.type === "scroll-container") {
this.logger.debug("findNodeAt: Node is scrollable, returning.");
return node;
}
}
}
return undefined;
}
/**
* Handle mouse scroll events
*/
private handleMouseScroll(direction: number, x: number, y: number): void {
if (this.root === undefined) return;
// Find which element was scrolled over
const scrollContainer = this.findScrollableNodeAt(this.root, x, y);
if (scrollContainer?.scrollProps) {
// Scroll by 1 line per wheel step
const scrollAmount = direction * 1;
scrollContainer.scrollBy(0, scrollAmount);
this.needsRender = true;
this.logger.debug(
string.format(
"handleMouseScroll: Scrolled container by %d, new position: (%d, %d)",
scrollAmount,
scrollContainer.scrollProps.scrollX,
scrollContainer.scrollProps.scrollY,
),
);
}
}
/**
* Collect all interactive elements in the tree
*/

View File

@@ -4,7 +4,10 @@
*/
import { UIObject, BaseProps, createTextNode } from "./UIObject";
import { Accessor, Setter, Signal } from "./reactivity";
import { Accessor, createMemo, Setter, Signal } from "./reactivity";
import { For } from "./controlFlow";
import { context } from "./context";
import { concatSentence } from "../common";
/**
* Props for div component
@@ -14,7 +17,10 @@ export type DivProps = BaseProps & Record<string, unknown>;
/**
* Props for label component
*/
export type LabelProps = 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
@@ -95,10 +101,84 @@ export function div(
* label({}, () => `Hello, ${name()}!`)
* ```
*/
/**
* Splits a string by whitespace, keeping the whitespace as separate elements.
* This is a TSTL-compatible replacement for `text.split(/(\s+)/)`.
* @param text The text to split.
* @returns An array of words and whitespace.
*/
function splitByWhitespace(text: string): string[] {
if (!text) return [];
const parts: string[] = [];
let currentWord = "";
let currentWhitespace = "";
for (const char of text) {
if (char === " " || char === "\t" || char === "\n" || char === "\r") {
if (currentWord.length > 0) {
parts.push(currentWord);
currentWord = "";
}
currentWhitespace += char;
} else {
if (currentWhitespace.length > 0) {
parts.push(currentWhitespace);
currentWhitespace = "";
}
currentWord += char;
}
}
if (currentWord.length > 0) {
parts.push(currentWord);
}
if (currentWhitespace.length > 0) {
parts.push(currentWhitespace);
}
return parts;
}
export function label(
props: LabelProps,
text: string | Accessor<string>,
): UIObject {
context.logger?.debug(`label : ${textutils.serialiseJSON(props)}`);
context.logger?.debug(
`label text: ${typeof text == "string" ? text : text()}`,
);
if (props.wordWrap === true) {
const p = { ...props };
delete p.wordWrap;
const containerProps: DivProps = {
...p,
class: `${p.class ?? ""} flex flex-col`,
};
if (typeof text === "string") {
// Handle static strings
const words = splitByWhitespace(text);
const children = words.map((word) => createTextNode(word));
const node = new UIObject("div", containerProps, children);
children.forEach((child) => (child.parent = node));
return node;
} else {
// Handle reactive strings (Accessor<string>)
const sentences = createMemo(() => {
const words = splitByWhitespace(text());
const ret = concatSentence(words, 40);
context.logger?.debug(`label words changed : [ ${ret.join(",")} ]`);
return ret;
});
const forNode = For({ class: `flex flex-col`, each: sentences }, (word) =>
label({ class: p.class }, word),
);
return forNode;
}
}
const textNode = createTextNode(text);
const node = new UIObject("label", props, [textNode]);
textNode.parent = node;
@@ -176,11 +256,11 @@ export function input(props: InputProps): UIObject {
const normalizedProps = { ...props };
if (Array.isArray(normalizedProps.value)) {
normalizedProps.value = (normalizedProps.value)[0];
normalizedProps.value = normalizedProps.value[0];
}
if (Array.isArray(normalizedProps.checked)) {
normalizedProps.checked = (normalizedProps.checked)[0];
normalizedProps.checked = normalizedProps.checked[0];
}
return new UIObject("input", normalizedProps, []);

23
src/lib/ccTUI/context.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Global context for the TUI application.
* This is a simple way to provide global instances like a logger
* to all components without prop drilling.
*/
import type { CCLog } from "../ccLog";
/**
* The global context object for the TUI application.
* This will be set by the Application instance on creation.
*/
export const context: { logger: CCLog | undefined } = {
logger: undefined,
};
/**
* Sets the global logger instance.
* @param l The logger instance.
*/
export function setLogger(l: CCLog): void {
context.logger = l;
}

View File

@@ -23,19 +23,35 @@ export type ShowProps = {
fallback?: UIObject;
} & Record<string, unknown>;
/**
* Props for Switch component
*/
export type SwitchProps = {
/** Optional fallback to show when no Match condition is met */
fallback?: UIObject;
} & Record<string, unknown>;
/**
* Props for Match component
*/
export type MatchProps = {
/** Condition accessor - when truthy, this Match will be selected */
when: Accessor<boolean>;
} & Record<string, unknown>;
/**
* For component - renders a list of items
* Efficiently updates when the array changes
*
*
* @template T - The type of items in the array
* @param props - Props containing the array accessor
* @param renderFn - Function to render each item
* @returns UIObject representing the list
*
*
* @example
* ```typescript
* const [todos, setTodos] = createStore<Todo[]>([]);
*
*
* For({ each: () => todos },
* (todo, i) => div({ class: "flex flex-row" },
* label({}, () => todo.title),
@@ -46,24 +62,24 @@ export type ShowProps = {
*/
export function For<T>(
props: ForProps<T>,
renderFn: (item: T, index: Accessor<number>) => UIObject
renderFn: (item: T, index: Accessor<number>) => UIObject,
): UIObject {
const container = new UIObject("for", props, []);
// Track rendered items
let renderedItems: UIObject[] = [];
/**
* Update the list when the array changes
*/
const updateList = () => {
const items = props.each();
// Clear old items
renderedItems.forEach(item => item.unmount());
renderedItems.forEach((item) => item.unmount());
container.children = [];
renderedItems = [];
// Render new items
items.forEach((item, index) => {
const indexAccessor = () => index;
@@ -74,26 +90,26 @@ export function For<T>(
rendered.mount();
});
};
// Create effect to watch for changes
createEffect(() => {
updateList();
});
return container;
}
/**
* Show component - conditionally renders content
*
*
* @param props - Props containing condition and optional fallback
* @param child - Content to show when condition is true
* @returns UIObject representing the conditional content
*
*
* @example
* ```typescript
* const [loggedIn, setLoggedIn] = createSignal(false);
*
*
* Show(
* {
* when: loggedIn,
@@ -105,21 +121,21 @@ export function For<T>(
*/
export function Show(props: ShowProps, child: UIObject): UIObject {
const container = new UIObject("show", props, []);
let currentChild: UIObject | undefined = undefined;
/**
* Update which child is shown based on condition
*/
const updateChild = () => {
const condition = props.when();
// Unmount current child
if (currentChild !== undefined) {
currentChild.unmount();
container.removeChild(currentChild);
}
// Mount appropriate child
if (condition) {
currentChild = child;
@@ -129,17 +145,115 @@ export function Show(props: ShowProps, child: UIObject): UIObject {
currentChild = undefined;
return;
}
if (currentChild !== undefined) {
container.appendChild(currentChild);
currentChild.mount();
}
};
// Create effect to watch for condition changes
createEffect(() => {
updateChild();
});
return container;
}
/**
* Switch component - renders the first Match whose condition is truthy
* Similar to a switch statement or if/else if/else chain
*
* @param props - Props containing optional fallback
* @param matches - Array of Match components to evaluate
* @returns UIObject representing the switch statement
*
* @example
* ```typescript
* const [status, setStatus] = createSignal("loading");
*
* Switch(
* { fallback: div({}, "Unknown status") },
* Match({ when: () => status() === "loading" }, div({}, "Loading...")),
* Match({ when: () => status() === "success" }, div({}, "Success!")),
* Match({ when: () => status() === "error" }, div({}, "Error occurred"))
* )
* ```
*/
export function Switch(props: SwitchProps, ...matches: UIObject[]): UIObject {
const container = new UIObject("switch", props, []);
let currentChild: UIObject | undefined = undefined;
/**
* Evaluate all Match conditions and show the first truthy one
*/
const updateChild = () => {
// Unmount current child
if (currentChild !== undefined) {
currentChild.unmount();
container.removeChild(currentChild);
}
// Find the first Match with a truthy condition
for (const match of matches) {
if (match.type === "match") {
const matchProps = match.props as MatchProps;
const condition = matchProps.when();
if (
condition !== undefined &&
condition !== null &&
condition !== false
) {
// This Match's condition is truthy, use it
if (match.children.length > 0) {
currentChild = match.children[0];
container.appendChild(currentChild);
currentChild.mount();
}
return;
}
}
}
// No Match condition was truthy, use fallback if available
if (props.fallback !== undefined) {
currentChild = props.fallback;
container.appendChild(currentChild);
currentChild.mount();
} else {
currentChild = undefined;
}
};
// Create effect to watch for condition changes
createEffect(() => {
updateChild();
});
return container;
}
/**
* Match component - represents a single case in a Switch
* Should only be used as a child of Switch
*
* @param props - Props containing the condition
* @param child - Content to render when condition is truthy
* @returns UIObject representing this match case
*
* @example
* ```typescript
* const [color, setColor] = createSignal("red");
*
* Match({ when: () => color() === "red" },
* div({ class: "text-red" }, "Stop")
* )
* ```
*/
export function Match(props: MatchProps, child: UIObject): UIObject {
const container = new UIObject("match", props, [child]);
child.parent = container;
return container;
}

View File

@@ -41,7 +41,26 @@ export {
} from "./components";
// Control flow
export { For, Show, type ForProps, type ShowProps } from "./controlFlow";
export {
For,
Show,
Switch,
Match,
type ForProps,
type ShowProps,
type SwitchProps,
type MatchProps,
} from "./controlFlow";
// Scroll container
export {
ScrollContainer,
isScrollContainer,
findScrollContainer,
isPointVisible,
screenToContent,
type ScrollContainerProps,
} from "./scrollContainer";
// Application
export { Application, render } from "./application";
@@ -51,6 +70,7 @@ export {
UIObject,
type LayoutProps,
type StyleProps,
type ScrollProps,
type ComputedLayout,
type BaseProps,
} from "./UIObject";

View File

@@ -5,81 +5,209 @@
import { UIObject } from "./UIObject";
/**
* Get the terminal dimensions
* @returns Terminal width and height
*/
function getTerminalSize(): { width: number; height: number } {
const [w, h] = term.getSize();
return { width: w, height: h };
}
/**
* Measure the natural size of a UI element
* This determines how much space an element wants to take up
*
*
* @param node - The UI node to measure
* @param parentWidth - Available width from parent (for percentage calculations)
* @param parentHeight - Available height from parent (for percentage calculations)
* @returns Width and height of the element
*/
function measureNode(node: UIObject): { width: number; height: number } {
function measureNode(
node: UIObject,
parentWidth?: number,
parentHeight?: number,
): { width: number; height: number } {
// Get text content if it exists
const getTextContent = (): string => {
if (node.textContent !== undefined) {
if (typeof node.textContent === "function") {
return (node.textContent)();
return node.textContent();
}
return node.textContent;
}
// For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) {
if (
node.children.length > 0 &&
node.children[0].textContent !== undefined
) {
const child = node.children[0];
if (typeof child.textContent === "function") {
return (child.textContent)();
return child.textContent();
}
return child.textContent!;
}
return "";
};
// Check for explicit size styling first
let measuredWidth: number | undefined;
let measuredHeight: number | undefined;
// Handle width styling
if (node.styleProps.width !== undefined) {
if (node.styleProps.width === "screen") {
const termSize = getTerminalSize();
measuredWidth = termSize.width;
} else if (node.styleProps.width === "full" && parentWidth !== undefined) {
measuredWidth = parentWidth;
} else if (typeof node.styleProps.width === "number") {
measuredWidth = node.styleProps.width;
}
}
// Handle height styling
if (node.styleProps.height !== undefined) {
if (node.styleProps.height === "screen") {
const termSize = getTerminalSize();
measuredHeight = termSize.height;
} else if (
node.styleProps.height === "full" &&
parentHeight !== undefined
) {
measuredHeight = parentHeight;
} else if (typeof node.styleProps.height === "number") {
measuredHeight = node.styleProps.height;
}
}
switch (node.type) {
case "label":
case "h1":
case "h2":
case "h3": {
const text = getTextContent();
return { width: text.length, height: 1 };
const naturalWidth = text.length;
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "button": {
const text = getTextContent();
// Buttons have brackets around them: [text]
return { width: text.length + 2, height: 1 };
const naturalWidth = text.length + 2;
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "input": {
const type = node.props.type as string | undefined;
if (type === "checkbox") {
return { width: 3, height: 1 }; // [X] or [ ]
const naturalWidth = 3; // [X] or [ ]
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
// Text input - use a default width or from props
const width = (node.props.width as number | undefined) ?? 20;
return { width, height: 1 };
const defaultWidth = (node.props.width as number | undefined) ?? 20;
const naturalHeight = 1;
return {
width: measuredWidth ?? defaultWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "div":
case "form":
case "for":
case "show":
case "fragment": {
case "switch":
case "match":
case "fragment":
case "scroll-container": {
// Container elements size based on their children
let totalWidth = 0;
let totalHeight = 0;
if (node.children.length === 0) {
return { width: 0, height: 0 };
const naturalWidth = 0;
const naturalHeight = 0;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
const direction = node.layoutProps.flexDirection ?? "row";
const isFlex = node.type === "div" || node.type === "form";
const gap = isFlex ? 1 : 0;
// For scroll containers, calculate content size and update scroll bounds
if (node.type === "scroll-container" && node.scrollProps) {
// Calculate actual content size without viewport constraints
const childParentWidth = undefined; // No width constraint for content measurement
const childParentHeight = undefined; // No height constraint for content measurement
if (direction === "row") {
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
}
if (node.children.length > 1) {
totalWidth += gap * (node.children.length - 1);
}
} else {
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
if (node.children.length > 1) {
totalHeight += gap * (node.children.length - 1);
}
}
// Update scroll bounds with actual content size
node.updateScrollBounds(totalWidth, totalHeight);
// Return viewport size as the container size
return {
width: measuredWidth ?? node.scrollProps.viewportWidth,
height: measuredHeight ?? node.scrollProps.viewportHeight,
};
}
// Calculate available space for children (non-scroll containers)
const childParentWidth = measuredWidth ?? parentWidth;
const childParentHeight = measuredHeight ?? parentHeight;
if (direction === "row") {
// In row direction, width is sum of children, height is max
for (const child of node.children) {
const childSize = measureNode(child);
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
}
@@ -89,7 +217,11 @@ function measureNode(node: UIObject): { width: number; height: number } {
} else {
// In column direction, height is sum of children, width is max
for (const child of node.children) {
const childSize = measureNode(child);
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
@@ -97,18 +229,24 @@ function measureNode(node: UIObject): { width: number; height: number } {
totalHeight += gap * (node.children.length - 1);
}
}
return { width: totalWidth, height: totalHeight };
return {
width: measuredWidth ?? totalWidth,
height: measuredHeight ?? totalHeight,
};
}
default:
return { width: 0, height: 0 };
return {
width: measuredWidth ?? 0,
height: measuredHeight ?? 0,
};
}
}
/**
* Apply flexbox layout algorithm to a container and its children
*
*
* @param node - The container node
* @param availableWidth - Available width for layout
* @param availableHeight - Available height for layout
@@ -120,7 +258,7 @@ export function calculateLayout(
availableWidth: number,
availableHeight: number,
startX = 1,
startY = 1
startY = 1,
): void {
// Set this node's layout
node.layout = {
@@ -141,13 +279,37 @@ export function calculateLayout(
const isFlex = node.type === "div" || node.type === "form";
const gap = isFlex ? 1 : 0;
// Handle scroll container layout
if (node.type === "scroll-container" && node.scrollProps) {
// For scroll containers, position children based on scroll offset
const scrollOffsetX = -node.scrollProps.scrollX;
const scrollOffsetY = -node.scrollProps.scrollY;
for (const child of node.children) {
// Calculate child's natural size and position it with scroll offset
const childSize = measureNode(
child,
node.scrollProps.contentWidth,
node.scrollProps.contentHeight,
);
const childX = startX + scrollOffsetX;
const childY = startY + scrollOffsetY;
// Recursively calculate layout for child with its natural size
calculateLayout(child, childSize.width, childSize.height, childX, childY);
}
return;
}
// Measure all children
const childMeasurements = node.children.map((child: UIObject) => measureNode(child));
const childMeasurements = node.children.map((child: UIObject) =>
measureNode(child, availableWidth, availableHeight),
);
// Calculate total size needed
let totalMainAxisSize = 0;
let maxCrossAxisSize = 0;
if (direction === "row") {
for (const measure of childMeasurements) {
totalMainAxisSize += measure.width;
@@ -168,10 +330,10 @@ export function calculateLayout(
// Calculate starting position based on justify-content
let mainAxisPos = 0;
let spacing = 0;
if (direction === "row") {
const remainingSpace = availableWidth - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
@@ -181,7 +343,7 @@ export function calculateLayout(
}
} else {
const remainingSpace = availableHeight - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
@@ -195,14 +357,14 @@ export function calculateLayout(
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const measure = childMeasurements[i];
let childX = startX;
let childY = startY;
if (direction === "row") {
// Main axis is horizontal
childX = startX + math.floor(mainAxisPos);
// Cross axis (vertical) alignment
if (align === "center") {
childY = startY + math.floor((availableHeight - measure.height) / 2);
@@ -211,7 +373,7 @@ export function calculateLayout(
} else {
childY = startY; // start
}
mainAxisPos += measure.width + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
@@ -219,7 +381,7 @@ export function calculateLayout(
} else {
// Main axis is vertical
childY = startY + math.floor(mainAxisPos);
// Cross axis (horizontal) alignment
if (align === "center") {
childX = startX + math.floor((availableWidth - measure.width) / 2);
@@ -228,13 +390,13 @@ export function calculateLayout(
} else {
childX = startX; // start
}
mainAxisPos += measure.height + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
}
}
// Recursively calculate layout for child
calculateLayout(child, measure.width, measure.height, childX, childY);
}

View File

@@ -4,6 +4,7 @@
import { UIObject } from "./UIObject";
import { Accessor } from "./reactivity";
import { isScrollContainer } from "./scrollContainer";
/**
* Get text content from a node (resolving signals if needed)
@@ -11,50 +12,144 @@ import { Accessor } from "./reactivity";
function getTextContent(node: UIObject): string {
if (node.textContent !== undefined) {
if (typeof node.textContent === "function") {
return (node.textContent)();
return node.textContent();
}
return node.textContent;
}
// For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) {
const child = node.children[0];
if (typeof child.textContent === "function") {
return (child.textContent)();
return child.textContent();
}
return child.textContent!;
}
return "";
}
/**
* Check if a position is within the visible area of all scroll container ancestors
*/
function isPositionVisible(
node: UIObject,
screenX: number,
screenY: number,
): boolean {
let current = node.parent;
while (current) {
if (isScrollContainer(current) && current.layout && current.scrollProps) {
const { x: containerX, y: containerY } = current.layout;
const { viewportWidth, viewportHeight } = current.scrollProps;
// Check if position is within the scroll container's viewport
if (
screenX < containerX ||
screenX >= containerX + viewportWidth ||
screenY < containerY ||
screenY >= containerY + viewportHeight
) {
return false;
}
}
current = current.parent;
}
return true;
}
/**
* Draw a scrollbar for a scroll container
*/
function drawScrollbar(container: UIObject): void {
if (
!container.layout ||
!container.scrollProps ||
container.scrollProps.showScrollbar === false
) {
return;
}
const { x, y, width, height } = container.layout;
const { scrollY, maxScrollY, viewportHeight, contentHeight } =
container.scrollProps;
// Only draw vertical scrollbar if content is scrollable
if (maxScrollY <= 0) return;
const scrollbarX = x + width - 1; // Position scrollbar at the right edge
const scrollbarHeight = height;
// Calculate scrollbar thumb position and size
const thumbHeight = Math.max(
1,
Math.floor((viewportHeight / contentHeight) * scrollbarHeight),
);
const thumbPosition = Math.floor(
(scrollY / maxScrollY) * (scrollbarHeight - thumbHeight),
);
// Save current colors
const [origX, origY] = term.getCursorPos();
try {
// Draw scrollbar track
term.setTextColor(colors.gray);
term.setBackgroundColor(colors.lightGray);
for (let i = 0; i < scrollbarHeight; i++) {
term.setCursorPos(scrollbarX, y + i);
if (i >= thumbPosition && i < thumbPosition + thumbHeight) {
// Draw scrollbar thumb
term.setBackgroundColor(colors.gray);
term.write(" ");
} else {
// Draw scrollbar track
term.setBackgroundColor(colors.lightGray);
term.write(" ");
}
}
} finally {
term.setCursorPos(origX, origY);
}
}
/**
* Draw a single UI node to the terminal
*
*
* @param node - The node to draw
* @param focused - Whether this node has focus
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
*/
function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): void {
function drawNode(
node: UIObject,
focused: boolean,
cursorBlinkState: boolean,
): void {
if (!node.layout) return;
const { x, y, width } = node.layout;
const { x, y, width, height } = node.layout;
// Check if this node is visible within scroll container viewports
if (!isPositionVisible(node, x, y)) {
return;
}
// Save cursor position
const [origX, origY] = term.getCursorPos();
try {
// Default colors that can be overridden by styleProps
let textColor = node.styleProps.textColor;
const bgColor = node.styleProps.backgroundColor;
switch (node.type) {
case "label":
case "h1":
case "h2":
case "h3": {
const text = getTextContent(node);
// Set colors based on heading level (if not overridden by styleProps)
if (textColor === undefined) {
if (node.type === "h1") {
@@ -67,18 +162,18 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
textColor = colors.white;
}
}
term.setTextColor(textColor);
term.setBackgroundColor(bgColor ?? colors.black);
term.setCursorPos(x, y);
term.write(text.substring(0, width));
break;
}
case "button": {
const text = getTextContent(node);
// Set colors based on focus (if not overridden by styleProps)
if (focused) {
term.setTextColor(textColor ?? colors.black);
@@ -87,15 +182,15 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.gray);
}
term.setCursorPos(x, y);
term.write(`[${text}]`);
break;
}
case "input": {
const type = node.props.type as string | undefined;
if (type === "checkbox") {
// Draw checkbox
let isChecked = false;
@@ -103,7 +198,7 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
if (typeof checkedProp === "function") {
isChecked = (checkedProp as Accessor<boolean>)();
}
if (focused) {
term.setTextColor(textColor ?? colors.black);
term.setBackgroundColor(bgColor ?? colors.white);
@@ -111,27 +206,26 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.black);
}
term.setCursorPos(x, y);
term.write(isChecked ? "[X]" : "[ ]");
} else {
// Draw text input
let value = "";
let displayText = "";
const valueProp = node.props.value;
if (typeof valueProp === "function") {
value = (valueProp as Accessor<string>)();
displayText = (valueProp as Accessor<string>)();
}
const placeholder = node.props.placeholder as string | undefined;
const cursorPos = node.cursorPos ?? 0;
let displayText = value;
let currentTextColor = textColor;
let showPlaceholder = false;
const focusedBgColor = bgColor ?? colors.white;
const unfocusedBgColor = bgColor ?? colors.black;
if (value === "" && placeholder !== undefined && !focused) {
if (displayText === "" && placeholder !== undefined && !focused) {
displayText = placeholder;
showPlaceholder = true;
currentTextColor = currentTextColor ?? colors.gray;
@@ -150,16 +244,20 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
term.setCursorPos(x + 1, y); // Position cursor for text after padding
const renderWidth = width - 1;
let textToRender = displayText;
const textToRender = displayText + " ";
// Truncate text if it's too long for the padded area
if (textToRender.length > renderWidth) {
textToRender = textToRender.substring(0, renderWidth);
}
// Move text if it's too long for the padded area
const startDisPos =
cursorPos >= renderWidth ? cursorPos - renderWidth + 1 : 0;
const stopDisPos = startDisPos + renderWidth;
if (focused && !showPlaceholder && cursorBlinkState) {
// Draw text with a block cursor by inverting colors at the cursor position
for (let i = 0; i < textToRender.length; i++) {
for (
let i = startDisPos;
i < textToRender.length && i < stopDisPos;
i++
) {
const char = textToRender.substring(i, i + 1);
if (i === cursorPos) {
// Invert colors for cursor
@@ -184,19 +282,26 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
}
} else {
// Not focused or no cursor, just write the text
term.write(textToRender);
term.write(textToRender.substring(startDisPos, stopDisPos));
}
}
break;
}
case "div":
case "form":
case "for":
case "show": {
case "show":
case "switch":
case "match": {
// Container elements may have background colors
if (bgColor !== undefined && node.layout !== undefined) {
const { x: divX, y: divY, width: divWidth, height: divHeight } = node.layout;
const {
x: divX,
y: divY,
width: divWidth,
height: divHeight,
} = node.layout;
term.setBackgroundColor(bgColor);
// Fill the background area
for (let row = 0; row < divHeight; row++) {
@@ -206,16 +311,33 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
}
break;
}
case "scroll-container": {
// Draw the scroll container background
if (bgColor !== undefined) {
term.setBackgroundColor(bgColor);
for (let row = 0; row < height; row++) {
term.setCursorPos(x, y + row);
term.write(string.rep(" ", width));
}
}
// Draw scrollbar after rendering children
// (This will be called after children are rendered)
break;
}
case "fragment": {
// Fragment with text content
if (node.textContent !== undefined) {
const text = typeof node.textContent === "function"
? (node.textContent)()
: node.textContent;
term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.black);
const text =
typeof node.textContent === "function"
? node.textContent()
: node.textContent;
if (bgColor !== undefined) {
term.setBackgroundColor(bgColor);
}
term.setCursorPos(x, y);
term.write(text.substring(0, width));
}
@@ -230,19 +352,34 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
/**
* Recursively render a UI tree
*
*
* @param node - The root node to render
* @param focusedNode - The currently focused node (if any)
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
*/
export function render(node: UIObject, focusedNode?: UIObject, cursorBlinkState = false): void {
export function render(
node: UIObject,
focusedNode?: UIObject,
cursorBlinkState = false,
): void {
// Draw this node
const isFocused = node === focusedNode;
drawNode(node, isFocused, cursorBlinkState);
// Recursively draw children
for (const child of node.children) {
render(child, focusedNode, cursorBlinkState);
// For scroll containers, set up clipping region before rendering children
if (isScrollContainer(node) && node.layout && node.scrollProps) {
// Recursively draw children (they will be clipped by visibility checks)
for (const child of node.children) {
render(child, focusedNode, cursorBlinkState);
}
// Draw scrollbar after children
drawScrollbar(node);
} else {
// Recursively draw children normally
for (const child of node.children) {
render(child, focusedNode, cursorBlinkState);
}
}
}

View File

@@ -0,0 +1,204 @@
/**
* Scroll container component for handling scrollable content
*/
import { UIObject } from "./UIObject";
import { createSignal, createEffect } from "./reactivity";
/**
* Props for ScrollContainer component
*/
export type ScrollContainerProps = {
/** Maximum width of the scroll container viewport */
width?: number;
/** Maximum height of the scroll container viewport */
height?: number;
/** Whether to show scrollbars (default: true) */
showScrollbar?: boolean;
/** CSS-like class names for styling */
class?: string;
/** Callback when scroll position changes */
onScroll?: (scrollX: number, scrollY: number) => void;
} & Record<string, unknown>;
/**
* ScrollContainer component - provides scrollable viewport for content
* When content exceeds the container size, scrollbars appear and mouse wheel scrolling is enabled
*
* @param props - Props containing dimensions and scroll options
* @param content - Content to be scrolled
* @returns UIObject representing the scroll container
*
* @example
* ```typescript
* const [items, setItems] = createStore<string[]>([]);
*
* ScrollContainer(
* { width: 20, height: 10, showScrollbar: true },
* div({ class: "flex flex-col" },
* For({ each: () => items },
* (item, i) => div({}, item)
* )
* )
* )
* ```
*/
export function ScrollContainer(
props: ScrollContainerProps,
content: UIObject,
): UIObject {
const container = new UIObject("scroll-container", props, [content]);
content.parent = container;
// Set up scroll properties from props
if (container.scrollProps) {
container.scrollProps.viewportWidth = props.width ?? 10;
container.scrollProps.viewportHeight = props.height ?? 10;
container.scrollProps.showScrollbar = props.showScrollbar !== false;
}
// Create reactive signals for scroll position
const [scrollX, setScrollX] = createSignal(0);
const [scrollY, setScrollY] = createSignal(0);
// Update scroll position when signals change
createEffect(() => {
const x = scrollX();
const y = scrollY();
container.scrollTo(x, y);
// Call onScroll callback if provided
if (props.onScroll && typeof props.onScroll === "function") {
props.onScroll(x, y);
}
});
// Override scroll methods to update signals
const originalScrollBy = container.scrollBy.bind(container);
const originalScrollTo = container.scrollTo.bind(container);
container.scrollBy = (deltaX: number, deltaY: number): void => {
originalScrollBy(deltaX, deltaY);
if (container.scrollProps) {
setScrollX(container.scrollProps.scrollX);
setScrollY(container.scrollProps.scrollY);
}
};
container.scrollTo = (x: number, y: number): void => {
originalScrollTo(x, y);
if (container.scrollProps) {
setScrollX(container.scrollProps.scrollX);
setScrollY(container.scrollProps.scrollY);
}
};
// Expose scroll control methods on the container
const containerWithMethods = container as UIObject & {
getScrollX: () => number;
getScrollY: () => number;
setScrollX: (value: number) => void;
setScrollY: (value: number) => void;
};
containerWithMethods.getScrollX = () => scrollX();
containerWithMethods.getScrollY = () => scrollY();
containerWithMethods.setScrollX = (value: number) => setScrollX(value);
containerWithMethods.setScrollY = (value: number) => setScrollY(value);
return container;
}
/**
* Check if a UI node is a scroll container
* @param node - The UI node to check
* @returns True if the node is a scroll container
*/
export function isScrollContainer(node: UIObject): boolean {
return node.type === "scroll-container";
}
/**
* Find the nearest scroll container ancestor of a node
* @param node - The node to start searching from
* @returns The nearest scroll container, or undefined if none found
*/
export function findScrollContainer(node: UIObject): UIObject | undefined {
let current = node.parent;
while (current) {
if (isScrollContainer(current)) {
return current;
}
current = current.parent;
}
return undefined;
}
/**
* Check if a point is within the visible area of a scroll container
* @param container - The scroll container
* @param x - X coordinate relative to container
* @param y - Y coordinate relative to container
* @returns True if the point is visible
*/
export function isPointVisible(
container: UIObject,
x: number,
y: number,
): boolean {
if (!isScrollContainer(container) || !container.scrollProps) {
return true;
}
const { scrollX, scrollY, viewportWidth, viewportHeight } =
container.scrollProps;
return (
x >= scrollX &&
x < scrollX + viewportWidth &&
y >= scrollY &&
y < scrollY + viewportHeight
);
}
/**
* Convert screen coordinates to scroll container content coordinates
* @param container - The scroll container
* @param screenX - Screen X coordinate
* @param screenY - Screen Y coordinate
* @returns Content coordinates, or undefined if not within container
*/
export function screenToContent(
container: UIObject,
screenX: number,
screenY: number,
): { x: number; y: number } | undefined {
if (
!isScrollContainer(container) ||
!container.layout ||
!container.scrollProps
) {
return undefined;
}
const { x: containerX, y: containerY } = container.layout;
const { scrollX, scrollY } = container.scrollProps;
// Check if point is within container bounds
const relativeX = screenX - containerX;
const relativeY = screenY - containerY;
if (
relativeX < 0 ||
relativeY < 0 ||
relativeX >= container.scrollProps.viewportWidth ||
relativeY >= container.scrollProps.viewportHeight
) {
return undefined;
}
return {
x: relativeX + scrollX,
y: relativeY + scrollY,
};
}

0
src/lib/ccTUI/utils.ts Normal file
View File

View File

@@ -4,3 +4,21 @@ export function parseBoolean(obj: string): boolean | undefined {
else if (str === "false") return false;
else return undefined;
}
export function concatSentence(words: string[], length: number): string[] {
let i = 0,
j = 1;
const ret: string[] = [];
while (i < words.length) {
let sentence = words[i];
while (j < words.length && sentence.length + words[j].length < length) {
sentence += words[j];
j++;
}
ret.push(sentence);
i = j;
j++;
}
return ret;
}

View File

@@ -15,6 +15,7 @@ import {
For,
createStore,
removeIndex,
ScrollContainer,
} from "../lib/ccTUI";
/**
@@ -99,6 +100,194 @@ const TodosApp = () => {
);
};
/**
* Example data type
*/
interface ListItem {
id: number;
title: string;
description: string;
}
/**
* Simple scroll example with a list of items
*/
function SimpleScrollExample() {
// Create a large list of items to demonstrate scrolling
const [items, setItems] = createStore<ListItem[]>([]);
const [itemCount, setItemCount] = createSignal(0);
// Generate initial items
const generateItems = (count: number) => {
const newItems: ListItem[] = [];
for (let i = 1; i <= count; i++) {
newItems.push({
id: i,
title: `Item ${i}`,
description: `Description for item ${i}`,
});
}
setItems(() => newItems);
setItemCount(count);
};
// Initialize with some items
generateItems(20);
return div(
{ class: "flex flex-col h-screen bg-black text-white" },
// Header
div(
{ class: "flex flex-row justify-center bg-blue text-white" },
label({}, "Scroll Container Demo"),
),
// Control buttons
div(
{ class: "flex flex-row justify-center bg-gray" },
button(
{ onClick: () => generateItems(itemCount() + 10) },
"Add 10 Items",
),
button(
{ onClick: () => generateItems(Math.max(0, itemCount() - 10)) },
"Remove 10 Items",
),
button({ onClick: () => generateItems(50) }, "Generate 50 Items"),
),
// Main scrollable content
div(
{ class: "flex flex-col" },
label({}, "Scrollable List:"),
ScrollContainer(
{
width: 40,
height: 15,
showScrollbar: true,
},
div(
{ class: "flex flex-col" },
For({ each: items }, (item: ListItem, index) =>
div(
{ class: "flex flex-col" },
label({}, () => `${index() + 1}. ${item.title}`),
label({}, item.description),
label({}, ""), // Empty line for spacing
),
),
),
),
),
// Instructions
div(
{ class: "flex flex-col bg-brown text-white" },
label({}, "Instructions:"),
label({}, "• Use mouse wheel to scroll within the container"),
label({}, "• Notice the scrollbar on the right side"),
label({}, "• Try adding/removing items to see scroll behavior"),
),
);
}
/**
* Example with static long content
*/
function StaticScrollExample() {
const longText = [
"Line 1: This is a demonstration of vertical scrolling.",
"Line 2: The content extends beyond the visible area.",
"Line 3: Use your mouse wheel to scroll up and down.",
"Line 4: Notice how the scrollbar appears on the right.",
"Line 5: The scrollbar thumb shows your current position.",
"Line 6: This content is much longer than the container.",
"Line 7: Keep scrolling to see more lines.",
"Line 8: The scroll container handles overflow automatically.",
"Line 9: You can also scroll horizontally if content is wide.",
"Line 10: This demonstrates the scroll functionality.",
"Line 11: More content here to fill the scrollable area.",
"Line 12: The framework handles all the complex scroll logic.",
"Line 13: Just wrap your content in a ScrollContainer.",
"Line 14: Set width and height to define the viewport.",
"Line 15: The end! Try scrolling back to the top.",
];
return div(
{ class: "flex flex-col justify-center items-center h-screen bg-black" },
label({}, "Static Scroll Example"),
ScrollContainer(
{
width: 50,
height: 10,
showScrollbar: true,
},
div(
{ class: "flex flex-col" },
...longText.map((line) => label({}, line)),
),
),
label({}, "Use mouse wheel to scroll"),
);
}
/**
* Example with multiple independent scroll containers
*/
function MultiScrollExample() {
return div(
{ class: "flex flex-col h-screen bg-black" },
label({}, "Multiple Scroll Containers"),
div(
{ class: "flex flex-row justify-between" },
// Left container - numbers
div(
{ class: "flex flex-col" },
label({}, "Numbers"),
ScrollContainer(
{ width: 15, height: 10 },
div(
{ class: "flex flex-col" },
For(
{
each: () =>
Array.from({ length: 30 }, (_, i) => i + 1) as number[],
},
(num: number) => label({}, () => `Number: ${num}`),
),
),
),
),
// Right container - letters
div(
{ class: "flex flex-col" },
label({}, "Letters"),
ScrollContainer(
{ width: 15, height: 10 },
div(
{ class: "flex flex-col" },
For(
{
each: () => "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as string[],
},
(letter: string, index) =>
label({}, () => `${index() + 1}. Letter ${letter}`),
),
),
),
),
),
label({}, "Each container scrolls independently"),
);
}
/**
* Main application component with tabs
*/
@@ -111,11 +300,32 @@ const App = () => {
{ class: "flex flex-row" },
button({ onClick: () => setTabIndex(0) }, "CountDemo"),
button({ onClick: () => setTabIndex(1) }, "TodosDemo"),
button({ onClick: () => setTabIndex(2) }, "SimpleScroll"),
button({ onClick: () => setTabIndex(3) }, "StaticScroll"),
button({ onClick: () => setTabIndex(4) }, "MultiScroll"),
),
Show(
{
when: () => tabIndex() === 0,
fallback: Show({ when: () => tabIndex() === 1 }, TodosApp()),
fallback: Show(
{
when: () => tabIndex() === 1,
fallback: Show(
{
when: () => tabIndex() === 2,
fallback: Show(
{
when: () => tabIndex() === 3,
fallback: MultiScrollExample(),
},
StaticScrollExample()
)
},
SimpleScrollExample()
)
},
TodosApp()
),
},
Counter(),
),
@@ -137,4 +347,4 @@ try {
print("Error running application:");
printError(e);
}
}
}