mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-05 03:37:50 +08:00
finish basic tui for accesscontrol, add scroll for tui
This commit is contained in:
@@ -1,8 +1,5 @@
|
|||||||
import { CCLog } from "@/lib/ccLog";
|
|
||||||
import * as dkjson from "@sikongjueluo/dkjson-types";
|
import * as dkjson from "@sikongjueluo/dkjson-types";
|
||||||
|
|
||||||
let log: CCLog | undefined;
|
|
||||||
|
|
||||||
interface ToastConfig {
|
interface ToastConfig {
|
||||||
title: MinecraftTextComponent;
|
title: MinecraftTextComponent;
|
||||||
msg: MinecraftTextComponent;
|
msg: MinecraftTextComponent;
|
||||||
@@ -104,10 +101,6 @@ const defaultConfig: AccessConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function setLog(newLog: CCLog) {
|
|
||||||
log = newLog;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadConfig(filepath: string): AccessConfig {
|
function loadConfig(filepath: string): AccessConfig {
|
||||||
const [fp] = io.open(filepath, "r");
|
const [fp] = io.open(filepath, "r");
|
||||||
if (fp == undefined) {
|
if (fp == undefined) {
|
||||||
@@ -121,18 +114,18 @@ function loadConfig(filepath: string): AccessConfig {
|
|||||||
return defaultConfig;
|
return defaultConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [config, pos, err] = dkjson.decode(configJson);
|
// const [config, pos, err] = dkjson.decode(configJson);
|
||||||
if (config == undefined) {
|
// if (config == undefined) {
|
||||||
log?.warn(
|
// log?.warn(
|
||||||
`Config decode failed at ${pos}, use default instead. Error :${err}`,
|
// `Config decode failed at ${pos}, use default instead. Error :${err}`,
|
||||||
);
|
// );
|
||||||
return defaultConfig;
|
// return defaultConfig;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Not use external lib
|
// Not use external lib
|
||||||
// const config = textutils.unserialiseJSON(configJson, {
|
const config = textutils.unserialiseJSON(configJson, {
|
||||||
// parse_empty_array: true,
|
parse_empty_array: true,
|
||||||
// });
|
});
|
||||||
|
|
||||||
return config as AccessConfig;
|
return config as AccessConfig;
|
||||||
}
|
}
|
||||||
@@ -155,11 +148,4 @@ function saveConfig(config: AccessConfig, filepath: string) {
|
|||||||
fp.close();
|
fp.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { ToastConfig, UserGroupConfig, AccessConfig, loadConfig, saveConfig };
|
||||||
ToastConfig,
|
|
||||||
UserGroupConfig,
|
|
||||||
AccessConfig,
|
|
||||||
loadConfig,
|
|
||||||
saveConfig,
|
|
||||||
setLog,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CCLog, DAY } from "@/lib/ccLog";
|
import { CCLog, DAY } from "@/lib/ccLog";
|
||||||
import { ToastConfig, UserGroupConfig, loadConfig, setLog } from "./config";
|
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
|
||||||
import { createAccessControlCLI } from "./cli";
|
import { createAccessControlCLI } from "./cli";
|
||||||
import { launchAccessControlTUI } from "./tui";
|
import { launchAccessControlTUI } from "./tui";
|
||||||
import * as peripheralManager from "../lib/PeripheralManager";
|
import * as peripheralManager from "../lib/PeripheralManager";
|
||||||
@@ -9,7 +9,6 @@ const args = [...$vararg];
|
|||||||
|
|
||||||
// Init Log
|
// Init Log
|
||||||
const log = new CCLog("accesscontrol.log", true, DAY);
|
const log = new CCLog("accesscontrol.log", true, DAY);
|
||||||
setLog(log);
|
|
||||||
|
|
||||||
// Load Config
|
// Load Config
|
||||||
const configFilepath = `${shell.dir()}/access.config.json`;
|
const configFilepath = `${shell.dir()}/access.config.json`;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
render,
|
render,
|
||||||
Show,
|
Show,
|
||||||
For,
|
For,
|
||||||
|
Switch,
|
||||||
|
Match,
|
||||||
} from "../lib/ccTUI";
|
} from "../lib/ccTUI";
|
||||||
import {
|
import {
|
||||||
AccessConfig,
|
AccessConfig,
|
||||||
@@ -63,13 +65,7 @@ const AccessControlTUI = () => {
|
|||||||
setConfig(() => loadedConfig);
|
setConfig(() => loadedConfig);
|
||||||
|
|
||||||
// Tab navigation functions
|
// Tab navigation functions
|
||||||
const tabNames = [
|
const tabNames = ["Basic", "Groups", "Welcome", "Warn", "Notice Toast"];
|
||||||
"Basic",
|
|
||||||
"Groups",
|
|
||||||
"Welcome Toast",
|
|
||||||
"Warn Toast",
|
|
||||||
"Notice Toast",
|
|
||||||
];
|
|
||||||
|
|
||||||
const showError = (message: string) => {
|
const showError = (message: string) => {
|
||||||
setErrorState("show", true);
|
setErrorState("show", true);
|
||||||
@@ -247,52 +243,63 @@ const AccessControlTUI = () => {
|
|||||||
const BasicTab = () => {
|
const BasicTab = () => {
|
||||||
return div(
|
return div(
|
||||||
{ class: "flex flex-col" },
|
{ class: "flex flex-col" },
|
||||||
label({}, "Detect Interval (ms):"),
|
div(
|
||||||
input({
|
{ class: "flex flex-row" },
|
||||||
type: "text",
|
label({}, "Detect Interval (ms):"),
|
||||||
value: () => config().detectInterval?.toString() ?? "",
|
input({
|
||||||
onInput: (value) => {
|
type: "text",
|
||||||
const num = validateNumber(value);
|
value: () => config().detectInterval?.toString() ?? "",
|
||||||
if (num !== null) setConfig("detectInterval", num);
|
onInput: (value) => {
|
||||||
},
|
const num = validateNumber(value);
|
||||||
}),
|
if (num !== null) setConfig("detectInterval", num);
|
||||||
|
},
|
||||||
label({}, "Watch Interval (ms):"),
|
}),
|
||||||
input({
|
),
|
||||||
type: "text",
|
div(
|
||||||
value: () => config().watchInterval?.toString() ?? "",
|
{ class: "flex flex-row" },
|
||||||
onInput: (value) => {
|
label({}, "Watch Interval (ms):"),
|
||||||
const num = validateNumber(value);
|
input({
|
||||||
if (num !== null) setConfig("watchInterval", num);
|
type: "text",
|
||||||
},
|
value: () => config().watchInterval?.toString() ?? "",
|
||||||
}),
|
onInput: (value) => {
|
||||||
|
const num = validateNumber(value);
|
||||||
label({}, "Notice Times:"),
|
if (num !== null) setConfig("watchInterval", num);
|
||||||
input({
|
},
|
||||||
type: "text",
|
}),
|
||||||
value: () => config().noticeTimes?.toString() ?? "",
|
),
|
||||||
onInput: (value) => {
|
div(
|
||||||
const num = validateNumber(value);
|
{ class: "flex flex-row" },
|
||||||
if (num !== null) setConfig("noticeTimes", num);
|
label({}, "Notice Times:"),
|
||||||
},
|
input({
|
||||||
}),
|
type: "text",
|
||||||
|
value: () => config().noticeTimes?.toString() ?? "",
|
||||||
label({}, "Detect Range:"),
|
onInput: (value) => {
|
||||||
input({
|
const num = validateNumber(value);
|
||||||
type: "text",
|
if (num !== null) setConfig("noticeTimes", num);
|
||||||
value: () => config().detectRange?.toString() ?? "",
|
},
|
||||||
onInput: (value) => {
|
}),
|
||||||
const num = validateNumber(value);
|
),
|
||||||
if (num !== null) setConfig("detectRange", num);
|
div(
|
||||||
},
|
{ class: "flex flex-row" },
|
||||||
}),
|
label({}, "Detect Range:"),
|
||||||
|
input({
|
||||||
label({}, "Is Warn:"),
|
type: "text",
|
||||||
input({
|
value: () => config().detectRange?.toString() ?? "",
|
||||||
type: "checkbox",
|
onInput: (value) => {
|
||||||
checked: () => config().isWarn ?? false,
|
const num = validateNumber(value);
|
||||||
onChange: (checked) => setConfig("isWarn", checked),
|
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 +308,6 @@ const AccessControlTUI = () => {
|
|||||||
*/
|
*/
|
||||||
const GroupsTab = () => {
|
const GroupsTab = () => {
|
||||||
const groups = getAllGroups();
|
const groups = getAllGroups();
|
||||||
const selectedGroup = getSelectedGroup();
|
|
||||||
|
|
||||||
return div(
|
return div(
|
||||||
{ class: "flex flex-row" },
|
{ class: "flex flex-row" },
|
||||||
@@ -309,7 +315,7 @@ const AccessControlTUI = () => {
|
|||||||
div(
|
div(
|
||||||
{ class: "flex flex-col" },
|
{ class: "flex flex-col" },
|
||||||
label({}, "Groups:"),
|
label({}, "Groups:"),
|
||||||
For({ each: () => groups }, (group, index) =>
|
For({ each: () => groups, class: "flex flex-col" }, (group, index) =>
|
||||||
button(
|
button(
|
||||||
{
|
{
|
||||||
class:
|
class:
|
||||||
@@ -324,59 +330,64 @@ const AccessControlTUI = () => {
|
|||||||
// Right side - Group details
|
// Right side - Group details
|
||||||
div(
|
div(
|
||||||
{ class: "flex flex-col ml-2" },
|
{ class: "flex flex-col ml-2" },
|
||||||
label({}, () => `Group: ${selectedGroup.groupName}`),
|
label({}, () => `Group: ${getSelectedGroup().groupName}`),
|
||||||
|
|
||||||
label({}, "Is Allowed:"),
|
div(
|
||||||
input({
|
{ class: "flex flex-row" },
|
||||||
type: "checkbox",
|
label({}, "Is Allowed:"),
|
||||||
checked: () => selectedGroup.isAllowed,
|
input({
|
||||||
onChange: (checked) => {
|
type: "checkbox",
|
||||||
const groupIndex = selectedGroupIndex();
|
checked: () => getSelectedGroup().isAllowed,
|
||||||
if (groupIndex === 0) {
|
onChange: (checked) => {
|
||||||
const currentAdmin = config().adminGroupConfig;
|
const groupIndex = selectedGroupIndex();
|
||||||
setConfig("adminGroupConfig", {
|
if (groupIndex === 0) {
|
||||||
...currentAdmin,
|
const currentAdmin = config().adminGroupConfig;
|
||||||
isAllowed: checked,
|
setConfig("adminGroupConfig", {
|
||||||
});
|
...currentAdmin,
|
||||||
} else {
|
isAllowed: checked,
|
||||||
const actualIndex = groupIndex - 1;
|
});
|
||||||
const currentGroups = config().usersGroups;
|
} else {
|
||||||
const currentGroup = currentGroups[actualIndex];
|
const actualIndex = groupIndex - 1;
|
||||||
const newGroups = [...currentGroups];
|
const currentGroups = config().usersGroups;
|
||||||
newGroups[actualIndex] = {
|
const currentGroup = currentGroups[actualIndex];
|
||||||
...currentGroup,
|
const newGroups = [...currentGroups];
|
||||||
isAllowed: checked,
|
newGroups[actualIndex] = {
|
||||||
};
|
...currentGroup,
|
||||||
setConfig("usersGroups", newGroups);
|
isAllowed: checked,
|
||||||
}
|
};
|
||||||
},
|
setConfig("usersGroups", newGroups);
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
label({}, "Is Notice:"),
|
}),
|
||||||
input({
|
),
|
||||||
type: "checkbox",
|
div(
|
||||||
checked: () => selectedGroup.isNotice,
|
{ class: "flex flex-row" },
|
||||||
onChange: (checked) => {
|
label({}, "Is Notice:"),
|
||||||
const groupIndex = selectedGroupIndex();
|
input({
|
||||||
if (groupIndex === 0) {
|
type: "checkbox",
|
||||||
const currentAdmin = config().adminGroupConfig;
|
checked: () => getSelectedGroup().isNotice,
|
||||||
setConfig("adminGroupConfig", {
|
onChange: (checked) => {
|
||||||
...currentAdmin,
|
const groupIndex = selectedGroupIndex();
|
||||||
isNotice: checked,
|
if (groupIndex === 0) {
|
||||||
});
|
const currentAdmin = config().adminGroupConfig;
|
||||||
} else {
|
setConfig("adminGroupConfig", {
|
||||||
const actualIndex = groupIndex - 1;
|
...currentAdmin,
|
||||||
const currentGroups = config().usersGroups;
|
isNotice: checked,
|
||||||
const currentGroup = currentGroups[actualIndex];
|
});
|
||||||
const newGroups = [...currentGroups];
|
} else {
|
||||||
newGroups[actualIndex] = {
|
const actualIndex = groupIndex - 1;
|
||||||
...currentGroup,
|
const currentGroups = config().usersGroups;
|
||||||
isNotice: checked,
|
const currentGroup = currentGroups[actualIndex];
|
||||||
};
|
const newGroups = [...currentGroups];
|
||||||
setConfig("usersGroups", newGroups);
|
newGroups[actualIndex] = {
|
||||||
}
|
...currentGroup,
|
||||||
},
|
isNotice: checked,
|
||||||
}),
|
};
|
||||||
|
setConfig("usersGroups", newGroups);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
label({}, "Group Users:"),
|
label({}, "Group Users:"),
|
||||||
// User management
|
// User management
|
||||||
@@ -392,7 +403,7 @@ const AccessControlTUI = () => {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Users list
|
// Users list
|
||||||
For({ each: () => selectedGroup.groupUsers ?? [] }, (user) =>
|
For({ each: () => getSelectedGroup().groupUsers ?? [] }, (user) =>
|
||||||
div(
|
div(
|
||||||
{ class: "flex flex-row items-center" },
|
{ class: "flex flex-row items-center" },
|
||||||
label({}, user),
|
label({}, user),
|
||||||
@@ -401,7 +412,7 @@ const AccessControlTUI = () => {
|
|||||||
class: "ml-1 bg-red text-white",
|
class: "ml-1 bg-red text-white",
|
||||||
onClick: () => removeUser(user),
|
onClick: () => removeUser(user),
|
||||||
},
|
},
|
||||||
"Remove",
|
"X",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -419,9 +430,10 @@ const AccessControlTUI = () => {
|
|||||||
const toastConfig = config()[toastType];
|
const toastConfig = config()[toastType];
|
||||||
|
|
||||||
return div(
|
return div(
|
||||||
{ class: "flex flex-col" },
|
{ class: "flex flex-col w-full" },
|
||||||
label({}, "Title (JSON):"),
|
label({}, "Title (JSON):"),
|
||||||
input({
|
input({
|
||||||
|
class: "w-full",
|
||||||
type: "text",
|
type: "text",
|
||||||
value: () => textutils.serialiseJSON(toastConfig?.title) ?? "",
|
value: () => textutils.serialiseJSON(toastConfig?.title) ?? "",
|
||||||
onInput: (value) => {
|
onInput: (value) => {
|
||||||
@@ -443,6 +455,7 @@ const AccessControlTUI = () => {
|
|||||||
|
|
||||||
label({}, "Message (JSON):"),
|
label({}, "Message (JSON):"),
|
||||||
input({
|
input({
|
||||||
|
class: "w-full",
|
||||||
type: "text",
|
type: "text",
|
||||||
value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "",
|
value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "",
|
||||||
onInput: (value) => {
|
onInput: (value) => {
|
||||||
@@ -462,38 +475,47 @@ const AccessControlTUI = () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
label({}, "Prefix:"),
|
div(
|
||||||
input({
|
{ class: "flex flex-row" },
|
||||||
type: "text",
|
label({}, "Prefix:"),
|
||||||
value: () => toastConfig?.prefix ?? "",
|
input({
|
||||||
onInput: (value) => {
|
type: "text",
|
||||||
const currentConfig = config();
|
value: () => toastConfig?.prefix ?? "",
|
||||||
const currentToast = currentConfig[toastType];
|
onInput: (value) => {
|
||||||
setConfig(toastType, { ...currentToast, prefix: value });
|
const currentConfig = config();
|
||||||
},
|
const currentToast = currentConfig[toastType];
|
||||||
}),
|
setConfig(toastType, { ...currentToast, prefix: value });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
label({}, "Brackets:"),
|
div(
|
||||||
input({
|
{ class: "flex flex-row" },
|
||||||
type: "text",
|
label({}, "Brackets:"),
|
||||||
value: () => toastConfig?.brackets ?? "",
|
input({
|
||||||
onInput: (value) => {
|
type: "text",
|
||||||
const currentConfig = config();
|
value: () => toastConfig?.brackets ?? "",
|
||||||
const currentToast = currentConfig[toastType];
|
onInput: (value) => {
|
||||||
setConfig(toastType, { ...currentToast, brackets: value });
|
const currentConfig = config();
|
||||||
},
|
const currentToast = currentConfig[toastType];
|
||||||
}),
|
setConfig(toastType, { ...currentToast, brackets: value });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
label({}, "Bracket Color:"),
|
div(
|
||||||
input({
|
{ class: "flex flex-row" },
|
||||||
type: "text",
|
label({}, "Bracket Color:"),
|
||||||
value: () => toastConfig?.bracketColor ?? "",
|
input({
|
||||||
onInput: (value) => {
|
type: "text",
|
||||||
const currentConfig = config();
|
value: () => toastConfig?.bracketColor ?? "",
|
||||||
const currentToast = currentConfig[toastType];
|
onInput: (value) => {
|
||||||
setConfig(toastType, { ...currentToast, bracketColor: value });
|
const currentConfig = config();
|
||||||
},
|
const currentToast = currentConfig[toastType];
|
||||||
}),
|
setConfig(toastType, { ...currentToast, bracketColor: value });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -533,13 +555,20 @@ const AccessControlTUI = () => {
|
|||||||
* Tab Content Renderer
|
* Tab Content Renderer
|
||||||
*/
|
*/
|
||||||
const TabContent = () => {
|
const TabContent = () => {
|
||||||
const tab = currentTab();
|
return Switch(
|
||||||
if (tab === TABS.BASIC) return BasicTab();
|
{ fallback: BasicTab() },
|
||||||
if (tab === TABS.GROUPS) return GroupsTab();
|
Match({ when: () => currentTab() === TABS.BASIC }, BasicTab()),
|
||||||
if (tab === TABS.WELCOME_TOAST) return WelcomeToastTab();
|
Match({ when: () => currentTab() === TABS.GROUPS }, GroupsTab()),
|
||||||
if (tab === TABS.WARN_TOAST) return WarnToastTab();
|
Match(
|
||||||
if (tab === TABS.NOTICE_TOAST) return NoticeToastTab();
|
{ when: () => currentTab() === TABS.WELCOME_TOAST },
|
||||||
return BasicTab(); // fallback
|
WelcomeToastTab(),
|
||||||
|
),
|
||||||
|
Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()),
|
||||||
|
Match(
|
||||||
|
{ when: () => currentTab() === TABS.NOTICE_TOAST },
|
||||||
|
NoticeToastTab(),
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -548,7 +577,10 @@ const AccessControlTUI = () => {
|
|||||||
return div(
|
return div(
|
||||||
{ class: "flex flex-col h-full" },
|
{ class: "flex flex-col h-full" },
|
||||||
// Header
|
// Header
|
||||||
h1("Access Control Configuration"),
|
div(
|
||||||
|
{ class: "flex flex-row justify-center" },
|
||||||
|
h1("Access Control Configuration"),
|
||||||
|
),
|
||||||
|
|
||||||
// Tab bar
|
// Tab bar
|
||||||
div(
|
div(
|
||||||
@@ -565,7 +597,7 @@ const AccessControlTUI = () => {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Content area
|
// Content area
|
||||||
div({ class: "flex-1 p-2" }, TabContent()),
|
div({ class: "flex-1 p-2 w-screen" }, TabContent()),
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
div(
|
div(
|
||||||
|
|||||||
@@ -25,6 +25,32 @@ export interface StyleProps {
|
|||||||
textColor?: number;
|
textColor?: number;
|
||||||
/** Background color */
|
/** Background color */
|
||||||
backgroundColor?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +85,10 @@ export type UIObjectType =
|
|||||||
| "h3"
|
| "h3"
|
||||||
| "for"
|
| "for"
|
||||||
| "show"
|
| "show"
|
||||||
| "fragment";
|
| "switch"
|
||||||
|
| "match"
|
||||||
|
| "fragment"
|
||||||
|
| "scroll-container";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UIObject represents a node in the UI tree
|
* UIObject represents a node in the UI tree
|
||||||
@@ -102,10 +131,13 @@ export class UIObject {
|
|||||||
/** For input text components - cursor position */
|
/** For input text components - cursor position */
|
||||||
cursorPos?: number;
|
cursorPos?: number;
|
||||||
|
|
||||||
|
/** For scroll containers - scroll state */
|
||||||
|
scrollProps?: ScrollProps;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
type: UIObjectType,
|
type: UIObjectType,
|
||||||
props: Record<string, unknown> = {},
|
props: Record<string, unknown> = {},
|
||||||
children: UIObject[] = []
|
children: UIObject[] = [],
|
||||||
) {
|
) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.props = props;
|
this.props = props;
|
||||||
@@ -126,6 +158,21 @@ export class UIObject {
|
|||||||
if (type === "input" && props.type !== "checkbox") {
|
if (type === "input" && props.type !== "checkbox") {
|
||||||
this.cursorPos = 0;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,22 +183,22 @@ export class UIObject {
|
|||||||
*/
|
*/
|
||||||
private parseColor(colorName: string): number | undefined {
|
private parseColor(colorName: string): number | undefined {
|
||||||
const colorMap: Record<string, number> = {
|
const colorMap: Record<string, number> = {
|
||||||
"white": colors.white,
|
white: colors.white,
|
||||||
"orange": colors.orange,
|
orange: colors.orange,
|
||||||
"magenta": colors.magenta,
|
magenta: colors.magenta,
|
||||||
"lightBlue": colors.lightBlue,
|
lightBlue: colors.lightBlue,
|
||||||
"yellow": colors.yellow,
|
yellow: colors.yellow,
|
||||||
"lime": colors.lime,
|
lime: colors.lime,
|
||||||
"pink": colors.pink,
|
pink: colors.pink,
|
||||||
"gray": colors.gray,
|
gray: colors.gray,
|
||||||
"lightGray": colors.lightGray,
|
lightGray: colors.lightGray,
|
||||||
"cyan": colors.cyan,
|
cyan: colors.cyan,
|
||||||
"purple": colors.purple,
|
purple: colors.purple,
|
||||||
"blue": colors.blue,
|
blue: colors.blue,
|
||||||
"brown": colors.brown,
|
brown: colors.brown,
|
||||||
"green": colors.green,
|
green: colors.green,
|
||||||
"red": colors.red,
|
red: colors.red,
|
||||||
"black": colors.black,
|
black: colors.black,
|
||||||
};
|
};
|
||||||
|
|
||||||
return colorMap[colorName];
|
return colorMap[colorName];
|
||||||
@@ -164,7 +211,7 @@ export class UIObject {
|
|||||||
const className = this.props.class as string | undefined;
|
const className = this.props.class as string | undefined;
|
||||||
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);
|
||||||
|
|
||||||
for (const cls of classes) {
|
for (const cls of classes) {
|
||||||
// Flex direction
|
// Flex direction
|
||||||
@@ -211,6 +258,36 @@ export class UIObject {
|
|||||||
this.styleProps.backgroundColor = color;
|
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
|
// Set defaults
|
||||||
@@ -226,7 +303,11 @@ export class UIObject {
|
|||||||
*/
|
*/
|
||||||
private extractHandlers(): void {
|
private extractHandlers(): void {
|
||||||
for (const [key, value] of pairs(this.props)) {
|
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;
|
this.handlers[key] = value as (...args: unknown[]) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +374,75 @@ export class UIObject {
|
|||||||
onCleanup(fn: () => void): void {
|
onCleanup(fn: () => void): void {
|
||||||
this.cleanupFns.push(fn);
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { UIObject } from "./UIObject";
|
|||||||
import { calculateLayout } from "./layout";
|
import { calculateLayout } from "./layout";
|
||||||
import { render as renderTree, clearScreen } from "./renderer";
|
import { render as renderTree, clearScreen } from "./renderer";
|
||||||
import { CCLog } from "../ccLog";
|
import { CCLog } from "../ccLog";
|
||||||
|
import { findScrollContainer } from "./scrollContainer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application class
|
* Main application class
|
||||||
@@ -176,6 +177,20 @@ export class Application {
|
|||||||
eventData[1] as number,
|
eventData[1] as number,
|
||||||
eventData[2] 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 +228,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
|
// Handle text input key events
|
||||||
const type = this.focusedNode.props.type as string | undefined;
|
const type = this.focusedNode.props.type as string | undefined;
|
||||||
if (type !== "checkbox") {
|
if (type !== "checkbox") {
|
||||||
@@ -231,10 +249,7 @@ export class Application {
|
|||||||
const valueProp = this.focusedNode.props.value;
|
const valueProp = this.focusedNode.props.value;
|
||||||
const onInputProp = this.focusedNode.props.onInput;
|
const onInputProp = this.focusedNode.props.onInput;
|
||||||
|
|
||||||
if (
|
if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
|
||||||
typeof valueProp !== "function" ||
|
|
||||||
typeof onInputProp !== "function"
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +437,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
|
* Collect all interactive elements in the tree
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -176,11 +176,11 @@ export function input(props: InputProps): UIObject {
|
|||||||
const normalizedProps = { ...props };
|
const normalizedProps = { ...props };
|
||||||
|
|
||||||
if (Array.isArray(normalizedProps.value)) {
|
if (Array.isArray(normalizedProps.value)) {
|
||||||
normalizedProps.value = (normalizedProps.value)[0];
|
normalizedProps.value = normalizedProps.value[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(normalizedProps.checked)) {
|
if (Array.isArray(normalizedProps.checked)) {
|
||||||
normalizedProps.checked = (normalizedProps.checked)[0];
|
normalizedProps.checked = normalizedProps.checked[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return new UIObject("input", normalizedProps, []);
|
return new UIObject("input", normalizedProps, []);
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ export type ShowProps = {
|
|||||||
fallback?: UIObject;
|
fallback?: UIObject;
|
||||||
} & Record<string, unknown>;
|
} & 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
|
* For component - renders a list of items
|
||||||
* Efficiently updates when the array changes
|
* Efficiently updates when the array changes
|
||||||
@@ -46,7 +62,7 @@ export type ShowProps = {
|
|||||||
*/
|
*/
|
||||||
export function For<T>(
|
export function For<T>(
|
||||||
props: ForProps<T>,
|
props: ForProps<T>,
|
||||||
renderFn: (item: T, index: Accessor<number>) => UIObject
|
renderFn: (item: T, index: Accessor<number>) => UIObject,
|
||||||
): UIObject {
|
): UIObject {
|
||||||
const container = new UIObject("for", props, []);
|
const container = new UIObject("for", props, []);
|
||||||
|
|
||||||
@@ -60,7 +76,7 @@ export function For<T>(
|
|||||||
const items = props.each();
|
const items = props.each();
|
||||||
|
|
||||||
// Clear old items
|
// Clear old items
|
||||||
renderedItems.forEach(item => item.unmount());
|
renderedItems.forEach((item) => item.unmount());
|
||||||
container.children = [];
|
container.children = [];
|
||||||
renderedItems = [];
|
renderedItems = [];
|
||||||
|
|
||||||
@@ -143,3 +159,101 @@ export function Show(props: ShowProps, child: UIObject): UIObject {
|
|||||||
|
|
||||||
return container;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,6 +136,37 @@ render(App);
|
|||||||
- `fallback?: UIObject`: 当 `when` 返回 `false` 时要渲染的组件。
|
- `fallback?: UIObject`: 当 `when` 返回 `false` 时要渲染的组件。
|
||||||
- `child`: 当 `when` 返回 `true` 时要渲染的组件。
|
- `child`: 当 `when` 返回 `true` 时要渲染的组件。
|
||||||
|
|
||||||
|
### `<Switch>` and `<Match>`
|
||||||
|
|
||||||
|
For more complex conditional logic involving multiple branches (like an `if/else if/else` chain), you can use the `<Switch>` and `<Match>` components. `<Switch>` evaluates its `<Match>` children in order and renders the first one whose `when` prop evaluates to a truthy value.
|
||||||
|
|
||||||
|
An optional `fallback` prop on the `<Switch>` component will be rendered if none of the `<Match>` conditions are met.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createSignal } from 'cc-tui';
|
||||||
|
import { Switch, Match } from 'cc-tui';
|
||||||
|
|
||||||
|
function TrafficLight() {
|
||||||
|
const [color, setColor] = createSignal('red');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch fallback={<span>Signal is broken</span>}>
|
||||||
|
<Match when={color() === 'red'}>
|
||||||
|
<p style={{ color: 'red' }}>Stop</p>
|
||||||
|
</Match>
|
||||||
|
<Match when={color() === 'yellow'}>
|
||||||
|
<p style={{ color: 'yellow' }}>Slow Down</p>
|
||||||
|
</Match>
|
||||||
|
<Match when={color() === 'green'}>
|
||||||
|
<p style={{ color: 'green' }}>Go</p>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 布局系统 (Flexbox)
|
## 3. 布局系统 (Flexbox)
|
||||||
@@ -170,6 +201,50 @@ render(App);
|
|||||||
|
|
||||||
颜色名称直接映射自 `tweaked.cc` 的 `colors` API: `white`, `orange`, `magenta`, `lightBlue`, `yellow`, `lime`, `pink`, `gray`, `lightGray`, `cyan`, `purple`, `blue`, `brown`, `green`, `red`, `black`.
|
颜色名称直接映射自 `tweaked.cc` 的 `colors` API: `white`, `orange`, `magenta`, `lightBlue`, `yellow`, `lime`, `pink`, `gray`, `lightGray`, `cyan`, `purple`, `blue`, `brown`, `green`, `red`, `black`.
|
||||||
|
|
||||||
|
### Sizing
|
||||||
|
|
||||||
|
#### Width
|
||||||
|
|
||||||
|
Control the width of an element using the `w` property in the `style` object.
|
||||||
|
|
||||||
|
- `w: <number>`: Sets a fixed width in characters.
|
||||||
|
- `w: "full"`: Sets the width to 100% of its parent's content area.
|
||||||
|
- `w: "screen"`: Sets the width to the full width of the terminal screen.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// A box with a fixed width of 20 characters
|
||||||
|
<Box style={{ w: 20, border: 'single' }}>Fixed Width</Box>
|
||||||
|
|
||||||
|
// A box that fills its parent's width
|
||||||
|
<Box style={{ w: 'full', border: 'single' }}>Full Width</Box>
|
||||||
|
|
||||||
|
// A box that spans the entire screen width
|
||||||
|
<Box style={{ w: 'screen', border: 'single' }}>Screen Width</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Height
|
||||||
|
|
||||||
|
Control the height of an element using the `h` property in the `style` object.
|
||||||
|
|
||||||
|
- `h: <number>`: Sets a fixed height in characters.
|
||||||
|
- `h: "full"`: Sets the height to 100% of its parent's content area.
|
||||||
|
- `h: "screen"`: Sets the height to the full height of the terminal screen.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// A box with a fixed height of 5 characters
|
||||||
|
<Box style={{ h: 5, border: 'single' }}>Fixed Height</Box>
|
||||||
|
|
||||||
|
// A box that fills its parent's height
|
||||||
|
<Box style={{ h: 'full', border: 'single' }}>Full Height</Box>
|
||||||
|
|
||||||
|
// A box that spans the entire screen height
|
||||||
|
<Box style={{ h: 'screen', border: 'single' }}>Screen Height</Box>
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 响应式系统 (Reactivity System)
|
## 4. 响应式系统 (Reactivity System)
|
||||||
@@ -367,4 +442,6 @@ pnpm dlx eslint src/**/*.ts
|
|||||||
|
|
||||||
# OR
|
# OR
|
||||||
just lint
|
just lint
|
||||||
```
|
`
|
||||||
|
|
||||||
|
为ccTUI添加滚动支持,当内容放不下的时候可以使鼠标滚轮滚动查看更多内容,最好能够实现滚动条。
|
||||||
|
|||||||
@@ -41,7 +41,26 @@ export {
|
|||||||
} from "./components";
|
} from "./components";
|
||||||
|
|
||||||
// Control flow
|
// 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
|
// Application
|
||||||
export { Application, render } from "./application";
|
export { Application, render } from "./application";
|
||||||
@@ -51,6 +70,7 @@ export {
|
|||||||
UIObject,
|
UIObject,
|
||||||
type LayoutProps,
|
type LayoutProps,
|
||||||
type StyleProps,
|
type StyleProps,
|
||||||
|
type ScrollProps,
|
||||||
type ComputedLayout,
|
type ComputedLayout,
|
||||||
type BaseProps,
|
type BaseProps,
|
||||||
} from "./UIObject";
|
} from "./UIObject";
|
||||||
|
|||||||
@@ -5,28 +5,46 @@
|
|||||||
|
|
||||||
import { UIObject } from "./UIObject";
|
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
|
* Measure the natural size of a UI element
|
||||||
* This determines how much space an element wants to take up
|
* This determines how much space an element wants to take up
|
||||||
*
|
*
|
||||||
* @param node - The UI node to measure
|
* @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
|
* @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
|
// Get text content if it exists
|
||||||
const getTextContent = (): string => {
|
const getTextContent = (): string => {
|
||||||
if (node.textContent !== undefined) {
|
if (node.textContent !== undefined) {
|
||||||
if (typeof node.textContent === "function") {
|
if (typeof node.textContent === "function") {
|
||||||
return (node.textContent)();
|
return node.textContent();
|
||||||
}
|
}
|
||||||
return node.textContent;
|
return node.textContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For nodes with text children, get their content
|
// 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];
|
const child = node.children[0];
|
||||||
if (typeof child.textContent === "function") {
|
if (typeof child.textContent === "function") {
|
||||||
return (child.textContent)();
|
return child.textContent();
|
||||||
}
|
}
|
||||||
return child.textContent!;
|
return child.textContent!;
|
||||||
}
|
}
|
||||||
@@ -34,52 +52,162 @@ function measureNode(node: UIObject): { width: number; height: number } {
|
|||||||
return "";
|
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) {
|
switch (node.type) {
|
||||||
case "label":
|
case "label":
|
||||||
case "h1":
|
case "h1":
|
||||||
case "h2":
|
case "h2":
|
||||||
case "h3": {
|
case "h3": {
|
||||||
const text = getTextContent();
|
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": {
|
case "button": {
|
||||||
const text = getTextContent();
|
const text = getTextContent();
|
||||||
// Buttons have brackets around them: [text]
|
// 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": {
|
case "input": {
|
||||||
const type = node.props.type as string | undefined;
|
const type = node.props.type as string | undefined;
|
||||||
if (type === "checkbox") {
|
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
|
// Text input - use a default width or from props
|
||||||
const width = (node.props.width as number | undefined) ?? 20;
|
const defaultWidth = (node.props.width as number | undefined) ?? 20;
|
||||||
return { width, height: 1 };
|
const naturalHeight = 1;
|
||||||
|
return {
|
||||||
|
width: measuredWidth ?? defaultWidth,
|
||||||
|
height: measuredHeight ?? naturalHeight,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "div":
|
case "div":
|
||||||
case "form":
|
case "form":
|
||||||
case "for":
|
case "for":
|
||||||
case "show":
|
case "show":
|
||||||
case "fragment": {
|
case "switch":
|
||||||
|
case "match":
|
||||||
|
case "fragment":
|
||||||
|
case "scroll-container": {
|
||||||
// Container elements size based on their children
|
// Container elements size based on their children
|
||||||
let totalWidth = 0;
|
let totalWidth = 0;
|
||||||
let totalHeight = 0;
|
let totalHeight = 0;
|
||||||
|
|
||||||
if (node.children.length === 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 direction = node.layoutProps.flexDirection ?? "row";
|
||||||
const isFlex = node.type === "div" || node.type === "form";
|
const isFlex = node.type === "div" || node.type === "form";
|
||||||
const gap = isFlex ? 1 : 0;
|
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") {
|
if (direction === "row") {
|
||||||
// In row direction, width is sum of children, height is max
|
// In row direction, width is sum of children, height is max
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
const childSize = measureNode(child);
|
const childSize = measureNode(
|
||||||
|
child,
|
||||||
|
childParentWidth,
|
||||||
|
childParentHeight,
|
||||||
|
);
|
||||||
totalWidth += childSize.width;
|
totalWidth += childSize.width;
|
||||||
totalHeight = math.max(totalHeight, childSize.height);
|
totalHeight = math.max(totalHeight, childSize.height);
|
||||||
}
|
}
|
||||||
@@ -89,7 +217,11 @@ function measureNode(node: UIObject): { width: number; height: number } {
|
|||||||
} else {
|
} else {
|
||||||
// In column direction, height is sum of children, width is max
|
// In column direction, height is sum of children, width is max
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
const childSize = measureNode(child);
|
const childSize = measureNode(
|
||||||
|
child,
|
||||||
|
childParentWidth,
|
||||||
|
childParentHeight,
|
||||||
|
);
|
||||||
totalWidth = math.max(totalWidth, childSize.width);
|
totalWidth = math.max(totalWidth, childSize.width);
|
||||||
totalHeight += childSize.height;
|
totalHeight += childSize.height;
|
||||||
}
|
}
|
||||||
@@ -98,11 +230,17 @@ function measureNode(node: UIObject): { width: number; height: number } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { width: totalWidth, height: totalHeight };
|
return {
|
||||||
|
width: measuredWidth ?? totalWidth,
|
||||||
|
height: measuredHeight ?? totalHeight,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { width: 0, height: 0 };
|
return {
|
||||||
|
width: measuredWidth ?? 0,
|
||||||
|
height: measuredHeight ?? 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +258,7 @@ export function calculateLayout(
|
|||||||
availableWidth: number,
|
availableWidth: number,
|
||||||
availableHeight: number,
|
availableHeight: number,
|
||||||
startX = 1,
|
startX = 1,
|
||||||
startY = 1
|
startY = 1,
|
||||||
): void {
|
): void {
|
||||||
// Set this node's layout
|
// Set this node's layout
|
||||||
node.layout = {
|
node.layout = {
|
||||||
@@ -141,8 +279,32 @@ export function calculateLayout(
|
|||||||
const isFlex = node.type === "div" || node.type === "form";
|
const isFlex = node.type === "div" || node.type === "form";
|
||||||
const gap = isFlex ? 1 : 0;
|
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
|
// 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
|
// Calculate total size needed
|
||||||
let totalMainAxisSize = 0;
|
let totalMainAxisSize = 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { UIObject } from "./UIObject";
|
import { UIObject } from "./UIObject";
|
||||||
import { Accessor } from "./reactivity";
|
import { Accessor } from "./reactivity";
|
||||||
|
import { isScrollContainer } from "./scrollContainer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get text content from a node (resolving signals if needed)
|
* Get text content from a node (resolving signals if needed)
|
||||||
@@ -11,7 +12,7 @@ import { Accessor } from "./reactivity";
|
|||||||
function getTextContent(node: UIObject): string {
|
function getTextContent(node: UIObject): string {
|
||||||
if (node.textContent !== undefined) {
|
if (node.textContent !== undefined) {
|
||||||
if (typeof node.textContent === "function") {
|
if (typeof node.textContent === "function") {
|
||||||
return (node.textContent)();
|
return node.textContent();
|
||||||
}
|
}
|
||||||
return node.textContent;
|
return node.textContent;
|
||||||
}
|
}
|
||||||
@@ -20,7 +21,7 @@ function getTextContent(node: UIObject): string {
|
|||||||
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];
|
const child = node.children[0];
|
||||||
if (typeof child.textContent === "function") {
|
if (typeof child.textContent === "function") {
|
||||||
return (child.textContent)();
|
return child.textContent();
|
||||||
}
|
}
|
||||||
return child.textContent!;
|
return child.textContent!;
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,91 @@ function getTextContent(node: UIObject): string {
|
|||||||
return "";
|
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
|
* Draw a single UI node to the terminal
|
||||||
*
|
*
|
||||||
@@ -35,10 +121,19 @@ function getTextContent(node: UIObject): string {
|
|||||||
* @param focused - Whether this node has focus
|
* @param focused - Whether this node has focus
|
||||||
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
|
* @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;
|
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
|
// Save cursor position
|
||||||
const [origX, origY] = term.getCursorPos();
|
const [origX, origY] = term.getCursorPos();
|
||||||
@@ -193,10 +288,17 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
|
|||||||
case "div":
|
case "div":
|
||||||
case "form":
|
case "form":
|
||||||
case "for":
|
case "for":
|
||||||
case "show": {
|
case "show":
|
||||||
|
case "switch":
|
||||||
|
case "match": {
|
||||||
// Container elements may have background colors
|
// Container elements may have background colors
|
||||||
if (bgColor !== undefined && node.layout !== undefined) {
|
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);
|
term.setBackgroundColor(bgColor);
|
||||||
// Fill the background area
|
// Fill the background area
|
||||||
for (let row = 0; row < divHeight; row++) {
|
for (let row = 0; row < divHeight; row++) {
|
||||||
@@ -207,12 +309,28 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
|
|||||||
break;
|
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": {
|
case "fragment": {
|
||||||
// Fragment with text content
|
// Fragment with text content
|
||||||
if (node.textContent !== undefined) {
|
if (node.textContent !== undefined) {
|
||||||
const text = typeof node.textContent === "function"
|
const text =
|
||||||
? (node.textContent)()
|
typeof node.textContent === "function"
|
||||||
: node.textContent;
|
? node.textContent()
|
||||||
|
: node.textContent;
|
||||||
|
|
||||||
term.setTextColor(textColor ?? colors.white);
|
term.setTextColor(textColor ?? colors.white);
|
||||||
term.setBackgroundColor(bgColor ?? colors.black);
|
term.setBackgroundColor(bgColor ?? colors.black);
|
||||||
@@ -235,14 +353,29 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
|
|||||||
* @param focusedNode - The currently focused node (if any)
|
* @param focusedNode - The currently focused node (if any)
|
||||||
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
|
* @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
|
// Draw this node
|
||||||
const isFocused = node === focusedNode;
|
const isFocused = node === focusedNode;
|
||||||
drawNode(node, isFocused, cursorBlinkState);
|
drawNode(node, isFocused, cursorBlinkState);
|
||||||
|
|
||||||
// Recursively draw children
|
// For scroll containers, set up clipping region before rendering children
|
||||||
for (const child of node.children) {
|
if (isScrollContainer(node) && node.layout && node.scrollProps) {
|
||||||
render(child, focusedNode, cursorBlinkState);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
204
src/lib/ccTUI/scrollContainer.ts
Normal file
204
src/lib/ccTUI/scrollContainer.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
For,
|
For,
|
||||||
createStore,
|
createStore,
|
||||||
removeIndex,
|
removeIndex,
|
||||||
|
ScrollContainer,
|
||||||
} from "../lib/ccTUI";
|
} 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
|
* Main application component with tabs
|
||||||
*/
|
*/
|
||||||
@@ -111,11 +300,32 @@ const App = () => {
|
|||||||
{ class: "flex flex-row" },
|
{ class: "flex flex-row" },
|
||||||
button({ onClick: () => setTabIndex(0) }, "CountDemo"),
|
button({ onClick: () => setTabIndex(0) }, "CountDemo"),
|
||||||
button({ onClick: () => setTabIndex(1) }, "TodosDemo"),
|
button({ onClick: () => setTabIndex(1) }, "TodosDemo"),
|
||||||
|
button({ onClick: () => setTabIndex(2) }, "SimpleScroll"),
|
||||||
|
button({ onClick: () => setTabIndex(3) }, "StaticScroll"),
|
||||||
|
button({ onClick: () => setTabIndex(4) }, "MultiScroll"),
|
||||||
),
|
),
|
||||||
Show(
|
Show(
|
||||||
{
|
{
|
||||||
when: () => tabIndex() === 0,
|
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(),
|
Counter(),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user