From bd8e1f9b8d9547000f4de9ef4004e94c17778db1 Mon Sep 17 00:00:00 2001 From: SikongJueluo <1822250894@qq.com> Date: Sun, 12 Oct 2025 20:23:08 +0800 Subject: [PATCH] finish basic tui for accesscontrol, add scroll for tui --- src/accesscontrol/config.ts | 36 ++-- src/accesscontrol/main.ts | 3 +- src/accesscontrol/tui.ts | 330 +++++++++++++++++-------------- src/lib/ccTUI/UIObject.ts | 250 ++++++++++++++++++----- src/lib/ccTUI/application.ts | 93 ++++++++- src/lib/ccTUI/components.ts | 4 +- src/lib/ccTUI/controlFlow.ts | 156 +++++++++++++-- src/lib/ccTUI/framework.md | 79 +++++++- src/lib/ccTUI/index.ts | 22 ++- src/lib/ccTUI/layout.ts | 244 +++++++++++++++++++---- src/lib/ccTUI/renderer.ts | 203 +++++++++++++++---- src/lib/ccTUI/scrollContainer.ts | 204 +++++++++++++++++++ src/tuiExample/main.ts | 214 +++++++++++++++++++- 13 files changed, 1503 insertions(+), 335 deletions(-) create mode 100644 src/lib/ccTUI/scrollContainer.ts diff --git a/src/accesscontrol/config.ts b/src/accesscontrol/config.ts index d507bc3..8e6f3aa 100644 --- a/src/accesscontrol/config.ts +++ b/src/accesscontrol/config.ts @@ -1,8 +1,5 @@ -import { CCLog } from "@/lib/ccLog"; import * as dkjson from "@sikongjueluo/dkjson-types"; -let log: CCLog | undefined; - interface ToastConfig { title: MinecraftTextComponent; msg: MinecraftTextComponent; @@ -104,10 +101,6 @@ const defaultConfig: AccessConfig = { }, }; -function setLog(newLog: CCLog) { - log = newLog; -} - function loadConfig(filepath: string): AccessConfig { const [fp] = io.open(filepath, "r"); if (fp == undefined) { @@ -121,18 +114,18 @@ function loadConfig(filepath: string): AccessConfig { return defaultConfig; } - const [config, pos, err] = dkjson.decode(configJson); - if (config == undefined) { - log?.warn( - `Config decode failed at ${pos}, use default instead. Error :${err}`, - ); - return defaultConfig; - } + // const [config, pos, err] = dkjson.decode(configJson); + // if (config == undefined) { + // log?.warn( + // `Config decode failed at ${pos}, use default instead. Error :${err}`, + // ); + // return defaultConfig; + // } // Not use external lib - // const config = textutils.unserialiseJSON(configJson, { - // parse_empty_array: true, - // }); + const config = textutils.unserialiseJSON(configJson, { + parse_empty_array: true, + }); return config as AccessConfig; } @@ -155,11 +148,4 @@ function saveConfig(config: AccessConfig, filepath: string) { fp.close(); } -export { - ToastConfig, - UserGroupConfig, - AccessConfig, - loadConfig, - saveConfig, - setLog, -}; +export { ToastConfig, UserGroupConfig, AccessConfig, loadConfig, saveConfig }; diff --git a/src/accesscontrol/main.ts b/src/accesscontrol/main.ts index 8e8dbe5..38a08fc 100644 --- a/src/accesscontrol/main.ts +++ b/src/accesscontrol/main.ts @@ -1,5 +1,5 @@ import { CCLog, DAY } from "@/lib/ccLog"; -import { ToastConfig, UserGroupConfig, loadConfig, setLog } from "./config"; +import { ToastConfig, UserGroupConfig, loadConfig } from "./config"; import { createAccessControlCLI } from "./cli"; import { launchAccessControlTUI } from "./tui"; import * as peripheralManager from "../lib/PeripheralManager"; @@ -9,7 +9,6 @@ const args = [...$vararg]; // Init Log const log = new CCLog("accesscontrol.log", true, DAY); -setLog(log); // Load Config const configFilepath = `${shell.dir()}/access.config.json`; diff --git a/src/accesscontrol/tui.ts b/src/accesscontrol/tui.ts index 04f8315..2392033 100644 --- a/src/accesscontrol/tui.ts +++ b/src/accesscontrol/tui.ts @@ -14,6 +14,8 @@ import { render, Show, For, + Switch, + Match, } from "../lib/ccTUI"; import { AccessConfig, @@ -63,13 +65,7 @@ const AccessControlTUI = () => { setConfig(() => loadedConfig); // Tab navigation functions - const tabNames = [ - "Basic", - "Groups", - "Welcome Toast", - "Warn Toast", - "Notice Toast", - ]; + const tabNames = ["Basic", "Groups", "Welcome", "Warn", "Notice Toast"]; const showError = (message: string) => { setErrorState("show", true); @@ -247,52 +243,63 @@ const AccessControlTUI = () => { const BasicTab = () => { return div( { class: "flex flex-col" }, - label({}, "Detect Interval (ms):"), - input({ - type: "text", - value: () => config().detectInterval?.toString() ?? "", - onInput: (value) => { - const num = validateNumber(value); - if (num !== null) setConfig("detectInterval", num); - }, - }), - - label({}, "Watch Interval (ms):"), - input({ - type: "text", - value: () => config().watchInterval?.toString() ?? "", - onInput: (value) => { - const num = validateNumber(value); - if (num !== null) setConfig("watchInterval", num); - }, - }), - - label({}, "Notice Times:"), - input({ - type: "text", - value: () => config().noticeTimes?.toString() ?? "", - onInput: (value) => { - const num = validateNumber(value); - if (num !== null) setConfig("noticeTimes", num); - }, - }), - - label({}, "Detect Range:"), - input({ - type: "text", - value: () => config().detectRange?.toString() ?? "", - onInput: (value) => { - const num = validateNumber(value); - if (num !== null) setConfig("detectRange", num); - }, - }), - - label({}, "Is Warn:"), - input({ - type: "checkbox", - checked: () => config().isWarn ?? false, - onChange: (checked) => setConfig("isWarn", checked), - }), + div( + { class: "flex flex-row" }, + label({}, "Detect Interval (ms):"), + input({ + type: "text", + value: () => config().detectInterval?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("detectInterval", num); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Watch Interval (ms):"), + input({ + type: "text", + value: () => config().watchInterval?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("watchInterval", num); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Notice Times:"), + input({ + type: "text", + value: () => config().noticeTimes?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("noticeTimes", num); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Detect Range:"), + input({ + type: "text", + value: () => config().detectRange?.toString() ?? "", + onInput: (value) => { + const num = validateNumber(value); + if (num !== null) setConfig("detectRange", num); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Is Warn:"), + input({ + type: "checkbox", + checked: () => config().isWarn ?? false, + onChange: (checked) => setConfig("isWarn", checked), + }), + ), ); }; @@ -301,7 +308,6 @@ const AccessControlTUI = () => { */ const GroupsTab = () => { const groups = getAllGroups(); - const selectedGroup = getSelectedGroup(); return div( { class: "flex flex-row" }, @@ -309,7 +315,7 @@ const AccessControlTUI = () => { div( { class: "flex flex-col" }, label({}, "Groups:"), - For({ each: () => groups }, (group, index) => + For({ each: () => groups, class: "flex flex-col" }, (group, index) => button( { class: @@ -324,59 +330,64 @@ const AccessControlTUI = () => { // Right side - Group details div( { class: "flex flex-col ml-2" }, - label({}, () => `Group: ${selectedGroup.groupName}`), + label({}, () => `Group: ${getSelectedGroup().groupName}`), - label({}, "Is Allowed:"), - input({ - type: "checkbox", - checked: () => selectedGroup.isAllowed, - onChange: (checked) => { - const groupIndex = selectedGroupIndex(); - if (groupIndex === 0) { - const currentAdmin = config().adminGroupConfig; - setConfig("adminGroupConfig", { - ...currentAdmin, - isAllowed: checked, - }); - } else { - const actualIndex = groupIndex - 1; - const currentGroups = config().usersGroups; - const currentGroup = currentGroups[actualIndex]; - const newGroups = [...currentGroups]; - newGroups[actualIndex] = { - ...currentGroup, - isAllowed: checked, - }; - setConfig("usersGroups", newGroups); - } - }, - }), - - label({}, "Is Notice:"), - input({ - type: "checkbox", - checked: () => selectedGroup.isNotice, - onChange: (checked) => { - const groupIndex = selectedGroupIndex(); - if (groupIndex === 0) { - const currentAdmin = config().adminGroupConfig; - setConfig("adminGroupConfig", { - ...currentAdmin, - isNotice: checked, - }); - } else { - const actualIndex = groupIndex - 1; - const currentGroups = config().usersGroups; - const currentGroup = currentGroups[actualIndex]; - const newGroups = [...currentGroups]; - newGroups[actualIndex] = { - ...currentGroup, - isNotice: checked, - }; - setConfig("usersGroups", newGroups); - } - }, - }), + div( + { class: "flex flex-row" }, + label({}, "Is Allowed:"), + input({ + type: "checkbox", + checked: () => getSelectedGroup().isAllowed, + onChange: (checked) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + isAllowed: checked, + }); + } else { + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + isAllowed: checked, + }; + setConfig("usersGroups", newGroups); + } + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Is Notice:"), + input({ + type: "checkbox", + checked: () => getSelectedGroup().isNotice, + onChange: (checked) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + isNotice: checked, + }); + } else { + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + isNotice: checked, + }; + setConfig("usersGroups", newGroups); + } + }, + }), + ), label({}, "Group Users:"), // User management @@ -392,7 +403,7 @@ const AccessControlTUI = () => { ), // Users list - For({ each: () => selectedGroup.groupUsers ?? [] }, (user) => + For({ each: () => getSelectedGroup().groupUsers ?? [] }, (user) => div( { class: "flex flex-row items-center" }, label({}, user), @@ -401,7 +412,7 @@ const AccessControlTUI = () => { class: "ml-1 bg-red text-white", onClick: () => removeUser(user), }, - "Remove", + "X", ), ), ), @@ -419,9 +430,10 @@ const AccessControlTUI = () => { const toastConfig = config()[toastType]; return div( - { class: "flex flex-col" }, + { class: "flex flex-col w-full" }, label({}, "Title (JSON):"), input({ + class: "w-full", type: "text", value: () => textutils.serialiseJSON(toastConfig?.title) ?? "", onInput: (value) => { @@ -443,6 +455,7 @@ const AccessControlTUI = () => { label({}, "Message (JSON):"), input({ + class: "w-full", type: "text", value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "", onInput: (value) => { @@ -462,38 +475,47 @@ const AccessControlTUI = () => { }, }), - label({}, "Prefix:"), - input({ - type: "text", - value: () => toastConfig?.prefix ?? "", - onInput: (value) => { - const currentConfig = config(); - const currentToast = currentConfig[toastType]; - setConfig(toastType, { ...currentToast, prefix: value }); - }, - }), + div( + { class: "flex flex-row" }, + label({}, "Prefix:"), + input({ + type: "text", + value: () => toastConfig?.prefix ?? "", + onInput: (value) => { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { ...currentToast, prefix: value }); + }, + }), + ), - label({}, "Brackets:"), - input({ - type: "text", - value: () => toastConfig?.brackets ?? "", - onInput: (value) => { - const currentConfig = config(); - const currentToast = currentConfig[toastType]; - setConfig(toastType, { ...currentToast, brackets: value }); - }, - }), + div( + { class: "flex flex-row" }, + label({}, "Brackets:"), + input({ + type: "text", + value: () => toastConfig?.brackets ?? "", + onInput: (value) => { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { ...currentToast, brackets: value }); + }, + }), + ), - label({}, "Bracket Color:"), - input({ - type: "text", - value: () => toastConfig?.bracketColor ?? "", - onInput: (value) => { - const currentConfig = config(); - const currentToast = currentConfig[toastType]; - setConfig(toastType, { ...currentToast, bracketColor: value }); - }, - }), + div( + { class: "flex flex-row" }, + label({}, "Bracket Color:"), + input({ + type: "text", + value: () => toastConfig?.bracketColor ?? "", + onInput: (value) => { + const currentConfig = config(); + const currentToast = currentConfig[toastType]; + setConfig(toastType, { ...currentToast, bracketColor: value }); + }, + }), + ), ); }; }; @@ -533,13 +555,20 @@ const AccessControlTUI = () => { * Tab Content Renderer */ const TabContent = () => { - const tab = currentTab(); - if (tab === TABS.BASIC) return BasicTab(); - if (tab === TABS.GROUPS) return GroupsTab(); - if (tab === TABS.WELCOME_TOAST) return WelcomeToastTab(); - if (tab === TABS.WARN_TOAST) return WarnToastTab(); - if (tab === TABS.NOTICE_TOAST) return NoticeToastTab(); - return BasicTab(); // fallback + return Switch( + { fallback: BasicTab() }, + Match({ when: () => currentTab() === TABS.BASIC }, BasicTab()), + Match({ when: () => currentTab() === TABS.GROUPS }, GroupsTab()), + Match( + { when: () => currentTab() === TABS.WELCOME_TOAST }, + WelcomeToastTab(), + ), + Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()), + Match( + { when: () => currentTab() === TABS.NOTICE_TOAST }, + NoticeToastTab(), + ), + ); }; /** @@ -548,7 +577,10 @@ const AccessControlTUI = () => { return div( { class: "flex flex-col h-full" }, // Header - h1("Access Control Configuration"), + div( + { class: "flex flex-row justify-center" }, + h1("Access Control Configuration"), + ), // Tab bar div( @@ -565,7 +597,7 @@ const AccessControlTUI = () => { ), // Content area - div({ class: "flex-1 p-2" }, TabContent()), + div({ class: "flex-1 p-2 w-screen" }, TabContent()), // Action buttons div( diff --git a/src/lib/ccTUI/UIObject.ts b/src/lib/ccTUI/UIObject.ts index 8c08dc3..6f279e0 100644 --- a/src/lib/ccTUI/UIObject.ts +++ b/src/lib/ccTUI/UIObject.ts @@ -25,6 +25,32 @@ export interface StyleProps { textColor?: number; /** Background color */ backgroundColor?: number; + /** Width - can be a number (fixed), "full" (100% of parent), or "screen" (terminal width) */ + width?: number | "full" | "screen"; + /** Height - can be a number (fixed), "full" (100% of parent), or "screen" (terminal height) */ + height?: number | "full" | "screen"; +} + +/** + * Scroll properties for scroll containers + */ +export interface ScrollProps { + /** Current horizontal scroll position */ + scrollX: number; + /** Current vertical scroll position */ + scrollY: number; + /** Maximum horizontal scroll (content width - viewport width) */ + maxScrollX: number; + /** Maximum vertical scroll (content height - viewport height) */ + maxScrollY: number; + /** Content dimensions */ + contentWidth: number; + contentHeight: number; + /** Whether to show scrollbars */ + showScrollbar?: boolean; + /** Viewport dimensions (visible area) */ + viewportWidth: number; + viewportHeight: number; } /** @@ -48,18 +74,21 @@ export interface BaseProps { /** * UIObject node type */ -export type UIObjectType = - | "div" - | "label" - | "button" - | "input" +export type UIObjectType = + | "div" + | "label" + | "button" + | "input" | "form" | "h1" | "h2" | "h3" | "for" | "show" - | "fragment"; + | "switch" + | "match" + | "fragment" + | "scroll-container"; /** * UIObject represents a node in the UI tree @@ -68,44 +97,47 @@ export type UIObjectType = export class UIObject { /** Type of the UI object */ type: UIObjectType; - + /** Props passed to the component */ props: Record; - + /** Children UI objects */ children: UIObject[]; - + /** Parent UI object */ parent?: UIObject; - + /** Computed layout after flexbox calculation */ layout?: ComputedLayout; - + /** Layout properties parsed from class string */ layoutProps: LayoutProps; - + /** Style properties parsed from class string */ styleProps: StyleProps; - + /** Whether this component is currently mounted */ mounted: boolean; - + /** Cleanup functions to call when unmounting */ cleanupFns: (() => void)[]; - + /** For text nodes - the text content (can be reactive) */ textContent?: string | Accessor; - + /** Event handlers */ handlers: Record void) | undefined>; - + /** For input text components - cursor position */ cursorPos?: number; + /** For scroll containers - scroll state */ + scrollProps?: ScrollProps; + constructor( type: UIObjectType, props: Record = {}, - children: UIObject[] = [] + children: UIObject[] = [], ) { this.type = type; this.props = props; @@ -115,45 +147,60 @@ export class UIObject { this.mounted = false; this.cleanupFns = []; this.handlers = {}; - + // Parse layout and styles from class prop this.parseClassNames(); - + // Extract event handlers this.extractHandlers(); - + // Initialize cursor position for text inputs if (type === "input" && props.type !== "checkbox") { this.cursorPos = 0; } + + // Initialize scroll properties for scroll containers + if (type === "scroll-container") { + this.scrollProps = { + scrollX: 0, + scrollY: 0, + maxScrollX: 0, + maxScrollY: 0, + contentWidth: 0, + contentHeight: 0, + showScrollbar: props.showScrollbar !== false, + viewportWidth: (props.width as number) ?? 10, + viewportHeight: (props.height as number) ?? 10, + }; + } } /** * Map color name to ComputerCraft colors API value - * + * * @param colorName - The color name from class (e.g., "white", "red") * @returns The color value from colors API, or undefined if invalid */ private parseColor(colorName: string): number | undefined { const colorMap: Record = { - "white": colors.white, - "orange": colors.orange, - "magenta": colors.magenta, - "lightBlue": colors.lightBlue, - "yellow": colors.yellow, - "lime": colors.lime, - "pink": colors.pink, - "gray": colors.gray, - "lightGray": colors.lightGray, - "cyan": colors.cyan, - "purple": colors.purple, - "blue": colors.blue, - "brown": colors.brown, - "green": colors.green, - "red": colors.red, - "black": colors.black, + white: colors.white, + orange: colors.orange, + magenta: colors.magenta, + lightBlue: colors.lightBlue, + yellow: colors.yellow, + lime: colors.lime, + pink: colors.pink, + gray: colors.gray, + lightGray: colors.lightGray, + cyan: colors.cyan, + purple: colors.purple, + blue: colors.blue, + brown: colors.brown, + green: colors.green, + red: colors.red, + black: colors.black, }; - + return colorMap[colorName]; } @@ -164,8 +211,8 @@ export class UIObject { const className = this.props.class as string | undefined; if (className === undefined) return; - const classes = className.split(" ").filter(c => c.length > 0); - + const classes = className.split(" ").filter((c) => c.length > 0); + for (const cls of classes) { // Flex direction if (cls === "flex-row") { @@ -173,7 +220,7 @@ export class UIObject { } else if (cls === "flex-col") { this.layoutProps.flexDirection = "column"; } - + // Justify content else if (cls === "justify-start") { this.layoutProps.justifyContent = "start"; @@ -184,7 +231,7 @@ export class UIObject { } else if (cls === "justify-between") { this.layoutProps.justifyContent = "between"; } - + // Align items else if (cls === "items-start") { this.layoutProps.alignItems = "start"; @@ -193,7 +240,7 @@ export class UIObject { } else if (cls === "items-end") { this.layoutProps.alignItems = "end"; } - + // Text color (text-) else if (cls.startsWith("text-")) { const colorName = cls.substring(5); // Remove "text-" prefix @@ -202,7 +249,7 @@ export class UIObject { this.styleProps.textColor = color; } } - + // Background color (bg-) else if (cls.startsWith("bg-")) { const colorName = cls.substring(3); // Remove "bg-" prefix @@ -211,8 +258,38 @@ export class UIObject { this.styleProps.backgroundColor = color; } } + + // Width sizing (w-) + 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-) + else if (cls.startsWith("h-")) { + const sizeValue = cls.substring(2); // Remove "h-" prefix + if (sizeValue === "full") { + this.styleProps.height = "full"; + } else if (sizeValue === "screen") { + this.styleProps.height = "screen"; + } else { + const numValue = tonumber(sizeValue); + if (numValue !== undefined) { + this.styleProps.height = numValue; + } + } + } } - + // Set defaults if (this.type === "div") { this.layoutProps.flexDirection ??= "row"; @@ -226,7 +303,11 @@ export class UIObject { */ private extractHandlers(): void { for (const [key, value] of pairs(this.props)) { - if (typeof key === "string" && key.startsWith("on") && typeof value === "function") { + if ( + typeof key === "string" && + key.startsWith("on") && + typeof value === "function" + ) { this.handlers[key] = value as (...args: unknown[]) => void; } } @@ -257,7 +338,7 @@ export class UIObject { mount(): void { if (this.mounted) return; this.mounted = true; - + // Mount all children for (const child of this.children) { child.mount(); @@ -270,12 +351,12 @@ export class UIObject { unmount(): void { if (!this.mounted) return; this.mounted = false; - + // Unmount all children first for (const child of this.children) { child.unmount(); } - + // Run cleanup functions for (const cleanup of this.cleanupFns) { try { @@ -293,6 +374,75 @@ export class UIObject { onCleanup(fn: () => void): void { this.cleanupFns.push(fn); } + + /** + * Scroll the container by the given amount + * @param deltaX - Horizontal scroll delta + * @param deltaY - Vertical scroll delta + */ + scrollBy(deltaX: number, deltaY: number): void { + if (this.type !== "scroll-container" || !this.scrollProps) return; + + const newScrollX = Math.max( + 0, + Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX), + ); + const newScrollY = Math.max( + 0, + Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY), + ); + + this.scrollProps.scrollX = newScrollX; + this.scrollProps.scrollY = newScrollY; + } + + /** + * Scroll to a specific position + * @param x - Horizontal scroll position + * @param y - Vertical scroll position + */ + scrollTo(x: number, y: number): void { + if (this.type !== "scroll-container" || !this.scrollProps) return; + + this.scrollProps.scrollX = Math.max( + 0, + Math.min(this.scrollProps.maxScrollX, x), + ); + this.scrollProps.scrollY = Math.max( + 0, + Math.min(this.scrollProps.maxScrollY, y), + ); + } + + /** + * Update scroll bounds based on content size + * @param contentWidth - Total content width + * @param contentHeight - Total content height + */ + updateScrollBounds(contentWidth: number, contentHeight: number): void { + if (this.type !== "scroll-container" || !this.scrollProps) return; + + this.scrollProps.contentWidth = contentWidth; + this.scrollProps.contentHeight = contentHeight; + this.scrollProps.maxScrollX = Math.max( + 0, + contentWidth - this.scrollProps.viewportWidth, + ); + this.scrollProps.maxScrollY = Math.max( + 0, + contentHeight - this.scrollProps.viewportHeight, + ); + + // Clamp current scroll position to new bounds + this.scrollProps.scrollX = Math.max( + 0, + Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX), + ); + this.scrollProps.scrollY = Math.max( + 0, + Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY), + ); + } } /** diff --git a/src/lib/ccTUI/application.ts b/src/lib/ccTUI/application.ts index ff84b06..1ee6677 100644 --- a/src/lib/ccTUI/application.ts +++ b/src/lib/ccTUI/application.ts @@ -6,6 +6,7 @@ import { UIObject } from "./UIObject"; import { calculateLayout } from "./layout"; import { render as renderTree, clearScreen } from "./renderer"; import { CCLog } from "../ccLog"; +import { findScrollContainer } from "./scrollContainer"; /** * Main application class @@ -138,7 +139,7 @@ export class Application { if (currentTime - this.lastBlinkTime >= this.BLINK_INTERVAL) { this.lastBlinkTime = currentTime; this.cursorBlinkState = !this.cursorBlinkState; - + // Only trigger render if we have a focused text input if ( this.focusedNode !== undefined && @@ -176,6 +177,20 @@ export class Application { eventData[1] as number, eventData[2] as number, ); + } else if (eventType === "mouse_scroll") { + this.logger.debug( + string.format( + "eventLoop: Mouse scroll detected at (%d, %d) direction %d", + eventData[1], + eventData[2], + eventData[0], + ), + ); + this.handleMouseScroll( + eventData[0] as number, + eventData[1] as number, + eventData[2] as number, + ); } } } @@ -213,7 +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 const type = this.focusedNode.props.type as string | undefined; if (type !== "checkbox") { @@ -231,10 +249,7 @@ export class Application { const valueProp = this.focusedNode.props.value; const onInputProp = this.focusedNode.props.onInput; - if ( - typeof valueProp !== "function" || - typeof onInputProp !== "function" - ) { + if (typeof valueProp !== "function" || typeof onInputProp !== "function") { return; } @@ -422,6 +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 */ diff --git a/src/lib/ccTUI/components.ts b/src/lib/ccTUI/components.ts index 51cfaed..ce38fe3 100644 --- a/src/lib/ccTUI/components.ts +++ b/src/lib/ccTUI/components.ts @@ -176,11 +176,11 @@ export function input(props: InputProps): UIObject { const normalizedProps = { ...props }; if (Array.isArray(normalizedProps.value)) { - normalizedProps.value = (normalizedProps.value)[0]; + normalizedProps.value = normalizedProps.value[0]; } if (Array.isArray(normalizedProps.checked)) { - normalizedProps.checked = (normalizedProps.checked)[0]; + normalizedProps.checked = normalizedProps.checked[0]; } return new UIObject("input", normalizedProps, []); diff --git a/src/lib/ccTUI/controlFlow.ts b/src/lib/ccTUI/controlFlow.ts index 47c0519..1be68b5 100644 --- a/src/lib/ccTUI/controlFlow.ts +++ b/src/lib/ccTUI/controlFlow.ts @@ -23,19 +23,35 @@ export type ShowProps = { fallback?: UIObject; } & Record; +/** + * Props for Switch component + */ +export type SwitchProps = { + /** Optional fallback to show when no Match condition is met */ + fallback?: UIObject; +} & Record; + +/** + * Props for Match component + */ +export type MatchProps = { + /** Condition accessor - when truthy, this Match will be selected */ + when: Accessor; +} & Record; + /** * For component - renders a list of items * Efficiently updates when the array changes - * + * * @template T - The type of items in the array * @param props - Props containing the array accessor * @param renderFn - Function to render each item * @returns UIObject representing the list - * + * * @example * ```typescript * const [todos, setTodos] = createStore([]); - * + * * For({ each: () => todos }, * (todo, i) => div({ class: "flex flex-row" }, * label({}, () => todo.title), @@ -46,24 +62,24 @@ export type ShowProps = { */ export function For( props: ForProps, - renderFn: (item: T, index: Accessor) => UIObject + renderFn: (item: T, index: Accessor) => UIObject, ): UIObject { const container = new UIObject("for", props, []); - + // Track rendered items let renderedItems: UIObject[] = []; - + /** * Update the list when the array changes */ const updateList = () => { const items = props.each(); - + // Clear old items - renderedItems.forEach(item => item.unmount()); + renderedItems.forEach((item) => item.unmount()); container.children = []; renderedItems = []; - + // Render new items items.forEach((item, index) => { const indexAccessor = () => index; @@ -74,26 +90,26 @@ export function For( rendered.mount(); }); }; - + // Create effect to watch for changes createEffect(() => { updateList(); }); - + return container; } /** * Show component - conditionally renders content - * + * * @param props - Props containing condition and optional fallback * @param child - Content to show when condition is true * @returns UIObject representing the conditional content - * + * * @example * ```typescript * const [loggedIn, setLoggedIn] = createSignal(false); - * + * * Show( * { * when: loggedIn, @@ -105,21 +121,21 @@ export function For( */ export function Show(props: ShowProps, child: UIObject): UIObject { const container = new UIObject("show", props, []); - + let currentChild: UIObject | undefined = undefined; - + /** * Update which child is shown based on condition */ const updateChild = () => { const condition = props.when(); - + // Unmount current child if (currentChild !== undefined) { currentChild.unmount(); container.removeChild(currentChild); } - + // Mount appropriate child if (condition) { currentChild = child; @@ -129,17 +145,115 @@ export function Show(props: ShowProps, child: UIObject): UIObject { currentChild = undefined; return; } - + if (currentChild !== undefined) { container.appendChild(currentChild); currentChild.mount(); } }; - + // Create effect to watch for condition changes createEffect(() => { updateChild(); }); - + + return container; +} + +/** + * Switch component - renders the first Match whose condition is truthy + * Similar to a switch statement or if/else if/else chain + * + * @param props - Props containing optional fallback + * @param matches - Array of Match components to evaluate + * @returns UIObject representing the switch statement + * + * @example + * ```typescript + * const [status, setStatus] = createSignal("loading"); + * + * Switch( + * { fallback: div({}, "Unknown status") }, + * Match({ when: () => status() === "loading" }, div({}, "Loading...")), + * Match({ when: () => status() === "success" }, div({}, "Success!")), + * Match({ when: () => status() === "error" }, div({}, "Error occurred")) + * ) + * ``` + */ +export function Switch(props: SwitchProps, ...matches: UIObject[]): UIObject { + const container = new UIObject("switch", props, []); + + let currentChild: UIObject | undefined = undefined; + + /** + * Evaluate all Match conditions and show the first truthy one + */ + const updateChild = () => { + // Unmount current child + if (currentChild !== undefined) { + currentChild.unmount(); + container.removeChild(currentChild); + } + + // Find the first Match with a truthy condition + for (const match of matches) { + if (match.type === "match") { + const matchProps = match.props as MatchProps; + const condition = matchProps.when(); + + if ( + condition !== undefined && + condition !== null && + condition !== false + ) { + // This Match's condition is truthy, use it + if (match.children.length > 0) { + currentChild = match.children[0]; + container.appendChild(currentChild); + currentChild.mount(); + } + return; + } + } + } + + // No Match condition was truthy, use fallback if available + if (props.fallback !== undefined) { + currentChild = props.fallback; + container.appendChild(currentChild); + currentChild.mount(); + } else { + currentChild = undefined; + } + }; + + // Create effect to watch for condition changes + createEffect(() => { + updateChild(); + }); + + return container; +} + +/** + * Match component - represents a single case in a Switch + * Should only be used as a child of Switch + * + * @param props - Props containing the condition + * @param child - Content to render when condition is truthy + * @returns UIObject representing this match case + * + * @example + * ```typescript + * const [color, setColor] = createSignal("red"); + * + * Match({ when: () => color() === "red" }, + * div({ class: "text-red" }, "Stop") + * ) + * ``` + */ +export function Match(props: MatchProps, child: UIObject): UIObject { + const container = new UIObject("match", props, [child]); + child.parent = container; return container; } diff --git a/src/lib/ccTUI/framework.md b/src/lib/ccTUI/framework.md index 3c2a74b..4dea893 100644 --- a/src/lib/ccTUI/framework.md +++ b/src/lib/ccTUI/framework.md @@ -136,6 +136,37 @@ render(App); - `fallback?: UIObject`: 当 `when` 返回 `false` 时要渲染的组件。 - `child`: 当 `when` 返回 `true` 时要渲染的组件。 +### `` and `` + +For more complex conditional logic involving multiple branches (like an `if/else if/else` chain), you can use the `` and `` components. `` evaluates its `` children in order and renders the first one whose `when` prop evaluates to a truthy value. + +An optional `fallback` prop on the `` component will be rendered if none of the `` conditions are met. + +**Example:** + +```typescript +import { createSignal } from 'cc-tui'; +import { Switch, Match } from 'cc-tui'; + +function TrafficLight() { + const [color, setColor] = createSignal('red'); + + return ( + Signal is broken}> + +

Stop

+
+ +

Slow Down

+
+ +

Go

+
+
+ ); +} +``` + --- ## 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`. +### Sizing + +#### Width + +Control the width of an element using the `w` property in the `style` object. + +- `w: `: 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 +Fixed Width + +// A box that fills its parent's width +Full Width + +// A box that spans the entire screen width +Screen Width +``` + +#### Height + +Control the height of an element using the `h` property in the `style` object. + +- `h: `: 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 +Fixed Height + +// A box that fills its parent's height +Full Height + +// A box that spans the entire screen height +Screen Height +``` + --- ## 4. 响应式系统 (Reactivity System) @@ -367,4 +442,6 @@ pnpm dlx eslint src/**/*.ts # OR just lint -``` +` + +为ccTUI添加滚动支持,当内容放不下的时候可以使鼠标滚轮滚动查看更多内容,最好能够实现滚动条。 diff --git a/src/lib/ccTUI/index.ts b/src/lib/ccTUI/index.ts index 90bdf53..bdc67ae 100644 --- a/src/lib/ccTUI/index.ts +++ b/src/lib/ccTUI/index.ts @@ -41,7 +41,26 @@ export { } from "./components"; // Control flow -export { For, Show, type ForProps, type ShowProps } from "./controlFlow"; +export { + For, + Show, + Switch, + Match, + type ForProps, + type ShowProps, + type SwitchProps, + type MatchProps, +} from "./controlFlow"; + +// Scroll container +export { + ScrollContainer, + isScrollContainer, + findScrollContainer, + isPointVisible, + screenToContent, + type ScrollContainerProps, +} from "./scrollContainer"; // Application export { Application, render } from "./application"; @@ -51,6 +70,7 @@ export { UIObject, type LayoutProps, type StyleProps, + type ScrollProps, type ComputedLayout, type BaseProps, } from "./UIObject"; diff --git a/src/lib/ccTUI/layout.ts b/src/lib/ccTUI/layout.ts index b827f53..c3c7e9e 100644 --- a/src/lib/ccTUI/layout.ts +++ b/src/lib/ccTUI/layout.ts @@ -5,81 +5,209 @@ import { UIObject } from "./UIObject"; +/** + * Get the terminal dimensions + * @returns Terminal width and height + */ +function getTerminalSize(): { width: number; height: number } { + const [w, h] = term.getSize(); + return { width: w, height: h }; +} + /** * Measure the natural size of a UI element * This determines how much space an element wants to take up - * + * * @param node - The UI node to measure + * @param parentWidth - Available width from parent (for percentage calculations) + * @param parentHeight - Available height from parent (for percentage calculations) * @returns Width and height of the element */ -function measureNode(node: UIObject): { width: number; height: number } { +function measureNode( + node: UIObject, + parentWidth?: number, + parentHeight?: number, +): { width: number; height: number } { // Get text content if it exists const getTextContent = (): string => { if (node.textContent !== undefined) { if (typeof node.textContent === "function") { - return (node.textContent)(); + return node.textContent(); } return node.textContent; } - + // For nodes with text children, get their content - if (node.children.length > 0 && node.children[0].textContent !== undefined) { + if ( + node.children.length > 0 && + node.children[0].textContent !== undefined + ) { const child = node.children[0]; if (typeof child.textContent === "function") { - return (child.textContent)(); + return child.textContent(); } return child.textContent!; } - + return ""; }; + // Check for explicit size styling first + let measuredWidth: number | undefined; + let measuredHeight: number | undefined; + + // Handle width styling + if (node.styleProps.width !== undefined) { + if (node.styleProps.width === "screen") { + const termSize = getTerminalSize(); + measuredWidth = termSize.width; + } else if (node.styleProps.width === "full" && parentWidth !== undefined) { + measuredWidth = parentWidth; + } else if (typeof node.styleProps.width === "number") { + measuredWidth = node.styleProps.width; + } + } + + // Handle height styling + if (node.styleProps.height !== undefined) { + if (node.styleProps.height === "screen") { + const termSize = getTerminalSize(); + measuredHeight = termSize.height; + } else if ( + node.styleProps.height === "full" && + parentHeight !== undefined + ) { + measuredHeight = parentHeight; + } else if (typeof node.styleProps.height === "number") { + measuredHeight = node.styleProps.height; + } + } + switch (node.type) { case "label": case "h1": case "h2": case "h3": { const text = getTextContent(); - return { width: text.length, height: 1 }; + const naturalWidth = text.length; + const naturalHeight = 1; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; } - + case "button": { const text = getTextContent(); // Buttons have brackets around them: [text] - return { width: text.length + 2, height: 1 }; + const naturalWidth = text.length + 2; + const naturalHeight = 1; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; } - + case "input": { const type = node.props.type as string | undefined; if (type === "checkbox") { - return { width: 3, height: 1 }; // [X] or [ ] + const naturalWidth = 3; // [X] or [ ] + const naturalHeight = 1; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; } // Text input - use a default width or from props - const width = (node.props.width as number | undefined) ?? 20; - return { width, height: 1 }; + const defaultWidth = (node.props.width as number | undefined) ?? 20; + const naturalHeight = 1; + return { + width: measuredWidth ?? defaultWidth, + height: measuredHeight ?? naturalHeight, + }; } - + case "div": case "form": case "for": case "show": - case "fragment": { + case "switch": + case "match": + case "fragment": + case "scroll-container": { // Container elements size based on their children let totalWidth = 0; let totalHeight = 0; - + if (node.children.length === 0) { - return { width: 0, height: 0 }; + const naturalWidth = 0; + const naturalHeight = 0; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; } - + const direction = node.layoutProps.flexDirection ?? "row"; const isFlex = node.type === "div" || node.type === "form"; const gap = isFlex ? 1 : 0; - + + // For scroll containers, calculate content size and update scroll bounds + if (node.type === "scroll-container" && node.scrollProps) { + // Calculate actual content size without viewport constraints + const childParentWidth = undefined; // No width constraint for content measurement + const childParentHeight = undefined; // No height constraint for content measurement + + if (direction === "row") { + for (const child of node.children) { + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); + totalWidth += childSize.width; + totalHeight = math.max(totalHeight, childSize.height); + } + if (node.children.length > 1) { + totalWidth += gap * (node.children.length - 1); + } + } else { + for (const child of node.children) { + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); + totalWidth = math.max(totalWidth, childSize.width); + totalHeight += childSize.height; + } + if (node.children.length > 1) { + totalHeight += gap * (node.children.length - 1); + } + } + + // Update scroll bounds with actual content size + node.updateScrollBounds(totalWidth, totalHeight); + + // Return viewport size as the container size + return { + width: measuredWidth ?? node.scrollProps.viewportWidth, + height: measuredHeight ?? node.scrollProps.viewportHeight, + }; + } + + // Calculate available space for children (non-scroll containers) + const childParentWidth = measuredWidth ?? parentWidth; + const childParentHeight = measuredHeight ?? parentHeight; + if (direction === "row") { // In row direction, width is sum of children, height is max for (const child of node.children) { - const childSize = measureNode(child); + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); totalWidth += childSize.width; totalHeight = math.max(totalHeight, childSize.height); } @@ -89,7 +217,11 @@ function measureNode(node: UIObject): { width: number; height: number } { } else { // In column direction, height is sum of children, width is max for (const child of node.children) { - const childSize = measureNode(child); + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); totalWidth = math.max(totalWidth, childSize.width); totalHeight += childSize.height; } @@ -97,18 +229,24 @@ function measureNode(node: UIObject): { width: number; height: number } { totalHeight += gap * (node.children.length - 1); } } - - return { width: totalWidth, height: totalHeight }; + + return { + width: measuredWidth ?? totalWidth, + height: measuredHeight ?? totalHeight, + }; } - + default: - return { width: 0, height: 0 }; + return { + width: measuredWidth ?? 0, + height: measuredHeight ?? 0, + }; } } /** * Apply flexbox layout algorithm to a container and its children - * + * * @param node - The container node * @param availableWidth - Available width for layout * @param availableHeight - Available height for layout @@ -120,7 +258,7 @@ export function calculateLayout( availableWidth: number, availableHeight: number, startX = 1, - startY = 1 + startY = 1, ): void { // Set this node's layout node.layout = { @@ -141,13 +279,37 @@ export function calculateLayout( const isFlex = node.type === "div" || node.type === "form"; const gap = isFlex ? 1 : 0; + // Handle scroll container layout + if (node.type === "scroll-container" && node.scrollProps) { + // For scroll containers, position children based on scroll offset + const scrollOffsetX = -node.scrollProps.scrollX; + const scrollOffsetY = -node.scrollProps.scrollY; + + for (const child of node.children) { + // Calculate child's natural size and position it with scroll offset + const childSize = measureNode( + child, + node.scrollProps.contentWidth, + node.scrollProps.contentHeight, + ); + const childX = startX + scrollOffsetX; + const childY = startY + scrollOffsetY; + + // Recursively calculate layout for child with its natural size + calculateLayout(child, childSize.width, childSize.height, childX, childY); + } + return; + } + // Measure all children - const childMeasurements = node.children.map((child: UIObject) => measureNode(child)); - + const childMeasurements = node.children.map((child: UIObject) => + measureNode(child, availableWidth, availableHeight), + ); + // Calculate total size needed let totalMainAxisSize = 0; let maxCrossAxisSize = 0; - + if (direction === "row") { for (const measure of childMeasurements) { totalMainAxisSize += measure.width; @@ -168,10 +330,10 @@ export function calculateLayout( // Calculate starting position based on justify-content let mainAxisPos = 0; let spacing = 0; - + if (direction === "row") { const remainingSpace = availableWidth - totalMainAxisSize; - + if (justify === "center") { mainAxisPos = remainingSpace / 2; } else if (justify === "end") { @@ -181,7 +343,7 @@ export function calculateLayout( } } else { const remainingSpace = availableHeight - totalMainAxisSize; - + if (justify === "center") { mainAxisPos = remainingSpace / 2; } else if (justify === "end") { @@ -195,14 +357,14 @@ export function calculateLayout( for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; const measure = childMeasurements[i]; - + let childX = startX; let childY = startY; - + if (direction === "row") { // Main axis is horizontal childX = startX + math.floor(mainAxisPos); - + // Cross axis (vertical) alignment if (align === "center") { childY = startY + math.floor((availableHeight - measure.height) / 2); @@ -211,7 +373,7 @@ export function calculateLayout( } else { childY = startY; // start } - + mainAxisPos += measure.width + spacing; if (i < node.children.length - 1) { mainAxisPos += gap; @@ -219,7 +381,7 @@ export function calculateLayout( } else { // Main axis is vertical childY = startY + math.floor(mainAxisPos); - + // Cross axis (horizontal) alignment if (align === "center") { childX = startX + math.floor((availableWidth - measure.width) / 2); @@ -228,13 +390,13 @@ export function calculateLayout( } else { childX = startX; // start } - + mainAxisPos += measure.height + spacing; if (i < node.children.length - 1) { mainAxisPos += gap; } } - + // Recursively calculate layout for child calculateLayout(child, measure.width, measure.height, childX, childY); } diff --git a/src/lib/ccTUI/renderer.ts b/src/lib/ccTUI/renderer.ts index 3eded01..02f1874 100644 --- a/src/lib/ccTUI/renderer.ts +++ b/src/lib/ccTUI/renderer.ts @@ -4,6 +4,7 @@ import { UIObject } from "./UIObject"; import { Accessor } from "./reactivity"; +import { isScrollContainer } from "./scrollContainer"; /** * Get text content from a node (resolving signals if needed) @@ -11,50 +12,144 @@ import { Accessor } from "./reactivity"; function getTextContent(node: UIObject): string { if (node.textContent !== undefined) { if (typeof node.textContent === "function") { - return (node.textContent)(); + return node.textContent(); } return node.textContent; } - + // For nodes with text children, get their content if (node.children.length > 0 && node.children[0].textContent !== undefined) { const child = node.children[0]; if (typeof child.textContent === "function") { - return (child.textContent)(); + return child.textContent(); } return child.textContent!; } - + return ""; } +/** + * Check if a position is within the visible area of all scroll container ancestors + */ +function isPositionVisible( + node: UIObject, + screenX: number, + screenY: number, +): boolean { + let current = node.parent; + while (current) { + if (isScrollContainer(current) && current.layout && current.scrollProps) { + const { x: containerX, y: containerY } = current.layout; + const { viewportWidth, viewportHeight } = current.scrollProps; + + // Check if position is within the scroll container's viewport + if ( + screenX < containerX || + screenX >= containerX + viewportWidth || + screenY < containerY || + screenY >= containerY + viewportHeight + ) { + return false; + } + } + current = current.parent; + } + return true; +} + +/** + * Draw a scrollbar for a scroll container + */ +function drawScrollbar(container: UIObject): void { + if ( + !container.layout || + !container.scrollProps || + container.scrollProps.showScrollbar === false + ) { + return; + } + + const { x, y, width, height } = container.layout; + const { scrollY, maxScrollY, viewportHeight, contentHeight } = + container.scrollProps; + + // Only draw vertical scrollbar if content is scrollable + if (maxScrollY <= 0) return; + + const scrollbarX = x + width - 1; // Position scrollbar at the right edge + const scrollbarHeight = height; + + // Calculate scrollbar thumb position and size + const thumbHeight = Math.max( + 1, + Math.floor((viewportHeight / contentHeight) * scrollbarHeight), + ); + const thumbPosition = Math.floor( + (scrollY / maxScrollY) * (scrollbarHeight - thumbHeight), + ); + + // Save current colors + const [origX, origY] = term.getCursorPos(); + + try { + // Draw scrollbar track + term.setTextColor(colors.gray); + term.setBackgroundColor(colors.lightGray); + + for (let i = 0; i < scrollbarHeight; i++) { + term.setCursorPos(scrollbarX, y + i); + if (i >= thumbPosition && i < thumbPosition + thumbHeight) { + // Draw scrollbar thumb + term.setBackgroundColor(colors.gray); + term.write(" "); + } else { + // Draw scrollbar track + term.setBackgroundColor(colors.lightGray); + term.write(" "); + } + } + } finally { + term.setCursorPos(origX, origY); + } +} + /** * Draw a single UI node to the terminal - * + * * @param node - The node to draw * @param focused - Whether this node has focus * @param cursorBlinkState - Whether the cursor should be visible (for blinking) */ -function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): void { +function drawNode( + node: UIObject, + focused: boolean, + cursorBlinkState: boolean, +): void { if (!node.layout) return; - - const { x, y, width } = node.layout; - + + const { x, y, width, height } = node.layout; + + // Check if this node is visible within scroll container viewports + if (!isPositionVisible(node, x, y)) { + return; + } + // Save cursor position const [origX, origY] = term.getCursorPos(); - + try { // Default colors that can be overridden by styleProps let textColor = node.styleProps.textColor; const bgColor = node.styleProps.backgroundColor; - + switch (node.type) { case "label": case "h1": case "h2": case "h3": { const text = getTextContent(node); - + // Set colors based on heading level (if not overridden by styleProps) if (textColor === undefined) { if (node.type === "h1") { @@ -67,18 +162,18 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): textColor = colors.white; } } - + term.setTextColor(textColor); term.setBackgroundColor(bgColor ?? colors.black); - + term.setCursorPos(x, y); term.write(text.substring(0, width)); break; } - + case "button": { const text = getTextContent(node); - + // Set colors based on focus (if not overridden by styleProps) if (focused) { term.setTextColor(textColor ?? colors.black); @@ -87,15 +182,15 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): term.setTextColor(textColor ?? colors.white); term.setBackgroundColor(bgColor ?? colors.gray); } - + term.setCursorPos(x, y); term.write(`[${text}]`); break; } - + case "input": { const type = node.props.type as string | undefined; - + if (type === "checkbox") { // Draw checkbox let isChecked = false; @@ -103,7 +198,7 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): if (typeof checkedProp === "function") { isChecked = (checkedProp as Accessor)(); } - + if (focused) { term.setTextColor(textColor ?? colors.black); term.setBackgroundColor(bgColor ?? colors.white); @@ -111,7 +206,7 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): term.setTextColor(textColor ?? colors.white); term.setBackgroundColor(bgColor ?? colors.black); } - + term.setCursorPos(x, y); term.write(isChecked ? "[X]" : "[ ]"); } else { @@ -189,14 +284,21 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): } break; } - + case "div": case "form": case "for": - case "show": { + case "show": + case "switch": + case "match": { // Container elements may have background colors if (bgColor !== undefined && node.layout !== undefined) { - const { x: divX, y: divY, width: divWidth, height: divHeight } = node.layout; + const { + x: divX, + y: divY, + width: divWidth, + height: divHeight, + } = node.layout; term.setBackgroundColor(bgColor); // Fill the background area for (let row = 0; row < divHeight; row++) { @@ -206,14 +308,30 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): } break; } - + + case "scroll-container": { + // Draw the scroll container background + if (bgColor !== undefined) { + term.setBackgroundColor(bgColor); + for (let row = 0; row < height; row++) { + term.setCursorPos(x, y + row); + term.write(string.rep(" ", width)); + } + } + + // Draw scrollbar after rendering children + // (This will be called after children are rendered) + break; + } + case "fragment": { // Fragment with text content if (node.textContent !== undefined) { - const text = typeof node.textContent === "function" - ? (node.textContent)() - : node.textContent; - + const text = + typeof node.textContent === "function" + ? node.textContent() + : node.textContent; + term.setTextColor(textColor ?? colors.white); term.setBackgroundColor(bgColor ?? colors.black); term.setCursorPos(x, y); @@ -230,19 +348,34 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): /** * Recursively render a UI tree - * + * * @param node - The root node to render * @param focusedNode - The currently focused node (if any) * @param cursorBlinkState - Whether the cursor should be visible (for blinking) */ -export function render(node: UIObject, focusedNode?: UIObject, cursorBlinkState = false): void { +export function render( + node: UIObject, + focusedNode?: UIObject, + cursorBlinkState = false, +): void { // Draw this node const isFocused = node === focusedNode; drawNode(node, isFocused, cursorBlinkState); - - // Recursively draw children - for (const child of node.children) { - render(child, focusedNode, cursorBlinkState); + + // For scroll containers, set up clipping region before rendering children + if (isScrollContainer(node) && node.layout && node.scrollProps) { + // Recursively draw children (they will be clipped by visibility checks) + for (const child of node.children) { + render(child, focusedNode, cursorBlinkState); + } + + // Draw scrollbar after children + drawScrollbar(node); + } else { + // Recursively draw children normally + for (const child of node.children) { + render(child, focusedNode, cursorBlinkState); + } } } diff --git a/src/lib/ccTUI/scrollContainer.ts b/src/lib/ccTUI/scrollContainer.ts new file mode 100644 index 0000000..9af5893 --- /dev/null +++ b/src/lib/ccTUI/scrollContainer.ts @@ -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; + +/** + * 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([]); + * + * 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, + }; +} diff --git a/src/tuiExample/main.ts b/src/tuiExample/main.ts index 3994b33..b7ebd5b 100644 --- a/src/tuiExample/main.ts +++ b/src/tuiExample/main.ts @@ -15,6 +15,7 @@ import { For, createStore, removeIndex, + ScrollContainer, } from "../lib/ccTUI"; /** @@ -99,6 +100,194 @@ const TodosApp = () => { ); }; +/** + * Example data type + */ +interface ListItem { + id: number; + title: string; + description: string; +} + +/** + * Simple scroll example with a list of items + */ +function SimpleScrollExample() { + // Create a large list of items to demonstrate scrolling + const [items, setItems] = createStore([]); + const [itemCount, setItemCount] = createSignal(0); + + // Generate initial items + const generateItems = (count: number) => { + const newItems: ListItem[] = []; + for (let i = 1; i <= count; i++) { + newItems.push({ + id: i, + title: `Item ${i}`, + description: `Description for item ${i}`, + }); + } + setItems(() => newItems); + setItemCount(count); + }; + + // Initialize with some items + generateItems(20); + + return div( + { class: "flex flex-col h-screen bg-black text-white" }, + + // Header + div( + { class: "flex flex-row justify-center bg-blue text-white" }, + label({}, "Scroll Container Demo"), + ), + + // Control buttons + div( + { class: "flex flex-row justify-center bg-gray" }, + button( + { onClick: () => generateItems(itemCount() + 10) }, + "Add 10 Items", + ), + button( + { onClick: () => generateItems(Math.max(0, itemCount() - 10)) }, + "Remove 10 Items", + ), + button({ onClick: () => generateItems(50) }, "Generate 50 Items"), + ), + + // Main scrollable content + div( + { class: "flex flex-col" }, + label({}, "Scrollable List:"), + ScrollContainer( + { + width: 40, + height: 15, + showScrollbar: true, + }, + div( + { class: "flex flex-col" }, + For({ each: items }, (item: ListItem, index) => + div( + { class: "flex flex-col" }, + label({}, () => `${index() + 1}. ${item.title}`), + label({}, item.description), + label({}, ""), // Empty line for spacing + ), + ), + ), + ), + ), + + // Instructions + div( + { class: "flex flex-col bg-brown text-white" }, + label({}, "Instructions:"), + label({}, "• Use mouse wheel to scroll within the container"), + label({}, "• Notice the scrollbar on the right side"), + label({}, "• Try adding/removing items to see scroll behavior"), + ), + ); +} + +/** + * Example with static long content + */ +function StaticScrollExample() { + const longText = [ + "Line 1: This is a demonstration of vertical scrolling.", + "Line 2: The content extends beyond the visible area.", + "Line 3: Use your mouse wheel to scroll up and down.", + "Line 4: Notice how the scrollbar appears on the right.", + "Line 5: The scrollbar thumb shows your current position.", + "Line 6: This content is much longer than the container.", + "Line 7: Keep scrolling to see more lines.", + "Line 8: The scroll container handles overflow automatically.", + "Line 9: You can also scroll horizontally if content is wide.", + "Line 10: This demonstrates the scroll functionality.", + "Line 11: More content here to fill the scrollable area.", + "Line 12: The framework handles all the complex scroll logic.", + "Line 13: Just wrap your content in a ScrollContainer.", + "Line 14: Set width and height to define the viewport.", + "Line 15: The end! Try scrolling back to the top.", + ]; + + return div( + { class: "flex flex-col justify-center items-center h-screen bg-black" }, + label({}, "Static Scroll Example"), + + ScrollContainer( + { + width: 50, + height: 10, + showScrollbar: true, + }, + div( + { class: "flex flex-col" }, + ...longText.map((line) => label({}, line)), + ), + ), + + label({}, "Use mouse wheel to scroll"), + ); +} + +/** + * Example with multiple independent scroll containers + */ +function MultiScrollExample() { + return div( + { class: "flex flex-col h-screen bg-black" }, + label({}, "Multiple Scroll Containers"), + + div( + { class: "flex flex-row justify-between" }, + + // Left container - numbers + div( + { class: "flex flex-col" }, + label({}, "Numbers"), + ScrollContainer( + { width: 15, height: 10 }, + div( + { class: "flex flex-col" }, + For( + { + each: () => + Array.from({ length: 30 }, (_, i) => i + 1) as number[], + }, + (num: number) => label({}, () => `Number: ${num}`), + ), + ), + ), + ), + + // Right container - letters + div( + { class: "flex flex-col" }, + label({}, "Letters"), + ScrollContainer( + { width: 15, height: 10 }, + div( + { class: "flex flex-col" }, + For( + { + each: () => "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as string[], + }, + (letter: string, index) => + label({}, () => `${index() + 1}. Letter ${letter}`), + ), + ), + ), + ), + ), + + label({}, "Each container scrolls independently"), + ); +} + /** * Main application component with tabs */ @@ -111,11 +300,32 @@ const App = () => { { class: "flex flex-row" }, button({ onClick: () => setTabIndex(0) }, "CountDemo"), button({ onClick: () => setTabIndex(1) }, "TodosDemo"), + button({ onClick: () => setTabIndex(2) }, "SimpleScroll"), + button({ onClick: () => setTabIndex(3) }, "StaticScroll"), + button({ onClick: () => setTabIndex(4) }, "MultiScroll"), ), Show( { when: () => tabIndex() === 0, - fallback: Show({ when: () => tabIndex() === 1 }, TodosApp()), + fallback: Show( + { + when: () => tabIndex() === 1, + fallback: Show( + { + when: () => tabIndex() === 2, + fallback: Show( + { + when: () => tabIndex() === 3, + fallback: MultiScrollExample(), + }, + StaticScrollExample() + ) + }, + SimpleScrollExample() + ) + }, + TodosApp() + ), }, Counter(), ), @@ -137,4 +347,4 @@ try { print("Error running application:"); printError(e); } -} +} \ No newline at end of file