finish basic tui for accesscontrol, add scroll for tui

This commit is contained in:
2025-10-12 20:23:08 +08:00
parent 1f85ef6aa2
commit bd8e1f9b8d
13 changed files with 1503 additions and 335 deletions

View File

@@ -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,
};

View File

@@ -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`;

View File

@@ -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(

View File

@@ -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),
);
}
} }
/** /**

View File

@@ -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
*/ */

View File

@@ -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, []);

View File

@@ -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;
}

View File

@@ -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添加滚动支持当内容放不下的时候可以使鼠标滚轮滚动查看更多内容最好能够实现滚动条。

View File

@@ -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";

View File

@@ -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;

View File

@@ -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);
}
} }
} }

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,
};
}

View File

@@ -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(),
), ),