mirror of
				https://github.com/SikongJueluo/cc-utils.git
				synced 2025-11-04 19:27:50 +08:00 
			
		
		
		
	finish basic tui for accesscontrol, add scroll for tui
This commit is contained in:
		@@ -1,8 +1,5 @@
 | 
			
		||||
import { CCLog } from "@/lib/ccLog";
 | 
			
		||||
import * as dkjson from "@sikongjueluo/dkjson-types";
 | 
			
		||||
 | 
			
		||||
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 };
 | 
			
		||||
 
 | 
			
		||||
@@ -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`;
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,32 @@ export interface StyleProps {
 | 
			
		||||
  textColor?: number;
 | 
			
		||||
  /** Background color */
 | 
			
		||||
  backgroundColor?: number;
 | 
			
		||||
  /** Width - can be a number (fixed), "full" (100% of parent), or "screen" (terminal width) */
 | 
			
		||||
  width?: number | "full" | "screen";
 | 
			
		||||
  /** Height - can be a number (fixed), "full" (100% of parent), or "screen" (terminal height) */
 | 
			
		||||
  height?: number | "full" | "screen";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Scroll properties for scroll containers
 | 
			
		||||
 */
 | 
			
		||||
export interface ScrollProps {
 | 
			
		||||
  /** Current horizontal scroll position */
 | 
			
		||||
  scrollX: number;
 | 
			
		||||
  /** Current vertical scroll position */
 | 
			
		||||
  scrollY: number;
 | 
			
		||||
  /** Maximum horizontal scroll (content width - viewport width) */
 | 
			
		||||
  maxScrollX: number;
 | 
			
		||||
  /** Maximum vertical scroll (content height - viewport height) */
 | 
			
		||||
  maxScrollY: number;
 | 
			
		||||
  /** Content dimensions */
 | 
			
		||||
  contentWidth: number;
 | 
			
		||||
  contentHeight: number;
 | 
			
		||||
  /** Whether to show scrollbars */
 | 
			
		||||
  showScrollbar?: boolean;
 | 
			
		||||
  /** Viewport dimensions (visible area) */
 | 
			
		||||
  viewportWidth: number;
 | 
			
		||||
  viewportHeight: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -48,18 +74,21 @@ export interface BaseProps {
 | 
			
		||||
/**
 | 
			
		||||
 * UIObject node type
 | 
			
		||||
 */
 | 
			
		||||
export type UIObjectType = 
 | 
			
		||||
  | "div" 
 | 
			
		||||
  | "label" 
 | 
			
		||||
  | "button" 
 | 
			
		||||
  | "input" 
 | 
			
		||||
export type UIObjectType =
 | 
			
		||||
  | "div"
 | 
			
		||||
  | "label"
 | 
			
		||||
  | "button"
 | 
			
		||||
  | "input"
 | 
			
		||||
  | "form"
 | 
			
		||||
  | "h1"
 | 
			
		||||
  | "h2"
 | 
			
		||||
  | "h3"
 | 
			
		||||
  | "for"
 | 
			
		||||
  | "show"
 | 
			
		||||
  | "fragment";
 | 
			
		||||
  | "switch"
 | 
			
		||||
  | "match"
 | 
			
		||||
  | "fragment"
 | 
			
		||||
  | "scroll-container";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * UIObject represents a node in the UI tree
 | 
			
		||||
@@ -68,44 +97,47 @@ export type UIObjectType =
 | 
			
		||||
export class UIObject {
 | 
			
		||||
  /** Type of the UI object */
 | 
			
		||||
  type: UIObjectType;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Props passed to the component */
 | 
			
		||||
  props: Record<string, unknown>;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Children UI objects */
 | 
			
		||||
  children: UIObject[];
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Parent UI object */
 | 
			
		||||
  parent?: UIObject;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Computed layout after flexbox calculation */
 | 
			
		||||
  layout?: ComputedLayout;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Layout properties parsed from class string */
 | 
			
		||||
  layoutProps: LayoutProps;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Style properties parsed from class string */
 | 
			
		||||
  styleProps: StyleProps;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Whether this component is currently mounted */
 | 
			
		||||
  mounted: boolean;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Cleanup functions to call when unmounting */
 | 
			
		||||
  cleanupFns: (() => void)[];
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** For text nodes - the text content (can be reactive) */
 | 
			
		||||
  textContent?: string | Accessor<string>;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** Event handlers */
 | 
			
		||||
  handlers: Record<string, ((...args: unknown[]) => void) | undefined>;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /** For input text components - cursor position */
 | 
			
		||||
  cursorPos?: number;
 | 
			
		||||
 | 
			
		||||
  /** For scroll containers - scroll state */
 | 
			
		||||
  scrollProps?: ScrollProps;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    type: UIObjectType,
 | 
			
		||||
    props: Record<string, unknown> = {},
 | 
			
		||||
    children: UIObject[] = []
 | 
			
		||||
    children: UIObject[] = [],
 | 
			
		||||
  ) {
 | 
			
		||||
    this.type = type;
 | 
			
		||||
    this.props = props;
 | 
			
		||||
@@ -115,45 +147,60 @@ export class UIObject {
 | 
			
		||||
    this.mounted = false;
 | 
			
		||||
    this.cleanupFns = [];
 | 
			
		||||
    this.handlers = {};
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Parse layout and styles from class prop
 | 
			
		||||
    this.parseClassNames();
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Extract event handlers
 | 
			
		||||
    this.extractHandlers();
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Initialize cursor position for text inputs
 | 
			
		||||
    if (type === "input" && props.type !== "checkbox") {
 | 
			
		||||
      this.cursorPos = 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Initialize scroll properties for scroll containers
 | 
			
		||||
    if (type === "scroll-container") {
 | 
			
		||||
      this.scrollProps = {
 | 
			
		||||
        scrollX: 0,
 | 
			
		||||
        scrollY: 0,
 | 
			
		||||
        maxScrollX: 0,
 | 
			
		||||
        maxScrollY: 0,
 | 
			
		||||
        contentWidth: 0,
 | 
			
		||||
        contentHeight: 0,
 | 
			
		||||
        showScrollbar: props.showScrollbar !== false,
 | 
			
		||||
        viewportWidth: (props.width as number) ?? 10,
 | 
			
		||||
        viewportHeight: (props.height as number) ?? 10,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Map color name to ComputerCraft colors API value
 | 
			
		||||
   * 
 | 
			
		||||
   *
 | 
			
		||||
   * @param colorName - The color name from class (e.g., "white", "red")
 | 
			
		||||
   * @returns The color value from colors API, or undefined if invalid
 | 
			
		||||
   */
 | 
			
		||||
  private parseColor(colorName: string): number | undefined {
 | 
			
		||||
    const colorMap: Record<string, number> = {
 | 
			
		||||
      "white": colors.white,
 | 
			
		||||
      "orange": colors.orange,
 | 
			
		||||
      "magenta": colors.magenta,
 | 
			
		||||
      "lightBlue": colors.lightBlue,
 | 
			
		||||
      "yellow": colors.yellow,
 | 
			
		||||
      "lime": colors.lime,
 | 
			
		||||
      "pink": colors.pink,
 | 
			
		||||
      "gray": colors.gray,
 | 
			
		||||
      "lightGray": colors.lightGray,
 | 
			
		||||
      "cyan": colors.cyan,
 | 
			
		||||
      "purple": colors.purple,
 | 
			
		||||
      "blue": colors.blue,
 | 
			
		||||
      "brown": colors.brown,
 | 
			
		||||
      "green": colors.green,
 | 
			
		||||
      "red": colors.red,
 | 
			
		||||
      "black": colors.black,
 | 
			
		||||
      white: colors.white,
 | 
			
		||||
      orange: colors.orange,
 | 
			
		||||
      magenta: colors.magenta,
 | 
			
		||||
      lightBlue: colors.lightBlue,
 | 
			
		||||
      yellow: colors.yellow,
 | 
			
		||||
      lime: colors.lime,
 | 
			
		||||
      pink: colors.pink,
 | 
			
		||||
      gray: colors.gray,
 | 
			
		||||
      lightGray: colors.lightGray,
 | 
			
		||||
      cyan: colors.cyan,
 | 
			
		||||
      purple: colors.purple,
 | 
			
		||||
      blue: colors.blue,
 | 
			
		||||
      brown: colors.brown,
 | 
			
		||||
      green: colors.green,
 | 
			
		||||
      red: colors.red,
 | 
			
		||||
      black: colors.black,
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    return colorMap[colorName];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -164,8 +211,8 @@ export class UIObject {
 | 
			
		||||
    const className = this.props.class as string | undefined;
 | 
			
		||||
    if (className === undefined) return;
 | 
			
		||||
 | 
			
		||||
    const classes = className.split(" ").filter(c => c.length > 0);
 | 
			
		||||
    
 | 
			
		||||
    const classes = className.split(" ").filter((c) => c.length > 0);
 | 
			
		||||
 | 
			
		||||
    for (const cls of classes) {
 | 
			
		||||
      // Flex direction
 | 
			
		||||
      if (cls === "flex-row") {
 | 
			
		||||
@@ -173,7 +220,7 @@ export class UIObject {
 | 
			
		||||
      } else if (cls === "flex-col") {
 | 
			
		||||
        this.layoutProps.flexDirection = "column";
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      // Justify content
 | 
			
		||||
      else if (cls === "justify-start") {
 | 
			
		||||
        this.layoutProps.justifyContent = "start";
 | 
			
		||||
@@ -184,7 +231,7 @@ export class UIObject {
 | 
			
		||||
      } else if (cls === "justify-between") {
 | 
			
		||||
        this.layoutProps.justifyContent = "between";
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      // Align items
 | 
			
		||||
      else if (cls === "items-start") {
 | 
			
		||||
        this.layoutProps.alignItems = "start";
 | 
			
		||||
@@ -193,7 +240,7 @@ export class UIObject {
 | 
			
		||||
      } else if (cls === "items-end") {
 | 
			
		||||
        this.layoutProps.alignItems = "end";
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      // Text color (text-<color>)
 | 
			
		||||
      else if (cls.startsWith("text-")) {
 | 
			
		||||
        const colorName = cls.substring(5); // Remove "text-" prefix
 | 
			
		||||
@@ -202,7 +249,7 @@ export class UIObject {
 | 
			
		||||
          this.styleProps.textColor = color;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      // Background color (bg-<color>)
 | 
			
		||||
      else if (cls.startsWith("bg-")) {
 | 
			
		||||
        const colorName = cls.substring(3); // Remove "bg-" prefix
 | 
			
		||||
@@ -211,8 +258,38 @@ export class UIObject {
 | 
			
		||||
          this.styleProps.backgroundColor = color;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Width sizing (w-<size>)
 | 
			
		||||
      else if (cls.startsWith("w-")) {
 | 
			
		||||
        const sizeValue = cls.substring(2); // Remove "w-" prefix
 | 
			
		||||
        if (sizeValue === "full") {
 | 
			
		||||
          this.styleProps.width = "full";
 | 
			
		||||
        } else if (sizeValue === "screen") {
 | 
			
		||||
          this.styleProps.width = "screen";
 | 
			
		||||
        } else {
 | 
			
		||||
          const numValue = tonumber(sizeValue);
 | 
			
		||||
          if (numValue !== undefined) {
 | 
			
		||||
            this.styleProps.width = numValue;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Height sizing (h-<size>)
 | 
			
		||||
      else if (cls.startsWith("h-")) {
 | 
			
		||||
        const sizeValue = cls.substring(2); // Remove "h-" prefix
 | 
			
		||||
        if (sizeValue === "full") {
 | 
			
		||||
          this.styleProps.height = "full";
 | 
			
		||||
        } else if (sizeValue === "screen") {
 | 
			
		||||
          this.styleProps.height = "screen";
 | 
			
		||||
        } else {
 | 
			
		||||
          const numValue = tonumber(sizeValue);
 | 
			
		||||
          if (numValue !== undefined) {
 | 
			
		||||
            this.styleProps.height = numValue;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Set defaults
 | 
			
		||||
    if (this.type === "div") {
 | 
			
		||||
      this.layoutProps.flexDirection ??= "row";
 | 
			
		||||
@@ -226,7 +303,11 @@ export class UIObject {
 | 
			
		||||
   */
 | 
			
		||||
  private extractHandlers(): void {
 | 
			
		||||
    for (const [key, value] of pairs(this.props)) {
 | 
			
		||||
      if (typeof key === "string" && key.startsWith("on") && typeof value === "function") {
 | 
			
		||||
      if (
 | 
			
		||||
        typeof key === "string" &&
 | 
			
		||||
        key.startsWith("on") &&
 | 
			
		||||
        typeof value === "function"
 | 
			
		||||
      ) {
 | 
			
		||||
        this.handlers[key] = value as (...args: unknown[]) => void;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@@ -257,7 +338,7 @@ export class UIObject {
 | 
			
		||||
  mount(): void {
 | 
			
		||||
    if (this.mounted) return;
 | 
			
		||||
    this.mounted = true;
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Mount all children
 | 
			
		||||
    for (const child of this.children) {
 | 
			
		||||
      child.mount();
 | 
			
		||||
@@ -270,12 +351,12 @@ export class UIObject {
 | 
			
		||||
  unmount(): void {
 | 
			
		||||
    if (!this.mounted) return;
 | 
			
		||||
    this.mounted = false;
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Unmount all children first
 | 
			
		||||
    for (const child of this.children) {
 | 
			
		||||
      child.unmount();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Run cleanup functions
 | 
			
		||||
    for (const cleanup of this.cleanupFns) {
 | 
			
		||||
      try {
 | 
			
		||||
@@ -293,6 +374,75 @@ export class UIObject {
 | 
			
		||||
  onCleanup(fn: () => void): void {
 | 
			
		||||
    this.cleanupFns.push(fn);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Scroll the container by the given amount
 | 
			
		||||
   * @param deltaX - Horizontal scroll delta
 | 
			
		||||
   * @param deltaY - Vertical scroll delta
 | 
			
		||||
   */
 | 
			
		||||
  scrollBy(deltaX: number, deltaY: number): void {
 | 
			
		||||
    if (this.type !== "scroll-container" || !this.scrollProps) return;
 | 
			
		||||
 | 
			
		||||
    const newScrollX = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX),
 | 
			
		||||
    );
 | 
			
		||||
    const newScrollY = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    this.scrollProps.scrollX = newScrollX;
 | 
			
		||||
    this.scrollProps.scrollY = newScrollY;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Scroll to a specific position
 | 
			
		||||
   * @param x - Horizontal scroll position
 | 
			
		||||
   * @param y - Vertical scroll position
 | 
			
		||||
   */
 | 
			
		||||
  scrollTo(x: number, y: number): void {
 | 
			
		||||
    if (this.type !== "scroll-container" || !this.scrollProps) return;
 | 
			
		||||
 | 
			
		||||
    this.scrollProps.scrollX = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      Math.min(this.scrollProps.maxScrollX, x),
 | 
			
		||||
    );
 | 
			
		||||
    this.scrollProps.scrollY = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      Math.min(this.scrollProps.maxScrollY, y),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Update scroll bounds based on content size
 | 
			
		||||
   * @param contentWidth - Total content width
 | 
			
		||||
   * @param contentHeight - Total content height
 | 
			
		||||
   */
 | 
			
		||||
  updateScrollBounds(contentWidth: number, contentHeight: number): void {
 | 
			
		||||
    if (this.type !== "scroll-container" || !this.scrollProps) return;
 | 
			
		||||
 | 
			
		||||
    this.scrollProps.contentWidth = contentWidth;
 | 
			
		||||
    this.scrollProps.contentHeight = contentHeight;
 | 
			
		||||
    this.scrollProps.maxScrollX = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      contentWidth - this.scrollProps.viewportWidth,
 | 
			
		||||
    );
 | 
			
		||||
    this.scrollProps.maxScrollY = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      contentHeight - this.scrollProps.viewportHeight,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Clamp current scroll position to new bounds
 | 
			
		||||
    this.scrollProps.scrollX = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX),
 | 
			
		||||
    );
 | 
			
		||||
    this.scrollProps.scrollY = Math.max(
 | 
			
		||||
      0,
 | 
			
		||||
      Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
   */
 | 
			
		||||
 
 | 
			
		||||
@@ -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, []);
 | 
			
		||||
 
 | 
			
		||||
@@ -23,19 +23,35 @@ export type ShowProps = {
 | 
			
		||||
  fallback?: UIObject;
 | 
			
		||||
} & Record<string, unknown>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Props for Switch component
 | 
			
		||||
 */
 | 
			
		||||
export type SwitchProps = {
 | 
			
		||||
  /** Optional fallback to show when no Match condition is met */
 | 
			
		||||
  fallback?: UIObject;
 | 
			
		||||
} & Record<string, unknown>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Props for Match component
 | 
			
		||||
 */
 | 
			
		||||
export type MatchProps = {
 | 
			
		||||
  /** Condition accessor - when truthy, this Match will be selected */
 | 
			
		||||
  when: Accessor<boolean>;
 | 
			
		||||
} & Record<string, unknown>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * For component - renders a list of items
 | 
			
		||||
 * Efficiently updates when the array changes
 | 
			
		||||
 * 
 | 
			
		||||
 *
 | 
			
		||||
 * @template T - The type of items in the array
 | 
			
		||||
 * @param props - Props containing the array accessor
 | 
			
		||||
 * @param renderFn - Function to render each item
 | 
			
		||||
 * @returns UIObject representing the list
 | 
			
		||||
 * 
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const [todos, setTodos] = createStore<Todo[]>([]);
 | 
			
		||||
 * 
 | 
			
		||||
 *
 | 
			
		||||
 * For({ each: () => todos },
 | 
			
		||||
 *   (todo, i) => div({ class: "flex flex-row" },
 | 
			
		||||
 *     label({}, () => todo.title),
 | 
			
		||||
@@ -46,24 +62,24 @@ export type ShowProps = {
 | 
			
		||||
 */
 | 
			
		||||
export function For<T>(
 | 
			
		||||
  props: ForProps<T>,
 | 
			
		||||
  renderFn: (item: T, index: Accessor<number>) => UIObject
 | 
			
		||||
  renderFn: (item: T, index: Accessor<number>) => UIObject,
 | 
			
		||||
): UIObject {
 | 
			
		||||
  const container = new UIObject("for", props, []);
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  // Track rendered items
 | 
			
		||||
  let renderedItems: UIObject[] = [];
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Update the list when the array changes
 | 
			
		||||
   */
 | 
			
		||||
  const updateList = () => {
 | 
			
		||||
    const items = props.each();
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Clear old items
 | 
			
		||||
    renderedItems.forEach(item => item.unmount());
 | 
			
		||||
    renderedItems.forEach((item) => item.unmount());
 | 
			
		||||
    container.children = [];
 | 
			
		||||
    renderedItems = [];
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Render new items
 | 
			
		||||
    items.forEach((item, index) => {
 | 
			
		||||
      const indexAccessor = () => index;
 | 
			
		||||
@@ -74,26 +90,26 @@ export function For<T>(
 | 
			
		||||
      rendered.mount();
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  // Create effect to watch for changes
 | 
			
		||||
  createEffect(() => {
 | 
			
		||||
    updateList();
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  return container;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Show component - conditionally renders content
 | 
			
		||||
 * 
 | 
			
		||||
 *
 | 
			
		||||
 * @param props - Props containing condition and optional fallback
 | 
			
		||||
 * @param child - Content to show when condition is true
 | 
			
		||||
 * @returns UIObject representing the conditional content
 | 
			
		||||
 * 
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const [loggedIn, setLoggedIn] = createSignal(false);
 | 
			
		||||
 * 
 | 
			
		||||
 *
 | 
			
		||||
 * Show(
 | 
			
		||||
 *   {
 | 
			
		||||
 *     when: loggedIn,
 | 
			
		||||
@@ -105,21 +121,21 @@ export function For<T>(
 | 
			
		||||
 */
 | 
			
		||||
export function Show(props: ShowProps, child: UIObject): UIObject {
 | 
			
		||||
  const container = new UIObject("show", props, []);
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  let currentChild: UIObject | undefined = undefined;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Update which child is shown based on condition
 | 
			
		||||
   */
 | 
			
		||||
  const updateChild = () => {
 | 
			
		||||
    const condition = props.when();
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Unmount current child
 | 
			
		||||
    if (currentChild !== undefined) {
 | 
			
		||||
      currentChild.unmount();
 | 
			
		||||
      container.removeChild(currentChild);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Mount appropriate child
 | 
			
		||||
    if (condition) {
 | 
			
		||||
      currentChild = child;
 | 
			
		||||
@@ -129,17 +145,115 @@ export function Show(props: ShowProps, child: UIObject): UIObject {
 | 
			
		||||
      currentChild = undefined;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    if (currentChild !== undefined) {
 | 
			
		||||
      container.appendChild(currentChild);
 | 
			
		||||
      currentChild.mount();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  // Create effect to watch for condition changes
 | 
			
		||||
  createEffect(() => {
 | 
			
		||||
    updateChild();
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  return container;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Switch component - renders the first Match whose condition is truthy
 | 
			
		||||
 * Similar to a switch statement or if/else if/else chain
 | 
			
		||||
 *
 | 
			
		||||
 * @param props - Props containing optional fallback
 | 
			
		||||
 * @param matches - Array of Match components to evaluate
 | 
			
		||||
 * @returns UIObject representing the switch statement
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const [status, setStatus] = createSignal("loading");
 | 
			
		||||
 *
 | 
			
		||||
 * Switch(
 | 
			
		||||
 *   { fallback: div({}, "Unknown status") },
 | 
			
		||||
 *   Match({ when: () => status() === "loading" }, div({}, "Loading...")),
 | 
			
		||||
 *   Match({ when: () => status() === "success" }, div({}, "Success!")),
 | 
			
		||||
 *   Match({ when: () => status() === "error" }, div({}, "Error occurred"))
 | 
			
		||||
 * )
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function Switch(props: SwitchProps, ...matches: UIObject[]): UIObject {
 | 
			
		||||
  const container = new UIObject("switch", props, []);
 | 
			
		||||
 | 
			
		||||
  let currentChild: UIObject | undefined = undefined;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Evaluate all Match conditions and show the first truthy one
 | 
			
		||||
   */
 | 
			
		||||
  const updateChild = () => {
 | 
			
		||||
    // Unmount current child
 | 
			
		||||
    if (currentChild !== undefined) {
 | 
			
		||||
      currentChild.unmount();
 | 
			
		||||
      container.removeChild(currentChild);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Find the first Match with a truthy condition
 | 
			
		||||
    for (const match of matches) {
 | 
			
		||||
      if (match.type === "match") {
 | 
			
		||||
        const matchProps = match.props as MatchProps;
 | 
			
		||||
        const condition = matchProps.when();
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
          condition !== undefined &&
 | 
			
		||||
          condition !== null &&
 | 
			
		||||
          condition !== false
 | 
			
		||||
        ) {
 | 
			
		||||
          // This Match's condition is truthy, use it
 | 
			
		||||
          if (match.children.length > 0) {
 | 
			
		||||
            currentChild = match.children[0];
 | 
			
		||||
            container.appendChild(currentChild);
 | 
			
		||||
            currentChild.mount();
 | 
			
		||||
          }
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // No Match condition was truthy, use fallback if available
 | 
			
		||||
    if (props.fallback !== undefined) {
 | 
			
		||||
      currentChild = props.fallback;
 | 
			
		||||
      container.appendChild(currentChild);
 | 
			
		||||
      currentChild.mount();
 | 
			
		||||
    } else {
 | 
			
		||||
      currentChild = undefined;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Create effect to watch for condition changes
 | 
			
		||||
  createEffect(() => {
 | 
			
		||||
    updateChild();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return container;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Match component - represents a single case in a Switch
 | 
			
		||||
 * Should only be used as a child of Switch
 | 
			
		||||
 *
 | 
			
		||||
 * @param props - Props containing the condition
 | 
			
		||||
 * @param child - Content to render when condition is truthy
 | 
			
		||||
 * @returns UIObject representing this match case
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const [color, setColor] = createSignal("red");
 | 
			
		||||
 *
 | 
			
		||||
 * Match({ when: () => color() === "red" },
 | 
			
		||||
 *   div({ class: "text-red" }, "Stop")
 | 
			
		||||
 * )
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function Match(props: MatchProps, child: UIObject): UIObject {
 | 
			
		||||
  const container = new UIObject("match", props, [child]);
 | 
			
		||||
  child.parent = container;
 | 
			
		||||
  return container;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -136,6 +136,37 @@ render(App);
 | 
			
		||||
    - `fallback?: UIObject`: 当 `when` 返回 `false` 时要渲染的组件。
 | 
			
		||||
  - `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)
 | 
			
		||||
