TIMAS_Bro

ui.lya

Oct 24th, 2025
69
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 93.86 KB | None | 0 0
  1. local cliEnabled = false
  2. -- ReactiveUI: A React-like UI framework for ComputerCraft
  3. -- Requires: animgraph library for animations
  4. local ReactiveUI = {}
  5. local AnimGraph = {}
  6. local currentApp = nil
  7. -- ============================================================================
  8. -- ANIMGRAPH INTEGRATION
  9. -- ============================================================================
  10. -- Constants
  11. local FPS = 20 -- ComputerCraft runs at 20 ticks per second
  12. -- Helper function to create animation graph objects
  13. local function createGraph(duration, easingFunc, startVal, endVal)
  14. local totalFrames = math.floor(duration * FPS)
  15. if totalFrames < 1 then totalFrames = 1 end
  16. local hasRange = (startVal ~= nil and endVal ~= nil)
  17. local graph = {}
  18. -- Metatable to handle indexing
  19. setmetatable(graph, {
  20. __index = function(t, frame)
  21. if frame == 0 then
  22. -- Return total frames when indexed with 0
  23. return totalFrames
  24. elseif frame < 0 or frame > totalFrames then
  25. -- Clamp to valid range
  26. frame = math.max(0, math.min(frame, totalFrames))
  27. end
  28. -- Calculate progress (0 to 1)
  29. local progress = frame / totalFrames
  30. -- Apply easing function
  31. local easedProgress = easingFunc(progress)
  32. -- If range specified, interpolate between start and end values
  33. if hasRange then
  34. return startVal + (endVal - startVal) * easedProgress
  35. else
  36. return easedProgress
  37. end
  38. end
  39. })
  40. return graph
  41. end
  42. -- Easing Functions (all return values from 0 to 1)
  43. -- Linear
  44. local function linear(t)
  45. return t
  46. end
  47. -- Ease In (Quadratic)
  48. local function easeInQuad(t)
  49. return t * t
  50. end
  51. -- Ease Out (Quadratic)
  52. local function easeOutQuad(t)
  53. return t * (2 - t)
  54. end
  55. -- Ease In-Out (Quadratic)
  56. local function easeInOutQuad(t)
  57. if t < 0.5 then
  58. return 2 * t * t
  59. else
  60. return -1 + (4 - 2 * t) * t
  61. end
  62. end
  63. -- Ease In (Cubic)
  64. local function easeInCubic(t)
  65. return t * t * t
  66. end
  67. -- Ease Out (Cubic)
  68. local function easeOutCubic(t)
  69. local t1 = t - 1
  70. return t1 * t1 * t1 + 1
  71. end
  72. -- Ease In-Out (Cubic)
  73. local function easeInOutCubic(t)
  74. if t < 0.5 then
  75. return 4 * t * t * t
  76. else
  77. local t1 = 2 * t - 2
  78. return (t1 * t1 * t1 + 2) / 2
  79. end
  80. end
  81. -- Ease In (Quartic)
  82. local function easeInQuart(t)
  83. return t * t * t * t
  84. end
  85. -- Ease Out (Quartic)
  86. local function easeOutQuart(t)
  87. local t1 = t - 1
  88. return 1 - t1 * t1 * t1 * t1
  89. end
  90. -- Ease In-Out (Quartic)
  91. local function easeInOutQuart(t)
  92. if t < 0.5 then
  93. return 8 * t * t * t * t
  94. else
  95. local t1 = t - 1
  96. return 1 - 8 * t1 * t1 * t1 * t1
  97. end
  98. end
  99. -- Ease In (Quintic)
  100. local function easeInQuint(t)
  101. return t * t * t * t * t
  102. end
  103. -- Ease Out (Quintic)
  104. local function easeOutQuint(t)
  105. local t1 = t - 1
  106. return t1 * t1 * t1 * t1 * t1 + 1
  107. end
  108. -- Ease In-Out (Quintic)
  109. local function easeInOutQuint(t)
  110. if t < 0.5 then
  111. return 16 * t * t * t * t * t
  112. else
  113. local t1 = 2 * t - 2
  114. return (t1 * t1 * t1 * t1 * t1 + 2) / 2
  115. end
  116. end
  117. -- Ease In (Sine)
  118. local function easeInSine(t)
  119. return 1 - math.cos(t * math.pi / 2)
  120. end
  121. -- Ease Out (Sine)
  122. local function easeOutSine(t)
  123. return math.sin(t * math.pi / 2)
  124. end
  125. -- Ease In-Out (Sine)
  126. local function easeInOutSine(t)
  127. return -(math.cos(math.pi * t) - 1) / 2
  128. end
  129. -- Ease In (Exponential)
  130. local function easeInExpo(t)
  131. if t == 0 then return 0 end
  132. return math.pow(2, 10 * (t - 1))
  133. end
  134. -- Ease Out (Exponential)
  135. local function easeOutExpo(t)
  136. if t == 1 then return 1 end
  137. return 1 - math.pow(2, -10 * t)
  138. end
  139. -- Ease In-Out (Exponential)
  140. local function easeInOutExpo(t)
  141. if t == 0 then return 0 end
  142. if t == 1 then return 1 end
  143. if t < 0.5 then
  144. return math.pow(2, 20 * t - 10) / 2
  145. else
  146. return (2 - math.pow(2, -20 * t + 10)) / 2
  147. end
  148. end
  149. -- Ease In (Circular)
  150. local function easeInCirc(t)
  151. return 1 - math.sqrt(1 - t * t)
  152. end
  153. -- Ease Out (Circular)
  154. local function easeOutCirc(t)
  155. local t1 = t - 1
  156. return math.sqrt(1 - t1 * t1)
  157. end
  158. -- Ease In-Out (Circular)
  159. local function easeInOutCirc(t)
  160. if t < 0.5 then
  161. return (1 - math.sqrt(1 - 4 * t * t)) / 2
  162. else
  163. local t1 = -2 * t + 2
  164. return (math.sqrt(1 - t1 * t1) + 1) / 2
  165. end
  166. end
  167. -- Ease In (Back - overshoots)
  168. local function easeInBack(t)
  169. local c = 1.70158
  170. return t * t * ((c + 1) * t - c)
  171. end
  172. -- Ease Out (Back - overshoots)
  173. local function easeOutBack(t)
  174. local c = 1.70158
  175. local t1 = t - 1
  176. return 1 + t1 * t1 * ((c + 1) * t1 + c)
  177. end
  178. -- Ease In-Out (Back - overshoots)
  179. local function easeInOutBack(t)
  180. local c = 1.70158 * 1.525
  181. if t < 0.5 then
  182. local t2 = 2 * t
  183. return (t2 * t2 * ((c + 1) * t2 - c)) / 2
  184. else
  185. local t2 = 2 * t - 2
  186. return (t2 * t2 * ((c + 1) * t2 + c) + 2) / 2
  187. end
  188. end
  189. -- Ease In (Elastic - bouncy)
  190. local function easeInElastic(t)
  191. if t == 0 then return 0 end
  192. if t == 1 then return 1 end
  193. local c = (2 * math.pi) / 3
  194. return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c)
  195. end
  196. -- Ease Out (Elastic - bouncy)
  197. local function easeOutElastic(t)
  198. if t == 0 then return 0 end
  199. if t == 1 then return 1 end
  200. local c = (2 * math.pi) / 3
  201. return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c) + 1
  202. end
  203. -- Ease In-Out (Elastic - bouncy)
  204. local function easeInOutElastic(t)
  205. if t == 0 then return 0 end
  206. if t == 1 then return 1 end
  207. local c = (2 * math.pi) / 4.5
  208. if t < 0.5 then
  209. return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c)) / 2
  210. else
  211. return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c)) / 2 + 1
  212. end
  213. end
  214. -- Ease Out (Bounce)
  215. local function easeOutBounce(t)
  216. local n1 = 7.5625
  217. local d1 = 2.75
  218. if t < 1 / d1 then
  219. return n1 * t * t
  220. elseif t < 2 / d1 then
  221. t = t - 1.5 / d1
  222. return n1 * t * t + 0.75
  223. elseif t < 2.5 / d1 then
  224. t = t - 2.25 / d1
  225. return n1 * t * t + 0.9375
  226. else
  227. t = t - 2.625 / d1
  228. return n1 * t * t + 0.984375
  229. end
  230. end
  231. -- Ease In (Bounce)
  232. local function easeInBounce(t)
  233. return 1 - easeOutBounce(1 - t)
  234. end
  235. -- Ease In-Out (Bounce)
  236. local function easeInOutBounce(t)
  237. if t < 0.5 then
  238. return (1 - easeOutBounce(1 - 2 * t)) / 2
  239. else
  240. return (1 + easeOutBounce(2 * t - 1)) / 2
  241. end
  242. end
  243. -- Public API
  244. AnimGraph.linear = function(duration, startVal, endVal) return createGraph(duration, linear, startVal, endVal) end
  245. AnimGraph.easein = function(duration, startVal, endVal) return createGraph(duration, easeInQuad, startVal, endVal) end
  246. AnimGraph.easeout = function(duration, startVal, endVal) return createGraph(duration, easeOutQuad, startVal, endVal) end
  247. AnimGraph.easeinout = function(duration, startVal, endVal) return createGraph(duration, easeInOutQuad, startVal, endVal) end
  248. AnimGraph.easeinCubic = function(duration, startVal, endVal) return createGraph(duration, easeInCubic, startVal, endVal) end
  249. AnimGraph.easeoutCubic = function(duration, startVal, endVal) return createGraph(duration, easeOutCubic, startVal, endVal) end
  250. AnimGraph.easeinoutCubic = function(duration, startVal, endVal)
  251. return createGraph(duration, easeInOutCubic, startVal,
  252. endVal)
  253. end
  254. AnimGraph.easeinQuart = function(duration, startVal, endVal) return createGraph(duration, easeInQuart, startVal, endVal) end
  255. AnimGraph.easeoutQuart = function(duration, startVal, endVal) return createGraph(duration, easeOutQuart, startVal, endVal) end
  256. AnimGraph.easeinoutQuart = function(duration, startVal, endVal)
  257. return createGraph(duration, easeInOutQuart, startVal,
  258. endVal)
  259. end
  260. AnimGraph.easeinQuint = function(duration, startVal, endVal) return createGraph(duration, easeInQuint, startVal, endVal) end
  261. AnimGraph.easeoutQuint = function(duration, startVal, endVal) return createGraph(duration, easeOutQuint, startVal, endVal) end
  262. AnimGraph.easeinoutQuint = function(duration, startVal, endVal)
  263. return createGraph(duration, easeInOutQuint, startVal,
  264. endVal)
  265. end
  266. AnimGraph.easeinSine = function(duration, startVal, endVal) return createGraph(duration, easeInSine, startVal, endVal) end
  267. AnimGraph.easeoutSine = function(duration, startVal, endVal) return createGraph(duration, easeOutSine, startVal, endVal) end
  268. AnimGraph.easeinoutSine = function(duration, startVal, endVal)
  269. return createGraph(duration, easeInOutSine, startVal,
  270. endVal)
  271. end
  272. AnimGraph.easeinExpo = function(duration, startVal, endVal) return createGraph(duration, easeInExpo, startVal, endVal) end
  273. AnimGraph.easeoutExpo = function(duration, startVal, endVal) return createGraph(duration, easeOutExpo, startVal, endVal) end
  274. AnimGraph.easeinoutExpo = function(duration, startVal, endVal)
  275. return createGraph(duration, easeInOutExpo, startVal,
  276. endVal)
  277. end
  278. AnimGraph.easeinCirc = function(duration, startVal, endVal) return createGraph(duration, easeInCirc, startVal, endVal) end
  279. AnimGraph.easeoutCirc = function(duration, startVal, endVal) return createGraph(duration, easeOutCirc, startVal, endVal) end
  280. AnimGraph.easeinoutCirc = function(duration, startVal, endVal)
  281. return createGraph(duration, easeInOutCirc, startVal,
  282. endVal)
  283. end
  284. AnimGraph.easeinBack = function(duration, startVal, endVal) return createGraph(duration, easeInBack, startVal, endVal) end
  285. AnimGraph.easeoutBack = function(duration, startVal, endVal) return createGraph(duration, easeOutBack, startVal, endVal) end
  286. AnimGraph.easeinoutBack = function(duration, startVal, endVal)
  287. return createGraph(duration, easeInOutBack, startVal,
  288. endVal)
  289. end
  290. AnimGraph.easeinElastic = function(duration, startVal, endVal)
  291. return createGraph(duration, easeInElastic, startVal,
  292. endVal)
  293. end
  294. AnimGraph.easeoutElastic = function(duration, startVal, endVal)
  295. return createGraph(duration, easeOutElastic, startVal,
  296. endVal)
  297. end
  298. AnimGraph.easeinoutElastic = function(duration, startVal, endVal)
  299. return createGraph(duration, easeInOutElastic, startVal,
  300. endVal)
  301. end
  302. AnimGraph.easeinBounce = function(duration, startVal, endVal) return createGraph(duration, easeInBounce, startVal, endVal) end
  303. AnimGraph.easeoutBounce = function(duration, startVal, endVal)
  304. return createGraph(duration, easeOutBounce, startVal,
  305. endVal)
  306. end
  307. AnimGraph.easeinoutBounce = function(duration, startVal, endVal)
  308. return createGraph(duration, easeInOutBounce, startVal,
  309. endVal)
  310. end
  311. -- Utility: Create custom easing function
  312. function AnimGraph.custom(duration, easingFunc, startVal, endVal)
  313. return createGraph(duration, easingFunc, startVal, endVal)
  314. end
  315. -- Utility: Set FPS (default is 20 for ComputerCraft)
  316. function AnimGraph.setFPS(i)
  317. FPS = fps
  318. end
  319. ReactiveUI.animGraph = AnimGraph
  320. -- ============================================================================
  321. -- CORE STATE MANAGEMENT
  322. -- ============================================================================
  323. local currentComponent = nil
  324. local componentStack = {}
  325. local globalState = {}
  326. local globalStore = {}
  327. local routerState = {
  328. currentRoute = "/",
  329. routes = {},
  330. history = {}
  331. }
  332. local roundedEnabled = false
  333. local colorMap = {
  334. black = colors.black,
  335. white = colors.white,
  336. gray = colors.gray,
  337. grey = colors.gray,
  338. red = colors.red,
  339. green = colors.green,
  340. yellow = colors.yellow,
  341. blue = colors.blue,
  342. magenta = colors.magenta,
  343. cyan = colors.cyan,
  344. orange = colors.orange,
  345. lightBlue = colors.lightBlue,
  346. lightGray = colors.lightGray,
  347. brown = colors.brown,
  348. lime = colors.lime,
  349. pink = colors.pink
  350. }
  351. -- ============================================================================
  352. -- STYLING ENGINE
  353. -- ============================================================================
  354. local function mergeStyles(base, override)
  355. local result = {}
  356. for k, v in pairs(base or {}) do
  357. result[k] = v
  358. end
  359. for k, v in pairs(override or {}) do
  360. result[k] = v
  361. end
  362. return result
  363. end
  364. local function parseClass(cls, isMd, isHover)
  365. local style = {}
  366. if cls == "rounded" then
  367. style.rounded = true
  368. elseif cls:match("^bg%-(%w+)$") then
  369. local colName = cls:match("^bg%-(%w+)$")
  370. style.bg = colorMap[colName] or colors.black
  371. elseif cls:match("^text%-(%w+)$") then
  372. local colName = cls:match("^text%-(%w+)$")
  373. style.fg = colorMap[colName] or colors.white
  374. elseif cls:match("^w%-(%d+)$") then
  375. local sz = tonumber(cls:match("^w%-(%d+)$"))
  376. style.width = sz
  377. elseif cls:match("^h%-(%d+)$") then
  378. local sz = tonumber(cls:match("^h%-(%d+)$"))
  379. style.height = sz
  380. elseif cls:match("^p%-(%d+)$") then
  381. local sz = tonumber(cls:match("^p%-(%d+)$"))
  382. style.padding = sz
  383. elseif cls:match("^animate%-pulse$") then
  384. style.animate = "pulse"
  385. end
  386. return style
  387. end
  388. local function parseClasses(classStr, screenW)
  389. local base = {}
  390. local hover = {}
  391. local focus = {}
  392. local active = {}
  393. if classStr then
  394. for cls in classStr:gmatch("%S+") do
  395. local target = base
  396. if cls:match("^md:") then
  397. if screenW < 40 then goto continue end
  398. cls = cls:sub(4)
  399. elseif cls:match("^hover:") then
  400. target = hover
  401. cls = cls:sub(7)
  402. end
  403. local s = parseClass(cls, true, target == hover)
  404. for k, v in pairs(s) do
  405. target[k] = v
  406. end
  407. ::continue::
  408. end
  409. end
  410. return base, hover, focus, active
  411. end
  412. local function applyStyle(x, y, w, h, style)
  413. local borderSize = style.border and 1 or 0
  414. local pad = style.padding or 0
  415. local innerX = x + borderSize + pad
  416. local innerY = y + borderSize + pad
  417. local innerW = w - 2 * borderSize - 2 * pad
  418. local innerH = h - 2 * borderSize - 2 * pad
  419. if innerW < 1 then innerW = 1 end
  420. if innerH < 1 then innerH = 1 end
  421. if style.bg then
  422. term.setBackgroundColor(style.bg)
  423. end
  424. if style.fg then
  425. term.setTextColor(style.fg)
  426. end
  427. -- Fill background
  428. if style.bg then
  429. for dy = 0, h - 1 do
  430. term.setCursorPos(x, y + dy)
  431. term.write(string.rep(" ", w))
  432. end
  433. end
  434. -- Border
  435. if style.border then
  436. term.setTextColor(style.borderColor or colors.gray)
  437. local topStr, bottomStr, sideLeft, sideRight
  438. if style.rounded and roundedEnabled then
  439. topStr = "╭" .. string.rep("─", w - 2) .. "╮"
  440. bottomStr = "╰" .. string.rep("─", w - 2) .. "╯"
  441. sideLeft = "│"
  442. sideRight = "│"
  443. else
  444. topStr = string.rep("-", w)
  445. bottomStr = string.rep("-", w)
  446. sideLeft = "|"
  447. sideRight = "|"
  448. end
  449. term.setCursorPos(x, y)
  450. term.write(topStr)
  451. term.setCursorPos(x, y + h - 1)
  452. term.write(bottomStr)
  453. for dy = 1, h - 2 do
  454. term.setCursorPos(x, y + dy)
  455. term.write(sideLeft)
  456. term.setCursorPos(x + w - 1, y + dy)
  457. term.write(sideRight)
  458. end
  459. end
  460. return innerX, innerY, innerW, innerH
  461. end
  462. -- ============================================================================
  463. -- ELEMENT SYSTEM
  464. -- ============================================================================
  465. local Element = {}
  466. Element.__index = Element
  467. function Element.new(type, props)
  468. local self = setmetatable({}, Element)
  469. self.type = type
  470. self.props = props or {}
  471. self.id = props.id
  472. self.transitions = props.transitions or {}
  473. self.activeAnimations = {}
  474. local screenW, _ = term.getSize()
  475. local baseCls, hoverCls, focusCls, activeCls = parseClasses(props.class, screenW)
  476. self.style = mergeStyles(props.style or baseCls, baseCls)
  477. if type == "input" and (not self.style.fg) then
  478. self.style.fg = colors.black
  479. end
  480. self.hoverStyle = mergeStyles(props.hoverStyle or {}, hoverCls)
  481. self.activeStyle = mergeStyles(props.activeStyle or {}, activeCls)
  482. self.focusStyle = mergeStyles(props.focusStyle or {}, focusCls)
  483. self.children = props.children or {}
  484. self.dragging = false
  485. self.dragType = nil
  486. self.parent = nil
  487. self.mounted = false
  488. self.hovered = false
  489. self.active = false
  490. self.focused = false
  491. self.cursorPos = (type == "input") and 0 or nil
  492. self.animations = {}
  493. self.targetScroll = nil
  494. -- Initialize current values from props
  495. self.x = props.x or 1
  496. self.y = props.y or 1
  497. self.width = self.style.width or props.width or 10
  498. self.height = self.style.height or props.height or 3
  499. -- Set parent for children
  500. for _, child in ipairs(self.children) do
  501. if type(child) == "table" and child.setParent then
  502. child:setParent(self)
  503. end
  504. end
  505. return self
  506. end
  507. function Element:setParent(parent)
  508. self.parent = parent
  509. self:updateAbsolutePosition()
  510. end
  511. function Element:updateAbsolutePosition()
  512. if self.parent then
  513. -- Calculate absolute position based on parent
  514. local parentBorder = self.parent.style.border and 1 or 0
  515. local parentPad = self.parent.style.padding or 0
  516. self.x = self.parent.x + parentBorder + parentPad + (self.props.x or 0)
  517. self.y = self.parent.y + parentBorder + parentPad + (self.props.y or 0)
  518. else
  519. self.x = self.props.x or 1
  520. self.y = self.props.y or 1
  521. end
  522. -- Update children positions
  523. for _, child in ipairs(self.children) do
  524. if type(child) == "table" and child.updateAbsolutePosition then
  525. child:updateAbsolutePosition()
  526. end
  527. end
  528. end
  529. function Element:updateFromProps(props)
  530. self.props = props
  531. local screenW, _ = term.getSize()
  532. local baseCls, hoverCls, focusCls, activeCls = parseClasses(props.class, screenW)
  533. self.style = mergeStyles(props.style or baseCls, baseCls)
  534. if self.type == "input" and (not self.style.fg) then
  535. self.style.fg = colors.black
  536. end
  537. self.hoverStyle = mergeStyles(props.hoverStyle or {}, hoverCls)
  538. self.activeStyle = mergeStyles(props.activeStyle or {}, activeCls)
  539. self.focusStyle = mergeStyles(props.focusStyle or {}, focusCls)
  540. self.width = self.style.width or props.width or 10
  541. self.height = self.style.height or props.height or 3
  542. end
  543. function Element:isPointInside(px, py)
  544. return px >= self.x and px < self.x + self.width and
  545. py >= self.y and py < self.y + self.height
  546. end
  547. function Element:getCurrentStyle()
  548. local style = self.style
  549. if self.focused and next(self.focusStyle) then
  550. style = mergeStyles(style, self.focusStyle)
  551. elseif self.active and next(self.activeStyle) then
  552. style = mergeStyles(style, self.activeStyle)
  553. elseif self.hovered and next(self.hoverStyle) then
  554. style = mergeStyles(style, self.hoverStyle)
  555. end
  556. return style
  557. end
  558. function Element:render(scrollOffset, offsetX, offsetY)
  559. scrollOffset = scrollOffset or 0
  560. offsetX = offsetX or 0
  561. offsetY = offsetY or 0
  562. local style = self:getCurrentStyle()
  563. -- Apply scroll offset to rendering position
  564. local renderY = math.floor(self.y + 0.5) + offsetY - scrollOffset
  565. local renderX = math.floor(self.x + 0.5) + offsetX
  566. local innerX, innerY, innerW, innerH = applyStyle(renderX, renderY, self.width, self.height, style)
  567. if self.type == "box" or self.type == "container" or self.type == "scrollview" then
  568. elseif self.type == "text" then
  569. term.setCursorPos(renderX, renderY)
  570. term.setTextColor(style.fg or colors.white)
  571. term.setBackgroundColor(style.bg or colors.black)
  572. term.write(self.props.text or "")
  573. elseif self.type == "button" then
  574. local text = self.props.text or "Button"
  575. local textX = innerX + math.floor((innerW - #text) / 2)
  576. local textY = innerY + math.floor(innerH / 2)
  577. term.setCursorPos(textX, textY)
  578. term.setTextColor(style.fg or colors.white)
  579. term.write(text)
  580. elseif self.type == "input" then
  581. local text = self.props.value or ""
  582. local displayText = text
  583. local textColor = style.fg or colors.white
  584. if text == "" then
  585. displayText = self.props.placeholder or ""
  586. textColor = colors.gray
  587. else
  588. if self.props.password then
  589. displayText = string.rep("*", #text)
  590. end
  591. end
  592. displayText = string.sub(displayText, 1, innerW)
  593. term.setCursorPos(innerX, innerY)
  594. term.setTextColor(textColor)
  595. term.setBackgroundColor(style.bg or colors.white)
  596. term.write(displayText)
  597. if #displayText < innerW then
  598. term.write(string.rep(" ", innerW - #displayText))
  599. end
  600. elseif self.type == "checkbox" then
  601. local checked = self.props.checked or false
  602. local checkChar = checked and "X" or " "
  603. local label = self.props.label or ""
  604. term.setCursorPos(innerX, innerY)
  605. term.setTextColor(style.fg or colors.white)
  606. term.write("[" .. checkChar .. "] " .. label)
  607. elseif self.type == "radio" then
  608. local selected = self.props.selected or false
  609. local selectChar = selected and "*" or " "
  610. local label = self.props.label or ""
  611. term.setCursorPos(innerX, innerY)
  612. term.setTextColor(style.fg or colors.white)
  613. term.write("(" .. selectChar .. ") " .. label)
  614. elseif self.type == "slider" then
  615. local value = self.props.value or 0
  616. local min = self.props.min or 0
  617. local max = self.props.max or 100
  618. local sliderWidth = innerW
  619. local normalizedValue = (value - min) / (max - min)
  620. local sliderPos = math.floor(normalizedValue * sliderWidth)
  621. term.setCursorPos(innerX, innerY)
  622. term.setTextColor(style.fg or colors.white)
  623. term.write("[" .. string.rep("-", sliderPos) .. "O" .. string.rep("-", sliderWidth - sliderPos - 1) .. "]")
  624. if self.props.showValue then
  625. term.setCursorPos(innerX + innerW - #tostring(value) - 1, innerY)
  626. term.write(tostring(value))
  627. end
  628. elseif self.type == "file" then
  629. local filename = self.props.value or ""
  630. local displayText = filename ~= "" and filename or (self.props.placeholder or "No file selected")
  631. local textColor = filename ~= "" and (style.fg or colors.white) or colors.gray
  632. displayText = string.sub(displayText, 1, innerW - 8)
  633. term.setCursorPos(innerX, innerY)
  634. term.setTextColor(textColor)
  635. term.write(displayText)
  636. -- Browse button
  637. local browseX = renderX + self.width - 8
  638. term.setCursorPos(browseX, innerY)
  639. term.setTextColor(colors.white)
  640. term.setBackgroundColor(colors.blue)
  641. term.write("Browse")
  642. elseif self.type == "list" then
  643. if self.targetScroll then
  644. local current = self.props.scroll or 0
  645. local delta = self.targetScroll - current
  646. if math.abs(delta) < 1 then
  647. self.props.scroll = self.targetScroll
  648. self.targetScroll = nil
  649. else
  650. self.props.scroll = current + math.floor(delta * 0.3)
  651. end
  652. end
  653. local scroll = self.props.scroll or 0
  654. local borderOffset = (style.border and 2 or 0)
  655. local visibleHeight = innerH
  656. local displayWidth = innerW - 1
  657. for i = 1, visibleHeight do
  658. local itemIndex = scroll + i
  659. if itemIndex <= # (self.props.items or {}) then
  660. local item = (self.props.items or {})[itemIndex]
  661. local itemY = innerY + i - 1
  662. term.setCursorPos(innerX, itemY)
  663. local selectedIndex = self.props.selectedIndex or nil
  664. if itemIndex == selectedIndex then
  665. term.setBackgroundColor(style.selectedBg or colors.blue)
  666. term.setTextColor(style.selectedFg or colors.white)
  667. else
  668. term.setBackgroundColor(style.bg or colors.black)
  669. term.setTextColor(style.fg or colors.white)
  670. end
  671. local displayText = tostring(item)
  672. if #displayText > displayWidth then
  673. displayText = string.sub(displayText, 1, displayWidth - 3) .. "..."
  674. else
  675. displayText = displayText .. string.rep(" ", displayWidth - #displayText)
  676. end
  677. term.write(displayText)
  678. end
  679. end
  680. local totalItems = #(self.props.items or {})
  681. if totalItems > visibleHeight then
  682. local barX = renderX + self.width - 1
  683. local maxScroll = totalItems - visibleHeight
  684. local thumbHeight = math.max(1, math.floor((visibleHeight / totalItems) * visibleHeight))
  685. local thumbTop = math.floor((scroll / maxScroll) * (visibleHeight - thumbHeight)) + 1
  686. for i = 1, visibleHeight do
  687. term.setCursorPos(barX, innerY + i - 1)
  688. if i >= thumbTop and i < thumbTop + thumbHeight then
  689. term.setBackgroundColor(colors.lightGray)
  690. term.setTextColor(colors.white)
  691. term.write("█")
  692. else
  693. term.setBackgroundColor(colors.black)
  694. term.setTextColor(colors.gray)
  695. term.write("│")
  696. end
  697. end
  698. end
  699. end
  700. -- Render children with the same scroll offset and offsets
  701. for _, child in ipairs(self.children) do
  702. if child.render then
  703. child:render(scrollOffset, offsetX, offsetY)
  704. end
  705. end
  706. end
  707. function Element:focus()
  708. self.focused = true
  709. if self.type == "input" then
  710. local text = self.props.value or ""
  711. self.cursorPos = #text
  712. end
  713. if self.props.onFocus then
  714. self.props.onFocus(self)
  715. end
  716. end
  717. function Element:blur()
  718. if not self.focused then return end
  719. self.focused = false
  720. if self.props.onBlur then
  721. self.props.onBlur(self)
  722. end
  723. end
  724. function Element:handleClick(x, y, button, app)
  725. -- Check children first (top to bottom)
  726. for i = #self.children, 1, -1 do
  727. local child = self.children[i]
  728. if child.handleClick and child:handleClick(x, y, button, app) then
  729. return true
  730. end
  731. end
  732. if self:isPointInside(x, y) then
  733. self.active = true
  734. -- Handle focus for focusable elements
  735. if self.type == "input" then
  736. -- Unfocus all other elements
  737. if app then
  738. local allElements = app:getAllElements()
  739. for _, el in ipairs(allElements) do
  740. if el ~= self and el.focused then
  741. el:blur()
  742. end
  743. end
  744. end
  745. -- Focus this element
  746. if not self.focused then
  747. self:focus()
  748. end
  749. -- Set cursor position
  750. local text = self.props.value or ""
  751. local relPos = math.max(0, math.min(#text, x - self.x - 1))
  752. self.cursorPos = relPos
  753. elseif self.type == "checkbox" then
  754. self.props.checked = not (self.props.checked or false)
  755. if self.props.onChange then
  756. self.props.onChange(self.props.checked)
  757. end
  758. elseif self.type == "radio" then
  759. if not (self.props.selected or false) then
  760. if self.props.group and app then
  761. local allEls = app:getAllElements()
  762. for _, other in ipairs(allEls) do
  763. if other.type == "radio" and other.props.group == self.props.group and other.props.selected then
  764. other.props.selected = false
  765. end
  766. end
  767. end
  768. self.props.selected = true
  769. if self.props.onChange then
  770. self.props.onChange(self.props.value)
  771. end
  772. end
  773. elseif self.type == "slider" then
  774. local innerW = self.width - 4
  775. local clickPos = x - self.x - 2
  776. if clickPos >= 0 and clickPos <= innerW then
  777. local min = self.props.min or 0
  778. local max = self.props.max or 100
  779. local value = min + (clickPos / innerW) * (max - min)
  780. self.props.value = math.floor(value + 0.5)
  781. self.dragging = true
  782. self.dragType = "slider"
  783. if self.props.onChange then
  784. self.props.onChange(self.props.value)
  785. end
  786. end
  787. elseif self.type == "file" then
  788. -- Check if browse button was clicked
  789. if x >= self.x + self.width - 8 and x < self.x + self.width - 1 then
  790. if self.props.onBrowse then
  791. self.props.onBrowse(self)
  792. end
  793. end
  794. elseif self.type == "list" then
  795. local innerH = self.height - (self.style.border and 2 or 0)
  796. local clickY = y - self.y - (self.style.border and 1 or 0)
  797. local scroll = self.props.scroll or 0
  798. local itemIndex = scroll + clickY
  799. if itemIndex >= 1 and itemIndex <= #(self.props.items or {}) then
  800. self.props.selectedIndex = itemIndex
  801. if self.props.onSelect then
  802. self.props.onSelect(itemIndex, self.props.items[itemIndex])
  803. end
  804. end
  805. end
  806. -- Call custom onClick if provided (allows user override)
  807. if self.props.onClick then
  808. self.props.onClick(self, x, y, button)
  809. end
  810. return true
  811. end
  812. return false
  813. end
  814. function Element:handleHover(x, y)
  815. -- Update children hover state
  816. if self.dragging and self.dragType == "slider" and self.type == "slider" then
  817. local innerW = self.width - 4
  818. local clickPos = x - self.x - 2
  819. clickPos = math.max(0, math.min(clickPos, innerW))
  820. local min = self.props.min or 0
  821. local max = self.props.max or 100
  822. local value = min + (clickPos / innerW) * (max - min)
  823. self.props.value = math.floor(value + 0.5)
  824. if self.props.onChange then
  825. self.props.onChange(self.props.value)
  826. end
  827. end
  828. -- Update children hover state
  829. for _, child in ipairs(self.children) do
  830. if child.handleHover then
  831. child:handleHover(x, y)
  832. end
  833. end
  834. local wasHovered = self.hovered
  835. self.hovered = self:isPointInside(x, y)
  836. if self.hovered and not wasHovered and self.props.onHover then
  837. self.props.onHover(self)
  838. elseif not self.hovered and wasHovered and self.props.onHoverEnd then
  839. self.props.onHoverEnd(self)
  840. end
  841. end
  842. function Element:handleKey(key)
  843. if self.focused and self.type == "input" then
  844. local text = self.props.value or ""
  845. local handled = false
  846. if key == keys.backspace then
  847. if self.cursorPos > 0 then
  848. text = text:sub(1, self.cursorPos - 1) .. text:sub(self.cursorPos + 1)
  849. self.cursorPos = self.cursorPos - 1
  850. self.props.value = text
  851. handled = true
  852. end
  853. elseif key == keys.delete then
  854. if self.cursorPos < #text then
  855. text = text:sub(1, self.cursorPos) .. text:sub(self.cursorPos + 2)
  856. self.props.value = text
  857. handled = true
  858. end
  859. elseif key == keys.left then
  860. self.cursorPos = math.max(0, self.cursorPos - 1)
  861. handled = true
  862. elseif key == keys.right then
  863. self.cursorPos = math.min(#text, self.cursorPos + 1)
  864. handled = true
  865. elseif key == keys.home then
  866. self.cursorPos = 0
  867. handled = true
  868. elseif key == keys["end"] then
  869. self.cursorPos = #text
  870. handled = true
  871. elseif key == keys.enter then
  872. if self.props.onSubmit then
  873. self.props.onSubmit(text)
  874. end
  875. -- Unfocus on Enter
  876. self:blur()
  877. handled = true
  878. elseif key == keys.tab then
  879. -- Tab to next focusable element (handled by app)
  880. handled = true
  881. end
  882. if handled and self.props.onChange then
  883. self.props.onChange(self.props.value)
  884. end
  885. return handled
  886. end
  887. return false
  888. end
  889. function Element:handleChar(char)
  890. if self.focused and self.type == "input" then
  891. local text = self.props.value or ""
  892. local newText = text:sub(1, self.cursorPos) .. char .. text:sub(self.cursorPos + 1)
  893. self.cursorPos = self.cursorPos + 1
  894. self.props.value = newText
  895. if self.props.onChange then
  896. self.props.onChange(newText)
  897. end
  898. return true
  899. end
  900. return false
  901. end
  902. function Element:handleScroll(direction, x, y)
  903. if self.type == "list" and self:isPointInside(x, y) then
  904. local current = self.props.scroll or 0
  905. local items = self.props.items or {}
  906. local visibleHeight = self.height - (self.style.border and 2 or 0)
  907. local maxScroll = math.max(0, #items - visibleHeight)
  908. local newScroll = math.max(0, math.min(current + direction * 1, maxScroll))
  909. self.targetScroll = newScroll
  910. return true
  911. end
  912. return false
  913. end
  914. -- ============================================================================
  915. -- COMPONENT SYSTEM
  916. -- ============================================================================
  917. local Component = {}
  918. Component.__index = Component
  919. function Component.new(config)
  920. local self = setmetatable({}, Component)
  921. self.name = config.name or "Component"
  922. self.render = config.render
  923. self.onMount = config.onMount
  924. self.onUnmount = config.onUnmount
  925. local internalState = {}
  926. self.state = {} --proxy
  927. setmetatable(self.state, {
  928. __index = internalState,
  929. __newindex = function(t, key, value)
  930. -- 1. Update the actual value in our internal table.
  931. internalState[key] = value
  932. -- 2. AUTOMATICALLY mark the component as dirty to trigger a re-render.
  933. if not self.isRendering then
  934. self.dirty = true
  935. end
  936. end
  937. })
  938. self.props = {}
  939. self.elements = {}
  940. self.emitters = {}
  941. self.dirty = false
  942. self.loop = nil
  943. self.isRendering = false
  944. return self
  945. end
  946. function Component:setState(newState)
  947. if self.isRendering then return end
  948. for k, v in pairs(newState) do
  949. self.state[k] = v
  950. end
  951. self.dirty = true
  952. end
  953. function Component:emit(event, data)
  954. if self.emitters[event] then
  955. for _, callback in ipairs(self.emitters[event]) do
  956. callback(data)
  957. end
  958. end
  959. end
  960. function Component:on(event, callback)
  961. if not self.emitters[event] then
  962. self.emitters[event] = {}
  963. end
  964. table.insert(self.emitters[event], callback)
  965. end
  966. function Component:mount(props)
  967. self.props = props or {}
  968. if self.onMount then
  969. self.onMount(self)
  970. end
  971. self.isRendering = true
  972. self.elements = self:render() or {}
  973. self.isRendering = false
  974. self.dirty = false
  975. return self.elements
  976. end
  977. function Component:unmount()
  978. if self.onUnmount then
  979. self.onUnmount(self)
  980. end
  981. self.elements = {}
  982. end
  983. function Component:update()
  984. self.isRendering = true
  985. local newElements = self:render() or {}
  986. self.isRendering = false
  987. self.dirty = false
  988. return newElements
  989. end
  990. -- ============================================================================
  991. -- ROUTER SYSTEM
  992. -- ============================================================================
  993. local Router = {}
  994. Router.__index = Router
  995. function Router.new()
  996. local self = setmetatable({}, Router)
  997. self.routes = {}
  998. self.currentRoute = "/"
  999. self.currentComponent = nil
  1000. self.middleware = {}
  1001. return self
  1002. end
  1003. function Router:addRoute(path, component)
  1004. self.routes[path] = component
  1005. end
  1006. function Router:addMiddleware(middleware)
  1007. table.insert(self.middleware, middleware)
  1008. end
  1009. local function safeSerialize(tbl, indent)
  1010. indent = indent or 0
  1011. local str = "{\n"
  1012. for k, v in pairs(tbl) do
  1013. local key = tostring(k)
  1014. local value
  1015. if type(v) == "table" then
  1016. value = safeSerialize(v, indent + 2)
  1017. elseif type(v) == "function" then
  1018. value = "\"<function>\""
  1019. else
  1020. value = textutils.serialize(v)
  1021. end
  1022. str = str .. string.rep(" ", indent + 2) .. key .. " = " .. value .. ",\n"
  1023. end
  1024. return str .. string.rep(" ", indent) .. "}"
  1025. end
  1026. function Router:navigate(path, props, app, transition)
  1027. -- Run middleware
  1028. for _, middleware in ipairs(self.middleware) do
  1029. local result = middleware(path, props)
  1030. if result == false then
  1031. return self.currentComponent and self.currentComponent.elements or {}
  1032. elseif type(result) == "string" then
  1033. path = result
  1034. end
  1035. end
  1036. -- Unfocus all elements on route change
  1037. if app then
  1038. local allElements = app:getAllElements()
  1039. for _, el in ipairs(allElements) do
  1040. if el.focused then
  1041. el:blur()
  1042. end
  1043. end
  1044. end
  1045. if self.currentComponent and self.currentComponent.unmount then
  1046. self.currentComponent:unmount()
  1047. end
  1048. table.insert(routerState.history, self.currentRoute)
  1049. self.currentRoute = path
  1050. routerState.currentRoute = path
  1051. term.setCursorPos(1, 1)
  1052. term.setBackgroundColor(colors.black)
  1053. term.setTextColor(colors.white)
  1054. local componentConfig = self.routes[path]
  1055. local newElements
  1056. if componentConfig then
  1057. if componentConfig.middleware then
  1058. for _, mw in ipairs(componentConfig.middleware) do
  1059. local result = mw(path, props)
  1060. if result == false then
  1061. return self.currentComponent and self.currentComponent.elements or {}
  1062. elseif type(result) == "string" then
  1063. return self:navigate(result, props, app, transition)
  1064. end
  1065. end
  1066. end
  1067. self.currentComponent = Component.new(componentConfig)
  1068. newElements = self.currentComponent:mount(props)
  1069. else
  1070. local notFound = ReactiveUI.createElement("text", {
  1071. x = 1,
  1072. y = 1,
  1073. text = "Page not found: " .. (path or "(path is nil)"),
  1074. style = { fg = colors.white }
  1075. })
  1076. newElements = { notFound }
  1077. end
  1078. if transition and transition.type then
  1079. app.transition = {
  1080. type = transition.type,
  1081. dir = transition.dir or "right",
  1082. oldElements = app.elements,
  1083. newElements = newElements,
  1084. startTime = os.epoch("utc"),
  1085. duration = (transition.duration or 0.5) * 1000,
  1086. progress = 0
  1087. }
  1088. else
  1089. app:setElements(newElements)
  1090. end
  1091. return newElements
  1092. end
  1093. function Router:back(app, transition)
  1094. if #routerState.history > 0 then
  1095. local previousRoute = table.remove(routerState.history)
  1096. return self:navigate(previousRoute, nil, app, transition)
  1097. end
  1098. end
  1099. function Router:getCurrentComponent()
  1100. return self.currentComponent
  1101. end
  1102. -- ============================================================================
  1103. -- APPLICATION SYSTEM
  1104. -- ============================================================================
  1105. local App = {}
  1106. App.__index = App
  1107. function App.new()
  1108. local self = setmetatable({}, App)
  1109. self.router = Router.new()
  1110. self.elements = {}
  1111. self.running = false
  1112. self.needsRedraw = true
  1113. self.components = {}
  1114. self.pageRoot = nil
  1115. self.compRoot = nil
  1116. self.modTimes = { pages = {}, components = {} }
  1117. self.focusedElement = nil
  1118. self.scrollY = 0
  1119. self.targetScrollY = nil
  1120. self.contentHeight = 0
  1121. self.transition = nil
  1122. return self
  1123. end
  1124. function App:setElements(newElements)
  1125. self.elements = newElements or {}
  1126. for _, root in ipairs(self.elements) do
  1127. root:updateAbsolutePosition()
  1128. end
  1129. self:recalculateContentHeight()
  1130. -- Restore focus if needed
  1131. if self.focusedElement then
  1132. local restored = false
  1133. for _, el in ipairs(self:getAllElements()) do
  1134. if el.id == self.focusedElement.id then
  1135. el.focused = true
  1136. el.cursorPos = self.focusedElement.cursorPos
  1137. self.focusedElement = el
  1138. restored = true
  1139. break
  1140. end
  1141. end
  1142. if not restored then
  1143. self.focusedElement = nil
  1144. end
  1145. end
  1146. end
  1147. function App:findElementById(element, id)
  1148. if element.id == id then
  1149. return element
  1150. end
  1151. for _, child in ipairs(element.children or {}) do
  1152. local found = self:findElementById(child, id)
  1153. if found then
  1154. return found
  1155. end
  1156. end
  1157. return nil
  1158. end
  1159. function App:getAllElements(elements)
  1160. local all = {}
  1161. elements = elements or self.elements
  1162. for _, el in ipairs(elements) do
  1163. table.insert(all, el)
  1164. for _, child in ipairs(self:getAllElements(el.children)) do
  1165. table.insert(all, child)
  1166. end
  1167. end
  1168. return all
  1169. end
  1170. function App:getFocusableElements()
  1171. local focusable = {}
  1172. for _, el in ipairs(self:getAllElements()) do
  1173. if el.type == "input" or el.type == "button" then
  1174. table.insert(focusable, el)
  1175. end
  1176. end
  1177. return focusable
  1178. end
  1179. function App:focusNext()
  1180. local focusable = self:getFocusableElements()
  1181. if #focusable == 0 then return end
  1182. local currentIndex = 0
  1183. for i, el in ipairs(focusable) do
  1184. if el.focused then
  1185. currentIndex = i
  1186. el:blur()
  1187. break
  1188. end
  1189. end
  1190. local nextIndex = (currentIndex % #focusable) + 1
  1191. focusable[nextIndex]:focus()
  1192. self.focusedElement = focusable[nextIndex]
  1193. end
  1194. function App:loadPages(pathOrDir)
  1195. local normalized = pathOrDir:gsub("/+$", "")
  1196. self.pageRoot = normalized
  1197. self.modTimes.pages = {}
  1198. local isDir = fs.isDir(normalized)
  1199. if isDir then
  1200. if not fs.exists(normalized) or not fs.isDir(normalized) then
  1201. print("Pages directory not found: " .. normalized)
  1202. return
  1203. end
  1204. local files = fs.list(normalized)
  1205. local hasIndex = false
  1206. for _, file in ipairs(files) do
  1207. if file:match("%.lua$") then
  1208. local fullpath = fs.combine(normalized, file)
  1209. local f = fs.open(fullpath, "r")
  1210. local cont = f.readAll()
  1211. f.close()
  1212. local env = { ReactiveUI = ReactiveUI }
  1213. setmetatable(env, { __index = _G })
  1214. local chunk, err = load(cont, file, "t", env)
  1215. if not chunk then
  1216. print("Failed to load page: " .. file)
  1217. print("Error: " .. tostring(err))
  1218. else
  1219. local success, pageConfig = pcall(chunk)
  1220. if success and type(pageConfig) == "table" then
  1221. local routeName = file:gsub("%.lua$", "")
  1222. local routePath = routeName == "index" and "/" or "/" .. routeName
  1223. self.router:addRoute(routePath, pageConfig)
  1224. print("Loaded page: " .. routePath)
  1225. local attrs = fs.attributes(fullpath)
  1226. self.modTimes.pages[fullpath] = attrs.modified
  1227. if routePath == "/" then
  1228. hasIndex = true
  1229. end
  1230. else
  1231. print("Failed to execute page: " .. file)
  1232. if not success then
  1233. print("Error: " .. tostring(pageConfig))
  1234. end
  1235. end
  1236. end
  1237. end
  1238. end
  1239. if not hasIndex then
  1240. local firstRoute = next(self.router.routes)
  1241. if firstRoute then
  1242. self.router.currentRoute = firstRoute
  1243. print("No index page, starting at: " .. firstRoute)
  1244. end
  1245. end
  1246. else
  1247. if not fs.exists(normalized) then
  1248. print("Page file not found: " .. normalized)
  1249. return
  1250. end
  1251. local f = fs.open(normalized, "r")
  1252. local cont = f.readAll()
  1253. f.close()
  1254. local env = { ReactiveUI = ReactiveUI }
  1255. setmetatable(env, { __index = _G })
  1256. local chunk, err = load(cont, fs.getName(normalized), "t", env)
  1257. if not chunk then
  1258. print("Failed to load page: " .. normalized)
  1259. print("Error: " .. tostring(err))
  1260. else
  1261. local success, pageConfig = pcall(chunk)
  1262. if success and type(pageConfig) == "table" then
  1263. local routeName = fs.getName(normalized):gsub("%.lua$", "")
  1264. local routePath = routeName == "index" and "/" or "/" .. routeName
  1265. self.router:addRoute(routePath, pageConfig)
  1266. if self.router.currentRoute == "/" then
  1267. self.router.currentRoute = routePath
  1268. end
  1269. print("Loaded page: " .. routePath)
  1270. local attrs = fs.attributes(normalized)
  1271. self.modTimes.pages[normalized] = attrs.modified
  1272. else
  1273. print("Failed to execute page: " .. normalized)
  1274. if not success then
  1275. print("Error: " .. tostring(pageConfig))
  1276. end
  1277. end
  1278. end
  1279. end
  1280. end
  1281. function App:loadPagesFromStrings(pagesTbl)
  1282. for route, cont in pairs(pagesTbl) do
  1283. local env = { ReactiveUI = ReactiveUI }
  1284. setmetatable(env, { __index = _G })
  1285. local chunk, err = load(cont, route .. ".lua", "t", env)
  1286. if not chunk then
  1287. print("Failed to load page from string: " .. route)
  1288. print("Error: " .. tostring(err))
  1289. else
  1290. local success, pageConfig = pcall(chunk)
  1291. if success and type(pageConfig) == "table" then
  1292. self.router:addRoute(route, pageConfig)
  1293. print("Loaded page from string: " .. route)
  1294. else
  1295. print("Failed to execute page from string: " .. route)
  1296. if not success then
  1297. print("Error: " .. tostring(pageConfig))
  1298. end
  1299. end
  1300. end
  1301. end
  1302. end
  1303. function App:loadComponents(dir)
  1304. local normalized = dir:gsub("/+$", "")
  1305. self.components = {}
  1306. self.modTimes.components = {}
  1307. self.compRoot = normalized
  1308. if not fs.exists(normalized) or not fs.isDir(normalized) then
  1309. print("Components directory not found: " .. normalized)
  1310. return
  1311. end
  1312. local files = fs.list(normalized)
  1313. for _, file in ipairs(files) do
  1314. if file:match("%.lua$") then
  1315. local fullpath = fs.combine(normalized, file)
  1316. local f = fs.open(fullpath, "r")
  1317. local cont = f.readAll()
  1318. f.close()
  1319. local env = { ReactiveUI = ReactiveUI }
  1320. setmetatable(env, { __index = _G })
  1321. local chunk, err = load(cont, file, "t", env)
  1322. if not chunk then
  1323. print("Failed to load component: " .. file)
  1324. print("Error: " .. tostring(err))
  1325. else
  1326. local success, component = pcall(chunk)
  1327. if success and type(component) == "table" then
  1328. local componentName = file:gsub("%.lua$", "")
  1329. self.components[componentName] = component
  1330. local attrs = fs.attributes(fullpath)
  1331. self.modTimes.components[fullpath] = attrs.modified
  1332. print("Loaded component: " .. componentName)
  1333. else
  1334. print("Failed to execute component: " .. file)
  1335. if not success then
  1336. print("Error: " .. tostring(component))
  1337. end
  1338. end
  1339. end
  1340. end
  1341. end
  1342. end
  1343. function App:loadComponentsFromStrings(compsTbl)
  1344. for name, cont in pairs(compsTbl) do
  1345. local env = { ReactiveUI = ReactiveUI }
  1346. setmetatable(env, { __index = _G })
  1347. local chunk, err = load(cont, name .. ".lua", "t", env)
  1348. if not chunk then
  1349. print("Failed to load component from string: " .. name)
  1350. print("Error: " .. tostring(err))
  1351. else
  1352. local success, component = pcall(chunk)
  1353. if success and type(component) == "table" then
  1354. self.components[name] = component
  1355. print("Loaded component from string: " .. name)
  1356. else
  1357. print("Failed to execute component from string: " .. name)
  1358. if not success then
  1359. print("Error: " .. tostring(component))
  1360. end
  1361. end
  1362. end
  1363. end
  1364. end
  1365. function App:getComponent(name)
  1366. return self.components[name]
  1367. end
  1368. function App:render()
  1369. if self.needsRedraw then
  1370. local screenW, screenH = term.getSize()
  1371. term.setBackgroundColor(colors.black)
  1372. term.clear()
  1373. -- Handle transition rendering
  1374. if self.transition then
  1375. local now = os.epoch("utc")
  1376. local elapsed = now - self.transition.startTime
  1377. local prog = math.min(1, elapsed / self.transition.duration)
  1378. self.transition.progress = prog
  1379. local eased = ReactiveUI.animGraph.easeinout(prog)
  1380. local oldOffsetX, oldOffsetY = 0, 0
  1381. local newOffsetX, newOffsetY = 0, 0
  1382. local transType = self.transition.type
  1383. local dir = self.transition.dir
  1384. local deltaX = screenW * eased
  1385. local deltaY = screenH * eased
  1386. if transType == "fade" then
  1387. if prog < 0.5 then
  1388. for _, el in ipairs(self.transition.oldElements) do
  1389. el:render(self.scrollY, 0, 0)
  1390. end
  1391. else
  1392. for _, el in ipairs(self.transition.newElements) do
  1393. el:render(self.scrollY, 0, 0)
  1394. end
  1395. end
  1396. elseif transType == "carousel" then
  1397. if dir == "left" then
  1398. oldOffsetX = -deltaX
  1399. newOffsetX = screenW - deltaX
  1400. elseif dir == "right" then
  1401. oldOffsetX = deltaX
  1402. newOffsetX = -screenW + deltaX
  1403. elseif dir == "up" then
  1404. oldOffsetY = -deltaY
  1405. newOffsetY = screenH - deltaY
  1406. elseif dir == "down" then
  1407. oldOffsetY = deltaY
  1408. newOffsetY = -screenH + deltaY
  1409. end
  1410. for _, el in ipairs(self.transition.oldElements) do
  1411. el:render(self.scrollY, oldOffsetX, oldOffsetY)
  1412. end
  1413. for _, el in ipairs(self.transition.newElements) do
  1414. el:render(self.scrollY, newOffsetX, newOffsetY)
  1415. end
  1416. elseif transType == "slideOnTop" then
  1417. for _, el in ipairs(self.transition.oldElements) do
  1418. el:render(self.scrollY, 0, 0)
  1419. end
  1420. newOffsetY = screenH * (1 - eased)
  1421. for _, el in ipairs(self.transition.newElements) do
  1422. el:render(self.scrollY, 0, newOffsetY)
  1423. end
  1424. end
  1425. if prog >= 1 then
  1426. self.transition = nil
  1427. self:setElements(self.transition.newElements)
  1428. end
  1429. else
  1430. -- Normal rendering
  1431. -- Find focused element for cursor rendering
  1432. local focused = nil
  1433. for _, el in ipairs(self:getAllElements()) do
  1434. if el.focused then
  1435. focused = el
  1436. break
  1437. end
  1438. end
  1439. -- Render all elements with scroll offset and viewport culling
  1440. for _, element in ipairs(self.elements) do
  1441. local displayY = element.y - self.scrollY
  1442. local elemBottom = displayY + element.height - 1
  1443. -- Only render if element is within viewport (not fully outside)
  1444. if elemBottom >= 1 and displayY <= screenH then
  1445. element:render(self.scrollY, 0, 0)
  1446. end
  1447. end
  1448. -- Render cursor for focused input
  1449. if focused and focused.type == "input" then
  1450. local text = focused.props.value or ""
  1451. local cursorPos = focused.cursorPos or #text
  1452. local maxVisible = focused.width - 2
  1453. local displayCursor = math.min(cursorPos, maxVisible)
  1454. local cursorX = focused.x + 1 + displayCursor
  1455. local cursorY = focused.y + 1 - self.scrollY
  1456. -- Only show cursor if it's in viewport
  1457. if cursorY >= 1 and cursorY <= screenH then
  1458. term.setCursorPos(cursorX, cursorY)
  1459. term.setCursorBlink(true)
  1460. else
  1461. term.setCursorBlink(false)
  1462. end
  1463. else
  1464. term.setCursorBlink(false)
  1465. end
  1466. -- Render scrollbar if content is scrollable
  1467. if self.contentHeight > screenH then
  1468. local trackX = screenW
  1469. local trackHeight = screenH
  1470. local maxScroll = self.contentHeight - screenH
  1471. local thumbHeight = math.max(1, math.floor((screenH / self.contentHeight) * trackHeight))
  1472. local thumbTop = math.floor((self.scrollY / maxScroll) * (trackHeight - thumbHeight)) + 1
  1473. for i = 1, trackHeight do
  1474. term.setCursorPos(trackX, i)
  1475. if i >= thumbTop and i < thumbTop + thumbHeight then
  1476. term.setBackgroundColor(colors.lightGray)
  1477. term.setTextColor(colors.white)
  1478. term.write(" ")
  1479. else
  1480. term.setBackgroundColor(colors.black)
  1481. term.setTextColor(colors.gray)
  1482. term.write("|")
  1483. end
  1484. end
  1485. end
  1486. end
  1487. self.needsRedraw = false
  1488. end
  1489. end
  1490. function App:reload()
  1491. local currentRoute = self.router.currentRoute
  1492. self.router.routes = {}
  1493. local normalized = self.pageRoot:gsub("/+$", "")
  1494. local isPageDir = fs.isDir(normalized)
  1495. if isPageDir then
  1496. self:loadPages(normalized)
  1497. else
  1498. local pagePath = next(self.modTimes.pages)
  1499. if pagePath then
  1500. local f = fs.open(pagePath, "r")
  1501. local cont = f.readAll()
  1502. f.close()
  1503. local env = { ReactiveUI = ReactiveUI }
  1504. setmetatable(env, { __index = _G })
  1505. local chunk, err = load(cont, fs.getName(pagePath), "t", env)
  1506. if chunk then
  1507. local success, pageConfig = pcall(chunk)
  1508. if success and type(pageConfig) == "table" then
  1509. local routeName = fs.getName(pagePath):gsub("%.lua$", "")
  1510. local routePath = routeName == "index" and "/" or "/" .. routeName
  1511. self.router:addRoute(routePath, pageConfig)
  1512. if self.router.currentRoute == "/" then
  1513. self.router.currentRoute = routePath
  1514. end
  1515. print("Reloaded page: " .. routePath)
  1516. local attrs = fs.attributes(pagePath)
  1517. self.modTimes.pages[pagePath] = attrs.modified
  1518. end
  1519. end
  1520. end
  1521. end
  1522. local normalizedComp = self.compRoot and self.compRoot:gsub("/+$", "") or nil
  1523. if normalizedComp and fs.exists(normalizedComp) and fs.isDir(normalizedComp) then
  1524. self:loadComponents(normalizedComp)
  1525. end
  1526. local elements = self.router:navigate(currentRoute, nil, self)
  1527. self:setElements(elements or {})
  1528. self.needsRedraw = true
  1529. print("Reload complete")
  1530. end
  1531. function App:updateAnimations()
  1532. local allElements = self:getAllElements()
  1533. local isAnimating = false
  1534. for _, el in ipairs(allElements) do
  1535. -- Check for new animations to start
  1536. for prop, config in pairs(el.transitions) do
  1537. if el.props[prop] ~= nil and el.props[prop] ~= el[prop] and not el.activeAnimations[prop] then
  1538. local anim = ReactiveUI.animGraph[config.ease or "easeinout"](
  1539. config.duration or 0.5,
  1540. el[prop],
  1541. el.props[prop]
  1542. )
  1543. el.activeAnimations[prop] = {
  1544. graph = anim,
  1545. frame = 1
  1546. }
  1547. end
  1548. end
  1549. -- Update currently running animations
  1550. if next(el.activeAnimations) then
  1551. isAnimating = true
  1552. for prop, animData in pairs(el.activeAnimations) do
  1553. local anim = animData.graph
  1554. local frame = animData.frame
  1555. el[prop] = anim[frame]
  1556. if frame >= anim[0] then
  1557. el[prop] = el.props[prop]
  1558. el.activeAnimations[prop] = nil
  1559. else
  1560. animData.frame = frame + 1
  1561. end
  1562. end
  1563. end
  1564. end
  1565. return isAnimating
  1566. end
  1567. function App:reconcile(oldElements, newElements)
  1568. local finalElements = {}
  1569. local oldElementsById = {}
  1570. for _, oldEl in ipairs(oldElements) do
  1571. if oldEl.id then
  1572. oldElementsById[oldEl.id] = oldEl
  1573. end
  1574. end
  1575. for _, newEl in ipairs(newElements) do
  1576. local oldEl = newEl.id and oldElementsById[newEl.id] or nil
  1577. if oldEl then
  1578. oldEl:updateFromProps(newEl.props)
  1579. if oldEl.children or newEl.children then
  1580. oldEl.children = self:reconcile(oldEl.children or {}, newEl.children or {})
  1581. end
  1582. table.insert(finalElements, oldEl)
  1583. else
  1584. table.insert(finalElements, newEl)
  1585. end
  1586. end
  1587. return finalElements
  1588. end
  1589. function App:recalculateContentHeight()
  1590. local _, screenH = term.getSize()
  1591. self.contentHeight = screenH
  1592. local all = self:getAllElements()
  1593. for _, el in ipairs(all) do
  1594. -- Find the lowest point occupied by any element
  1595. self.contentHeight = math.max(self.contentHeight, el.y + el.height - 1)
  1596. end
  1597. end
  1598. function App:run(watch)
  1599. watch = watch or false
  1600. currentApp = self
  1601. self.running = true
  1602. local elements = self.router:navigate(self.router.currentRoute, nil, self)
  1603. self:setElements(elements or {})
  1604. self:render()
  1605. local tickTimer = os.startTimer(0)
  1606. local watchTimer
  1607. if watch then
  1608. watchTimer = os.startTimer(1)
  1609. end
  1610. while self.running do
  1611. local event, p1, p2, p3 = os.pullEventRaw()
  1612. local needsRedraw = false
  1613. if event == "terminate" then
  1614. self:stop()
  1615. break
  1616. elseif event == "timer" and p1 == tickTimer then
  1617. tickTimer = os.startTimer(0)
  1618. elseif event == "reload" then
  1619. self:reload()
  1620. needsRedraw = true
  1621. elseif watch and event == "timer" and p1 == watchTimer then
  1622. local changed = false
  1623. for path, oldMtime in pairs(self.modTimes.pages) do
  1624. if fs.exists(path) then
  1625. local attrs = fs.attributes(path)
  1626. local mtime = attrs.modified
  1627. if mtime > oldMtime then
  1628. changed = true
  1629. self.modTimes.pages[path] = mtime
  1630. end
  1631. end
  1632. end
  1633. for path, oldMtime in pairs(self.modTimes.components) do
  1634. if fs.exists(path) then
  1635. local attrs = fs.attributes(path)
  1636. local mtime = attrs.modified
  1637. if mtime > oldMtime then
  1638. changed = true
  1639. self.modTimes.components[path] = mtime
  1640. end
  1641. end
  1642. end
  1643. if changed then
  1644. os.queueEvent("reload")
  1645. print("Detected changes, reloading...")
  1646. end
  1647. watchTimer = os.startTimer(1)
  1648. else
  1649. if self.transition then
  1650. needsRedraw = true
  1651. elseif event == "mouse_click" then
  1652. local button, x, y = p1, p2, p3
  1653. local adjustedY = y + self.scrollY
  1654. local allElements = self:getAllElements()
  1655. local clickedSomething = false
  1656. for i = #self.elements, 1, -1 do
  1657. if self.elements[i]:handleClick(x, adjustedY, button, self) then
  1658. clickedSomething = true
  1659. break
  1660. end
  1661. end
  1662. if not clickedSomething then
  1663. for _, el in ipairs(allElements) do
  1664. if el.focused then
  1665. el:blur()
  1666. self.focusedElement = nil
  1667. end
  1668. end
  1669. end
  1670. for _, element in ipairs(allElements) do
  1671. element.active = false
  1672. end
  1673. needsRedraw = true
  1674. elseif event == "mouse_drag" or event == "mouse_move" then
  1675. local x, y = p2, p3
  1676. local adjustedY = y + self.scrollY
  1677. for _, element in ipairs(self.elements) do
  1678. element:handleHover(x, adjustedY)
  1679. end
  1680. needsRedraw = true
  1681. elseif event == "mouse_up" then
  1682. local allElements = self:getAllElements()
  1683. for _, element in ipairs(allElements) do
  1684. element.dragging = false
  1685. element.dragType = nil
  1686. end
  1687. elseif event == "mouse_scroll" then
  1688. local direction, x, y = p1, p2, p3
  1689. local handled = false
  1690. local adjustedY = y + self.scrollY
  1691. local allElements = self:getAllElements()
  1692. for _, element in ipairs(allElements) do
  1693. if element:handleScroll(direction, x, adjustedY) then
  1694. handled = true
  1695. break
  1696. end
  1697. end
  1698. if not handled then
  1699. local screenW, screenH = term.getSize()
  1700. local maxScroll = math.max(0, self.contentHeight - screenH)
  1701. if maxScroll > 0 then
  1702. local scrollAmount = direction * 1
  1703. self.scrollY = math.max(0, math.min(self.scrollY + scrollAmount, maxScroll))
  1704. handled = true
  1705. end
  1706. end
  1707. if handled then
  1708. needsRedraw = true
  1709. end
  1710. elseif event == "key" then
  1711. local key = p1
  1712. local handled = false
  1713. if key == keys.tab then
  1714. self:focusNext()
  1715. needsRedraw = true
  1716. handled = true
  1717. end
  1718. if not handled then
  1719. for _, element in ipairs(self:getAllElements()) do
  1720. if element:handleKey(key) then
  1721. needsRedraw = true
  1722. if element.focused and element.id then
  1723. self.focusedElement = element
  1724. elseif not element.focused and self.focusedElement == element then
  1725. self.focusedElement = nil
  1726. end
  1727. break
  1728. end
  1729. end
  1730. end
  1731. elseif event == "char" then
  1732. local char = p1
  1733. for _, element in ipairs(self:getAllElements()) do
  1734. if element:handleChar(char) then
  1735. needsRedraw = true
  1736. if element.id then
  1737. self.focusedElement = element
  1738. end
  1739. break
  1740. end
  1741. end
  1742. elseif event == "term_resize" then
  1743. needsRedraw = true
  1744. end
  1745. end
  1746. self.needsRedraw = needsRedraw or self.needsRedraw
  1747. local comp = self.router:getCurrentComponent()
  1748. if comp and comp.dirty then
  1749. local newElements = comp:update()
  1750. self.elements = self:reconcile(self.elements, newElements)
  1751. for _, root in ipairs(self.elements) do
  1752. root:updateAbsolutePosition()
  1753. end
  1754. self:recalculateContentHeight()
  1755. self.needsRedraw = true
  1756. end
  1757. if comp and comp.loop and coroutine.status(comp.loop) == "suspended" then
  1758. local ok, err = coroutine.resume(comp.loop)
  1759. if not ok then
  1760. print("Loop error: " .. tostring(err))
  1761. comp.loop = nil
  1762. end
  1763. if coroutine.status(comp.loop) == "dead" then
  1764. comp.loop = nil
  1765. end
  1766. end
  1767. if self.targetScrollY then
  1768. local current = self.scrollY or 0
  1769. local delta = self.targetScrollY - current
  1770. if math.abs(delta) < 1 then
  1771. self.scrollY = self.targetScrollY
  1772. self.targetScrollY = nil
  1773. else
  1774. self.scrollY = current + math.floor(delta * 0.3)
  1775. self.needsRedraw = true
  1776. end
  1777. end
  1778. local isAnimating = self:updateAnimations()
  1779. if isAnimating then
  1780. self.needsRedraw = true
  1781. end
  1782. if self.needsRedraw then
  1783. self:render()
  1784. end
  1785. end
  1786. end
  1787. function App:stop()
  1788. self.running = false
  1789. end
  1790. -- ============================================================================
  1791. -- HELPER FUNCTIONS
  1792. -- ============================================================================
  1793. function ReactiveUI.createElement(type, props)
  1794. return Element.new(type, props)
  1795. end
  1796. function ReactiveUI.createComponent(config)
  1797. return Component.new(config)
  1798. end
  1799. function ReactiveUI.createApp()
  1800. return App.new()
  1801. end
  1802. function ReactiveUI.log(wait, logs)
  1803. term.setCursorPos(1, 1)
  1804. term.setTextColor(colors.white)
  1805. term.setBackgroundColor(colors.black)
  1806. for i, log in ipairs(logs) do
  1807. print(log)
  1808. end
  1809. if wait then os.pullEvent("key") end
  1810. end
  1811. function ReactiveUI.normalizePath(path)
  1812. if type(path) ~= "string" then
  1813. print("!!! Warning: tried to navigate with non-string path:", tostring(path))
  1814. return "/"
  1815. end
  1816. path = path:gsub("/+", "/")
  1817. path = path:gsub("/$", "")
  1818. if path == "" then path = "/" end
  1819. return path
  1820. end
  1821. function ReactiveUI.navigate(path, props, transition)
  1822. if not path or path == "" then
  1823. path = "/"
  1824. end
  1825. local app = currentApp or ReactiveUI.createApp()
  1826. app.router:navigate(path, props, app, transition or {})
  1827. end
  1828. function ReactiveUI.setGlobal(key, value)
  1829. globalStore[key] = value
  1830. end
  1831. function ReactiveUI.getGlobal(key, default)
  1832. return globalStore[key] or default
  1833. end
  1834. -- Middleware helpers
  1835. function ReactiveUI.createAuthMiddleware(checkAuth)
  1836. return function(path, props)
  1837. if not checkAuth() then
  1838. return "/login"
  1839. end
  1840. return true
  1841. end
  1842. end
  1843. function ReactiveUI.createLoggerMiddleware()
  1844. return function(path, props)
  1845. print("[Router] Navigating to: " .. path)
  1846. return true
  1847. end
  1848. end
  1849. -- ============================================================================
  1850. -- EXAMPLE USAGE
  1851. -- ============================================================================
  1852. function ReactiveUI.exampleApp()
  1853. local app = ReactiveUI.createApp()
  1854. app:loadPages("pages")
  1855. app.router:addRoute("/page", {
  1856. name = "TestPage",
  1857. render = function(self)
  1858. local w, h = term.getSize()
  1859. local y = 1
  1860. local elements = {}
  1861. table.insert(elements,
  1862. ReactiveUI.createElement("text",
  1863. { x = 1, y = y, text = "ReactiveUI Component Showcase", style = { fg = colors.white } }))
  1864. y = y + 2
  1865. table.insert(elements,
  1866. ReactiveUI.createElement("text", { x = 1, y = y, text = "Box:", style = { fg = colors.yellow } }))
  1867. y = y + 1
  1868. table.insert(elements,
  1869. ReactiveUI.createElement("box",
  1870. { x = 1, y = y, width = 20, height = 3, style = { bg = colors.lightBlue, border = true, rounded = true } }))
  1871. y = y + 4
  1872. table.insert(elements,
  1873. ReactiveUI.createElement("text", { x = 1, y = y, text = "Text:", style = { fg = colors.yellow } }))
  1874. y = y + 1
  1875. table.insert(elements,
  1876. ReactiveUI.createElement("text",
  1877. { x = 1, y = y, text = "This is a text element.", style = { fg = colors.green } }))
  1878. y = y + 2
  1879. table.insert(elements,
  1880. ReactiveUI.createElement("text", { x = 1, y = y, text = "Button:", style = { fg = colors.yellow } }))
  1881. y = y + 1
  1882. table.insert(elements,
  1883. ReactiveUI.createElement("button",
  1884. {
  1885. x = 1,
  1886. y = y,
  1887. width = 10,
  1888. height = 3,
  1889. text = "Click Me",
  1890. class = "bg-green-500 text-white border rounded hover:bg-blue-500",
  1891. style = { bg = colors.green, fg = colors.white, border = true },
  1892. onClick = function()
  1893. print("Clicked!")
  1894. end
  1895. }))
  1896. y = y + 4
  1897. table.insert(elements,
  1898. ReactiveUI.createElement("text", { x = 1, y = y, text = "Input:", style = { fg = colors.yellow } }))
  1899. y = y + 1
  1900. local input = ReactiveUI.createElement("input",
  1901. { x = 1, y = y, width = 20, height = 3, value = "", placeholder = "Type here...", class = "border", style = { border = true } })
  1902. table.insert(elements, input)
  1903. y = y + 4
  1904. table.insert(elements,
  1905. ReactiveUI.createElement("text", { x = 1, y = y, text = "Checkbox:", style = { fg = colors.yellow } }))
  1906. y = y + 1
  1907. local checkbox = ReactiveUI.createElement("checkbox",
  1908. {
  1909. x = 1,
  1910. y = y,
  1911. width = 20,
  1912. height = 3,
  1913. checked = false,
  1914. label = "Check me",
  1915. class = "border",
  1916. style = { border = true },
  1917. onChange = function(
  1918. checked)
  1919. print("Checked: " .. tostring(checked))
  1920. end
  1921. })
  1922. table.insert(elements, checkbox)
  1923. y = y + 4
  1924. table.insert(elements,
  1925. ReactiveUI.createElement("text", { x = 1, y = y, text = "Radio:", style = { fg = colors.yellow } }))
  1926. y = y + 1
  1927. table.insert(elements,
  1928. ReactiveUI.createElement("radio",
  1929. {
  1930. x = 1,
  1931. y = y,
  1932. width = 20,
  1933. height = 3,
  1934. selected = true,
  1935. label = "Option 1",
  1936. class = "border",
  1937. style = { border = true },
  1938. onChange = function(
  1939. val)
  1940. print("Selected: " .. val)
  1941. end,
  1942. value = "opt1",
  1943. group = "test"
  1944. }))
  1945. y = y + 3
  1946. table.insert(elements,
  1947. ReactiveUI.createElement("radio",
  1948. {
  1949. x = 1,
  1950. y = y,
  1951. width = 20,
  1952. height = 3,
  1953. selected = false,
  1954. label = "Option 2",
  1955. class = "border",
  1956. style = { border = true },
  1957. group =
  1958. "test",
  1959. onChange = function(val) print("Selected: " .. val) end,
  1960. value = "opt2"
  1961. }))
  1962. y = y + 4
  1963. table.insert(elements,
  1964. ReactiveUI.createElement("text", { x = 1, y = y, text = "Slider:", style = { fg = colors.yellow } }))
  1965. y = y + 1
  1966. local slider = ReactiveUI.createElement("slider",
  1967. {
  1968. x = 1,
  1969. y = y,
  1970. width = 20,
  1971. height = 3,
  1972. value = 50,
  1973. min = 0,
  1974. max = 100,
  1975. showValue = true,
  1976. class = "border",
  1977. style = { border = true },
  1978. onChange = function(
  1979. val)
  1980. print("Slider: " .. val)
  1981. end
  1982. })
  1983. table.insert(elements, slider)
  1984. y = y + 4
  1985. table.insert(elements,
  1986. ReactiveUI.createElement("text", { x = 1, y = y, text = "File Input:", style = { fg = colors.yellow } }))
  1987. y = y + 1
  1988. local file = ReactiveUI.createElement("file",
  1989. {
  1990. x = 1,
  1991. y = y,
  1992. width = 20,
  1993. height = 3,
  1994. value = "",
  1995. placeholder = "No file",
  1996. class = "border",
  1997. style = { border = true },
  1998. onBrowse = function()
  1999. print("Browse clicked")
  2000. end
  2001. })
  2002. table.insert(elements, file)
  2003. y = y + 4
  2004. table.insert(elements,
  2005. ReactiveUI.createElement("text", { x = 1, y = y, text = "List:", style = { fg = colors.yellow } }))
  2006. y = y + 1
  2007. local listItems = { "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7", "Item 8", "Item 9",
  2008. "Item 10", "Item 11", "Item 12" }
  2009. local list = ReactiveUI.createElement("list",
  2010. {
  2011. x = 1,
  2012. y = y,
  2013. width = 20,
  2014. height = 10,
  2015. items = listItems,
  2016. scroll = 0,
  2017. selectedIndex = 1,
  2018. class = "border",
  2019. style = { border = true },
  2020. onSelect = function(
  2021. idx, item)
  2022. print("Selected: " .. item)
  2023. end
  2024. })
  2025. table.insert(elements, list)
  2026. y = y + 12
  2027. table.insert(elements,
  2028. ReactiveUI.createElement("text",
  2029. { x = 1, y = y, text = "End of showcase. Scroll to see all.", style = { fg = colors.white } }))
  2030. return elements
  2031. end
  2032. })
  2033. return app
  2034. end
  2035. function ReactiveUI.cli(arg)
  2036. if #arg > 0 and arg[1] then
  2037. local cmd = arg[1]
  2038. if cmd == "server" then
  2039. local watchMode = false
  2040. local path
  2041. if #arg >= 3 and arg[2] == "-w" then
  2042. watchMode = true
  2043. path = arg[3]
  2044. elseif #arg >= 2 then
  2045. path = arg[2]
  2046. end
  2047. if not path then
  2048. print("Usage: server [-w] <path> | build [dir]")
  2049. return
  2050. end
  2051. local normalizedPath = path:gsub("/+$", "")
  2052. local app = ReactiveUI.createApp()
  2053. app:loadPages(normalizedPath)
  2054. local isPageDir = fs.isDir(normalizedPath)
  2055. local compRoot
  2056. if isPageDir then
  2057. compRoot = fs.combine(normalizedPath, "components")
  2058. else
  2059. local fileDir = fs.getDir(normalizedPath) or shell.dir()
  2060. compRoot = fs.combine(fileDir, "components")
  2061. end
  2062. if fs.exists(compRoot) and fs.isDir(compRoot) then
  2063. app:loadComponents(compRoot)
  2064. end
  2065. if watchMode then
  2066. app:run(true)
  2067. else
  2068. app:run()
  2069. end
  2070. elseif cmd == "build" then
  2071. local dir = arg[2] or "."
  2072. local libFile = "react.lua"
  2073. if not fs.exists(libFile) then
  2074. error("Library file not found: " .. libFile)
  2075. end
  2076. local f = fs.open(libFile, "r")
  2077. local libCode = f.readAll()
  2078. f.close()
  2079. -- disable CLI
  2080. local lines = {}
  2081. for line in libCode:gmatch("[^\r\n]+") do
  2082. table.insert(lines, line)
  2083. end
  2084. if #lines > 0 then
  2085. lines[1] = "local cliEnabled = false"
  2086. end
  2087. libCode = table.concat(lines, "\n")
  2088. local pages = {}
  2089. local components = {}
  2090. local pagesDir = fs.combine(dir, "pages")
  2091. if fs.exists(pagesDir) and fs.isDir(pagesDir) then
  2092. local files = fs.list(pagesDir)
  2093. for _, file in ipairs(files) do
  2094. if file:match("%.lua$") then
  2095. local path = fs.combine(pagesDir, file)
  2096. local f2 = fs.open(path, "r")
  2097. local cont = f2.readAll()
  2098. f2.close()
  2099. local routeName = file:gsub("%.lua$", "")
  2100. local routePath = routeName == "index" and "/" or "/" .. routeName
  2101. pages[routePath] = cont
  2102. end
  2103. end
  2104. end
  2105. local compsDir = fs.combine(dir, "components")
  2106. if fs.exists(compsDir) and fs.isDir(compsDir) then
  2107. local files = fs.list(compsDir)
  2108. for _, file in ipairs(files) do
  2109. if file:match("%.lua$") then
  2110. local path = fs.combine(compsDir, file)
  2111. local f2 = fs.open(path, "r")
  2112. local cont = f2.readAll()
  2113. f2.close()
  2114. local compName = file:gsub("%.lua$", "")
  2115. components[compName] = cont
  2116. end
  2117. end
  2118. end
  2119. local function escapeLuaString(str)
  2120. return str:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\r", "\\r")
  2121. end
  2122. local builtCode = libCode .. "\n\n-- Built Application\nlocal pages = {\n"
  2123. for route, cont in pairs(pages) do
  2124. local esc = escapeLuaString(cont)
  2125. builtCode = builtCode .. " ['" .. route .. "'] = \"" .. esc .. "\",\n"
  2126. end
  2127. builtCode = builtCode .. "}\nlocal components = {\n"
  2128. for name, cont in pairs(components) do
  2129. local esc = escapeLuaString(cont)
  2130. builtCode = builtCode .. " ['" .. name .. "'] = \"" .. esc .. "\",\n"
  2131. end
  2132. builtCode = builtCode ..
  2133. "}\nlocal app = ReactiveUI.createApp()\napp:loadPagesFromStrings(pages)\napp:loadComponentsFromStrings(components)\napp:run()\n"
  2134. local output = fs.combine(dir, "ui.lua")
  2135. local outF = fs.open(output, "w")
  2136. outF.write(builtCode)
  2137. outF.close()
  2138. print("Built to " .. output)
  2139. else
  2140. print("Usage: server [-w] <path> | build [dir]")
  2141. end
  2142. end
  2143. end
  2144. if shell and cliEnabled then
  2145. local args = { ... }
  2146. local program = shell.getRunningProgram()
  2147. local resolved = shell.resolveProgram("react") or shell.resolveProgram("react.lua")
  2148. if program == resolved then
  2149. ReactiveUI.cli(args)
  2150. else
  2151. return ReactiveUI
  2152. end
  2153. elseif cliEnabled then
  2154. return ReactiveUI
  2155. end
  2156.  
  2157. -- Built Application
  2158. local pages = {
  2159. ['/profile'] = "-- pages/profile.lua\nreturn {\n name = \"ProfilePage\",\n\n onMount = function(self)\n self.state.name = \"\"\n self.state.email = \"\"\n end,\n\n render = function(self)\n local w, h = term.getSize()\n\n local elements = {\n -- Background\n ReactiveUI.createElement(\"box\", {\n x = 1,\n y = 1,\n width = w,\n height = h,\n style = { bg = colors.lightBlue }\n }),\n\n -- Title\n ReactiveUI.createElement(\"text\", {\n x = 2,\n y = 2,\n text = \"Profile Page\",\n style = { fg = colors.white, bg = colors.lightBlue }\n }),\n\n -- Name label and input\n ReactiveUI.createElement(\"text\", {\n x = 2,\n y = 4,\n text = \"Name:\",\n style = { fg = colors.white, bg = colors.lightBlue }\n }),\n ReactiveUI.createElement(\"input\", {\n x = 8,\n y = 4,\n width = 20,\n height = 3,\n id = \"name_input\",\n value = self.state.name,\n placeholder = \"Enter your name\",\n style = { bg = colors.white, fg = colors.black, border = true, rounded = true },\n focusStyle = { borderColor = colors.blue },\n onChange = function(value)\n self.state.name = value\n end\n }),\n\n -- Email label and input\n ReactiveUI.createElement(\"text\", {\n x = 2,\n y = 8,\n text = \"Email:\",\n style = { fg = colors.white, bg = colors.lightBlue }\n }),\n ReactiveUI.createElement(\"input\", {\n x = 9,\n y = 8,\n width = 20,\n height = 3,\n id = \"email_input\",\n value = self.state.email,\n placeholder = \"Enter your email\",\n style = { bg = colors.white, fg = colors.black, border = true, rounded = true },\n focusStyle = { borderColor = colors.blue },\n onChange = function(value)\n self.state.email = value\n end\n }),\n\n -- Greeting\n ReactiveUI.createElement(\"text\", {\n x = 2,\n y = 12,\n text = \"Hello, \" .. (self.state.name ~= \"\" and self.state.name or \"stranger\") .. \"!\",\n style = { fg = colors.green, bg = colors.lightBlue }\n }),\n\n -- Back button\n ReactiveUI.createElement(\"button\", {\n x = 2,\n y = 15,\n width = 16,\n height = 3,\n text = \"Back to Home\",\n style = { bg = colors.red, fg = colors.white, border = true, rounded = true },\n hoverStyle = { bg = colors.orange },\n onClick = function()\n ReactiveUI.navigate(\"/home\")\n end\n })\n }\n\n return elements\n end\n}\n",
  2160. ['/'] = "-- pages/test.lua\nreturn {\n name = \"TestPage\",\n render = function(self)\n local w, h = term.getSize()\n local y = 1\n local elements = {}\n table.insert(elements,\n ReactiveUI.createElement(\"text\",\n { x = 1, y = y, text = \"ReactiveUI Component Showcase\", style = { fg = colors.white } }))\n y = y + 2\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"Box:\", style = { fg = colors.yellow } }))\n y = y + 1\n table.insert(elements,\n ReactiveUI.createElement(\"box\",\n { x = 1, y = y, width = 20, height = 3, style = { bg = colors.lightBlue, border = true, rounded = true } }))\n y = y + 4\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"Text:\", style = { fg = colors.yellow } }))\n y = y + 1\n table.insert(elements,\n ReactiveUI.createElement(\"text\",\n { x = 1, y = y, text = \"This is a text element.\", style = { fg = colors.green } }))\n y = y + 2\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"Button:\", style = { fg = colors.yellow } }))\n y = y + 1\n table.insert(elements,\n ReactiveUI.createElement(\"button\",\n {\n x = 1,\n y = y,\n width = 10,\n height = 3,\n text = \"Click Me\",\n style = { bg = colors.green, fg = colors.white, border = true },\n onClick = function()\n ReactiveUI.navigate(\"/home\")\n end\n }))\n y = y + 4\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"Input:\", style = { fg = colors.yellow } }))\n y = y + 1\n local input = ReactiveUI.createElement(\"input\",\n { x = 1, y = y, width = 20, height = 3, value = \"\", placeholder = \"Type here...\", style = { border = true, fg = colors.black } })\n table.insert(elements, input)\n y = y + 4\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"Checkbox:\", style = { fg = colors.yellow } }))\n y = y + 1\n local checkbox = ReactiveUI.createElement(\"checkbox\",\n {\n x = 1,\n y = y,\n width = 20,\n height = 3,\n checked = false,\n label = \"Check me\",\n style = { border = true },\n onChange = function(\n checked)\n print(\"Checked: \" .. tostring(checked))\n end\n })\n table.insert(elements, checkbox)\n y = y + 4\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"Radio:\", style = { fg = colors.yellow } }))\n y = y + 1\n table.insert(elements,\n ReactiveUI.createElement(\"radio\",\n {\n x = 1,\n y = y,\n width = 20,\n height = 3,\n selected = true,\n label = \"Option 1\",\n style = { border = true },\n onChange = function(\n val)\n print(\"Selected: \" .. val)\n end,\n value = \"opt1\",\n group = \"test\"\n }))\n y = y + 3\n table.insert(elements,\n ReactiveUI.createElement(\"radio\",\n {\n x = 1,\n y = y,\n width = 20,\n height = 3,\n selected = false,\n label = \"Option 2\",\n style = { border = true },\n group =\n \"test\",\n onChange = function(val) print(\"Selected: \" .. val) end,\n value = \"opt2\"\n }))\n y = y + 4\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"Slider:\", style = { fg = colors.yellow } }))\n y = y + 1\n local slider = ReactiveUI.createElement(\"slider\",\n {\n x = 1,\n y = y,\n width = 20,\n height = 3,\n value = 50,\n min = 0,\n max = 100,\n showValue = true,\n style = { border = true },\n onChange = function(\n val)\n print(\"Slider: \" .. val)\n end\n })\n table.insert(elements, slider)\n y = y + 4\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"File Input:\", style = { fg = colors.yellow } }))\n y = y + 1\n local file = ReactiveUI.createElement(\"file\",\n {\n x = 1,\n y = y,\n width = 20,\n height = 3,\n value = \"\",\n placeholder = \"No file\",\n style = { border = true },\n onBrowse = function()\n print(\"Browse clicked\")\n end\n })\n table.insert(elements, file)\n y = y + 4\n table.insert(elements,\n ReactiveUI.createElement(\"text\", { x = 1, y = y, text = \"List:\", style = { fg = colors.yellow } }))\n y = y + 1\n local listItems = { \"Item 1\", \"Item 2\", \"Item 3\", \"Item 4\", \"Item 5\", \"Item 6\", \"Item 7\", \"Item 8\", \"Item 9\",\n \"Item 10\", \"Item 11\", \"Item 12\" }\n local list = ReactiveUI.createElement(\"list\",\n {\n x = 1,\n y = y,\n width = 20,\n height = 10,\n items = listItems,\n scroll = 0,\n selectedIndex = 1,\n style = { border = true },\n onSelect = function(\n idx, item)\n print(\"Selected: \" .. item)\n end\n })\n table.insert(elements, list)\n y = y + 12\n table.insert(elements,\n ReactiveUI.createElement(\"text\",\n { x = 1, y = y, text = \"End of showcase. Scroll to see all.\", style = { fg = colors.white } }))\n return elements\n end\n}\n",
  2161. ['/home'] = "return {\n name = \"HomePage\",\n onMount = function(self)\n self.state.x = 2\n self.state.y = 10\n end,\n render = function(self)\n local w, h = term.getSize()\n return {\n ReactiveUI.createElement(\"box\", {\n x = 1,\n y = 1,\n width = w,\n height = h,\n style = { bg = colors.blue }\n }),\n ReactiveUI.createElement(\"button\", {\n id = \"moveBtn\",\n x = self.state.x, -- This is the TARGET x value\n y = self.state.y,\n width = 12,\n height = 3,\n text = \"Move me!\",\n style = {\n bg = colors.pink,\n fg = colors.white\n },\n\n transitions = {\n x = { duration = 1, ease = \"easeoutCubic\" },\n y = { duration = 1, ease = \"easeoutCubic\" }\n },\n\n onClick = function()\n local newX\n local newY\n local sx, sy = term.getSize()\n repeat\n newX = math.random(1, sx - 10)\n until newX ~= self.state.x\n repeat\n newY = math.random(1, sy - 3)\n until newY ~= self.state.y\n self.state.x = newX\n self.state.y = newY\n end\n }),\n ReactiveUI.createElement(\"text\", {\n x = 2,\n y = 2,\n text = \"Home Page\",\n style = { fg = colors.white }\n }),\n ReactiveUI.createElement(\"button\", {\n x = 2,\n y = 5,\n width = 16,\n height = 3,\n text = \"Go to Profile\",\n style = { bg = colors.orange, fg = colors.white, border = true },\n onClick = function()\n ReactiveUI.navigate(\"/profile\")\n end\n }),\n ReactiveUI.createElement(\"button\", {\n x = 20,\n y = 5,\n width = 14,\n height = 3,\n text = \"Go to Test\",\n style = { bg = colors.black, fg = colors.white, border = true },\n onClick = function()\n ReactiveUI.navigate(\"/\")\n end\n })\n }\n end\n}\n",
  2162. }
  2163. local components = {
  2164. }
  2165. local app = ReactiveUI.createApp()
  2166. app:loadPagesFromStrings(pages)
  2167. app:loadComponentsFromStrings(components)
  2168. app:run()
  2169.  
Advertisement
Add Comment
Please, Sign In to add comment