@@ -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: <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)
 | 
			
		||||
@@ -367,4 +442,6 @@ pnpm dlx eslint src/**/*.ts
 | 
			
		||||
 | 
			
		||||
# OR
 | 
			
		||||
just lint
 | 
			
		||||
```
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
为ccTUI添加滚动支持,当内容放不下的时候可以使鼠标滚轮滚动查看更多内容,最好能够实现滚动条。
 | 
			
		||||
 
 | 
			
		||||
@@ -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";
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@
 | 
			
		||||
 | 
			
		||||
import { UIObject } from "./UIObject";
 | 
			
		||||
import { Accessor } from "./reactivity";
 | 
			
		||||
import { isScrollContainer } from "./scrollContainer";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get text content from a node (resolving signals if needed)
 | 
			
		||||
@@ -11,50 +12,144 @@ import { Accessor } from "./reactivity";
 | 
			
		||||
function getTextContent(node: UIObject): string {
 | 
			
		||||
  if (node.textContent !== undefined) {
 | 
			
		||||
    if (typeof node.textContent === "function") {
 | 
			
		||||
      return (node.textContent)();
 | 
			
		||||
      return node.textContent();
 | 
			
		||||
    }
 | 
			
		||||
    return node.textContent;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  // For nodes with text children, get their content
 | 
			
		||||
  if (node.children.length > 0 && node.children[0].textContent !== undefined) {
 | 
			
		||||
    const child = node.children[0];
 | 
			
		||||
    if (typeof child.textContent === "function") {
 | 
			
		||||
      return (child.textContent)();
 | 
			
		||||
      return child.textContent();
 | 
			
		||||
    }
 | 
			
		||||
    return child.textContent!;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  return "";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if a position is within the visible area of all scroll container ancestors
 | 
			
		||||
 */
 | 
			
		||||
function isPositionVisible(
 | 
			
		||||
  node: UIObject,
 | 
			
		||||
  screenX: number,
 | 
			
		||||
  screenY: number,
 | 
			
		||||
): boolean {
 | 
			
		||||
  let current = node.parent;
 | 
			
		||||
  while (current) {
 | 
			
		||||
    if (isScrollContainer(current) && current.layout && current.scrollProps) {
 | 
			
		||||
      const { x: containerX, y: containerY } = current.layout;
 | 
			
		||||
      const { viewportWidth, viewportHeight } = current.scrollProps;
 | 
			
		||||
 | 
			
		||||
      // Check if position is within the scroll container's viewport
 | 
			
		||||
      if (
 | 
			
		||||
        screenX < containerX ||
 | 
			
		||||
        screenX >= containerX + viewportWidth ||
 | 
			
		||||
        screenY < containerY ||
 | 
			
		||||
        screenY >= containerY + viewportHeight
 | 
			
		||||
      ) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    current = current.parent;
 | 
			
		||||
  }
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Draw a scrollbar for a scroll container
 | 
			
		||||
 */
 | 
			
		||||
function drawScrollbar(container: UIObject): void {
 | 
			
		||||
  if (
 | 
			
		||||
    !container.layout ||
 | 
			
		||||
    !container.scrollProps ||
 | 
			
		||||
    container.scrollProps.showScrollbar === false
 | 
			
		||||
  ) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { x, y, width, height } = container.layout;
 | 
			
		||||
  const { scrollY, maxScrollY, viewportHeight, contentHeight } =
 | 
			
		||||
    container.scrollProps;
 | 
			
		||||
 | 
			
		||||
  // Only draw vertical scrollbar if content is scrollable
 | 
			
		||||
  if (maxScrollY <= 0) return;
 | 
			
		||||
 | 
			
		||||
  const scrollbarX = x + width - 1; // Position scrollbar at the right edge
 | 
			
		||||
  const scrollbarHeight = height;
 | 
			
		||||
 | 
			
		||||
  // Calculate scrollbar thumb position and size
 | 
			
		||||
  const thumbHeight = Math.max(
 | 
			
		||||
    1,
 | 
			
		||||
    Math.floor((viewportHeight / contentHeight) * scrollbarHeight),
 | 
			
		||||
  );
 | 
			
		||||
  const thumbPosition = Math.floor(
 | 
			
		||||
    (scrollY / maxScrollY) * (scrollbarHeight - thumbHeight),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // Save current colors
 | 
			
		||||
  const [origX, origY] = term.getCursorPos();
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    // Draw scrollbar track
 | 
			
		||||
    term.setTextColor(colors.gray);
 | 
			
		||||
    term.setBackgroundColor(colors.lightGray);
 | 
			
		||||
 | 
			
		||||
    for (let i = 0; i < scrollbarHeight; i++) {
 | 
			
		||||
      term.setCursorPos(scrollbarX, y + i);
 | 
			
		||||
      if (i >= thumbPosition && i < thumbPosition + thumbHeight) {
 | 
			
		||||
        // Draw scrollbar thumb
 | 
			
		||||
        term.setBackgroundColor(colors.gray);
 | 
			
		||||
        term.write(" ");
 | 
			
		||||
      } else {
 | 
			
		||||
        // Draw scrollbar track
 | 
			
		||||
        term.setBackgroundColor(colors.lightGray);
 | 
			
		||||
        term.write(" ");
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  } finally {
 | 
			
		||||
    term.setCursorPos(origX, origY);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Draw a single UI node to the terminal
 | 
			
		||||
 * 
 | 
			
		||||
 *
 | 
			
		||||
 * @param node - The node to draw
 | 
			
		||||
 * @param focused - Whether this node has focus
 | 
			
		||||
 * @param cursorBlinkState - Whether the cursor should be visible (for blinking)
 | 
			
		||||
 */
 | 
			
		||||
function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): void {
 | 
			
		||||
function drawNode(
 | 
			
		||||
  node: UIObject,
 | 
			
		||||
  focused: boolean,
 | 
			
		||||
  cursorBlinkState: boolean,
 | 
			
		||||
): void {
 | 
			
		||||
  if (!node.layout) return;
 | 
			
		||||
  
 | 
			
		||||
  const { x, y, width } = node.layout;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  const { x, y, width, height } = node.layout;
 | 
			
		||||
 | 
			
		||||
  // Check if this node is visible within scroll container viewports
 | 
			
		||||
  if (!isPositionVisible(node, x, y)) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Save cursor position
 | 
			
		||||
  const [origX, origY] = term.getCursorPos();
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    // Default colors that can be overridden by styleProps
 | 
			
		||||
    let textColor = node.styleProps.textColor;
 | 
			
		||||
    const bgColor = node.styleProps.backgroundColor;
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    switch (node.type) {
 | 
			
		||||
      case "label":
 | 
			
		||||
      case "h1":
 | 
			
		||||
      case "h2":
 | 
			
		||||
      case "h3": {
 | 
			
		||||
        const text = getTextContent(node);
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        // Set colors based on heading level (if not overridden by styleProps)
 | 
			
		||||
        if (textColor === undefined) {
 | 
			
		||||
          if (node.type === "h1") {
 | 
			
		||||
@@ -67,18 +162,18 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
 | 
			
		||||
            textColor = colors.white;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        term.setTextColor(textColor);
 | 
			
		||||
        term.setBackgroundColor(bgColor ?? colors.black);
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        term.setCursorPos(x, y);
 | 
			
		||||
        term.write(text.substring(0, width));
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      case "button": {
 | 
			
		||||
        const text = getTextContent(node);
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        // Set colors based on focus (if not overridden by styleProps)
 | 
			
		||||
        if (focused) {
 | 
			
		||||
          term.setTextColor(textColor ?? colors.black);
 | 
			
		||||
@@ -87,15 +182,15 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
 | 
			
		||||
          term.setTextColor(textColor ?? colors.white);
 | 
			
		||||
          term.setBackgroundColor(bgColor ?? colors.gray);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        term.setCursorPos(x, y);
 | 
			
		||||
        term.write(`[${text}]`);
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      case "input": {
 | 
			
		||||
        const type = node.props.type as string | undefined;
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        if (type === "checkbox") {
 | 
			
		||||
          // Draw checkbox
 | 
			
		||||
          let isChecked = false;
 | 
			
		||||
@@ -103,7 +198,7 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
 | 
			
		||||
          if (typeof checkedProp === "function") {
 | 
			
		||||
            isChecked = (checkedProp as Accessor<boolean>)();
 | 
			
		||||
          }
 | 
			
		||||
          
 | 
			
		||||
 | 
			
		||||
          if (focused) {
 | 
			
		||||
            term.setTextColor(textColor ?? colors.black);
 | 
			
		||||
            term.setBackgroundColor(bgColor ?? colors.white);
 | 
			
		||||
@@ -111,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);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										204
									
								
								src/lib/ccTUI/scrollContainer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/lib/ccTUI/scrollContainer.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,204 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Scroll container component for handling scrollable content
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { UIObject } from "./UIObject";
 | 
			
		||||
import { createSignal, createEffect } from "./reactivity";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Props for ScrollContainer component
 | 
			
		||||
 */
 | 
			
		||||
export type ScrollContainerProps = {
 | 
			
		||||
  /** Maximum width of the scroll container viewport */
 | 
			
		||||
  width?: number;
 | 
			
		||||
  /** Maximum height of the scroll container viewport */
 | 
			
		||||
  height?: number;
 | 
			
		||||
  /** Whether to show scrollbars (default: true) */
 | 
			
		||||
  showScrollbar?: boolean;
 | 
			
		||||
  /** CSS-like class names for styling */
 | 
			
		||||
  class?: string;
 | 
			
		||||
  /** Callback when scroll position changes */
 | 
			
		||||
  onScroll?: (scrollX: number, scrollY: number) => void;
 | 
			
		||||
} & Record<string, unknown>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * ScrollContainer component - provides scrollable viewport for content
 | 
			
		||||
 * When content exceeds the container size, scrollbars appear and mouse wheel scrolling is enabled
 | 
			
		||||
 *
 | 
			
		||||
 * @param props - Props containing dimensions and scroll options
 | 
			
		||||
 * @param content - Content to be scrolled
 | 
			
		||||
 * @returns UIObject representing the scroll container
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const [items, setItems] = createStore<string[]>([]);
 | 
			
		||||
 *
 | 
			
		||||
 * ScrollContainer(
 | 
			
		||||
 *   { width: 20, height: 10, showScrollbar: true },
 | 
			
		||||
 *   div({ class: "flex flex-col" },
 | 
			
		||||
 *     For({ each: () => items },
 | 
			
		||||
 *       (item, i) => div({}, item)
 | 
			
		||||
 *     )
 | 
			
		||||
 *   )
 | 
			
		||||
 * )
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function ScrollContainer(
 | 
			
		||||
  props: ScrollContainerProps,
 | 
			
		||||
  content: UIObject,
 | 
			
		||||
): UIObject {
 | 
			
		||||
  const container = new UIObject("scroll-container", props, [content]);
 | 
			
		||||
  content.parent = container;
 | 
			
		||||
 | 
			
		||||
  // Set up scroll properties from props
 | 
			
		||||
  if (container.scrollProps) {
 | 
			
		||||
    container.scrollProps.viewportWidth = props.width ?? 10;
 | 
			
		||||
    container.scrollProps.viewportHeight = props.height ?? 10;
 | 
			
		||||
    container.scrollProps.showScrollbar = props.showScrollbar !== false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Create reactive signals for scroll position
 | 
			
		||||
  const [scrollX, setScrollX] = createSignal(0);
 | 
			
		||||
  const [scrollY, setScrollY] = createSignal(0);
 | 
			
		||||
 | 
			
		||||
  // Update scroll position when signals change
 | 
			
		||||
  createEffect(() => {
 | 
			
		||||
    const x = scrollX();
 | 
			
		||||
    const y = scrollY();
 | 
			
		||||
    container.scrollTo(x, y);
 | 
			
		||||
 | 
			
		||||
    // Call onScroll callback if provided
 | 
			
		||||
    if (props.onScroll && typeof props.onScroll === "function") {
 | 
			
		||||
      props.onScroll(x, y);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Override scroll methods to update signals
 | 
			
		||||
  const originalScrollBy = container.scrollBy.bind(container);
 | 
			
		||||
  const originalScrollTo = container.scrollTo.bind(container);
 | 
			
		||||
 | 
			
		||||
  container.scrollBy = (deltaX: number, deltaY: number): void => {
 | 
			
		||||
    originalScrollBy(deltaX, deltaY);
 | 
			
		||||
    if (container.scrollProps) {
 | 
			
		||||
      setScrollX(container.scrollProps.scrollX);
 | 
			
		||||
      setScrollY(container.scrollProps.scrollY);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  container.scrollTo = (x: number, y: number): void => {
 | 
			
		||||
    originalScrollTo(x, y);
 | 
			
		||||
    if (container.scrollProps) {
 | 
			
		||||
      setScrollX(container.scrollProps.scrollX);
 | 
			
		||||
      setScrollY(container.scrollProps.scrollY);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Expose scroll control methods on the container
 | 
			
		||||
  const containerWithMethods = container as UIObject & {
 | 
			
		||||
    getScrollX: () => number;
 | 
			
		||||
    getScrollY: () => number;
 | 
			
		||||
    setScrollX: (value: number) => void;
 | 
			
		||||
    setScrollY: (value: number) => void;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  containerWithMethods.getScrollX = () => scrollX();
 | 
			
		||||
  containerWithMethods.getScrollY = () => scrollY();
 | 
			
		||||
  containerWithMethods.setScrollX = (value: number) => setScrollX(value);
 | 
			
		||||
  containerWithMethods.setScrollY = (value: number) => setScrollY(value);
 | 
			
		||||
 | 
			
		||||
  return container;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if a UI node is a scroll container
 | 
			
		||||
 * @param node - The UI node to check
 | 
			
		||||
 * @returns True if the node is a scroll container
 | 
			
		||||
 */
 | 
			
		||||
export function isScrollContainer(node: UIObject): boolean {
 | 
			
		||||
  return node.type === "scroll-container";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Find the nearest scroll container ancestor of a node
 | 
			
		||||
 * @param node - The node to start searching from
 | 
			
		||||
 * @returns The nearest scroll container, or undefined if none found
 | 
			
		||||
 */
 | 
			
		||||
export function findScrollContainer(node: UIObject): UIObject | undefined {
 | 
			
		||||
  let current = node.parent;
 | 
			
		||||
  while (current) {
 | 
			
		||||
    if (isScrollContainer(current)) {
 | 
			
		||||
      return current;
 | 
			
		||||
    }
 | 
			
		||||
    current = current.parent;
 | 
			
		||||
  }
 | 
			
		||||
  return undefined;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if a point is within the visible area of a scroll container
 | 
			
		||||
 * @param container - The scroll container
 | 
			
		||||
 * @param x - X coordinate relative to container
 | 
			
		||||
 * @param y - Y coordinate relative to container
 | 
			
		||||
 * @returns True if the point is visible
 | 
			
		||||
 */
 | 
			
		||||
export function isPointVisible(
 | 
			
		||||
  container: UIObject,
 | 
			
		||||
  x: number,
 | 
			
		||||
  y: number,
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (!isScrollContainer(container) || !container.scrollProps) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { scrollX, scrollY, viewportWidth, viewportHeight } =
 | 
			
		||||
    container.scrollProps;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    x >= scrollX &&
 | 
			
		||||
    x < scrollX + viewportWidth &&
 | 
			
		||||
    y >= scrollY &&
 | 
			
		||||
    y < scrollY + viewportHeight
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert screen coordinates to scroll container content coordinates
 | 
			
		||||
 * @param container - The scroll container
 | 
			
		||||
 * @param screenX - Screen X coordinate
 | 
			
		||||
 * @param screenY - Screen Y coordinate
 | 
			
		||||
 * @returns Content coordinates, or undefined if not within container
 | 
			
		||||
 */
 | 
			
		||||
export function screenToContent(
 | 
			
		||||
  container: UIObject,
 | 
			
		||||
  screenX: number,
 | 
			
		||||
  screenY: number,
 | 
			
		||||
): { x: number; y: number } | undefined {
 | 
			
		||||
  if (
 | 
			
		||||
    !isScrollContainer(container) ||
 | 
			
		||||
    !container.layout ||
 | 
			
		||||
    !container.scrollProps
 | 
			
		||||
  ) {
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { x: containerX, y: containerY } = container.layout;
 | 
			
		||||
  const { scrollX, scrollY } = container.scrollProps;
 | 
			
		||||
 | 
			
		||||
  // Check if point is within container bounds
 | 
			
		||||
  const relativeX = screenX - containerX;
 | 
			
		||||
  const relativeY = screenY - containerY;
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
    relativeX < 0 ||
 | 
			
		||||
    relativeY < 0 ||
 | 
			
		||||
    relativeX >= container.scrollProps.viewportWidth ||
 | 
			
		||||
    relativeY >= container.scrollProps.viewportHeight
 | 
			
		||||
  ) {
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    x: relativeX + scrollX,
 | 
			
		||||
    y: relativeY + scrollY,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@@ -15,6 +15,7 @@ import {
 | 
			
		||||
  For,
 | 
			
		||||
  createStore,
 | 
			
		||||
  removeIndex,
 | 
			
		||||
  ScrollContainer,
 | 
			
		||||
} from "../lib/ccTUI";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -99,6 +100,194 @@ const TodosApp = () => {
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Example data type
 | 
			
		||||
 */
 | 
			
		||||
interface ListItem {
 | 
			
		||||
  id: number;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Simple scroll example with a list of items
 | 
			
		||||
 */
 | 
			
		||||
function SimpleScrollExample() {
 | 
			
		||||
  // Create a large list of items to demonstrate scrolling
 | 
			
		||||
  const [items, setItems] = createStore<ListItem[]>([]);
 | 
			
		||||
  const [itemCount, setItemCount] = createSignal(0);
 | 
			
		||||
 | 
			
		||||
  // Generate initial items
 | 
			
		||||
  const generateItems = (count: number) => {
 | 
			
		||||
    const newItems: ListItem[] = [];
 | 
			
		||||
    for (let i = 1; i <= count; i++) {
 | 
			
		||||
      newItems.push({
 | 
			
		||||
        id: i,
 | 
			
		||||
        title: `Item ${i}`,
 | 
			
		||||
        description: `Description for item ${i}`,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    setItems(() => newItems);
 | 
			
		||||
    setItemCount(count);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Initialize with some items
 | 
			
		||||
  generateItems(20);
 | 
			
		||||
 | 
			
		||||
  return div(
 | 
			
		||||
    { class: "flex flex-col h-screen bg-black text-white" },
 | 
			
		||||
 | 
			
		||||
    // Header
 | 
			
		||||
    div(
 | 
			
		||||
      { class: "flex flex-row justify-center bg-blue text-white" },
 | 
			
		||||
      label({}, "Scroll Container Demo"),
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
    // Control buttons
 | 
			
		||||
    div(
 | 
			
		||||
      { class: "flex flex-row justify-center bg-gray" },
 | 
			
		||||
      button(
 | 
			
		||||
        { onClick: () => generateItems(itemCount() + 10) },
 | 
			
		||||
        "Add 10 Items",
 | 
			
		||||
      ),
 | 
			
		||||
      button(
 | 
			
		||||
        { onClick: () => generateItems(Math.max(0, itemCount() - 10)) },
 | 
			
		||||
        "Remove 10 Items",
 | 
			
		||||
      ),
 | 
			
		||||
      button({ onClick: () => generateItems(50) }, "Generate 50 Items"),
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
    // Main scrollable content
 | 
			
		||||
    div(
 | 
			
		||||
      { class: "flex flex-col" },
 | 
			
		||||
      label({}, "Scrollable List:"),
 | 
			
		||||
      ScrollContainer(
 | 
			
		||||
        {
 | 
			
		||||
          width: 40,
 | 
			
		||||
          height: 15,
 | 
			
		||||
          showScrollbar: true,
 | 
			
		||||
        },
 | 
			
		||||
        div(
 | 
			
		||||
          { class: "flex flex-col" },
 | 
			
		||||
          For({ each: items }, (item: ListItem, index) =>
 | 
			
		||||
            div(
 | 
			
		||||
              { class: "flex flex-col" },
 | 
			
		||||
              label({}, () => `${index() + 1}. ${item.title}`),
 | 
			
		||||
              label({}, item.description),
 | 
			
		||||
              label({}, ""), // Empty line for spacing
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
    // Instructions
 | 
			
		||||
    div(
 | 
			
		||||
      { class: "flex flex-col bg-brown text-white" },
 | 
			
		||||
      label({}, "Instructions:"),
 | 
			
		||||
      label({}, "• Use mouse wheel to scroll within the container"),
 | 
			
		||||
      label({}, "• Notice the scrollbar on the right side"),
 | 
			
		||||
      label({}, "• Try adding/removing items to see scroll behavior"),
 | 
			
		||||
    ),
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Example with static long content
 | 
			
		||||
 */
 | 
			
		||||
function StaticScrollExample() {
 | 
			
		||||
  const longText = [
 | 
			
		||||
    "Line 1: This is a demonstration of vertical scrolling.",
 | 
			
		||||
    "Line 2: The content extends beyond the visible area.",
 | 
			
		||||
    "Line 3: Use your mouse wheel to scroll up and down.",
 | 
			
		||||
    "Line 4: Notice how the scrollbar appears on the right.",
 | 
			
		||||
    "Line 5: The scrollbar thumb shows your current position.",
 | 
			
		||||
    "Line 6: This content is much longer than the container.",
 | 
			
		||||
    "Line 7: Keep scrolling to see more lines.",
 | 
			
		||||
    "Line 8: The scroll container handles overflow automatically.",
 | 
			
		||||
    "Line 9: You can also scroll horizontally if content is wide.",
 | 
			
		||||
    "Line 10: This demonstrates the scroll functionality.",
 | 
			
		||||
    "Line 11: More content here to fill the scrollable area.",
 | 
			
		||||
    "Line 12: The framework handles all the complex scroll logic.",
 | 
			
		||||
    "Line 13: Just wrap your content in a ScrollContainer.",
 | 
			
		||||
    "Line 14: Set width and height to define the viewport.",
 | 
			
		||||
    "Line 15: The end! Try scrolling back to the top.",
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  return div(
 | 
			
		||||
    { class: "flex flex-col justify-center items-center h-screen bg-black" },
 | 
			
		||||
    label({}, "Static Scroll Example"),
 | 
			
		||||
 | 
			
		||||
    ScrollContainer(
 | 
			
		||||
      {
 | 
			
		||||
        width: 50,
 | 
			
		||||
        height: 10,
 | 
			
		||||
        showScrollbar: true,
 | 
			
		||||
      },
 | 
			
		||||
      div(
 | 
			
		||||
        { class: "flex flex-col" },
 | 
			
		||||
        ...longText.map((line) => label({}, line)),
 | 
			
		||||
      ),
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
    label({}, "Use mouse wheel to scroll"),
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Example with multiple independent scroll containers
 | 
			
		||||
 */
 | 
			
		||||
function MultiScrollExample() {
 | 
			
		||||
  return div(
 | 
			
		||||
    { class: "flex flex-col h-screen bg-black" },
 | 
			
		||||
    label({}, "Multiple Scroll Containers"),
 | 
			
		||||
 | 
			
		||||
    div(
 | 
			
		||||
      { class: "flex flex-row justify-between" },
 | 
			
		||||
 | 
			
		||||
      // Left container - numbers
 | 
			
		||||
      div(
 | 
			
		||||
        { class: "flex flex-col" },
 | 
			
		||||
        label({}, "Numbers"),
 | 
			
		||||
        ScrollContainer(
 | 
			
		||||
          { width: 15, height: 10 },
 | 
			
		||||
          div(
 | 
			
		||||
            { class: "flex flex-col" },
 | 
			
		||||
            For(
 | 
			
		||||
              {
 | 
			
		||||
                each: () =>
 | 
			
		||||
                  Array.from({ length: 30 }, (_, i) => i + 1) as number[],
 | 
			
		||||
              },
 | 
			
		||||
              (num: number) => label({}, () => `Number: ${num}`),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
      // Right container - letters
 | 
			
		||||
      div(
 | 
			
		||||
        { class: "flex flex-col" },
 | 
			
		||||
        label({}, "Letters"),
 | 
			
		||||
        ScrollContainer(
 | 
			
		||||
          { width: 15, height: 10 },
 | 
			
		||||
          div(
 | 
			
		||||
            { class: "flex flex-col" },
 | 
			
		||||
            For(
 | 
			
		||||
              {
 | 
			
		||||
                each: () => "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as string[],
 | 
			
		||||
              },
 | 
			
		||||
              (letter: string, index) =>
 | 
			
		||||
                label({}, () => `${index() + 1}. Letter ${letter}`),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
    label({}, "Each container scrolls independently"),
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Main application component with tabs
 | 
			
		||||
 */
 | 
			
		||||
@@ -111,11 +300,32 @@ const App = () => {
 | 
			
		||||
      { class: "flex flex-row" },
 | 
			
		||||
      button({ onClick: () => setTabIndex(0) }, "CountDemo"),
 | 
			
		||||
      button({ onClick: () => setTabIndex(1) }, "TodosDemo"),
 | 
			
		||||
      button({ onClick: () => setTabIndex(2) }, "SimpleScroll"),
 | 
			
		||||
      button({ onClick: () => setTabIndex(3) }, "StaticScroll"),
 | 
			
		||||
      button({ onClick: () => setTabIndex(4) }, "MultiScroll"),
 | 
			
		||||
    ),
 | 
			
		||||
    Show(
 | 
			
		||||
      {
 | 
			
		||||
        when: () => tabIndex() === 0,
 | 
			
		||||
        fallback: Show({ when: () => tabIndex() === 1 }, TodosApp()),
 | 
			
		||||
        fallback: Show(
 | 
			
		||||
          { 
 | 
			
		||||
            when: () => tabIndex() === 1,
 | 
			
		||||
            fallback: Show(
 | 
			
		||||
              {
 | 
			
		||||
                when: () => tabIndex() === 2,
 | 
			
		||||
                fallback: Show(
 | 
			
		||||
                  {
 | 
			
		||||
                    when: () => tabIndex() === 3,
 | 
			
		||||
                    fallback: MultiScrollExample(),
 | 
			
		||||
                  },
 | 
			
		||||
                  StaticScrollExample()
 | 
			
		||||
                )
 | 
			
		||||
              },
 | 
			
		||||
              SimpleScrollExample()
 | 
			
		||||
            )
 | 
			
		||||
          }, 
 | 
			
		||||
          TodosApp()
 | 
			
		||||
        ),
 | 
			
		||||
      },
 | 
			
		||||
      Counter(),
 | 
			
		||||
    ),
 | 
			
		||||
@@ -137,4 +347,4 @@ try {
 | 
			
		||||
    print("Error running application:");
 | 
			
		||||
    printError(e);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user