76 Commits

Author SHA1 Message Date
25890131e0 更新 install.lua 2025-11-18 21:52:38 +08:00
github-actions[bot]
278b7ca1da Update config, BasaltLS definitions, full and core bundles, and changelog 2025-11-05 15:20:58 +00:00
Robert Jelic
5ddca6850a Enhance Menu component with dropdown functionality and properties 2025-11-05 16:20:32 +01:00
github-actions[bot]
0fbd348bba Update config, BasaltLS definitions, full and core bundles, and changelog 2025-11-05 13:15:03 +00:00
Robert Jelic
6ad7e0f51b Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-11-05 14:14:31 +01:00
Robert Jelic
47354f64ee Add easing functions and custom animation support to animation plugin 2025-11-05 14:14:30 +01:00
github-actions[bot]
6d347a7a58 Update config, BasaltLS definitions, full and core bundles, and changelog 2025-11-05 12:56:50 +00:00
Robert Jelic
71d23ee9a2 - Updated theme plugin to support changing themes on runtime
- Added 4 themes
- Added Cache System for States
2025-11-05 13:56:19 +01:00
Robert Jelic
e4ee937f0f Merge pull request #15 from Shlomo1412/patch-7
Update shield badges' styles
2025-11-05 02:03:45 +01:00
Robert Jelic
084c882a23 Merge pull request #14 from Idriss975/main
Add undropSymbol property + implement both self.dropSymbol & self.undropSymbol
2025-11-05 02:03:31 +01:00
Robert Jelic
234151d0a0 Merge branch 'main' into main 2025-11-05 02:03:22 +01:00
github-actions[bot]
52bef2a613 Update config, BasaltLS definitions, full and core bundles, and changelog 2025-11-05 00:37:18 +00:00
Robert Jelic
cf082af210 - Fixed reactive not using getResolved
- Fixed responsive using default value instead of lower priority state value
2025-11-05 01:36:52 +01:00
github-actions[bot]
424334cfae Update config, BasaltLS definitions, full and core bundles, and changelog 2025-11-04 21:41:18 +00:00
Robert Jelic
2ca7ad1e4c - Added comprehensive state management with conditional states, priority-based resolution, and property overrides
- Added responsive.lua with fluent builder API (:when()/:apply()/:otherwise()) for creating responsive layouts that react to parent size or custom conditions
- All elements now use getResolved() to check active states, enabling multiple responsive rules to coexist
2025-11-04 22:40:37 +01:00
Shlomo FI
0c80c958c0 Update shield badges' styles
A small fix to the first two badges in the README, which where on different style than the others. Also, added a badge for XCC Basalt designer
2025-11-04 21:05:15 +02:00
Idriss975
fd97eda9fe Add undropSymbol property + implement both self.dropSymbol & self.undropSymbol 2025-11-04 18:07:50 +01:00
github-actions[bot]
083a3b0b7b Update config, BasaltLS definitions, full and core bundles, and changelog 2025-11-04 09:01:45 +00:00
Robert Jelic
b4c987d2bd Changed default core-requirement for tons of elements and plugins 2025-11-04 10:01:09 +01:00
Robert Jelic
1ade7ff00a Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-11-04 09:53:51 +01:00
Robert Jelic
24bd3a0398 Added Core Version to the installer as default option 2025-11-04 09:53:49 +01:00
github-actions[bot]
1b2c6021e3 Update config, BasaltLS definitions, full and core bundles, and changelog 2025-11-04 08:44:02 +00:00
Robert Jelic
5dd2c77dbc - Update bundler to support core and full versions
- Update ScrollFrame's properties
2025-11-04 09:42:44 +01:00
github-actions[bot]
2b0f14dc56 Update config, BasaltLS definitions, bundle and changelog 2025-11-03 12:29:39 +00:00
Robert Jelic
465f544ff7 Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-11-03 13:29:14 +01:00
Robert Jelic
c723c66004 fixed layout manager so that we know if size got manually changed
fixed scrollframe not sending scroll events to its children
fixed scrollframe scrolling even if mouse is not hovering over the element
improved the behaviour of the flow layout
2025-11-03 13:29:11 +01:00
github-actions[bot]
6c07a8074a Update config, BasaltLS definitions, bundle and changelog 2025-11-02 17:37:25 +00:00
Robert Jelic
250ce886ca Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-11-02 18:36:57 +01:00
Robert Jelic
dc51a73749 Fixed a bug in reactive not calling the observers
Fixed a layout issue (not updating properly)
added the flow layout
2025-11-02 18:36:55 +01:00
github-actions[bot]
e605840e06 Update config, BasaltLS definitions, bundle and changelog 2025-11-02 09:56:27 +00:00
Robert Jelic
7375c33bbb Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-11-02 10:55:52 +01:00
Robert Jelic
482dc266bc Fixed ComboBox (updated for to the rework dropdown version)
Fixed Dropdown rendering 1 Item more than it shoud
Added dropdown:prioritize() when focusing the dropdown
2025-11-02 10:55:49 +01:00
github-actions[bot]
09081e9636 Update config, BasaltLS definitions, bundle and changelog 2025-11-01 09:48:19 +00:00
Robert Jelic
f14928859c Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-11-01 10:47:58 +01:00
Robert Jelic
82d8d6cc43 - Added Layout System
- Added Grid Layout
- Removed Flexbox

this will make it easy to apply layouts on all container types.
2025-11-01 10:47:56 +01:00
github-actions[bot]
e92f137fb4 Update config, BasaltLS definitions, bundle and changelog 2025-11-01 01:55:40 +00:00
Robert Jelic
278ad08c04 Fixed install.lua issue 2025-11-01 02:54:57 +01:00
github-actions[bot]
5161b51323 Update config, BasaltLS definitions, bundle and changelog 2025-10-31 17:56:17 +00:00
Robert Jelic
3e79321143 Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-31 18:55:50 +01:00
Robert Jelic
a97f799b4b Fixed a issue with frame events
Fixed Collection setItems not triggering render
2025-10-31 18:55:47 +01:00
github-actions[bot]
8afdb785e0 Update config, BasaltLS definitions, bundle and changelog 2025-10-30 13:13:22 +00:00
Robert Jelic
a967cde115 Add Breadcrumb, ContextMenu, Dialog, and Toast elements with event handling and rendering capabilities
- Implemented Breadcrumb element for navigation with selectable segments.
- Created ContextMenu element for displaying clickable items and submenus.
- Developed Dialog element for alert, confirm, and prompt dialogs with modal support.
- Introduced Toast element for temporary message notifications with auto-hide functionality.
2025-10-30 14:12:54 +01:00
github-actions[bot]
196cf93f68 Update config, BasaltLS definitions, bundle and changelog 2025-10-30 08:43:00 +00:00
Robert Jelic
7649c97c71 Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-30 09:42:34 +01:00
Robert Jelic
8c73572a6c Enhance documentation for ScrollFrame and TabControl elements with usage examples 2025-10-30 09:42:32 +01:00
github-actions[bot]
9120191008 Update config, BasaltLS definitions, bundle and changelog 2025-10-30 08:24:18 +00:00
Robert Jelic
fd67d0e4c7 Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-30 09:23:35 +01:00
Robert Jelic
7871234251 Refactor documentation and examples for Display, Frame, Program, SideNav, TabControl, and Tree elements 2025-10-30 09:23:33 +01:00
github-actions[bot]
4ba25b73b6 Update config, BasaltLS definitions, bundle and changelog 2025-10-30 07:43:38 +00:00
Robert Jelic
69a0254f84 Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-30 08:43:14 +01:00
Robert Jelic
c6cb1ac670 Improve documentation formatting for Image and Tree elements 2025-10-30 08:43:12 +01:00
github-actions[bot]
1f5a4b9c3a Update config, BasaltLS definitions, bundle and changelog 2025-10-30 07:40:57 +00:00
Robert Jelic
83c27d824f Refactor documentation for Container, Table, and Tree elements to improve clarity and usage examples 2025-10-30 08:40:14 +01:00
Robert Jelic
fa25007de6 Generation fix 2025-10-30 08:20:28 +01:00
github-actions[bot]
535b109dee Update config, BasaltLS definitions, bundle and changelog 2025-10-30 07:14:29 +00:00
Robert Jelic
c2621bbafa XML added attribute version for states 2025-10-30 08:14:06 +01:00
github-actions[bot]
bf8c6d695c Update config, BasaltLS definitions, bundle and changelog 2025-10-30 06:43:41 +00:00
Robert Jelic
8eb3a095d3 Fix mouse drag condition in Frame and set default vOffset in List render function 2025-10-30 07:43:18 +01:00
github-actions[bot]
ba82eaf2e9 Update config, BasaltLS definitions, bundle and changelog 2025-10-30 06:33:28 +00:00
Robert Jelic
69034e9a2a Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-30 07:32:48 +01:00
Robert Jelic
9dbdc82167 Reactive State Fix 2025-10-30 07:32:46 +01:00
github-actions[bot]
a94f78c2bc Update config, BasaltLS definitions, bundle and changelog 2025-10-29 17:41:55 +00:00
Robert Jelic
7099b5c548 Docs fixes 2025-10-29 18:41:32 +01:00
github-actions[bot]
400f19d8f1 Update config, BasaltLS definitions, bundle and changelog 2025-10-29 17:18:11 +00:00
Robert Jelic
167fc8ef81 Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-29 18:17:42 +01:00
Robert Jelic
d2e0c30b80 Small note addition 2025-10-29 18:17:40 +01:00
github-actions[bot]
c05dbd3b8c Update config, BasaltLS definitions, bundle and changelog 2025-10-29 16:56:09 +00:00
Robert Jelic
5a1ec672a7 Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-29 17:55:31 +01:00
Robert Jelic
6f14eadf0a - Updated DocsGenerator to support run code and item tables
- Updated Table to support new Collection System (could break things, sorry)
- Updated Tree to support new Collection System
- Added experimental ScrollFrame
- Updated Menu to support Collection System
2025-10-29 17:55:29 +01:00
github-actions[bot]
4c856bf710 Update config, BasaltLS definitions, bundle and changelog 2025-10-29 08:11:10 +00:00
Robert Jelic
41bd5bdf04 - Added DropDown Scrollbar
- Added List Scrollbar
- Added Statemanagementsystem for XML
2025-10-29 09:10:23 +01:00
github-actions[bot]
c7f63b7684 Update config, BasaltLS definitions, bundle and changelog 2025-10-27 16:47:12 +00:00
Robert Jelic
eaad81dc1a Merge branch 'main' of https://github.com/Pyroxenium/Basalt2 2025-10-27 17:46:39 +01:00
Robert Jelic
2244c84323 Refactor documentation for addBorder and getRelativePosition methods in VisualElement class 2025-10-27 17:46:37 +01:00
github-actions[bot]
9737c4bb64 Update config, BasaltLS definitions, bundle and changelog 2025-10-27 07:26:29 +00:00
Robert Jelic
b96875a3e9 - Added a new system to dynamically require source from multiple locations (including web)
- Added the Collection Element and moved parts of the List logic to collection
- Added a State Management System
- Added a better system to change the position/size of elements
- Removed the state plugin
2025-10-27 08:25:58 +01:00
73 changed files with 22577 additions and 8335 deletions

View File

@@ -76,6 +76,6 @@ jobs:
run: | run: |
git config --global user.name 'github-actions[bot]' git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add config.lua BasaltLS.lua release/basalt.lua CHANGELOG.md git add config.lua BasaltLS.lua release/basalt-full.lua release/basalt-core.lua CHANGELOG.md
git commit -m "Update config, BasaltLS definitions, bundle and changelog" || exit 0 git commit -m "Update config, BasaltLS definitions, full and core bundles, and changelog" || exit 0
git push git push

4
.gitignore vendored
View File

@@ -11,8 +11,6 @@ Flexbox2.lua
markdown.lua markdown.lua
markdown2.lua markdown2.lua
SplitPane.lua SplitPane.lua
Accordion.lua
Stepper.lua Stepper.lua
Drawer.lua Drawer.lua
Breadcrumb.lua DockLayout.lua
Dialog.lua

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
# Basalt 2 - A UI Framework for CC:Tweaked # Basalt 2 - A UI Framework for CC:Tweaked
![GitHub Repo stars](https://img.shields.io/github/stars/Pyroxenium/Basalt2?style=for-the-badge) ![GitHub Repo stars](https://img.shields.io/github/stars/Pyroxenium/Basalt2)
[![Discord](https://img.shields.io/discord/976905222251233320?label=Discord&style=for-the-badge)](https://discord.gg/yNNnmBVBpE) [![Discord](https://img.shields.io/discord/976905222251233320?label=Discord)](https://discord.gg/yNNnmBVBpE)
[![Download on PineStore](https://raster.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpinestore.cc%2Fapi%2Fproject%2F15&query=%24.project.downloads&suffix=%20downloads&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNzYuOTA0IiBoZWlnaHQ9Ijg5LjI5NSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDc2OS4wNCA4OTIuOTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI%2BCiA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuNzQgLTQuNjgyNikiIGZpbGw9IiM5YWIyZjIiPgogIDxwYXRoIGQ9Im00MTAgODUxYzAtMTIgMjYtMjEgNTgtMjEgMTUgMCAyMiA0IDE3IDktMTQgMTItNzUgMjItNzUgMTJ6Ii8%2BCiAgPHBhdGggZD0ibTU4NSA3NDJjLTEtNDkgNC03MiAxNi04NSAyMi0yNCAzMC02OCAxNi04Ni0xMi0xNC0yNy0zOS00OC03OC0xMC0xOS05LTI2IDQtNDEgMjItMjQgMjEtNjctMi0xNDQtMjEtNjktMzktMTQ0LTQ4LTE5NS00LTI2LTItMzMgMTEtMzMgMzEgMCAxMTIgMzMgMTQxIDU4IDI4IDIzIDgxIDkyIDcxIDkyLTIgMCA1IDI2IDE2IDU3IDI4IDc5IDI5IDIyNCAzIDMwOC0xMCAzMy0xOSA2Mi0xOSA2NS00IDI2LTEzMiAxNTAtMTU1IDE1MC0zIDAtNi0zMC02LTY4eiIvPgogIDxwYXRoIGQ9Im02OCA2NzNjLTcyLTEwOS03MS0yNzggMy00MjMgMzYtNzEgNjItMTAwIDEyOC0xNDAgNDMtMjcgNjUtMzQgMTE4LTM2IDEwMC00IDk4IDExLTE5IDEzNi0zNCAzNy03OCA4OC05NiAxMTMtMjggMzktMzEgNDgtMjEgNjUgMTEgMTcgNiAyNy0zMyA3OS00MCA1My00NCA2Mi0zMiA3OCAxNyAyMyAxOCA1NyAyIDczLTYgNi0xNCAzMS0xNyA1NC02IDQyLTYgNDItMzMgMXoiLz4KIDwvZz4KIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNC43NCAtNC42ODI2KSIgZmlsbD0iIzU5YTY0ZiI%2BCiAgPHBhdGggZD0ibTM2NSA4MTNjLTUzLTYtMTM5LTMzLTE5Mi02MS02OC0zNS04My02Ny01OC0xMjIgMjYtNTkgNDAtNjcgNzgtNDkgNjggMzMgMTY3IDU4IDI2NiA2OSA1OCA1IDEwNiAxMiAxMDkgMTQgMiAzIDYgMzIgOSA2NSA4IDg1IDAgOTEtMTAxIDkwLTQ0LTEtOTQtNC0xMTEtNnoiLz4KICA8cGF0aCBkPSJtNDEwIDQ1OWMtNjctNy0xNjAtMjktMTk5LTQ4LTI3LTE0LTM0LTM2LTIwLTYzIDIxLTM4IDk3LTEzNiAxNTAtMTkzIDI1LTI3IDU4LTcxIDczLTk3IDI1LTQzIDMxLTQ3IDU0LTQyIDQwIDEwIDQyIDEyIDQyIDUyIDAgMjAgNiA1NyAxNCA4MiAyNCA3MyA1NCAxOTIgNjIgMjM2IDUgMzUgMyA0NS0xNSA2My0yMyAyMy0zNiAyNC0xNjEgMTB6Ii8%2BCiA8L2c%2BCiA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuNzQgLTQuNjgyNikiIGZpbGw9IiM3ZWNiMjUiPgogIDxwYXRoIGQ9Im01NTggNjc0Yy0yLTItNTEtOS0xMDktMTQtMTAyLTExLTIwNC0zNy0yNjQtNjktMTYtOC0zMi0xNC0zNC0xMi00IDMtMzEtNDgtMzEtNjEgMC01IDIxLTMxIDQ2LTU4IDUxLTU0IDcxLTYwIDEzMC0zNSAxOSA4IDgzIDE5IDE0MiAyNSA1OCA2IDEwNyAxMiAxMDcgMTNzMTUgMjYgMzMgNTZjMjcgNDMgMzIgNjMgMzAgOTktMiAzNS04IDQ3LTI1IDUzLTExIDQtMjMgNi0yNSAzeiIvPgogPC9nPgogPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE0Ljc0IC00LjY4MjYpIiBmaWxsPSIjZWNlZGVmIj4KICA8cGF0aCBkPSJtMjYwIDg5MGMtMzQtOC03MC00MS03MC02NSAwLTYtOS0yMC0yMC0zMHMtMjAtMjItMjAtMjctMTMtMjEtMzAtMzVjLTM1LTI5LTQxLTgzLTEzLTEyMiAxNS0yMiAxNS0yNi0xLTU2LTE4LTMzLTE4LTMzIDI3LTkxIDI4LTM2IDQyLTYzIDM2LTY4LTIzLTI1IDktNzggMTIwLTE5NyAzNi0zOCA3Mi04MSA4Mi05NiAxMC0xNCAyNS0zMCAzMy0zNSAzNi0yMCA3IDMyLTUzIDk3LTQ4IDUxLTEyNiAxNTAtMTQ5IDE4OS0xMCAxOC05IDI0IDEwIDQwIDIzIDE5IDIzIDE5LTI5IDcxLTUzIDUyLTUzIDUyLTM4IDgyIDE0IDI4IDE0IDMzLTEwIDc2LTMyIDU3LTIzIDgxIDQ2IDEyMCAzNCAxOSA0OSAzMyA0NSA0Mi0xNCAzNyAzNiA3NSA5OCA3NSAyNSAwIDQwLTcgNTQtMjUgMTgtMjMgMjctMjUgOTUtMjUgOTQgMCAxMDItOCA5My04OS02LTUzLTUtNTkgMTQtNjQgMzItOCAyNi02NC0xNS0xMzItMzUtNTgtMzUtNTgtOS04MiAyMS0xOSAyNC0yOSAxOS01Ni0xMC00Ny00NC0xNzUtNjEtMjI3LTgtMjUtMTQtNjItMTQtODMgMC0yNy01LTM5LTE3LTQzLTEwLTMtMjUtOC0zMy0xMC0xMi00LTEyLTYtMS0xNCAyNy0xNiA1NiA1IDY5IDUxIDM1IDExNyA0MyAxNDggNDYgMTcwIDIgMTMgMTEgNTEgMjEgODQgMjEgNzEgMjEgMTIxIDAgMTQ1LTE0IDE1LTEzIDE5IDUgNDMgMTEgMTQgMjAgMzAgMjAgMzVzNyAxNSAxNSAyMmMyMSAxNyAxNiA3NS0xMCAxMDItMTggMTktMjAgMzItMTcgNzkgNCA1MCAyIDU4LTE5IDcyLTEyIDktNTAgMTktODMgMjMtNDUgNS02NSAxMy04MyAzMi0yNiAyOC05MiAzOC0xNTMgMjJ6Ii8%2BCiA8L2c%2BCiA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuNzQgLTQuNjgyNikiIGZpbGw9IiM3ZTY3NGQiPgogIDxwYXRoIGQ9Im0yNDggODU0Yy0zMC0xNi00Ny01OS0zMC03NiA4LTggMjMtNyA1NCAyIDI0IDcgNjEgMTQgODMgMTcgNTQgNyA1OSAxNSAzNSA0Ni0xOCAyMy0yOSAyNy02OCAyNy0yNi0xLTU5LTctNzQtMTZ6Ii8%2BCiA8L2c%2BCjwvc3ZnPgo%3D&label=PineStore)](https://pinestore.cc/projects/15/basalt) [![Download on PineStore](https://raster.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpinestore.cc%2Fapi%2Fproject%2F15&query=%24.project.downloads&suffix=%20downloads&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNzYuOTA0IiBoZWlnaHQ9Ijg5LjI5NSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDc2OS4wNCA4OTIuOTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI%2BCiA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuNzQgLTQuNjgyNikiIGZpbGw9IiM5YWIyZjIiPgogIDxwYXRoIGQ9Im00MTAgODUxYzAtMTIgMjYtMjEgNTgtMjEgMTUgMCAyMiA0IDE3IDktMTQgMTItNzUgMjItNzUgMTJ6Ii8%2BCiAgPHBhdGggZD0ibTU4NSA3NDJjLTEtNDkgNC03MiAxNi04NSAyMi0yNCAzMC02OCAxNi04Ni0xMi0xNC0yNy0zOS00OC03OC0xMC0xOS05LTI2IDQtNDEgMjItMjQgMjEtNjctMi0xNDQtMjEtNjktMzktMTQ0LTQ4LTE5NS00LTI2LTItMzMgMTEtMzMgMzEgMCAxMTIgMzMgMTQxIDU4IDI4IDIzIDgxIDkyIDcxIDkyLTIgMCA1IDI2IDE2IDU3IDI4IDc5IDI5IDIyNCAzIDMwOC0xMCAzMy0xOSA2Mi0xOSA2NS00IDI2LTEzMiAxNTAtMTU1IDE1MC0zIDAtNi0zMC02LTY4eiIvPgogIDxwYXRoIGQ9Im02OCA2NzNjLTcyLTEwOS03MS0yNzggMy00MjMgMzYtNzEgNjItMTAwIDEyOC0xNDAgNDMtMjcgNjUtMzQgMTE4LTM2IDEwMC00IDk4IDExLTE5IDEzNi0zNCAzNy03OCA4OC05NiAxMTMtMjggMzktMzEgNDgtMjEgNjUgMTEgMTcgNiAyNy0zMyA3OS00MCA1My00NCA2Mi0zMiA3OCAxNyAyMyAxOCA1NyAyIDczLTYgNi0xNCAzMS0xNyA1NC02IDQyLTYgNDItMzMgMXoiLz4KIDwvZz4KIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNC43NCAtNC42ODI2KSIgZmlsbD0iIzU5YTY0ZiI%2BCiAgPHBhdGggZD0ibTM2NSA4MTNjLTUzLTYtMTM5LTMzLTE5Mi02MS02OC0zNS04My02Ny01OC0xMjIgMjYtNTkgNDAtNjcgNzgtNDkgNjggMzMgMTY3IDU4IDI2NiA2OSA1OCA1IDEwNiAxMiAxMDkgMTQgMiAzIDYgMzIgOSA2NSA4IDg1IDAgOTEtMTAxIDkwLTQ0LTEtOTQtNC0xMTEtNnoiLz4KICA8cGF0aCBkPSJtNDEwIDQ1OWMtNjctNy0xNjAtMjktMTk5LTQ4LTI3LTE0LTM0LTM2LTIwLTYzIDIxLTM4IDk3LTEzNiAxNTAtMTkzIDI1LTI3IDU4LTcxIDczLTk3IDI1LTQzIDMxLTQ3IDU0LTQyIDQwIDEwIDQyIDEyIDQyIDUyIDAgMjAgNiA1NyAxNCA4MiAyNCA3MyA1NCAxOTIgNjIgMjM2IDUgMzUgMyA0NS0xNSA2My0yMyAyMy0zNiAyNC0xNjEgMTB6Ii8%2BCiA8L2c%2BCiA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuNzQgLTQuNjgyNikiIGZpbGw9IiM3ZWNiMjUiPgogIDxwYXRoIGQ9Im01NTggNjc0Yy0yLTItNTEtOS0xMDktMTQtMTAyLTExLTIwNC0zNy0yNjQtNjktMTYtOC0zMi0xNC0zNC0xMi00IDMtMzEtNDgtMzEtNjEgMC01IDIxLTMxIDQ2LTU4IDUxLTU0IDcxLTYwIDEzMC0zNSAxOSA4IDgzIDE5IDE0MiAyNSA1OCA2IDEwNyAxMiAxMDcgMTNzMTUgMjYgMzMgNTZjMjcgNDMgMzIgNjMgMzAgOTktMiAzNS04IDQ3LTI1IDUzLTExIDQtMjMgNi0yNSAzeiIvPgogPC9nPgogPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE0Ljc0IC00LjY4MjYpIiBmaWxsPSIjZWNlZGVmIj4KICA8cGF0aCBkPSJtMjYwIDg5MGMtMzQtOC03MC00MS03MC02NSAwLTYtOS0yMC0yMC0zMHMtMjAtMjItMjAtMjctMTMtMjEtMzAtMzVjLTM1LTI5LTQxLTgzLTEzLTEyMiAxNS0yMiAxNS0yNi0xLTU2LTE4LTMzLTE4LTMzIDI3LTkxIDI4LTM2IDQyLTYzIDM2LTY4LTIzLTI1IDktNzggMTIwLTE5NyAzNi0zOCA3Mi04MSA4Mi05NiAxMC0xNCAyNS0zMCAzMy0zNSAzNi0yMCA3IDMyLTUzIDk3LTQ4IDUxLTEyNiAxNTAtMTQ5IDE4OS0xMCAxOC05IDI0IDEwIDQwIDIzIDE5IDIzIDE5LTI5IDcxLTUzIDUyLTUzIDUyLTM4IDgyIDE0IDI4IDE0IDMzLTEwIDc2LTMyIDU3LTIzIDgxIDQ2IDEyMCAzNCAxOSA0OSAzMyA0NSA0Mi0xNCAzNyAzNiA3NSA5OCA3NSAyNSAwIDQwLTcgNTQtMjUgMTgtMjMgMjctMjUgOTUtMjUgOTQgMCAxMDItOCA5My04OS02LTUzLTUtNTkgMTQtNjQgMzItOCAyNi02NC0xNS0xMzItMzUtNTgtMzUtNTgtOS04MiAyMS0xOSAyNC0yOSAxOS01Ni0xMC00Ny00NC0xNzUtNjEtMjI3LTgtMjUtMTQtNjItMTQtODMgMC0yNy01LTM5LTE3LTQzLTEwLTMtMjUtOC0zMy0xMC0xMi00LTEyLTYtMS0xNCAyNy0xNiA1NiA1IDY5IDUxIDM1IDExNyA0MyAxNDggNDYgMTcwIDIgMTMgMTEgNTEgMjEgODQgMjEgNzEgMjEgMTIxIDAgMTQ1LTE0IDE1LTEzIDE5IDUgNDMgMTEgMTQgMjAgMzAgMjAgMzVzNyAxNSAxNSAyMmMyMSAxNyAxNiA3NS0xMCAxMDItMTggMTktMjAgMzItMTcgNzkgNCA1MCAyIDU4LTE5IDcyLTEyIDktNTAgMTktODMgMjMtNDUgNS02NSAxMy04MyAzMi0yNiAyOC05MiAzOC0xNTMgMjJ6Ii8%2BCiA8L2c%2BCiA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuNzQgLTQuNjgyNikiIGZpbGw9IiM3ZTY3NGQiPgogIDxwYXRoIGQ9Im0yNDggODU0Yy0zMC0xNi00Ny01OS0zMC03NiA4LTggMjMtNyA1NCAyIDI0IDcgNjEgMTQgODMgMTcgNTQgNyA1OSAxNSAzNSA0Ni0xOCAyMy0yOSAyNy02OCAyNy0yNi0xLTU5LTctNzQtMTZ6Ii8%2BCiA8L2c%2BCjwvc3ZnPgo%3D&label=PineStore)](https://pinestore.cc/projects/15/basalt)
[![DeepWiki](https://img.shields.io/badge/DeepWiki-Pyroxenium%2FBasalt2-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/Pyroxenium/Basalt2) [![DeepWiki](https://img.shields.io/badge/DeepWiki-Pyroxenium%2FBasalt2-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/Pyroxenium/Basalt2)
[![Badge](https://img.shields.io/badge/🎨_Use_GUI-Designer-brightgreen)](https://xcc.madefor.cc/basalt-generator.html)
Welcome, Welcome,

View File

@@ -1,448 +1,517 @@
return { return {
["categories"] = { ["categories"] = {
["plugins"] = {
["files"] = {
["xml"] = {
["default"] = true,
["size"] = 9940,
["path"] = "plugins/xml.lua",
["requires"] = {
},
["description"] = "",
},
["canvas"] = {
["default"] = true,
["size"] = 7873,
["path"] = "plugins/canvas.lua",
["requires"] = {
},
["description"] = "",
},
["animation"] = {
["default"] = true,
["size"] = 18421,
["path"] = "plugins/animation.lua",
["requires"] = {
},
["description"] = "",
},
["benchmark"] = {
["default"] = true,
["size"] = 12581,
["path"] = "plugins/benchmark.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "",
},
["theme"] = {
["default"] = true,
["size"] = 7042,
["path"] = "plugins/theme.lua",
["requires"] = {
},
["description"] = "",
},
["debug"] = {
["default"] = true,
["size"] = 6250,
["path"] = "plugins/debug.lua",
["requires"] = {
},
["description"] = "",
},
["reactive"] = {
["default"] = true,
["size"] = 8619,
["path"] = "plugins/reactive.lua",
["requires"] = {
},
["description"] = "",
},
["state"] = {
["default"] = true,
["size"] = 6896,
["path"] = "plugins/state.lua",
["requires"] = {
[1] = "Container",
},
["description"] = "",
},
},
["description"] = "Plugins",
},
["core"] = {
["files"] = {
["log"] = {
["default"] = true,
["size"] = 3142,
["path"] = "log.lua",
["requires"] = {
},
["description"] = "",
},
["render"] = {
["default"] = true,
["size"] = 12422,
["path"] = "render.lua",
["requires"] = {
},
["description"] = "",
},
["init"] = {
["default"] = true,
["size"] = 622,
["path"] = "init.lua",
["requires"] = {
},
["description"] = "",
},
["elementManager"] = {
["default"] = true,
["size"] = 6297,
["path"] = "elementManager.lua",
["requires"] = {
},
["description"] = "",
},
["main"] = {
["default"] = true,
["size"] = 14085,
["path"] = "main.lua",
["requires"] = {
},
["description"] = "",
},
["propertySystem"] = {
["default"] = true,
["size"] = 15524,
["path"] = "propertySystem.lua",
["requires"] = {
},
["description"] = "",
},
["errorManager"] = {
["default"] = true,
["size"] = 3789,
["path"] = "errorManager.lua",
["requires"] = {
},
["description"] = "",
},
},
["description"] = "Core Files",
},
["libraries"] = { ["libraries"] = {
["files"] = { ["files"] = {
["colorHex"] = {
["default"] = true,
["size"] = 132,
["path"] = "libraries/colorHex.lua",
["requires"] = {
},
["description"] = "",
},
["utils"] = { ["utils"] = {
["default"] = true, ["default"] = true,
["size"] = 2661, ["size"] = 2661,
["path"] = "libraries/utils.lua", ["path"] = "libraries/utils.lua",
["description"] = "",
["requires"] = { ["requires"] = {
}, },
["description"] = "",
}, },
["expect"] = { ["expect"] = {
["default"] = true, ["default"] = true,
["size"] = 846, ["size"] = 846,
["path"] = "libraries/expect.lua", ["path"] = "libraries/expect.lua",
["description"] = "",
["requires"] = { ["requires"] = {
}, },
},
["collectionentry"] = {
["default"] = true,
["size"] = 3551,
["path"] = "libraries/collectionentry.lua",
["description"] = "", ["description"] = "",
["requires"] = {
},
},
["colorHex"] = {
["default"] = true,
["size"] = 132,
["path"] = "libraries/colorHex.lua",
["description"] = "",
["requires"] = {
},
}, },
}, },
["description"] = "Libraries", ["description"] = "Libraries",
}, },
["core"] = {
["files"] = {
["layoutManager"] = {
["default"] = true,
["size"] = 3633,
["path"] = "layoutManager.lua",
["description"] = "",
["requires"] = {
},
},
["errorManager"] = {
["default"] = true,
["size"] = 3789,
["path"] = "errorManager.lua",
["description"] = "",
["requires"] = {
},
},
["elementManager"] = {
["default"] = true,
["size"] = 15411,
["path"] = "elementManager.lua",
["description"] = "",
["requires"] = {
},
},
["main"] = {
["default"] = true,
["size"] = 19883,
["path"] = "main.lua",
["description"] = "",
["requires"] = {
},
},
["render"] = {
["default"] = true,
["size"] = 12422,
["path"] = "render.lua",
["description"] = "",
["requires"] = {
},
},
["init"] = {
["default"] = true,
["size"] = 622,
["path"] = "init.lua",
["description"] = "",
["requires"] = {
},
},
["propertySystem"] = {
["default"] = true,
["size"] = 18433,
["path"] = "propertySystem.lua",
["description"] = "",
["requires"] = {
},
},
["log"] = {
["default"] = true,
["size"] = 3142,
["path"] = "log.lua",
["description"] = "",
["requires"] = {
},
},
},
["description"] = "Core Files",
},
["elements"] = { ["elements"] = {
["files"] = { ["files"] = {
["SideNav"] = { ["ScrollFrame"] = {
["default"] = true, ["default"] = false,
["size"] = 20221, ["size"] = 17831,
["path"] = "elements/SideNav.lua", ["path"] = "elements/ScrollFrame.lua",
["description"] = "A scrollable container that automatically displays scrollbars when content overflows.",
["requires"] = { ["requires"] = {
[1] = "Container", [1] = "Container",
}, },
["description"] = "A SideNav element that provides sidebar navigation with multiple content areas.",
},
["ProgressBar"] = {
["default"] = true,
["size"] = 3397,
["path"] = "elements/ProgressBar.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "",
},
["Slider"] = {
["default"] = true,
["size"] = 4977,
["path"] = "elements/Slider.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "",
},
["VisualElement"] = {
["default"] = true,
["size"] = 22428,
["path"] = "elements/VisualElement.lua",
["requires"] = {
[1] = "BaseElement",
},
["description"] = "The Visual Element class which is the base class for all visual UI elements",
},
["List"] = {
["default"] = true,
["size"] = 8758,
["path"] = "elements/List.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "A scrollable list of selectable items",
},
["Menu"] = {
["default"] = true,
["size"] = 4679,
["path"] = "elements/Menu.lua",
["requires"] = {
[1] = "List",
},
["description"] = "A horizontal menu bar with selectable items.",
},
["Timer"] = {
["default"] = true,
["size"] = 2914,
["path"] = "elements/Timer.lua",
["requires"] = {
[1] = "BaseElement",
},
["description"] = "",
},
["FlexBox"] = {
["default"] = true,
["size"] = 32431,
["path"] = "elements/FlexBox.lua",
["requires"] = {
[1] = "Container",
},
["description"] = "A flexbox container that arranges its children in a flexible layout.",
},
["Container"] = {
["default"] = true,
["size"] = 26148,
["path"] = "elements/Container.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "The container class. It is a visual element that can contain other elements. It is the base class for all containers",
},
["Image"] = {
["default"] = false,
["size"] = 15125,
["path"] = "elements/Image.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "An element that displays an image in bimg format",
},
["Graph"] = {
["default"] = false,
["size"] = 6989,
["path"] = "elements/Graph.lua",
["requires"] = {
},
["description"] = "A point based graph element",
},
["TabControl"] = {
["default"] = true,
["size"] = 18961,
["path"] = "elements/TabControl.lua",
["requires"] = {
[1] = "Container",
},
["description"] = "A TabControl element that provides tabbed interface with multiple content areas.",
},
["Display"] = {
["default"] = false,
["size"] = 5071,
["path"] = "elements/Display.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "The Display is a special element which uses the CC Window API which you can use.",
},
["TextBox"] = {
["default"] = false,
["size"] = 43466,
["path"] = "elements/TextBox.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "A multi-line text editor component with cursor support and text manipulation features",
},
["Program"] = {
["default"] = true,
["size"] = 11430,
["path"] = "elements/Program.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "",
},
["CheckBox"] = {
["default"] = true,
["size"] = 3731,
["path"] = "elements/CheckBox.lua",
["requires"] = {
},
["description"] = "This is a checkbox. It is a visual element that can be checked.",
},
["ComboBox"] = {
["default"] = false,
["size"] = 15122,
["path"] = "elements/ComboBox.lua",
["requires"] = {
[1] = "DropDown",
},
["description"] = "A ComboBox that combines dropdown selection with editable text input",
},
["BaseFrame"] = {
["default"] = true,
["size"] = 9017,
["path"] = "elements/BaseFrame.lua",
["requires"] = {
[1] = "Container",
},
["description"] = "This is the base frame class. It is the root element of all elements and the only element without a parent.",
},
["ScrollBar"] = {
["default"] = true,
["size"] = 9665,
["path"] = "elements/ScrollBar.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "A ScrollBar element that can be attached to other elements to control their scroll properties.",
},
["Table"] = {
["default"] = true,
["size"] = 16214,
["path"] = "elements/Table.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "",
},
["Input"] = {
["default"] = true,
["size"] = 9558,
["path"] = "elements/Input.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "A text input field with various features",
},
["LineChart"] = {
["default"] = false,
["size"] = 3227,
["path"] = "elements/LineChart.lua",
["requires"] = {
},
["description"] = "",
},
["Label"] = {
["default"] = true,
["size"] = 3092,
["path"] = "elements/Label.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "A simple text display element that automatically resizes its width based on the text content.",
}, },
["BaseElement"] = { ["BaseElement"] = {
["default"] = true, ["default"] = true,
["size"] = 10012, ["size"] = 18777,
["path"] = "elements/BaseElement.lua", ["path"] = "elements/BaseElement.lua",
["description"] = "The base class for all UI elements in Basalt.",
["requires"] = { ["requires"] = {
}, },
["description"] = "The base class for all UI elements in Basalt.",
}, },
["Button"] = { ["Button"] = {
["default"] = true, ["default"] = true,
["size"] = 2540, ["size"] = 2461,
["path"] = "elements/Button.lua", ["path"] = "elements/Button.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "The Button is a standard button element with click handling and state management.", ["description"] = "The Button is a standard button element with click handling and state management.",
},
["Switch"] = {
["default"] = true,
["size"] = 3269,
["path"] = "elements/Switch.lua",
["requires"] = { ["requires"] = {
[1] = "VisualElement", [1] = "VisualElement",
}, },
["description"] = "The Switch is a standard Switch element with click handling and state management.",
},
["Tree"] = {
["default"] = true,
["size"] = 8519,
["path"] = "elements/Tree.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "",
},
["DropDown"] = {
["default"] = false,
["size"] = 7642,
["path"] = "elements/DropDown.lua",
["requires"] = {
[1] = "List",
},
["description"] = "A DropDown menu that shows a list of selectable items",
},
["BarChart"] = {
["default"] = true,
["size"] = 3590,
["path"] = "elements/BarChart.lua",
["requires"] = {
},
["description"] = "",
}, },
["BigFont"] = { ["BigFont"] = {
["default"] = false, ["default"] = false,
["size"] = 21649, ["size"] = 21675,
["path"] = "elements/BigFont.lua", ["path"] = "elements/BigFont.lua",
["description"] = "",
["requires"] = { ["requires"] = {
[1] = "VisualElement", [1] = "VisualElement",
}, },
["description"] = "",
}, },
["Frame"] = { ["Container"] = {
["default"] = true, ["default"] = true,
["size"] = 6508, ["size"] = 27731,
["path"] = "elements/Frame.lua", ["path"] = "elements/Container.lua",
["description"] = "The container class. It is a visual element that can contain other elements. It is the base class for all containers",
["requires"] = {
[1] = "VisualElement",
},
},
["TextBox"] = {
["default"] = false,
["size"] = 44462,
["path"] = "elements/TextBox.lua",
["description"] = "A multi-line text editor component with cursor support and text manipulation features",
["requires"] = {
[1] = "VisualElement",
},
},
["List"] = {
["default"] = true,
["size"] = 15714,
["path"] = "elements/List.lua",
["description"] = "A scrollable list of selectable items",
["requires"] = {
[1] = "Collection",
},
},
["BarChart"] = {
["default"] = true,
["size"] = 3547,
["path"] = "elements/BarChart.lua",
["description"] = "",
["requires"] = {
},
},
["Toast"] = {
["default"] = false,
["size"] = 7945,
["path"] = "elements/Toast.lua",
["description"] = "A toast notification element that displays temporary messages.",
["requires"] = {
[1] = "VisualElement",
},
},
["Tree"] = {
["default"] = false,
["size"] = 22552,
["path"] = "elements/Tree.lua",
["description"] = "",
["requires"] = {
[1] = "VisualElement",
},
},
["CheckBox"] = {
["default"] = true,
["size"] = 3748,
["path"] = "elements/CheckBox.lua",
["description"] = "This is a checkbox. It is a visual element that can be checked.",
["requires"] = {
},
},
["Breadcrumb"] = {
["default"] = false,
["size"] = 4461,
["path"] = "elements/Breadcrumb.lua",
["description"] = "A breadcrumb navigation element that displays the current path.",
["requires"] = {
[1] = "VisualElement",
},
},
["ProgressBar"] = {
["default"] = true,
["size"] = 3440,
["path"] = "elements/ProgressBar.lua",
["description"] = "",
["requires"] = {
[1] = "VisualElement",
},
},
["Program"] = {
["default"] = false,
["size"] = 12833,
["path"] = "elements/Program.lua",
["description"] = "",
["requires"] = {
[1] = "VisualElement",
},
},
["DropDown"] = {
["default"] = false,
["size"] = 8194,
["path"] = "elements/DropDown.lua",
["description"] = "A DropDown menu that shows a list of selectable items",
["requires"] = {
[1] = "List",
},
},
["Switch"] = {
["default"] = false,
["size"] = 3375,
["path"] = "elements/Switch.lua",
["description"] = "The Switch is a standard Switch element with click handling and state management.",
["requires"] = {
[1] = "VisualElement",
},
},
["LineChart"] = {
["default"] = true,
["size"] = 3228,
["path"] = "elements/LineChart.lua",
["description"] = "",
["requires"] = {
},
},
["SideNav"] = {
["default"] = false,
["size"] = 22429,
["path"] = "elements/SideNav.lua",
["description"] = "A SideNav element that provides sidebar navigation with multiple content areas.",
["requires"] = { ["requires"] = {
[1] = "Container", [1] = "Container",
}, },
},
["Graph"] = {
["default"] = false,
["size"] = 7045,
["path"] = "elements/Graph.lua",
["description"] = "A point based graph element",
["requires"] = {
},
},
["Display"] = {
["default"] = false,
["size"] = 4549,
["path"] = "elements/Display.lua",
["description"] = "The Display is a special element which uses the CC Window API which you can use.",
["requires"] = {
[1] = "VisualElement",
},
},
["Image"] = {
["default"] = true,
["size"] = 15372,
["path"] = "elements/Image.lua",
["description"] = "",
["requires"] = {
[1] = "VisualElement",
},
},
["VisualElement"] = {
["default"] = true,
["size"] = 45338,
["path"] = "elements/VisualElement.lua",
["description"] = "The Visual Element class which is the base class for all visual UI elements",
["requires"] = {
[1] = "BaseElement",
},
},
["ScrollBar"] = {
["default"] = false,
["size"] = 9941,
["path"] = "elements/ScrollBar.lua",
["description"] = "A ScrollBar element that can be attached to other elements to control their scroll properties.",
["requires"] = {
[1] = "VisualElement",
},
},
["Table"] = {
["default"] = false,
["size"] = 25766,
["path"] = "elements/Table.lua",
["description"] = "The Table is a sortable data grid with customizable columns, row selection, and scrolling capabilities.",
["requires"] = {
[1] = "Collection",
},
},
["Frame"] = {
["default"] = true,
["size"] = 6702,
["path"] = "elements/Frame.lua",
["description"] = "A frame element that serves as a grouping container for other elements.", ["description"] = "A frame element that serves as a grouping container for other elements.",
["requires"] = {
[1] = "Container",
},
},
["Accordion"] = {
["default"] = false,
["size"] = 15169,
["path"] = "elements/Accordion.lua",
["description"] = "An Accordion element that provides collapsible panels with headers.",
["requires"] = {
[1] = "Container",
},
},
["Label"] = {
["default"] = true,
["size"] = 2987,
["path"] = "elements/Label.lua",
["description"] = "A simple text display element that automatically resizes its width based on the text content.",
["requires"] = {
[1] = "VisualElement",
},
},
["Dialog"] = {
["default"] = false,
["size"] = 9125,
["path"] = "elements/Dialog.lua",
["description"] = "A dialog overlay system with common presets (alert, confirm, prompt).",
["requires"] = {
[1] = "Frame",
},
},
["ContextMenu"] = {
["default"] = false,
["size"] = 10836,
["path"] = "elements/ContextMenu.lua",
["description"] = "A ContextMenu element that displays a menu with items and submenus.",
["requires"] = {
[1] = "Container",
},
},
["Menu"] = {
["default"] = true,
["size"] = 14123,
["path"] = "elements/Menu.lua",
["description"] = "A horizontal menu bar with selectable items.",
["requires"] = {
[1] = "List",
},
},
["Input"] = {
["default"] = true,
["size"] = 9456,
["path"] = "elements/Input.lua",
["description"] = "A text input field with various features",
["requires"] = {
[1] = "VisualElement",
},
},
["TabControl"] = {
["default"] = false,
["size"] = 21136,
["path"] = "elements/TabControl.lua",
["description"] = "A TabControl element that provides tabbed interface with multiple content areas.",
["requires"] = {
[1] = "Container",
},
},
["Slider"] = {
["default"] = false,
["size"] = 5211,
["path"] = "elements/Slider.lua",
["description"] = "A slider control element for selecting a value within a range.",
["requires"] = {
[1] = "VisualElement",
},
},
["Collection"] = {
["default"] = true,
["size"] = 7874,
["path"] = "elements/Collection.lua",
["description"] = "A collection of items",
["requires"] = {
[1] = "VisualElement",
},
},
["ComboBox"] = {
["default"] = false,
["size"] = 15143,
["path"] = "elements/ComboBox.lua",
["description"] = "A ComboBox that combines dropdown selection with editable text input",
["requires"] = {
[1] = "DropDown",
},
},
["BaseFrame"] = {
["default"] = true,
["size"] = 8972,
["path"] = "elements/BaseFrame.lua",
["description"] = "This is the base frame class. It is the root element of all elements and the only element without a parent.",
["requires"] = {
[1] = "Container",
},
},
["Timer"] = {
["default"] = false,
["size"] = 2962,
["path"] = "elements/Timer.lua",
["description"] = "",
["requires"] = {
[1] = "BaseElement",
},
}, },
}, },
["description"] = "UI Elements", ["description"] = "UI Elements",
}, },
["plugins"] = {
["files"] = {
["debug"] = {
["default"] = false,
["size"] = 6274,
["path"] = "plugins/debug.lua",
["description"] = "",
["requires"] = {
},
},
["reactive"] = {
["default"] = false,
["size"] = 11893,
["path"] = "plugins/reactive.lua",
["description"] = "",
["requires"] = {
},
},
["theme"] = {
["default"] = false,
["size"] = 9028,
["path"] = "plugins/theme.lua",
["description"] = "",
["requires"] = {
},
},
["canvas"] = {
["default"] = false,
["size"] = 7897,
["path"] = "plugins/canvas.lua",
["description"] = "",
["requires"] = {
},
},
["xml"] = {
["default"] = false,
["size"] = 14068,
["path"] = "plugins/xml.lua",
["description"] = "",
["requires"] = {
},
},
["animation"] = {
["default"] = false,
["size"] = 23936,
["path"] = "plugins/animation.lua",
["description"] = "",
["requires"] = {
},
},
["responsive"] = {
["default"] = false,
["size"] = 5529,
["path"] = "plugins/responsive.lua",
["description"] = "",
["requires"] = {
},
},
["benchmark"] = {
["default"] = false,
["size"] = 12604,
["path"] = "plugins/benchmark.lua",
["description"] = "",
["requires"] = {
[1] = "VisualElement",
},
},
},
["description"] = "Plugins",
},
}, },
["metadata"] = { ["metadata"] = {
["version"] = "2.0", ["version"] = "2.0",
["generated"] = "Sun Oct 12 20:37:36 2025", ["generated"] = "Wed Nov 5 15:20:56 2025",
}, },
} }

View File

@@ -1,8 +1,9 @@
local basalt local basalt
local releasePath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/release/basalt.lua" local fullPath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/release/basalt-full.lua"
local devPath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/src/" local corePath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/release/basalt-core.lua"
local configPath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/config.lua" local devPath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/src/"
local luaLSPath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/BasaltLS.lua" local configPath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/config.lua"
local luaLSPath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/BasaltLS.lua"
local args = {...} local args = {...}
local config local config
@@ -24,22 +25,37 @@ if(args[1] == "-h")or(args[1] == "--help")then
print("Usage: install.lua [options]") print("Usage: install.lua [options]")
print("Options:") print("Options:")
print(" -h, --help Show this help message") print(" -h, --help Show this help message")
print(" -r, --release Install the release version") print(" -r, --release Install the core release version")
print(" -f, --full Install the full release version")
print(" -d, --dev Install the dev version") print(" -d, --dev Install the dev version")
return return
end end
if(args[1] == "-r")or(args[1] == "--release")then if(args[1] == "-r")or(args[1] == "--release")then
print("Installing release version...") print("Installing core release version...")
local request = http.get(releasePath) local request = http.get(corePath)
if not request then if not request then
error("Failed to download Basalt") error("Failed to download Basalt Core")
end end
local file = fs.open(args[2] or "basalt.lua", "w") local file = fs.open(args[2] or "basalt.lua", "w")
file.write(request.readAll()) file.write(request.readAll())
file.close() file.close()
request.close() request.close()
print("Basalt installed successfully!") print("Basalt Core installed successfully!")
return
end
if(args[1] == "-f")or(args[1] == "--full")then
print("Installing full release version...")
local request = http.get(fullPath)
if not request then
error("Failed to download Basalt Full")
end
local file = fs.open(args[2] or "basalt.lua", "w")
file.write(request.readAll())
file.close()
request.close()
print("Basalt Full installed successfully!")
return return
end end
@@ -83,7 +99,7 @@ if(args[1] == "-d")or(args[1] == "--dev")then
end end
local basaltRequest = http.get(releasePath) local basaltRequest = http.get(fullPath)
if not basaltRequest then if not basaltRequest then
error("Failed to download Basalt") error("Failed to download Basalt")
end end
@@ -131,7 +147,10 @@ local function getScreenPosition(index)
end end
local function createScreen(index) local function createScreen(index)
local screen = main:addFrame(coloring) local screen = main:addScrollFrame(coloring)
:setScrollBarBackgroundColor(colors.gray)
:setScrollBarBackgroundColor2(colors.black)
:setScrollBarColor(colors.lightGray)
:onScroll(function(self, direction) :onScroll(function(self, direction)
local height = getChildrenHeight(self) local height = getChildrenHeight(self)
local scrollOffset = self:getOffsetY() local scrollOffset = self:getOffsetY()
@@ -235,17 +254,19 @@ installScreen:addLabel(coloring)
local versionDropdown = installScreen:addDropDown() local versionDropdown = installScreen:addDropDown()
:setPosition("{parent.width - self.width - 1}", 4) :setPosition("{parent.width - self.width - 1}", 4)
:setSize(15, 1) :setSize(20, 1)
:setBackground(colors.black) :setBackground(colors.black)
:setForeground(colors.white) :setForeground(colors.white)
:addItem("Release") :addItem("Release (Core)")
:addItem("Release (Full)")
:addItem("Dev") :addItem("Dev")
:addItem("Custom") :addItem("Custom")
:selectItem(1)
local versionDesc = installScreen:addLabel("versionDesc") local versionDesc = installScreen:addLabel("versionDesc")
:setWidth("{parent.width - 2}") :setWidth("{parent.width - 2}")
:setAutoSize(false) :setAutoSize(false)
:setText("The Release version is the most stable and tested version of Basalt. It is recommended for production use.") :setText("The Core version includes only the essential elements and plugins. It's lighter and faster - perfect for most projects!")
:setPosition(2, 7) :setPosition(2, 7)
:setSize("{parent.width - 4}", 3) :setSize("{parent.width - 4}", 3)
:setBackground(colors.lightGray) :setBackground(colors.lightGray)
@@ -285,8 +306,14 @@ local installPathInput = installScreen:addInput()
:setForeground(colors.white) :setForeground(colors.white)
versionDropdown:onSelect(function(self, index, item) versionDropdown:onSelect(function(self, index, item)
if(item.text == "Release") then if(item.text == "Release (Core)") then
versionDesc:setText("The Release version is the most stable and tested version of Basalt. It is recommended for production use.") versionDesc:setText("The Core version includes only the essential elements and plugins. It's lighter and faster - perfect for most projects!")
additionalComponents:setVisible(false)
luaLSCheckbox:setVisible(false)
luaMinifyCheckbox:setVisible(false)
singleFileProject:setVisible(false)
elseif(item.text == "Release (Full)") then
versionDesc:setText("The Full version contains all elements and plugins. Use this if you need advanced or optional components.")
additionalComponents:setVisible(false) additionalComponents:setVisible(false)
luaLSCheckbox:setVisible(false) luaLSCheckbox:setVisible(false)
luaMinifyCheckbox:setVisible(false) luaMinifyCheckbox:setVisible(false)
@@ -298,7 +325,7 @@ versionDropdown:onSelect(function(self, index, item)
luaMinifyCheckbox:setVisible(true) luaMinifyCheckbox:setVisible(true)
singleFileProject:setVisible(true) singleFileProject:setVisible(true)
else else
versionDesc:setText("The Dev version is the latest development version of Basalt. It may contain new features and improvements, but it may also have bugs and issues.") versionDesc:setText("The Dev version downloads the complete source code as individual files. Perfect for development and debugging!")
additionalComponents:setVisible(false) additionalComponents:setVisible(false)
luaLSCheckbox:setVisible(false) luaLSCheckbox:setVisible(false)
luaMinifyCheckbox:setVisible(false) luaMinifyCheckbox:setVisible(false)
@@ -419,9 +446,10 @@ local function updateProgress(progressBar, current, total)
progressBar:setProgress(math.ceil((current / total) * 100)) progressBar:setProgress(math.ceil((current / total) * 100))
end end
local function installRelease(installPath, log, progressBar) local function installRelease(installPath, log, progressBar, isCore)
logMessage(log, "Installing Release version...") logMessage(log, "Installing Release " .. (isCore and "Core" or "Full") .. " version...")
local releasePath = isCore and corePath or fullPath
local request = http.get(releasePath) local request = http.get(releasePath)
if not request then if not request then
logMessage(log, "Failed to download release version, aborting installation.") logMessage(log, "Failed to download release version, aborting installation.")
@@ -572,7 +600,7 @@ local function installCustom(installPath, log, progressBar, selectedElements, se
'local project = {}\n', 'local project = {}\n',
'local loadedProject = {}\n', 'local loadedProject = {}\n',
'local baseRequire = require\n', 'local baseRequire = require\n',
'require = function(path) if(project[path..".lua"])then if(loadedProject[path]==nil)then loadedProject[path] = project[path..".lua"]() end return loadedProject[path] end baseRequire(path) end\n' 'require = function(path) if(project[path..".lua"])then if(loadedProject[path]==nil)then loadedProject[path] = project[path..".lua"]() end return loadedProject[path] end return baseRequire(path) end\n'
} }
for filePath, content in pairs(project) do for filePath, content in pairs(project) do
@@ -642,7 +670,7 @@ local function installBasalt()
installButton:setVisible(false) installButton:setVisible(false)
local selection = versionDropdown:getSelectedItems()[1] local selection = versionDropdown:getSelectedItems()[1]
if(selection==nil)then if(selection==nil)then
selection = "Release" selection = "Release (Core)"
else else
selection = selection.text selection = selection.text
end end
@@ -652,8 +680,10 @@ local function installBasalt()
else else
path = path:gsub(".lua", "") path = path:gsub(".lua", "")
end end
if(selection == "Release")then if(selection == "Release (Core)")then
installRelease(path..".lua", log, progressBar) installRelease(path..".lua", log, progressBar, true)
elseif(selection == "Release (Full)")then
installRelease(path..".lua", log, progressBar, false)
elseif(selection == "Dev")then elseif(selection == "Dev")then
installDev(path, log, progressBar) installDev(path, log, progressBar)
else else

294
layouts/flow.lua Normal file
View File

@@ -0,0 +1,294 @@
local flow = {}
--- Calculates positions for all children in a flow layout
--- @param instance table The layout instance
--- - container: the container to layout
--- - options: layout options
--- - direction: "horizontal" or "vertical" (default: "horizontal")
--- - spacing: gap between elements (default: 0)
--- - padding: padding around the flow (default: 0)
--- - align: "start", "center", or "end" (default: "start")
function flow.calculate(instance)
local container = instance.container
local options = instance.options or {}
local children = container.get("children")
local containerWidth = container.get("width")
local containerHeight = container.get("height")
local direction = options.direction or "horizontal"
local spacing = options.spacing or 0
local padding = options.padding or 0
local align = options.align or "start"
local childCount = #children
if childCount == 0 then
instance._positions = {}
return
end
local positions = {}
if direction == "horizontal" then
local rows = {}
local currentRow = {}
local currentX = padding + 1
local currentY = padding + 1
local maxHeightInRow = 0
for i, child in ipairs(children) do
local childWidth = child.get("width")
local childHeight = child.get("height")
local layoutConfig = child.get("layoutConfig") or {}
if child._userModified then
child._originalWidth = childWidth
child._originalHeight = childHeight
end
local basis = layoutConfig.basis
if not basis then
if not child._originalWidth then
child._originalWidth = childWidth
end
basis = child._originalWidth
end
local hasFlexConfig = layoutConfig.grow or layoutConfig.shrink or layoutConfig.basis
if currentX + basis - 1 > containerWidth - padding and currentX > padding + 1 then
table.insert(rows, {
children = currentRow,
y = currentY,
height = maxHeightInRow
})
currentRow = {}
currentX = padding + 1
currentY = currentY + maxHeightInRow + spacing
maxHeightInRow = 0
end
table.insert(currentRow, {
child = child,
width = childWidth,
height = childHeight,
basis = basis,
grow = layoutConfig.grow or 0,
shrink = layoutConfig.shrink or 1,
hasFlexConfig = hasFlexConfig
})
currentX = currentX + basis + spacing
maxHeightInRow = math.max(maxHeightInRow, childHeight)
end
if #currentRow > 0 then
table.insert(rows, {
children = currentRow,
y = currentY,
height = maxHeightInRow
})
end
for _, row in ipairs(rows) do
local totalBasis = 0
local totalSpacing = (#row.children - 1) * spacing
local totalGrow = 0
local totalShrink = 0
for _, item in ipairs(row.children) do
totalBasis = totalBasis + item.basis
totalGrow = totalGrow + item.grow
totalShrink = totalShrink + item.shrink
end
local availableWidth = containerWidth - 2 * padding
local remainingSpace = availableWidth - totalBasis - totalSpacing
for _, item in ipairs(row.children) do
if remainingSpace > 0 and totalGrow > 0 then
local extraSpace = (remainingSpace * item.grow) / totalGrow
item.finalWidth = item.basis + extraSpace
elseif remainingSpace < 0 and totalShrink > 0 then
local reduceSpace = (-remainingSpace * item.shrink) / totalShrink
item.finalWidth = math.max(1, item.basis - reduceSpace)
else
item.finalWidth = item.basis
end
if not item.hasFlexConfig then
item.finalWidth = item.basis
end
end
local rowWidth = 0
for j, item in ipairs(row.children) do
rowWidth = rowWidth + item.finalWidth
if j < #row.children then
rowWidth = rowWidth + spacing
end
end
local startX = padding + 1
if align == "center" then
startX = padding + 1 + math.floor((containerWidth - 2 * padding - rowWidth) / 2)
elseif align == "end" then
startX = containerWidth - padding - rowWidth + 1
end
local x = startX
for _, item in ipairs(row.children) do
local y = row.y
if align == "center" then
y = row.y + math.floor((row.height - item.height) / 2)
elseif align == "end" then
y = row.y + row.height - item.height
end
positions[item.child] = {
x = x,
y = y,
width = math.floor(item.finalWidth),
height = item.height
}
x = x + math.floor(item.finalWidth) + spacing
end
end
else
local columns = {}
local currentColumn = {}
local currentX = padding + 1
local currentY = padding + 1
local maxWidthInColumn = 0
for i, child in ipairs(children) do
local childWidth = child.get("width")
local childHeight = child.get("height")
local layoutConfig = child.get("layoutConfig") or {}
-- If user modified the element, update the original size
if child._userModified then
child._originalWidth = childWidth
child._originalHeight = childHeight
end
local basis = layoutConfig.basis
if not basis then
if not child._originalHeight then
child._originalHeight = childHeight
end
basis = child._originalHeight
end
local hasFlexConfig = layoutConfig.grow or layoutConfig.shrink or layoutConfig.basis
if currentY + basis - 1 > containerHeight - padding and currentY > padding + 1 then
table.insert(columns, {
children = currentColumn,
x = currentX,
width = maxWidthInColumn
})
currentColumn = {}
currentY = padding + 1
currentX = currentX + maxWidthInColumn + spacing
maxWidthInColumn = 0
end
table.insert(currentColumn, {
child = child,
width = childWidth,
height = childHeight,
basis = basis,
grow = layoutConfig.grow or 0,
shrink = layoutConfig.shrink or 1,
hasFlexConfig = hasFlexConfig
})
currentY = currentY + basis + spacing
maxWidthInColumn = math.max(maxWidthInColumn, childWidth)
end
if #currentColumn > 0 then
table.insert(columns, {
children = currentColumn,
x = currentX,
width = maxWidthInColumn
})
end
for _, column in ipairs(columns) do
local totalBasis = 0
local totalSpacing = (#column.children - 1) * spacing
local totalGrow = 0
local totalShrink = 0
for _, item in ipairs(column.children) do
totalBasis = totalBasis + item.basis
totalGrow = totalGrow + item.grow
totalShrink = totalShrink + item.shrink
end
local availableHeight = containerHeight - 2 * padding
local remainingSpace = availableHeight - totalBasis - totalSpacing
for _, item in ipairs(column.children) do
if remainingSpace > 0 and totalGrow > 0 then
local extraSpace = (remainingSpace * item.grow) / totalGrow
item.finalHeight = item.basis + extraSpace
elseif remainingSpace < 0 and totalShrink > 0 then
local reduceSpace = (-remainingSpace * item.shrink) / totalShrink
item.finalHeight = math.max(1, item.basis - reduceSpace)
else
item.finalHeight = item.basis
end
if not item.hasFlexConfig then
item.finalHeight = item.basis
end
end
local columnHeight = 0
for j, item in ipairs(column.children) do
columnHeight = columnHeight + item.finalHeight
if j < #column.children then
columnHeight = columnHeight + spacing
end
end
local startY = padding + 1
if align == "center" then
startY = padding + 1 + math.floor((containerHeight - 2 * padding - columnHeight) / 2)
elseif align == "end" then
startY = containerHeight - padding - columnHeight + 1
end
local y = startY
for _, item in ipairs(column.children) do
local x = column.x
if align == "center" then
x = column.x + math.floor((column.width - item.width) / 2)
elseif align == "end" then
x = column.x + column.width - item.width
end
positions[item.child] = {
x = x,
y = y,
width = item.width,
height = math.floor(item.finalHeight)
}
y = y + math.floor(item.finalHeight) + spacing
end
end
end
instance._positions = positions
end
return flow

74
layouts/grid.lua Normal file
View File

@@ -0,0 +1,74 @@
local grid = {}
--- Calculates positions for all children in a grid layout
--- @param instance table The layout instance
--- - container: the container to layout
--- - options: layout options
--- - rows: number of rows (optional, auto-calculated if not provided)
--- - columns: number of columns (optional, auto-calculated if not provided)
--- - spacing: gap between cells (default: 0)
--- - padding: padding around the grid (default: 0)
function grid.calculate(instance)
local container = instance.container
local options = instance.options or {}
local children = container.get("children")
local containerWidth = container.get("width")
local containerHeight = container.get("height")
local spacing = options.spacing or 0
local padding = options.padding or 0
local rows = options.rows
local columns = options.columns
local childCount = #children
if childCount == 0 then
instance._positions = {}
return
end
if not rows and not columns then
columns = math.ceil(math.sqrt(childCount))
rows = math.ceil(childCount / columns)
elseif rows and not columns then
columns = math.ceil(childCount / rows)
elseif columns and not rows then
rows = math.ceil(childCount / columns)
end
if columns <= 0 then columns = 1 end
if rows <= 0 then rows = 1 end
local availableWidth = containerWidth - (2 * padding) - ((columns - 1) * spacing)
local availableHeight = containerHeight - (2 * padding) - ((rows - 1) * spacing)
if availableWidth < 1 then availableWidth = 1 end
if availableHeight < 1 then availableHeight = 1 end
local cellWidth = math.floor(availableWidth / columns)
local cellHeight = math.floor(availableHeight / rows)
if cellWidth < 1 then cellWidth = 1 end
if cellHeight < 1 then cellHeight = 1 end
local positions = {}
for i, child in ipairs(children) do
local row = math.floor((i - 1) / columns)
local col = (i - 1) % columns
local x = padding + 1 + (col * (cellWidth + spacing))
local y = padding + 1 + (row * (cellHeight + spacing))
positions[child] = {
x = x,
y = y,
width = cellWidth,
height = cellHeight
}
end
instance._positions = positions
end
return grid

2007
release/basalt-core.lua Normal file

File diff suppressed because it is too large Load Diff

4807
release/basalt-full.lua Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -17,6 +17,16 @@ local ElementManager = {}
ElementManager._elements = {} ElementManager._elements = {}
ElementManager._plugins = {} ElementManager._plugins = {}
ElementManager._APIs = {} ElementManager._APIs = {}
ElementManager._config = {
autoLoadMissing = false,
allowRemoteLoading = false,
allowDiskLoading = true,
remoteSources = {},
diskMounts = {},
useGlobalCache = false,
globalCacheName = "_BASALT_ELEMENT_CACHE"
}
local elementsDirectory = fs.combine(dir, "elements") local elementsDirectory = fs.combine(dir, "elements")
local pluginsDirectory = fs.combine(dir, "plugins") local pluginsDirectory = fs.combine(dir, "plugins")
@@ -29,7 +39,9 @@ if fs.exists(elementsDirectory) then
ElementManager._elements[name] = { ElementManager._elements[name] = {
class = nil, class = nil,
plugins = {}, plugins = {},
loaded = false loaded = false,
source = "local",
path = nil
} }
end end
end end
@@ -66,7 +78,9 @@ if(minified)then
ElementManager._elements[name:gsub(".lua", "")] = { ElementManager._elements[name:gsub(".lua", "")] = {
class = nil, class = nil,
plugins = {}, plugins = {},
loaded = false loaded = false,
source = "local",
path = nil
} }
end end
if(minified_pluginDirectory==nil)then if(minified_pluginDirectory==nil)then
@@ -90,20 +104,225 @@ if(minified)then
end end
end end
local function saveToGlobalCache(name, element)
if not ElementManager._config.useGlobalCache then return end
if not _G[ElementManager._config.globalCacheName] then
_G[ElementManager._config.globalCacheName] = {}
end
_G[ElementManager._config.globalCacheName][name] = element
log.debug("Cached element in _G: "..name)
end
local function loadFromGlobalCache(name)
if not ElementManager._config.useGlobalCache then return nil end
if _G[ElementManager._config.globalCacheName] and
_G[ElementManager._config.globalCacheName][name] then
log.debug("Loaded element from _G cache: "..name)
return _G[ElementManager._config.globalCacheName][name]
end
return nil
end
--- Configures the ElementManager
--- @param config table Configuration options
function ElementManager.configure(config)
for k, v in pairs(config) do
if ElementManager._config[k] ~= nil then
ElementManager._config[k] = v
end
end
end
--- Registers a disk mount point for loading elements
--- @param mountPath string The path to the disk mount
function ElementManager.registerDiskMount(mountPath)
if not fs.exists(mountPath) then
error("Disk mount path does not exist: "..mountPath)
end
table.insert(ElementManager._config.diskMounts, mountPath)
log.info("Registered disk mount: "..mountPath)
local elementsPath = fs.combine(mountPath, "elements")
if fs.exists(elementsPath) then
for _, file in ipairs(fs.list(elementsPath)) do
local name = file:match("(.+).lua")
if name then
if not ElementManager._elements[name] then
log.debug("Found element on disk: "..name)
ElementManager._elements[name] = {
class = nil,
plugins = {},
loaded = false,
source = "disk",
path = fs.combine(elementsPath, file)
}
end
end
end
end
end
--- Registers a remote source for an element
--- @param elementName string The name of the element
--- @param url string The URL to load the element from
function ElementManager.registerRemoteSource(elementName, url)
if not ElementManager._config.allowRemoteLoading then
error("Remote loading is disabled. Enable with ElementManager.configure({allowRemoteLoading = true})")
end
ElementManager._config.remoteSources[elementName] = url
if not ElementManager._elements[elementName] then
ElementManager._elements[elementName] = {
class = nil,
plugins = {},
loaded = false,
source = "remote",
path = url
}
else
ElementManager._elements[elementName].source = "remote"
ElementManager._elements[elementName].path = url
end
log.info("Registered remote source for "..elementName..": "..url)
end
local function loadFromRemote(url)
if not http then
error("HTTP API is not available. Enable it in your CC:Tweaked config.")
end
log.info("Loading element from remote: "..url)
local response = http.get(url)
if not response then
error("Failed to download from: "..url)
end
local content = response.readAll()
response.close()
if not content or content == "" then
error("Empty response from: "..url)
end
local func, err = load(content, url, "t", _ENV)
if not func then
error("Failed to load element from "..url..": "..tostring(err))
end
local element = func()
return element
end
local function loadFromDisk(path)
if not fs.exists(path) then
error("Element file does not exist: "..path)
end
log.info("Loading element from disk: "..path)
local func, err = loadfile(path)
if not func then
error("Failed to load element from "..path..": "..tostring(err))
end
local element = func()
return element
end
--- Tries to load an element from any available source
--- @param name string The element name
--- @return boolean success Whether the element was loaded
function ElementManager.tryAutoLoad(name)
-- Try disk mounts first
if ElementManager._config.allowDiskLoading then
for _, mountPath in ipairs(ElementManager._config.diskMounts) do
local elementsPath = fs.combine(mountPath, "elements")
local filePath = fs.combine(elementsPath, name..".lua")
if fs.exists(filePath) then
ElementManager._elements[name] = {
class = nil,
plugins = {},
loaded = false,
source = "disk",
path = filePath
}
ElementManager.loadElement(name)
return true
end
end
end
if ElementManager._config.allowRemoteLoading and ElementManager._config.remoteSources[name] then
ElementManager.loadElement(name)
return true
end
return false
end
--- Loads an element by name. This will load the element and apply any plugins to it. --- Loads an element by name. This will load the element and apply any plugins to it.
--- @param name string The name of the element to load --- @param name string The name of the element to load
--- @usage ElementManager.loadElement("Button") --- @usage ElementManager.loadElement("Button")
function ElementManager.loadElement(name) function ElementManager.loadElement(name)
if not ElementManager._elements[name] then
-- Try to auto-load if enabled
if ElementManager._config.autoLoadMissing then
local success = ElementManager.tryAutoLoad(name)
if not success then
error("Element '"..name.."' not found and could not be auto-loaded")
end
else
error("Element '"..name.."' not found")
end
end
if not ElementManager._elements[name].loaded then if not ElementManager._elements[name].loaded then
package.path = main.."rom/?" local source = ElementManager._elements[name].source or "local"
local element = require(fs.combine("elements", name)) local element
package.path = defaultPath local loadedFromCache = false
element = loadFromGlobalCache(name)
if element then
loadedFromCache = true
log.info("Loaded element from _G cache: "..name)
elseif source == "local" then
package.path = main.."rom/?"
element = require(fs.combine("elements", name))
package.path = defaultPath
elseif source == "disk" then
if not ElementManager._config.allowDiskLoading then
error("Disk loading is disabled for element: "..name)
end
element = loadFromDisk(ElementManager._elements[name].path)
saveToGlobalCache(name, element)
elseif source == "remote" then
if not ElementManager._config.allowRemoteLoading then
error("Remote loading is disabled for element: "..name)
end
element = loadFromRemote(ElementManager._elements[name].path)
saveToGlobalCache(name, element)
else
error("Unknown source type: "..source)
end
ElementManager._elements[name] = { ElementManager._elements[name] = {
class = element, class = element,
plugins = element.plugins, plugins = element.plugins,
loaded = true loaded = true,
source = loadedFromCache and "cache" or source,
path = ElementManager._elements[name].path
} }
log.debug("Loaded element: "..name)
if not loadedFromCache then
log.debug("Loaded element: "..name.." from "..source)
end
if(ElementManager._plugins[name]~=nil)then if(ElementManager._plugins[name]~=nil)then
for _, plugin in pairs(ElementManager._plugins[name]) do for _, plugin in pairs(ElementManager._plugins[name]) do
@@ -148,6 +367,17 @@ end
--- @param name string The name of the element to get --- @param name string The name of the element to get
--- @return table Element The element class --- @return table Element The element class
function ElementManager.getElement(name) function ElementManager.getElement(name)
if not ElementManager._elements[name] then
if ElementManager._config.autoLoadMissing then
local success = ElementManager.tryAutoLoad(name)
if not success then
error("Element '"..name.."' not found")
end
else
error("Element '"..name.."' not found")
end
end
if not ElementManager._elements[name].loaded then if not ElementManager._elements[name].loaded then
ElementManager.loadElement(name) ElementManager.loadElement(name)
end end
@@ -167,4 +397,55 @@ function ElementManager.getAPI(name)
return ElementManager._APIs[name] return ElementManager._APIs[name]
end end
--- Checks if an element exists (is registered)
--- @param name string The element name
--- @return boolean exists Whether the element exists
function ElementManager.hasElement(name)
return ElementManager._elements[name] ~= nil
end
--- Checks if an element is loaded
--- @param name string The element name
--- @return boolean loaded Whether the element is loaded
function ElementManager.isElementLoaded(name)
return ElementManager._elements[name] and ElementManager._elements[name].loaded or false
end
--- Clears the global cache (_G)
--- @usage ElementManager.clearGlobalCache()
function ElementManager.clearGlobalCache()
if _G[ElementManager._config.globalCacheName] then
_G[ElementManager._config.globalCacheName] = nil
log.info("Cleared global element cache")
end
end
--- Gets cache statistics
--- @return table stats Cache statistics with size and element names
function ElementManager.getCacheStats()
if not _G[ElementManager._config.globalCacheName] then
return {size = 0, elements = {}}
end
local elements = {}
for name, _ in pairs(_G[ElementManager._config.globalCacheName]) do
table.insert(elements, name)
end
return {
size = #elements,
elements = elements
}
end
--- Preloads elements into the global cache
--- @param elementNames table List of element names to preload
function ElementManager.preloadElements(elementNames)
for _, name in ipairs(elementNames) do
if ElementManager._elements[name] and not ElementManager._elements[name].loaded then
ElementManager.loadElement(name)
end
end
end
return ElementManager return ElementManager

472
src/elements/Accordion.lua Normal file
View File

@@ -0,0 +1,472 @@
local elementManager = require("elementManager")
local VisualElement = require("elements/VisualElement")
local Container = elementManager.getElement("Container")
local tHex = require("libraries/colorHex")
---@configDescription An Accordion element that provides collapsible panels with headers.
---@configDefault false
--- The Accordion is a container that provides collapsible panel functionality
--- @run [[
--- local basalt = require("basalt")
---
--- local main = basalt.getMainFrame()
---
--- -- Create an Accordion
--- local accordion = main:addAccordion({
--- x = 2,
--- y = 2,
--- width = 30,
--- height = 15,
--- allowMultiple = true, -- Only one panel open at a time
--- headerBackground = colors.gray,
--- headerTextColor = colors.white,
--- expandedHeaderBackground = colors.lightBlue,
--- expandedHeaderTextColor = colors.white,
--- })
---
--- -- Panel 1: Info
--- local infoPanel = accordion:newPanel("Information", true) -- starts expanded
--- infoPanel:addLabel({
--- x = 2,
--- y = 1,
--- text = "This is an accordion",
--- foreground = colors.yellow
--- })
--- infoPanel:addLabel({
--- x = 2,
--- y = 2,
--- text = "with collapsible panels.",
--- foreground = colors.white
--- })
---
--- -- Panel 2: Settings
--- local settingsPanel = accordion:newPanel("Settings", false)
--- settingsPanel:addLabel({
--- x = 2,
--- y = 1,
--- text = "Volume:",
--- foreground = colors.white
--- })
--- local volumeSlider = settingsPanel:addSlider({
--- x = 10,
--- y = 1,
--- width = 15,
--- value = 50
--- })
--- settingsPanel:addLabel({
--- x = 2,
--- y = 3,
--- text = "Auto-save:",
--- foreground = colors.white
--- })
--- settingsPanel:addSwitch({
--- x = 13,
--- y = 3,
--- })
---
--- -- Panel 3: Actions
--- local actionsPanel = accordion:newPanel("Actions", false)
--- local statusLabel = actionsPanel:addLabel({
--- x = 2,
--- y = 4,
--- text = "Ready",
--- foreground = colors.lime
--- })
---
--- actionsPanel:addButton({
--- x = 2,
--- y = 1,
--- width = 10,
--- height = 1,
--- text = "Save",
--- background = colors.green,
--- foreground = colors.white,
--- })
---
--- actionsPanel:addButton({
--- x = 14,
--- y = 1,
--- width = 10,
--- height = 1,
--- text = "Cancel",
--- background = colors.red,
--- foreground = colors.white,
--- })
---
--- -- Panel 4: About
--- local aboutPanel = accordion:newPanel("About", false)
--- aboutPanel:addLabel({
--- x = 2,
--- y = 1,
--- text = "Basalt Accordion v1.0",
--- foreground = colors.white
--- })
--- aboutPanel:addLabel({
--- x = 2,
--- y = 2,
--- text = "A collapsible panel",
--- foreground = colors.gray
--- })
--- aboutPanel:addLabel({
--- x = 2,
--- y = 3,
--- text = "component for UI.",
--- foreground = colors.gray
--- })
---
--- -- Instructions
--- main:addLabel({
--- x = 2,
--- y = 18,
--- text = "Click panel headers to expand/collapse",
--- foreground = colors.lightGray
--- })
---
--- basalt.run()
--- ]]
---@class Accordion : Container
local Accordion = setmetatable({}, Container)
Accordion.__index = Accordion
---@property panels table {} List of panel definitions
Accordion.defineProperty(Accordion, "panels", {default = {}, type = "table"})
---@property panelHeaderHeight number 1 Height of each panel header
Accordion.defineProperty(Accordion, "panelHeaderHeight", {default = 1, type = "number", canTriggerRender = true})
---@property allowMultiple boolean false Allow multiple panels to be open at once
Accordion.defineProperty(Accordion, "allowMultiple", {default = false, type = "boolean"})
---@property headerBackground color gray Background color for panel headers
Accordion.defineProperty(Accordion, "headerBackground", {default = colors.gray, type = "color", canTriggerRender = true})
---@property headerTextColor color white Text color for panel headers
Accordion.defineProperty(Accordion, "headerTextColor", {default = colors.white, type = "color", canTriggerRender = true})
---@property expandedHeaderBackground color lightGray Background color for expanded panel headers
Accordion.defineProperty(Accordion, "expandedHeaderBackground", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property expandedHeaderTextColor color black Text color for expanded panel headers
Accordion.defineProperty(Accordion, "expandedHeaderTextColor", {default = colors.black, type = "color", canTriggerRender = true})
Accordion.defineEvent(Accordion, "mouse_click")
Accordion.defineEvent(Accordion, "mouse_up")
--- @shortDescription Creates a new Accordion instance
--- @return Accordion self The created instance
--- @private
function Accordion.new()
local self = setmetatable({}, Accordion):__init()
self.class = Accordion
self.set("width", 20)
self.set("height", 10)
self.set("z", 10)
return self
end
--- @shortDescription Initializes the Accordion instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @protected
function Accordion:init(props, basalt)
Container.init(self, props, basalt)
self.set("type", "Accordion")
end
--- Creates a new panel and returns the panel's container
--- @shortDescription Creates a new accordion panel
--- @param title string The title of the panel
--- @param expanded boolean Whether the panel starts expanded (default: false)
--- @return table panelContainer The container for this panel
function Accordion:newPanel(title, expanded)
local panels = self.getResolved("panels") or {}
local panelId = #panels + 1
local panelContainer = self:addContainer()
panelContainer.set("x", 1)
panelContainer.set("y", 1)
panelContainer.set("width", self.getResolved("width"))
panelContainer.set("height", self.getResolved("height"))
panelContainer.set("visible", expanded or false)
panelContainer.set("ignoreOffset", true)
table.insert(panels, {
id = panelId,
title = tostring(title or ("Panel " .. panelId)),
expanded = expanded or false,
container = panelContainer
})
self.set("panels", panels)
self:updatePanelLayout()
return panelContainer
end
Accordion.addPanel = Accordion.newPanel
--- @shortDescription Updates the layout of all panels (positions and visibility)
--- @private
function Accordion:updatePanelLayout()
local panels = self.getResolved("panels") or {}
local headerHeight = self.getResolved("panelHeaderHeight") or 1
local currentY = 1
local width = self.getResolved("width")
local accordionHeight = self.getResolved("height")
for _, panel in ipairs(panels) do
local contentY = currentY + headerHeight
panel.container.set("x", 1)
panel.container.set("y", contentY)
panel.container.set("width", width)
panel.container.set("visible", panel.expanded)
panel.container.set("ignoreOffset", false)
currentY = currentY + headerHeight
if panel.expanded then
local maxY = 0
for _, child in ipairs(panel.container._values.children or {}) do
if not child._destroyed then
local childY = child.get("y")
local childH = child.get("height")
local childBottom = childY + childH - 1
if childBottom > maxY then
maxY = childBottom
end
end
end
local contentHeight = math.max(1, maxY)
panel.container.set("height", contentHeight)
currentY = currentY + contentHeight
end
end
local totalHeight = currentY - 1
local maxOffset = math.max(0, totalHeight - accordionHeight)
local currentOffset = self.getResolved("offsetY")
if currentOffset > maxOffset then
self.set("offsetY", maxOffset)
end
self:updateRender()
end
--- @shortDescription Toggles a panel's expanded state
--- @param panelId number The ID of the panel to toggle
--- @return Accordion self For method chaining
function Accordion:togglePanel(panelId)
local panels = self.getResolved("panels") or {}
local allowMultiple = self.getResolved("allowMultiple")
for i, panel in ipairs(panels) do
if panel.id == panelId then
panel.expanded = not panel.expanded
if not allowMultiple and panel.expanded then
for j, otherPanel in ipairs(panels) do
if j ~= i then
otherPanel.expanded = false
end
end
end
self:updatePanelLayout()
self:dispatchEvent("panelToggled", panelId, panel.expanded)
break
end
end
return self
end
--- @shortDescription Expands a specific panel
--- @param panelId number The ID of the panel to expand
--- @return Accordion self For method chaining
function Accordion:expandPanel(panelId)
local panels = self.getResolved("panels") or {}
local allowMultiple = self.getResolved("allowMultiple")
for i, panel in ipairs(panels) do
if panel.id == panelId then
if not panel.expanded then
panel.expanded = true
if not allowMultiple then
for j, otherPanel in ipairs(panels) do
if j ~= i then
otherPanel.expanded = false
end
end
end
self:updatePanelLayout()
self:dispatchEvent("panelToggled", panelId, true)
end
break
end
end
return self
end
--- @shortDescription Collapses a specific panel
--- @param panelId number The ID of the panel to collapse
--- @return Accordion self For method chaining
function Accordion:collapsePanel(panelId)
local panels = self.getResolved("panels") or {}
for _, panel in ipairs(panels) do
if panel.id == panelId then
if panel.expanded then
panel.expanded = false
self:updatePanelLayout()
self:dispatchEvent("panelToggled", panelId, false)
end
break
end
end
return self
end
--- @shortDescription Gets a panel container by ID
--- @param panelId number The ID of the panel
--- @return table? container The panel's container or nil
function Accordion:getPanel(panelId)
local panels = self.getResolved("panels") or {}
for _, panel in ipairs(panels) do
if panel.id == panelId then
return panel.container
end
end
return nil
end
--- @shortDescription Calculates panel header positions for rendering
--- @return table metrics Panel layout information
--- @private
function Accordion:_getPanelMetrics()
local panels = self.getResolved("panels") or {}
local headerHeight = self.getResolved("panelHeaderHeight") or 1
local positions = {}
local currentY = 1
for _, panel in ipairs(panels) do
table.insert(positions, {
id = panel.id,
title = panel.title,
expanded = panel.expanded,
headerY = currentY,
headerHeight = headerHeight
})
currentY = currentY + headerHeight
if panel.expanded then
currentY = currentY + panel.container.get("height")
end
end
return {
positions = positions,
totalHeight = currentY - 1
}
end
--- @shortDescription Handles mouse click events for panel toggling
--- @param button number The button that was clicked
--- @param x number The x position of the click (global)
--- @param y number The y position of the click (global)
--- @return boolean Whether the event was handled
--- @protected
function Accordion:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then
return false
end
local relX, relY = VisualElement.getRelativePosition(self, x, y)
local offsetY = self.getResolved("offsetY")
local adjustedY = relY + offsetY
local metrics = self:_getPanelMetrics()
for _, panelInfo in ipairs(metrics.positions) do
local headerEndY = panelInfo.headerY + panelInfo.headerHeight - 1
if adjustedY >= panelInfo.headerY and adjustedY <= headerEndY then
self:togglePanel(panelInfo.id)
self.set("focusedChild", nil)
return true
end
end
return Container.mouse_click(self, button, x, y)
end
function Accordion:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then
local metrics = self:_getPanelMetrics()
local accordionHeight = self.getResolved("height")
local totalHeight = metrics.totalHeight
local maxOffset = math.max(0, totalHeight - accordionHeight)
if maxOffset > 0 then
local currentOffset = self.getResolved("offsetY")
local newOffset = currentOffset + direction
newOffset = math.max(0, math.min(maxOffset, newOffset))
self.set("offsetY", newOffset)
return true
end
return Container.mouse_scroll(self, direction, x, y)
end
return false
end
--- @shortDescription Renders the Accordion (headers + panel containers)
--- @protected
function Accordion:render()
VisualElement.render(self)
local width = self.getResolved("width")
local offsetY = self.getResolved("offsetY")
local metrics = self:_getPanelMetrics()
for _, panelInfo in ipairs(metrics.positions) do
local bgColor = panelInfo.expanded and self.getResolved("expandedHeaderBackground") or self.getResolved("headerBackground")
local fgColor = panelInfo.expanded and self.getResolved("expandedHeaderTextColor") or self.getResolved("headerTextColor")
local headerY = panelInfo.headerY - offsetY
if headerY >= 1 and headerY <= self.getResolved("height") then
VisualElement.multiBlit(
self,
1,
headerY,
width,
panelInfo.headerHeight,
" ",
tHex[fgColor],
tHex[bgColor]
)
local indicator = panelInfo.expanded and "v" or ">"
local headerText = indicator .. " " .. panelInfo.title
VisualElement.textFg(self, 1, headerY, headerText, fgColor)
end
end
if not self.getResolved("childrenSorted") then
self:sortChildren()
end
if not self.getResolved("childrenEventsSorted") then
for eventName in pairs(self._values.childrenEvents or {}) do
self:sortChildrenEvents(eventName)
end
end
for _, child in ipairs(self.getResolved("visibleChildren") or {}) do
if child == self then
error("CIRCULAR REFERENCE DETECTED!")
return
end
child:render()
child:postRender()
end
end
return Accordion

View File

@@ -6,21 +6,23 @@ local tHex = require("libraries/colorHex")
--- @configDefault false --- @configDefault false
--- A data visualization element that represents numeric data through vertical bars. Each bar's height corresponds to its value, making it ideal for comparing quantities across categories or showing data changes over time. Supports multiple data series with customizable colors and styles. --- A data visualization element that represents numeric data through vertical bars. Each bar's height corresponds to its value, making it ideal for comparing quantities across categories or showing data changes over time. Supports multiple data series with customizable colors and styles.
--- @usage -- Create a bar chart --- @usage [[
--- @usage local chart = main:addBarChart() --- -- Create a bar chart
--- @usage --- local chart = main:addBarChart()
--- @usage -- Add two data series with different colors ---
--- @usage chart:addSeries("input", " ", colors.green, colors.green, 5) --- -- Add two data series with different colors
--- @usage chart:addSeries("output", " ", colors.red, colors.red, 5) --- chart:addSeries("input", " ", colors.green, colors.green, 5)
--- @usage --- chart:addSeries("output", " ", colors.red, colors.red, 5)
--- @usage -- Continuously update the chart with random data ---
--- @usage basalt.schedule(function() --- -- Continuously update the chart with random data
--- @usage while true do --- basalt.schedule(function()
--- @usage chart:addPoint("input", math.random(1,100)) --- while true do
--- @usage chart:addPoint("output", math.random(1,100)) --- chart:addPoint("input", math.random(1,100))
--- @usage sleep(2) --- chart:addPoint("output", math.random(1,100))
--- @usage end --- sleep(2)
--- @usage end) --- end
--- end)
--- ]]
--- @class BarChart : Graph --- @class BarChart : Graph
local BarChart = setmetatable({}, BaseGraph) local BarChart = setmetatable({}, BaseGraph)
BarChart.__index = BarChart BarChart.__index = BarChart
@@ -52,11 +54,11 @@ end
function BarChart:render() function BarChart:render()
VisualElement.render(self) VisualElement.render(self)
local width = self.get("width") local width = self.getResolved("width")
local height = self.get("height") local height = self.getResolved("height")
local minVal = self.get("minValue") local minVal = self.getResolved("minValue")
local maxVal = self.get("maxValue") local maxVal = self.getResolved("maxValue")
local series = self.get("series") local series = self.getResolved("series")
local activeSeriesCount = 0 local activeSeriesCount = 0
local seriesList = {} local seriesList = {}

View File

@@ -34,6 +34,13 @@ BaseElement.defineProperty(BaseElement, "eventCallbacks", {default = {}, type =
--- @property enabled boolean BaseElement Controls event processing for this element --- @property enabled boolean BaseElement Controls event processing for this element
BaseElement.defineProperty(BaseElement, "enabled", {default = true, type = "boolean" }) BaseElement.defineProperty(BaseElement, "enabled", {default = true, type = "boolean" })
--- @property states table {} Table of currently active states with their priorities
BaseElement.defineProperty(BaseElement, "states", {
default = {},
type = "table",
canTriggerRender = true
})
--- Registers a class-level event listener with optional dependency --- Registers a class-level event listener with optional dependency
--- @shortDescription Registers a new event listener for the element (on class level) --- @shortDescription Registers a new event listener for the element (on class level)
--- @param class table The class to register --- @param class table The class to register
@@ -93,6 +100,8 @@ function BaseElement:init(props, basalt)
self._values.id = uuid() self._values.id = uuid()
self.basalt = basalt self.basalt = basalt
self._registeredEvents = {} self._registeredEvents = {}
self._registeredStates = {}
self._cachedActiveStates = nil
local currentClass = getmetatable(self).__index local currentClass = getmetatable(self).__index
@@ -134,6 +143,7 @@ function BaseElement:postInit()
return self return self
end end
self._postInitialized = true self._postInitialized = true
self._modifiedProperties = {}
if(self._props)then if(self._props)then
for k,v in pairs(self._props)do for k,v in pairs(self._props)do
self.set(k, v) self.set(k, v)
@@ -197,17 +207,282 @@ function BaseElement:registerCallback(event, callback)
return self return self
end end
--- Registers a new state with optional auto-condition
--- @shortDescription Registers a state
--- @param stateName string The name of the state
--- @param condition? function Optional: Function that returns true if state is active: function(element) return boolean end
--- @param priority? number Priority (higher = more important, default: 0)
--- @return BaseElement self The BaseElement instance
function BaseElement:registerState(stateName, condition, priority)
self._registeredStates[stateName] = {
condition = condition,
priority = priority or 0
}
return self
end
--- Manually activates a state
--- @shortDescription Activates a state
--- @param stateName string The state to activate
--- @param priority? number Optional priority override
--- @return BaseElement self
function BaseElement:setState(stateName, priority)
local states = self.getResolved("states")
if not priority and self._registeredStates[stateName] then
priority = self._registeredStates[stateName].priority
end
states[stateName] = priority or 0
self.set("states", states)
self._cachedActiveStates = nil
return self
end
--- Manually deactivates a state
--- @shortDescription Deactivates a state
--- @param stateName string The state to deactivate
--- @return BaseElement self
function BaseElement:unsetState(stateName)
local states = self.get("states")
if states[stateName] ~= nil then
states[stateName] = nil
self.set("states", states)
self._cachedActiveStates = nil
end
return self
end
--- Checks if a state is currently active
--- @shortDescription Checks if state is active
--- @param stateName string The state to check
--- @return boolean isActive
function BaseElement:hasState(stateName)
local states = self.get("states")
return states[stateName] ~= nil
end
--- Gets the highest priority active state
--- @shortDescription Gets current primary state
--- @return string|nil currentState The state with highest priority
function BaseElement:getCurrentState()
local states = self.get("states")
local highestPriority = -math.huge
local currentState = nil
for stateName, priority in pairs(states) do
if priority > highestPriority then
highestPriority = priority
currentState = stateName
end
end
return currentState
end
--- Gets all currently active states sorted by priority
--- @shortDescription Gets all active states
--- @return table states Array of {name, priority} sorted by priority
function BaseElement:getActiveStates()
-- Return cached version if available
if self._cachedActiveStates then
return self._cachedActiveStates
end
local states = self.get("states")
local result = {}
for stateName, priority in pairs(states) do
table.insert(result, {name = stateName, priority = priority})
end
table.sort(result, function(a, b) return a.priority > b.priority end)
self._cachedActiveStates = result
return result
end
--- Updates all states that have auto-conditions
--- @shortDescription Updates conditional states
--- @return BaseElement self
function BaseElement:updateConditionalStates()
for stateName, stateInfo in pairs(self._registeredStates) do
if stateInfo.condition then
local result = stateInfo.condition(self)
if result then
self:setState(stateName, stateInfo.priority)
else
self:unsetState(stateName)
end
end
end
return self
end
--- Registers a responsive state that reacts to parent size changes
--- @shortDescription Registers a state that responds to parent dimensions
--- @param stateName string The name of the state
--- @param condition string|function Condition as string expression or function: function(element) return boolean end
--- @param options? table|number Options table with 'priority' and 'observe', or just priority number
--- @return BaseElement self
function BaseElement:registerResponsiveState(stateName, condition, options)
local priority = 100
local observeList = {}
if type(options) == "number" then
priority = options
elseif type(options) == "table" then
priority = options.priority or 100
observeList = options.observe or {}
end
local conditionFunc
local isStringExpr = type(condition) == "string"
if isStringExpr then
conditionFunc = self:_parseResponsiveExpression(condition)
local autoDeps = self:_detectDependencies(condition)
for _, dep in ipairs(autoDeps) do
table.insert(observeList, dep)
end
else
conditionFunc = condition
end
self:registerState(stateName, conditionFunc, priority)
for _, observeInfo in ipairs(observeList) do
local element = observeInfo.element or observeInfo[1]
local property = observeInfo.property or observeInfo[2]
if element and property then
element:observe(property, function()
self:updateConditionalStates()
end)
end
end
self:updateConditionalStates()
return self
end
--- Parses a responsive expression string into a function
--- @private
--- @param expr string The expression to parse
--- @return function conditionFunc The parsed condition function
function BaseElement:_parseResponsiveExpression(expr)
local protectedNames = {
colors = true,
math = true,
clamp = true,
round = true
}
local mathEnv = {
clamp = function(val, min, max)
return math.min(math.max(val, min), max)
end,
round = function(val)
return math.floor(val + 0.5)
end,
floor = math.floor,
ceil = math.ceil,
abs = math.abs
}
expr = expr:gsub("([%w_]+)%.([%w_]+)", function(obj, prop)
if protectedNames[obj] or tonumber(obj) then
return obj.."."..prop
end
return string.format('__getProperty("%s", "%s")', obj, prop)
end)
local element = self
local env = setmetatable({
colors = colors,
math = math,
tostring = tostring,
tonumber = tonumber,
__getProperty = function(objName, propName)
if objName == "self" then
if element._properties[propName] then
return element.get(propName)
end
elseif objName == "parent" then
if element.parent and element.parent._properties[propName] then
return element.parent.get(propName)
end
else
local target = element:getBaseFrame():getChild(objName)
if target and target._properties[propName] then
return target.get(propName)
end
end
return nil
end
}, { __index = mathEnv })
local func, err = load("return "..expr, "responsive", "t", env)
if not func then
error("Invalid responsive expression: " .. err)
end
return function(self)
local ok, result = pcall(func)
return ok and result or false
end
end
--- Detects dependencies in a responsive expression
--- @private
--- @param expr string The expression to analyze
--- @return table dependencies List of {element, property} pairs
function BaseElement:_detectDependencies(expr)
local deps = {}
local protectedNames = {colors = true, math = true, clamp = true, round = true}
for ref, prop in expr:gmatch("([%w_]+)%.([%w_]+)") do
if not protectedNames[ref] and not tonumber(ref) then
local element
if ref == "self" then
element = self
elseif ref == "parent" then
element = self.parent
else
element = self:getBaseFrame():getChild(ref)
end
if element then
table.insert(deps, {element = element, property = prop})
end
end
end
return deps
end
--- Removes a state from the registry
--- @shortDescription Removes state definition
--- @param stateName string The state to remove
--- @return BaseElement self
function BaseElement:unregisterState(stateName)
self._stateRegistry[stateName] = nil
self:unsetState(stateName)
return self
end
--- Executes all registered callbacks for the specified event --- Executes all registered callbacks for the specified event
--- @shortDescription Triggers event callbacks with provided arguments --- @shortDescription Triggers event callbacks with provided arguments
--- @param event string The event to fire --- @param event string The event to fire
--- @param ... any Additional arguments to pass to the callbacks --- @param ... any Additional arguments to pass to the callbacks
--- @return table self The BaseElement instance --- @return table self The BaseElement instance
function BaseElement:fireEvent(event, ...) function BaseElement:fireEvent(event, ...)
if self.get("eventCallbacks")[event] then if self.getResolved("eventCallbacks")[event] then
for _, callback in ipairs(self.get("eventCallbacks")[event]) do local lastResult
local result = callback(self, ...) for _, callback in ipairs(self.getResolved("eventCallbacks")[event]) do
return result lastResult = callback(self, ...)
end end
return lastResult
end end
return self return self
end end
@@ -218,7 +493,7 @@ end
--- @return boolean? handled Whether the event was handled --- @return boolean? handled Whether the event was handled
--- @protected --- @protected
function BaseElement:dispatchEvent(event, ...) function BaseElement:dispatchEvent(event, ...)
if self.get("enabled") == false then if self.getResolved("enabled") == false then
return false return false
end end
if self[event] then if self[event] then

View File

@@ -1,6 +1,5 @@
local elementManager = require("elementManager") local elementManager = require("elementManager")
local Container = elementManager.getElement("Container") local Container = elementManager.getElement("Container")
local errorManager = require("errorManager")
local Render = require("render") local Render = require("render")
---@configDescription This is the base frame class. It is the root element of all elements and the only element without a parent. ---@configDescription This is the base frame class. It is the root element of all elements and the only element without a parent.

View File

@@ -146,35 +146,37 @@ local VisualElement = elementManager.getElement("VisualElement")
---@configDefault false ---@configDefault false
--- A specialized text element that renders characters in larger sizes using Wojbie's BigFont API. Supports multiple font sizes and custom colors while maintaining the pixel-art style of ComputerCraft. Ideal for headers, titles, and emphasis text. --- A specialized text element that renders characters in larger sizes using Wojbie's BigFont API. Supports multiple font sizes and custom colors while maintaining the pixel-art style of ComputerCraft. Ideal for headers, titles, and emphasis text.
--- @usage -- Create a large welcome message --- @usage [[
--- @usage local main = basalt.getMainFrame() --- -- Create a large welcome message
--- @usage local title = main:addBigFont() --- local main = basalt.getMainFrame()
--- @usage :setPosition(3, 3) --- local title = main:addBigFont()
--- @usage :setFontSize(2) -- Makes text twice as large --- :setPosition(3, 3)
--- @usage :setText("Welcome!") --- :setFontSize(2) -- Makes text twice as large
--- @usage :setForeground(colors.yellow) -- Make text yellow --- :setText("Welcome!")
--- @usage --- :setForeground(colors.yellow) -- Make text yellow
--- @usage -- For animated text ---
--- @usage basalt.schedule(function() --- -- For animated text
--- @usage while true do --- basalt.schedule(function()
--- @usage title:setForeground(colors.yellow) --- while true do
--- @usage sleep(0.5) --- title:setForeground(colors.yellow)
--- @usage title:setForeground(colors.orange) --- sleep(0.5)
--- @usage sleep(0.5) --- title:setForeground(colors.orange)
--- @usage end --- sleep(0.5)
--- @usage end) --- end
--- end)
--- ]]
---@class BigFont : VisualElement ---@class BigFont : VisualElement
local BigFont = setmetatable({}, VisualElement) local BigFont = setmetatable({}, VisualElement)
BigFont.__index = BigFont BigFont.__index = BigFont
---@property text string BigFont The text string to display in enlarged format ---@property text string BigFont The text string to display in enlarged format
BigFont.defineProperty(BigFont, "text", {default = "BigFont", type = "string", canTriggerRender = true, setter=function(self, value) BigFont.defineProperty(BigFont, "text", {default = "BigFont", type = "string", canTriggerRender = true, setter=function(self, value)
self.bigfontText = makeText(self.get("fontSize"), value, self.get("foreground"), self.get("background")) self.bigfontText = makeText(self.getResolved("fontSize"), value, self.getResolved("foreground"), self.getResolved("background"))
return value return value
end}) end})
---@property fontSize number 1 Scale factor for text size (1-3, where 1 is 3x3 pixels per character) ---@property fontSize number 1 Scale factor for text size (1-3, where 1 is 3x3 pixels per character)
BigFont.defineProperty(BigFont, "fontSize", {default = 1, type = "number", canTriggerRender = true, setter=function(self, value) BigFont.defineProperty(BigFont, "fontSize", {default = 1, type = "number", canTriggerRender = true, setter=function(self, value)
self.bigfontText = makeText(value, self.get("text"), self.get("foreground"), self.get("background")) self.bigfontText = makeText(value, self.getResolved("text"), self.getResolved("foreground"), self.getResolved("background"))
return value return value
end}) end})
@@ -198,10 +200,10 @@ function BigFont:init(props, basalt)
VisualElement.init(self, props, basalt) VisualElement.init(self, props, basalt)
self.set("type", "BigFont") self.set("type", "BigFont")
self:observe("background", function(self, value) self:observe("background", function(self, value)
self.bigfontText = makeText(self.get("fontSize"), self.get("text"), self.get("foreground"), value) self.bigfontText = makeText(self.getResolved("fontSize"), self.getResolved("text"), self.getResolved("foreground"), value)
end) end)
self:observe("foreground", function(self, value) self:observe("foreground", function(self, value)
self.bigfontText = makeText(self.get("fontSize"), self.get("text"), value, self.get("background")) self.bigfontText = makeText(self.getResolved("fontSize"), self.getResolved("text"), value, self.getResolved("background"))
end) end)
end end
@@ -210,11 +212,12 @@ end
function BigFont:render() function BigFont:render()
VisualElement.render(self) VisualElement.render(self)
if(self.bigfontText)then if(self.bigfontText)then
local x, y = self.get("x"), self.get("y") local x, y = self.getResolved("x"), self.getResolved("y")
local width = self.getResolved("width")
for i = 1, #self.bigfontText[1] do for i = 1, #self.bigfontText[1] do
local text = self.bigfontText[1][i]:sub(1, self.get("width")) local text = self.bigfontText[1][i]:sub(1, width)
local fg = self.bigfontText[2][i]:sub(1, self.get("width")) local fg = self.bigfontText[2][i]:sub(1, width)
local bg = self.bigfontText[3][i]:sub(1, self.get("width")) local bg = self.bigfontText[3][i]:sub(1, width)
self:blit(x, y + i - 1, text, fg, bg) self:blit(x, y + i - 1, text, fg, bg)
end end
end end

126
src/elements/Breadcrumb.lua Normal file
View File

@@ -0,0 +1,126 @@
local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement")
---@configDescription A breadcrumb navigation element that displays the current path.
---@configDefault false
---@class Breadcrumb : VisualElement
local Breadcrumb = setmetatable({}, VisualElement)
Breadcrumb.__index = Breadcrumb
---@property path table {} Array of strings representing the breadcrumb segments
Breadcrumb.defineProperty(Breadcrumb, "path", {default = {}, type = "table", canTriggerRender = true})
---@property separator > string Character(s) separating path segments
Breadcrumb.defineProperty(Breadcrumb, "separator", {default = " > ", type = "string", canTriggerRender = true})
---@property clickable true boolean Whether the segments are clickable
Breadcrumb.defineProperty(Breadcrumb, "clickable", {default = true, type = "boolean"})
---@property autoSize false boolean Whether to resize the element width automatically based on text
Breadcrumb.defineProperty(Breadcrumb, "autoSize", {default = true, type = "boolean"})
Breadcrumb.defineEvent(Breadcrumb, "mouse_click")
Breadcrumb.defineEvent(Breadcrumb, "mouse_up")
--- @shortDescription Creates a new Breadcrumb instance
--- @return table self
function Breadcrumb.new()
local self = setmetatable({}, Breadcrumb):__init()
self.class = Breadcrumb
self.set("z", 5)
self.set("height", 1)
self.set("backgroundEnabled", false)
return self
end
--- @shortDescription Initializes the Breadcrumb instance
--- @param props table
--- @param basalt table
function Breadcrumb:init(props, basalt)
VisualElement.init(self, props, basalt)
self.set("type", "Breadcrumb")
end
--- @shortDescription Handles mouse click events
--- @param button number
--- @param x number
--- @param y number
--- @return boolean handled
function Breadcrumb:mouse_click(button, x, y)
if not self.getResolved("clickable") then return false end
if VisualElement.mouse_click(self, button, x, y) then
local path = self.getResolved("path")
local separator = self.getResolved("separator")
local cursorX = 1
for i, segment in ipairs(path) do
local segLen = #segment
if x >= cursorX and x < cursorX + segLen then
self:fireEvent("select",
i,
{table.unpack(path, 1, i)}
)
return true
end
cursorX = cursorX + segLen
if i < #path then
cursorX = cursorX + #separator
end
end
end
return false
end
--- Registers a callback for the select event
--- @shortDescription Registers a callback for the select event
--- @param callback function The callback function to register
--- @return Breadcrumb self The Breadcrumb instance
--- @usage breadcrumb:onSelect(function(segmentIndex, path) print("Navigated to segment:", segmentIndex, path) end)
function Breadcrumb:onSelect(callback)
self:registerCallback("select", callback)
return self
end
--- @shortDescription Renders the breadcrumb trail
--- @protected
function Breadcrumb:render()
local path = self.getResolved("path")
local separator = self.getResolved("separator")
local fg = self.getResolved("foreground")
local clickable = self.getResolved("clickable")
local width = self.getResolved("width")
local fullText = ""
for i, segment in ipairs(path) do
fullText = fullText .. segment
if i < #path then
fullText = fullText .. separator
end
end
if self.getResolved("autoSize") then
self.getResolved("width", #fullText)
else
if #fullText > width then
local ellipsis = "... > "
local maxTextLen = width - #ellipsis
if maxTextLen > 0 then
fullText = ellipsis .. fullText:sub(-maxTextLen)
else
fullText = ellipsis:sub(1, width)
end
end
end
local cursorX = 1
local color
for word in fullText:gmatch("[^"..separator.."]+") do
color = fg
self:textFg(cursorX, 1, word, color)
cursorX = cursorX + #word
local sepStart = fullText:find(separator, cursorX, true)
if sepStart then
self:textFg(cursorX, 1, separator, clickable and colors.gray or colors.lightGray)
cursorX = cursorX + #separator
end
end
end
return Breadcrumb

View File

@@ -4,26 +4,28 @@ local getCenteredPosition = require("libraries/utils").getCenteredPosition
---@configDescription The Button is a standard button element with click handling and state management. ---@configDescription The Button is a standard button element with click handling and state management.
--- A clickable interface element that triggers actions when pressed. Supports text labels, custom styling, and automatic text centering. Commonly used for user interactions and form submissions. --- A clickable interface element that triggers actions when pressed. Supports text labels, custom styling, and automatic text centering. Commonly used for user interactions and form submissions.
--- @usage -- Create a simple action button --- @usage [[
--- @usage local button = parent:addButton() --- -- Create a simple action button
--- @usage :setPosition(5, 5) --- local button = parent:addButton()
--- @usage :setText("Click me!") --- :setPosition(5, 5)
--- @usage :setBackground(colors.blue) --- :setText("Click me!")
--- @usage :setForeground(colors.white) --- :setBackground(colors.blue)
--- @usage --- :setForeground(colors.white)
--- @usage -- Add click handling ---
--- @usage button:onClick(function(self, button, x, y) --- -- Add click handling
--- @usage -- Change appearance when clicked --- button:onClick(function(self, button, x, y)
--- @usage self:setBackground(colors.green) --- -- Change appearance when clicked
--- @usage self:setText("Success!") --- self:setBackground(colors.green)
--- @usage --- self:setText("Success!")
--- @usage -- Revert after delay ---
--- @usage basalt.schedule(function() --- -- Revert after delay
--- @usage sleep(1) --- basalt.schedule(function()
--- @usage self:setBackground(colors.blue) --- sleep(1)
--- @usage self:setText("Click me!") --- self:setBackground(colors.blue)
--- @usage end) --- self:setText("Click me!")
--- @usage end) --- end)
--- end)
--- ]]
---@class Button : VisualElement ---@class Button : VisualElement
local Button = setmetatable({}, VisualElement) local Button = setmetatable({}, VisualElement)
Button.__index = Button Button.__index = Button
@@ -59,10 +61,10 @@ end
--- @protected --- @protected
function Button:render() function Button:render()
VisualElement.render(self) VisualElement.render(self)
local text = self.get("text") local text = self.getResolved("text")
text = text:sub(1, self.get("width")) text = text:sub(1, self.getResolved("width"))
local xO, yO = getCenteredPosition(text, self.get("width"), self.get("height")) local xO, yO = getCenteredPosition(text, self.getResolved("width"), self.getResolved("height"))
self:textFg(xO, yO, text, self.get("foreground")) self:textFg(xO, yO, text, self.getResolved("foreground"))
end end
return Button return Button

View File

@@ -2,18 +2,20 @@ local VisualElement = require("elements/VisualElement")
---@configDescription This is a checkbox. It is a visual element that can be checked. ---@configDescription This is a checkbox. It is a visual element that can be checked.
--- A toggleable UI element that can be checked or unchecked. Displays different text based on its state and supports automatic sizing. Commonly used in forms and settings interfaces for boolean options. --- A toggleable UI element that can be checked or unchecked. Displays different text based on its state and supports automatic sizing. Commonly used in forms and settings interfaces for boolean options.
--- @usage -- Create a checkbox for a setting --- @usage [[
--- @usage local checkbox = parent:addCheckBox() --- -- Create a checkbox for a setting
--- @usage :setText("Enable Feature") --- local checkbox = parent:addCheckBox()
--- @usage :setCheckedText("✓") --- :setText("Enable Feature")
--- @usage :onChange("checked", function(self, checked) --- :setCheckedText("✓")
--- @usage -- React to checkbox state changes --- :onChange("checked", function(self, checked)
--- @usage if checked then --- -- React to checkbox state changes
--- @usage -- Handle enabled state --- if checked then
--- @usage else --- -- Handle enabled state
--- @usage -- Handle disabled state --- else
--- @usage end --- -- Handle disabled state
--- @usage end) --- end
--- end)
--- ]]
--- @class CheckBox : VisualElement --- @class CheckBox : VisualElement
local CheckBox = setmetatable({}, VisualElement) local CheckBox = setmetatable({}, VisualElement)
CheckBox.__index = CheckBox CheckBox.__index = CheckBox
@@ -22,18 +24,18 @@ CheckBox.__index = CheckBox
CheckBox.defineProperty(CheckBox, "checked", {default = false, type = "boolean", canTriggerRender = true}) CheckBox.defineProperty(CheckBox, "checked", {default = false, type = "boolean", canTriggerRender = true})
---@property text string empty Text shown when the checkbox is unchecked ---@property text string empty Text shown when the checkbox is unchecked
CheckBox.defineProperty(CheckBox, "text", {default = " ", type = "string", canTriggerRender = true, setter=function(self, value) CheckBox.defineProperty(CheckBox, "text", {default = " ", type = "string", canTriggerRender = true, setter=function(self, value)
local checkedText = self.get("checkedText") local checkedText = self.getResolved("checkedText")
local width = math.max(#value, #checkedText) local width = math.max(#value, #checkedText)
if(self.get("autoSize"))then if(self.getResolved("autoSize"))then
self.set("width", width) self.set("width", width)
end end
return value return value
end}) end})
---@property checkedText string x Text shown when the checkbox is checked ---@property checkedText string x Text shown when the checkbox is checked
CheckBox.defineProperty(CheckBox, "checkedText", {default = "x", type = "string", canTriggerRender = true, setter=function(self, value) CheckBox.defineProperty(CheckBox, "checkedText", {default = "x", type = "string", canTriggerRender = true, setter=function(self, value)
local text = self.get("text") local text = self.getResolved("text")
local width = math.max(#value, #text) local width = math.max(#value, #text)
if(self.get("autoSize"))then if(self.getResolved("autoSize"))then
self.set("width", width) self.set("width", width)
end end
return value return value
@@ -72,7 +74,7 @@ end
--- @protected --- @protected
function CheckBox:mouse_click(button, x, y) function CheckBox:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then if VisualElement.mouse_click(self, button, x, y) then
self.set("checked", not self.get("checked")) self.set("checked", not self.getResolved("checked"))
return true return true
end end
return false return false
@@ -83,12 +85,12 @@ end
function CheckBox:render() function CheckBox:render()
VisualElement.render(self) VisualElement.render(self)
local checked = self.get("checked") local checked = self.getResolved("checked")
local defaultText = self.get("text") local defaultText = self.getResolved("text")
local checkedText = self.get("checkedText") local checkedText = self.getResolved("checkedText")
local text = string.sub(checked and checkedText or defaultText, 1, self.get("width")) local text = string.sub(checked and checkedText or defaultText, 1, self.getResolved("width"))
self:textFg(1, 1, text, self.get("foreground")) self:textFg(1, 1, text, self.getResolved("foreground"))
end end
return CheckBox return CheckBox

241
src/elements/Collection.lua Normal file
View File

@@ -0,0 +1,241 @@
local VisualElement = require("elements/VisualElement")
local CollectionEntry = require("libraries/collectionentry")
---@configDescription A collection of items
--- This is the Collection class. It provides a collection of items
---@class Collection : VisualElement
local Collection = setmetatable({}, VisualElement)
Collection.__index = Collection
Collection.defineProperty(Collection, "items", {default={}, type = "table", canTriggerRender = true})
---@property selectable boolean true Whether items can be selected
Collection.defineProperty(Collection, "selectable", {default = true, type = "boolean"})
---@property multiSelection boolean false Whether multiple items can be selected at once
Collection.defineProperty(Collection, "multiSelection", {default = false, type = "boolean"})
---@property selectedBackground color blue Background color for selected items
Collection.defineProperty(Collection, "selectedBackground", {default = colors.blue, type = "color", canTriggerRender = true})
---@property selectedForeground color white Text color for selected items
Collection.defineProperty(Collection, "selectedForeground", {default = colors.white, type = "color", canTriggerRender = true})
---@event onSelect {index number, item table} Fired when an item is selected
--- Creates a new Collection instance
--- @shortDescription Creates a new Collection instance
--- @return Collection self The newly created Collection instance
--- @private
function Collection.new()
local self = setmetatable({}, Collection):__init()
self.class = Collection
return self
end
--- @shortDescription Initializes the Collection instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @return Collection self The initialized instance
--- @protected
function Collection:init(props, basalt)
VisualElement.init(self, props, basalt)
self._entrySchema = {}
self.set("type", "Collection")
return self
end
--- Adds an item to the Collection
--- @shortDescription Adds an item to the Collection
--- @param text string|table The item to add (string or item table)
--- @return Collection self The Collection instance
--- @usage Collection:addItem("New Item")
--- @usage Collection:addItem({text="Item", callback=function() end})
function Collection:addItem(itemData)
if type(itemData) == "string" then
itemData = {text = itemData}
end
if itemData.selected == nil then
itemData.selected = false
end
local entry = CollectionEntry.new(self, itemData, self._entrySchema)
table.insert(self.getResolved("items"), entry)
self:updateRender()
return entry
end
--- Removes an item from the Collection
--- @shortDescription Removes an item from the Collection
--- @param index number The index of the item to remove
--- @return Collection self The Collection instance
--- @usage Collection:removeItem(1)
function Collection:removeItem(index)
local items = self.getResolved("items")
if type(index) == "number" then
table.remove(items, index)
else
for k,v in pairs(items)do
if v == index then
table.remove(items, k)
break
end
end
end
self:updateRender()
return self
end
--- Clears all items from the Collection
--- @shortDescription Clears all items from the Collection
--- @return Collection self The Collection instance
--- @usage Collection:clear()
function Collection:clear()
self.set("items", {})
self:updateRender()
return self
end
-- Gets the currently selected items
--- @shortDescription Gets the currently selected items
--- @return table selected Collection of selected items
--- @usage local selected = Collection:getSelectedItems()
function Collection:getSelectedItems()
local selected = {}
for i, item in ipairs(self.getResolved("items")) do
if type(item) == "table" and item.selected then
local selectedItem = item
selectedItem.index = i
table.insert(selected, selectedItem)
end
end
return selected
end
--- Gets first selected item
--- @shortDescription Gets first selected item
--- @return table? selected The first item
function Collection:getSelectedItem()
local items = self.getResolved("items")
for i, item in ipairs(items) do
if type(item) == "table" and item.selected then
return item
end
end
return nil
end
function Collection:selectItem(index)
local items = self.getResolved("items")
if type(index) == "number" then
if items[index] and type(items[index]) == "table" then
items[index].selected = true
end
else
for k,v in pairs(items)do
if v == index then
if type(v) == "table" then
v.selected = true
end
break
end
end
end
self:updateRender()
return self
end
function Collection:unselectItem(index)
local items = self.getResolved("items")
if type(index) == "number" then
if items[index] and type(items[index]) == "table" then
items[index].selected = false
end
else
for k,v in pairs(items)do
if v == index then
if type(items[k]) == "table" then
items[k].selected = false
end
break
end
end
end
self:updateRender()
return self
end
function Collection:clearItemSelection()
local items = self.getResolved("items")
for i, item in ipairs(items) do
item.selected = false
end
self:updateRender()
return self
end
--- Gets the index of the first selected item
--- @shortDescription Gets the index of the first selected item
--- @return number? index The index of the first selected item, or nil if none selected
--- @usage local index = Collection:getSelectedIndex()
function Collection:getSelectedIndex()
local items = self.getResolved("items")
for i, item in ipairs(items) do
if type(item) == "table" and item.selected then
return i
end
end
return nil
end
--- Selects the next item in the collection
--- @shortDescription Selects the next item
--- @return Collection self The Collection instance
function Collection:selectNext()
local items = self.getResolved("items")
local currentIndex = self:getSelectedIndex()
if not currentIndex then
if #items > 0 then
self:selectItem(1)
end
elseif currentIndex < #items then
if not self.getResolved("multiSelection") then
self:clearItemSelection()
end
self:selectItem(currentIndex + 1)
end
self:updateRender()
return self
end
--- Selects the previous item in the collection
--- @shortDescription Selects the previous item
--- @return Collection self The Collection instance
function Collection:selectPrevious()
local items = self.getResolved("items")
local currentIndex = self:getSelectedIndex()
if not currentIndex then
if #items > 0 then
self:selectItem(#items)
end
elseif currentIndex > 1 then
if not self.getResolved("multiSelection") then
self:clearItemSelection()
end
self:selectItem(currentIndex - 1)
end
self:updateRender()
return self
end
--- Registers a callback for the select event
--- @shortDescription Registers a callback for the select event
--- @param callback function The callback function to register
--- @return Collection self The Collection instance
--- @usage Collection:onSelect(function(index, item) print("Selected item:", index, item) end)
function Collection:onSelect(callback)
self:registerCallback("select", callback)
return self
end
return Collection

View File

@@ -1,30 +1,32 @@
local VisualElement = require("elements/VisualElement") local VisualElement = require("elements/VisualElement")
local List = require("elements/List")
local DropDown = require("elements/DropDown") local DropDown = require("elements/DropDown")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDescription A ComboBox that combines dropdown selection with editable text input ---@configDescription A ComboBox that combines dropdown selection with editable text input
---@configDefault false ---@configDefault false
--- A hybrid input element that combines a text input field with a dropdown list. Users can either type directly or select from predefined options. --- A hybrid input element that combines a text input field with a dropdown list. Users can either type directly or select from predefined options.
--- Supports auto-completion, custom styling, and both single and multi-selection modes. --- Supports auto-completion, custom styling, and both single and multi-selection modes.
--- @usage -- Create a searchable country selector --- @usage [[
--- @usage local combo = main:addComboBox() --- -- Create a searchable country selector
--- @usage :setPosition(5, 5) --- local combo = main:addComboBox()
--- @usage :setSize(20, 1) -- Height will expand when opened --- :setPosition(5, 5)
--- @usage :setItems({ --- :setSize(20, 1) -- Height will expand when opened
--- @usage {text = "Germany"}, --- :setItems({
--- @usage {text = "France"}, --- {text = "Germany"},
--- @usage {text = "Spain"}, --- {text = "France"},
--- @usage {text = "Italy"} --- {text = "Spain"},
--- @usage }) --- {text = "Italy"}
--- @usage :setPlaceholder("Select country...") --- })
--- @usage :setAutoComplete(true) -- Enable filtering while typing --- :setSelectedText("Select country...") -- Placeholder text
--- @usage --- :setAutoComplete(true) -- Enable filtering while typing
--- @usage -- Handle selection changes ---
--- @usage combo:onChange(function(self, value) --- -- Handle selection changes
--- @usage -- value will be the selected country --- combo:onChange(function(self, value)
--- @usage basalt.debug("Selected:", value) --- -- value will be the selected country
--- @usage end) --- basalt.debug("Selected:", value)
--- end)
--- ]]
---@class ComboBox : DropDown ---@class ComboBox : DropDown
local ComboBox = setmetatable({}, DropDown) local ComboBox = setmetatable({}, DropDown)
ComboBox.__index = ComboBox ComboBox.__index = ComboBox
@@ -32,19 +34,15 @@ ComboBox.__index = ComboBox
---@property editable boolean true Enables direct text input in the field ---@property editable boolean true Enables direct text input in the field
ComboBox.defineProperty(ComboBox, "editable", {default = true, type = "boolean", canTriggerRender = true}) ComboBox.defineProperty(ComboBox, "editable", {default = true, type = "boolean", canTriggerRender = true})
---@property text string "" The current text value of the input field ---@property text string "" The current text value of the input field
ComboBox.defineProperty(ComboBox, "text", {default = "", type = "string", canTriggerRender = true}) ComboBox.defineProperty(ComboBox, "text", {default = "", type = "string", canTriggerRender = true, setter = function(self, value)
self.set("cursorPos", #value + 1)
self:updateViewport()
return value
end})
---@property cursorPos number 1 Current cursor position in the text input ---@property cursorPos number 1 Current cursor position in the text input
ComboBox.defineProperty(ComboBox, "cursorPos", {default = 1, type = "number"}) ComboBox.defineProperty(ComboBox, "cursorPos", {default = 1, type = "number"})
---@property viewOffset number 0 Horizontal scroll position for viewing long text ---@property viewOffset number 0 Horizontal scroll position for viewing long text
ComboBox.defineProperty(ComboBox, "viewOffset", {default = 0, type = "number", canTriggerRender = true}) ComboBox.defineProperty(ComboBox, "viewOffset", {default = 0, type = "number", canTriggerRender = true})
---@property placeholder string "..." Text shown when the input is empty
ComboBox.defineProperty(ComboBox, "placeholder", {default = "...", type = "string"})
---@property placeholderColor color gray Color used for placeholder text
ComboBox.defineProperty(ComboBox, "placeholderColor", {default = colors.gray, type = "color"})
---@property focusedBackground color blue Background color when input is focused
ComboBox.defineProperty(ComboBox, "focusedBackground", {default = colors.blue, type = "color"})
---@property focusedForeground color white Text color when input is focused
ComboBox.defineProperty(ComboBox, "focusedForeground", {default = colors.white, type = "color"})
---@property autoComplete boolean false Enables filtering dropdown items while typing ---@property autoComplete boolean false Enables filtering dropdown items while typing
ComboBox.defineProperty(ComboBox, "autoComplete", {default = false, type = "boolean"}) ComboBox.defineProperty(ComboBox, "autoComplete", {default = false, type = "boolean"})
---@property manuallyOpened boolean false Indicates if dropdown was opened by user action ---@property manuallyOpened boolean false Indicates if dropdown was opened by user action
@@ -73,35 +71,6 @@ function ComboBox:init(props, basalt)
self.set("cursorPos", 1) self.set("cursorPos", 1)
self.set("viewOffset", 0) self.set("viewOffset", 0)
return self
end
--- Sets the text content of the ComboBox
--- @shortDescription Sets the text content
--- @param text string The text to set
--- @return ComboBox self
function ComboBox:setText(text)
if text == nil then text = "" end
self.set("text", tostring(text))
self.set("cursorPos", #self.get("text") + 1)
self:updateViewport()
return self
end
--- Gets the current text content
--- @shortDescription Gets the text content
--- @return string text The current text
function ComboBox:getText()
return self.get("text")
end
--- Sets whether the ComboBox is editable
--- @shortDescription Sets editable state
--- @param editable boolean Whether the ComboBox should be editable
--- @return ComboBox self
function ComboBox:setEditable(editable)
self.set("editable", editable)
return self return self
end end
@@ -109,10 +78,10 @@ end
--- @shortDescription Filters items for auto-complete --- @shortDescription Filters items for auto-complete
--- @private --- @private
function ComboBox:getFilteredItems() function ComboBox:getFilteredItems()
local allItems = self.get("items") or {} local allItems = self.getResolved("items") or {}
local currentText = self.get("text"):lower() local currentText = self.getResolved("text"):lower()
if not self.get("autoComplete") or #currentText == 0 then if not self.getResolved("autoComplete") or #currentText == 0 then
return allItems return allItems
end end
@@ -137,19 +106,19 @@ end
--- @shortDescription Updates dropdown with filtered items --- @shortDescription Updates dropdown with filtered items
--- @private --- @private
function ComboBox:updateFilteredDropdown() function ComboBox:updateFilteredDropdown()
if not self.get("autoComplete") then return end if not self.getResolved("autoComplete") then return end
local filteredItems = self:getFilteredItems() local filteredItems = self:getFilteredItems()
local shouldOpen = #filteredItems > 0 and #self.get("text") > 0 local shouldOpen = #filteredItems > 0 and #self.getResolved("text") > 0
if shouldOpen then if shouldOpen then
self.set("isOpen", true) self:setState("opened")
self.set("manuallyOpened", false) self.set("manuallyOpened", false)
local dropdownHeight = self.get("dropdownHeight") or 5 local dropdownHeight = self.getResolved("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #filteredItems) local actualHeight = math.min(dropdownHeight, #filteredItems)
self.set("height", 1 + actualHeight) self.set("height", 1 + actualHeight)
else else
self.set("isOpen", false) self:unsetState("opened")
self.set("manuallyOpened", false) self.set("manuallyOpened", false)
self.set("height", 1) self.set("height", 1)
end end
@@ -159,15 +128,15 @@ end
--- @shortDescription Updates the viewport --- @shortDescription Updates the viewport
--- @private --- @private
function ComboBox:updateViewport() function ComboBox:updateViewport()
local text = self.get("text") local text = self.getResolved("text")
local cursorPos = self.get("cursorPos") local cursorPos = self.getResolved("cursorPos")
local width = self.get("width") local width = self.getResolved("width")
local dropSymbol = self.get("dropSymbol") local dropSymbol = self.getResolved("dropSymbol")
local textWidth = width - #dropSymbol local textWidth = width - #dropSymbol
if textWidth < 1 then textWidth = 1 end if textWidth < 1 then textWidth = 1 end
local viewOffset = self.get("viewOffset") local viewOffset = self.getResolved("viewOffset")
if cursorPos - viewOffset > textWidth then if cursorPos - viewOffset > textWidth then
viewOffset = cursorPos - textWidth viewOffset = cursorPos - textWidth
@@ -182,18 +151,18 @@ end
--- @shortDescription Handles character input --- @shortDescription Handles character input
--- @param char string The character that was typed --- @param char string The character that was typed
function ComboBox:char(char) function ComboBox:char(char)
if not self.get("editable") then return end if not self.getResolved("editable") then return end
if not self.get("focused") then return end if not self:hasState("focused") then return end
local text = self.get("text") local text = self.getResolved("text")
local cursorPos = self.get("cursorPos") local cursorPos = self.getResolved("cursorPos")
local newText = text:sub(1, cursorPos - 1) .. char .. text:sub(cursorPos) local newText = text:sub(1, cursorPos - 1) .. char .. text:sub(cursorPos)
self.set("text", newText) self.set("text", newText)
self.set("cursorPos", cursorPos + 1) self.set("cursorPos", cursorPos + 1)
self:updateViewport() self:updateViewport()
if self.get("autoComplete") then if self.getResolved("autoComplete") then
self:updateFilteredDropdown() self:updateFilteredDropdown()
else else
self:updateRender() self:updateRender()
@@ -205,11 +174,11 @@ end
--- @param key number The key code that was pressed --- @param key number The key code that was pressed
--- @param held boolean Whether the key is being held --- @param held boolean Whether the key is being held
function ComboBox:key(key, held) function ComboBox:key(key, held)
if not self.get("editable") then return end if not self.getResolved("editable") then return end
if not self.get("focused") then return end if not self:hasState("focused") then return end
local text = self.get("text") local text = self.getResolved("text")
local cursorPos = self.get("cursorPos") local cursorPos = self.getResolved("cursorPos")
if key == keys.left then if key == keys.left then
self.set("cursorPos", math.max(1, cursorPos - 1)) self.set("cursorPos", math.max(1, cursorPos - 1))
@@ -224,7 +193,7 @@ function ComboBox:key(key, held)
self.set("cursorPos", cursorPos - 1) self.set("cursorPos", cursorPos - 1)
self:updateViewport() self:updateViewport()
if self.get("autoComplete") then if self.getResolved("autoComplete") then
self:updateFilteredDropdown() self:updateFilteredDropdown()
else else
self:updateRender() self:updateRender()
@@ -236,7 +205,7 @@ function ComboBox:key(key, held)
self.set("text", newText) self.set("text", newText)
self:updateViewport() self:updateViewport()
if self.get("autoComplete") then if self.getResolved("autoComplete") then
self:updateFilteredDropdown() self:updateFilteredDropdown()
else else
self:updateRender() self:updateRender()
@@ -249,7 +218,11 @@ function ComboBox:key(key, held)
self.set("cursorPos", #text + 1) self.set("cursorPos", #text + 1)
self:updateViewport() self:updateViewport()
elseif key == keys.enter then elseif key == keys.enter then
self.set("isOpen", not self.get("isOpen")) if self:hasState("opened") then
self:unsetState("opened")
else
self:setState("opened")
end
self:updateRender() self:updateRender()
end end
end end
@@ -265,98 +238,141 @@ function ComboBox:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end if not VisualElement.mouse_click(self, button, x, y) then return false end
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local width = self.get("width") local width = self.getResolved("width")
local dropSymbol = self.get("dropSymbol") local dropSymbol = self.getResolved("dropSymbol")
local isOpen = self:hasState("opened")
if relY == 1 then if relY == 1 then
if relX >= width - #dropSymbol + 1 and relX <= width then if relX >= width - #dropSymbol + 1 and relX <= width then
if isOpen then
local isCurrentlyOpen = self.get("isOpen") self:unsetState("opened")
self.set("isOpen", not isCurrentlyOpen) self.set("height", 1)
self.set("manuallyOpened", false)
if self.get("isOpen") then else
local allItems = self.get("items") or {} self:setState("opened")
local dropdownHeight = self.get("dropdownHeight") or 5 local allItems = self.getResolved("items") or {}
local dropdownHeight = self.getResolved("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #allItems) local actualHeight = math.min(dropdownHeight, #allItems)
self.set("height", 1 + actualHeight) self.set("height", 1 + actualHeight)
self.set("manuallyOpened", true) self.set("manuallyOpened", true)
else
self.set("height", 1)
self.set("manuallyOpened", false)
end end
self:updateRender() self:updateRender()
return true return true
end end
if relX <= width - #dropSymbol and self.get("editable") then if relX <= width - #dropSymbol and self.getResolved("editable") then
local text = self.get("text") local text = self.getResolved("text")
local viewOffset = self.get("viewOffset") local viewOffset = self.getResolved("viewOffset")
local maxPos = #text + 1 local maxPos = #text + 1
local targetPos = math.min(maxPos, viewOffset + relX) local targetPos = math.min(maxPos, viewOffset + relX)
self.set("cursorPos", targetPos) self.set("cursorPos", targetPos)
if not isOpen then
self:setState("opened")
local allItems = self.getResolved("items") or {}
local dropdownHeight = self.getResolved("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #allItems)
self.set("height", 1 + actualHeight)
self.set("manuallyOpened", true)
end
self:updateRender() self:updateRender()
return true return true
end end
return true return true
elseif self.get("isOpen") and relY > 1 and self.get("selectable") then elseif isOpen and relY > 1 then
local itemIndex = (relY - 1) + self.get("offset") return DropDown.mouse_click(self, button, x, y)
local items = self.get("items")
if itemIndex <= #items then
local item = items[itemIndex]
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
otherItem.selected = false
end
end
end
item.selected = true
if item.text then
self:setText(item.text)
end
self.set("isOpen", false)
self.set("height", 1)
self:updateRender()
return true
end
end end
return false return false
end end
--- Handles mouse up events for item selection
--- @shortDescription Handles mouse up for selection
--- @param button number The mouse button that was released
--- @param x number The x-coordinate of the release
--- @param y number The y-coordinate of the release
--- @return boolean handled Whether the event was handled
--- @protected
function ComboBox:mouse_up(button, x, y)
if self:hasState("opened") then
local relX, relY = self:getRelativePosition(x, y)
if relY > 1 and self.getResolved("selectable") and not self._scrollBarDragging then
local itemIndex = (relY - 1) + self.getResolved("offset")
local items
if self.getResolved("autoComplete") and not self.getResolved("manuallyOpened") then
items = self:getFilteredItems()
else
items = self.getResolved("items")
end
if itemIndex <= #items then
local item = items[itemIndex]
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if not self.getResolved("multiSelection") then
for _, otherItem in ipairs(self.getResolved("items")) do
if type(otherItem) == "table" then
otherItem.selected = false
end
end
end
item.selected = true
if item.text then
self.set("text", item.text)
self.set("cursorPos", #item.text + 1)
self:updateViewport()
end
if item.callback then
item.callback(self)
end
self:fireEvent("select", itemIndex, item)
self:unsetState("opened")
self:unsetState("clicked")
self.set("height", 1)
self.set("manuallyOpened", false)
self:updateRender()
return true
end
end
return DropDown.mouse_up(self, button, x, y)
end
return VisualElement.mouse_up and VisualElement.mouse_up(self, button, x, y) or false
end
--- Renders the ComboBox --- Renders the ComboBox
--- @shortDescription Renders the ComboBox --- @shortDescription Renders the ComboBox
--- @protected
function ComboBox:render() function ComboBox:render()
VisualElement.render(self) VisualElement.render(self)
local text = self.get("text")
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
local isFocused = self.get("focused")
local isOpen = self.get("isOpen")
local viewOffset = self.get("viewOffset")
local placeholder = self.get("placeholder")
local bg = isFocused and self.get("focusedBackground") or self.get("background") local text = self.getResolved("text")
local fg = isFocused and self.get("focusedForeground") or self.get("foreground") local width = self.getResolved("width")
local dropSymbol = self.getResolved("dropSymbol")
local isFocused = self:hasState("focused")
local isOpen = self:hasState("opened")
local viewOffset = self.getResolved("viewOffset")
local selectedText = self.getResolved("selectedText")
local bg = self.getResolved("background")
local fg = self.getResolved("foreground")
local displayText = text local displayText = text
local textWidth = width - #dropSymbol local textWidth = width - #dropSymbol
if #text == 0 and not isFocused and #placeholder > 0 then if #text == 0 and not isFocused and #selectedText > 0 then
displayText = placeholder displayText = selectedText
fg = self.get("placeholderColor") fg = colors.gray
end end
if #displayText > 0 then if #displayText > 0 then
@@ -371,64 +387,45 @@ function ComboBox:render()
string.rep(tHex[fg], width), string.rep(tHex[fg], width),
string.rep(tHex[bg], width)) string.rep(tHex[bg], width))
if isFocused and self.get("editable") then if isFocused and self.getResolved("editable") then
local cursorPos = self.get("cursorPos") local cursorPos = self.getResolved("cursorPos")
local cursorX = cursorPos - viewOffset local cursorX = cursorPos - viewOffset
if cursorX >= 1 and cursorX <= textWidth then if cursorX >= 1 and cursorX <= textWidth then
self:setCursor(cursorX, 1, true, self.get("foreground")) self:setCursor(cursorX, 1, true, fg)
end end
end end
if isOpen then if isOpen then
local items local actualHeight = self.getResolved("height")
if self.get("autoComplete") and not self.get("manuallyOpened") then local items = self.getResolved("items")
if self.getResolved("autoComplete") and not self.getResolved("manuallyOpened") then
items = self:getFilteredItems() items = self:getFilteredItems()
else
items = self.get("items")
end end
local dropdownHeight = math.min(self.get("dropdownHeight"), #items) local dropdownHeight = math.min(self.getResolved("dropdownHeight"), #items)
if dropdownHeight > 0 then
local offset = self.get("offset")
for i = 1, dropdownHeight do local originalItems = self._values.items
local itemIndex = i + offset self._values.items = items
if items[itemIndex] then self.set("height", dropdownHeight)
local item = items[itemIndex]
local itemText = item.text or ""
local isSelected = item.selected or false
local itemBg = isSelected and self.get("selectedBackground") or self.get("background") List.render(self, 1)
local itemFg = isSelected and self.get("selectedForeground") or self.get("foreground")
if #itemText > width then self._values.items = originalItems
itemText = itemText:sub(1, width) self.set("height", actualHeight)
end
itemText = itemText .. string.rep(" ", width - #itemText) self:blit(1, 1, fullText,
self:blit(1, i + 1, itemText, string.rep(tHex[fg], width),
string.rep(tHex[itemFg], width), string.rep(tHex[bg], width))
string.rep(tHex[itemBg], width))
end if isFocused and self.getResolved("editable") then
local cursorPos = self.getResolved("cursorPos")
local cursorX = cursorPos - viewOffset
if cursorX >= 1 and cursorX <= textWidth then
self:setCursor(cursorX, 1, true, fg)
end end
end end
end end
end end
--- Called when the ComboBox gains focus return ComboBox
--- @shortDescription Called when gaining focus
function ComboBox:focus()
DropDown.focus(self)
-- Additional focus logic for input if needed
end
--- Called when the ComboBox loses focus
--- @shortDescription Called when losing focus
function ComboBox:blur()
DropDown.blur(self)
self.set("isOpen", false)
self.set("height", 1)
self:updateRender()
end
return ComboBox

View File

@@ -1,18 +1,13 @@
local elementManager = require("elementManager") local elementManager = require("elementManager")
local errorManager = require("errorManager") local errorManager = require("errorManager")
local VisualElement = elementManager.getElement("VisualElement") local VisualElement = elementManager.getElement("VisualElement")
local LayoutManager = require("layoutManager")
local expect = require("libraries/expect") local expect = require("libraries/expect")
local split = require("libraries/utils").split local split = require("libraries/utils").split
---@configDescription The container class. It is a visual element that can contain other elements. It is the base class for all containers ---@configDescription The container class. It is a visual element that can contain other elements. It is the base class for all containers
---@configDefault true ---@configDefault true
--- A fundamental layout element that manages child UI components. Containers handle element organization, event propagation, --- A fundamental layout element that manages child UI components. Containers handle element organization, event propagation, rendering hierarchy, and coordinate space management.
--- rendering hierarchy, and coordinate space management. They serve as the backbone of Basalt's UI structure by providing:
--- - Child element management and organization
--- - Event bubbling and distribution
--- - Visibility calculations and clipping
--- - Focus management
--- - Coordinate space transformation
---@class Container : VisualElement ---@class Container : VisualElement
local Container = setmetatable({}, VisualElement) local Container = setmetatable({}, VisualElement)
Container.__index = Container Container.__index = Container
@@ -37,11 +32,11 @@ Container.defineProperty(Container, "focusedChild", {default = nil, type = "tabl
if oldChild:isType("Container") then if oldChild:isType("Container") then
oldChild.set("focusedChild", nil, true) oldChild.set("focusedChild", nil, true)
end end
oldChild.set("focused", false, true) oldChild:setFocused(false, true)
end end
if value and not internal then if value and not internal then
value.set("focused", true, true) value:setFocused(true, true)
if self.parent then if self.parent then
self.parent:setFocusedChild(self) self.parent:setFocusedChild(self)
end end
@@ -78,7 +73,7 @@ for k, _ in pairs(elementManager:getElementList()) do
expect(1, self, "table") expect(1, self, "table")
local element = self.basalt.create(k, ...) local element = self.basalt.create(k, ...)
self:addChild(element) self:addChild(element)
element:postInit() --element:postInit()
return element return element
end end
Container["addDelayed"..capitalizedName] = function(self, prop) Container["addDelayed"..capitalizedName] = function(self, prop)
@@ -109,10 +104,12 @@ function Container:init(props, basalt)
self:observe("width", function() self:observe("width", function()
self.set("childrenSorted", false) self.set("childrenSorted", false)
self.set("childrenEventsSorted", false) self.set("childrenEventsSorted", false)
self:updateRender()
end) end)
self:observe("height", function() self:observe("height", function()
self.set("childrenSorted", false) self.set("childrenSorted", false)
self.set("childrenEventsSorted", false) self.set("childrenEventsSorted", false)
self:updateRender()
end) end)
end end
@@ -124,8 +121,8 @@ function Container:isChildVisible(child)
if not child:isType("VisualElement") then return false end if not child:isType("VisualElement") then return false end
if(child.get("visible") == false)then return false end if(child.get("visible") == false)then return false end
if(child._destroyed)then return false end if(child._destroyed)then return false end
local containerW, containerH = self.get("width"), self.get("height") local containerW, containerH = self.getResolved("width"), self.getResolved("height")
local offsetX, offsetY = self.get("offsetX"), self.get("offsetY") local offsetX, offsetY = self.getResolved("offsetX"), self.getResolved("offsetY")
local childX, childY = child.get("x"), child.get("y") local childX, childY = child.get("x"), child.get("y")
local childW, childH = child.get("width"), child.get("height") local childW, childH = child.get("width"), child.get("height")
@@ -209,8 +206,12 @@ end
--- @shortDescription Updates child element ordering --- @shortDescription Updates child element ordering
--- @return Container self For method chaining --- @return Container self For method chaining
function Container:sortChildren() function Container:sortChildren()
self.set("visibleChildren", sortAndFilterChildren(self, self._values.children))
self.set("childrenSorted", true) self.set("childrenSorted", true)
if self._layoutInstance then
self:updateLayout()
end
self.set("visibleChildren", sortAndFilterChildren(self, self._values.children))
return self return self
end end
@@ -300,7 +301,6 @@ function Container:unregisterChildEvent(child, eventName)
end end
end end
self.set("childrenEventsSorted", false) self.set("childrenEventsSorted", false)
self:updateRender()
break break
end end
end end
@@ -353,7 +353,7 @@ local function convertMousePosition(self, event, ...)
local args = {...} local args = {...}
if event and event:find("mouse_") then if event and event:find("mouse_") then
local button, absX, absY = ... local button, absX, absY = ...
local xOffset, yOffset = self.get("offsetX"), self.get("offsetY") local xOffset, yOffset = self.getResolved("offsetX"), self.getResolved("offsetY")
local relX, relY = self:getRelativePosition(absX + xOffset, absY + yOffset) local relX, relY = self:getRelativePosition(absX + xOffset, absY + yOffset)
args = {button, relX, relY} args = {button, relX, relY}
end end
@@ -368,7 +368,13 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @return table? child The child that handled the event --- @return table? child The child that handled the event
function Container:callChildrenEvent(visibleOnly, event, ...) function Container:callChildrenEvent(visibleOnly, event, ...)
local children = visibleOnly and self.get("visibleChildrenEvents") or self.get("childrenEvents") if visibleOnly and not self.getResolved("childrenEventsSorted") then
for evt in pairs(self._values.childrenEvents) do
self:sortChildrenEvents(evt)
end
end
local children = visibleOnly and self.getResolved("visibleChildrenEvents") or self.getResolved("childrenEvents")
if children[event] then if children[event] then
local events = children[event] local events = children[event]
for i = #events, 1, -1 do for i = #events, 1, -1 do
@@ -428,6 +434,7 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Container:mouse_up(button, x, y) function Container:mouse_up(button, x, y)
self:mouse_release(button, x, y)
if VisualElement.mouse_up(self, button, x, y) then if VisualElement.mouse_up(self, button, x, y) then
local args = convertMousePosition(self, "mouse_up", button, x, y) local args = convertMousePosition(self, "mouse_up", button, x, y)
local success, child = self:callChildrenEvent(true, "mouse_up", table.unpack(args)) local success, child = self:callChildrenEvent(true, "mouse_up", table.unpack(args))
@@ -493,7 +500,7 @@ function Container:mouse_scroll(direction, x, y)
if(VisualElement.mouse_scroll(self, direction, x, y))then if(VisualElement.mouse_scroll(self, direction, x, y))then
local args = convertMousePosition(self, "mouse_scroll", direction, x, y) local args = convertMousePosition(self, "mouse_scroll", direction, x, y)
local success, child = self:callChildrenEvent(true, "mouse_scroll", table.unpack(args)) local success, child = self:callChildrenEvent(true, "mouse_scroll", table.unpack(args))
return success return true
end end
return false return false
end end
@@ -503,8 +510,8 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Container:key(key) function Container:key(key)
if self.get("focusedChild") then if self.getResolved("focusedChild") then
return self.get("focusedChild"):dispatchEvent("key", key) return self.getResolved("focusedChild"):dispatchEvent("key", key)
end end
return true return true
end end
@@ -514,8 +521,8 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Container:char(char) function Container:char(char)
if self.get("focusedChild") then if self.getResolved("focusedChild") then
return self.get("focusedChild"):dispatchEvent("char", char) return self.getResolved("focusedChild"):dispatchEvent("char", char)
end end
return true return true
end end
@@ -525,8 +532,8 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Container:key_up(key) function Container:key_up(key)
if self.get("focusedChild") then if self.getResolved("focusedChild") then
return self.get("focusedChild"):dispatchEvent("key_up", key) return self.getResolved("focusedChild"):dispatchEvent("key_up", key)
end end
return true return true
end end
@@ -542,8 +549,8 @@ end
--- @return Container self The container instance --- @return Container self The container instance
--- @protected --- @protected
function Container:multiBlit(x, y, width, height, text, fg, bg) function Container:multiBlit(x, y, width, height, text, fg, bg)
local w, h = self.get("width"), self.get("height") local w, h = self.getResolved("width"), self.getResolved("height")
width = x < 1 and math.min(width + x - 1, w) or math.min(width, math.max(0, w - x + 1)) width = x < 1 and math.min(width + x - 1, w) or math.min(width, math.max(0, w - x + 1))
height = y < 1 and math.min(height + y - 1, h) or math.min(height, math.max(0, h - y + 1)) height = y < 1 and math.min(height + y - 1, h) or math.min(height, math.max(0, h - y + 1))
@@ -561,7 +568,7 @@ end
--- @return Container self The container instance --- @return Container self The container instance
--- @protected --- @protected
function Container:textFg(x, y, text, fg) function Container:textFg(x, y, text, fg)
local w, h = self.get("width"), self.get("height") local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end if y < 1 or y > h then return self end
@@ -582,7 +589,7 @@ end
--- @return Container self The container instance --- @return Container self The container instance
--- @protected --- @protected
function Container:textBg(x, y, text, bg) function Container:textBg(x, y, text, bg)
local w, h = self.get("width"), self.get("height") local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end if y < 1 or y > h then return self end
@@ -596,7 +603,7 @@ function Container:textBg(x, y, text, bg)
end end
function Container:drawText(x, y, text) function Container:drawText(x, y, text)
local w, h = self.get("width"), self.get("height") local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end if y < 1 or y > h then return self end
@@ -610,7 +617,7 @@ function Container:drawText(x, y, text)
end end
function Container:drawFg(x, y, fg) function Container:drawFg(x, y, fg)
local w, h = self.get("width"), self.get("height") local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end if y < 1 or y > h then return self end
@@ -623,7 +630,7 @@ function Container:drawFg(x, y, fg)
end end
function Container:drawBg(x, y, bg) function Container:drawBg(x, y, bg)
local w, h = self.get("width"), self.get("height") local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end if y < 1 or y > h then return self end
@@ -644,7 +651,7 @@ end
--- @return Container self The container instance --- @return Container self The container instance
--- @protected --- @protected
function Container:blit(x, y, text, fg, bg) function Container:blit(x, y, text, fg, bg)
local w, h = self.get("width"), self.get("height") local w, h = self.getResolved("width"), self.getResolved("height")
if y < 1 or y > h then return self end if y < 1 or y > h then return self end
@@ -667,15 +674,15 @@ end
--- @protected --- @protected
function Container:render() function Container:render()
VisualElement.render(self) VisualElement.render(self)
if not self.get("childrenSorted")then if not self.getResolved("childrenSorted")then
self:sortChildren() self:sortChildren()
end end
if not self.get("childrenEventsSorted")then if not self.getResolved("childrenEventsSorted")then
for event in pairs(self._values.childrenEvents) do for event in pairs(self._values.childrenEvents) do
self:sortChildrenEvents(event) self:sortChildrenEvents(event)
end end
end end
for _, child in ipairs(self.get("visibleChildren")) do for _, child in ipairs(self.getResolved("visibleChildren")) do
if child == self then if child == self then
errorManager.error("CIRCULAR REFERENCE DETECTED!") errorManager.error("CIRCULAR REFERENCE DETECTED!")
return return
@@ -685,6 +692,47 @@ function Container:render()
end end
end end
--- Applies a layout from a file to this container
--- @shortDescription Applies a layout to the container
--- @param layoutPath string Path to the layout file (e.g. "layouts/grid")
--- @param options? table Optional layout-specific options
--- @return Container self For method chaining
function Container:applyLayout(layoutPath, options)
if self._layoutInstance then
LayoutManager.destroy(self._layoutInstance)
end
self._layoutInstance = LayoutManager.apply(self, layoutPath)
if options then
self._layoutInstance.options = options
end
return self
end
--- Updates the current layout (recalculates positions)
--- @shortDescription Updates the layout
--- @return Container self For method chaining
function Container:updateLayout()
if self._layoutInstance then
LayoutManager.update(self._layoutInstance)
end
return self
end
--- Removes the current layout
--- @shortDescription Clears the layout
--- @return Container self For method chaining
function Container:clearLayout()
if self._layoutInstance then
local LayoutManager = require("layoutManager")
LayoutManager.destroy(self._layoutInstance)
self._layoutInstance = nil
end
return self
end
--- @private --- @private
function Container:destroy() function Container:destroy()

View File

@@ -0,0 +1,364 @@
local elementManager = require("elementManager")
local VisualElement = require("elements/VisualElement")
local Container = elementManager.getElement("Container")
local tHex = require("libraries/colorHex")
---@configDescription A ContextMenu element that displays a menu with items and submenus.
---@configDefault false
--- The ContextMenu displays a list of clickable items with optional submenus
--- @run [[
--- local basalt = require("basalt")
---
--- local main = basalt.getMainFrame()
---
--- -- Create a label that shows the selected action
--- local statusLabel = main:addLabel({
--- x = 2,
--- y = 2,
--- text = "Right-click anywhere!",
--- foreground = colors.yellow
--- })
---
--- -- Create a ContextMenu
--- local contextMenu = main:addContextMenu({
--- x = 10,
--- y = 5,
--- background = colors.black,
--- foreground = colors.white,
--- })
---
--- contextMenu:setItems({
--- {
--- label = "Copy",
--- onClick = function()
--- statusLabel:setText("Action: Copy")
--- end
--- },
--- {
--- label = "Paste",
--- onClick = function()
--- statusLabel:setText("Action: Paste")
--- end
--- },
--- {
--- label = "Delete",
--- background = colors.red,
--- foreground = colors.white,
--- onClick = function()
--- statusLabel:setText("Action: Delete")
--- end
--- },
--- {label = "---", disabled = true},
--- {
--- label = "More Options",
--- submenu = {
--- {
--- label = "Option 1",
--- onClick = function()
--- statusLabel:setText("Action: Option 1")
--- end
--- },
--- {
--- label = "Option 2",
--- onClick = function()
--- statusLabel:setText("Action: Option 2")
--- end
--- },
--- {label = "---", disabled = true},
--- {
--- label = "Nested",
--- submenu = {
--- {
--- label = "Deep 1",
--- onClick = function()
--- statusLabel:setText("Action: Deep 1")
--- end
--- }
--- }
--- }
--- }
--- },
--- {label = "---", disabled = true},
--- {
--- label = "Exit",
--- onClick = function()
--- statusLabel:setText("Action: Exit")
--- end
--- }
--- })
---
--- -- Open menu on right-click anywhere
--- main:onClick(function(self, button, x, y)
--- if button == 2 then
--- contextMenu.set("x", x)
--- contextMenu.set("y", y)
--- contextMenu:open()
--- basalt.LOGGER.info("Context menu opened at (" .. x .. ", " .. y .. ")")
--- end
--- end)
---
--- basalt.run()
--- ]]
---@class ContextMenu : Container
local ContextMenu = setmetatable({}, Container)
ContextMenu.__index = ContextMenu
---@property items table {} List of menu items
ContextMenu.defineProperty(ContextMenu, "items", {default = {}, type = "table", canTriggerRender = true})
---@property isOpen boolean false Whether the menu is currently open
ContextMenu.defineProperty(ContextMenu, "isOpen", {default = false, type = "boolean", canTriggerRender = true})
---@property openSubmenu table nil Currently open submenu data
ContextMenu.defineProperty(ContextMenu, "openSubmenu", {default = nil, type = "table", allowNil = true})
---@property itemHeight number 1 Height of each menu item
ContextMenu.defineProperty(ContextMenu, "itemHeight", {default = 1, type = "number", canTriggerRender = true})
ContextMenu.defineEvent(ContextMenu, "mouse_click")
--- @shortDescription Creates a new ContextMenu instance
--- @return ContextMenu self The created instance
--- @private
function ContextMenu.new()
local self = setmetatable({}, ContextMenu):__init()
self.class = ContextMenu
self.set("width", 10)
self.set("height", 10)
self.set("visible", false)
return self
end
--- @shortDescription Initializes the ContextMenu instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @protected
function ContextMenu:init(props, basalt)
Container.init(self, props, basalt)
self.set("type", "ContextMenu")
end
--- Sets the menu items
--- @shortDescription Sets the menu items from a table
--- @param items table Array of item definitions
--- @return ContextMenu self For method chaining
function ContextMenu:setItems(items)
self.set("items", items or {})
self:calculateSize()
return self
end
--- @shortDescription Calculates menu size based on items
--- @private
function ContextMenu:calculateSize()
local items = self.getResolved("items")
local itemHeight = self.getResolved("itemHeight")
if #items == 0 then
self.set("width", 10)
self.set("height", 2)
return
end
local maxWidth = 8
for _, item in ipairs(items) do
if item.label then
local labelLen = #item.label
local itemWidth = labelLen + 3
if item.submenu then
itemWidth = itemWidth + 1 -- " >"
end
if itemWidth > maxWidth then
maxWidth = itemWidth
end
end
end
local height = #items * itemHeight
self.set("width", maxWidth)
self.set("height", height)
end
--- Opens the menu
--- @shortDescription Opens the context menu
--- @return ContextMenu self For method chaining
function ContextMenu:open()
self.set("isOpen", true)
self.set("visible", true)
self:updateRender()
self:dispatchEvent("opened")
return self
end
--- Closes the menu and any submenus
--- @shortDescription Closes the context menu
--- @return ContextMenu self For method chaining
function ContextMenu:close()
self.set("isOpen", false)
self.set("visible", false)
local openSubmenu = self.getResolved("openSubmenu")
if openSubmenu and openSubmenu.menu then
openSubmenu.menu:close()
end
self.set("openSubmenu", nil)
self:updateRender()
self:dispatchEvent("closed")
return self
end
--- Closes the entire menu chain (parent and all submenus)
--- @shortDescription Closes the root menu and all child menus
--- @return ContextMenu self For method chaining
function ContextMenu:closeAll()
local root = self
while root.parentMenu do
root = root.parentMenu
end
root:close()
return self
end
--- @shortDescription Gets item at Y position
--- @param y number Relative Y position
--- @return number? index Item index or nil
--- @return table? item Item data or nil
--- @private
function ContextMenu:getItemAt(y)
local items = self.getResolved("items")
local itemHeight = self.getResolved("itemHeight")
local index = math.floor((y - 1) / itemHeight) + 1
if index >= 1 and index <= #items then
return index, items[index]
end
return nil, nil
end
--- @shortDescription Creates a submenu
--- @private
function ContextMenu:createSubmenu(submenuItems, parentItem)
local submenu = self.parent:addContextMenu()
submenu:setItems(submenuItems)
submenu.set("background", self.getResolved("background"))
submenu.set("foreground", self.getResolved("foreground"))
submenu.parentMenu = self
local parentX = self.getResolved("x")
local parentY = self.getResolved("y")
local parentWidth = self.getResolved("width")
local itemHeight = self.getResolved("itemHeight")
local itemIndex = parentItem._index or 1
submenu.set("x", parentX + parentWidth)
submenu.set("y", parentY + (itemIndex - 1) * itemHeight)
submenu.set("z", self.getResolved("z") + 1)
return submenu
end
--- @shortDescription Handles mouse click events
--- @protected
function ContextMenu:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then
self:close()
return false
end
local relX, relY = VisualElement.getRelativePosition(self, x, y)
local index, item = self:getItemAt(relY)
if item then
if item.disabled then
return true
end
if item.submenu then
local openSubmenu = self.getResolved("openSubmenu")
if openSubmenu and openSubmenu.index == index then
openSubmenu.menu:close()
self.set("openSubmenu", nil)
else
if openSubmenu and openSubmenu.menu then
openSubmenu.menu:close()
end
item._index = index
local submenu = self:createSubmenu(item.submenu, item)
submenu:open()
self.set("openSubmenu", {
index = index,
menu = submenu
})
end
return true
end
if item.onClick then
item.onClick(item)
end
self:closeAll()
return true
end
return true
end
--- @shortDescription Renders the ContextMenu
--- @protected
function ContextMenu:render()
local items = self.getResolved("items")
local width = self.getResolved("width")
local height = self.getResolved("height")
local itemHeight = self.getResolved("itemHeight")
local menuBg = self.getResolved("background")
local menuFg = self.getResolved("foreground")
for i, item in ipairs(items) do
local y = (i - 1) * itemHeight + 1
local itemBg = item.background or menuBg
local itemFg = item.foreground or menuFg
local bgHex = tHex[itemBg]
local fgHex = tHex[itemFg]
local spaces = string.rep(" ", width)
local bgColors = string.rep(bgHex, width)
local fgColors = string.rep(fgHex, width)
self:blit(1, y, spaces, fgColors, bgColors)
local label = item.label or ""
if #label > width - 3 then
label = label:sub(1, width - 3)
end
self:textFg(2, y, label, itemFg)
if item.submenu then
self:textFg(width - 1, y, ">", itemFg)
end
end
if not self.getResolved("childrenSorted") then
self:sortChildren()
end
if not self.getResolved("childrenEventsSorted") then
for eventName in pairs(self._values.childrenEvents or {}) do
self:sortChildrenEvents(eventName)
end
end
for _, child in ipairs(self.getResolved("visibleChildren") or {}) do
if child == self then
error("CIRCULAR REFERENCE DETECTED!")
return
end
child:render()
child:postRender()
end
end
return ContextMenu

298
src/elements/Dialog.lua Normal file
View File

@@ -0,0 +1,298 @@
local elementManager = require("elementManager")
local Frame = elementManager.getElement("Frame")
---@configDescription A dialog overlay system with common presets (alert, confirm, prompt).
---@configDefault false
--- A dialog overlay system that provides common dialog types such as alert, confirm, and prompt.
---@class Dialog : Frame
local Dialog = setmetatable({}, Frame)
Dialog.__index = Dialog
---@property title string "" The dialog title
Dialog.defineProperty(Dialog, "title", {default = "", type = "string", canTriggerRender = true})
---@property primaryColor color lime Primary button color (OK, confirm actions)
Dialog.defineProperty(Dialog, "primaryColor", {default = colors.lime, type = "color"})
---@property secondaryColor color lightGray Secondary button color (Cancel, dismiss actions)
Dialog.defineProperty(Dialog, "secondaryColor", {default = colors.lightGray, type = "color"})
---@property buttonForeground color black Foreground color for buttons
Dialog.defineProperty(Dialog, "buttonForeground", {default = colors.black, type = "color"})
---@property modal boolean true If true, blocks all events outside the dialog
Dialog.defineProperty(Dialog, "modal", {default = true, type = "boolean"})
Dialog.defineEvent(Dialog, "mouse_click")
Dialog.defineEvent(Dialog, "close")
--- Creates a new Dialog instance
--- @shortDescription Creates a new Dialog instance
--- @return Dialog self The newly created Dialog instance
--- @private
function Dialog.new()
local self = setmetatable({}, Dialog):__init()
self.class = Dialog
self.set("z", 100)
self.set("width", 30)
self.set("height", 10)
self.set("background", colors.gray)
self.set("foreground", colors.white)
self.set("borderColor", colors.cyan)
return self
end
--- Initializes a Dialog instance
--- @shortDescription Initializes a Dialog instance
--- @param props table Initial properties
--- @param basalt table The basalt instance
--- @return Dialog self The initialized Dialog instance
--- @private
function Dialog:init(props, basalt)
Frame.init(self, props, basalt)
self:addBorder({left = true, right = true, top = true, bottom = true})
self.set("type", "Dialog")
return self
end
--- Shows the dialog
--- @shortDescription Shows the dialog
--- @return Dialog self The Dialog instance
function Dialog:show()
self:center()
self.set("visible", true)
-- Auto-focus when modal
if self.getResolved("modal") then
self:setFocused(true)
end
return self
end
--- Closes the dialog
--- @shortDescription Closes the dialog
--- @return Dialog self The Dialog instance
function Dialog:close()
self.set("visible", false)
self:fireEvent("close")
return self
end
--- Creates a simple alert dialog
--- @shortDescription Creates a simple alert dialog
--- @param title string The alert title
--- @param message string The alert message
--- @param callback? function Callback when OK is clicked
--- @return Dialog self The Dialog instance
function Dialog:alert(title, message, callback)
self:clear()
self.set("title", title)
self.set("height", 8)
self:addLabel({
text = message,
x = 2, y = 3,
width = self.getResolved("width") - 3,
height = 3,
foreground = colors.white
})
local btnWidth = 10
local btnX = math.floor((self.getResolved("width") - btnWidth) / 2) + 1
self:addButton({
text = "OK",
x = btnX,
y = self.getResolved("height") - 2,
width = btnWidth,
height = 1,
background = self.getResolved("primaryColor"),
foreground = self.getResolved("buttonForeground")
}):onClick(function()
if callback then callback() end
self:close()
end)
return self:show()
end
--- Creates a confirm dialog
--- @shortDescription Creates a confirm dialog
--- @param title string The dialog title
--- @param message string The confirmation message
--- @param callback function Callback (receives boolean result)
--- @return Dialog self The Dialog instance
function Dialog:confirm(title, message, callback)
self:clear()
self.set("title", title)
self.set("height", 8)
self:addLabel({
text = message,
x = 2, y = 3,
width = self.getResolved("width") - 3,
height = 3,
foreground = colors.white
})
local btnWidth = 10
local spacing = 2
local totalWidth = btnWidth * 2 + spacing
local startX = math.floor((self.getResolved("width") - totalWidth) / 2) + 1
self:addButton({
text = "Cancel",
x = startX,
y = self.getResolved("height") - 2,
width = btnWidth,
height = 1,
background = self.getResolved("secondaryColor"),
foreground = self.getResolved("buttonForeground")
}):onClick(function()
if callback then callback(false) end
self:close()
end)
self:addButton({
text = "OK",
x = startX + btnWidth + spacing,
y = self.getResolved("height") - 2,
width = btnWidth,
height = 1,
background = self.getResolved("primaryColor"),
foreground = self.getResolved("buttonForeground")
}):onClick(function()
if callback then callback(true) end
self:close()
end)
return self:show()
end
--- Creates a prompt dialog with input
--- @shortDescription Creates a prompt dialog with input
--- @param title string The dialog title
--- @param message string The prompt message
--- @param default? string Default input value
--- @param callback? function Callback (receives input text or nil if cancelled)
--- @return Dialog self The Dialog instance
function Dialog:prompt(title, message, default, callback)
self:clear()
self.set("title", title)
self.set("height", 11)
self:addLabel({
text = message,
x = 2, y = 3,
foreground = colors.white
})
local input = self:addInput({
x = 2, y = 5,
width = self.getResolved("width") - 3,
height = 1,
defaultText = default or "",
background = colors.white,
foreground = colors.black
})
local btnWidth = 10
local spacing = 2
local totalWidth = btnWidth * 2 + spacing
local startX = math.floor((self.getResolved("width") - totalWidth) / 2) + 1
self:addButton({
text = "Cancel",
x = startX,
y = self.getResolved("height") - 2,
width = btnWidth,
height = 1,
background = self.getResolved("secondaryColor"),
foreground = self.getResolved("buttonForeground")
}):onClick(function()
if callback then callback(nil) end
self:close()
end)
self:addButton({
text = "OK",
x = startX + btnWidth + spacing,
y = self.getResolved("height") - 2,
width = btnWidth,
height = 1,
background = self.getResolved("primaryColor"),
foreground = self.getResolved("buttonForeground")
}):onClick(function()
if callback then callback(input.get("text") or "") end
self:close()
end)
return self:show()
end
--- Renders the dialog
--- @shortDescription Renders the dialog
--- @protected
function Dialog:render()
Frame.render(self)
local title = self.getResolved("title")
if title ~= "" then
local width = self.getResolved("width")
local titleText = title:sub(1, width - 4)
self:textFg(2, 2, titleText, colors.white)
end
end
--- Handles mouse click events
--- @shortDescription Handles mouse click events
--- @protected
function Dialog:mouse_click(button, x, y)
if self.getResolved("modal") then
if self:isInBounds(x, y) then
return Frame.mouse_click(self, button, x, y)
end
return true
end
return Frame.mouse_click(self, button, x, y)
end
--- Handles mouse drag events
--- @shortDescription Handles mouse drag events
--- @protected
function Dialog:mouse_drag(button, x, y)
if self.getResolved("modal") then
if self:isInBounds(x, y) then
return Frame.mouse_drag and Frame.mouse_drag(self, button, x, y) or false
end
return true
end
return Frame.mouse_drag and Frame.mouse_drag(self, button, x, y) or false
end
--- Handles mouse up events
--- @shortDescription Handles mouse up events
--- @protected
function Dialog:mouse_up(button, x, y)
if self.getResolved("modal") then
if self:isInBounds(x, y) then
return Frame.mouse_up and Frame.mouse_up(self, button, x, y) or false
end
return true
end
return Frame.mouse_up and Frame.mouse_up(self, button, x, y) or false
end
--- Handles mouse scroll events
--- @shortDescription Handles mouse scroll events
--- @protected
function Dialog:mouse_scroll(direction, x, y)
if self.getResolved("modal") then
if self:isInBounds(x, y) then
return Frame.mouse_scroll and Frame.mouse_scroll(self, direction, x, y) or false
end
return true
end
return Frame.mouse_scroll and Frame.mouse_scroll(self, direction, x, y) or false
end
return Dialog

View File

@@ -1,39 +1,32 @@
local elementManager = require("elementManager") local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement") local VisualElement = elementManager.getElement("VisualElement")
local getCenteredPosition = require("libraries/utils").getCenteredPosition
local deepcopy = require("libraries/utils").deepcopy
local colorHex = require("libraries/colorHex")
---@configDescription The Display is a special element which uses the CC Window API which you can use. ---@configDescription The Display is a special element which uses the CC Window API which you can use.
---@configDefault false ---@configDefault false
--- A specialized element that provides direct access to ComputerCraft's Window API. --- A specialized element that provides direct access to ComputerCraft's Window API. It acts as a canvas where you can use standard CC terminal operations.
--- It acts as a canvas where you can use standard CC terminal operations, making it ideal for: --- @usage [[
--- - Integration with existing CC programs and APIs --- -- Create a display for a custom terminal
--- - Custom drawing operations --- local display = main:addDisplay()
--- - Terminal emulation --- :setSize(30, 10)
--- - Complex text manipulation --- :setPosition(2, 2)
--- The Display maintains its own terminal buffer and can be manipulated using familiar CC terminal methods. ---
--- @usage -- Create a display for a custom terminal --- -- Get the window object for CC API operations
--- @usage local display = main:addDisplay() --- local win = display:getWindow()
--- @usage :setSize(30, 10) ---
--- @usage :setPosition(2, 2) --- -- Use standard CC terminal operations
--- @usage --- win.setTextColor(colors.yellow)
--- @usage -- Get the window object for CC API operations --- win.setBackgroundColor(colors.blue)
--- @usage local win = display:getWindow() --- win.clear()
--- @usage --- win.setCursorPos(1, 1)
--- @usage -- Use standard CC terminal operations --- win.write("Hello World!")
--- @usage win.setTextColor(colors.yellow) ---
--- @usage win.setBackgroundColor(colors.blue) --- -- Or use the helper method
--- @usage win.clear() --- display:write(1, 2, "Direct write", colors.red, colors.black)
--- @usage win.setCursorPos(1, 1) ---
--- @usage win.write("Hello World!") --- -- Useful for external APIs
--- @usage --- local paintutils = require("paintutils")
--- @usage -- Or use the helper method --- paintutils.drawLine(1, 1, 10, 1, colors.red, win)
--- @usage display:write(1, 2, "Direct write", colors.red, colors.black) --- ]]
--- @usage
--- @usage -- Useful for external APIs
--- @usage local paintutils = require("paintutils")
--- @usage paintutils.drawLine(1, 1, 10, 1, colors.red, win)
---@class Display : VisualElement ---@class Display : VisualElement
local Display = setmetatable({}, VisualElement) local Display = setmetatable({}, VisualElement)
Display.__index = Display Display.__index = Display
@@ -57,7 +50,7 @@ end
function Display:init(props, basalt) function Display:init(props, basalt)
VisualElement.init(self, props, basalt) VisualElement.init(self, props, basalt)
self.set("type", "Display") self.set("type", "Display")
self._window = window.create(basalt.getActiveFrame():getTerm(), 1, 1, self.get("width"), self.get("height"), false) self._window = window.create(basalt.getActiveFrame():getTerm(), 1, 1, self.getResolved("width"), self.getResolved("height"), false)
local reposition = self._window.reposition local reposition = self._window.reposition
local blit = self._window.blit local blit = self._window.blit
local write = self._window.write local write = self._window.write
@@ -70,7 +63,7 @@ function Display:init(props, basalt)
end end
self._window.getPosition = function(self) self._window.getPosition = function(self)
return self.get("x"), self.get("y") return self.getResolved("x"), self.getResolved("y")
end end
self._window.setVisible = function(visible) self._window.setVisible = function(visible)
@@ -78,7 +71,7 @@ function Display:init(props, basalt)
end end
self._window.isVisible = function(self) self._window.isVisible = function(self)
return self.get("visible") return self.getResolved("visible")
end end
self._window.blit = function(x, y, text, fg, bg) self._window.blit = function(x, y, text, fg, bg)
blit(x, y, text, fg, bg) blit(x, y, text, fg, bg)
@@ -92,13 +85,13 @@ function Display:init(props, basalt)
self:observe("width", function(self, width) self:observe("width", function(self, width)
local window = self._window local window = self._window
if window then if window then
window.reposition(1, 1, width, self.get("height")) window.reposition(1, 1, width, self.getResolved("height"))
end end
end) end)
self:observe("height", function(self, height) self:observe("height", function(self, height)
local window = self._window local window = self._window
if window then if window then
window.reposition(1, 1, self.get("width"), height) window.reposition(1, 1, self.getResolved("width"), height)
end end
end) end)
end end

View File

@@ -1,67 +1,70 @@
local VisualElement = require("elements/VisualElement") local VisualElement = require("elements/VisualElement")
local List = require("elements/List") local List = require("elements/List")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDescription A DropDown menu that shows a list of selectable items ---@configDescription A DropDown menu that shows a list of selectable items
---@configDefault false ---@configDefault false
--- Item Properties: ---@tableType ItemTable
--- Property|Type|Description ---@tableField text string The display text for the item
--- -------|------|------------- ---@tableField callback function Function called when selected
--- text|string|The display text for the item ---@tableField fg color Normal text color
--- separator|boolean|Makes item a divider line ---@tableField bg color Normal background color
--- callback|function|Function called when selected ---@tableField selectedFg color Text color when selected
--- foreground|color|Normal text color ---@tableField selectedBg color Background when selected
--- background|color|Normal background color
--- selectedForeground|color|Text color when selected
--- selectedBackground|color|Background when selected
--- A collapsible selection menu that expands to show multiple options when clicked. Supports single and multi-selection modes, custom item styling, separators, and item callbacks. --- A collapsible selection menu that expands to show multiple options when clicked. Supports single and multi-selection modes, custom item styling, separators, and item callbacks.
--- @usage -- Create a styled dropdown menu --- @run [[
--- @usage local dropdown = main:addDropDown() --- local basalt = require("basalt")
--- @usage :setPosition(5, 5) --- local main = basalt.getMainFrame()
--- @usage :setSize(20, 1) -- Height expands when opened ---
--- @usage :setSelectedText("Select an option...") --- -- Create a styled dropdown menu
--- @usage --- local dropdown = main:addDropDown()
--- @usage -- Add items with different styles and callbacks --- :setPosition(5, 5)
--- @usage dropdown:setItems({ --- :setSize(20, 1) -- Height expands when opened
--- @usage { --- :setSelectedText("Select an option...")
--- @usage text = "Category A", ---
--- @usage background = colors.blue, --- -- Add items with different styles and callbacks
--- @usage foreground = colors.white --- dropdown:setItems({
--- @usage }, --- {
--- @usage { separator = true, text = "-" }, -- Add a separator --- text = "Category A",
--- @usage { --- background = colors.blue,
--- @usage text = "Option 1", --- foreground = colors.white
--- @usage callback = function(self) --- },
--- @usage -- Handle selection --- { separator = true, text = "-" }, -- Add a separator
--- @usage basalt.debug("Selected Option 1") --- {
--- @usage end --- text = "Option 1",
--- @usage }, --- callback = function(self)
--- @usage { --- -- Handle selection
--- @usage text = "Option 2", --- basalt.LOGGER.debug("Selected Option 1")
--- @usage -- Custom colors when selected --- end
--- @usage selectedBackground = colors.green, --- },
--- @usage selectedForeground = colors.white --- {
--- @usage } --- text = "Option 2",
--- @usage }) --- -- Custom colors when selected
--- @usage --- selectedBackground = colors.green,
--- @usage -- Listen for selections --- selectedForeground = colors.white
--- @usage dropdown:onChange(function(self, value) --- }
--- @usage basalt.debug("Selected:", value) --- })
--- @usage end) ---
--- -- Listen for selections
--- dropdown:onChange(function(self, value)
--- basalt.LOGGER.debug("Selected:", value)
--- end)
---
--- basalt.run()
--- ]]
---@class DropDown : List ---@class DropDown : List
local DropDown = setmetatable({}, List) local DropDown = setmetatable({}, List)
DropDown.__index = DropDown DropDown.__index = DropDown
---@property isOpen boolean false Controls the expanded/collapsed state
DropDown.defineProperty(DropDown, "isOpen", {default = false, type = "boolean", canTriggerRender = true})
---@property dropdownHeight number 5 Maximum visible items when expanded ---@property dropdownHeight number 5 Maximum visible items when expanded
DropDown.defineProperty(DropDown, "dropdownHeight", {default = 5, type = "number"}) DropDown.defineProperty(DropDown, "dropdownHeight", {default = 5, type = "number"})
---@property selectedText string "" Text shown when no selection made ---@property selectedText string "" Text shown when no selection made
DropDown.defineProperty(DropDown, "selectedText", {default = "", type = "string"}) DropDown.defineProperty(DropDown, "selectedText", {default = "", type = "string"})
---@property dropSymbol string "\31" Indicator for dropdown state ---@property dropSymbol string "\31" Indicator for dropdown state
DropDown.defineProperty(DropDown, "dropSymbol", {default = "\31", type = "string"}) DropDown.defineProperty(DropDown, "dropSymbol", {default = "\31", type = "string"})
---@property undropSymbol string "\31" Indicator for dropdown state
DropDown.defineProperty(DropDown, "undropSymbol", {default = "\17", type = "string"})
--- Creates a new DropDown instance --- Creates a new DropDown instance
--- @shortDescription Creates a new DropDown instance --- @shortDescription Creates a new DropDown instance
@@ -84,6 +87,7 @@ end
function DropDown:init(props, basalt) function DropDown:init(props, basalt)
List.init(self, props, basalt) List.init(self, props, basalt)
self.set("type", "DropDown") self.set("type", "DropDown")
self:registerState("opened", nil, 200)
return self return self
end end
@@ -97,110 +101,133 @@ function DropDown:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end if not VisualElement.mouse_click(self, button, x, y) then return false end
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local isOpen = self:hasState("opened")
if relY == 1 then if relY == 1 then
self.set("isOpen", not self.get("isOpen")) if isOpen then
if not self.get("isOpen") then
self.set("height", 1) self.set("height", 1)
self:unsetState("opened")
else else
self.set("height", 1 + math.min(self.get("dropdownHeight"), #self.get("items"))) self.set("height", 1 + math.min(self.getResolved("dropdownHeight"), #self.getResolved("items")))
self:setState("opened")
end end
return true return true
elseif self.get("isOpen") and relY > 1 and self.get("selectable") then elseif isOpen and relY > 1 then
local itemIndex = (relY - 1) + self.get("offset") return List.mouse_click(self, button, x, y - 1)
local items = self.get("items")
if itemIndex <= #items then
local item = items[itemIndex]
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
otherItem.selected = false
end
end
end
item.selected = not item.selected
if item.callback then
item.callback(self)
end
self:fireEvent("select", itemIndex, item)
self.set("isOpen", false)
self.set("height", 1)
self:updateRender()
return true
end
end end
return false return false
end end
--- @shortDescription Handles mouse drag events for scrollbar
--- @param button number The mouse button being dragged
--- @param x number The x-coordinate of the drag
--- @param y number The y-coordinate of the drag
--- @return boolean Whether the event was handled
--- @protected
function DropDown:mouse_drag(button, x, y)
if self:hasState("opened") then
return List.mouse_drag(self, button, x, y - 1)
end
return VisualElement.mouse_drag and VisualElement.mouse_drag(self, button, x, y) or false
end
--- @shortDescription Handles mouse up events to stop scrollbar dragging
--- @param button number The mouse button that was released
--- @param x number The x-coordinate of the release
--- @param y number The y-coordinate of the release
--- @return boolean Whether the event was handled
--- @protected
function DropDown:mouse_up(button, x, y)
if self:hasState("opened") then
local relX, relY = self:getRelativePosition(x, y)
if relY > 1 and self.getResolved("selectable") and not self._scrollBarDragging then
local itemIndex = (relY - 1) + self.getResolved("offset")
local items = self.getResolved("items")
if itemIndex <= #items then
local item = items[itemIndex]
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if not self.getResolved("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
otherItem.selected = false
end
end
end
item.selected = not item.selected
if item.callback then
item.callback(self)
end
self:fireEvent("select", itemIndex, item)
self:unsetState("opened")
self:unsetState("clicked")
self.set("height", 1)
self:updateRender()
return true
end
end
List.mouse_up(self, button, x, y - 1)
self:unsetState("clicked")
return true
end
return VisualElement.mouse_up and VisualElement.mouse_up(self, button, x, y) or false
end
--- @shortDescription Renders the DropDown --- @shortDescription Renders the DropDown
--- @protected --- @protected
function DropDown:render() function DropDown:render()
VisualElement.render(self) VisualElement.render(self)
local text = self.get("selectedText") local width = self.getResolved("width")
local height = self.getResolved("height")
local text = self.getResolved("selectedText")
local isOpen = self:hasState("opened")
local selectedItems = self:getSelectedItems() local selectedItems = self:getSelectedItems()
if #selectedItems > 0 then if #selectedItems > 0 then
local selectedItem = selectedItems[1] local selectedItem = selectedItems[1]
text = selectedItem.text or "" text = selectedItem.text or ""
text = text:sub(1, self.get("width") - 2) text = text:sub(1, width - 2)
end end
self:blit(1, 1, text .. string.rep(" ", self.get("width") - #text - 1) .. (self.get("isOpen") and "\31" or "\17"), if isOpen then
string.rep(tHex[self.get("foreground")], self.get("width")), local actualHeight = height
string.rep(tHex[self.get("background")], self.get("width"))) local dropdownHeight = math.min(self.getResolved("dropdownHeight"), #self.getResolved("items"))
self.set("height", dropdownHeight)
if self.get("isOpen") then List.render(self, 1)
local items = self.get("items") self.set("height", actualHeight)
local height = self.get("height") - 1
local offset = self.get("offset")
local width = self.get("width")
for i = 1, height do
local itemIndex = i + offset
local item = items[itemIndex]
if item then
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if item.separator then
local separatorChar = (item.text or "-"):sub(1,1)
local separatorText = string.rep(separatorChar, width)
local fg = item.foreground or self.get("foreground")
local bg = item.background or self.get("background")
self:textBg(1, i + 1, string.rep(" ", width), bg)
self:textFg(1, i + 1, separatorText, fg)
else
local text = item.text
local isSelected = item.selected
text = text:sub(1, width)
local bg = isSelected and
(item.selectedBackground or self.get("selectedBackground")) or
(item.background or self.get("background"))
local fg = isSelected and
(item.selectedForeground or self.get("selectedForeground")) or
(item.foreground or self.get("foreground"))
self:textBg(1, i + 1, string.rep(" ", width), bg)
self:textFg(1, i + 1, text, fg)
end
end
end
end end
self:blit(1, 1, text .. string.rep(" ", width - #text - 1) .. (isOpen and self.getResolved("dropSymbol") or self.getResolved("undropSymbol")),
string.rep(tHex[self.getResolved("foreground")], width),
string.rep(tHex[self.getResolved("background")], width))
end end
return DropDown --- Called when the DropDown gains focus
--- @shortDescription Called when gaining focus
--- @protected
function DropDown:focus()
VisualElement.focus(self)
self:prioritize()
self:setState("opened")
end
--- Called when the DropDown loses focus
--- @shortDescription Called when losing focus
--- @protected
function DropDown:blur()
VisualElement.blur(self)
self:unsetState("opened")
self.set("height", 1)
self:updateRender()
end
return DropDown

View File

@@ -1,831 +0,0 @@
local elementManager = require("elementManager")
local Container = elementManager.getElement("Container")
---@configDescription A flexbox container that arranges its children in a flexible layout.
--- This is the FlexBox class. It is a container that arranges its children in a flexible layout.
--- @usage local flex = main:addFlexbox({background=colors.black, width=30, height=10})
--- @usage flex:addButton():setFlexGrow(1)
--- @usage flex:addButton():setFlexGrow(1)
--- @usage flex:addButton():setFlexGrow(1)
--- The flexbox element adds the following properties to its children:
---
--- @usage flex:addButton():setFlexGrow(1) -- The flex-grow property defines the ability for a flex item to grow if necessary.
--- @usage flex:addButton():setFlexShrink(1) -- The flex-shrink property defines the ability for a flex item to shrink if necessary.
--- @usage flex:addButton():setFlexBasis(1) -- The flex-basis property defines the default size of an element before the remaining space is distributed.
---@class FlexBox : Container
local FlexBox = setmetatable({}, Container)
FlexBox.__index = FlexBox
---@property flexDirection string "row" The direction of the flexbox layout "row" or "column"
FlexBox.defineProperty(FlexBox, "flexDirection", {default = "row", type = "string"})
---@property flexSpacing number 1 The spacing between flex items
FlexBox.defineProperty(FlexBox, "flexSpacing", {default = 1, type = "number"})
---@property flexJustifyContent string "flex-start" The alignment of flex items along the main axis
FlexBox.defineProperty(FlexBox, "flexJustifyContent", {
default = "flex-start",
type = "string",
setter = function(self, value)
if not value:match("^flex%-") then
value = "flex-" .. value
end
return value
end
})
---@property flexAlignItems string "flex-start" The alignment of flex items along the cross axis
FlexBox.defineProperty(FlexBox, "flexAlignItems", {
default = "flex-start",
type = "string",
setter = function(self, value)
if not value:match("^flex%-") and value ~= "stretch" then
value = "flex-" .. value
end
return value
end
})
---@property flexCrossPadding number 0 The padding on both sides of the cross axis
FlexBox.defineProperty(FlexBox, "flexCrossPadding", {default = 0, type = "number"})
---@property flexWrap boolean false Whether to wrap flex items onto multiple lines
---@property flexUpdateLayout boolean false Whether to update the layout of the flexbox
FlexBox.defineProperty(FlexBox, "flexWrap", {default = false, type = "boolean"})
FlexBox.defineProperty(FlexBox, "flexUpdateLayout", {default = false, type = "boolean"})
local lineBreakElement = {
getHeight = function(self) return 0 end,
getWidth = function(self) return 0 end,
getZ = function(self) return 1 end,
getPosition = function(self) return 0, 0 end,
getSize = function(self) return 0, 0 end,
isType = function(self) return false end,
getType = function(self) return "lineBreak" end,
getName = function(self) return "lineBreak" end,
setPosition = function(self) end,
setParent = function(self) end,
setSize = function(self) end,
getFlexGrow = function(self) return 0 end,
getFlexShrink = function(self) return 0 end,
getFlexBasis = function(self) return 0 end,
init = function(self) end,
getVisible = function(self) return true end,
}
local function sortElements(self, direction, spacing, wrap)
local sortedElements = {}
local visibleElements = {}
local childCount = 0
-- We can't use self.get("visibleChildren") here
--because it would exclude elements that are obscured
for _, elem in pairs(self.get("children")) do
if elem.get("visible") then
table.insert(visibleElements, elem)
if elem ~= lineBreakElement then
childCount = childCount + 1
end
end
end
if childCount == 0 then
return sortedElements
end
if not wrap then
sortedElements[1] = {offset=1}
for _, elem in ipairs(visibleElements) do
if elem == lineBreakElement then
local nextIndex = #sortedElements + 1
if sortedElements[nextIndex] == nil then
sortedElements[nextIndex] = {offset=1}
end
else
table.insert(sortedElements[#sortedElements], elem)
end
end
else
local containerSize = direction == "row" and self.get("width") or self.get("height")
local segments = {{}}
local currentSegment = 1
for _, elem in ipairs(visibleElements) do
if elem == lineBreakElement then
currentSegment = currentSegment + 1
segments[currentSegment] = {}
else
table.insert(segments[currentSegment], elem)
end
end
for segmentIndex, segment in ipairs(segments) do
if #segment == 0 then
sortedElements[#sortedElements + 1] = {offset=1}
else
local rows = {}
local currentRow = {}
local currentWidth = 0
for _, elem in ipairs(segment) do
local intrinsicSize = 0
local currentSize = direction == "row" and elem.get("width") or elem.get("height")
local hasIntrinsic = false
if direction == "row" then
local ok, intrinsicWidth = pcall(function() return elem.get("intrinsicWidth") end)
if ok and intrinsicWidth then
intrinsicSize = intrinsicWidth
hasIntrinsic = true
end
else
local ok, intrinsicHeight = pcall(function() return elem.get("intrinsicHeight") end)
if ok and intrinsicHeight then
intrinsicSize = intrinsicHeight
hasIntrinsic = true
end
end
local elemSize = hasIntrinsic and intrinsicSize or currentSize
local spaceNeeded = elemSize
if #currentRow > 0 then
spaceNeeded = spaceNeeded + spacing
end
if currentWidth + spaceNeeded <= containerSize or #currentRow == 0 then
table.insert(currentRow, elem)
currentWidth = currentWidth + spaceNeeded
else
table.insert(rows, currentRow)
currentRow = {elem}
currentWidth = elemSize
end
end
if #currentRow > 0 then
table.insert(rows, currentRow)
end
for _, row in ipairs(rows) do
sortedElements[#sortedElements + 1] = {offset=1}
for _, elem in ipairs(row) do
table.insert(sortedElements[#sortedElements], elem)
end
end
end
end
end
local filteredElements = {}
for i, rowOrColumn in ipairs(sortedElements) do
if #rowOrColumn > 0 then
table.insert(filteredElements, rowOrColumn)
end
end
return filteredElements
end
local function calculateRow(self, children, spacing, justifyContent)
-- Make a copy of children that filters out lineBreak elements
local filteredChildren = {}
for _, child in ipairs(children) do
if child ~= lineBreakElement then
table.insert(filteredChildren, child)
end
end
-- Skip processing if no children
if #filteredChildren == 0 then
return
end
local containerWidth = self.get("width")
local containerHeight = self.get("height")
local alignItems = self.get("flexAlignItems")
local crossPadding = self.get("flexCrossPadding")
local wrap = self.get("flexWrap")
-- Safety check
if containerWidth <= 0 then return end
-- Calculate available cross axis space (considering padding)
local availableCrossAxisSpace = containerHeight - (crossPadding * 2)
if availableCrossAxisSpace < 1 then
availableCrossAxisSpace = containerHeight
crossPadding = 0
end
-- Cache local variables to reduce function calls
local max = math.max
local min = math.min
local floor = math.floor
local ceil = math.ceil
-- Categorize elements and calculate their minimal widths and flexibilities
local totalFixedWidth = 0
local totalFlexGrow = 0
local minWidths = {}
local flexGrows = {}
local flexShrinks = {}
-- First pass: collect fixed widths and flex properties
for _, child in ipairs(filteredChildren) do
local grow = child.get("flexGrow") or 0
local shrink = child.get("flexShrink") or 0
local width = child.get("width")
-- Track element properties
flexGrows[child] = grow
flexShrinks[child] = shrink
minWidths[child] = width
-- Calculate total flex grow factor
if grow > 0 then
totalFlexGrow = totalFlexGrow + grow
else
-- If not flex grow, it's a fixed element
totalFixedWidth = totalFixedWidth + width
end
end
-- Calculate total spacing
local elementsCount = #filteredChildren
local totalSpacing = (elementsCount > 1) and ((elementsCount - 1) * spacing) or 0
-- Calculate available space for flex items
local availableSpace = containerWidth - totalFixedWidth - totalSpacing
-- Second pass: distribute available space to flex-grow items
if availableSpace > 0 and totalFlexGrow > 0 then
-- Container has extra space - distribute according to flex-grow
for _, child in ipairs(filteredChildren) do
local grow = flexGrows[child]
if grow > 0 then
-- Calculate flex basis (never less than minWidth)
local minWidth = minWidths[child]
local flexWidth = floor((grow / totalFlexGrow) * availableSpace)
-- Set calculated width, ensure it's at least 1
child.set("width", max(flexWidth, 1))
end
end
elseif availableSpace < 0 then
-- Container doesn't have enough space - check for shrinkable items
local totalFlexShrink = 0
local shrinkableItems = {}
-- Find shrinkable items
for _, child in ipairs(filteredChildren) do
local shrink = flexShrinks[child]
if shrink > 0 then
totalFlexShrink = totalFlexShrink + shrink
table.insert(shrinkableItems, child)
end
end
-- If we have shrinkable items, shrink them proportionally
if totalFlexShrink > 0 and #shrinkableItems > 0 then
local excessWidth = -availableSpace
for _, child in ipairs(shrinkableItems) do
local width = child.get("width")
local shrink = flexShrinks[child]
local proportion = shrink / totalFlexShrink
local reduction = ceil(excessWidth * proportion)
-- Ensure width doesn't go below 1
child.set("width", max(1, width - reduction))
end
end
-- Recalculate fixed widths after shrinking
totalFixedWidth = 0
for _, child in ipairs(filteredChildren) do
totalFixedWidth = totalFixedWidth + child.get("width")
end
-- If we still have flex-grow items, ensure they have proportional space
if totalFlexGrow > 0 then
local growableItems = {}
local totalGrowableInitialWidth = 0
-- Find growable items
for _, child in ipairs(filteredChildren) do
if flexGrows[child] > 0 then
table.insert(growableItems, child)
totalGrowableInitialWidth = totalGrowableInitialWidth + child.get("width")
end
end
-- Ensure flexGrow items get at least some width, even if space is tight
if #growableItems > 0 and totalGrowableInitialWidth > 0 then
-- Minimum guaranteed width for flex items (at least 20% of container)
local minFlexSpace = max(floor(containerWidth * 0.2), #growableItems)
-- Reserve space for flex items
local reservedFlexSpace = min(minFlexSpace, containerWidth - totalSpacing)
-- Distribute among flex items
for _, child in ipairs(growableItems) do
local grow = flexGrows[child]
local proportion = grow / totalFlexGrow
local flexWidth = max(1, floor(reservedFlexSpace * proportion))
child.set("width", flexWidth)
end
end
end
end
-- Step 3: Position elements (never allow overlapping)
local currentX = 1
-- Place all elements sequentially
for _, child in ipairs(filteredChildren) do
-- Apply X coordinate
child.set("x", currentX)
-- Apply Y coordinate (based on vertical alignment) ONLY if not in wrapped mode
if not wrap then
if alignItems == "stretch" then
-- Vertical stretch to fill container, considering padding
child.set("height", availableCrossAxisSpace)
child.set("y", 1 + crossPadding)
else
local childHeight = child.get("height")
local y = 1
if alignItems == "flex-end" then
-- Bottom align
y = containerHeight - childHeight + 1
elseif alignItems == "flex-center" or alignItems == "center" then
-- Center align
y = floor((containerHeight - childHeight) / 2) + 1
end
-- Ensure Y value is not less than 1
child.set("y", max(1, y))
end
end
-- Final safety check height doesn't exceed container - only for elements with flexShrink
local bottomEdge = child.get("y") + child.get("height") - 1
if bottomEdge > containerHeight and (child.get("flexShrink") or 0) > 0 then
child.set("height", max(1, containerHeight - child.get("y") + 1))
end
-- Update position for next element - advance by element width + spacing
currentX = currentX + child.get("width") + spacing
end
-- Apply justifyContent only if there's remaining space
local lastChild = filteredChildren[#filteredChildren]
local usedWidth = 0
if lastChild then
usedWidth = lastChild.get("x") + lastChild.get("width") - 1
end
local remainingSpace = containerWidth - usedWidth
if remainingSpace > 0 then
if justifyContent == "flex-end" then
for _, child in ipairs(filteredChildren) do
child.set("x", child.get("x") + remainingSpace)
end
elseif justifyContent == "flex-center" or justifyContent == "center" then
local offset = floor(remainingSpace / 2)
for _, child in ipairs(filteredChildren) do
child.set("x", child.get("x") + offset)
end
end
end
end
local function calculateColumn(self, children, spacing, justifyContent)
-- Make a copy of children that filters out lineBreak elements
local filteredChildren = {}
for _, child in ipairs(children) do
if child ~= lineBreakElement then
table.insert(filteredChildren, child)
end
end
-- Skip processing if no children
if #filteredChildren == 0 then
return
end
local containerWidth = self.get("width")
local containerHeight = self.get("height")
local alignItems = self.get("flexAlignItems")
local crossPadding = self.get("flexCrossPadding")
local wrap = self.get("flexWrap")
-- Safety check
if containerHeight <= 0 then return end
-- Calculate available cross axis space (considering padding)
local availableCrossAxisSpace = containerWidth - (crossPadding * 2)
if availableCrossAxisSpace < 1 then
availableCrossAxisSpace = containerWidth
crossPadding = 0
end
-- Cache local variables to reduce function calls
local max = math.max
local min = math.min
local floor = math.floor
local ceil = math.ceil
-- Categorize elements and calculate their minimal heights and flexibilities
local totalFixedHeight = 0
local totalFlexGrow = 0
local minHeights = {}
local flexGrows = {}
local flexShrinks = {}
-- First pass: collect fixed heights and flex properties
for _, child in ipairs(filteredChildren) do
local grow = child.get("flexGrow") or 0
local shrink = child.get("flexShrink") or 0
local height = child.get("height")
-- Track element properties
flexGrows[child] = grow
flexShrinks[child] = shrink
minHeights[child] = height
-- Calculate total flex grow factor
if grow > 0 then
totalFlexGrow = totalFlexGrow + grow
else
-- If not flex grow, it's a fixed element
totalFixedHeight = totalFixedHeight + height
end
end
-- Calculate total spacing
local elementsCount = #filteredChildren
local totalSpacing = (elementsCount > 1) and ((elementsCount - 1) * spacing) or 0
-- Calculate available space for flex items
local availableSpace = containerHeight - totalFixedHeight - totalSpacing
-- Second pass: distribute available space to flex-grow items
if availableSpace > 0 and totalFlexGrow > 0 then
-- Container has extra space - distribute according to flex-grow
for _, child in ipairs(filteredChildren) do
local grow = flexGrows[child]
if grow > 0 then
-- Calculate flex basis (never less than minHeight)
local minHeight = minHeights[child]
local flexHeight = floor((grow / totalFlexGrow) * availableSpace)
-- Set calculated height, ensure it's at least 1
child.set("height", max(flexHeight, 1))
end
end
elseif availableSpace < 0 then
-- Container doesn't have enough space - check for shrinkable items
local totalFlexShrink = 0
local shrinkableItems = {}
-- Find shrinkable items
for _, child in ipairs(filteredChildren) do
local shrink = flexShrinks[child]
if shrink > 0 then
totalFlexShrink = totalFlexShrink + shrink
table.insert(shrinkableItems, child)
end
end
-- If we have shrinkable items, shrink them proportionally
if totalFlexShrink > 0 and #shrinkableItems > 0 then
local excessHeight = -availableSpace
for _, child in ipairs(shrinkableItems) do
local height = child.get("height")
local shrink = flexShrinks[child]
local proportion = shrink / totalFlexShrink
local reduction = ceil(excessHeight * proportion)
-- Ensure height doesn't go below 1
child.set("height", max(1, height - reduction))
end
end
-- Recalculate fixed heights after shrinking
totalFixedHeight = 0
for _, child in ipairs(filteredChildren) do
totalFixedHeight = totalFixedHeight + child.get("height")
end
-- If we still have flex-grow items, ensure they have proportional space
if totalFlexGrow > 0 then
local growableItems = {}
local totalGrowableInitialHeight = 0
-- Find growable items
for _, child in ipairs(filteredChildren) do
if flexGrows[child] > 0 then
table.insert(growableItems, child)
totalGrowableInitialHeight = totalGrowableInitialHeight + child.get("height")
end
end
-- Ensure flexGrow items get at least some height, even if space is tight
if #growableItems > 0 and totalGrowableInitialHeight > 0 then
-- Minimum guaranteed height for flex items (at least 20% of container)
local minFlexSpace = max(floor(containerHeight * 0.2), #growableItems)
-- Reserve space for flex items
local reservedFlexSpace = min(minFlexSpace, containerHeight - totalSpacing)
-- Distribute among flex items
for _, child in ipairs(growableItems) do
local grow = flexGrows[child]
local proportion = grow / totalFlexGrow
local flexHeight = max(1, floor(reservedFlexSpace * proportion))
child.set("height", flexHeight)
end
end
end
end
-- Step 3: Position elements (never allow overlapping)
local currentY = 1
-- Place all elements sequentially
for _, child in ipairs(filteredChildren) do
-- Apply Y coordinate
child.set("y", currentY)
-- Apply X coordinate (based on horizontal alignment)
if not wrap then
if alignItems == "stretch" then
-- Horizontal stretch to fill container, considering padding
child.set("width", availableCrossAxisSpace)
child.set("x", 1 + crossPadding)
else
local childWidth = child.get("width")
local x = 1
if alignItems == "flex-end" then
-- Right align
x = containerWidth - childWidth + 1
elseif alignItems == "flex-center" or alignItems == "center" then
-- Center align
x = floor((containerWidth - childWidth) / 2) + 1
end
-- Ensure X value is not less than 1
child.set("x", max(1, x))
end
end
-- Final safety check width doesn't exceed container - only for elements with flexShrink
local rightEdge = child.get("x") + child.get("width") - 1
if rightEdge > containerWidth and (child.get("flexShrink") or 0) > 0 then
child.set("width", max(1, containerWidth - child.get("x") + 1))
end
-- Update position for next element - advance by element height + spacing
currentY = currentY + child.get("height") + spacing
end
-- Apply justifyContent only if there's remaining space
local lastChild = filteredChildren[#filteredChildren]
local usedHeight = 0
if lastChild then
usedHeight = lastChild.get("y") + lastChild.get("height") - 1
end
local remainingSpace = containerHeight - usedHeight
if remainingSpace > 0 then
if justifyContent == "flex-end" then
for _, child in ipairs(filteredChildren) do
child.set("y", child.get("y") + remainingSpace)
end
elseif justifyContent == "flex-center" or justifyContent == "center" then
local offset = floor(remainingSpace / 2)
for _, child in ipairs(filteredChildren) do
child.set("y", child.get("y") + offset)
end
end
end
end
-- Optimize updateLayout function
local function updateLayout(self, direction, spacing, justifyContent, wrap)
if self.get("width") <= 0 or self.get("height") <= 0 then
return
end
direction = (direction == "row" or direction == "column") and direction or "row"
local currentWidth, currentHeight = self.get("width"), self.get("height")
local sizeChanged = currentWidth ~= self._lastLayoutWidth or currentHeight ~= self._lastLayoutHeight
self._lastLayoutWidth = currentWidth
self._lastLayoutHeight = currentHeight
if wrap and sizeChanged and (currentWidth > self._lastLayoutWidth or currentHeight > self._lastLayoutHeight) then
for _, child in pairs(self.get("children")) do
if child ~= lineBreakElement and child:getVisible() and child.get("flexGrow") and child.get("flexGrow") > 0 then
if direction == "row" then
local ok, value = pcall(function() return child.get("intrinsicWidth") end)
if ok and value then
child.set("width", value)
end
else
local ok, value = pcall(function() return child.get("intrinsicHeight") end)
if ok and value then
child.set("height", value)
end
end
end
end
end
local elements = sortElements(self, direction, spacing, wrap)
if #elements == 0 then return end
local layoutFunction = direction == "row" and calculateRow or calculateColumn
if direction == "row" and wrap then
local currentY = 1
for i, rowOrColumn in ipairs(elements) do
if #rowOrColumn > 0 then
for _, element in ipairs(rowOrColumn) do
if element ~= lineBreakElement then
element.set("y", currentY)
end
end
layoutFunction(self, rowOrColumn, spacing, justifyContent)
local rowHeight = 0
for _, element in ipairs(rowOrColumn) do
if element ~= lineBreakElement then
rowHeight = math.max(rowHeight, element.get("height"))
end
end
if i < #elements then
currentY = currentY + rowHeight + spacing
else
currentY = currentY + rowHeight
end
end
end
elseif direction == "column" and wrap then
local currentX = 1
for i, rowOrColumn in ipairs(elements) do
if #rowOrColumn > 0 then
for _, element in ipairs(rowOrColumn) do
if element ~= lineBreakElement then
element.set("x", currentX)
end
end
layoutFunction(self, rowOrColumn, spacing, justifyContent)
local columnWidth = 0
for _, element in ipairs(rowOrColumn) do
if element ~= lineBreakElement then
columnWidth = math.max(columnWidth, element.get("width"))
end
end
if i < #elements then
currentX = currentX + columnWidth + spacing
else
currentX = currentX + columnWidth
end
end
end
else
for _, rowOrColumn in ipairs(elements) do
layoutFunction(self, rowOrColumn, spacing, justifyContent)
end
end
self:sortChildren()
self.set("childrenEventsSorted", false)
self.set("flexUpdateLayout", false)
end
--- @shortDescription Creates a new FlexBox instance
--- @return FlexBox object The newly created FlexBox instance
--- @private
function FlexBox.new()
local self = setmetatable({}, FlexBox):__init()
self.class = FlexBox
self.set("width", 12)
self.set("height", 6)
self.set("background", colors.blue)
self.set("z", 10)
self._lastLayoutWidth = 0
self._lastLayoutHeight = 0
self:observe("width", function() self.set("flexUpdateLayout", true) end)
self:observe("height", function() self.set("flexUpdateLayout", true) end)
self:observe("flexDirection", function() self.set("flexUpdateLayout", true) end)
self:observe("flexSpacing", function() self.set("flexUpdateLayout", true) end)
self:observe("flexWrap", function() self.set("flexUpdateLayout", true) end)
self:observe("flexJustifyContent", function() self.set("flexUpdateLayout", true) end)
self:observe("flexAlignItems", function() self.set("flexUpdateLayout", true) end)
self:observe("flexCrossPadding", function() self.set("flexUpdateLayout", true) end)
return self
end
--- @shortDescription Initializes the FlexBox instance
--- @param props table The properties to initialize the element with
--- @param basalt table The basalt instance
--- @return FlexBox self The initialized instance
--- @protected
function FlexBox:init(props, basalt)
Container.init(self, props, basalt)
self.set("type", "FlexBox")
return self
end
--- Adds a child element to the flexbox
--- @shortDescription Adds a child element to the flexbox
--- @param element Element The child element to add
--- @return FlexBox self The flexbox instance
function FlexBox:addChild(element)
Container.addChild(self, element)
if(element~=lineBreakElement)then
element:instanceProperty("flexGrow", {default = 0, type = "number"})
element:instanceProperty("flexShrink", {default = 0, type = "number"})
element:instanceProperty("flexBasis", {default = 0, type = "number"})
element:instanceProperty("intrinsicWidth", {default = element.get("width"), type = "number"})
element:instanceProperty("intrinsicHeight", {default = element.get("height"), type = "number"})
element:observe("flexGrow", function() self.set("flexUpdateLayout", true) end)
element:observe("flexShrink", function() self.set("flexUpdateLayout", true) end)
element:observe("width", function(_, newValue, oldValue)
if element.get("flexGrow") == 0 then
element.set("intrinsicWidth", newValue)
end
self.set("flexUpdateLayout", true)
end)
element:observe("height", function(_, newValue, oldValue)
if element.get("flexGrow") == 0 then
element.set("intrinsicHeight", newValue)
end
self.set("flexUpdateLayout", true)
end)
end
self.set("flexUpdateLayout", true)
return self
end
--- @shortDescription Removes a child element from the flexbox
--- @param element Element The child element to remove
--- @return FlexBox self The flexbox instance
--- @protected
function FlexBox:removeChild(element)
Container.removeChild(self, element)
if(element~=lineBreakElement)then
element.setFlexGrow = nil
element.setFlexShrink = nil
element.setFlexBasis = nil
element.getFlexGrow = nil
element.getFlexShrink = nil
element.getFlexBasis = nil
element.set("flexGrow", nil)
element.set("flexShrink", nil)
element.set("flexBasis", nil)
end
self.set("flexUpdateLayout", true)
return self
end
--- Adds a new line break to the flexbox
--- @shortDescription Adds a new line break to the flexbox.
---@param self FlexBox The element itself
---@return FlexBox
function FlexBox:addLineBreak()
self:addChild(lineBreakElement)
return self
end
--- @shortDescription Renders the flexbox and its children
--- @protected
function FlexBox:render()
if(self.get("flexUpdateLayout"))then
updateLayout(self, self.get("flexDirection"), self.get("flexSpacing"), self.get("flexJustifyContent"), self.get("flexWrap"))
end
Container.render(self)
end
return FlexBox

View File

@@ -9,23 +9,16 @@ local Frame = setmetatable({}, Container)
Frame.__index = Frame Frame.__index = Frame
---@property draggable boolean false Whether the frame is draggable ---@property draggable boolean false Whether the frame is draggable
Frame.defineProperty(Frame, "draggable", {default = false, type = "boolean", setter=function(self, value) Frame.defineProperty(Frame, "draggable", {default = false, type = "boolean"})
if value then
self:listenEvent("mouse_click", true)
self:listenEvent("mouse_up", true)
self:listenEvent("mouse_drag", true)
end
return value
end})
---@property draggingMap table {} The map of dragging positions ---@property draggingMap table {} The map of dragging positions
Frame.defineProperty(Frame, "draggingMap", {default = {{x=1, y=1, width="width", height=1}}, type = "table"}) Frame.defineProperty(Frame, "draggingMap", {default = {{x=1, y=1, width="width", height=1}}, type = "table"})
---@property scrollable boolean false Whether the frame is scrollable ---@property scrollable boolean false Whether the frame is scrollable
Frame.defineProperty(Frame, "scrollable", {default = false, type = "boolean", setter=function(self, value) Frame.defineProperty(Frame, "scrollable", {default = false, type = "boolean"})
if value then
self:listenEvent("mouse_scroll", true) Frame.defineEvent(Frame, "mouse_click")
end Frame.defineEvent(Frame, "mouse_drag")
return value Frame.defineEvent(Frame, "mouse_up")
end}) Frame.defineEvent(Frame, "mouse_scroll")
--- Creates a new Frame instance --- Creates a new Frame instance
--- @shortDescription Creates a new Frame instance --- @shortDescription Creates a new Frame instance
@@ -36,7 +29,6 @@ function Frame.new()
self.class = Frame self.class = Frame
self.set("width", 12) self.set("width", 12)
self.set("height", 6) self.set("height", 6)
self.set("background", colors.gray)
self.set("z", 10) self.set("z", 10)
return self return self
end end
@@ -59,22 +51,22 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Frame:mouse_click(button, x, y) function Frame:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then if self:isInBounds(x, y) then
if self.get("draggable") then if self.getResolved("draggable") then
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local draggingMap = self.get("draggingMap") local draggingMap = self.getResolved("draggingMap")
for _, map in ipairs(draggingMap) do for _, map in ipairs(draggingMap) do
local width = map.width or 1 local width = map.width or 1
local height = map.height or 1 local height = map.height or 1
if type(width) == "string" and width == "width" then if type(width) == "string" and width == "width" then
width = self.get("width") width = self.getResolved("width")
elseif type(width) == "function" then elseif type(width) == "function" then
width = width(self) width = width(self)
end end
if type(height) == "string" and height == "height" then if type(height) == "string" and height == "height" then
height = self.get("height") height = self.getResolved("height")
elseif type(height) == "function" then elseif type(height) == "function" then
height = height(self) height = height(self)
end end
@@ -82,8 +74,8 @@ function Frame:mouse_click(button, x, y)
local mapY = map.y or 1 local mapY = map.y or 1
if relX >= map.x and relX <= map.x + width - 1 and if relX >= map.x and relX <= map.x + width - 1 and
relY >= mapY and relY <= mapY + height - 1 then relY >= mapY and relY <= mapY + height - 1 then
self.dragStartX = x - self.get("x") self.dragStartX = x - self.getResolved("x")
self.dragStartY = y - self.get("y") self.dragStartY = y - self.getResolved("y")
self.dragging = true self.dragging = true
return true return true
end end
@@ -117,7 +109,7 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Frame:mouse_drag(button, x, y) function Frame:mouse_drag(button, x, y)
if self.get("clicked") and self.dragging then if self.dragging then
local newX = x - self.dragStartX local newX = x - self.dragStartX
local newY = y - self.dragStartY local newY = y - self.dragStartY
@@ -125,10 +117,7 @@ function Frame:mouse_drag(button, x, y)
self.set("y", newY) self.set("y", newY)
return true return true
end end
if not self.dragging then return Container.mouse_drag(self, button, x, y)
return Container.mouse_drag(self, button, x, y)
end
return false
end end
--- @shortDescription Calculates the total height of all children elements --- @shortDescription Calculates the total height of all children elements
@@ -136,7 +125,7 @@ end
--- @protected --- @protected
function Frame:getChildrenHeight() function Frame:getChildrenHeight()
local maxHeight = 0 local maxHeight = 0
local children = self.get("children") local children = self.getResolved("children")
for _, child in ipairs(children) do for _, child in ipairs(children) do
if child.get("visible") then if child.get("visible") then
@@ -153,6 +142,17 @@ function Frame:getChildrenHeight()
return maxHeight return maxHeight
end end
local function convertMousePosition(self, event, ...)
local args = {...}
if event and event:find("mouse_") then
local button, absX, absY = ...
local xOffset, yOffset = self.getResolved("offsetX"), self.getResolved("offsetY")
local relX, relY = self:getRelativePosition(absX + xOffset, absY + yOffset)
args = {button, relX, relY}
end
return args
end
--- @shortDescription Handles mouse scroll events --- @shortDescription Handles mouse scroll events
--- @param direction number The scroll direction --- @param direction number The scroll direction
--- @param x number The x position of the scroll --- @param x number The x position of the scroll
@@ -160,18 +160,17 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Frame:mouse_scroll(direction, x, y) function Frame:mouse_scroll(direction, x, y)
if Container.mouse_scroll(self, direction, x, y) then if(VisualElement.mouse_scroll(self, direction, x, y))then
return true local args = convertMousePosition(self, "mouse_scroll", direction, x, y)
end local success, child = self:callChildrenEvent(true, "mouse_scroll", table.unpack(args))
if success then
return true
end
if self.getResolved("scrollable") then
local height = self.getResolved("height")
if self.get("scrollable") then
local relX, relY = self:getRelativePosition(x, y)
local width = self.get("width")
local height = self.get("height")
if relX >= 1 and relX <= width and relY >= 1 and relY <= height then
local childrenHeight = self:getChildrenHeight() local childrenHeight = self:getChildrenHeight()
local currentOffset = self.get("offsetY") local currentOffset = self.getResolved("offsetY")
local maxScroll = math.max(0, childrenHeight - height) local maxScroll = math.max(0, childrenHeight - height)
local newOffset = currentOffset + direction local newOffset = currentOffset + direction
@@ -181,7 +180,6 @@ function Frame:mouse_scroll(direction, x, y)
return true return true
end end
end end
return false return false
end end

View File

@@ -5,17 +5,19 @@ local tHex = require("libraries/colorHex")
---@configDefault false ---@configDefault false
--- This is the base class for all graph elements. It is a point based graph. --- This is the base class for all graph elements. It is a point based graph.
--- @usage local graph = main:addGraph() --- @usage [[
--- @usage :addSeries("input", " ", colors.green, colors.green, 10) --- local graph = main:addGraph()
--- @usage :addSeries("output", " ", colors.red, colors.red, 10) --- :addSeries("input", " ", colors.green, colors.green, 10)
--- @usage --- :addSeries("output", " ", colors.red, colors.red, 10)
--- @usage basalt.schedule(function() ---
--- @usage while true do --- basalt.schedule(function()
--- @usage graph:addPoint("input", math.random(1,100)) --- while true do
--- @usage graph:addPoint("output", math.random(1,100)) --- graph:addPoint("input", math.random(1,100))
--- @usage sleep(2) --- graph:addPoint("output", math.random(1,100))
--- @usage end --- sleep(2)
--- @usage end) --- end
--- end)
--- ]]
--- @class Graph : VisualElement --- @class Graph : VisualElement
local Graph = setmetatable({}, VisualElement) local Graph = setmetatable({}, VisualElement)
Graph.__index = Graph Graph.__index = Graph
@@ -58,13 +60,13 @@ end
--- @param pointCount number The number of points in the series --- @param pointCount number The number of points in the series
--- @return Graph self The graph instance --- @return Graph self The graph instance
function Graph:addSeries(name, symbol, bgCol, fgCol, pointCount) function Graph:addSeries(name, symbol, bgCol, fgCol, pointCount)
local series = self.get("series") local series = self.getResolved("series")
table.insert(series, { table.insert(series, {
name = name, name = name,
symbol = symbol or " ", symbol = symbol or " ",
bgColor = bgCol or colors.white, bgColor = bgCol or colors.white,
fgColor = fgCol or colors.black, fgColor = fgCol or colors.black,
pointCount = pointCount or self.get("width"), pointCount = pointCount or self.getResolved("width"),
data = {}, data = {},
visible = true visible = true
}) })
@@ -76,7 +78,7 @@ end
--- @param name string The name of the series --- @param name string The name of the series
--- @return Graph self The graph instance --- @return Graph self The graph instance
function Graph:removeSeries(name) function Graph:removeSeries(name)
local series = self.get("series") local series = self.getResolved("series")
for i, s in ipairs(series) do for i, s in ipairs(series) do
if s.name == name then if s.name == name then
table.remove(series, i) table.remove(series, i)
@@ -91,7 +93,7 @@ end
--- @param name string The name of the series --- @param name string The name of the series
--- @return table? series The series --- @return table? series The series
function Graph:getSeries(name) function Graph:getSeries(name)
local series = self.get("series") local series = self.getResolved("series")
for _, s in ipairs(series) do for _, s in ipairs(series) do
if s.name == name then if s.name == name then
return s return s
@@ -105,7 +107,7 @@ end
--- @param visible boolean Whether the series should be visible --- @param visible boolean Whether the series should be visible
--- @return Graph self The graph instance --- @return Graph self The graph instance
function Graph:changeSeriesVisibility(name, visible) function Graph:changeSeriesVisibility(name, visible)
local series = self.get("series") local series = self.getResolved("series")
for _, s in ipairs(series) do for _, s in ipairs(series) do
if s.name == name then if s.name == name then
s.visible = visible s.visible = visible
@@ -121,7 +123,7 @@ end
--- @param value number The value of the point --- @param value number The value of the point
--- @return Graph self The graph instance --- @return Graph self The graph instance
function Graph:addPoint(name, value) function Graph:addPoint(name, value)
local series = self.get("series") local series = self.getResolved("series")
for _, s in ipairs(series) do for _, s in ipairs(series) do
if s.name == name then if s.name == name then
@@ -140,7 +142,7 @@ end
--- @param name string The name of the series --- @param name string The name of the series
--- @return Graph self The graph instance --- @return Graph self The graph instance
function Graph:focusSeries(name) function Graph:focusSeries(name)
local series = self.get("series") local series = self.getResolved("series")
for index, s in ipairs(series) do for index, s in ipairs(series) do
if s.name == name then if s.name == name then
table.remove(series, index) table.remove(series, index)
@@ -157,7 +159,7 @@ end
--- @param count number The number of points in the series --- @param count number The number of points in the series
--- @return Graph self The graph instance --- @return Graph self The graph instance
function Graph:setSeriesPointCount(name, count) function Graph:setSeriesPointCount(name, count)
local series = self.get("series") local series = self.getResolved("series")
for _, s in ipairs(series) do for _, s in ipairs(series) do
if s.name == name then if s.name == name then
s.pointCount = count s.pointCount = count
@@ -176,7 +178,7 @@ end
--- @param name? string The name of the series --- @param name? string The name of the series
--- @return Graph self The graph instance --- @return Graph self The graph instance
function Graph:clear(seriesName) function Graph:clear(seriesName)
local series = self.get("series") local series = self.getResolved("series")
if seriesName then if seriesName then
for _, s in ipairs(series) do for _, s in ipairs(series) do
if s.name == seriesName then if s.name == seriesName then
@@ -197,11 +199,11 @@ end
function Graph:render() function Graph:render()
VisualElement.render(self) VisualElement.render(self)
local width = self.get("width") local width = self.getResolved("width")
local height = self.get("height") local height = self.getResolved("height")
local minVal = self.get("minValue") local minVal = self.getResolved("minValue")
local maxVal = self.get("maxValue") local maxVal = self.getResolved("maxValue")
local series = self.get("series") local series = self.getResolved("series")
for _, s in pairs(series) do for _, s in pairs(series) do
if(s.visible)then if(s.visible)then

View File

@@ -1,12 +1,9 @@
local elementManager = require("elementManager") local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement") local VisualElement = elementManager.getElement("VisualElement")
local tHex = require("libraries/colorHex") --- @configDescription An element that displays an image in bimg format
---@configDescription An element that displays an image in bimg format --- @configDefault false
---@configDefault false
--- This is the Image element class which can be used to display bimg formatted images. --- This is the Image element class which can be used to display bimg formatted images. Bimg is a universal ComputerCraft image format. See: https://github.com/SkyTheCodeMaster/bimg
--- Bimg is a universal ComputerCraft image format.
--- See: https://github.com/SkyTheCodeMaster/bimg
---@class Image : VisualElement ---@class Image : VisualElement
local Image = setmetatable({}, VisualElement) local Image = setmetatable({}, VisualElement)
Image.__index = Image Image.__index = Image
@@ -56,7 +53,7 @@ end
--- @param height number The new height of the image --- @param height number The new height of the image
--- @return Image self The Image instance --- @return Image self The Image instance
function Image:resizeImage(width, height) function Image:resizeImage(width, height)
local frames = self.get("bimg") local frames = self.getResolved("bimg")
for frameIndex, frame in ipairs(frames) do for frameIndex, frame in ipairs(frames) do
local newFrame = {} local newFrame = {}
@@ -89,7 +86,7 @@ end
--- @return number width The width of the image --- @return number width The width of the image
--- @return number height The height of the image --- @return number height The height of the image
function Image:getImageSize() function Image:getImageSize()
local bimg = self.get("bimg") local bimg = self.getResolved("bimg")
if not bimg[1] or not bimg[1][1] then return 0, 0 end if not bimg[1] or not bimg[1][1] then return 0, 0 end
return #bimg[1][1][1], #bimg[1] return #bimg[1][1][1], #bimg[1]
end end
@@ -102,7 +99,7 @@ end
--- @return number? bg Background color --- @return number? bg Background color
--- @return string? char Character at position --- @return string? char Character at position
function Image:getPixelData(x, y) function Image:getPixelData(x, y)
local frame = self.get("bimg")[self.get("currentFrame")] local frame = self.getResolved("bimg")[self.getResolved("currentFrame")]
if not frame or not frame[y] then return end if not frame or not frame[y] then return end
local text = frame[y][1] local text = frame[y][1]
@@ -119,10 +116,10 @@ function Image:getPixelData(x, y)
end end
local function ensureFrame(self, y) local function ensureFrame(self, y)
local frame = self.get("bimg")[self.get("currentFrame")] local frame = self.getResolved("bimg")[self.getResolved("currentFrame")]
if not frame then if not frame then
frame = {} frame = {}
self.get("bimg")[self.get("currentFrame")] = frame self.getResolved("bimg")[self.getResolved("currentFrame")] = frame
end end
if not frame[y] then if not frame[y] then
frame[y] = {"", "", ""} frame[y] = {"", "", ""}
@@ -131,9 +128,9 @@ local function ensureFrame(self, y)
end end
local function updateFrameSize(self, neededWidth, neededHeight) local function updateFrameSize(self, neededWidth, neededHeight)
if not self.get("autoResize") then return end if not self.getResolved("autoResize") then return end
local frames = self.get("bimg") local frames = self.getResolved("bimg")
local maxWidth = neededWidth local maxWidth = neededWidth
local maxHeight = neededHeight local maxHeight = neededHeight
@@ -167,13 +164,13 @@ end
--- @return Image self The Image instance --- @return Image self The Image instance
function Image:setText(x, y, text) function Image:setText(x, y, text)
if type(text) ~= "string" or #text < 1 or x < 1 or y < 1 then return self end if type(text) ~= "string" or #text < 1 or x < 1 or y < 1 then return self end
if not self.get("autoResize")then if not self.getResolved("autoResize")then
local imgWidth, imgHeight = self:getImageSize() local imgWidth, imgHeight = self:getImageSize()
if y > imgHeight then return self end if y > imgHeight then return self end
end end
local frame = ensureFrame(self, y) local frame = ensureFrame(self, y)
if self.get("autoResize") then if self.getResolved("autoResize") then
updateFrameSize(self, x + #text - 1, y) updateFrameSize(self, x + #text - 1, y)
else else
local maxLen = #frame[y][1] local maxLen = #frame[y][1]
@@ -196,7 +193,7 @@ end
--- @return string text The text at the specified position --- @return string text The text at the specified position
function Image:getText(x, y, length) function Image:getText(x, y, length)
if not x or not y then return "" end if not x or not y then return "" end
local frame = self.get("bimg")[self.get("currentFrame")] local frame = self.getResolved("bimg")[self.getResolved("currentFrame")]
if not frame or not frame[y] then return "" end if not frame or not frame[y] then return "" end
local text = frame[y][1] local text = frame[y][1]
@@ -217,13 +214,13 @@ end
--- @return Image self The Image instance --- @return Image self The Image instance
function Image:setFg(x, y, pattern) function Image:setFg(x, y, pattern)
if type(pattern) ~= "string" or #pattern < 1 or x < 1 or y < 1 then return self end if type(pattern) ~= "string" or #pattern < 1 or x < 1 or y < 1 then return self end
if not self.get("autoResize")then if not self.getResolved("autoResize")then
local imgWidth, imgHeight = self:getImageSize() local imgWidth, imgHeight = self:getImageSize()
if y > imgHeight then return self end if y > imgHeight then return self end
end end
local frame = ensureFrame(self, y) local frame = ensureFrame(self, y)
if self.get("autoResize") then if self.getResolved("autoResize") then
updateFrameSize(self, x + #pattern - 1, y) updateFrameSize(self, x + #pattern - 1, y)
else else
local maxLen = #frame[y][2] local maxLen = #frame[y][2]
@@ -246,7 +243,7 @@ end
--- @return string fg The foreground color pattern --- @return string fg The foreground color pattern
function Image:getFg(x, y, length) function Image:getFg(x, y, length)
if not x or not y then return "" end if not x or not y then return "" end
local frame = self.get("bimg")[self.get("currentFrame")] local frame = self.getResolved("bimg")[self.getResolved("currentFrame")]
if not frame or not frame[y] then return "" end if not frame or not frame[y] then return "" end
local fg = frame[y][2] local fg = frame[y][2]
@@ -267,13 +264,13 @@ end
--- @return Image self The Image instance --- @return Image self The Image instance
function Image:setBg(x, y, pattern) function Image:setBg(x, y, pattern)
if type(pattern) ~= "string" or #pattern < 1 or x < 1 or y < 1 then return self end if type(pattern) ~= "string" or #pattern < 1 or x < 1 or y < 1 then return self end
if not self.get("autoResize")then if not self.getResolved("autoResize")then
local imgWidth, imgHeight = self:getImageSize() local imgWidth, imgHeight = self:getImageSize()
if y > imgHeight then return self end if y > imgHeight then return self end
end end
local frame = ensureFrame(self, y) local frame = ensureFrame(self, y)
if self.get("autoResize") then if self.getResolved("autoResize") then
updateFrameSize(self, x + #pattern - 1, y) updateFrameSize(self, x + #pattern - 1, y)
else else
local maxLen = #frame[y][3] local maxLen = #frame[y][3]
@@ -296,7 +293,7 @@ end
--- @return string bg The background color pattern --- @return string bg The background color pattern
function Image:getBg(x, y, length) function Image:getBg(x, y, length)
if not x or not y then return "" end if not x or not y then return "" end
local frame = self.get("bimg")[self.get("currentFrame")] local frame = self.getResolved("bimg")[self.getResolved("currentFrame")]
if not frame or not frame[y] then return "" end if not frame or not frame[y] then return "" end
local bg = frame[y][3] local bg = frame[y][3]
@@ -328,10 +325,10 @@ end
--- @shortDescription Advances to the next frame in the animation --- @shortDescription Advances to the next frame in the animation
--- @return Image self The Image instance --- @return Image self The Image instance
function Image:nextFrame() function Image:nextFrame()
if not self.get("bimg").animation then return self end if not self.getResolved("bimg").animation then return self end
local frames = self.get("bimg") local frames = self.getResolved("bimg")
local current = self.get("currentFrame") local current = self.getResolved("currentFrame")
local next = current + 1 local next = current + 1
if next > #frames then next = 1 end if next > #frames then next = 1 end
@@ -343,7 +340,7 @@ end
--- @shortDescription Adds a new frame to the image --- @shortDescription Adds a new frame to the image
--- @return Image self The Image instance --- @return Image self The Image instance
function Image:addFrame() function Image:addFrame()
local frames = self.get("bimg") local frames = self.getResolved("bimg")
local width = frames.width or #frames[1][1][1] local width = frames.width or #frames[1][1][1]
local height = frames.height or #frames[1] local height = frames.height or #frames[1]
local frame = {} local frame = {}
@@ -363,7 +360,7 @@ end
--- @param frame table The new frame data --- @param frame table The new frame data
--- @return Image self The Image instance --- @return Image self The Image instance
function Image:updateFrame(frameIndex, frame) function Image:updateFrame(frameIndex, frame)
local frames = self.get("bimg") local frames = self.getResolved("bimg")
frames[frameIndex] = frame frames[frameIndex] = frame
self:updateRender() self:updateRender()
return self return self
@@ -374,8 +371,8 @@ end
--- @param frameIndex number The index of the frame to get --- @param frameIndex number The index of the frame to get
--- @return table frame The frame data --- @return table frame The frame data
function Image:getFrame(frameIndex) function Image:getFrame(frameIndex)
local frames = self.get("bimg") local frames = self.getResolved("bimg")
return frames[frameIndex or self.get("currentFrame")] return frames[frameIndex or self.getResolved("currentFrame")]
end end
--- Gets the metadata of the image --- Gets the metadata of the image
@@ -383,7 +380,7 @@ end
--- @return table metadata The metadata of the image --- @return table metadata The metadata of the image
function Image:getMetadata() function Image:getMetadata()
local metadata = {} local metadata = {}
local bimg = self.get("bimg") local bimg = self.getResolved("bimg")
for k,v in pairs(bimg)do for k,v in pairs(bimg)do
if(type(v)=="string")then if(type(v)=="string")then
metadata[k] = v metadata[k] = v
@@ -404,7 +401,7 @@ function Image:setMetadata(key, value)
end end
return self return self
end end
local bimg = self.get("bimg") local bimg = self.getResolved("bimg")
if(type(value)=="string")then if(type(value)=="string")then
bimg[key] = value bimg[key] = value
end end
@@ -416,13 +413,13 @@ end
function Image:render() function Image:render()
VisualElement.render(self) VisualElement.render(self)
local frame = self.get("bimg")[self.get("currentFrame")] local frame = self.getResolved("bimg")[self.getResolved("currentFrame")]
if not frame then return end if not frame then return end
local offsetX = self.get("offsetX") local offsetX = self.getResolved("offsetX")
local offsetY = self.get("offsetY") local offsetY = self.getResolved("offsetY")
local elementWidth = self.get("width") local elementWidth = self.getResolved("width")
local elementHeight = self.get("height") local elementHeight = self.getResolved("height")
for y = 1, elementHeight do for y = 1, elementHeight do
local frameY = y + offsetY local frameY = y + offsetY

View File

@@ -20,10 +20,6 @@ Input.defineProperty(Input, "maxLength", {default = nil, type = "number"})
Input.defineProperty(Input, "placeholder", {default = "...", type = "string"}) Input.defineProperty(Input, "placeholder", {default = "...", type = "string"})
---@property placeholderColor color gray Color of the placeholder text ---@property placeholderColor color gray Color of the placeholder text
Input.defineProperty(Input, "placeholderColor", {default = colors.gray, type = "color"}) Input.defineProperty(Input, "placeholderColor", {default = colors.gray, type = "color"})
---@property focusedBackground color blue Background color when input is focused
Input.defineProperty(Input, "focusedBackground", {default = colors.blue, type = "color"})
---@property focusedForeground color white Foreground color when input is focused
Input.defineProperty(Input, "focusedForeground", {default = colors.white, type = "color"})
---@property pattern string? nil Regular expression pattern for input validation ---@property pattern string? nil Regular expression pattern for input validation
Input.defineProperty(Input, "pattern", {default = nil, type = "string"}) Input.defineProperty(Input, "pattern", {default = nil, type = "string"})
---@property cursorColor number nil Color of the cursor ---@property cursorColor number nil Color of the cursor
@@ -32,6 +28,7 @@ Input.defineProperty(Input, "cursorColor", {default = nil, type = "number"})
Input.defineProperty(Input, "replaceChar", {default = nil, type = "string", canTriggerRender = true}) Input.defineProperty(Input, "replaceChar", {default = nil, type = "string", canTriggerRender = true})
Input.defineEvent(Input, "mouse_click") Input.defineEvent(Input, "mouse_click")
Input.defineEvent(Input, "mouse_up")
Input.defineEvent(Input, "key") Input.defineEvent(Input, "key")
Input.defineEvent(Input, "char") Input.defineEvent(Input, "char")
Input.defineEvent(Input, "paste") Input.defineEvent(Input, "paste")
@@ -65,7 +62,7 @@ end
--- @param blink boolean Whether the cursor should blink --- @param blink boolean Whether the cursor should blink
--- @param color number The color of the cursor --- @param color number The color of the cursor
function Input:setCursor(x, y, blink, color) function Input:setCursor(x, y, blink, color)
x = math.min(self.get("width"), math.max(1, x)) x = math.min(self.getResolved("width"), math.max(1, x))
return VisualElement.setCursor(self, x, y, blink, color) return VisualElement.setCursor(self, x, y, blink, color)
end end
@@ -74,11 +71,11 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Input:char(char) function Input:char(char)
if not self.get("focused") then return false end if not self:hasState("focused") then return false end
local text = self.get("text") local text = self.getResolved("text")
local pos = self.get("cursorPos") local pos = self.getResolved("cursorPos")
local maxLength = self.get("maxLength") local maxLength = self.getResolved("maxLength")
local pattern = self.get("pattern") local pattern = self.getResolved("pattern")
if maxLength and #text >= maxLength then return false end if maxLength and #text >= maxLength then return false end
if pattern and not char:match(pattern) then return false end if pattern and not char:match(pattern) then return false end
@@ -87,8 +84,8 @@ function Input:char(char)
self.set("cursorPos", pos + 1) self.set("cursorPos", pos + 1)
self:updateViewport() self:updateViewport()
local relPos = self.get("cursorPos") - self.get("viewOffset") local relPos = self.getResolved("cursorPos") - self.getResolved("viewOffset")
self:setCursor(relPos, 1, true, self.get("cursorColor") or self.get("foreground")) self:setCursor(relPos, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
VisualElement.char(self, char) VisualElement.char(self, char)
return true return true
end end
@@ -98,11 +95,11 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Input:key(key, held) function Input:key(key, held)
if not self.get("focused") then return false end if not self:hasState("focused") then return false end
local pos = self.get("cursorPos") local pos = self.getResolved("cursorPos")
local text = self.get("text") local text = self.getResolved("text")
local viewOffset = self.get("viewOffset") local viewOffset = self.getResolved("viewOffset")
local width = self.get("width") local width = self.getResolved("width")
if key == keys.left then if key == keys.left then
if pos > 1 then if pos > 1 then
@@ -127,8 +124,8 @@ function Input:key(key, held)
end end
end end
local relativePos = self.get("cursorPos") - self.get("viewOffset") local relativePos = self.getResolved("cursorPos") - self.getResolved("viewOffset")
self:setCursor(relativePos, 1, true, self.get("cursorColor") or self.get("foreground")) self:setCursor(relativePos, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
VisualElement.key(self, key, held) VisualElement.key(self, key, held)
return true return true
end end
@@ -142,15 +139,15 @@ end
function Input:mouse_click(button, x, y) function Input:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then if VisualElement.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local text = self.get("text") local text = self.getResolved("text")
local viewOffset = self.get("viewOffset") local viewOffset = self.getResolved("viewOffset")
local maxPos = #text + 1 local maxPos = #text + 1
local targetPos = math.min(maxPos, viewOffset + relX) local targetPos = math.min(maxPos, viewOffset + relX)
self.set("cursorPos", targetPos) self.set("cursorPos", targetPos)
local visualX = targetPos - viewOffset local visualX = targetPos - viewOffset
self:setCursor(visualX, 1, true, self.get("cursorColor") or self.get("foreground")) self:setCursor(visualX, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
return true return true
end end
@@ -161,10 +158,10 @@ end
--- @shortDescription Updates the input's viewport --- @shortDescription Updates the input's viewport
--- @return Input self The updated instance --- @return Input self The updated instance
function Input:updateViewport() function Input:updateViewport()
local width = self.get("width") local width = self.getResolved("width")
local cursorPos = self.get("cursorPos") local cursorPos = self.getResolved("cursorPos")
local viewOffset = self.get("viewOffset") local viewOffset = self.getResolved("viewOffset")
local textLength = #self.get("text") local textLength = #self.getResolved("text")
if cursorPos - viewOffset >= width then if cursorPos - viewOffset >= width then
self.set("viewOffset", cursorPos - width + 1) self.set("viewOffset", cursorPos - width + 1)
@@ -172,7 +169,7 @@ function Input:updateViewport()
self.set("viewOffset", cursorPos - 1) self.set("viewOffset", cursorPos - 1)
end end
self.set("viewOffset", math.max(0, math.min(self.get("viewOffset"), textLength - width + 1))) self.set("viewOffset", math.max(0, math.min(self.getResolved("viewOffset"), textLength - width + 1)))
return self return self
end end
@@ -181,7 +178,7 @@ end
--- @protected --- @protected
function Input:focus() function Input:focus()
VisualElement.focus(self) VisualElement.focus(self)
self:setCursor(self.get("cursorPos") - self.get("viewOffset"), 1, true, self.get("cursorColor") or self.get("foreground")) self:setCursor(self.getResolved("cursorPos") - self.getResolved("viewOffset"), 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
self:updateRender() self:updateRender()
end end
@@ -189,18 +186,18 @@ end
--- @protected --- @protected
function Input:blur() function Input:blur()
VisualElement.blur(self) VisualElement.blur(self)
self:setCursor(1, 1, false, self.get("cursorColor") or self.get("foreground")) self:setCursor(1, 1, false, self.getResolved("cursorColor") or self.getResolved("foreground"))
self:updateRender() self:updateRender()
end end
--- @shortDescription Handles paste events --- @shortDescription Handles paste events
--- @protected --- @protected
function Input:paste(content) function Input:paste(content)
if not self.get("focused") then return false end if not self:hasState("focused") then return false end
local text = self.get("text") local text = self.getResolved("text")
local pos = self.get("cursorPos") local pos = self.getResolved("cursorPos")
local maxLength = self.get("maxLength") local maxLength = self.getResolved("maxLength")
local pattern = self.get("pattern") local pattern = self.getResolved("pattern")
local newText = text:sub(1, pos - 1) .. content .. text:sub(pos) local newText = text:sub(1, pos - 1) .. content .. text:sub(pos)
if maxLength and #newText > maxLength then if maxLength and #newText > maxLength then
newText = newText:sub(1, maxLength) newText = newText:sub(1, maxLength)
@@ -216,31 +213,28 @@ end
--- @shortDescription Renders the input element --- @shortDescription Renders the input element
--- @protected --- @protected
function Input:render() function Input:render()
local text = self.get("text") local text = self.getResolved("text")
local viewOffset = self.get("viewOffset") local viewOffset = self.getResolved("viewOffset")
local width = self.get("width") local placeholder = self.getResolved("placeholder")
local placeholder = self.get("placeholder") local focused = self:hasState("focused")
local focusedBg = self.get("focusedBackground") local width, height = self.getResolved("width"), self.getResolved("height")
local focusedFg = self.get("focusedForeground") local replaceChar = self.getResolved("replaceChar")
local focused = self.get("focused") self:multiBlit(1, 1, width, height, " ", tHex[self.getResolved("foreground")], tHex[self.getResolved("background")])
local width, height = self.get("width"), self.get("height")
local replaceChar = self.get("replaceChar")
self:multiBlit(1, 1, width, height, " ", tHex[focused and focusedFg or self.get("foreground")], tHex[focused and focusedBg or self.get("background")])
if #text == 0 and #placeholder ~= 0 and self.get("focused") == false then if #text == 0 and #placeholder ~= 0 and not focused then
self:textFg(1, 1, placeholder:sub(1, width), self.get("placeholderColor")) self:textFg(1, 1, placeholder:sub(1, width), self.getResolved("placeholderColor"))
return return
end end
if(focused) then if(focused) then
self:setCursor(self.get("cursorPos") - viewOffset, 1, true, self.get("cursorColor") or self.get("foreground")) self:setCursor(self.getResolved("cursorPos") - viewOffset, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
end end
local visibleText = text:sub(viewOffset + 1, viewOffset + width) local visibleText = text:sub(viewOffset + 1, viewOffset + width)
if replaceChar and #replaceChar > 0 then if replaceChar and #replaceChar > 0 then
visibleText = replaceChar:rep(#visibleText) visibleText = replaceChar:rep(#visibleText)
end end
self:textFg(1, 1, visibleText, self.get("foreground")) self:textFg(1, 1, visibleText, self.getResolved("foreground"))
end end
return Input return Input

View File

@@ -3,8 +3,7 @@ local VisualElement = elementManager.getElement("VisualElement")
local wrapText = require("libraries/utils").wrapText local wrapText = require("libraries/utils").wrapText
---@configDescription A simple text display element that automatically resizes its width based on the text content. ---@configDescription A simple text display element that automatically resizes its width based on the text content.
--- This is the label class. It provides a simple text display element that automatically --- This is the label class. It provides a simple text display element that automatically resizes its width based on the text content.
--- resizes its width based on the text content.
---@class Label : VisualElement ---@class Label : VisualElement
local Label = setmetatable({}, VisualElement) local Label = setmetatable({}, VisualElement)
Label.__index = Label Label.__index = Label
@@ -12,10 +11,10 @@ Label.__index = Label
---@property text string Label The text content to display. Can be a string or a function that returns a string ---@property text string Label The text content to display. Can be a string or a function that returns a string
Label.defineProperty(Label, "text", {default = "Label", type = "string", canTriggerRender = true, setter = function(self, value) Label.defineProperty(Label, "text", {default = "Label", type = "string", canTriggerRender = true, setter = function(self, value)
if(type(value)=="function")then value = value() end if(type(value)=="function")then value = value() end
if(self.get("autoSize"))then if(self.getResolved("autoSize"))then
self.set("width", #value) self.set("width", #value)
else else
self.set("height", #wrapText(value, self.get("width"))) self.set("height", #wrapText(value, self.getResolved("width")))
end end
return value return value
end}) end})
@@ -23,9 +22,9 @@ end})
---@property autoSize boolean true Whether the label should automatically resize its width based on the text content ---@property autoSize boolean true Whether the label should automatically resize its width based on the text content
Label.defineProperty(Label, "autoSize", {default = true, type = "boolean", canTriggerRender = true, setter = function(self, value) Label.defineProperty(Label, "autoSize", {default = true, type = "boolean", canTriggerRender = true, setter = function(self, value)
if(value)then if(value)then
self.set("width", #self.get("text")) self.set("width", #self.getResolved("text"))
else else
self.set("height", #wrapText(self.get("text"), self.get("width"))) self.set("height", #wrapText(self.getResolved("text"), self.getResolved("width")))
end end
return value return value
end}) end})
@@ -38,7 +37,6 @@ function Label.new()
local self = setmetatable({}, Label):__init() local self = setmetatable({}, Label):__init()
self.class = Label self.class = Label
self.set("z", 3) self.set("z", 3)
self.set("foreground", colors.black)
self.set("backgroundEnabled", false) self.set("backgroundEnabled", false)
return self return self
end end
@@ -50,10 +48,6 @@ end
--- @protected --- @protected
function Label:init(props, basalt) function Label:init(props, basalt)
VisualElement.init(self, props, basalt) VisualElement.init(self, props, basalt)
if(self.parent)then
self.set("background", self.parent.get("background"))
self.set("foreground", self.parent.get("foreground"))
end
self.set("type", "Label") self.set("type", "Label")
return self return self
end end
@@ -62,8 +56,8 @@ end
--- @shortDescription Gets the wrapped lines of the Label --- @shortDescription Gets the wrapped lines of the Label
--- @return table wrappedText The wrapped lines of the Label --- @return table wrappedText The wrapped lines of the Label
function Label:getWrappedText() function Label:getWrappedText()
local text = self.get("text") local text = self.getResolved("text")
local wrappedText = wrapText(text, self.get("width")) local wrappedText = wrapText(text, self.getResolved("width"))
return wrappedText return wrappedText
end end
@@ -71,13 +65,13 @@ end
--- @protected --- @protected
function Label:render() function Label:render()
VisualElement.render(self) VisualElement.render(self)
local text = self.get("text") local text = self.getResolved("text")
if(self.get("autoSize"))then if(self.getResolved("autoSize"))then
self:textFg(1, 1, text, self.get("foreground")) self:textFg(1, 1, text, self.getResolved("foreground"))
else else
local wrappedText = wrapText(text, self.get("width")) local wrappedText = wrapText(text, self.getResolved("width"))
for i, line in ipairs(wrappedText) do for i, line in ipairs(wrappedText) do
self:textFg(1, i, line, self.get("foreground")) self:textFg(1, i, line, self.getResolved("foreground"))
end end
end end
end end

View File

@@ -3,20 +3,22 @@ local VisualElement = elementManager.getElement("VisualElement")
local Graph = elementManager.getElement("Graph") local Graph = elementManager.getElement("Graph")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
--- @configDescription A line chart element based on the graph element --- @configDescription A line chart element based on the graph element
---@configDefault false --- @configDefault false
--- The Line Chart element visualizes data series as connected line graphs. It plots points on a coordinate system and connects them with lines. --- The Line Chart element visualizes data series as connected line graphs. It plots points on a coordinate system and connects them with lines.
--- @usage local chart = main:addLineChart() --- @usage [[
--- @usage :addSeries("input", " ", colors.green, colors.green, 10) --- local chart = main:addLineChart()
--- @usage :addSeries("output", " ", colors.red, colors.red, 10) --- :addSeries("input", " ", colors.green, colors.green, 10)
--- @usage --- :addSeries("output", " ", colors.red, colors.red, 10)
--- @usage basalt.schedule(function() ---
--- @usage while true do --- basalt.schedule(function()
--- @usage chart:addPoint("input", math.random(1,100)) --- while true do
--- @usage chart:addPoint("output", math.random(1,100)) --- chart:addPoint("input", math.random(1,100))
--- @usage sleep(2) --- chart:addPoint("output", math.random(1,100))
--- @usage end --- sleep(2)
--- @usage end) --- end
--- end)
--- ]]
--- @class LineChart : Graph --- @class LineChart : Graph
local LineChart = setmetatable({}, Graph) local LineChart = setmetatable({}, Graph)
LineChart.__index = LineChart LineChart.__index = LineChart
@@ -51,7 +53,7 @@ local function drawLine(self, x1, y1, x2, y2, symbol, bgColor, fgColor)
local t = steps == 0 and 0 or i / steps local t = steps == 0 and 0 or i / steps
local x = math.floor(x1 + dx * t) local x = math.floor(x1 + dx * t)
local y = math.floor(y1 + dy * t) local y = math.floor(y1 + dy * t)
if x >= 1 and x <= self.get("width") and y >= 1 and y <= self.get("height") then if x >= 1 and x <= self.getResolved("width") and y >= 1 and y <= self.getResolved("height") then
self:blit(x, y, symbol, tHex[bgColor], tHex[fgColor]) self:blit(x, y, symbol, tHex[bgColor], tHex[fgColor])
end end
end end
@@ -62,11 +64,11 @@ end
function LineChart:render() function LineChart:render()
VisualElement.render(self) VisualElement.render(self)
local width = self.get("width") local width = self.getResolved("width")
local height = self.get("height") local height = self.getResolved("height")
local minVal = self.get("minValue") local minVal = self.getResolved("minValue")
local maxVal = self.get("maxValue") local maxVal = self.getResolved("maxValue")
local series = self.get("series") local series = self.getResolved("series")
for _, s in pairs(series) do for _, s in pairs(series) do
if(s.visible)then if(s.visible)then

View File

@@ -1,28 +1,65 @@
local VisualElement = require("elements/VisualElement") local Collection = require("elements/Collection")
local tHex = require("libraries/colorHex")
---@configDescription A scrollable list of selectable items ---@configDescription A scrollable list of selectable items
--- This is the list class. It provides a scrollable list of selectable items with support for --- This is the list class. It provides a scrollable list of selectable items with support for
--- custom item rendering, separators, and selection handling. --- custom item rendering, separators, and selection handling.
---@class List : VisualElement ---@class List : Collection
local List = setmetatable({}, VisualElement) local List = setmetatable({}, Collection)
List.__index = List List.__index = List
---@property items table {} List of items to display. Items can be tables with properties including selected state
List.defineProperty(List, "items", {default = {}, type = "table", canTriggerRender = true})
---@property selectable boolean true Whether items in the list can be selected
List.defineProperty(List, "selectable", {default = true, type = "boolean"})
---@property multiSelection boolean false Whether multiple items can be selected at once
List.defineProperty(List, "multiSelection", {default = false, type = "boolean"})
---@property offset number 0 Current scroll offset for viewing long lists ---@property offset number 0 Current scroll offset for viewing long lists
List.defineProperty(List, "offset", {default = 0, type = "number", canTriggerRender = true}) List.defineProperty(List, "offset", {
---@property selectedBackground color blue Background color for selected items default = 0,
List.defineProperty(List, "selectedBackground", {default = colors.blue, type = "color"}) type = "number",
---@property selectedForeground color white Text color for selected items canTriggerRender = true,
List.defineProperty(List, "selectedForeground", {default = colors.white, type = "color"}) setter = function(self, value)
local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
return math.min(maxOffset, math.max(0, value))
end
})
---@event onSelect {index number, item table} Fired when an item is selected ---@property emptyText string "No items" Text to display when the list is empty
List.defineProperty(List, "emptyText", {default = "No items", type = "string", canTriggerRender = true})
---@property showScrollBar boolean true Whether to show the scrollbar when items exceed height
List.defineProperty(List, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true})
---@property scrollBarSymbol string " " Symbol used for the scrollbar handle
List.defineProperty(List, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true})
---@property scrollBarBackground string "\127" Symbol used for the scrollbar background
List.defineProperty(List, "scrollBarBackground", {default = "\127", type = "string", canTriggerRender = true})
---@property scrollBarColor color lightGray Color of the scrollbar handle
List.defineProperty(List, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor color gray Background color of the scrollbar
List.defineProperty(List, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true})
---@event onSelect {List self, index number, item table} Fired when an item is selected
List.defineEvent(List, "mouse_click") List.defineEvent(List, "mouse_click")
List.defineEvent(List, "mouse_up")
List.defineEvent(List, "mouse_drag")
List.defineEvent(List, "mouse_scroll") List.defineEvent(List, "mouse_scroll")
List.defineEvent(List, "key")
---@tableType ItemTable
---@tableField text string The display text for the item
---@tableField callback function Function called when selected
---@tableField fg color Normal text color
---@tableField bg color Normal background color
---@tableField selectedFg color Text color when selected
---@tableField selectedBg color Background when selected
local entrySchema = {
text = { type = "string", default = "Entry" },
bg = { type = "number", default = nil },
fg = { type = "number", default = nil },
selectedBg = { type = "number", default = nil },
selectedFg = { type = "number", default = nil },
callback = { type = "function", default = nil }
}
--- Creates a new List instance --- Creates a new List instance
--- @shortDescription Creates a new List instance --- @shortDescription Creates a new List instance
@@ -34,7 +71,6 @@ function List.new()
self.set("width", 16) self.set("width", 16)
self.set("height", 8) self.set("height", 8)
self.set("z", 5) self.set("z", 5)
self.set("background", colors.gray)
return self return self
end end
@@ -44,73 +80,25 @@ end
--- @return List self The initialized instance --- @return List self The initialized instance
--- @protected --- @protected
function List:init(props, basalt) function List:init(props, basalt)
VisualElement.init(self, props, basalt) Collection.init(self, props, basalt)
self._entrySchema = entrySchema
self.set("type", "List") self.set("type", "List")
return self
end
--- Adds an item to the list self:observe("items", function()
--- @shortDescription Adds an item to the list local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
--- @param text string|table The item to add (string or item table) if self.getResolved("offset") > maxOffset then
--- @return List self The List instance self.set("offset", maxOffset)
--- @usage list:addItem("New Item")
--- @usage list:addItem({text="Item", callback=function() end})
function List:addItem(text)
local items = self.get("items")
table.insert(items, text)
self:updateRender()
return self
end
--- Removes an item from the list
--- @shortDescription Removes an item from the list
--- @param index number The index of the item to remove
--- @return List self The List instance
--- @usage list:removeItem(1)
function List:removeItem(index)
local items = self.get("items")
table.remove(items, index)
self:updateRender()
return self
end
--- Clears all items from the list
--- @shortDescription Clears all items from the list
--- @return List self The List instance
--- @usage list:clear()
function List:clear()
self.set("items", {})
self:updateRender()
return self
end
-- Gets the currently selected items
--- @shortDescription Gets the currently selected items
--- @return table selected List of selected items
--- @usage local selected = list:getSelectedItems()
function List:getSelectedItems()
local selected = {}
for i, item in ipairs(self.get("items")) do
if type(item) == "table" and item.selected then
local selectedItem = item
selectedItem.index = i
table.insert(selected, selectedItem)
end end
end end)
return selected
end
--- Gets first selected item self:observe("height", function()
--- @shortDescription Gets first selected item local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
--- @return table? selected The first item if self.getResolved("offset") > maxOffset then
function List:getSelectedItem() self.set("offset", maxOffset)
local items = self.get("items")
for i, item in ipairs(items) do
if type(item) == "table" and item.selected then
return item
end end
end end)
return nil
return self
end end
--- @shortDescription Handles mouse click events --- @shortDescription Handles mouse click events
@@ -120,40 +108,98 @@ end
--- @return boolean Whether the event was handled --- @return boolean Whether the event was handled
--- @protected --- @protected
function List:mouse_click(button, x, y) function List:mouse_click(button, x, y)
if self:isInBounds(x, y) and self.get("selectable") then if Collection.mouse_click(self, button, x, y) then
local _, index = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local adjustedIndex = index + self.get("offset") local width = self.getResolved("width")
local items = self.get("items") local items = self.getResolved("items")
local height = self.getResolved("height")
local showScrollBar = self.getResolved("showScrollBar")
if adjustedIndex <= #items then if showScrollBar and #items > height and relX == width then
local item = items[adjustedIndex] local maxOffset = #items - height
if type(item) == "string" then local handleSize = math.max(1, math.floor((height / #items) * height))
item = {text = item}
items[adjustedIndex] = item local currentPercent = maxOffset > 0 and (self.getResolved("offset") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (height - handleSize)) + 1
if relY >= handlePos and relY < handlePos + handleSize then
self._scrollBarDragging = true
self._scrollBarDragOffset = relY - handlePos
else
local newPercent = ((relY - 1) / (height - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
end end
return true
end
if not self.get("multiSelection") then if self.getResolved("selectable") then
for _, otherItem in ipairs(items) do local adjustedIndex = relY + self.getResolved("offset")
if type(otherItem) == "table" then
otherItem.selected = false if adjustedIndex <= #items then
local item = items[adjustedIndex]
if not self.getResolved("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
otherItem.selected = false
end
end end
end end
end
item.selected = not item.selected item.selected = not item.selected
if item.callback then if item.callback then
item.callback(self) item.callback(self)
end
self:fireEvent("select", adjustedIndex, item)
self:updateRender()
end end
self:fireEvent("mouse_click", button, x, y)
self:fireEvent("select", adjustedIndex, item)
self:updateRender()
end end
return true return true
end end
return false return false
end end
--- @shortDescription Handles mouse drag events for scrollbar
--- @param button number The mouse button being dragged
--- @param x number The x-coordinate of the drag
--- @param y number The y-coordinate of the drag
--- @return boolean Whether the event was handled
--- @protected
function List:mouse_drag(button, x, y)
if self._scrollBarDragging then
local _, relY = self:getRelativePosition(x, y)
local items = self.getResolved("items")
local height = self.getResolved("height")
local handleSize = math.max(1, math.floor((height / #items) * height))
local maxOffset = #items - height
relY = math.max(1, math.min(height, relY))
local newPos = relY - (self._scrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (height - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
return true
end
return Collection.mouse_drag and Collection.mouse_drag(self, button, x, y) or false
end
--- @shortDescription Handles mouse up events to stop scrollbar dragging
--- @param button number The mouse button that was released
--- @param x number The x-coordinate of the release
--- @param y number The y-coordinate of the release
--- @return boolean Whether the event was handled
--- @protected
function List:mouse_up(button, x, y)
if self._scrollBarDragging then
self._scrollBarDragging = false
self._scrollBarDragOffset = nil
return true
end
return Collection.mouse_up and Collection.mouse_up(self, button, x, y) or false
end
--- @shortDescription Handles mouse scroll events --- @shortDescription Handles mouse scroll events
--- @param direction number The direction of the scroll (1 for down, -1 for up) --- @param direction number The direction of the scroll (1 for down, -1 for up)
--- @param x number The x-coordinate of the scroll --- @param x number The x-coordinate of the scroll
@@ -161,13 +207,12 @@ end
--- @return boolean Whether the event was handled --- @return boolean Whether the event was handled
--- @protected --- @protected
function List:mouse_scroll(direction, x, y) function List:mouse_scroll(direction, x, y)
if self:isInBounds(x, y) then if Collection.mouse_scroll(self, direction, x, y) then
local offset = self.get("offset") local offset = self.getResolved("offset")
local maxOffset = math.max(0, #self.get("items") - self.get("height")) local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
offset = math.min(maxOffset, math.max(0, offset + direction)) offset = math.min(maxOffset, math.max(0, offset + direction))
self.set("offset", offset) self.set("offset", offset)
self:fireEvent("mouse_scroll", direction, x, y)
return true return true
end end
return false return false
@@ -187,7 +232,7 @@ end
--- @shortDescription Scrolls the list to the bottom --- @shortDescription Scrolls the list to the bottom
--- @return List self The List instance --- @return List self The List instance
function List:scrollToBottom() function List:scrollToBottom()
local maxOffset = math.max(0, #self.get("items") - self.get("height")) local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
self.set("offset", maxOffset) self.set("offset", maxOffset)
return self return self
end end
@@ -200,51 +245,166 @@ function List:scrollToTop()
return self return self
end end
--- Scrolls to make a specific item visible
--- @shortDescription Scrolls to a specific item
--- @param index number The index of the item to scroll to
--- @return List self The List instance
--- @usage list:scrollToItem(5)
function List:scrollToItem(index)
local height = self.getResolved("height")
local offset = self.getResolved("offset")
if index < offset + 1 then
self.set("offset", math.max(0, index - 1))
elseif index > offset + height then
self.set("offset", index - height)
end
return self
end
--- Handles key events for keyboard navigation
--- @shortDescription Handles key events
--- @param keyCode number The key code
--- @return boolean Whether the event was handled
--- @protected
function List:key(keyCode)
if Collection.key(self, keyCode) and self.getResolved("selectable") then
local items = self.getResolved("items")
local currentIndex = self:getSelectedIndex()
if keyCode == keys.up then
self:selectPrevious()
if currentIndex and currentIndex > 1 then
self:scrollToItem(currentIndex - 1)
end
return true
elseif keyCode == keys.down then
self:selectNext()
if currentIndex and currentIndex < #items then
self:scrollToItem(currentIndex + 1)
end
return true
elseif keyCode == keys.home then
self:clearItemSelection()
self:selectItem(1)
self:scrollToTop()
return true
elseif keyCode == keys["end"] then
self:clearItemSelection()
self:selectItem(#items)
self:scrollToBottom()
return true
elseif keyCode == keys.pageUp then
local height = self.getResolved("height")
local newIndex = math.max(1, (currentIndex or 1) - height)
self:clearItemSelection()
self:selectItem(newIndex)
self:scrollToItem(newIndex)
return true
elseif keyCode == keys.pageDown then
local height = self.getResolved("height")
local newIndex = math.min(#items, (currentIndex or 1) + height)
self:clearItemSelection()
self:selectItem(newIndex)
self:scrollToItem(newIndex)
return true
end
end
return false
end
--- @shortDescription Renders the list --- @shortDescription Renders the list
--- @protected --- @protected
function List:render() function List:render(vOffset)
VisualElement.render(self) vOffset = vOffset or 0
Collection.render(self)
local items = self.get("items") local items = self.getResolved("items")
local height = self.get("height") local height = self.getResolved("height")
local offset = self.get("offset") local offset = self.getResolved("offset")
local width = self.get("width") local width = self.getResolved("width")
local listBg = self.getResolved("background")
local listFg = self.getResolved("foreground")
local showScrollBar = self.getResolved("showScrollBar")
local needsScrollBar = showScrollBar and #items > height
local contentWidth = needsScrollBar and width - 1 or width
if #items == 0 then
local emptyText = self.getResolved("emptyText")
local y = math.floor(height / 2) + vOffset
local x = math.max(1, math.floor((width - #emptyText) / 2) + 1)
for i = 1, height do
self:textBg(1, i, string.rep(" ", width), listBg)
end
if y >= 1 and y <= height then
self:textFg(x, y + vOffset, emptyText, colors.gray)
end
return
end
for i = 1, height do for i = 1, height do
local itemIndex = i + offset local itemIndex = i + offset
local item = items[itemIndex] local item = items[itemIndex]
if item then if item then
if type(item) == "string" then
item = {text = item}
items[itemIndex] = item
end
if item.separator then if item.separator then
local separatorChar = (item.text or "-"):sub(1,1) local separatorChar = ((item.text or "-") ~= "" and item.text or "-"):sub(1,1)
local separatorText = string.rep(separatorChar, width) local separatorText = string.rep(separatorChar, contentWidth)
local fg = item.foreground or self.get("foreground") local fg = item.fg or listFg
local bg = item.background or self.get("background") local bg = item.bg or listBg
self:textBg(1, i, string.rep(" ", width), bg) self:textBg(1, i + vOffset, string.rep(" ", contentWidth), bg)
self:textFg(1, i, separatorText:sub(1, width), fg) self:textFg(1, i + vOffset, separatorText, fg)
else else
local text = item.text local text = item.text or ""
local isSelected = item.selected local isSelected = item.selected
local bg = isSelected and local bg = isSelected and
(item.selectedBackground or self.get("selectedBackground")) or (item.selectedBg or self.getResolved("selectedBackground")) or
(item.background or self.get("background")) (item.bg or listBg)
local fg = isSelected and local fg = isSelected and
(item.selectedForeground or self.get("selectedForeground")) or (item.selectedFg or self.getResolved("selectedForeground")) or
(item.foreground or self.get("foreground")) (item.fg or listFg)
self:textBg(1, i, string.rep(" ", width), bg) local displayText = text
self:textFg(1, i, text:sub(1, width), fg) if #displayText > contentWidth then
displayText = displayText:sub(1, contentWidth - 3) .. "..."
else
displayText = displayText .. string.rep(" ", contentWidth - #displayText)
end
self:textBg(1, i + vOffset, string.rep(" ", contentWidth), bg)
self:textFg(1, i + vOffset, displayText, fg)
end end
else
self:textBg(1, i + vOffset, string.rep(" ", contentWidth), listBg)
end
end
if needsScrollBar then
local handleSize = math.max(1, math.floor((height / #items) * height))
local maxOffset = #items - height
local currentPercent = maxOffset > 0 and (offset / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (height - handleSize)) + 1
local scrollBarSymbol = self.getResolved("scrollBarSymbol")
local scrollBarBg = self.getResolved("scrollBarBackground")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
for i = 1, height do
self:blit(width, i + vOffset, scrollBarBg, tHex[listFg], tHex[scrollBarBgColor])
end
for i = handlePos, math.min(height, handlePos + handleSize - 1) do
self:blit(width, i + vOffset, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
end end
end end
end end
return List return List

View File

@@ -3,8 +3,7 @@ local List = require("elements/List")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDescription A horizontal menu bar with selectable items. ---@configDescription A horizontal menu bar with selectable items.
--- This is the menu class. It provides a horizontal menu bar with selectable items. --- This is the menu class. It provides a horizontal menu bar with selectable items. Menu items are displayed in a single row and can have custom colors and callbacks.
--- Menu items are displayed in a single row and can have custom colors and callbacks.
---@class Menu : List ---@class Menu : List
local Menu = setmetatable({}, List) local Menu = setmetatable({}, List)
Menu.__index = Menu Menu.__index = Menu
@@ -12,6 +11,51 @@ Menu.__index = Menu
---@property separatorColor color gray The color used for separator items in the menu ---@property separatorColor color gray The color used for separator items in the menu
Menu.defineProperty(Menu, "separatorColor", {default = colors.gray, type = "color"}) Menu.defineProperty(Menu, "separatorColor", {default = colors.gray, type = "color"})
---@property spacing number 0 The number of spaces between menu items
Menu.defineProperty(Menu, "spacing", {default = 1, type = "number", canTriggerRender = true})
---@property openDropdown table nil Currently open dropdown data {index, items, x, y, width, height}
Menu.defineProperty(Menu, "openDropdown", {default = nil, type = "table", allowNil = true, canTriggerRender = true})
---@property dropdownBackground color black Background color for dropdown menus
Menu.defineProperty(Menu, "dropdownBackground", {default = colors.black, type = "color", canTriggerRender = true})
---@property dropdownForeground color white Foreground color for dropdown menus
Menu.defineProperty(Menu, "dropdownForeground", {default = colors.white, type = "color", canTriggerRender = true})
---@property horizontalOffset number 0 Current horizontal scroll offset
Menu.defineProperty(Menu, "horizontalOffset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
local maxOffset = math.max(0, self:getTotalWidth() - self.getResolved("width"))
return math.min(maxOffset, math.max(0, value))
end
})
---@property maxWidth number nil Maximum width before scrolling is enabled (nil = auto-size to items)
Menu.defineProperty(Menu, "maxWidth", {default = nil, type = "number", canTriggerRender = true})
---@tableType ItemTable
---@tableField text string The display text for the item
---@tableField callback function Function called when selected
---@tableField fg color Normal text color
---@tableField bg color Normal background color
---@tableField selectedFg color Text color when selected
---@tableField selectedBg color Background when selected
---@tableField dropdown table Array of dropdown items
local entrySchema = {
text = { type = "string", default = "Entry" },
bg = { type = "number", default = nil },
fg = { type = "number", default = nil },
selectedBg = { type = "number", default = nil },
selectedFg = { type = "number", default = nil },
callback = { type = "function", default = nil },
dropdown = { type = "table", default = nil },
}
--- Creates a new Menu instance --- Creates a new Menu instance
--- @shortDescription Creates a new Menu instance --- @shortDescription Creates a new Menu instance
--- @return Menu self The newly created Menu instance --- @return Menu self The newly created Menu instance
@@ -21,7 +65,7 @@ function Menu.new()
self.class = Menu self.class = Menu
self.set("width", 30) self.set("width", 30)
self.set("height", 1) self.set("height", 1)
self.set("background", colors.gray) self.set("z", 8)
return self return self
end end
@@ -32,84 +76,213 @@ end
--- @protected --- @protected
function Menu:init(props, basalt) function Menu:init(props, basalt)
List.init(self, props, basalt) List.init(self, props, basalt)
self._entrySchema = entrySchema
self.set("type", "Menu") self.set("type", "Menu")
self:observe("items", function()
local maxWidth = self.getResolved("maxWidth")
if maxWidth then
self.set("width", math.min(maxWidth, self:getTotalWidth()), true)
else
self.set("width", self:getTotalWidth(), true)
end
end)
return self return self
end end
--- Sets the menu items --- Calculates the total width of all menu items with spacing
--- @shortDescription Sets the menu items and calculates total width --- @shortDescription Calculates total width of menu items
--- @param items table[] List of items with {text, separator, callback, foreground, background} properties --- @return number totalWidth The total width of all items
--- @return Menu self The Menu instance function Menu:getTotalWidth()
--- @usage menu:setItems({{text="File"}, {separator=true}, {text="Edit"}}) local items = self.getResolved("items")
function Menu:setItems(items) local spacing = self.getResolved("spacing")
local listItems = {}
local totalWidth = 0 local totalWidth = 0
for _, item in ipairs(items) do
if item.separator then for i, item in ipairs(items) do
table.insert(listItems, {text = item.text or "|", selectable = false}) if type(item) == "table" then
totalWidth = totalWidth + 1 totalWidth = totalWidth + #item.text
else else
local text = " " .. item.text .. " " totalWidth = totalWidth + #tostring(item) + 2
item.text = text end
table.insert(listItems, item)
totalWidth = totalWidth + #text if i < #items then
totalWidth = totalWidth + spacing
end end
end end
self.set("width", totalWidth)
return List.setItems(self, listItems) return totalWidth
end end
--- @shortDescription Renders the menu horizontally with proper spacing and colors --- @shortDescription Renders the menu horizontally with proper spacing and colors
--- @protected --- @protected
function Menu:render() function Menu:render()
VisualElement.render(self) VisualElement.render(self)
local viewportWidth = self.getResolved("width")
local spacing = self.getResolved("spacing")
local offset = self.getResolved("horizontalOffset")
local items = self.getResolved("items")
local itemPositions = {}
local currentX = 1 local currentX = 1
for i, item in ipairs(self.get("items")) do for i, item in ipairs(items) do
if type(item) == "string" then if type(item) == "string" then
item = {text = " "..item.." "} item = {text = " "..item.." "}
self.get("items")[i] = item items[i] = item
end end
local isSelected = item.selected itemPositions[i] = {
local fg = item.selectable == false and self.get("separatorColor") or startX = currentX,
(isSelected and (item.selectedForeground or self.get("selectedForeground")) or endX = currentX + #item.text - 1,
(item.foreground or self.get("foreground"))) text = item.text,
item = item
local bg = isSelected and }
(item.selectedBackground or self.get("selectedBackground")) or
(item.background or self.get("background"))
self:blit(currentX, 1, item.text,
string.rep(tHex[fg], #item.text),
string.rep(tHex[bg], #item.text))
currentX = currentX + #item.text currentX = currentX + #item.text
if i < #items and spacing > 0 then
currentX = currentX + spacing
end
end
for i, pos in ipairs(itemPositions) do
local item = pos.item
local itemStartInViewport = pos.startX - offset
local itemEndInViewport = pos.endX - offset
if itemStartInViewport > viewportWidth then
break
end
if itemEndInViewport >= 1 then
local visibleStart = math.max(1, itemStartInViewport)
local visibleEnd = math.min(viewportWidth, itemEndInViewport)
local textStartIdx = math.max(1, 1 - itemStartInViewport + 1)
local textEndIdx = math.min(#pos.text, #pos.text - (itemEndInViewport - viewportWidth))
local visibleText = pos.text:sub(textStartIdx, textEndIdx)
if #visibleText > 0 then
local isSelected = item.selected
local fg = item.selectable == false and self.getResolved("separatorColor") or
(isSelected and (item.selectedForeground or self.getResolved("selectedForeground")) or
(item.foreground or self.getResolved("foreground")))
local bg = isSelected and
(item.selectedBackground or self.getResolved("selectedBackground")) or
(item.background or self.getResolved("background"))
self:blit(visibleStart, 1, visibleText,
string.rep(tHex[fg], #visibleText),
string.rep(tHex[bg], #visibleText))
end
if i < #items and spacing > 0 then
local spacingStart = pos.endX + 1 - offset
local spacingEnd = spacingStart + spacing - 1
if spacingEnd >= 1 and spacingStart <= viewportWidth then
local visibleSpacingStart = math.max(1, spacingStart)
local visibleSpacingEnd = math.min(viewportWidth, spacingEnd)
local spacingWidth = visibleSpacingEnd - visibleSpacingStart + 1
if spacingWidth > 0 then
local spacingText = string.rep(" ", spacingWidth)
self:blit(visibleSpacingStart, 1, spacingText,
string.rep(tHex[self.getResolved("foreground")], spacingWidth),
string.rep(tHex[self.getResolved("background")], spacingWidth))
end
end
end
end
end
local openDropdown = self.getResolved("openDropdown")
if openDropdown then
self:renderDropdown(openDropdown)
end
end
--- Renders the dropdown menu
--- @shortDescription Renders dropdown overlay
--- @param dropdown table Dropdown data
--- @protected
function Menu:renderDropdown(dropdown)
local dropdownBg = self.getResolved("dropdownBackground")
local dropdownFg = self.getResolved("dropdownForeground")
for i, item in ipairs(dropdown.items) do
local y = dropdown.y + i - 1
local label = item.text or item.label or ""
local isSeparator = label == "---"
local bgHex = tHex[item.background or dropdownBg]
local fgHex = tHex[item.foreground or dropdownFg]
local spaces = string.rep(" ", dropdown.width)
self:blit(dropdown.x, y, spaces,
string.rep(fgHex, dropdown.width),
string.rep(bgHex, dropdown.width))
if isSeparator then
local separator = string.rep("-", dropdown.width)
self:blit(dropdown.x, y, separator,
string.rep(tHex[colors.gray], dropdown.width),
string.rep(bgHex, dropdown.width))
else
if #label > dropdown.width - 2 then
label = label:sub(1, dropdown.width - 2)
end
self:textFg(dropdown.x + 1, y, label, item.foreground or dropdownFg)
end
end end
end end
--- @shortDescription Handles mouse click events and item selection --- @shortDescription Handles mouse click events and item selection
--- @param button number The button that was clicked
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean Whether the event was handled
--- @protected --- @protected
function Menu:mouse_click(button, x, y) function Menu:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end local openDropdown = self.getResolved("openDropdown")
if(self.get("selectable") == false) then return false end if openDropdown then
local relX, relY = self:getRelativePosition(x, y)
if self:isInsideDropdown(relX, relY, openDropdown) then
return self:handleDropdownClick(relX, relY, openDropdown)
else
self:hideDropdown()
end
end
if not VisualElement.mouse_click(self, button, x, y) then
return false
end
if(self.getResolved("selectable") == false) then return false end
local relX = select(1, self:getRelativePosition(x, y)) local relX = select(1, self:getRelativePosition(x, y))
local offset = self.getResolved("horizontalOffset")
local spacing = self.getResolved("spacing")
local items = self.getResolved("items")
local virtualX = relX + offset
local currentX = 1 local currentX = 1
for i, item in ipairs(self.get("items")) do for i, item in ipairs(items) do
if relX >= currentX and relX < currentX + #item.text then local itemWidth = #item.text
if virtualX >= currentX and virtualX < currentX + itemWidth then
if item.selectable ~= false then if item.selectable ~= false then
if type(item) == "string" then if type(item) == "string" then
item = {text = item} item = {text = item}
self.get("items")[i] = item items[i] = item
end end
if not self.get("multiSelection") then if item.dropdown and #item.dropdown > 0 then
for _, otherItem in ipairs(self.get("items")) do self:showDropdown(i, item, currentX - offset)
return true
end
if not self.getResolved("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then if type(otherItem) == "table" then
otherItem.selected = false otherItem.selected = false
end end
@@ -125,7 +298,105 @@ function Menu:mouse_click(button, x, y)
end end
return true return true
end end
currentX = currentX + #item.text currentX = currentX + itemWidth
if i < #items and spacing > 0 then
currentX = currentX + spacing
end
end
return false
end
--- @shortDescription Handles mouse scroll events for horizontal scrolling
--- @protected
function Menu:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then
local offset = self.getResolved("horizontalOffset")
local maxOffset = math.max(0, self:getTotalWidth() - self.getResolved("width"))
offset = math.min(maxOffset, math.max(0, offset + (direction * 3)))
self.set("horizontalOffset", offset)
return true
end
return false
end
--- Shows a dropdown menu for a specific item
--- @shortDescription Shows dropdown menu
--- @param index number The item index
--- @param item table The menu item
--- @param itemX number The X position of the item
function Menu:showDropdown(index, item, itemX)
local dropdown = item.dropdown
if not dropdown or #dropdown == 0 then return end
local maxWidth = 8
for _, dropItem in ipairs(dropdown) do
local label = dropItem.text or dropItem.label or ""
if #label + 2 > maxWidth then
maxWidth = #label + 2
end
end
local height = #dropdown
local menuHeight = self.getResolved("height")
self.set("openDropdown", {
index = index,
items = dropdown,
x = itemX,
y = menuHeight + 1,
width = maxWidth,
height = height
})
self:updateRender()
end
--- Closes the currently open dropdown
--- @shortDescription Closes dropdown menu
function Menu:hideDropdown()
self.set("openDropdown", nil)
self:updateRender()
end
--- Checks if a position is inside the dropdown
--- @shortDescription Checks if position is in dropdown
--- @param relX number Relative X position
--- @param relY number Relative Y position
--- @param dropdown table Dropdown data
--- @return boolean inside Whether position is inside dropdown
function Menu:isInsideDropdown(relX, relY, dropdown)
return relX >= dropdown.x and
relX < dropdown.x + dropdown.width and
relY >= dropdown.y and
relY < dropdown.y + dropdown.height
end
--- Handles click inside dropdown
--- @shortDescription Handles dropdown click
--- @param relX number Relative X position
--- @param relY number Relative Y position
--- @param dropdown table Dropdown data
--- @return boolean handled Whether click was handled
function Menu:handleDropdownClick(relX, relY, dropdown)
local itemIndex = relY - dropdown.y + 1
if itemIndex >= 1 and itemIndex <= #dropdown.items then
local item = dropdown.items[itemIndex]
if item.text == "---" or item.label == "---" or item.disabled then
return true
end
if item.callback then
item.callback(self, item)
elseif item.onClick then
item.onClick(self, item)
end
self:hideDropdown()
return true
end end
return false return false
end end

View File

@@ -1,11 +1,65 @@
local elementManager = require("elementManager") local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement") local VisualElement = elementManager.getElement("VisualElement")
local errorManager = require("errorManager") local errorManager = require("errorManager")
--- @configDescription A program that runs in a window --- @configDescription A program that runs in a window
---@configDefault false
--- This is the program class. It provides a program that runs in a window. --- This is the program class. It provides a program that runs in a window.
---@class Program : VisualElement ---@class Program : VisualElement
---@run [[
--- local basalt = require("basalt")
---
--- local main = basalt.getMainFrame()
---
--- local execPath = "rom/programs/fun/worm.lua"
---
--- local btn = main:addButton({
--- x = 5,
--- y = 2,
--- width = 20,
--- height = 3,
--- text = "Run Worm",
--- }):onClick(function()
--- local frame = main:addFrame({
--- x = 2,
--- y = 2,
--- width = 28,
--- height = 10,
--- title = "Worm Program",
--- draggable = true,
--- })
--- :setDraggingMap({{x=1, y=1, width=27, height=1}})
--- :onFocus(function(self)
--- self:prioritize()
--- end)
--- local program = frame:addProgram({
--- x = 1,
--- y = 2,
--- width = 28,
--- height = 9,
--- })
--- program:execute(execPath)
--- frame:addLabel({
--- x = 2,
--- y = 1,
--- text = "Worm",
--- foreground = colors.lightBlue
--- })
--- frame:addButton({
--- x = frame.get("width"),
--- y = 1,
--- width = 1,
--- height = 1,
--- text = "X",
--- background = colors.red,
--- foreground = colors.white
--- }):onClick(function()
--- frame:destroy()
--- end)
--- end)
---
--- basalt.run()
--- ]]
local Program = setmetatable({}, VisualElement) local Program = setmetatable({}, VisualElement)
Program.__index = Program Program.__index = Program
@@ -200,15 +254,15 @@ function Program:init(props, basalt)
VisualElement.init(self, props, basalt) VisualElement.init(self, props, basalt)
self.set("type", "Program") self.set("type", "Program")
self:observe("width", function(self, width) self:observe("width", function(self, width)
local program = self.get("program") local program = self.getResolved("program")
if program then if program then
program:resize(width, self.get("height")) program:resize(width, self.getResolved("height"))
end end
end) end)
self:observe("height", function(self, height) self:observe("height", function(self, height)
local program = self.get("program") local program = self.getResolved("program")
if program then if program then
program:resize(self.get("width"), height) program:resize(self.getResolved("width"), height)
end end
end) end)
return self return self
@@ -226,7 +280,7 @@ function Program:execute(path, env, addEnvironment, ...)
local program = BasaltProgram.new(self, env, addEnvironment) local program = BasaltProgram.new(self, env, addEnvironment)
self.set("program", program) self.set("program", program)
program:setArgs(...) program:setArgs(...)
program:run(path, self.get("width"), self.get("height"), ...) program:run(path, self.getResolved("width"), self.getResolved("height"), ...)
self:updateRender() self:updateRender()
return self return self
end end
@@ -235,7 +289,7 @@ end
--- @shortDescription Stops the program --- @shortDescription Stops the program
--- @return Program self The Program instance --- @return Program self The Program instance
function Program:stop() function Program:stop()
local program = self.get("program") local program = self.getResolved("program")
if program then if program then
program:stop() program:stop()
self.set("running", false) self.set("running", false)
@@ -278,11 +332,11 @@ end
--- @return any result The event result --- @return any result The event result
--- @protected --- @protected
function Program:dispatchEvent(event, ...) function Program:dispatchEvent(event, ...)
local program = self.get("program") local program = self.getResolved("program")
local result = VisualElement.dispatchEvent(self, event, ...) local result = VisualElement.dispatchEvent(self, event, ...)
if program then if program then
program:resume(event, ...) program:resume(event, ...)
if(self.get("focused"))then if(self:hasState("focused"))then
local cursorBlink = program.window.getCursorBlink() local cursorBlink = program.window.getCursorBlink()
local cursorX, cursorY = program.window.getCursorPos() local cursorX, cursorY = program.window.getCursorPos()
self:setCursor(cursorX, cursorY, cursorBlink, program.window.getTextColor()) self:setCursor(cursorX, cursorY, cursorBlink, program.window.getTextColor())
@@ -296,7 +350,7 @@ end
--- @protected --- @protected
function Program:focus() function Program:focus()
if(VisualElement.focus(self))then if(VisualElement.focus(self))then
local program = self.get("program") local program = self.getResolved("program")
if program then if program then
local cursorBlink = program.window.getCursorBlink() local cursorBlink = program.window.getCursorBlink()
local cursorX, cursorY = program.window.getCursorPos() local cursorX, cursorY = program.window.getCursorPos()
@@ -309,7 +363,7 @@ end
--- @protected --- @protected
function Program:render() function Program:render()
VisualElement.render(self) VisualElement.render(self)
local program = self.get("program") local program = self.getResolved("program")
if program then if program then
local _, height = program.window.getSize() local _, height = program.window.getSize()
for y = 1, height do for y = 1, height do

View File

@@ -3,9 +3,11 @@ local tHex = require("libraries/colorHex")
--- This is the progress bar class. It provides a visual representation of progress --- This is the progress bar class. It provides a visual representation of progress
--- with optional percentage display and customizable colors. --- with optional percentage display and customizable colors.
--- @usage local progressBar = main:addProgressBar() --- @usage [[
--- @usage progressBar:setDirection("up") --- local progressBar = main:addProgressBar()
--- @usage progressBar:setProgress(50) --- progressBar:setDirection("up")
--- progressBar:setProgress(50)
--- ]]
---@class ProgressBar : VisualElement ---@class ProgressBar : VisualElement
local ProgressBar = setmetatable({}, VisualElement) local ProgressBar = setmetatable({}, VisualElement)
ProgressBar.__index = ProgressBar ProgressBar.__index = ProgressBar
@@ -45,29 +47,30 @@ end
--- @protected --- @protected
function ProgressBar:render() function ProgressBar:render()
VisualElement.render(self) VisualElement.render(self)
local width = self.get("width") local width = self.getResolved("width")
local height = self.get("height") local height = self.getResolved("height")
local progress = math.min(100, math.max(0, self.get("progress"))) local progress = math.min(100, math.max(0, self.getResolved("progress")))
local fillWidth = math.floor((width * progress) / 100) local fillWidth = math.floor((width * progress) / 100)
local fillHeight = math.floor((height * progress) / 100) local fillHeight = math.floor((height * progress) / 100)
local direction = self.get("direction") local direction = self.getResolved("direction")
local progressColor = self.get("progressColor") local progressColor = self.getResolved("progressColor")
local foreground = self.getResolved("foreground")
if direction == "right" then if direction == "right" then
self:multiBlit(1, 1, fillWidth, height, " ", tHex[self.get("foreground")], tHex[progressColor]) self:multiBlit(1, 1, fillWidth, height, " ", tHex[foreground], tHex[progressColor])
elseif direction == "left" then elseif direction == "left" then
self:multiBlit(width - fillWidth + 1, 1, fillWidth, height, " ", tHex[self.get("foreground")], tHex[progressColor]) self:multiBlit(width - fillWidth + 1, 1, fillWidth, height, " ", tHex[foreground], tHex[progressColor])
elseif direction == "up" then elseif direction == "up" then
self:multiBlit(1, height - fillHeight + 1, width, fillHeight, " ", tHex[self.get("foreground")], tHex[progressColor]) self:multiBlit(1, height - fillHeight + 1, width, fillHeight, " ", tHex[foreground], tHex[progressColor])
elseif direction == "down" then elseif direction == "down" then
self:multiBlit(1, 1, width, fillHeight, " ", tHex[self.get("foreground")], tHex[progressColor]) self:multiBlit(1, 1, width, fillHeight, " ", tHex[foreground], tHex[progressColor])
end end
if self.get("showPercentage") then if self.getResolved("showPercentage") then
local text = tostring(progress).."%" local text = tostring(progress).."%"
local x = math.floor((width - #text) / 2) + 1 local x = math.floor((width - #text) / 2) + 1
local y = math.floor((height - 1) / 2) + 1 local y = math.floor((height - 1) / 2) + 1
self:textFg(x, y, text, self.get("foreground")) self:textFg(x, y, text, foreground)
end end
end end

View File

@@ -1,6 +1,7 @@
local VisualElement = require("elements/VisualElement") local VisualElement = require("elements/VisualElement")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDescription A ScrollBar element that can be attached to other elements to control their scroll properties. ---@configDescription A ScrollBar element that can be attached to other elements to control their scroll properties.
---@configDefault false
---A ScrollBar element that can be attached to other elements to control their scroll properties ---A ScrollBar element that can be attached to other elements to control their scroll properties
---@class ScrollBar : VisualElement ---@class ScrollBar : VisualElement
@@ -79,8 +80,8 @@ function ScrollBar:attach(element, config)
self.set("maxValue", config.max or 100) self.set("maxValue", config.max or 100)
element:observe(config.property, function(_, value) element:observe(config.property, function(_, value)
if value then if value then
local min = self.get("minValue") local min = self.getResolved("minValue")
local max = self.get("maxValue") local max = self.getResolved("maxValue")
if min == max then return end if min == max then return end
self.set("value", math.floor( self.set("value", math.floor(
@@ -95,28 +96,28 @@ end
--- @shortDescription Updates the attached element's property based on the ScrollBar value --- @shortDescription Updates the attached element's property based on the ScrollBar value
--- @return ScrollBar self The ScrollBar instance --- @return ScrollBar self The ScrollBar instance
function ScrollBar:updateAttachedElement() function ScrollBar:updateAttachedElement()
local element = self.get("attachedElement") local element = self.getResolved("attachedElement")
if not element then return end if not element then return end
local value = self.get("value") local value = self.getResolved("value")
local min = self.get("minValue") local min = self.getResolved("minValue")
local max = self.get("maxValue") local max = self.getResolved("maxValue")
if type(min) == "function" then min = min() end if type(min) == "function" then min = min() end
if type(max) == "function" then max = max() end if type(max) == "function" then max = max() end
local mappedValue = min + (value / 100) * (max - min) local mappedValue = min + (value / 100) * (max - min)
element.set(self.get("attachedProperty"), math.floor(mappedValue + 0.5)) element.set(self.getResolved("attachedProperty"), math.floor(mappedValue + 0.5))
return self return self
end end
local function getScrollbarSize(self) local function getScrollbarSize(self)
return self.get("orientation") == "vertical" and self.get("height") or self.get("width") return self.getResolved("orientation") == "vertical" and self.getResolved("height") or self.getResolved("width")
end end
local function getRelativeScrollPosition(self, x, y) local function getRelativeScrollPosition(self, x, y)
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
return self.get("orientation") == "vertical" and relY or relX return self.getResolved("orientation") == "vertical" and relY or relX
end end
--- @shortDescription Handles mouse click events --- @shortDescription Handles mouse click events
@@ -128,8 +129,8 @@ end
function ScrollBar:mouse_click(button, x, y) function ScrollBar:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then if VisualElement.mouse_click(self, button, x, y) then
local size = getScrollbarSize(self) local size = getScrollbarSize(self)
local value = self.get("value") local value = self.getResolved("value")
local handleSize = self.get("handleSize") local handleSize = self.getResolved("handleSize")
local handlePos = math.floor((value / 100) * (size - handleSize)) + 1 local handlePos = math.floor((value / 100) * (size - handleSize)) + 1
local relPos = getRelativeScrollPosition(self, x, y) local relPos = getRelativeScrollPosition(self, x, y)
@@ -154,8 +155,8 @@ end
function ScrollBar:mouse_drag(button, x, y) function ScrollBar:mouse_drag(button, x, y)
if(VisualElement.mouse_drag(self, button, x, y))then if(VisualElement.mouse_drag(self, button, x, y))then
local size = getScrollbarSize(self) local size = getScrollbarSize(self)
local handleSize = self.get("handleSize") local handleSize = self.getResolved("handleSize")
local dragMultiplier = self.get("dragMultiplier") local dragMultiplier = self.getResolved("dragMultiplier")
local relPos = getRelativeScrollPosition(self, x, y) local relPos = getRelativeScrollPosition(self, x, y)
relPos = math.max(1, math.min(size, relPos)) relPos = math.max(1, math.min(size, relPos))
@@ -178,8 +179,8 @@ end
function ScrollBar:mouse_scroll(direction, x, y) function ScrollBar:mouse_scroll(direction, x, y)
if not self:isInBounds(x, y) then return false end if not self:isInBounds(x, y) then return false end
direction = direction > 0 and -1 or 1 direction = direction > 0 and -1 or 1
local step = self.get("step") local step = self.getResolved("step")
local currentValue = self.get("value") local currentValue = self.getResolved("value")
local newValue = currentValue - direction * step local newValue = currentValue - direction * step
self.set("value", math.min(100, math.max(0, newValue))) self.set("value", math.min(100, math.max(0, newValue)))
@@ -193,21 +194,23 @@ function ScrollBar:render()
VisualElement.render(self) VisualElement.render(self)
local size = getScrollbarSize(self) local size = getScrollbarSize(self)
local value = self.get("value") local value = self.getResolved("value")
local handleSize = self.get("handleSize") local handleSize = self.getResolved("handleSize")
local symbol = self.get("symbol") local symbol = self.getResolved("symbol")
local symbolColor = self.get("symbolColor") local symbolColor = self.getResolved("symbolColor")
local symbolBackgroundColor = self.get("symbolBackgroundColor") local symbolBackgroundColor = self.getResolved("symbolBackgroundColor")
local bgSymbol = self.get("backgroundSymbol") local bgSymbol = self.getResolved("backgroundSymbol")
local isVertical = self.get("orientation") == "vertical" local isVertical = self.getResolved("orientation") == "vertical"
local foreground = self.getResolved("foreground")
local background = self.getResolved("background")
local handlePos = math.floor((value / 100) * (size - handleSize)) + 1 local handlePos = math.floor((value / 100) * (size - handleSize)) + 1
for i = 1, size do for i = 1, size do
if isVertical then if isVertical then
self:blit(1, i, bgSymbol, tHex[self.get("foreground")], tHex[self.get("background")]) self:blit(1, i, bgSymbol, tHex[foreground], tHex[background])
else else
self:blit(i, 1, bgSymbol, tHex[self.get("foreground")], tHex[self.get("background")]) self:blit(i, 1, bgSymbol, tHex[foreground], tHex[background])
end end
end end

View File

@@ -0,0 +1,429 @@
local elementManager = require("elementManager")
local Container = elementManager.getElement("Container")
local tHex = require("libraries/colorHex")
---@configDescription A scrollable container that automatically displays scrollbars when content overflows.
---@configDefault false
--- A container that provides automatic scrolling capabilities with visual scrollbars. Displays vertical and/or horizontal scrollbars when child content exceeds the container's dimensions.
--- @run [[
--- local basalt = require("basalt")
---
--- local main = basalt.getMainFrame()
---
--- -- Create a ScrollFrame with content larger than the frame
--- local scrollFrame = main:addScrollFrame({
--- x = 2,
--- y = 2,
--- width = 30,
--- height = 12,
--- background = colors.lightGray
--- })
---
--- -- Add a title
--- scrollFrame:addLabel({
--- x = 2,
--- y = 1,
--- text = "ScrollFrame Example",
--- foreground = colors.yellow
--- })
---
--- -- Add multiple labels that exceed the frame height
--- for i = 1, 20 do
--- scrollFrame:addLabel({
--- x = 2,
--- y = i + 2,
--- text = "Line " .. i .. " - Scroll to see more",
--- foreground = i % 2 == 0 and colors.white or colors.lightGray
--- })
--- end
---
--- -- Add some interactive buttons at different positions
--- scrollFrame:addButton({
--- x = 2,
--- y = 24,
--- width = 15,
--- height = 3,
--- text = "Button 1",
--- background = colors.blue
--- })
--- :onClick(function()
--- scrollFrame:addLabel({
--- x = 18,
--- y = 24,
--- text = "Clicked!",
--- foreground = colors.lime
--- })
--- end)
---
--- scrollFrame:addButton({
--- x = 2,
--- y = 28,
--- width = 15,
--- height = 3,
--- text = "Button 2",
--- background = colors.green
--- })
--- :onClick(function()
--- scrollFrame:addLabel({
--- x = 18,
--- y = 28,
--- text = "Nice!",
--- foreground = colors.orange
--- })
--- end)
---
--- -- Info label outside the scroll frame
--- main:addLabel({
--- x = 2,
--- y = 15,
--- text = "Use mouse wheel to scroll!",
--- foreground = colors.gray
--- })
---
--- basalt.run()
--- ]]
---@class ScrollFrame : Container
local ScrollFrame = setmetatable({}, Container)
ScrollFrame.__index = ScrollFrame
---@property showScrollBar boolean true Whether to show scrollbars
ScrollFrame.defineProperty(ScrollFrame, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true})
---@property scrollBarSymbol string "_" The symbol used for the scrollbar handle
ScrollFrame.defineProperty(ScrollFrame, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true})
---@property scrollBarBackgroundSymbol string "\127" The symbol used for the scrollbar background
ScrollFrame.defineProperty(ScrollFrame, "scrollBarBackgroundSymbol", {default = "\127", type = "string", canTriggerRender = true})
---@property scrollBarColor color lightGray Color of the scrollbar handle
ScrollFrame.defineProperty(ScrollFrame, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor color gray Background color of the scrollbar
ScrollFrame.defineProperty(ScrollFrame, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor2 secondary color black Background color of the scrollbar
ScrollFrame.defineProperty(ScrollFrame, "scrollBarBackgroundColor2", {default = colors.black, type = "color", canTriggerRender = true})
---@property contentWidth number 0 The total width of the content (calculated from children)
ScrollFrame.defineProperty(ScrollFrame, "contentWidth", {
default = 0,
type = "number",
getter = function(self)
local maxWidth = 0
local children = self.getResolved("children")
for _, child in ipairs(children) do
local childX = child.get("x")
local childWidth = child.get("width")
local childRight = childX + childWidth - 1
if childRight > maxWidth then
maxWidth = childRight
end
end
return maxWidth
end
})
---@property contentHeight number 0 The total height of the content (calculated from children)
ScrollFrame.defineProperty(ScrollFrame, "contentHeight", {
default = 0,
type = "number",
getter = function(self)
local maxHeight = 0
local children = self.getResolved("children")
for _, child in ipairs(children) do
local childY = child.get("y")
local childHeight = child.get("height")
local childBottom = childY + childHeight - 1
if childBottom > maxHeight then
maxHeight = childBottom
end
end
return maxHeight
end
})
ScrollFrame.defineEvent(ScrollFrame, "mouse_click")
ScrollFrame.defineEvent(ScrollFrame, "mouse_drag")
ScrollFrame.defineEvent(ScrollFrame, "mouse_up")
ScrollFrame.defineEvent(ScrollFrame, "mouse_scroll")
--- Creates a new ScrollFrame instance
--- @shortDescription Creates a new ScrollFrame instance
--- @return ScrollFrame self The newly created ScrollFrame instance
--- @private
function ScrollFrame.new()
local self = setmetatable({}, ScrollFrame):__init()
self.class = ScrollFrame
self.set("width", 20)
self.set("height", 10)
self.set("z", 5)
return self
end
--- Initializes a ScrollFrame instance
--- @shortDescription Initializes a ScrollFrame instance
--- @param props table Initial properties
--- @param basalt table The basalt instance
--- @return ScrollFrame self The initialized ScrollFrame instance
--- @private
function ScrollFrame:init(props, basalt)
Container.init(self, props, basalt)
self.set("type", "ScrollFrame")
return self
end
--- Handles mouse click events for scrollbars and content
--- @shortDescription Handles mouse click events
--- @param button number The mouse button (1=left, 2=right, 3=middle)
--- @param x number The x-coordinate of the click
--- @param y number The y-coordinate of the click
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_click(button, x, y)
if Container.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y)
local width = self.getResolved("width")
local height = self.getResolved("height")
local showScrollBar = self.getResolved("showScrollBar")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local needsHorizontalScrollBar = showScrollBar and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
if needsVerticalScrollBar and relX == width and (not needsHorizontalScrollBar or relY < height) then
local scrollHeight = viewportHeight
local handleSize = math.max(1, math.floor((viewportHeight / contentHeight) * scrollHeight))
local maxOffset = contentHeight - viewportHeight
local currentPercent = maxOffset > 0 and (self.getResolved("offsetY") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollHeight - handleSize)) + 1
if relY >= handlePos and relY < handlePos + handleSize then
self._scrollBarDragging = true
self._scrollBarDragOffset = relY - handlePos
else
local newPercent = ((relY - 1) / (scrollHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetY", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
if needsHorizontalScrollBar and relY == height and (not needsVerticalScrollBar or relX < width) then
local scrollWidth = viewportWidth
local handleSize = math.max(1, math.floor((viewportWidth / contentWidth) * scrollWidth))
local maxOffset = contentWidth - viewportWidth
local currentPercent = maxOffset > 0 and (self.getResolved("offsetX") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollWidth - handleSize)) + 1
if relX >= handlePos and relX < handlePos + handleSize then
self._hScrollBarDragging = true
self._hScrollBarDragOffset = relX - handlePos
else
local newPercent = ((relX - 1) / (scrollWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetX", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
return true
end
return false
end
--- Handles mouse drag events for scrollbar
--- @shortDescription Handles mouse drag events for scrollbar
--- @param button number The mouse button being dragged
--- @param x number The x-coordinate of the drag
--- @param y number The y-coordinate of the drag
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_drag(button, x, y)
if self._scrollBarDragging then
local _, relY = self:getRelativePosition(x, y)
local height = self.getResolved("height")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local width = self.getResolved("width")
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local scrollHeight = viewportHeight
local handleSize = math.max(1, math.floor((viewportHeight / contentHeight) * scrollHeight))
local maxOffset = contentHeight - viewportHeight
relY = math.max(1, math.min(scrollHeight, relY))
local newPos = relY - (self._scrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (scrollHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetY", math.max(0, math.min(maxOffset, newOffset)))
return true
end
if self._hScrollBarDragging then
local relX, _ = self:getRelativePosition(x, y)
local width = self.getResolved("width")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local height = self.getResolved("height")
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = self.getResolved("showScrollBar") and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
local scrollWidth = viewportWidth
local handleSize = math.max(1, math.floor((viewportWidth / contentWidth) * scrollWidth))
local maxOffset = contentWidth - viewportWidth
relX = math.max(1, math.min(scrollWidth, relX))
local newPos = relX - (self._hScrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (scrollWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offsetX", math.max(0, math.min(maxOffset, newOffset)))
return true
end
return Container.mouse_drag and Container.mouse_drag(self, button, x, y) or false
end
--- Handles mouse up events to stop scrollbar dragging
--- @shortDescription Handles mouse up events to stop scrollbar dragging
--- @param button number The mouse button that was released
--- @param x number The x-coordinate of the release
--- @param y number The y-coordinate of the release
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_up(button, x, y)
if self._scrollBarDragging then
self._scrollBarDragging = false
self._scrollBarDragOffset = nil
return true
end
if self._hScrollBarDragging then
self._hScrollBarDragging = false
self._hScrollBarDragOffset = nil
return true
end
return Container.mouse_up and Container.mouse_up(self, button, x, y) or false
end
--- Handles mouse scroll events
--- @shortDescription Handles mouse scroll events
--- @param direction number 1 for up, -1 for down
--- @param x number Mouse x position relative to element
--- @param y number Mouse y position relative to element
--- @return boolean Whether the event was handled
--- @protected
function ScrollFrame:mouse_scroll(direction, x, y)
if self:isInBounds(x, y) then
local xOffset, yOffset = self.getResolved("offsetX"), self.getResolved("offsetY")
local relX, relY = self:getRelativePosition(x + xOffset, y + yOffset)
local success, child = self:callChildrenEvent(true, "mouse_scroll", direction, relX, relY)
if success then
return true
end
local height = self.getResolved("height")
local width = self.getResolved("width")
local offsetY = self.getResolved("offsetY")
local offsetX = self.getResolved("offsetX")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = self.getResolved("showScrollBar") and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
if needsVerticalScrollBar then
local maxScroll = math.max(0, contentHeight - viewportHeight)
local newScroll = math.min(maxScroll, math.max(0, offsetY + direction))
self.set("offsetY", newScroll)
elseif needsHorizontalScrollBar then
local maxScroll = math.max(0, contentWidth - viewportWidth)
local newScroll = math.min(maxScroll, math.max(0, offsetX + direction))
self.set("offsetX", newScroll)
end
return true
end
return false
end
--- Renders the ScrollFrame and its scrollbars
--- @shortDescription Renders the ScrollFrame and its scrollbars
--- @protected
function ScrollFrame:render()
Container.render(self)
local height = self.getResolved("height")
local width = self.getResolved("width")
local offsetY = self.getResolved("offsetY")
local offsetX = self.getResolved("offsetX")
local showScrollBar = self.getResolved("showScrollBar")
local contentWidth = self.getResolved("contentWidth")
local contentHeight = self.getResolved("contentHeight")
local needsHorizontalScrollBar = showScrollBar and contentWidth > width
local viewportHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and contentHeight > viewportHeight
local viewportWidth = needsVerticalScrollBar and width - 1 or width
if needsVerticalScrollBar then
local scrollHeight = viewportHeight
local handleSize = math.max(1, math.floor((viewportHeight / contentHeight) * scrollHeight))
local maxOffset = contentHeight - viewportHeight
local scrollBarBg = self.getResolved("scrollBarBackgroundSymbol")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
local scrollBarBg2Color = self.getResolved("scrollBarBackgroundColor2")
local currentPercent = maxOffset > 0 and (offsetY / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollHeight - handleSize)) + 1
for i = 1, scrollHeight do
if i >= handlePos and i < handlePos + handleSize then
self:blit(width, i, " ", tHex[scrollBarColor], tHex[scrollBarColor])
else
self:blit(width, i, scrollBarBg, tHex[scrollBarBgColor], tHex[scrollBarBg2Color])
end
end
end
if needsHorizontalScrollBar then
local scrollWidth = viewportWidth
local handleSize = math.max(1, math.floor((viewportWidth / contentWidth) * scrollWidth))
local maxOffset = contentWidth - viewportWidth
local scrollBarBg = self.getResolved("scrollBarBackgroundSymbol")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
local scrollBarBg2Color = self.getResolved("scrollBarBackgroundColor2")
local currentPercent = maxOffset > 0 and (offsetX / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollWidth - handleSize)) + 1
for i = 1, scrollWidth do
if i >= handlePos and i < handlePos + handleSize then
self:blit(i, height, " ", tHex[scrollBarColor], tHex[scrollBarColor])
else
self:blit(i, height, scrollBarBg, tHex[scrollBarBgColor], tHex[scrollBarBg2Color])
end
end
end
if needsVerticalScrollBar and needsHorizontalScrollBar then
local background = self.getResolved("background")
self:blit(width, height, " ", tHex[background], tHex[background])
end
end
return ScrollFrame

View File

@@ -3,8 +3,103 @@ local VisualElement = require("elements/VisualElement")
local Container = elementManager.getElement("Container") local Container = elementManager.getElement("Container")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDescription A SideNav element that provides sidebar navigation with multiple content areas. ---@configDescription A SideNav element that provides sidebar navigation with multiple content areas.
---@configDefault false
--- The SideNav is a container that provides sidebar navigation functionality --- The SideNav is a container that provides sidebar navigation functionality
--- @run [[
--- local basalt = require("basalt")
--- local main = basalt.getMainFrame()
---
--- -- Create a simple SideNav
--- local sideNav = main:addSideNav({
--- x = 1,
--- y = 1,
--- sidebarWidth = 12,
--- width = 48
--- })
---
--- -- Tab 1: Home
--- local homeTab = sideNav:newTab("Home")
---
--- homeTab:addLabel({
--- x = 2,
--- y = 2,
--- text = "Welcome!",
--- foreground = colors.yellow
--- })
---
--- homeTab:addLabel({
--- x = 2,
--- y = 4,
--- text = "This is a simple",
--- foreground = colors.white
--- })
---
--- homeTab:addLabel({
--- x = 2,
--- y = 5,
--- text = "SideNav example.",
--- foreground = colors.white
--- })
---
--- -- Tab 2: Counter
--- local counterTab = sideNav:newTab("Counter")
---
--- local counterLabel = counterTab:addLabel({
--- x = 2,
--- y = 2,
--- text = "Count: 0",
--- foreground = colors.lime
--- })
---
--- local count = 0
--- counterTab:addButton({
--- x = 2,
--- y = 4,
--- width = 12,
--- height = 3,
--- text = "Click Me",
--- background = colors.blue
--- })
--- :setBackgroundState("clicked", colors.lightBlue)
--- :onClick(function()
--- count = count + 1
--- counterLabel:setText("Count: " .. count)
--- end)
---
--- -- Tab 3: Info
--- local infoTab = sideNav:newTab("Info")
---
--- infoTab:addLabel({
--- x = 2,
--- y = 2,
--- text = "SideNav Features:",
--- foreground = colors.orange
--- })
---
--- infoTab:addLabel({
--- x = 2,
--- y = 4,
--- text = "- Multiple tabs",
--- foreground = colors.gray
--- })
---
--- infoTab:addLabel({
--- x = 2,
--- y = 5,
--- text = "- Easy navigation",
--- foreground = colors.gray
--- })
---
--- infoTab:addLabel({
--- x = 2,
--- y = 6,
--- text = "- Content per tab",
--- foreground = colors.gray
--- })
---
--- basalt.run()
--- ]]
---@class SideNav : Container ---@class SideNav : Container
local SideNav = setmetatable({}, Container) local SideNav = setmetatable({}, Container)
SideNav.__index = SideNav SideNav.__index = SideNav
@@ -59,7 +154,7 @@ end
--- @param title string The title of the navigation item --- @param title string The title of the navigation item
--- @return table tabHandler The navigation item handler proxy for adding elements --- @return table tabHandler The navigation item handler proxy for adding elements
function SideNav:newTab(title) function SideNav:newTab(title)
local tabs = self.get("tabs") or {} local tabs = self.getResolved("tabs") or {}
local tabId = #tabs + 1 local tabId = #tabs + 1
table.insert(tabs, { table.insert(tabs, {
@@ -69,7 +164,7 @@ function SideNav:newTab(title)
self.set("tabs", tabs) self.set("tabs", tabs)
if not self.get("activeTab") then if not self.getResolved("activeTab") then
self.set("activeTab", tabId) self.set("activeTab", tabId)
end end
self:updateTabVisibility() self:updateTabVisibility()
@@ -120,7 +215,7 @@ end
--- @return table element The created element --- @return table element The created element
function SideNav:addElement(elementType, tabId) function SideNav:addElement(elementType, tabId)
local element = Container.addElement(self, elementType) local element = Container.addElement(self, elementType)
local targetTab = tabId or self.get("activeTab") local targetTab = tabId or self.getResolved("activeTab")
if targetTab then if targetTab then
element._tabId = targetTab element._tabId = targetTab
self:updateTabVisibility() self:updateTabVisibility()
@@ -135,7 +230,7 @@ end
function SideNav:addChild(child) function SideNav:addChild(child)
Container.addChild(self, child) Container.addChild(self, child)
if not child._tabId then if not child._tabId then
local tabs = self.get("tabs") or {} local tabs = self.getResolved("tabs") or {}
if #tabs > 0 then if #tabs > 0 then
child._tabId = 1 child._tabId = 1
self:updateTabVisibility() self:updateTabVisibility()
@@ -154,7 +249,7 @@ end
--- @shortDescription Sets the active navigation item --- @shortDescription Sets the active navigation item
--- @param tabId number The ID of the navigation item to activate --- @param tabId number The ID of the navigation item to activate
function SideNav:setActiveTab(tabId) function SideNav:setActiveTab(tabId)
local oldTab = self.get("activeTab") local oldTab = self.getResolved("activeTab")
if oldTab == tabId then return self end if oldTab == tabId then return self end
self.set("activeTab", tabId) self.set("activeTab", tabId)
self:updateTabVisibility() self:updateTabVisibility()
@@ -171,7 +266,7 @@ function SideNav:isChildVisible(child)
return false return false
end end
if child._tabId then if child._tabId then
return child._tabId == self.get("activeTab") return child._tabId == self.getResolved("activeTab")
end end
return true return true
end end
@@ -185,11 +280,11 @@ function SideNav:getContentXOffset()
end end
function SideNav:_getSidebarMetrics() function SideNav:_getSidebarMetrics()
local tabs = self.get("tabs") or {} local tabs = self.getResolved("tabs") or {}
local height = self.get("height") or 1 local height = self.getResolved("height") or 1
local sidebarWidth = self.get("sidebarWidth") or 12 local sidebarWidth = self.getResolved("sidebarWidth") or 12
local scrollOffset = self.get("sidebarScrollOffset") or 0 local scrollOffset = self.getResolved("sidebarScrollOffset") or 0
local sidebarPos = self.get("sidebarPosition") or "left" local sidebarPos = self.getResolved("sidebarPosition") or "left"
local positions = {} local positions = {}
local actualY = 1 local actualY = 1
@@ -253,7 +348,7 @@ function SideNav:mouse_click(button, x, y)
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
local inSidebar = false local inSidebar = false
if metrics.sidebarPosition == "right" then if metrics.sidebarPosition == "right" then
@@ -278,7 +373,7 @@ end
function SideNav:getRelativePosition(x, y) function SideNav:getRelativePosition(x, y)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
if x == nil or y == nil then if x == nil or y == nil then
return VisualElement.getRelativePosition(self) return VisualElement.getRelativePosition(self)
@@ -361,7 +456,7 @@ function SideNav:mouse_up(button, x, y)
end end
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
local inSidebar = false local inSidebar = false
if metrics.sidebarPosition == "right" then if metrics.sidebarPosition == "right" then
@@ -380,7 +475,7 @@ function SideNav:mouse_release(button, x, y)
VisualElement.mouse_release(self, button, x, y) VisualElement.mouse_release(self, button, x, y)
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
local inSidebar = false local inSidebar = false
if metrics.sidebarPosition == "right" then if metrics.sidebarPosition == "right" then
@@ -399,7 +494,7 @@ function SideNav:mouse_move(_, x, y)
if VisualElement.mouse_move(self, _, x, y) then if VisualElement.mouse_move(self, _, x, y) then
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
local inSidebar = false local inSidebar = false
if metrics.sidebarPosition == "right" then if metrics.sidebarPosition == "right" then
@@ -424,7 +519,7 @@ function SideNav:mouse_drag(button, x, y)
if VisualElement.mouse_drag(self, button, x, y) then if VisualElement.mouse_drag(self, button, x, y) then
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
local inSidebar = false local inSidebar = false
if metrics.sidebarPosition == "right" then if metrics.sidebarPosition == "right" then
@@ -447,7 +542,7 @@ end
--- @return SideNav self For method chaining --- @return SideNav self For method chaining
function SideNav:scrollSidebar(direction) function SideNav:scrollSidebar(direction)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local currentOffset = self.get("sidebarScrollOffset") or 0 local currentOffset = self.getResolved("sidebarScrollOffset") or 0
local maxScroll = metrics.maxScroll or 0 local maxScroll = metrics.maxScroll or 0
local newOffset = currentOffset + (direction * 2) local newOffset = currentOffset + (direction * 2)
@@ -461,7 +556,7 @@ function SideNav:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then if VisualElement.mouse_scroll(self, direction, x, y) then
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y) local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
local inSidebar = false local inSidebar = false
if metrics.sidebarPosition == "right" then if metrics.sidebarPosition == "right" then
@@ -508,23 +603,25 @@ end
--- @protected --- @protected
function SideNav:render() function SideNav:render()
VisualElement.render(self) VisualElement.render(self)
local height = self.get("height") local height = self.getResolved("height")
local foreground = self.getResolved("foreground")
local sidebarBackground = self.getResolved("sidebarBackground")
local metrics = self:_getSidebarMetrics() local metrics = self:_getSidebarMetrics()
local sidebarW = metrics.sidebarWidth or 12 local sidebarW = metrics.sidebarWidth or 12
for y = 1, height do for y = 1, height do
VisualElement.multiBlit(self, 1, y, sidebarW, 1, " ", tHex[self.get("foreground")], tHex[self.get("sidebarBackground")]) VisualElement.multiBlit(self, 1, y, sidebarW, 1, " ", tHex[foreground], tHex[sidebarBackground])
end end
local activeTab = self.get("activeTab") local activeTab = self.getResolved("activeTab")
for _, pos in ipairs(metrics.positions) do for _, pos in ipairs(metrics.positions) do
local bgColor = (pos.id == activeTab) and self.get("activeTabBackground") or self.get("sidebarBackground") local bgColor = (pos.id == activeTab) and self.getResolved("activeTabBackground") or sidebarBackground
local fgColor = (pos.id == activeTab) and self.get("activeTabTextColor") or self.get("foreground") local fgColor = (pos.id == activeTab) and self.getResolved("activeTabTextColor") or foreground
local itemHeight = pos.displayHeight or (pos.y2 - pos.y1 + 1) local itemHeight = pos.displayHeight or (pos.y2 - pos.y1 + 1)
for dy = 0, itemHeight - 1 do for dy = 0, itemHeight - 1 do
VisualElement.multiBlit(self, 1, pos.y1 + dy, sidebarW, 1, " ", tHex[self.get("foreground")], tHex[bgColor]) VisualElement.multiBlit(self, 1, pos.y1 + dy, sidebarW, 1, " ", tHex[foreground], tHex[bgColor])
end end
local displayTitle = pos.title local displayTitle = pos.title
@@ -535,16 +632,16 @@ function SideNav:render()
VisualElement.textFg(self, 2, pos.y1, displayTitle, fgColor) VisualElement.textFg(self, 2, pos.y1, displayTitle, fgColor)
end end
if not self.get("childrenSorted") then if not self.getResolved("childrenSorted") then
self:sortChildren() self:sortChildren()
end end
if not self.get("childrenEventsSorted") then if not self.getResolved("childrenEventsSorted") then
for eventName in pairs(self._values.childrenEvents or {}) do for eventName in pairs(self._values.childrenEvents or {}) do
self:sortChildrenEvents(eventName) self:sortChildrenEvents(eventName)
end end
end end
for _, child in ipairs(self.get("visibleChildren") or {}) do for _, child in ipairs(self.getResolved("visibleChildren") or {}) do
if child == self then error("CIRCULAR REFERENCE DETECTED!") return end if child == self then error("CIRCULAR REFERENCE DETECTED!") return end
child:render() child:render()
child:postRender() child:postRender()

View File

@@ -1,5 +1,6 @@
local VisualElement = require("elements/VisualElement") local VisualElement = require("elements/VisualElement")
local tHex = require("libraries/colorHex") ---@configDescription A slider control element for selecting a value within a range.
---@configDefault false
--- This is the slider class. It provides a draggable slider control that can be either horizontal or vertical, --- This is the slider class. It provides a draggable slider control that can be either horizontal or vertical,
--- with customizable colors and value ranges. --- with customizable colors and value ranges.
@@ -58,9 +59,9 @@ end
--- @return number value The current value (0 to max) --- @return number value The current value (0 to max)
--- @usage local value = slider:getValue() --- @usage local value = slider:getValue()
function Slider:getValue() function Slider:getValue()
local step = self.get("step") local step = self.getResolved("step")
local max = self.get("max") local max = self.getResolved("max")
local maxSteps = self.get("horizontal") and self.get("width") or self.get("height") local maxSteps = self.getResolved("horizontal") and self.getResolved("width") or self.getResolved("height")
return math.floor((step - 1) * (max / (maxSteps - 1))) return math.floor((step - 1) * (max / (maxSteps - 1)))
end end
@@ -73,8 +74,8 @@ end
function Slider:mouse_click(button, x, y) function Slider:mouse_click(button, x, y)
if self:isInBounds(x, y) then if self:isInBounds(x, y) then
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local pos = self.get("horizontal") and relX or relY local pos = self.getResolved("horizontal") and relX or relY
local maxSteps = self.get("horizontal") and self.get("width") or self.get("height") local maxSteps = self.getResolved("horizontal") and self.getResolved("width") or self.getResolved("height")
self.set("step", math.min(maxSteps, math.max(1, pos))) self.set("step", math.min(maxSteps, math.max(1, pos)))
self:updateRender() self:updateRender()
@@ -92,8 +93,8 @@ Slider.mouse_drag = Slider.mouse_click
--- @protected --- @protected
function Slider:mouse_scroll(direction, x, y) function Slider:mouse_scroll(direction, x, y)
if self:isInBounds(x, y) then if self:isInBounds(x, y) then
local step = self.get("step") local step = self.getResolved("step")
local maxSteps = self.get("horizontal") and self.get("width") or self.get("height") local maxSteps = self.getResolved("horizontal") and self.getResolved("width") or self.getResolved("height")
self.set("step", math.min(maxSteps, math.max(1, step + direction))) self.set("step", math.min(maxSteps, math.max(1, step + direction)))
self:updateRender() self:updateRender()
return true return true
@@ -105,23 +106,23 @@ end
--- @protected --- @protected
function Slider:render() function Slider:render()
VisualElement.render(self) VisualElement.render(self)
local width = self.get("width") local width = self.getResolved("width")
local height = self.get("height") local height = self.getResolved("height")
local horizontal = self.get("horizontal") local horizontal = self.getResolved("horizontal")
local step = self.get("step") local step = self.getResolved("step")
local barChar = horizontal and "\140" or " " local barChar = horizontal and "\140" or " "
local text = string.rep(barChar, horizontal and width or height) local text = string.rep(barChar, horizontal and width or height)
if horizontal then if horizontal then
self:textFg(1, 1, text, self.get("barColor")) self:textFg(1, 1, text, self.getResolved("barColor"))
self:textBg(step, 1, " ", self.get("sliderColor")) self:textBg(step, 1, " ", self.getResolved("sliderColor"))
else else
local bg = self.get("background") local bg = self.getResolved("background")
for y = 1, height do for y = 1, height do
self:textBg(1, y, " ", bg) self:textBg(1, y, " ", bg)
end end
self:textBg(1, step, " ", self.get("sliderColor")) self:textBg(1, step, " ", self.getResolved("sliderColor"))
end end
end end

View File

@@ -2,6 +2,7 @@ local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement") local VisualElement = elementManager.getElement("VisualElement")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDescription The Switch is a standard Switch element with click handling and state management. ---@configDescription The Switch is a standard Switch element with click handling and state management.
---@configDefault false
--- The Switch is a standard Switch element with click handling and state management. --- The Switch is a standard Switch element with click handling and state management.
---@class Switch : VisualElement ---@class Switch : VisualElement
@@ -52,7 +53,7 @@ end
--- @protected --- @protected
function Switch:mouse_click(button, x, y) function Switch:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then if VisualElement.mouse_click(self, button, x, y) then
self.set("checked", not self.get("checked")) self.set("checked", not self.getResolved("checked"))
return true return true
end end
return false return false
@@ -61,20 +62,21 @@ end
--- @shortDescription Renders the Switch --- @shortDescription Renders the Switch
--- @protected --- @protected
function Switch:render() function Switch:render()
local checked = self.get("checked") local checked = self.getResolved("checked")
local text = self.get("text") local text = self.getResolved("text")
local switchWidth = self.get("width") local switchWidth = self.getResolved("width")
local switchHeight = self.get("height") local switchHeight = self.getResolved("height")
local foreground = self.getResolved("foreground")
local bgColor = checked and self.get("onBackground") or self.get("offBackground") local bgColor = checked and self.getResolved("onBackground") or self.getResolved("offBackground")
self:multiBlit(1, 1, switchWidth, switchHeight, " ", tHex[self.get("foreground")], tHex[bgColor]) self:multiBlit(1, 1, switchWidth, switchHeight, " ", tHex[foreground], tHex[bgColor])
local sliderSize = math.floor(switchWidth / 2) local sliderSize = math.floor(switchWidth / 2)
local sliderStart = checked and (switchWidth - sliderSize + 1) or 1 local sliderStart = checked and (switchWidth - sliderSize + 1) or 1
self:multiBlit(sliderStart, 1, sliderSize, switchHeight, " ", tHex[self.get("foreground")], tHex[self.get("background")]) self:multiBlit(sliderStart, 1, sliderSize, switchHeight, " ", tHex[foreground], tHex[self.getResolved("background")])
if text ~= "" then if text ~= "" then
self:textFg(switchWidth + 2, 1, text, self.get("foreground")) self:textFg(switchWidth + 2, 1, text, foreground)
end end
end end

View File

@@ -2,10 +2,105 @@ local elementManager = require("elementManager")
local VisualElement = require("elements/VisualElement") local VisualElement = require("elements/VisualElement")
local Container = elementManager.getElement("Container") local Container = elementManager.getElement("Container")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
local log = require("log")
---@configDescription A TabControl element that provides tabbed interface with multiple content areas. ---@configDescription A TabControl element that provides tabbed interface with multiple content areas.
---@configDefault false
--- The TabControl is a container that provides tabbed interface functionality --- The TabControl is a container that provides tabbed interface functionality
--- @run [[
--- local basalt = require("basalt")
---
--- local main = basalt.getMainFrame()
---
--- -- Create a simple TabControl
--- local tabControl = main:addTabControl({
--- x = 2,
--- y = 2,
--- width = 46,
--- height = 15,
--- })
---
--- -- Tab 1: Home
--- local homeTab = tabControl:newTab("Home")
---
--- homeTab:addLabel({
--- x = 2,
--- y = 2,
--- text = "Welcome!",
--- foreground = colors.yellow
--- })
---
--- homeTab:addLabel({
--- x = 2,
--- y = 4,
--- text = "This is a TabControl",
--- foreground = colors.white
--- })
---
--- homeTab:addLabel({
--- x = 2,
--- y = 5,
--- text = "example with tabs.",
--- foreground = colors.white
--- })
---
--- -- Tab 2: Counter
--- local counterTab = tabControl:newTab("Counter")
---
--- local counterLabel = counterTab:addLabel({
--- x = 2,
--- y = 2,
--- text = "Count: 0",
--- foreground = colors.lime
--- })
---
--- local count = 0
--- counterTab:addButton({
--- x = 2,
--- y = 4,
--- width = 12,
--- height = 3,
--- text = "Click Me",
--- background = colors.blue
--- })
--- :setBackgroundState("clicked", colors.lightBlue)
--- :onClick(function()
--- count = count + 1
--- counterLabel:setText("Count: " .. count)
--- end)
---
--- -- Tab 3: Info
--- local infoTab = tabControl:newTab("Info")
---
--- infoTab:addLabel({
--- x = 2,
--- y = 2,
--- text = "TabControl Features:",
--- foreground = colors.orange
--- })
---
--- infoTab:addLabel({
--- x = 2,
--- y = 4,
--- text = "- Horizontal tabs",
--- foreground = colors.gray
--- })
---
--- infoTab:addLabel({
--- x = 2,
--- y = 5,
--- text = "- Easy navigation",
--- foreground = colors.gray
--- })
---
--- infoTab:addLabel({
--- x = 2,
--- y = 6,
--- text = "- Content per tab",
--- foreground = colors.gray
--- })
---
--- basalt.run()
--- ]]
---@class TabControl : Container ---@class TabControl : Container
local TabControl = setmetatable({}, Container) local TabControl = setmetatable({}, Container)
TabControl.__index = TabControl TabControl.__index = TabControl
@@ -60,7 +155,7 @@ end
--- @param title string The title of the tab --- @param title string The title of the tab
--- @return table tabHandler The tab handler proxy for adding elements to the new tab --- @return table tabHandler The tab handler proxy for adding elements to the new tab
function TabControl:newTab(title) function TabControl:newTab(title)
local tabs = self.get("tabs") or {} local tabs = self.getResolved("tabs") or {}
local tabId = #tabs + 1 local tabId = #tabs + 1
table.insert(tabs, { table.insert(tabs, {
@@ -70,7 +165,7 @@ function TabControl:newTab(title)
self.set("tabs", tabs) self.set("tabs", tabs)
if not self.get("activeTab") then if not self.getResolved("activeTab") then
self.set("activeTab", tabId) self.set("activeTab", tabId)
end end
self:updateTabVisibility() self:updateTabVisibility()
@@ -121,7 +216,7 @@ end
--- @return table element The created element --- @return table element The created element
function TabControl:addElement(elementType, tabId) function TabControl:addElement(elementType, tabId)
local element = Container.addElement(self, elementType) local element = Container.addElement(self, elementType)
local targetTab = tabId or self.get("activeTab") local targetTab = tabId or self.getResolved("activeTab")
if targetTab then if targetTab then
element._tabId = targetTab element._tabId = targetTab
self:updateTabVisibility() self:updateTabVisibility()
@@ -136,7 +231,7 @@ end
function TabControl:addChild(child) function TabControl:addChild(child)
Container.addChild(self, child) Container.addChild(self, child)
if not child._tabId then if not child._tabId then
local tabs = self.get("tabs") or {} local tabs = self.getResolved("tabs") or {}
if #tabs > 0 then if #tabs > 0 then
child._tabId = 1 child._tabId = 1
self:updateTabVisibility() self:updateTabVisibility()
@@ -155,7 +250,7 @@ end
--- @shortDescription Sets the active tab --- @shortDescription Sets the active tab
--- @param tabId number The ID of the tab to activate --- @param tabId number The ID of the tab to activate
function TabControl:setActiveTab(tabId) function TabControl:setActiveTab(tabId)
local oldTab = self.get("activeTab") local oldTab = self.getResolved("activeTab")
if oldTab == tabId then return self end if oldTab == tabId then return self end
self.set("activeTab", tabId) self.set("activeTab", tabId)
self:updateTabVisibility() self:updateTabVisibility()
@@ -172,7 +267,7 @@ function TabControl:isChildVisible(child)
return false return false
end end
if child._tabId then if child._tabId then
return child._tabId == self.get("activeTab") return child._tabId == self.getResolved("activeTab")
end end
return true return true
end end
@@ -186,15 +281,15 @@ function TabControl:getContentYOffset()
end end
function TabControl:_getHeaderMetrics() function TabControl:_getHeaderMetrics()
local tabs = self.get("tabs") or {} local tabs = self.getResolved("tabs") or {}
local width = self.get("width") or 1 local width = self.getResolved("width") or 1
local minTabH = self.get("tabHeight") or 1 local minTabH = self.getResolved("tabHeight") or 1
local scrollable = self.get("scrollableTab") local scrollable = self.getResolved("scrollableTab")
local positions = {} local positions = {}
if scrollable then if scrollable then
local scrollOffset = self.get("tabScrollOffset") or 0 local scrollOffset = self.getResolved("tabScrollOffset") or 0
local actualX = 1 local actualX = 1
local totalWidth = 0 local totalWidth = 0
@@ -405,10 +500,10 @@ end
--- @param direction number -1 to scroll left, 1 to scroll right --- @param direction number -1 to scroll left, 1 to scroll right
--- @return TabControl self For method chaining --- @return TabControl self For method chaining
function TabControl:scrollTabs(direction) function TabControl:scrollTabs(direction)
if not self.get("scrollableTab") then return self end if not self.getResolved("scrollableTab") then return self end
local metrics = self:_getHeaderMetrics() local metrics = self:_getHeaderMetrics()
local currentOffset = self.get("tabScrollOffset") or 0 local currentOffset = self.getResolved("tabScrollOffset") or 0
local maxScroll = metrics.maxScroll or 0 local maxScroll = metrics.maxScroll or 0
local newOffset = currentOffset + (direction * 5) local newOffset = currentOffset + (direction * 5)
@@ -422,7 +517,7 @@ function TabControl:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then if VisualElement.mouse_scroll(self, direction, x, y) then
local headerH = self:_getHeaderMetrics().headerHeight local headerH = self:_getHeaderMetrics().headerHeight
if self.get("scrollableTab") and y == self.get("y") then if self.getResolved("scrollableTab") and y == self.getResolved("y") then
self:scrollTabs(direction) self:scrollTabs(direction)
return true return true
end end
@@ -454,18 +549,20 @@ end
--- @protected --- @protected
function TabControl:render() function TabControl:render()
VisualElement.render(self) VisualElement.render(self)
local width = self.get("width") local width = self.getResolved("width")
local foreground = self.getResolved("foreground")
local headerBackground = self.getResolved("headerBackground")
local metrics = self:_getHeaderMetrics() local metrics = self:_getHeaderMetrics()
local headerH = metrics.headerHeight or 1 local headerH = metrics.headerHeight or 1
VisualElement.multiBlit(self, 1, 1, width, headerH, " ", tHex[self.get("foreground")], tHex[self.get("headerBackground")]) VisualElement.multiBlit(self, 1, 1, width, headerH, " ", tHex[foreground], tHex[headerBackground])
local activeTab = self.get("activeTab") local activeTab = self.getResolved("activeTab")
for _, pos in ipairs(metrics.positions) do for _, pos in ipairs(metrics.positions) do
local bgColor = (pos.id == activeTab) and self.get("activeTabBackground") or self.get("headerBackground") local bgColor = (pos.id == activeTab) and self.getResolved("activeTabBackground") or headerBackground
local fgColor = (pos.id == activeTab) and self.get("activeTabTextColor") or self.get("foreground") local fgColor = (pos.id == activeTab) and self.getResolved("activeTabTextColor") or foreground
VisualElement.multiBlit(self, pos.x1, pos.line, pos.displayWidth or (pos.x2 - pos.x1 + 1), 1, " ", tHex[self.get("foreground")], tHex[bgColor]) VisualElement.multiBlit(self, pos.x1, pos.line, pos.displayWidth or (pos.x2 - pos.x1 + 1), 1, " ", tHex[foreground], tHex[bgColor])
local displayTitle = pos.title local displayTitle = pos.title
local textStartInTitle = 1 + (pos.startClip or 0) local textStartInTitle = 1 + (pos.startClip or 0)
@@ -481,16 +578,16 @@ function TabControl:render()
end end
end end
if not self.get("childrenSorted") then if not self.getResolved("childrenSorted") then
self:sortChildren() self:sortChildren()
end end
if not self.get("childrenEventsSorted") then if not self.getResolved("childrenEventsSorted") then
for eventName in pairs(self._values.childrenEvents or {}) do for eventName in pairs(self._values.childrenEvents or {}) do
self:sortChildrenEvents(eventName) self:sortChildrenEvents(eventName)
end end
end end
for _, child in ipairs(self.get("visibleChildren") or {}) do for _, child in ipairs(self.getResolved("visibleChildren") or {}) do
if child == self then error("CIRCULAR REFERENCE DETECTED!") return end if child == self then error("CIRCULAR REFERENCE DETECTED!") return end
child:render() child:render()
child:postRender() child:postRender()

View File

@@ -1,13 +1,40 @@
local VisualElement = require("elements/VisualElement") local Collection = require("elements/Collection")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDescription The Table is a sortable data grid with customizable columns, row selection, and scrolling capabilities.
---@configDefault false
--- This is the table class. It provides a sortable data grid with customizable columns, --- This is the table class. It provides a sortable data grid with customizable columns, row selection, and scrolling capabilities. Built on Collection for consistent item management.
--- row selection, and scrolling capabilities. --- @usage [[
--- @usage local people = container:addTable():setWidth(40) --- local peopleTable = main:addTable()
--- @usage people:setColumns({{name="Name",width=12}, {name="Age",width=10}, {name="Country",width=15}}) --- :setPosition(1, 2)
--- @usage people:setData({{"Alice", 30, "USA"}, {"Bob", 25, "UK"}}) --- :setSize(49, 10)
---@class Table : VisualElement --- :setColumns({
local Table = setmetatable({}, VisualElement) --- {name = "Name", width = 15},
--- {name = "Age", width = 8},
--- {name = "Country", width = 12},
--- {name = "Score", width = 10}
--- })
--- :setBackground(colors.black)
--- :setForeground(colors.white)
---
--- peopleTable:addRow("Alice", 30, "USA", 95)
--- peopleTable:addRow("Bob", 25, "UK", 87)
--- peopleTable:addRow("Charlie", 35, "Germany", 92)
--- peopleTable:addRow("Diana", 28, "France", 88)
--- peopleTable:addRow("Eve", 32, "Spain", 90)
--- peopleTable:addRow("Frank", 27, "Italy", 85)
--- peopleTable:addRow("Grace", 29, "Canada", 93)
--- peopleTable:addRow("Heidi", 31, "Australia", 89)
--- peopleTable:addRow("Ivan", 26, "Russia", 91)
--- peopleTable:addRow("Judy", 33, "Brazil", 86)
--- peopleTable:addRow("Karl", 34, "Sweden", 84)
--- peopleTable:addRow("Laura", 24, "Norway", 82)
--- peopleTable:addRow("Mallory", 36, "Netherlands", 83)
--- peopleTable:addRow("Niaj", 23, "Switzerland", 81)
--- peopleTable:addRow("Olivia", 38, "Denmark", 80)
--- ]]
---@class Table : Collection
local Table = setmetatable({}, Collection)
Table.__index = Table Table.__index = Table
---@property columns table {} List of column definitions with {name, width} properties ---@property columns table {} List of column definitions with {name, width} properties
@@ -27,34 +54,55 @@ Table.defineProperty(Table, "columns", {default = {}, type = "table", canTrigger
end end
return t return t
end}) end})
---@property data table {} The table data as array of row arrays
Table.defineProperty(Table, "data", {default = {}, type = "table", canTriggerRender = true, setter=function(self, value)
self.set("scrollOffset", 0)
self.set("selectedRow", nil)
self.set("sortColumn", nil)
self.set("sortDirection", "asc")
return value
end})
---@property selectedRow number? nil Currently selected row index
Table.defineProperty(Table, "selectedRow", {default = nil, type = "number", canTriggerRender = true})
---@property headerColor color blue Color of the column headers ---@property headerColor color blue Color of the column headers
Table.defineProperty(Table, "headerColor", {default = colors.blue, type = "color"}) Table.defineProperty(Table, "headerColor", {default = colors.blue, type = "color"})
---@property selectedColor color lightBlue Background color of selected row
Table.defineProperty(Table, "selectedColor", {default = colors.lightBlue, type = "color"})
---@property gridColor color gray Color of grid lines ---@property gridColor color gray Color of grid lines
Table.defineProperty(Table, "gridColor", {default = colors.gray, type = "color"}) Table.defineProperty(Table, "gridColor", {default = colors.gray, type = "color"})
---@property sortColumn number? nil Currently sorted column index ---@property sortColumn number? nil Currently sorted column index
Table.defineProperty(Table, "sortColumn", {default = nil, type = "number", canTriggerRender = true}) Table.defineProperty(Table, "sortColumn", {default = nil, type = "number", canTriggerRender = true})
---@property sortDirection string "asc" Sort direction ("asc" or "desc") ---@property sortDirection string "asc" Sort direction ("asc" or "desc")
Table.defineProperty(Table, "sortDirection", {default = "asc", type = "string", canTriggerRender = true}) Table.defineProperty(Table, "sortDirection", {default = "asc", type = "string", canTriggerRender = true})
---@property scrollOffset number 0 Current scroll position
Table.defineProperty(Table, "scrollOffset", {default = 0, type = "number", canTriggerRender = true})
---@property customSortFunction table {} Custom sort functions for columns ---@property customSortFunction table {} Custom sort functions for columns
Table.defineProperty(Table, "customSortFunction", {default = {}, type = "table"}) Table.defineProperty(Table, "customSortFunction", {default = {}, type = "table"})
---@property offset number 0 Scroll offset for vertical scrolling
Table.defineProperty(Table, "offset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
local maxOffset = math.max(0, #self.getResolved("items") - (self.getResolved("height") - 1))
return math.min(maxOffset, math.max(0, value))
end
})
---@property showScrollBar boolean true Whether to show the scrollbar when items exceed height
Table.defineProperty(Table, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true})
---@property scrollBarSymbol string " " Symbol used for the scrollbar handle
Table.defineProperty(Table, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true})
---@property scrollBarBackground string "\127" Symbol used for the scrollbar background
Table.defineProperty(Table, "scrollBarBackground", {default = "\127", type = "string", canTriggerRender = true})
---@property scrollBarColor color lightGray Color of the scrollbar handle
Table.defineProperty(Table, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor color gray Background color of the scrollbar
Table.defineProperty(Table, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true})
---@event onRowSelect {rowIndex number, row table} Fired when a row is selected
Table.defineEvent(Table, "mouse_click") Table.defineEvent(Table, "mouse_click")
Table.defineEvent(Table, "mouse_drag")
Table.defineEvent(Table, "mouse_up")
Table.defineEvent(Table, "mouse_scroll") Table.defineEvent(Table, "mouse_scroll")
local entrySchema = {
cells = { type = "table", default = {} },
_sortValues = { type = "table", default = {} },
selected = { type = "boolean", default = false },
text = { type = "string", default = "" }
}
--- Creates a new Table instance --- Creates a new Table instance
--- @shortDescription Creates a new Table instance --- @shortDescription Creates a new Table instance
--- @return Table self The newly created Table instance --- @return Table self The newly created Table instance
@@ -74,96 +122,165 @@ end
--- @return Table self The initialized instance --- @return Table self The initialized instance
--- @protected --- @protected
function Table:init(props, basalt) function Table:init(props, basalt)
VisualElement.init(self, props, basalt) Collection.init(self, props, basalt)
self._entrySchema = entrySchema
self.set("type", "Table") self.set("type", "Table")
self:observe("sortColumn", function()
if self.getResolved("sortColumn") then
self:sortByColumn(self.getResolved("sortColumn"))
end
end)
return self
end
--- Adds a new row to the table
--- @shortDescription Adds a new row with cell values
--- @param ... any The cell values for the new row
--- @return Table self The Table instance
--- @usage table:addRow("Alice", 30, "USA")
function Table:addRow(...)
local cells = {...}
Collection.addItem(self, {
cells = cells,
_sortValues = cells, -- Store original values for sorting
text = table.concat(cells, " ") -- For compatibility if needed
})
return self
end
--- Removes a row by index
--- @shortDescription Removes a row at the specified index
--- @param rowIndex number The index of the row to remove
--- @return Table self The Table instance
function Table:removeRow(rowIndex)
local items = self.getResolved("items")
if items[rowIndex] then
table.remove(items, rowIndex)
self.set("items", items)
end
return self
end
--- Gets a row by index
--- @shortDescription Gets the row data at the specified index
--- @param rowIndex number The index of the row
--- @return table? row The row data or nil
function Table:getRow(rowIndex)
local items = self.getResolved("items")
return items[rowIndex]
end
--- Updates a specific cell value
--- @shortDescription Updates a cell value at row and column
--- @param rowIndex number The row index
--- @param colIndex number The column index
--- @param value any The new value
--- @return Table self The Table instance
function Table:updateCell(rowIndex, colIndex, value)
local items = self.getResolved("items")
if items[rowIndex] and items[rowIndex].cells then
items[rowIndex].cells[colIndex] = value
self.set("items", items)
end
return self
end
--- Gets the currently selected row
--- @shortDescription Gets the currently selected row data
--- @return table? row The selected row or nil
function Table:getSelectedRow()
local items = self.getResolved("items")
for _, item in ipairs(items) do
local isSelected = item._data and item._data.selected or item.selected
if isSelected then
return item
end
end
return nil
end
--- Clears all table data
--- @shortDescription Removes all rows from the table
--- @return Table self The Table instance
function Table:clearData()
self.set("items", {})
return self return self
end end
--- Adds a new column to the table --- Adds a new column to the table
--- @shortDescription Adds a new column to the table --- @shortDescription Adds a new column to the table
--- @param name string The name of the column --- @param name string The name of the column
--- @param width number The width of the column --- @param width number|string The width of the column (number, "auto", or "30%")
--- @return Table self The Table instance --- @return Table self The Table instance
function Table:addColumn(name, width) function Table:addColumn(name, width)
local columns = self.get("columns") local columns = self.getResolved("columns")
table.insert(columns, {name = name, width = width}) table.insert(columns, {name = name, width = width})
self.set("columns", columns) self.set("columns", columns)
return self return self
end end
--- Adds a new row of data to the table
--- @shortDescription Adds a new row of data to the table
--- @param ... any The data for the new row
--- @return Table self The Table instance
function Table:addData(...)
local data = self.get("data")
table.insert(data, {...})
self.set("data", data)
return self
end
--- Sets a custom sort function for a specific column --- Sets a custom sort function for a specific column
--- @shortDescription Sets a custom sort function for a column --- @shortDescription Sets a custom sort function for a column
--- @param columnIndex number The index of the column --- @param columnIndex number The index of the column
--- @param sortFn function Function that takes (rowA, rowB) and returns comparison result --- @param sortFn function Function that takes (rowA, rowB) and returns comparison result
--- @return Table self The Table instance --- @return Table self The Table instance
function Table:setColumnSortFunction(columnIndex, sortFn) function Table:setColumnSortFunction(columnIndex, sortFn)
local customSorts = self.get("customSortFunction") local customSorts = self.getResolved("customSortFunction")
customSorts[columnIndex] = sortFn customSorts[columnIndex] = sortFn
self.set("customSortFunction", customSorts) self.set("customSortFunction", customSorts)
return self return self
end end
--- Adds data with both display and sort values --- Set data with automatic formatting
--- @shortDescription Adds formatted data with raw sort values --- @shortDescription Sets table data with optional column formatters
--- @param displayData table The formatted data for display --- @param rawData table The raw data array (array of row arrays)
--- @param sortData table The raw data for sorting (optional) --- @param formatters table? Optional formatter functions for columns {[2] = function(value) return value end}
--- @return Table self The Table instance --- @return Table self The Table instance
function Table:setFormattedData(displayData, sortData) --- @usage table:setData({{...}}, {[1] = tostring, [2] = function(age) return age.."y" end})
local enrichedData = {} function Table:setData(rawData, formatters)
self:clearData()
for i, row in ipairs(displayData) do for _, row in ipairs(rawData) do
local enrichedRow = {} local cells = {}
for j, cell in ipairs(row) do local sortValues = {}
enrichedRow[j] = cell
for j, cellValue in ipairs(row) do
sortValues[j] = cellValue
if formatters and formatters[j] then
cells[j] = formatters[j](cellValue)
else
cells[j] = cellValue
end
end end
if sortData and sortData[i] then Collection.addItem(self, {
enrichedRow._sortValues = sortData[i] cells = cells,
end _sortValues = sortValues,
text = table.concat(cells, " ")
table.insert(enrichedData, enrichedRow) })
end end
self.set("data", enrichedData)
return self return self
end end
--- Set data with automatic formatting --- Gets all table data
--- @shortDescription Sets table data with optional column formatters --- @shortDescription Gets all rows as array of cell arrays
--- @param rawData table The raw data array --- @return table data Array of row cell arrays
--- @param formatters table Optional formatter functions for columns {[2] = function(value) return value end} function Table:getData()
--- @return Table self The Table instance local items = self.getResolved("items")
function Table:setData(rawData, formatters) local data = {}
if not formatters then
self.set("data", rawData)
return self
end
local formattedData = {} for _, item in ipairs(items) do
for i, row in ipairs(rawData) do local cells = item._data and item._data.cells or item.cells
local formattedRow = {} if cells then
for j, cell in ipairs(row) do table.insert(data, cells)
if formatters[j] then
formattedRow[j] = formatters[j](cell)
else
formattedRow[j] = cell
end
end end
table.insert(formattedData, formattedRow)
end end
return self:setFormattedData(formattedData, rawData) return data
end end
--- @shortDescription Calculates column widths for rendering --- @shortDescription Calculates column widths for rendering
@@ -241,33 +358,38 @@ end
--- @param columnIndex number The index of the column to sort by --- @param columnIndex number The index of the column to sort by
--- @param fn function? Optional custom sorting function --- @param fn function? Optional custom sorting function
--- @return Table self The Table instance --- @return Table self The Table instance
function Table:sortData(columnIndex, fn) function Table:sortByColumn(columnIndex, fn)
local data = self.get("data") local items = self.getResolved("items")
local direction = self.get("sortDirection") local direction = self.getResolved("sortDirection")
local customSorts = self.get("customSortFunction") local customSorts = self.getResolved("customSortFunction")
local sortFn = fn or customSorts[columnIndex] local sortFn = fn or customSorts[columnIndex]
if sortFn then if sortFn then
table.sort(data, function(a, b) table.sort(items, function(a, b)
return sortFn(a, b, direction) return sortFn(a, b, direction)
end) end)
else else
table.sort(data, function(a, b) table.sort(items, function(a, b)
if not a or not b then return false end local aCells = a._data and a._data.cells or a.cells
local bCells = b._data and b._data.cells or b.cells
local aSortValues = a._data and a._data._sortValues or a._sortValues
local bSortValues = b._data and b._data._sortValues or b._sortValues
if not a or not b or not aCells or not bCells then return false end
local valueA, valueB local valueA, valueB
if a._sortValues and a._sortValues[columnIndex] then if aSortValues and aSortValues[columnIndex] then
valueA = a._sortValues[columnIndex] valueA = aSortValues[columnIndex]
else else
valueA = a[columnIndex] valueA = aCells[columnIndex]
end end
if b._sortValues and b._sortValues[columnIndex] then if bSortValues and bSortValues[columnIndex] then
valueB = b._sortValues[columnIndex] valueB = bSortValues[columnIndex]
else else
valueB = b[columnIndex] valueB = bCells[columnIndex]
end end
if type(valueA) == "number" and type(valueB) == "number" then if type(valueA) == "number" and type(valueB) == "number" then
@@ -287,67 +409,154 @@ function Table:sortData(columnIndex, fn)
end end
end) end)
end end
self.set("items", items)
return self
end
--- Registers callback for row selection
--- @shortDescription Registers a callback when a row is selected
--- @param callback function The callback function(rowIndex, row)
--- @return Table self The Table instance
function Table:onRowSelect(callback)
self:registerCallback("rowSelect", callback)
return self return self
end end
--- @shortDescription Handles header clicks for sorting and row selection --- @shortDescription Handles header clicks for sorting and row selection
--- @param button number The button that was clicked
--- @param x number The x position of the click
--- @param y number The y position of the click
--- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Table:mouse_click(button, x, y) function Table:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end if not Collection.mouse_click(self, button, x, y) then return false end
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local width = self.getResolved("width")
local height = self.getResolved("height")
local items = self.getResolved("items")
local showScrollBar = self.getResolved("showScrollBar")
local visibleRows = height - 1
if showScrollBar and #items > visibleRows and relX == width and relY > 1 then
local scrollBarHeight = height - 1
local maxOffset = #items - visibleRows
local handleSize = math.max(1, math.floor((visibleRows / #items) * scrollBarHeight))
local currentPercent = maxOffset > 0 and (self.getResolved("offset") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollBarHeight - handleSize)) + 1
local scrollBarRelY = relY - 1
if scrollBarRelY >= handlePos and scrollBarRelY < handlePos + handleSize then
self._scrollBarDragging = true
self._scrollBarDragOffset = scrollBarRelY - handlePos
else
local newPercent = ((scrollBarRelY - 1) / (scrollBarHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
if relY == 1 then if relY == 1 then
local columns = self.get("columns") local columns = self.getResolved("columns")
local width = self.get("width")
local calculatedColumns = self:calculateColumnWidths(columns, width) local calculatedColumns = self:calculateColumnWidths(columns, width)
local currentX = 1 local currentX = 1
for i, col in ipairs(calculatedColumns) do for i, col in ipairs(calculatedColumns) do
local colWidth = col.visibleWidth or col.width or 10 local colWidth = col.visibleWidth or col.width or 10
if relX >= currentX and relX < currentX + colWidth then if relX >= currentX and relX < currentX + colWidth then
if self.get("sortColumn") == i then if self.getResolved("sortColumn") == i then
self.set("sortDirection", self.get("sortDirection") == "asc" and "desc" or "asc") self.set("sortDirection", self.getResolved("sortDirection") == "asc" and "desc" or "asc")
else else
self.set("sortColumn", i) self.set("sortColumn", i)
self.set("sortDirection", "asc") self.set("sortDirection", "asc")
end end
self:sortData(i) self:sortByColumn(i)
break self:updateRender()
return true
end end
currentX = currentX + colWidth currentX = currentX + colWidth
end end
return true
end end
if relY > 1 then if relY > 1 then
local rowIndex = relY - 2 + self.get("scrollOffset") local rowIndex = relY - 2 + self.getResolved("offset")
if rowIndex >= 0 and rowIndex < #self.get("data") then
self.set("selectedRow", rowIndex + 1) if rowIndex >= 0 and rowIndex < #items then
local actualIndex = rowIndex + 1
for _, item in ipairs(items) do
if item._data then
item._data.selected = false
else
item.selected = false
end
end
if items[actualIndex] then
if items[actualIndex]._data then
items[actualIndex]._data.selected = true
else
items[actualIndex].selected = true
end
self:fireEvent("rowSelect", actualIndex, items[actualIndex])
self:updateRender()
end
end end
return true
end end
return true return true
end end
--- @shortDescription Handles mouse drag events for scrollbar
--- @protected
function Table:mouse_drag(button, x, y)
if self._scrollBarDragging then
local _, relY = self:getRelativePosition(x, y)
local items = self.getResolved("items")
local height = self.getResolved("height")
local visibleRows = height - 1
local scrollBarHeight = height - 1
local handleSize = math.max(1, math.floor((visibleRows / #items) * scrollBarHeight))
local maxOffset = #items - visibleRows
local scrollBarRelY = relY - 1
scrollBarRelY = math.max(1, math.min(scrollBarHeight, scrollBarRelY))
local newPos = scrollBarRelY - (self._scrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (scrollBarHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
return true
end
return Collection.mouse_drag and Collection.mouse_drag(self, button, x, y) or false
end
--- @shortDescription Handles mouse up events to stop scrollbar dragging
--- @protected
function Table:mouse_up(button, x, y)
if self._scrollBarDragging then
self._scrollBarDragging = false
self._scrollBarDragOffset = nil
return true
end
return Collection.mouse_up and Collection.mouse_up(self, button, x, y) or false
end
--- @shortDescription Handles scrolling through the table data --- @shortDescription Handles scrolling through the table data
--- @param direction number The scroll direction (-1 up, 1 down)
--- @param x number The x position of the scroll
--- @param y number The y position of the scroll
--- @return boolean handled Whether the event was handled
--- @protected --- @protected
function Table:mouse_scroll(direction, x, y) function Table:mouse_scroll(direction, x, y)
if(VisualElement.mouse_scroll(self, direction, x, y))then if Collection.mouse_scroll(self, direction, x, y) then
local data = self.get("data") local items = self.getResolved("items")
local height = self.get("height") local height = self.getResolved("height")
local visibleRows = height - 2 local visibleRows = height - 1 -- Subtract header
local maxScroll = math.max(0, #data - visibleRows - 1) local maxOffset = math.max(0, #items - visibleRows)
local newOffset = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction)) local newOffset = math.min(maxOffset, math.max(0, self.getResolved("offset") + direction))
self.set("scrollOffset", newOffset) self.set("offset", newOffset)
self:updateRender()
return true return true
end end
return false return false
@@ -356,21 +565,27 @@ end
--- @shortDescription Renders the table with headers, data and scrollbar --- @shortDescription Renders the table with headers, data and scrollbar
--- @protected --- @protected
function Table:render() function Table:render()
VisualElement.render(self) Collection.render(self)
local columns = self.get("columns") local columns = self.getResolved("columns")
local data = self.get("data") local items = self.getResolved("items")
local selected = self.get("selectedRow") local sortCol = self.getResolved("sortColumn")
local sortCol = self.get("sortColumn") local offset = self.getResolved("offset")
local scrollOffset = self.get("scrollOffset") local height = self.getResolved("height")
local height = self.get("height") local width = self.getResolved("width")
local width = self.get("width") local showScrollBar = self.getResolved("showScrollBar")
local background = self.getResolved("background")
local foreground = self.getResolved("foreground")
local visibleRows = height - 1
local calculatedColumns = self:calculateColumnWidths(columns, width) local needsScrollBar = showScrollBar and #items > visibleRows
local contentWidth = needsScrollBar and width - 1 or width
local calculatedColumns = self:calculateColumnWidths(columns, contentWidth)
local totalWidth = 0 local totalWidth = 0
local lastVisibleColumn = #calculatedColumns local lastVisibleColumn = #calculatedColumns
for i, col in ipairs(calculatedColumns) do for i, col in ipairs(calculatedColumns) do
if totalWidth + col.visibleWidth > width then if totalWidth + col.visibleWidth > contentWidth then
lastVisibleColumn = i - 1 lastVisibleColumn = i - 1
break break
end end
@@ -382,38 +597,73 @@ function Table:render()
if i > lastVisibleColumn then break end if i > lastVisibleColumn then break end
local text = col.name local text = col.name
if i == sortCol then if i == sortCol then
text = text .. (self.get("sortDirection") == "asc" and "\30" or "\31") text = text .. (self.getResolved("sortDirection") == "asc" and "\30" or "\31")
end end
self:textFg(currentX, 1, text:sub(1, col.visibleWidth), self.get("headerColor")) self:textFg(currentX, 1, text:sub(1, col.visibleWidth), self.getResolved("headerColor"))
currentX = currentX + col.visibleWidth currentX = currentX + col.visibleWidth
end end
if currentX <= contentWidth then
self:textBg(currentX, 1, string.rep(" ", contentWidth - currentX + 1), background)
end
for y = 2, height do for y = 2, height do
local rowIndex = y - 2 + scrollOffset local rowIndex = y - 2 + offset
local rowData = data[rowIndex + 1] local item = items[rowIndex + 1]
if rowData and (rowIndex + 1) <= #data then if item then
currentX = 1 local cells = item._data and item._data.cells or item.cells
local bg = (rowIndex + 1) == selected and self.get("selectedColor") or self.get("background") local isSelected = item._data and item._data.selected or item.selected
for i, col in ipairs(calculatedColumns) do if cells then
if i > lastVisibleColumn then break end currentX = 1
local cellText = tostring(rowData[i] or "") local bg = isSelected and self.getResolved("selectedBackground") or background
local paddedText = cellText .. string.rep(" ", col.visibleWidth - #cellText)
if i < lastVisibleColumn then for i, col in ipairs(calculatedColumns) do
paddedText = string.sub(paddedText, 1, col.visibleWidth - 1) .. " " if i > lastVisibleColumn then break end
local cellText = tostring(cells[i] or "")
local paddedText = cellText .. string.rep(" ", col.visibleWidth - #cellText)
if i < lastVisibleColumn then
paddedText = string.sub(paddedText, 1, col.visibleWidth - 1) .. " "
end
local finalText = string.sub(paddedText, 1, col.visibleWidth)
local finalForeground = string.rep(tHex[foreground], col.visibleWidth)
local finalBackground = string.rep(tHex[bg], col.visibleWidth)
self:blit(currentX, y, finalText, finalForeground, finalBackground)
currentX = currentX + col.visibleWidth
end end
local finalText = string.sub(paddedText, 1, col.visibleWidth)
local finalForeground = string.rep(tHex[self.get("foreground")], col.visibleWidth)
local finalBackground = string.rep(tHex[bg], col.visibleWidth)
self:blit(currentX, y, finalText, finalForeground, finalBackground) if currentX <= contentWidth then
currentX = currentX + col.visibleWidth self:textBg(currentX, y, string.rep(" ", contentWidth - currentX + 1), bg)
end
end end
else else
self:blit(1, y, string.rep(" ", self.get("width")), self:blit(1, y, string.rep(" ", contentWidth),
string.rep(tHex[self.get("foreground")], self.get("width")), string.rep(tHex[foreground], contentWidth),
string.rep(tHex[self.get("background")], self.get("width"))) string.rep(tHex[background], contentWidth))
end
end
if needsScrollBar then
local scrollBarHeight = height - 1
local handleSize = math.max(1, math.floor((visibleRows / #items) * scrollBarHeight))
local maxOffset = #items - visibleRows
local currentPercent = maxOffset > 0 and (offset / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollBarHeight - handleSize)) + 1
local scrollBarSymbol = self.getResolved("scrollBarSymbol")
local scrollBarBg = self.getResolved("scrollBarBackground")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
for i = 2, height do
self:blit(width, i, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
for i = handlePos, math.min(scrollBarHeight, handlePos + handleSize - 1) do
self:blit(width, i + 1, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
end end
end end
end end

View File

@@ -94,20 +94,20 @@ local function autoCompleteVisible(self)
end end
local function getBorderPadding(self) local function getBorderPadding(self)
return self.get("autoCompleteShowBorder") and 1 or 0 return self.getResolved("autoCompleteShowBorder") and 1 or 0
end end
local function updateAutoCompleteStyles(self) local function updateAutoCompleteStyles(self)
local frame = self._autoCompleteFrame local frame = self._autoCompleteFrame
local list = self._autoCompleteList local list = self._autoCompleteList
if not frame or frame._destroyed then return end if not frame or frame._destroyed then return end
frame:setBackground(self.get("autoCompleteBackground")) frame:setBackground(self.getResolved("autoCompleteBackground"))
frame:setForeground(self.get("autoCompleteForeground")) frame:setForeground(self.getResolved("autoCompleteForeground"))
if list and not list._destroyed then if list and not list._destroyed then
list:setBackground(self.get("autoCompleteBackground")) list:setBackground(self.getResolved("autoCompleteBackground"))
list:setForeground(self.get("autoCompleteForeground")) list:setForeground(self.getResolved("autoCompleteForeground"))
list:setSelectedBackground(self.get("autoCompleteSelectedBackground")) list:setSelectedBackground(self.getResolved("autoCompleteSelectedBackground"))
list:setSelectedForeground(self.get("autoCompleteSelectedForeground")) list:setSelectedForeground(self.getResolved("autoCompleteSelectedForeground"))
list:updateRender() list:updateRender()
end end
layoutAutoCompleteList(self) layoutAutoCompleteList(self)
@@ -165,9 +165,9 @@ local function applyAutoCompleteSelection(self, item)
local insertText = entry.insert or entry.text or "" local insertText = entry.insert or entry.text or ""
if insertText == "" then return end if insertText == "" then return end
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local line = lines[cursorY] or "" local line = lines[cursorY] or ""
local startIndex = self._autoCompleteTokenStart or cursorX local startIndex = self._autoCompleteTokenStart or cursorX
if startIndex < 1 then startIndex = 1 end if startIndex < 1 then startIndex = 1 end
@@ -184,7 +184,7 @@ local function applyAutoCompleteSelection(self, item)
end end
local function ensureAutoCompleteUI(self) local function ensureAutoCompleteUI(self)
if not self.get("autoCompleteEnabled") then return nil end if not self.getResolved("autoCompleteEnabled") then return nil end
local frame = self._autoCompleteFrame local frame = self._autoCompleteFrame
if frame and not frame._destroyed then if frame and not frame._destroyed then
return self._autoCompleteList return self._autoCompleteList
@@ -194,15 +194,15 @@ local function ensureAutoCompleteUI(self)
if not base or not base.addFrame then return nil end if not base or not base.addFrame then return nil end
frame = base:addFrame({ frame = base:addFrame({
width = self.get("width"), width = self.getResolved("width"),
height = 1, height = 1,
x = 1, x = 1,
y = 1, y = 1,
visible = false, visible = false,
background = self.get("autoCompleteBackground"), background = self.getResolved("autoCompleteBackground"),
foreground = self.get("autoCompleteForeground"), foreground = self.getResolved("autoCompleteForeground"),
ignoreOffset = true, ignoreOffset = true,
z = self.get("z") + self.get("autoCompleteZOffset"), z = self.getResolved("z") + self.getResolved("autoCompleteZOffset"),
}) })
frame:setIgnoreOffset(true) frame:setIgnoreOffset(true)
frame:setVisible(false) frame:setVisible(false)
@@ -215,16 +215,16 @@ local function ensureAutoCompleteUI(self)
height = math.max(1, frame.get("height") - padding * 2), height = math.max(1, frame.get("height") - padding * 2),
selectable = true, selectable = true,
multiSelection = false, multiSelection = false,
background = self.get("autoCompleteBackground"), background = self.getResolved("autoCompleteBackground"),
foreground = self.get("autoCompleteForeground"), foreground = self.getResolved("autoCompleteForeground"),
}) })
list:setSelectedBackground(self.get("autoCompleteSelectedBackground")) list:setSelectedBackground(self.getResolved("autoCompleteSelectedBackground"))
list:setSelectedForeground(self.get("autoCompleteSelectedForeground")) list:setSelectedForeground(self.getResolved("autoCompleteSelectedForeground"))
list:setOffset(0) list:setOffset(0)
list:onSelect(function(_, index, selectedItem) list:onSelect(function(_, index, selectedItem)
if not autoCompleteVisible(self) then return end if not autoCompleteVisible(self) then return end
setAutoCompleteSelection(self, index) setAutoCompleteSelection(self, index)
if self.get("autoCompleteAcceptOnClick") then if self.getResolved("autoCompleteAcceptOnClick") then
applyAutoCompleteSelection(self, selectedItem) applyAutoCompleteSelection(self, selectedItem)
end end
end) end)
@@ -272,12 +272,12 @@ updateAutoCompleteBorder = function(self)
frame._autoCompleteBorderCommand = nil frame._autoCompleteBorderCommand = nil
end end
if not self.get("autoCompleteShowBorder") then if not self.getResolved("autoCompleteShowBorder") then
frame:updateRender() frame:updateRender()
return return
end end
local borderColor = self.get("autoCompleteBorderColor") or colors.black local borderColor = self.getResolved("autoCompleteBorderColor") or colors.black
local commandIndex = canvas:addCommand(function(element) local commandIndex = canvas:addCommand(function(element)
local width = element.get("width") or 0 local width = element.get("width") or 0
@@ -303,12 +303,12 @@ updateAutoCompleteBorder = function(self)
end end
local function getTokenInfo(self) local function getTokenInfo(self)
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local line = lines[cursorY] or "" local line = lines[cursorY] or ""
local uptoCursor = line:sub(1, math.max(cursorX - 1, 0)) local uptoCursor = line:sub(1, math.max(cursorX - 1, 0))
local pattern = self.get("autoCompleteTokenPattern") or "[%w_]+" local pattern = self.getResolved("autoCompleteTokenPattern") or "[%w_]+"
local token = "" local token = ""
if pattern ~= "" then if pattern ~= "" then
@@ -355,7 +355,7 @@ local function iterateSuggestions(source, handler)
end end
local function gatherSuggestions(self, token) local function gatherSuggestions(self, token)
local provider = self.get("autoCompleteProvider") local provider = self.getResolved("autoCompleteProvider")
local source = {} local source = {}
if provider then if provider then
local ok, result = pcall(provider, self, token) local ok, result = pcall(provider, self, token)
@@ -363,11 +363,11 @@ local function gatherSuggestions(self, token)
source = result source = result
end end
else else
source = self.get("autoCompleteItems") or {} source = self.getResolved("autoCompleteItems") or {}
end end
local suggestions = {} local suggestions = {}
local caseInsensitive = self.get("autoCompleteCaseInsensitive") local caseInsensitive = self.getResolved("autoCompleteCaseInsensitive")
local target = caseInsensitive and token:lower() or token local target = caseInsensitive and token:lower() or token
iterateSuggestions(source, function(entry) iterateSuggestions(source, function(entry)
local normalized = normalizeSuggestion(entry) local normalized = normalizeSuggestion(entry)
@@ -379,7 +379,7 @@ local function gatherSuggestions(self, token)
end end
end) end)
local maxItems = self.get("autoCompleteMaxItems") local maxItems = self.getResolved("autoCompleteMaxItems")
if #suggestions > maxItems then if #suggestions > maxItems then
while #suggestions > maxItems do while #suggestions > maxItems do
table.remove(suggestions) table.remove(suggestions)
@@ -403,8 +403,8 @@ local function measureSuggestionWidth(self, suggestions)
end end
end end
local limit = self.get("autoCompleteMaxWidth") local limit = self.getResolved("autoCompleteMaxWidth")
local maxWidth = self.get("width") local maxWidth = self.getResolved("width")
if limit and limit > 0 then if limit and limit > 0 then
maxWidth = math.min(maxWidth, limit) maxWidth = math.min(maxWidth, limit)
end end
@@ -430,7 +430,7 @@ local function placeAutoCompleteFrame(self, visibleCount, width)
local list = self._autoCompleteList local list = self._autoCompleteList
if not frame or frame._destroyed then return end if not frame or frame._destroyed then return end
local border = getBorderPadding(self) local border = getBorderPadding(self)
local contentWidth = math.max(1, width or self.get("width")) local contentWidth = math.max(1, width or self.getResolved("width"))
local contentHeight = math.max(1, visibleCount or 1) local contentHeight = math.max(1, visibleCount or 1)
local base = self:getBaseFrame() local base = self:getBaseFrame()
@@ -457,17 +457,17 @@ local function placeAutoCompleteFrame(self, visibleCount, width)
local frameWidth = contentWidth + border * 2 local frameWidth = contentWidth + border * 2
local frameHeight = contentHeight + border * 2 local frameHeight = contentHeight + border * 2
local originX, originY = self:calculatePosition() local originX, originY = self:calculatePosition()
local scrollX = self.get("scrollX") or 0 local scrollX = self.getResolved("scrollX") or 0
local scrollY = self.get("scrollY") or 0 local scrollY = self.getResolved("scrollY") or 0
local tokenStart = (self._autoCompleteTokenStart or self.get("cursorX")) local tokenStart = (self._autoCompleteTokenStart or self.getResolved("cursorX"))
local column = tokenStart - scrollX local column = tokenStart - scrollX
column = math.max(1, math.min(self.get("width"), column)) column = math.max(1, math.min(self.getResolved("width"), column))
local cursorRow = self.get("cursorY") - scrollY local cursorRow = self.getResolved("cursorY") - scrollY
cursorRow = math.max(1, math.min(self.get("height"), cursorRow)) cursorRow = math.max(1, math.min(self.getResolved("height"), cursorRow))
local offsetX = self.get("autoCompleteOffsetX") local offsetX = self.getResolved("autoCompleteOffsetX")
local offsetY = self.get("autoCompleteOffsetY") local offsetY = self.getResolved("autoCompleteOffsetY")
local baseX = originX + column - 1 + offsetX local baseX = originX + column - 1 + offsetX
local x = baseX - border local x = baseX - border
@@ -520,7 +520,7 @@ local function placeAutoCompleteFrame(self, visibleCount, width)
frame:setPosition(x, y) frame:setPosition(x, y)
frame:setWidth(frameWidth) frame:setWidth(frameWidth)
frame:setHeight(frameHeight) frame:setHeight(frameHeight)
frame:setZ(self.get("z") + self.get("autoCompleteZOffset")) frame:setZ(self.getResolved("z") + self.getResolved("autoCompleteZOffset"))
layoutAutoCompleteList(self, contentWidth, contentHeight) layoutAutoCompleteList(self, contentWidth, contentHeight)
@@ -531,11 +531,11 @@ local function placeAutoCompleteFrame(self, visibleCount, width)
end end
local function refreshAutoComplete(self) local function refreshAutoComplete(self)
if not self.get("autoCompleteEnabled") then if not self.getResolved("autoCompleteEnabled") then
hideAutoComplete(self, true) hideAutoComplete(self, true)
return return
end end
if not self.get("focused") then if not self:hasState("focused") then
hideAutoComplete(self, true) hideAutoComplete(self, true)
return return
end end
@@ -544,7 +544,7 @@ local function refreshAutoComplete(self)
self._autoCompleteToken = token self._autoCompleteToken = token
self._autoCompleteTokenStart = startIndex self._autoCompleteTokenStart = startIndex
if #token < self.get("autoCompleteMinChars") then if #token < self.getResolved("autoCompleteMinChars") then
hideAutoComplete(self) hideAutoComplete(self)
return return
end end
@@ -576,7 +576,7 @@ end
local function handleAutoCompleteKey(self, key) local function handleAutoCompleteKey(self, key)
if not autoCompleteVisible(self) then return false end if not autoCompleteVisible(self) then return false end
if key == keys.tab or (key == keys.enter and self.get("autoCompleteAcceptOnEnter")) then if key == keys.tab or (key == keys.enter and self.getResolved("autoCompleteAcceptOnEnter")) then
applyAutoCompleteSelection(self) applyAutoCompleteSelection(self)
return true return true
elseif key == keys.up then elseif key == keys.up then
@@ -593,7 +593,7 @@ local function handleAutoCompleteKey(self, key)
local height = (self._autoCompleteList and self._autoCompleteList.get("height")) or 1 local height = (self._autoCompleteList and self._autoCompleteList.get("height")) or 1
setAutoCompleteSelection(self, (self._autoCompleteIndex or 1) + height) setAutoCompleteSelection(self, (self._autoCompleteIndex or 1) + height)
return true return true
elseif key == keys.escape and self.get("autoCompleteCloseOnEscape") then elseif key == keys.escape and self.getResolved("autoCompleteCloseOnEscape") then
hideAutoComplete(self) hideAutoComplete(self)
return true return true
end end
@@ -647,7 +647,7 @@ function TextBox:init(props, basalt)
self.set("type", "TextBox") self.set("type", "TextBox")
local function refreshIfEnabled() local function refreshIfEnabled()
if self.get("autoCompleteEnabled") and self.get("focused") then if self.getResolved("autoCompleteEnabled") and self:hasState("focused") then
refreshAutoComplete(self) refreshAutoComplete(self)
end end
end end
@@ -659,25 +659,26 @@ function TextBox:init(props, basalt)
local function reposition() local function reposition()
if autoCompleteVisible(self) then if autoCompleteVisible(self) then
local suggestions = rawget(self, "_autoCompleteSuggestions") or {} local suggestions = rawget(self, "_autoCompleteSuggestions") or {}
placeAutoCompleteFrame(self, math.max(#suggestions, 1), rawget(self, "_autoCompletePopupWidth") or self.get("width")) placeAutoCompleteFrame(self, math.max(#suggestions, 1), rawget(self, "_autoCompletePopupWidth") or self.getResolved("width"))
end end
end end
self:observe("autoCompleteEnabled", function(_, value) self:observe("autoCompleteEnabled", function(_, value)
if not value then if not value then
hideAutoComplete(self, true) hideAutoComplete(self, true)
elseif self.get("focused") then elseif self:hasState("focused") then
refreshAutoComplete(self) refreshAutoComplete(self)
end end
end) end)
--[[
self:observe("focused", function(_, focused) self:observe("focused", function(_, focused)
if focused then if focused then
refreshIfEnabled() refreshIfEnabled()
else else
hideAutoComplete(self, true) hideAutoComplete(self, true)
end end
end) end)]] -- needs a REWORK
self:observe("foreground", restyle) self:observe("foreground", restyle)
self:observe("background", restyle) self:observe("background", restyle)
@@ -689,12 +690,12 @@ function TextBox:init(props, basalt)
self:observe("autoCompleteZOffset", function() self:observe("autoCompleteZOffset", function()
if self._autoCompleteFrame and not self._autoCompleteFrame._destroyed then if self._autoCompleteFrame and not self._autoCompleteFrame._destroyed then
self._autoCompleteFrame:setZ(self.get("z") + self.get("autoCompleteZOffset")) self._autoCompleteFrame:setZ(self.getResolved("z") + self.getResolved("autoCompleteZOffset"))
end end
end) end)
self:observe("z", function() self:observe("z", function()
if self._autoCompleteFrame and not self._autoCompleteFrame._destroyed then if self._autoCompleteFrame and not self._autoCompleteFrame._destroyed then
self._autoCompleteFrame:setZ(self.get("z") + self.get("autoCompleteZOffset")) self._autoCompleteFrame:setZ(self.getResolved("z") + self.getResolved("autoCompleteZOffset"))
end end
end) end)
@@ -748,7 +749,7 @@ end
--- @param color number The color to apply --- @param color number The color to apply
--- @return TextBox self The TextBox instance --- @return TextBox self The TextBox instance
function TextBox:addSyntaxPattern(pattern, color) function TextBox:addSyntaxPattern(pattern, color)
table.insert(self.get("syntaxPatterns"), {pattern = pattern, color = color}) table.insert(self.getResolved("syntaxPatterns"), {pattern = pattern, color = color})
return self return self
end end
@@ -756,7 +757,7 @@ end
--- @param index number The index of the pattern to remove --- @param index number The index of the pattern to remove
--- @return TextBox self --- @return TextBox self
function TextBox:removeSyntaxPattern(index) function TextBox:removeSyntaxPattern(index)
local patterns = self.get("syntaxPatterns") or {} local patterns = self.getResolved("syntaxPatterns") or {}
if type(index) ~= "number" then return self end if type(index) ~= "number" then return self end
if index >= 1 and index <= #patterns then if index >= 1 and index <= #patterns then
table.remove(patterns, index) table.remove(patterns, index)
@@ -775,9 +776,9 @@ function TextBox:clearSyntaxPatterns()
end end
local function insertChar(self, char) local function insertChar(self, char)
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local currentLine = lines[cursorY] local currentLine = lines[cursorY]
lines[cursorY] = currentLine:sub(1, cursorX-1) .. char .. currentLine:sub(cursorX) lines[cursorY] = currentLine:sub(1, cursorX-1) .. char .. currentLine:sub(cursorX)
self.set("cursorX", cursorX + 1) self.set("cursorX", cursorX + 1)
@@ -792,9 +793,9 @@ local function insertText(self, text)
end end
local function newLine(self) local function newLine(self)
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local currentLine = lines[cursorY] local currentLine = lines[cursorY]
local restOfLine = currentLine:sub(cursorX) local restOfLine = currentLine:sub(cursorX)
@@ -808,9 +809,9 @@ local function newLine(self)
end end
local function backspace(self) local function backspace(self)
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local currentLine = lines[cursorY] local currentLine = lines[cursorY]
if cursorX > 1 then if cursorX > 1 then
@@ -831,12 +832,12 @@ end
--- @shortDescription Updates the viewport to keep the cursor in view --- @shortDescription Updates the viewport to keep the cursor in view
--- @return TextBox self The TextBox instance --- @return TextBox self The TextBox instance
function TextBox:updateViewport() function TextBox:updateViewport()
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local scrollX = self.get("scrollX") local scrollX = self.getResolved("scrollX")
local scrollY = self.get("scrollY") local scrollY = self.getResolved("scrollY")
local width = self.get("width") local width = self.getResolved("width")
local height = self.get("height") local height = self.getResolved("height")
-- Horizontal scrolling -- Horizontal scrolling
if cursorX - scrollX > width then if cursorX - scrollX > width then
@@ -859,14 +860,14 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function TextBox:char(char) function TextBox:char(char)
if not self.get("editable") or not self.get("focused") then return false end if not self.getResolved("editable") or not self:hasState("focused") then return false end
-- Auto-pair logic only triggers for single characters -- Auto-pair logic only triggers for single characters
local autoPair = self.get("autoPairEnabled") local autoPair = self.getResolved("autoPairEnabled")
if autoPair and #char == 1 then if autoPair and #char == 1 then
local map = self.get("autoPairCharacters") or {} local map = self.getResolved("autoPairCharacters") or {}
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local line = lines[cursorY] or "" local line = lines[cursorY] or ""
local afterChar = line:sub(cursorX, cursorX) local afterChar = line:sub(cursorX, cursorX)
@@ -875,22 +876,22 @@ function TextBox:char(char)
if closing then if closing then
-- If skip closing and same closing already directly after, just insert opening? -- If skip closing and same closing already directly after, just insert opening?
insertChar(self, char) insertChar(self, char)
if self.get("autoPairSkipClosing") then if self.getResolved("autoPairSkipClosing") then
if afterChar ~= closing then if afterChar ~= closing then
insertChar(self, closing) insertChar(self, closing)
-- Move cursor back inside pair -- Move cursor back inside pair
self.set("cursorX", self.get("cursorX") - 1) self.set("cursorX", self.getResolved("cursorX") - 1)
end end
else else
insertChar(self, closing) insertChar(self, closing)
self.set("cursorX", self.get("cursorX") - 1) self.set("cursorX", self.getResolved("cursorX") - 1)
end end
refreshAutoComplete(self) refreshAutoComplete(self)
return true return true
end end
-- If typed char is a closing we might want to overtype -- If typed char is a closing we might want to overtype
if self.get("autoPairOverType") then if self.getResolved("autoPairOverType") then
for open, close in pairs(map) do for open, close in pairs(map) do
if char == close and afterChar == close then if char == close and afterChar == close then
-- move over instead of inserting -- move over instead of inserting
@@ -912,24 +913,24 @@ end
--- @return boolean handled Whether the event was handled --- @return boolean handled Whether the event was handled
--- @protected --- @protected
function TextBox:key(key) function TextBox:key(key)
if not self.get("editable") or not self.get("focused") then return false end if not self.getResolved("editable") or not self:hasState("focused") then return false end
if handleAutoCompleteKey(self, key) then if handleAutoCompleteKey(self, key) then
return true return true
end end
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
if key == keys.enter then if key == keys.enter then
-- Smart newline between matching braces/brackets if enabled -- Smart newline between matching braces/brackets if enabled
if self.get("autoPairEnabled") and self.get("autoPairNewlineIndent") then if self.getResolved("autoPairEnabled") and self.getResolved("autoPairNewlineIndent") then
local lines = self.get("lines") local lines = self.getResolved("lines")
local cursorX = self.get("cursorX") local cursorX = self.getResolved("cursorX")
local cursorY = self.get("cursorY") local cursorY = self.getResolved("cursorY")
local line = lines[cursorY] or "" local line = lines[cursorY] or ""
local before = line:sub(1, cursorX - 1) local before = line:sub(1, cursorX - 1)
local after = line:sub(cursorX) local after = line:sub(cursorX)
local pairMap = self.get("autoPairCharacters") or {} local pairMap = self.getResolved("autoPairCharacters") or {}
local inverse = {} local inverse = {}
for o,c in pairs(pairMap) do inverse[c]=o end for o,c in pairs(pairMap) do inverse[c]=o end
local prevChar = before:sub(-1) local prevChar = before:sub(-1)
@@ -988,9 +989,9 @@ function TextBox:mouse_scroll(direction, x, y)
return true return true
end end
if self:isInBounds(x, y) then if self:isInBounds(x, y) then
local scrollY = self.get("scrollY") local scrollY = self.getResolved("scrollY")
local height = self.get("height") local height = self.getResolved("height")
local lines = self.get("lines") local lines = self.getResolved("lines")
local maxScroll = math.max(0, #lines - height + 2) local maxScroll = math.max(0, #lines - height + 2)
@@ -1012,11 +1013,11 @@ end
function TextBox:mouse_click(button, x, y) function TextBox:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then if VisualElement.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local scrollX = self.get("scrollX") local scrollX = self.getResolved("scrollX")
local scrollY = self.get("scrollY") local scrollY = self.getResolved("scrollY")
local targetY = (relY or 0) + (scrollY or 0) local targetY = (relY or 0) + (scrollY or 0)
local lines = self.get("lines") or {} local lines = self.getResolved("lines") or {}
-- clamp and validate before indexing to avoid nil errors -- clamp and validate before indexing to avoid nil errors
if targetY < 1 then targetY = 1 end if targetY < 1 then targetY = 1 end
@@ -1041,7 +1042,7 @@ end
--- @shortDescription Handles paste events --- @shortDescription Handles paste events
--- @protected --- @protected
function TextBox:paste(text) function TextBox:paste(text)
if not self.get("editable") or not self.get("focused") then return false end if not self.getResolved("editable") or not self:hasState("focused") then return false end
for char in text:gmatch(".") do for char in text:gmatch(".") do
if char == "\n" then if char == "\n" then
@@ -1077,13 +1078,13 @@ end
--- @shortDescription Gets the text of the TextBox --- @shortDescription Gets the text of the TextBox
--- @return string text The text of the TextBox --- @return string text The text of the TextBox
function TextBox:getText() function TextBox:getText()
return table.concat(self.get("lines"), "\n") return table.concat(self.getResolved("lines"), "\n")
end end
local function applySyntaxHighlighting(self, line) local function applySyntaxHighlighting(self, line)
local text = line local text = line
local colors = string.rep(tHex[self.get("foreground")], #text) local colors = string.rep(tHex[self.getResolved("foreground")], #text)
local patterns = self.get("syntaxPatterns") local patterns = self.getResolved("syntaxPatterns")
for _, syntax in ipairs(patterns) do for _, syntax in ipairs(patterns) do
local start = 1 local start = 1
@@ -1110,13 +1111,15 @@ end
function TextBox:render() function TextBox:render()
VisualElement.render(self) VisualElement.render(self)
local lines = self.get("lines") local lines = self.getResolved("lines")
local scrollX = self.get("scrollX") local scrollX = self.getResolved("scrollX")
local scrollY = self.get("scrollY") local scrollY = self.getResolved("scrollY")
local width = self.get("width") local width = self.getResolved("width")
local height = self.get("height") local height = self.getResolved("height")
local fg = tHex[self.get("foreground")] local foreground = self.getResolved("foreground")
local bg = tHex[self.get("background")] local background = self.getResolved("background")
local fg = tHex[foreground]
local bg = tHex[background]
for y = 1, height do for y = 1, height do
local lineNum = y + scrollY local lineNum = y + scrollY
@@ -1129,17 +1132,17 @@ function TextBox:render()
local padLen = width - #text local padLen = width - #text
if padLen > 0 then if padLen > 0 then
text = text .. string.rep(" ", padLen) text = text .. string.rep(" ", padLen)
colors = colors .. string.rep(tHex[self.get("foreground")], padLen) colors = colors .. string.rep(tHex[foreground], padLen)
end end
self:blit(1, y, text, colors, string.rep(bg, #text)) self:blit(1, y, text, colors, string.rep(bg, #text))
end end
if self.get("focused") then if self:hasState("focused") then
local relativeX = self.get("cursorX") - scrollX local relativeX = self.getResolved("cursorX") - scrollX
local relativeY = self.get("cursorY") - scrollY local relativeY = self.getResolved("cursorY") - scrollY
if relativeX >= 1 and relativeX <= width and relativeY >= 1 and relativeY <= height then if relativeX >= 1 and relativeX <= width and relativeY >= 1 and relativeY <= height then
self:setCursor(relativeX, relativeY, true, self.get("cursorColor") or self.get("foreground")) self:setCursor(relativeX, relativeY, true, self.getResolved("cursorColor") or foreground)
end end
end end
end end

View File

@@ -1,6 +1,7 @@
local elementManager = require("elementManager") local elementManager = require("elementManager")
local BaseElement = elementManager.getElement("BaseElement") local BaseElement = elementManager.getElement("BaseElement")
---@cofnigDescription The Timer is a non-visual element that can be used to perform actions at specific intervals. ---@cofnigDescription The Timer is a non-visual element that can be used to perform actions at specific intervals.
---@configDefault false
--- The Timer is a non-visual element that can be used to perform actions at specific intervals. --- The Timer is a non-visual element that can be used to perform actions at specific intervals.
---@class Timer : BaseElement ---@class Timer : BaseElement
@@ -43,7 +44,7 @@ end
function Timer:start() function Timer:start()
if not self.running then if not self.running then
self.running = true self.running = true
local time = self.get("interval") local time = self.getResolved("interval")
self.timerId = os.startTimer(time) self.timerId = os.startTimer(time)
end end
return self return self
@@ -69,12 +70,12 @@ function Timer:dispatchEvent(event, ...)
local timerId = select(1, ...) local timerId = select(1, ...)
if timerId == self.timerId then if timerId == self.timerId then
self.action() self.action()
local amount = self.get("amount") local amount = self.getResolved("amount")
if amount > 0 then if amount > 0 then
self.set("amount", amount - 1) self.set("amount", amount - 1)
end end
if amount ~= 0 then if amount ~= 0 then
self.timerId = os.startTimer(self.get("interval")) self.timerId = os.startTimer(self.getResolved("interval"))
end end
end end
end end

233
src/elements/Toast.lua Normal file
View File

@@ -0,0 +1,233 @@
local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement")
---@configDescription A toast notification element that displays temporary messages.
---@configDefault false
--- A toast notification element that displays temporary messages with optional icons and auto-hide functionality.
--- The element is always visible but only renders content when a message is shown.
---@class Toast : VisualElement
local Toast = setmetatable({}, VisualElement)
Toast.__index = Toast
---@property title string "" The title text of the toast
Toast.defineProperty(Toast, "title", {default = "", type = "string", canTriggerRender = true})
---@property message string "" The message text of the toast
Toast.defineProperty(Toast, "message", {default = "", type = "string", canTriggerRender = true})
---@property duration number 3 Duration in seconds before the toast auto-hides
Toast.defineProperty(Toast, "duration", {default = 3, type = "number"})
---@property toastType string "default" Type of toast: default, success, error, warning, info
Toast.defineProperty(Toast, "toastType", {default = "default", type = "string", canTriggerRender = true})
---@property autoHide boolean true Whether the toast should automatically hide after duration
Toast.defineProperty(Toast, "autoHide", {default = true, type = "boolean"})
---@property active boolean false Whether the toast is currently showing a message
Toast.defineProperty(Toast, "active", {default = false, type = "boolean", canTriggerRender = true})
---@property colorMap table Map of toast types to their colors
Toast.defineProperty(Toast, "colorMap", {
default = {
success = colors.green,
error = colors.red,
warning = colors.orange,
info = colors.lightBlue,
default = colors.gray
},
type = "table"
})
Toast.defineEvent(Toast, "timer")
--- Creates a new Toast instance
--- @shortDescription Creates a new Toast instance
--- @return Toast self The newly created Toast instance
--- @private
function Toast.new()
local self = setmetatable({}, Toast):__init()
self.class = Toast
self.set("width", 30)
self.set("height", 3)
self.set("z", 100) -- High z-index so it appears on top
return self
end
--- Initializes a Toast instance
--- @shortDescription Initializes a Toast instance
--- @param props table Initial properties
--- @param basalt table The basalt instance
--- @return Toast self The initialized Toast instance
--- @private
function Toast:init(props, basalt)
VisualElement.init(self, props, basalt)
return self
end
--- Shows a toast message
--- @shortDescription Shows a toast message
--- @param titleOrMessage string The title (if message provided) or the message (if no message)
--- @param messageOrDuration? string|number The message (if string) or duration (if number)
--- @param duration? number Duration in seconds
--- @return Toast self The Toast instance
function Toast:show(titleOrMessage, messageOrDuration, duration)
local title, message, dur
if type(messageOrDuration) == "string" then
title = titleOrMessage
message = messageOrDuration
dur = duration or self.getResolved("duration")
elseif type(messageOrDuration) == "number" then
title = ""
message = titleOrMessage
dur = messageOrDuration
else
title = ""
message = titleOrMessage
dur = self.getResolved("duration")
end
self.set("title", title)
self.set("message", message)
self.set("active", true)
if self._hideTimerId then
os.cancelTimer(self._hideTimerId)
self._hideTimerId = nil
end
if self.getResolved("autoHide") and dur > 0 then
self._hideTimerId = os.startTimer(dur)
end
return self
end
--- Hides the toast
--- @shortDescription Hides the toast
--- @return Toast self The Toast instance
function Toast:hide()
self.set("active", false)
self.set("title", "")
self.set("message", "")
if self._hideTimerId then
os.cancelTimer(self._hideTimerId)
self._hideTimerId = nil
end
return self
end
--- Shows a success toast
--- @shortDescription Shows a success toast
--- @param titleOrMessage string The title or message
--- @param messageOrDuration? string|number The message or duration
--- @param duration? number Duration in seconds
--- @return Toast self The Toast instance
function Toast:success(titleOrMessage, messageOrDuration, duration)
self.set("toastType", "success")
return self:show(titleOrMessage, messageOrDuration, duration)
end
--- Shows an error toast
--- @shortDescription Shows an error toast
--- @param titleOrMessage string The title or message
--- @param messageOrDuration? string|number The message or duration
--- @param duration? number Duration in seconds
--- @return Toast self The Toast instance
function Toast:error(titleOrMessage, messageOrDuration, duration)
self.set("toastType", "error")
return self:show(titleOrMessage, messageOrDuration, duration)
end
--- Shows a warning toast
--- @shortDescription Shows a warning toast
--- @param titleOrMessage string The title or message
--- @param messageOrDuration? string|number The message or duration
--- @param duration? number Duration in seconds
--- @return Toast self The Toast instance
function Toast:warning(titleOrMessage, messageOrDuration, duration)
self.set("toastType", "warning")
return self:show(titleOrMessage, messageOrDuration, duration)
end
--- Shows an info toast
--- @shortDescription Shows an info toast
--- @param titleOrMessage string The title or message
--- @param messageOrDuration? string|number The message or duration
--- @param duration? number Duration in seconds
--- @return Toast self The Toast instance
function Toast:info(titleOrMessage, messageOrDuration, duration)
self.set("toastType", "info")
return self:show(titleOrMessage, messageOrDuration, duration)
end
--- @shortDescription Dispatches events to the Toast instance
--- @protected
function Toast:dispatchEvent(event, ...)
VisualElement.dispatchEvent(self, event, ...)
if event == "timer" then
local timerId = select(1, ...)
if timerId == self._hideTimerId then
self:hide()
end
end
end
--- Renders the toast
--- @shortDescription Renders the toast
--- @protected
function Toast:render()
VisualElement.render(self)
if not self.getResolved("active") then
return
end
local width = self.getResolved("width")
local height = self.getResolved("height")
local title = self.getResolved("title")
local message = self.getResolved("message")
local toastType = self.getResolved("toastType")
local colorMap = self.getResolved("colorMap")
local typeColor = colorMap[toastType] or colorMap.default
local fg = self.getResolved("foreground")
local startX = 1
local currentY = 1
if title ~= "" then
local titleText = title:sub(1, width - startX + 1)
self:textFg(startX, currentY, titleText, typeColor)
currentY = currentY + 1
end
if message ~= "" and currentY <= height then
local availableWidth = width - startX + 1
local words = {}
for word in message:gmatch("%S+") do
table.insert(words, word)
end
local line = ""
for _, word in ipairs(words) do
if #line + #word + 1 > availableWidth then
if currentY <= height then
self:textFg(startX, currentY, line, fg)
currentY = currentY + 1
line = word
else
break
end
else
line = line == "" and word or line .. " " .. word
end
end
if line ~= "" and currentY <= height then
self:textFg(startX, currentY, line, fg)
end
end
end
return Toast

View File

@@ -2,10 +2,126 @@ local VisualElement = require("elements/VisualElement")
local sub = string.sub local sub = string.sub
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@cofnigDescription The tree element provides a hierarchical view of nodes that can be expanded and collapsed, with support for selection and scrolling. ---@cofnigDescription The tree element provides a hierarchical view of nodes that can be expanded and collapsed, with support for selection and scrolling.
---@configDefault false
local function flattenTree(nodes, expandedNodes, level, result)
result = result or {}
level = level or 0
--- This is the tree class. It provides a hierarchical view of nodes that can be expanded and collapsed, for _, node in ipairs(nodes) do
--- with support for selection and scrolling. table.insert(result, {node = node, level = level})
if expandedNodes[node] and node.children then
flattenTree(node.children, expandedNodes, level + 1, result)
end
end
return result
end
--- This is the tree class. It provides a hierarchical view of nodes that can be expanded and collapsed, with support for selection and scrolling.
--- @run [[
--- local basalt = require("basalt")
--- local main = basalt.getMainFrame()
---
--- local fileTree = main:addTree()
--- :setPosition(2, 2)
--- :setSize(15, 15)
--- :setBackground(colors.black)
--- :setForeground(colors.white)
--- :setSelectedBackgroundColor(colors.blue)
--- :setSelectedForegroundColor(colors.white)
--- :setScrollBarColor(colors.lightGray)
--- :setScrollBarBackgroundColor(colors.gray)
---
--- -- Build a file system-like tree structure
--- local treeData = {
--- {
--- text = "Root",
--- children = {
--- {
--- text = "Documents",
--- children = {
--- {text = "report.txt"},
--- {text = "notes.txt"},
--- {text = "todo.txt"}
--- }
--- },
--- {
--- text = "Pictures",
--- children = {
--- {text = "vacation.png"},
--- {text = "family.jpg"},
--- {
--- text = "Archive",
--- children = {
--- {text = "old_photo1.jpg"},
--- {text = "old_photo2.jpg"},
--- {text = "old_photo3.jpg"}
--- }
--- }
--- }
--- },
--- {
--- text = "Music",
--- children = {
--- {text = "song1.mp3"},
--- {text = "song2.mp3"},
--- {text = "song3.mp3"},
--- {text = "song4.mp3"}
--- }
--- },
--- {
--- text = "Videos",
--- children = {
--- {text = "movie1.mp4"},
--- {text = "movie2.mp4"}
--- }
--- },
--- {
--- text = "Projects",
--- children = {
--- {
--- text = "ProjectA",
--- children = {
--- {text = "src"},
--- {text = "tests"},
--- {text = "README.md"}
--- }
--- },
--- {
--- text = "ProjectB",
--- children = {
--- {text = "main.lua"},
--- {text = "config.lua"}
--- }
--- }
--- }
--- }
--- }
--- }
--- }
---
--- fileTree:setNodes(treeData)
--- local textLabel = main:addLabel()
--- :setPosition(2, 18)
--- :setForeground(colors.yellow)
--- :setText("Selected: None")
---
--- -- Handle node selection
--- fileTree:onSelect(function(self, node)
--- textLabel
--- :setText("Selected: " .. node.text)
--- :setPosition(2, 18)
--- :setForeground(colors.yellow)
--- end)
---
--- -- Info label
--- main:addLabel()
--- :setText("Click nodes to expand/collapse | Scroll to navigate")
--- :setPosition(2, 1)
--- :setForeground(colors.lightGray)
---
--- basalt.run()
---]]
---@class Tree : VisualElement ---@class Tree : VisualElement
local Tree = setmetatable({}, VisualElement) local Tree = setmetatable({}, VisualElement)
Tree.__index = Tree Tree.__index = Tree
@@ -13,7 +129,7 @@ Tree.__index = Tree
---@property nodes table {} The tree structure containing node objects with {text, children} properties ---@property nodes table {} The tree structure containing node objects with {text, children} properties
Tree.defineProperty(Tree, "nodes", {default = {}, type = "table", canTriggerRender = true, setter = function(self, value) Tree.defineProperty(Tree, "nodes", {default = {}, type = "table", canTriggerRender = true, setter = function(self, value)
if #value > 0 then if #value > 0 then
self.get("expandedNodes")[value[1]] = true self.getResolved("expandedNodes")[value[1]] = true
end end
return value return value
end}) end})
@@ -21,16 +137,47 @@ end})
Tree.defineProperty(Tree, "selectedNode", {default = nil, type = "table", canTriggerRender = true}) Tree.defineProperty(Tree, "selectedNode", {default = nil, type = "table", canTriggerRender = true})
---@property expandedNodes table {} Table of nodes that are currently expanded ---@property expandedNodes table {} Table of nodes that are currently expanded
Tree.defineProperty(Tree, "expandedNodes", {default = {}, type = "table", canTriggerRender = true}) Tree.defineProperty(Tree, "expandedNodes", {default = {}, type = "table", canTriggerRender = true})
---@property scrollOffset number 0 Current vertical scroll position ---@property offset number 0 Current vertical scroll position
Tree.defineProperty(Tree, "scrollOffset", {default = 0, type = "number", canTriggerRender = true}) Tree.defineProperty(Tree, "offset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
return math.max(0, value)
end
})
---@property horizontalOffset number 0 Current horizontal scroll position ---@property horizontalOffset number 0 Current horizontal scroll position
Tree.defineProperty(Tree, "horizontalOffset", {default = 0, type = "number", canTriggerRender = true}) Tree.defineProperty(Tree, "horizontalOffset", {
default = 0,
type = "number",
canTriggerRender = true,
setter = function(self, value)
return math.max(0, value)
end
})
---@property selectedForegroundColor color white foreground color of selected node ---@property selectedForegroundColor color white foreground color of selected node
Tree.defineProperty(Tree, "selectedForegroundColor", {default = colors.white, type = "color"}) Tree.defineProperty(Tree, "selectedForegroundColor", {default = colors.white, type = "color"})
---@property selectedBackgroundColor color lightBlue background color of selected node ---@property selectedBackgroundColor color lightBlue background color of selected node
Tree.defineProperty(Tree, "selectedBackgroundColor", {default = colors.lightBlue, type = "color"}) Tree.defineProperty(Tree, "selectedBackgroundColor", {default = colors.lightBlue, type = "color"})
---@property showScrollBar boolean true Whether to show the scrollbar when nodes exceed height
Tree.defineProperty(Tree, "showScrollBar", {default = true, type = "boolean", canTriggerRender = true})
---@property scrollBarSymbol string " " Symbol used for the scrollbar handle
Tree.defineProperty(Tree, "scrollBarSymbol", {default = " ", type = "string", canTriggerRender = true})
---@property scrollBarBackground string "\127" Symbol used for the scrollbar background
Tree.defineProperty(Tree, "scrollBarBackground", {default = "\127", type = "string", canTriggerRender = true})
---@property scrollBarColor color lightGray Color of the scrollbar handle
Tree.defineProperty(Tree, "scrollBarColor", {default = colors.lightGray, type = "color", canTriggerRender = true})
---@property scrollBarBackgroundColor color gray Background color of the scrollbar
Tree.defineProperty(Tree, "scrollBarBackgroundColor", {default = colors.gray, type = "color", canTriggerRender = true})
Tree.defineEvent(Tree, "mouse_click") Tree.defineEvent(Tree, "mouse_click")
Tree.defineEvent(Tree, "mouse_drag")
Tree.defineEvent(Tree, "mouse_up")
Tree.defineEvent(Tree, "mouse_scroll") Tree.defineEvent(Tree, "mouse_scroll")
--- Creates a new Tree instance --- Creates a new Tree instance
@@ -63,7 +210,7 @@ end
--- @param node table The node to expand --- @param node table The node to expand
--- @return Tree self The Tree instance --- @return Tree self The Tree instance
function Tree:expandNode(node) function Tree:expandNode(node)
self.get("expandedNodes")[node] = true self.getResolved("expandedNodes")[node] = true
self:updateRender() self:updateRender()
return self return self
end end
@@ -73,7 +220,7 @@ end
--- @param node table The node to collapse --- @param node table The node to collapse
--- @return Tree self The Tree instance --- @return Tree self The Tree instance
function Tree:collapseNode(node) function Tree:collapseNode(node)
self.get("expandedNodes")[node] = nil self.getResolved("expandedNodes")[node] = nil
self:updateRender() self:updateRender()
return self return self
end end
@@ -83,7 +230,7 @@ end
--- @param node table The node to toggle --- @param node table The node to toggle
--- @return Tree self The Tree instance --- @return Tree self The Tree instance
function Tree:toggleNode(node) function Tree:toggleNode(node)
if self.get("expandedNodes")[node] then if self.getResolved("expandedNodes")[node] then
self:collapseNode(node) self:collapseNode(node)
else else
self:expandNode(node) self:expandNode(node)
@@ -91,19 +238,6 @@ function Tree:toggleNode(node)
return self return self
end end
local function flattenTree(nodes, expandedNodes, level, result)
result = result or {}
level = level or 0
for _, node in ipairs(nodes) do
table.insert(result, {node = node, level = level})
if expandedNodes[node] and node.children then
flattenTree(node.children, expandedNodes, level + 1, result)
end
end
return result
end
--- Handles mouse click events --- Handles mouse click events
--- @shortDescription Handles mouse click events for node selection and expansion --- @shortDescription Handles mouse click events for node selection and expansion
--- @param button number The button that was clicked --- @param button number The button that was clicked
@@ -114,8 +248,54 @@ end
function Tree:mouse_click(button, x, y) function Tree:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then if VisualElement.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y) local relX, relY = self:getRelativePosition(x, y)
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes")) local width = self.getResolved("width")
local visibleIndex = relY + self.get("scrollOffset") local height = self.getResolved("height")
local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local showScrollBar = self.getResolved("showScrollBar")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = showScrollBar and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and #flatNodes > contentHeight
if needsVerticalScrollBar and relX == width and (not needsHorizontalScrollBar or relY < height) then
local scrollHeight = needsHorizontalScrollBar and height - 1 or height
local handleSize = math.max(1, math.floor((contentHeight / #flatNodes) * scrollHeight))
local maxOffset = #flatNodes - contentHeight
local currentPercent = maxOffset > 0 and (self.getResolved("offset") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollHeight - handleSize)) + 1
if relY >= handlePos and relY < handlePos + handleSize then
self._scrollBarDragging = true
self._scrollBarDragOffset = relY - handlePos
else
local newPercent = ((relY - 1) / (scrollHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
if needsHorizontalScrollBar and relY == height and (not needsVerticalScrollBar or relX < width) then
local contentWidth = needsVerticalScrollBar and width - 1 or width
local handleSize = math.max(1, math.floor((contentWidth / maxContentWidth) * contentWidth))
local maxOffset = maxContentWidth - contentWidth
local currentPercent = maxOffset > 0 and (self.getResolved("horizontalOffset") / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (contentWidth - handleSize)) + 1
if relX >= handlePos and relX < handlePos + handleSize then
self._hScrollBarDragging = true
self._hScrollBarDragOffset = relX - handlePos
else
local newPercent = ((relX - 1) / (contentWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("horizontalOffset", math.max(0, math.min(maxOffset, newOffset)))
end
return true
end
local visibleIndex = relY + self.getResolved("offset")
if flatNodes[visibleIndex] then if flatNodes[visibleIndex] then
local nodeInfo = flatNodes[visibleIndex] local nodeInfo = flatNodes[visibleIndex]
@@ -142,6 +322,82 @@ function Tree:onSelect(callback)
return self return self
end end
--- @shortDescription Handles mouse drag events for scrollbar
--- @param button number The mouse button being dragged
--- @param x number The x-coordinate of the drag
--- @param y number The y-coordinate of the drag
--- @return boolean Whether the event was handled
--- @protected
function Tree:mouse_drag(button, x, y)
if self._scrollBarDragging then
local _, relY = self:getRelativePosition(x, y)
local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local height = self.getResolved("height")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and maxContentWidth > self.getResolved("width")
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local scrollHeight = contentHeight
local handleSize = math.max(1, math.floor((contentHeight / #flatNodes) * scrollHeight))
local maxOffset = #flatNodes - contentHeight
relY = math.max(1, math.min(scrollHeight, relY))
local newPos = relY - (self._scrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (scrollHeight - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("offset", math.max(0, math.min(maxOffset, newOffset)))
return true
end
if self._hScrollBarDragging then
local relX, _ = self:getRelativePosition(x, y)
local width = self.getResolved("width")
local maxContentWidth, _ = self:getNodeSize()
local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local height = self.getResolved("height")
local needsHorizontalScrollBar = self.getResolved("showScrollBar") and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = self.getResolved("showScrollBar") and #flatNodes > contentHeight
local contentWidth = needsVerticalScrollBar and width - 1 or width
local handleSize = math.max(1, math.floor((contentWidth / maxContentWidth) * contentWidth))
local maxOffset = maxContentWidth - contentWidth
relX = math.max(1, math.min(contentWidth, relX))
local newPos = relX - (self._hScrollBarDragOffset or 0)
local newPercent = ((newPos - 1) / (contentWidth - handleSize)) * 100
local newOffset = math.floor((newPercent / 100) * maxOffset + 0.5)
self.set("horizontalOffset", math.max(0, math.min(maxOffset, newOffset)))
return true
end
return VisualElement.mouse_drag and VisualElement.mouse_drag(self, button, x, y) or false
end
--- @shortDescription Handles mouse up events to stop scrollbar dragging
--- @param button number The mouse button that was released
--- @param x number The x-coordinate of the release
--- @param y number The y-coordinate of the release
--- @return boolean Whether the event was handled
--- @protected
function Tree:mouse_up(button, x, y)
if self._scrollBarDragging then
self._scrollBarDragging = false
self._scrollBarDragOffset = nil
return true
end
if self._hScrollBarDragging then
self._hScrollBarDragging = false
self._hScrollBarDragOffset = nil
return true
end
return VisualElement.mouse_up and VisualElement.mouse_up(self, button, x, y) or false
end
--- @shortDescription Handles mouse scroll events for vertical scrolling --- @shortDescription Handles mouse scroll events for vertical scrolling
--- @param direction number The scroll direction (1 for up, -1 for down) --- @param direction number The scroll direction (1 for up, -1 for down)
--- @param x number The x position of the scroll --- @param x number The x position of the scroll
@@ -150,11 +406,17 @@ end
--- @protected --- @protected
function Tree:mouse_scroll(direction, x, y) function Tree:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then if VisualElement.mouse_scroll(self, direction, x, y) then
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes")) local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local maxScroll = math.max(0, #flatNodes - self.get("height")) local height = self.getResolved("height")
local newScroll = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction)) local width = self.getResolved("width")
local showScrollBar = self.getResolved("showScrollBar")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = showScrollBar and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local maxScroll = math.max(0, #flatNodes - contentHeight)
local newScroll = math.min(maxScroll, math.max(0, self.getResolved("offset") + direction))
self.set("scrollOffset", newScroll) self.set("offset", newScroll)
return true return true
end end
return false return false
@@ -166,9 +428,21 @@ end
--- @return number height The height of the tree --- @return number height The height of the tree
function Tree:getNodeSize() function Tree:getNodeSize()
local width, height = 0, 0 local width, height = 0, 0
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes")) local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local expandedNodes = self.getResolved("expandedNodes")
for _, nodeInfo in ipairs(flatNodes) do for _, nodeInfo in ipairs(flatNodes) do
width = math.max(width, nodeInfo.level + #nodeInfo.node.text) local node = nodeInfo.node
local level = nodeInfo.level
local indent = string.rep(" ", level)
local symbol = " "
if node.children and #node.children > 0 then
symbol = expandedNodes[node] and "\31" or "\16"
end
local fullText = indent .. symbol .. " " .. (node.text or "Node")
width = math.max(width, #fullText)
end end
height = #flatNodes height = #flatNodes
return width, height return width, height
@@ -179,15 +453,22 @@ end
function Tree:render() function Tree:render()
VisualElement.render(self) VisualElement.render(self)
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes")) local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local height = self.get("height") local height = self.getResolved("height")
local selectedNode = self.get("selectedNode") local width = self.getResolved("width")
local expandedNodes = self.get("expandedNodes") local selectedNode = self.getResolved("selectedNode")
local scrollOffset = self.get("scrollOffset") local expandedNodes = self.getResolved("expandedNodes")
local horizontalOffset = self.get("horizontalOffset") local offset = self.getResolved("offset")
local horizontalOffset = self.getResolved("horizontalOffset")
local showScrollBar = self.getResolved("showScrollBar")
local maxContentWidth, _ = self:getNodeSize()
local needsHorizontalScrollBar = showScrollBar and maxContentWidth > width
local contentHeight = needsHorizontalScrollBar and height - 1 or height
local needsVerticalScrollBar = showScrollBar and #flatNodes > contentHeight
local contentWidth = needsVerticalScrollBar and width - 1 or width
for y = 1, height do for y = 1, contentHeight do
local nodeInfo = flatNodes[y + scrollOffset] local nodeInfo = flatNodes[y + offset]
if nodeInfo then if nodeInfo then
local node = nodeInfo.node local node = nodeInfo.node
local level = nodeInfo.level local level = nodeInfo.level
@@ -199,21 +480,65 @@ function Tree:render()
end end
local isSelected = node == selectedNode local isSelected = node == selectedNode
local _bg = isSelected and self.get("selectedBackgroundColor") or (node.background or node.bg or self.get("background")) local _bg = isSelected and self.getResolved("selectedBackgroundColor") or (node.background or node.bg or self.getResolved("background"))
local _fg = isSelected and self.get("selectedForegroundColor") or (node.foreground or node.fg or self.get("foreground")) local _fg = isSelected and self.getResolved("selectedForegroundColor") or (node.foreground or node.fg or self.getResolved("foreground"))
local fullText = indent .. symbol .. " " .. (node.text or "Node") local fullText = indent .. symbol .. " " .. (node.text or "Node")
local text = sub(fullText, horizontalOffset + 1, horizontalOffset + self.get("width")) local text = sub(fullText, horizontalOffset + 1, horizontalOffset + contentWidth)
local paddedText = text .. string.rep(" ", self.get("width") - #text) local paddedText = text .. string.rep(" ", contentWidth - #text)
local bg = tHex[_bg]:rep(#paddedText) or tHex[colors.black]:rep(#paddedText) local bg = tHex[_bg]:rep(#paddedText) or tHex[colors.black]:rep(#paddedText)
local fg = tHex[_fg]:rep(#paddedText) or tHex[colors.white]:rep(#paddedText) local fg = tHex[_fg]:rep(#paddedText) or tHex[colors.white]:rep(#paddedText)
self:blit(1, y, paddedText, fg, bg) self:blit(1, y, paddedText, fg, bg)
else else
self:blit(1, y, string.rep(" ", self.get("width")), tHex[self.get("foreground")]:rep(self.get("width")), tHex[self.get("background")]:rep(self.get("width"))) self:blit(1, y, string.rep(" ", contentWidth), tHex[self.getResolved("foreground")]:rep(contentWidth), tHex[self.getResolved("background")]:rep(contentWidth))
end end
end end
local scrollBarSymbol = self.getResolved("scrollBarSymbol")
local scrollBarBg = self.getResolved("scrollBarBackground")
local scrollBarColor = self.getResolved("scrollBarColor")
local scrollBarBgColor = self.getResolved("scrollBarBackgroundColor")
local foreground = self.getResolved("foreground")
if needsVerticalScrollBar then
local scrollHeight = needsHorizontalScrollBar and height - 1 or height
local handleSize = math.max(1, math.floor((contentHeight / #flatNodes) * scrollHeight))
local maxOffset = #flatNodes - contentHeight
local currentPercent = maxOffset > 0 and (offset / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollHeight - handleSize)) + 1
for i = 1, scrollHeight do
self:blit(width, i, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
for i = handlePos, math.min(scrollHeight, handlePos + handleSize - 1) do
self:blit(width, i, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
end
end
if needsHorizontalScrollBar then
local scrollWidth = needsVerticalScrollBar and width - 1 or width
local handleSize = math.max(1, math.floor((scrollWidth / maxContentWidth) * scrollWidth))
local maxOffset = maxContentWidth - contentWidth
local currentPercent = maxOffset > 0 and (horizontalOffset / maxOffset * 100) or 0
local handlePos = math.floor((currentPercent / 100) * (scrollWidth - handleSize)) + 1
for i = 1, scrollWidth do
self:blit(i, height, scrollBarBg, tHex[foreground], tHex[scrollBarBgColor])
end
for i = handlePos, math.min(scrollWidth, handlePos + handleSize - 1) do
self:blit(i, height, scrollBarSymbol, tHex[scrollBarColor], tHex[scrollBarBgColor])
end
end
if needsVerticalScrollBar and needsHorizontalScrollBar then
self:blit(width, height, " ", tHex[foreground], tHex[self.getResolved("background")])
end
end end
return Tree return Tree

View File

@@ -22,6 +22,12 @@ VisualElement.defineProperty(VisualElement, "z", {default = 1, type = "number",
return value return value
end}) end})
VisualElement.defineProperty(VisualElement, "constraints", {
default = {},
type = "table"
})
---@property width number 1 The width of the element ---@property width number 1 The width of the element
VisualElement.defineProperty(VisualElement, "width", {default = 1, type = "number", canTriggerRender = true}) VisualElement.defineProperty(VisualElement, "width", {default = 1, type = "number", canTriggerRender = true})
---@property height number 1 The height of the element ---@property height number 1 The height of the element
@@ -30,10 +36,6 @@ VisualElement.defineProperty(VisualElement, "height", {default = 1, type = "numb
VisualElement.defineProperty(VisualElement, "background", {default = colors.black, type = "color", canTriggerRender = true}) VisualElement.defineProperty(VisualElement, "background", {default = colors.black, type = "color", canTriggerRender = true})
---@property foreground color white The text/foreground color ---@property foreground color white The text/foreground color
VisualElement.defineProperty(VisualElement, "foreground", {default = colors.white, type = "color", canTriggerRender = true}) VisualElement.defineProperty(VisualElement, "foreground", {default = colors.white, type = "color", canTriggerRender = true})
---@property clicked boolean false Whether the element is currently clicked
VisualElement.defineProperty(VisualElement, "clicked", {default = false, type = "boolean"})
---@property hover boolean false Whether the mouse is currently hover over the element (Craftos-PC only)
VisualElement.defineProperty(VisualElement, "hover", {default = false, type = "boolean"})
---@property backgroundEnabled boolean true Whether to render the background ---@property backgroundEnabled boolean true Whether to render the background
VisualElement.defineProperty(VisualElement, "backgroundEnabled", {default = true, type = "boolean", canTriggerRender = true}) VisualElement.defineProperty(VisualElement, "backgroundEnabled", {default = true, type = "boolean", canTriggerRender = true})
---@property borderTop boolean false Draw top border ---@property borderTop boolean false Draw top border
@@ -46,26 +48,6 @@ VisualElement.defineProperty(VisualElement, "borderLeft", {default = false, type
VisualElement.defineProperty(VisualElement, "borderRight", {default = false, type = "boolean", canTriggerRender = true}) VisualElement.defineProperty(VisualElement, "borderRight", {default = false, type = "boolean", canTriggerRender = true})
---@property borderColor color white Border color ---@property borderColor color white Border color
VisualElement.defineProperty(VisualElement, "borderColor", {default = colors.white, type = "color", canTriggerRender = true}) VisualElement.defineProperty(VisualElement, "borderColor", {default = colors.white, type = "color", canTriggerRender = true})
---@property focused boolean false Whether the element has input focus
VisualElement.defineProperty(VisualElement, "focused", {default = false, type = "boolean", setter = function(self, value, internal)
local curValue = self.get("focused")
if value == curValue then return value end
if value then
self:focus()
else
self:blur()
end
if not internal and self.parent then
if value then
self.parent:setFocusedChild(self)
else
self.parent:setFocusedChild(nil)
end
end
return value
end})
---@property visible boolean true Whether the element is visible ---@property visible boolean true Whether the element is visible
VisualElement.defineProperty(VisualElement, "visible", {default = true, type = "boolean", canTriggerRender = true, setter=function(self, value) VisualElement.defineProperty(VisualElement, "visible", {default = true, type = "boolean", canTriggerRender = true, setter=function(self, value)
@@ -74,7 +56,7 @@ VisualElement.defineProperty(VisualElement, "visible", {default = true, type = "
self.parent.set("childrenEventsSorted", false) self.parent.set("childrenEventsSorted", false)
end end
if(value==false)then if(value==false)then
self.set("clicked", false) self:unsetState("clicked")
end end
return value return value
end}) end})
@@ -82,6 +64,9 @@ end})
---@property ignoreOffset boolean false Whether to ignore the parent's offset ---@property ignoreOffset boolean false Whether to ignore the parent's offset
VisualElement.defineProperty(VisualElement, "ignoreOffset", {default = false, type = "boolean"}) VisualElement.defineProperty(VisualElement, "ignoreOffset", {default = false, type = "boolean"})
---@property layoutConfig table {} Configuration for layout systems (grow, shrink, alignSelf, etc.)
VisualElement.defineProperty(VisualElement, "layoutConfig", {default = {}, type = "table"})
---@combinedProperty position {x number, y number} Combined x, y position ---@combinedProperty position {x number, y number} Combined x, y position
VisualElement.combineProperties(VisualElement, "position", "x", "y") VisualElement.combineProperties(VisualElement, "position", "x", "y")
---@combinedProperty size {width number, height number} Combined width, height ---@combinedProperty size {width number, height number} Combined width, height
@@ -90,7 +75,7 @@ VisualElement.combineProperties(VisualElement, "size", "width", "height")
VisualElement.combineProperties(VisualElement, "color", "foreground", "background") VisualElement.combineProperties(VisualElement, "color", "foreground", "background")
---@event onClick {button string, x number, y number} Fired on mouse click ---@event onClick {button string, x number, y number} Fired on mouse click
---@event onMouseUp {button, x, y} Fired on mouse button release ---@event onClickUp {button, x, y} Fired on mouse button release
---@event onRelease {button, x, y} Fired when mouse leaves while clicked ---@event onRelease {button, x, y} Fired when mouse leaves while clicked
---@event onDrag {button, x, y} Fired when mouse moves while clicked ---@event onDrag {button, x, y} Fired when mouse moves while clicked
---@event onScroll {direction, x, y} Fired on mouse scroll ---@event onScroll {direction, x, y} Fired on mouse scroll
@@ -136,6 +121,12 @@ end
function VisualElement:init(props, basalt) function VisualElement:init(props, basalt)
BaseElement.init(self, props, basalt) BaseElement.init(self, props, basalt)
self.set("type", "VisualElement") self.set("type", "VisualElement")
self:registerState("disabled", nil, 1000)
self:registerState("clicked", nil, 500)
self:registerState("hover", nil, 400)
self:registerState("focused", nil, 300)
self:registerState("dragging", nil, 600)
self:observe("x", function() self:observe("x", function()
if self.parent then if self.parent then
self.parent.set("childrenSorted", false) self.parent.set("childrenSorted", false)
@@ -163,6 +154,530 @@ function VisualElement:init(props, basalt)
end) end)
end end
--- Sets a constraint on a property relative to another element's property
--- @shortDescription Sets a constraint on a property relative to another element's property
--- @param property string The property to constrain (x, y, width, height, left, right, top, bottom, centerX, centerY)
--- @param targetElement BaseElement|string The target element or "parent"
--- @param targetProperty string The target property to constrain to (left, right, top, bottom, centerX, centerY, width, height)
--- @param offset number The offset to apply (negative = inside, positive = outside, fractional = percentage)
--- @return VisualElement self The element instance
function VisualElement:setConstraint(property, targetElement, targetProperty, offset)
local constraints = self.getResolved("constraints")
if constraints[property] then
self:_removeConstraintObservers(property, constraints[property])
end
constraints[property] = {
element = targetElement,
property = targetProperty,
offset = offset or 0
}
self.set("constraints", constraints)
self:_addConstraintObservers(property, constraints[property])
self._constraintsDirty = true
self:updateRender()
return self
end
--- Updates a single property in the layoutConfig table
--- @shortDescription Updates a single layout config property without replacing the entire table
--- @param key string The layout config property to update (grow, shrink, basis, alignSelf, order, etc.)
--- @param value any The value to set for the property
--- @return VisualElement self The element instance
function VisualElement:setLayoutConfigProperty(key, value)
local layoutConfig = self.getResolved("layoutConfig")
layoutConfig[key] = value
self.set("layoutConfig", layoutConfig)
return self
end
--- Gets a single property from the layoutConfig table
--- @shortDescription Gets a single layout config property
--- @param key string The layout config property to get
--- @return any value The value of the property, or nil if not set
function VisualElement:getLayoutConfigProperty(key)
local layoutConfig = self.getResolved("layoutConfig")
return layoutConfig[key]
end
--- Resolves all constraints for the element
--- @shortDescription Resolves all constraints for the element
--- @return VisualElement self The element instance
function VisualElement:resolveAllConstraints()
if not self._constraintsDirty then return self end
local constraints = self.getResolved("constraints")
if not constraints or not next(constraints) then return self end
local order = {"width", "height", "left", "right", "top", "bottom", "x", "y", "centerX", "centerY"}
for _, property in ipairs(order) do
if constraints[property] then
local value = self:_resolveConstraint(property, constraints[property])
self:_applyConstraintValue(property, value, constraints)
end
end
self._constraintsDirty = false
return self
end
--- Applies a resolved constraint value to the appropriate property
--- @private
function VisualElement:_applyConstraintValue(property, value, constraints)
if property == "x" or property == "left" then
self.set("x", value)
elseif property == "y" or property == "top" then
self.set("y", value)
elseif property == "right" then
if constraints.left then
local leftValue = self:_resolveConstraint("left", constraints.left)
local width = value - leftValue + 1
self.set("width", width)
self.set("x", leftValue)
else
local width = self.getResolved("width")
self.set("x", value - width + 1)
end
elseif property == "bottom" then
if constraints.top then
local topValue = self:_resolveConstraint("top", constraints.top)
local height = value - topValue + 1
self.set("height", height)
self.set("y", topValue)
else
local height = self.getResolved("height")
self.set("y", value - height + 1)
end
elseif property == "centerX" then
local width = self.getResolved("width")
self.set("x", value - math.floor(width / 2))
elseif property == "centerY" then
local height = self.getResolved("height")
self.set("y", value - math.floor(height / 2))
elseif property == "width" then
self.set("width", value)
elseif property == "height" then
self.set("height", value)
end
end
--- Adds observers for a specific constraint to track changes in the target element
--- @private
function VisualElement:_addConstraintObservers(constraintProp, constraint)
local targetEl = constraint.element
local targetProp = constraint.property
if targetEl == "parent" then
targetEl = self.parent
end
if not targetEl then return end
local callback = function()
self._constraintsDirty = true
self:resolveAllConstraints()
self:updateRender()
end
if not self._constraintObserverCallbacks then
self._constraintObserverCallbacks = {}
end
if not self._constraintObserverCallbacks[constraintProp] then
self._constraintObserverCallbacks[constraintProp] = {}
end
local observeProps = {}
if targetProp == "left" or targetProp == "x" then
observeProps = {"x"}
elseif targetProp == "right" then
observeProps = {"x", "width"}
elseif targetProp == "top" or targetProp == "y" then
observeProps = {"y"}
elseif targetProp == "bottom" then
observeProps = {"y", "height"}
elseif targetProp == "centerX" then
observeProps = {"x", "width"}
elseif targetProp == "centerY" then
observeProps = {"y", "height"}
elseif targetProp == "width" then
observeProps = {"width"}
elseif targetProp == "height" then
observeProps = {"height"}
end
for _, prop in ipairs(observeProps) do
targetEl:observe(prop, callback)
table.insert(self._constraintObserverCallbacks[constraintProp], {
element = targetEl,
property = prop,
callback = callback
})
end
end
--- Removes observers for a specific constraint
--- @private
function VisualElement:_removeConstraintObservers(constraintProp, constraint)
if not self._constraintObserverCallbacks or not self._constraintObserverCallbacks[constraintProp] then
return
end
for _, observer in ipairs(self._constraintObserverCallbacks[constraintProp]) do
observer.element:removeObserver(observer.property, observer.callback)
end
self._constraintObserverCallbacks[constraintProp] = nil
end
--- Removes all constraint observers from the element
--- @private
function VisualElement:_removeAllConstraintObservers()
if not self._constraintObserverCallbacks then return end
for constraintProp, observers in pairs(self._constraintObserverCallbacks) do
for _, observer in ipairs(observers) do
observer.element:removeObserver(observer.property, observer.callback)
end
end
self._constraintObserverCallbacks = nil
end
--- Removes a constraint from the element
--- @shortDescription Removes a constraint from the element
--- @param property string The property of the constraint to remove
--- @return VisualElement self The element instance
function VisualElement:removeConstraint(property)
local constraints = self.getResolved("constraints")
constraints[property] = nil
self.set("constraints", constraints)
self:updateConstraints()
return self
end
--- Updates all constraints, recalculating positions and sizes
--- @shortDescription Updates all constraints, recalculating positions and sizes
--- @return VisualElement self The element instance
function VisualElement:updateConstraints()
local constraints = self.getResolved("constraints")
for property, constraint in pairs(constraints) do
local value = self:_resolveConstraint(property, constraint)
if property == "x" or property == "left" then
self.set("x", value)
elseif property == "y" or property == "top" then
self.set("y", value)
elseif property == "right" then
local width = self.getResolved("width")
self.set("x", value - width + 1)
elseif property == "bottom" then
local height = self.getResolved("height")
self.set("y", value - height + 1)
elseif property == "centerX" then
local width = self.getResolved("width")
self.set("x", value - math.floor(width / 2))
elseif property == "centerY" then
local height = self.getResolved("height")
self.set("y", value - math.floor(height / 2))
elseif property == "width" then
self.set("width", value)
elseif property == "height" then
self.set("height", value)
end
end
end
--- Resolves a constraint to an absolute value
--- @private
function VisualElement:_resolveConstraint(property, constraint)
local targetEl = constraint.element
local targetProp = constraint.property
local offset = constraint.offset
if targetEl == "parent" then
targetEl = self.parent
end
if not targetEl then
return self.getResolved(property) or 1
end
local value
if targetProp == "left" or targetProp == "x" then
value = targetEl.get("x")
elseif targetProp == "right" then
value = targetEl.get("x") + targetEl.get("width") - 1
elseif targetProp == "top" or targetProp == "y" then
value = targetEl.get("y")
elseif targetProp == "bottom" then
value = targetEl.get("y") + targetEl.get("height") - 1
elseif targetProp == "centerX" then
value = targetEl.get("x") + math.floor(targetEl.get("width") / 2)
elseif targetProp == "centerY" then
value = targetEl.get("y") + math.floor(targetEl.get("height") / 2)
elseif targetProp == "width" then
value = targetEl.get("width")
elseif targetProp == "height" then
value = targetEl.get("height")
end
if type(offset) == "number" then
if offset > -1 and offset < 1 and offset ~= 0 then
return math.floor(value * offset)
else
return value + offset
end
end
return value
end
--- Aligns the element's right edge to the target's right edge with optional offset
--- @shortDescription Aligns the element's right edge to the target's right edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignRight(target, offset)
offset = offset or 0
return self:setConstraint("right", target, "right", offset)
end
--- Aligns the element's left edge to the target's left edge with optional offset
--- @shortDescription Aligns the element's left edge to the target's left edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignLeft(target, offset)
offset = offset or 0
return self:setConstraint("left", target, "left", offset)
end
--- Aligns the element's top edge to the target's top edge with optional offset
--- @shortDescription Aligns the element's top edge to the target's top edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignTop(target, offset)
offset = offset or 0
return self:setConstraint("top", target, "top", offset)
end
--- Aligns the element's bottom edge to the target's bottom edge with optional offset
--- @shortDescription Aligns the element's bottom edge to the target's bottom edge with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset from the edge (negative = inside, positive = outside, default: 0)
--- @return VisualElement self
function VisualElement:alignBottom(target, offset)
offset = offset or 0
return self:setConstraint("bottom", target, "bottom", offset)
end
--- Centers the element horizontally relative to the target with optional offset
--- @shortDescription Centers the element horizontally relative to the target with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Horizontal offset from center (default: 0)
--- @return VisualElement self
function VisualElement:centerHorizontal(target, offset)
offset = offset or 0
return self:setConstraint("centerX", target, "centerX", offset)
end
--- Centers the element vertically relative to the target with optional offset
--- @shortDescription Centers the element vertically relative to the target with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Vertical offset from center (default: 0)
--- @return VisualElement self
function VisualElement:centerVertical(target, offset)
offset = offset or 0
return self:setConstraint("centerY", target, "centerY", offset)
end
--- Centers the element both horizontally and vertically relative to the target
--- @shortDescription Centers the element both horizontally and vertically relative to the target
--- @param target BaseElement|string The target element or "parent"
--- @return VisualElement self
function VisualElement:centerIn(target)
return self:centerHorizontal(target):centerVertical(target)
end
--- Positions the element to the right of the target with optional gap
--- @shortDescription Positions the element to the right of the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:rightOf(target, gap)
gap = gap or 0
return self:setConstraint("left", target, "right", gap)
end
--- Positions the element to the left of the target with optional gap
--- @shortDescription Positions the element to the left of the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:leftOf(target, gap)
gap = gap or 0
return self:setConstraint("right", target, "left", -gap)
end
--- Positions the element below the target with optional gap
--- @shortDescription Positions the element below the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:below(target, gap)
gap = gap or 0
return self:setConstraint("top", target, "bottom", gap)
end
--- Positions the element above the target with optional gap
--- @shortDescription Positions the element above the target with optional gap
--- @param target BaseElement|string The target element or "parent"
--- @param gap? number Gap between elements (default: 0)
--- @return VisualElement self
function VisualElement:above(target, gap)
gap = gap or 0
return self:setConstraint("bottom", target, "top", -gap)
end
--- Stretches the element to match the target's width with optional margin
--- @shortDescription Stretches the element to match the target's width with optional margin
--- @param target BaseElement|string The target element or "parent"
--- @param margin? number Margin on each side (default: 0)
--- @return VisualElement self
function VisualElement:stretchWidth(target, margin)
margin = margin or 0
return self
:setConstraint("left", target, "left", margin)
:setConstraint("right", target, "right", -margin)
end
--- Stretches the element to match the target's height with optional margin
--- @shortDescription Stretches the element to match the target's height with optional margin
--- @param target BaseElement|string The target element or "parent"
--- @param margin? number Margin on top and bottom (default: 0)
--- @return VisualElement self
function VisualElement:stretchHeight(target, margin)
margin = margin or 0
return self
:setConstraint("top", target, "top", margin)
:setConstraint("bottom", target, "bottom", -margin)
end
--- Stretches the element to match the target's width and height with optional margin
--- @shortDescription Stretches the element to match the target's width and height with optional margin
--- @param target BaseElement|string The target element or "parent"
--- @param margin? number Margin on all sides (default: 0)
--- @return VisualElement self
function VisualElement:stretch(target, margin)
return self:stretchWidth(target, margin):stretchHeight(target, margin)
end
--- Sets the element's width as a percentage of the target's width
--- @shortDescription Sets the element's width as a percentage of the target's width
--- @param target BaseElement|string The target element or "parent"
--- @param percent number Percentage of target's width (0-100)
--- @return VisualElement self
function VisualElement:widthPercent(target, percent)
return self:setConstraint("width", target, "width", percent / 100)
end
--- Sets the element's height as a percentage of the target's height
--- @shortDescription Sets the element's height as a percentage of the target's height
--- @param target BaseElement|string The target element or "parent"
--- @param percent number Percentage of target's height (0-100)
--- @return VisualElement self
function VisualElement:heightPercent(target, percent)
return self:setConstraint("height", target, "height", percent / 100)
end
--- Matches the element's width to the target's width with optional offset
--- @shortDescription Matches the element's width to the target's width with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset to add to target's width (default: 0)
--- @return VisualElement self
function VisualElement:matchWidth(target, offset)
offset = offset or 0
return self:setConstraint("width", target, "width", offset)
end
--- Matches the element's height to the target's height with optional offset
--- @shortDescription Matches the element's height to the target's height with optional offset
--- @param target BaseElement|string The target element or "parent"
--- @param offset? number Offset to add to target's height (default: 0)
--- @return VisualElement self
function VisualElement:matchHeight(target, offset)
offset = offset or 0
return self:setConstraint("height", target, "height", offset)
end
--- Stretches the element to fill its parent's width and height with optional margin
--- @shortDescription Stretches the element to fill its parent's width and height with optional margin
--- @param margin? number Margin on all sides (default: 0)
--- @return VisualElement self
function VisualElement:fillParent(margin)
return self:stretch("parent", margin)
end
--- Stretches the element to fill its parent's width with optional margin
--- @shortDescription Stretches the element to fill its parent's width with optional margin
--- @param margin? number Margin on left and right (default: 0)
--- @return VisualElement self
function VisualElement:fillWidth(margin)
return self:stretchWidth("parent", margin)
end
--- Stretches the element to fill its parent's height with optional margin
--- @shortDescription Stretches the element to fill its parent's height with optional margin
--- @param margin? number Margin on top and bottom (default: 0)
--- @return VisualElement self
function VisualElement:fillHeight(margin)
return self:stretchHeight("parent", margin)
end
--- Centers the element within its parent both horizontally and vertically
--- @shortDescription Centers the element within its parent both horizontally and vertically
--- @return VisualElement self
function VisualElement:center()
return self:centerIn("parent")
end
--- Aligns the element's right edge to its parent's right edge with optional gap
--- @shortDescription Aligns the element's right edge to its parent's right edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toRight(gap)
return self:alignRight("parent", -(gap or 0))
end
--- Aligns the element's left edge to its parent's left edge with optional gap
--- @shortDescription Aligns the element's left edge to its parent's left edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toLeft(gap)
return self:alignLeft("parent", gap or 0)
end
--- Aligns the element's top edge to its parent's top edge with optional gap
--- @shortDescription Aligns the element's top edge to its parent's top edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toTop(gap)
return self:alignTop("parent", gap or 0)
end
--- Aligns the element's bottom edge to its parent's bottom edge with optional gap
--- @shortDescription Aligns the element's bottom edge to its parent's bottom edge with optional gap
--- @param gap? number Gap from the edge (default: 0)
--- @return VisualElement self
function VisualElement:toBottom(gap)
return self:alignBottom("parent", -(gap or 0))
end
--- @shortDescription Multi-character drawing with colors --- @shortDescription Multi-character drawing with colors
--- @param x number The x position to draw --- @param x number The x position to draw
--- @param y number The y position to draw --- @param y number The y position to draw
@@ -246,9 +761,9 @@ end
--- @param y number The y position to check --- @param y number The y position to check
--- @return boolean isInBounds Whether the coordinates are within the bounds of the element --- @return boolean isInBounds Whether the coordinates are within the bounds of the element
function VisualElement:isInBounds(x, y) function VisualElement:isInBounds(x, y)
local xPos, yPos = self.get("x"), self.get("y") local xPos, yPos = self.getResolved("x"), self.getResolved("y")
local width, height = self.get("width"), self.get("height") local width, height = self.getResolved("width"), self.getResolved("height")
if(self.get("ignoreOffset"))then if(self.getResolved("ignoreOffset"))then
if(self.parent)then if(self.parent)then
x = x - self.parent.get("offsetX") x = x - self.parent.get("offsetX")
y = y - self.parent.get("offsetY") y = y - self.parent.get("offsetY")
@@ -267,7 +782,7 @@ end
--- @protected --- @protected
function VisualElement:mouse_click(button, x, y) function VisualElement:mouse_click(button, x, y)
if self:isInBounds(x, y) then if self:isInBounds(x, y) then
self.set("clicked", true) self:setState("clicked")
self:fireEvent("mouse_click", button, self:getRelativePosition(x, y)) self:fireEvent("mouse_click", button, self:getRelativePosition(x, y))
return true return true
end end
@@ -282,7 +797,8 @@ end
--- @protected --- @protected
function VisualElement:mouse_up(button, x, y) function VisualElement:mouse_up(button, x, y)
if self:isInBounds(x, y) then if self:isInBounds(x, y) then
self.set("clicked", false) self:unsetState("clicked")
self:unsetState("dragging")
self:fireEvent("mouse_up", button, self:getRelativePosition(x, y)) self:fireEvent("mouse_up", button, self:getRelativePosition(x, y))
return true return true
end end
@@ -296,7 +812,8 @@ end
--- @protected --- @protected
function VisualElement:mouse_release(button, x, y) function VisualElement:mouse_release(button, x, y)
self:fireEvent("mouse_release", button, self:getRelativePosition(x, y)) self:fireEvent("mouse_release", button, self:getRelativePosition(x, y))
self.set("clicked", false) self:unsetState("clicked")
self:unsetState("dragging")
end end
---@shortDescription Handles a mouse move event ---@shortDescription Handles a mouse move event
@@ -307,7 +824,7 @@ end
--- @protected --- @protected
function VisualElement:mouse_move(_, x, y) function VisualElement:mouse_move(_, x, y)
if(x==nil)or(y==nil)then return false end if(x==nil)or(y==nil)then return false end
local hover = self.get("hover") local hover = self.getResolved("hover")
if(self:isInBounds(x, y))then if(self:isInBounds(x, y))then
if(not hover)then if(not hover)then
self.set("hover", true) self.set("hover", true)
@@ -344,13 +861,51 @@ end
--- @return boolean drag Whether the element was dragged --- @return boolean drag Whether the element was dragged
--- @protected --- @protected
function VisualElement:mouse_drag(button, x, y) function VisualElement:mouse_drag(button, x, y)
if(self.get("clicked"))then if(self:hasState("clicked"))then
self:fireEvent("mouse_drag", button, self:getRelativePosition(x, y)) self:fireEvent("mouse_drag", button, self:getRelativePosition(x, y))
return true return true
end end
return false return false
end end
--- Sets or removes focus from this element
--- @shortDescription Sets focus state
--- @param focused boolean Whether to focus or blur
--- @param internal? boolean Internal flag to prevent parent notification
--- @return VisualElement self
function VisualElement:setFocused(focused, internal)
local currentlyFocused = self:hasState("focused")
if focused == currentlyFocused then
return self
end
if focused then
self:setState("focused")
self:focus()
if not internal and self.parent then
self.parent:setFocusedChild(self)
end
else
self:unsetState("focused")
self:blur()
if not internal and self.parent then
self.parent:setFocusedChild(nil)
end
end
return self
end
--- Gets whether this element is focused
--- @shortDescription Checks if element is focused
--- @return boolean isFocused
function VisualElement:isFocused()
return self:hasState("focused")
end
--- @shortDescription Handles a focus event --- @shortDescription Handles a focus event
--- @protected --- @protected
function VisualElement:focus() function VisualElement:focus()
@@ -362,13 +917,19 @@ end
function VisualElement:blur() function VisualElement:blur()
self:fireEvent("blur") self:fireEvent("blur")
-- Attempt to clear cursor; signature may expect (x,y,blink,fg,bg) -- Attempt to clear cursor; signature may expect (x,y,blink,fg,bg)
pcall(function() self:setCursor(1,1,false, self.get and self.get("foreground")) end) pcall(function() self:setCursor(1,1,false, self.get and self.getResolved("foreground")) end)
end end
--- Adds or updates a drawable character border around the element using the canvas plugin. --- Gets whether this element is focused
--- The border will automatically adapt to size/background changes because the command --- @shortDescription Checks if element is focused
--- reads current properties each render. --- @return boolean isFocused
-- @param colorOrOptions any Border color or options table function VisualElement:isFocused()
return self:hasState("focused")
end
--- Adds or updates a drawable character border around the element. The border will automatically adapt to size/background changes because the command reads current properties each render.
--- @param colorOrOptions any Border color or options table
--- @param sideOptions? table Side options table (if color is provided as first argument)
--- @return VisualElement self --- @return VisualElement self
function VisualElement:addBorder(colorOrOptions, sideOptions) function VisualElement:addBorder(colorOrOptions, sideOptions)
local col = nil local col = nil
@@ -410,7 +971,7 @@ end
--- @param key number The key that was pressed --- @param key number The key that was pressed
--- @protected --- @protected
function VisualElement:key(key, held) function VisualElement:key(key, held)
if(self.get("focused"))then if(self:hasState("focused"))then
self:fireEvent("key", key, held) self:fireEvent("key", key, held)
end end
end end
@@ -419,7 +980,7 @@ end
--- @param key number The key that was released --- @param key number The key that was released
--- @protected --- @protected
function VisualElement:key_up(key) function VisualElement:key_up(key)
if(self.get("focused"))then if(self:hasState("focused"))then
self:fireEvent("key_up", key) self:fireEvent("key_up", key)
end end
end end
@@ -428,7 +989,7 @@ end
--- @param char string The character that was pressed --- @param char string The character that was pressed
--- @protected --- @protected
function VisualElement:char(char) function VisualElement:char(char)
if(self.get("focused"))then if(self:hasState("focused"))then
self:fireEvent("char", char) self:fireEvent("char", char)
end end
end end
@@ -438,8 +999,9 @@ end
--- @return number x The x position --- @return number x The x position
--- @return number y The y position --- @return number y The y position
function VisualElement:calculatePosition() function VisualElement:calculatePosition()
local x, y = self.get("x"), self.get("y") self:resolveAllConstraints()
if not self.get("ignoreOffset") then local x, y = self.getResolved("x"), self.getResolved("y")
if not self.getResolved("ignoreOffset") then
if self.parent ~= nil then if self.parent ~= nil then
local xO, yO = self.parent.get("offsetX"), self.parent.get("offsetY") local xO, yO = self.parent.get("offsetX"), self.parent.get("offsetY")
x = x - xO x = x - xO
@@ -456,7 +1018,7 @@ end
---@return number x The absolute x position ---@return number x The absolute x position
---@return number y The absolute y position ---@return number y The absolute y position
function VisualElement:getAbsolutePosition(x, y) function VisualElement:getAbsolutePosition(x, y)
local xPos, yPos = self.get("x"), self.get("y") local xPos, yPos = self.getResolved("x"), self.getResolved("y")
if(x ~= nil) then if(x ~= nil) then
xPos = xPos + x - 1 xPos = xPos + x - 1
end end
@@ -477,13 +1039,13 @@ end
--- Returns the relative position of the element or the given coordinates. --- Returns the relative position of the element or the given coordinates.
--- @shortDescription Returns the relative position of the element --- @shortDescription Returns the relative position of the element
---@param x? number x position --- @param x? number x position
---@param y? number y position --- @param y? number y position
---@return number x The relative x position --- @return number x The relative x position
---@return number y The relative y position --- @return number y The relative y position
function VisualElement:getRelativePosition(x, y) function VisualElement:getRelativePosition(x, y)
if (x == nil) or (y == nil) then if (x == nil) or (y == nil) then
x, y = self.get("x"), self.get("y") x, y = self.getResolved("x"), self.getResolved("y")
end end
local parentX, parentY = 1, 1 local parentX, parentY = 1, 1
@@ -491,7 +1053,7 @@ function VisualElement:getRelativePosition(x, y)
parentX, parentY = self.parent:getRelativePosition() parentX, parentY = self.parent:getRelativePosition()
end end
local elementX, elementY = self.get("x"), self.get("y") local elementX, elementY = self.getResolved("x"), self.getResolved("y")
return x - (elementX - 1) - (parentX - 1), return x - (elementX - 1) - (parentX - 1),
y - (elementY - 1) - (parentY - 1) y - (elementY - 1) - (parentY - 1)
end end
@@ -531,31 +1093,36 @@ end
--- @shortDescription Renders the element --- @shortDescription Renders the element
--- @protected --- @protected
function VisualElement:render() function VisualElement:render()
if(not self.get("backgroundEnabled"))then return end if(not self.getResolved("backgroundEnabled"))then return end
local width, height = self.get("width"), self.get("height") local width, height = self.getResolved("width"), self.getResolved("height")
local fgHex = tHex[self.get("foreground")] local fgHex = tHex[self.getResolved("foreground")]
local bgHex = tHex[self.get("background")] local bgHex = tHex[self.getResolved("background")]
local bTop, bBottom, bLeft, bRight =
self.getResolved("borderTop"),
self.getResolved("borderBottom"),
self.getResolved("borderLeft"),
self.getResolved("borderRight")
self:multiBlit(1, 1, width, height, " ", fgHex, bgHex) self:multiBlit(1, 1, width, height, " ", fgHex, bgHex)
if (self.get("borderTop") or self.get("borderBottom") or self.get("borderLeft") or self.get("borderRight")) then if (bTop or bBottom or bLeft or bRight) then
local bColor = self.get("borderColor") or self.get("foreground") local bColor = self.getResolved("borderColor") or self.getResolved("foreground")
local bHex = tHex[bColor] or fgHex local bHex = tHex[bColor] or fgHex
if self.get("borderTop") then if bTop then
self:textFg(1,1,("\131"):rep(width), bColor) self:textFg(1,1,("\131"):rep(width), bColor)
end end
if self.get("borderBottom") then if bBottom then
self:multiBlit(1,height,width,1,"\143", bgHex, bHex) self:multiBlit(1,height,width,1,"\143", bgHex, bHex)
end end
if self.get("borderLeft") then if bLeft then
self:multiBlit(1,1,1,height,"\149", bHex, bgHex) self:multiBlit(1,1,1,height,"\149", bHex, bgHex)
end end
if self.get("borderRight") then if bRight then
self:multiBlit(width,1,1,height,"\149", bgHex, bHex) self:multiBlit(width,1,1,height,"\149", bgHex, bHex)
end end
-- Corners -- Corners
if self.get("borderTop") and self.get("borderLeft") then self:blit(1,1,"\151", bHex, bgHex) end if bTop and bLeft then self:blit(1,1,"\151", bHex, bgHex) end
if self.get("borderTop") and self.get("borderRight") then self:blit(width,1,"\148", bgHex, bHex) end if bTop and bRight then self:blit(width,1,"\148", bgHex, bHex) end
if self.get("borderBottom") and self.get("borderLeft") then self:blit(1,height,"\138", bgHex, bHex) end if bBottom and bLeft then self:blit(1,height,"\138", bgHex, bHex) end
if self.get("borderBottom") and self.get("borderRight") then self:blit(width,height,"\133", bgHex, bHex) end if bBottom and bRight then self:blit(width,height,"\133", bgHex, bHex) end
end end
end end
@@ -565,8 +1132,9 @@ function VisualElement:postRender()
end end
function VisualElement:destroy() function VisualElement:destroy()
self:_removeAllConstraintObservers()
self.set("visible", false) self.set("visible", false)
BaseElement.destroy(self) BaseElement.destroy(self)
end end
return VisualElement return VisualElement

117
src/layoutManager.lua Normal file
View File

@@ -0,0 +1,117 @@
--- LayoutManager - Core layout system for Basalt
--- Handles loading and applying layouts to containers
local LayoutManager = {}
LayoutManager._cache = {}
--- Loads a layout from a file
--- @param path string Path to the layout file
--- @return table layout The loaded layout module
function LayoutManager.load(path)
if LayoutManager._cache[path] then
return LayoutManager._cache[path]
end
local success, layout = pcall(require, path)
if not success then
error("Failed to load layout: " .. path .. "\n" .. layout)
end
if type(layout) ~= "table" then
error("Layout must return a table: " .. path)
end
if type(layout.calculate) ~= "function" then
error("Layout must have a calculate() function: " .. path)
end
LayoutManager._cache[path] = layout
return layout
end
--- Applies a layout to a container
--- @param container Container The container to apply the layout to
--- @param layoutPath string Path to the layout file
--- @return table layoutInstance The layout instance
function LayoutManager.apply(container, layoutPath)
local layout = LayoutManager.load(layoutPath)
local instance = {
layout = layout,
container = container,
options = {},
}
layout.calculate(instance)
LayoutManager._applyPositions(instance)
return instance
end
--- Internal: Applies calculated positions to children
--- @param instance table The layout instance
--- @private
function LayoutManager._applyPositions(instance)
if not instance._positions then return end
for child, pos in pairs(instance._positions) do
if not child._destroyed then
child.set("x", pos.x)
child.set("y", pos.y)
child.set("width", pos.width)
child.set("height", pos.height)
child._layoutValues = {
x = pos.x,
y = pos.y,
width = pos.width,
height = pos.height
}
end
end
end
--- Checks if a child's properties were changed by the user since last layout
--- @param child BaseElement The child element to check
--- @return boolean changed Whether the user changed x, y, width, or height
--- @private
function LayoutManager._wasChangedByUser(child)
if not child._layoutValues then return false end
local currentX = child.get("x")
local currentY = child.get("y")
local currentWidth = child.get("width")
local currentHeight = child.get("height")
return currentX ~= child._layoutValues.x or
currentY ~= child._layoutValues.y or
currentWidth ~= child._layoutValues.width or
currentHeight ~= child._layoutValues.height
end
--- Updates a layout instance (recalculates positions)
--- @param instance table The layout instance
function LayoutManager.update(instance)
if instance and instance.layout and instance.layout.calculate then
if instance._positions then
for child, pos in pairs(instance._positions) do
if not child._destroyed then
child._userModified = LayoutManager._wasChangedByUser(child)
end
end
end
instance.layout.calculate(instance)
LayoutManager._applyPositions(instance)
end
end
--- Destroys a layout instance
--- @param instance table The layout instance
function LayoutManager.destroy(instance)
if instance and instance.layout and instance.layout.destroy then
instance.layout.destroy(instance)
end
if instance then
instance._positions = nil
end
end
return LayoutManager

View File

@@ -0,0 +1,144 @@
local CollectionEntry = {}
CollectionEntry.__index = function(entry, key)
local self_method = rawget(CollectionEntry, key)
if self_method then
return self_method
end
if entry._data[key] ~= nil then
return entry._data[key]
end
local parent_method = entry._parent[key]
if parent_method then
return parent_method
end
return nil
end
function CollectionEntry.new(parent, data)
local instance = {
_parent = parent,
_data = data
}
return setmetatable(instance, CollectionEntry)
end
function CollectionEntry:_findIndex()
for i, entry in ipairs(self._parent:getItems()) do
if entry == self then
return i
end
end
return nil
end
function CollectionEntry:setText(text)
self._data.text = text
self._parent:updateRender()
return self
end
function CollectionEntry:getText()
return self._data.text
end
function CollectionEntry:moveUp(amount)
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex then return self end
amount = amount or 1
local newIndex = math.max(1, currentIndex - amount)
if currentIndex ~= newIndex then
table.remove(items, currentIndex)
table.insert(items, newIndex, self)
self._parent:updateRender()
end
return self
end
function CollectionEntry:moveDown(amount)
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex then return self end
amount = amount or 1
local newIndex = math.min(#items, currentIndex + amount)
if currentIndex ~= newIndex then
table.remove(items, currentIndex)
table.insert(items, newIndex, self)
self._parent:updateRender()
end
return self
end
function CollectionEntry:moveToTop()
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex or currentIndex == 1 then return self end
table.remove(items, currentIndex)
table.insert(items, 1, self)
self._parent:updateRender()
return self
end
function CollectionEntry:moveToBottom()
local items = self._parent:getItems()
local currentIndex = self:_findIndex()
if not currentIndex or currentIndex == #items then return self end
table.remove(items, currentIndex)
table.insert(items, self)
self._parent:updateRender()
return self
end
function CollectionEntry:getIndex()
return self:_findIndex()
end
function CollectionEntry:swapWith(otherEntry)
local items = self._parent:getItems()
local indexA = self:getIndex()
local indexB = otherEntry:getIndex()
if indexA and indexB and indexA ~= indexB then
items[indexA], items[indexB] = items[indexB], items[indexA]
self._parent:updateRender()
end
return self
end
function CollectionEntry:remove()
if self._parent and self._parent.removeItem then
self._parent:removeItem(self)
return true
end
return false
end
function CollectionEntry:select()
if self._parent and self._parent.selectItem then
self._parent:selectItem(self)
end
return self
end
function CollectionEntry:unselect()
if self._parent and self._parent.unselectItem then
self._parent:unselectItem(self)
end
end
function CollectionEntry:isSelected()
if self._parent and self._parent.getSelectedItem then
return self._parent:getSelectedItem() == self
end
return false
end
return CollectionEntry

View File

@@ -420,7 +420,6 @@ end
--- @usage basalt.triggerEvent("custom_event", "data1", "data2") --- @usage basalt.triggerEvent("custom_event", "data1", "data2")
function basalt.triggerEvent(eventName, ...) function basalt.triggerEvent(eventName, ...)
expect(1, eventName, "string") expect(1, eventName, "string")
if basalt._events[eventName] then if basalt._events[eventName] then
for _, callback in ipairs(basalt._events[eventName]) do for _, callback in ipairs(basalt._events[eventName]) do
local ok, err = pcall(callback, ...) local ok, err = pcall(callback, ...)
@@ -432,4 +431,174 @@ function basalt.triggerEvent(eventName, ...)
end end
end end
--- Requires specific elements and validates they are available
--- @shortDescription Requires elements for the application
--- @param elements table|string List of element names or single element name
--- @param autoLoad? boolean Whether to automatically load missing elements (default: false)
--- @usage basalt.requireElements({"Button", "Label", "Slider"})
--- @usage basalt.requireElements("Button", true)
function basalt.requireElements(elements, autoLoad)
if type(elements) == "string" then
elements = {elements}
end
expect(1, elements, "table")
if autoLoad ~= nil then
expect(2, autoLoad, "boolean")
end
local missing = {}
local notLoaded = {}
for _, elementName in ipairs(elements) do
if not elementManager.hasElement(elementName) then
table.insert(missing, elementName)
elseif not elementManager.isElementLoaded(elementName) then
table.insert(notLoaded, elementName)
end
end
if #notLoaded > 0 then
for _, name in ipairs(notLoaded) do
local ok, err = pcall(elementManager.loadElement, name)
if not ok then
basalt.LOGGER.warn("Failed to load element "..name..": "..tostring(err))
table.insert(missing, name)
end
end
end
if #missing > 0 then
if autoLoad then
local stillMissing = {}
for _, name in ipairs(missing) do
local ok = elementManager.tryAutoLoad(name)
if not ok then
table.insert(stillMissing, name)
end
end
if #stillMissing > 0 then
local msg = "Missing required elements: " .. table.concat(stillMissing, ", ")
msg = msg .. "\n\nThese elements could not be auto-loaded."
msg = msg .. "\nPlease install them or register remote sources."
errorManager.error(msg)
end
else
local msg = "Missing required elements: " .. table.concat(missing, ", ")
msg = msg .. "\n\nSuggestions:"
msg = msg .. "\n • Use basalt.requireElements({...}, true) to auto-load"
msg = msg .. "\n • Register remote sources with elementManager.registerRemoteSource()"
msg = msg .. "\n • Register disk mounts with elementManager.registerDiskMount()"
errorManager.error(msg)
end
end
basalt.LOGGER.info("All required elements are available: " .. table.concat(elements, ", "))
return true
end
--- Loads a manifest file that describes element requirements and configuration
--- @shortDescription Loads an application manifest
--- @param path string The path to the manifest file
--- @return table manifest The loaded manifest data
--- @usage basalt.loadManifest("myapp.manifest")
function basalt.loadManifest(path)
expect(1, path, "string")
if not fs.exists(path) then
errorManager.error("Manifest file not found: " .. path)
end
local manifest
local ok, result = pcall(dofile, path)
if not ok then
errorManager.error("Failed to load manifest: " .. tostring(result))
end
manifest = result
if type(manifest) ~= "table" then
errorManager.error("Manifest must return a table")
end
if manifest.config then
elementManager.configure(manifest.config)
basalt.LOGGER.debug("Applied manifest config")
end
if manifest.diskMounts then
for _, mountPath in ipairs(manifest.diskMounts) do
elementManager.registerDiskMount(mountPath)
end
end
if manifest.remoteSources then
for elementName, url in pairs(manifest.remoteSources) do
elementManager.registerRemoteSource(elementName, url)
end
end
if manifest.requiredElements then
local autoLoad = manifest.autoLoadMissing ~= false
basalt.requireElements(manifest.requiredElements, autoLoad)
end
if manifest.optionalElements then
for _, name in ipairs(manifest.optionalElements) do
pcall(elementManager.loadElement, name)
end
end
if manifest.preloadElements then
elementManager.preloadElements(manifest.preloadElements)
end
basalt.LOGGER.info("Manifest loaded successfully: " .. (manifest.name or path))
return manifest
end
--- Installs an element interactively or from a specified source
--- @shortDescription Installs an element
--- @param elementName string The name of the element to install
--- @param source? string Optional source URL or path
--- @usage basalt.install("Slider")
--- @usage basalt.install("Slider", "https://example.com/slider.lua")
function basalt.install(elementName, source)
expect(1, elementName, "string")
if source ~= nil then
expect(2, source, "string")
end
if elementManager.hasElement(elementName) and elementManager.isElementLoaded(elementName) then
return true
end
if source then
if source:match("^https?://") then
elementManager.registerRemoteSource(elementName, source)
else
if not fs.exists(source) then
errorManager.error("Source file not found: " .. source)
end
end
end
local ok = elementManager.tryAutoLoad(elementName)
if ok then
return true
else
return false
end
end
--- Configures the ElementManager (shortcut to elementManager.configure)
--- @shortDescription Configures element loading behavior
--- @param config table Configuration options
--- @usage basalt.configure({allowRemoteLoading = true, useGlobalCache = true})
function basalt.configure(config)
expect(1, config, "table")
elementManager.configure(config)
end
return basalt return basalt

View File

@@ -1,5 +1,8 @@
---@configDefault false
local registeredAnimations = {} local registeredAnimations = {}
local easings = { local easings = {}
easings = {
linear = function(progress) linear = function(progress)
return progress return progress
end, end,
@@ -17,6 +20,171 @@ local easings = {
return 2 * progress * progress return 2 * progress * progress
end end
return 1 - (-2 * progress + 2)^2 / 2 return 1 - (-2 * progress + 2)^2 / 2
end,
easeInCubic = function(progress)
return progress * progress * progress
end,
easeOutCubic = function(progress)
return 1 - (1 - progress)^3
end,
easeInOutCubic = function(progress)
if progress < 0.5 then
return 4 * progress * progress * progress
end
return 1 - (-2 * progress + 2)^3 / 2
end,
easeInQuart = function(progress)
return progress * progress * progress * progress
end,
easeOutQuart = function(progress)
return 1 - (1 - progress)^4
end,
easeInOutQuart = function(progress)
if progress < 0.5 then
return 8 * progress * progress * progress * progress
end
return 1 - (-2 * progress + 2)^4 / 2
end,
easeInQuint = function(progress)
return progress * progress * progress * progress * progress
end,
easeOutQuint = function(progress)
return 1 - (1 - progress)^5
end,
easeInOutQuint = function(progress)
if progress < 0.5 then
return 16 * progress * progress * progress * progress * progress
end
return 1 - (-2 * progress + 2)^5 / 2
end,
easeInSine = function(progress)
return 1 - math.cos(progress * math.pi / 2)
end,
easeOutSine = function(progress)
return math.sin(progress * math.pi / 2)
end,
easeInOutSine = function(progress)
return -(math.cos(math.pi * progress) - 1) / 2
end,
easeInExpo = function(progress)
if progress == 0 then return 0 end
return 2^(10 * progress - 10)
end,
easeOutExpo = function(progress)
if progress == 1 then return 1 end
return 1 - 2^(-10 * progress)
end,
easeInOutExpo = function(progress)
if progress == 0 then return 0 end
if progress == 1 then return 1 end
if progress < 0.5 then
return 2^(20 * progress - 10) / 2
end
return (2 - 2^(-20 * progress + 10)) / 2
end,
easeInCirc = function(progress)
return 1 - math.sqrt(1 - progress * progress)
end,
easeOutCirc = function(progress)
return math.sqrt(1 - (progress - 1) * (progress - 1))
end,
easeInOutCirc = function(progress)
if progress < 0.5 then
return (1 - math.sqrt(1 - (2 * progress)^2)) / 2
end
return (math.sqrt(1 - (-2 * progress + 2)^2) + 1) / 2
end,
easeInBack = function(progress)
local c1 = 1.70158
local c3 = c1 + 1
return c3 * progress * progress * progress - c1 * progress * progress
end,
easeOutBack = function(progress)
local c1 = 1.70158
local c3 = c1 + 1
return 1 + c3 * (progress - 1)^3 + c1 * (progress - 1)^2
end,
easeInOutBack = function(progress)
local c1 = 1.70158
local c2 = c1 * 1.525
if progress < 0.5 then
return ((2 * progress)^2 * ((c2 + 1) * 2 * progress - c2)) / 2
end
return ((2 * progress - 2)^2 * ((c2 + 1) * (progress * 2 - 2) + c2) + 2) / 2
end,
easeInElastic = function(progress)
local c4 = (2 * math.pi) / 3
if progress == 0 then return 0 end
if progress == 1 then return 1 end
return -(2^(10 * progress - 10)) * math.sin((progress * 10 - 10.75) * c4)
end,
easeOutElastic = function(progress)
local c4 = (2 * math.pi) / 3
if progress == 0 then return 0 end
if progress == 1 then return 1 end
return 2^(-10 * progress) * math.sin((progress * 10 - 0.75) * c4) + 1
end,
easeInOutElastic = function(progress)
local c5 = (2 * math.pi) / 4.5
if progress == 0 then return 0 end
if progress == 1 then return 1 end
if progress < 0.5 then
return -(2^(20 * progress - 10) * math.sin((20 * progress - 11.125) * c5)) / 2
end
return (2^(-20 * progress + 10) * math.sin((20 * progress - 11.125) * c5)) / 2 + 1
end,
easeInBounce = function(progress)
return 1 - easings.easeOutBounce(1 - progress)
end,
easeOutBounce = function(progress)
local n1 = 7.5625
local d1 = 2.75
if progress < 1 / d1 then
return n1 * progress * progress
elseif progress < 2 / d1 then
progress = progress - 1.5 / d1
return n1 * progress * progress + 0.75
elseif progress < 2.5 / d1 then
progress = progress - 2.25 / d1
return n1 * progress * progress + 0.9375
else
progress = progress - 2.625 / d1
return n1 * progress * progress + 0.984375
end
end,
easeInOutBounce = function(progress)
if progress < 0.5 then
return (1 - easings.easeOutBounce(1 - 2 * progress)) / 2
end
return (1 + easings.easeOutBounce(2 * progress - 1)) / 2
end end
} }
@@ -64,7 +232,7 @@ function AnimationInstance:start()
if self.handlers.start then if self.handlers.start then
self.handlers.start(self) self.handlers.start(self)
end end
return self return self
end end
--- Updates the animation --- Updates the animation
@@ -520,6 +688,27 @@ Animation.registerAnimation("marquee", {
end end
}) })
Animation.registerAnimation("custom", {
start = function(anim)
anim.callback = anim.args[1]
if type(anim.callback) ~= "function" then
error("custom animation requires a function as first argument")
end
end,
update = function(anim, progress)
local elapsed = os.epoch("local") / 1000 - anim.startTime
anim.callback(anim.element, progress, elapsed)
return progress >= 1
end,
complete = function(anim)
if anim.callback then
anim.callback(anim.element, 1, anim.duration)
end
end
})
--- Adds additional methods for VisualElement when adding animation plugin --- Adds additional methods for VisualElement when adding animation plugin
--- @class VisualElement --- @class VisualElement
local VisualElement = {hooks={}} local VisualElement = {hooks={}}

View File

@@ -1,5 +1,5 @@
local log = require("log") local log = require("log")
---@configDefault false
local activeProfiles = setmetatable({}, {__mode = "k"}) local activeProfiles = setmetatable({}, {__mode = "k"})

View File

@@ -2,6 +2,7 @@ local tHex = require("libraries/colorHex")
local errorManager = require("errorManager") local errorManager = require("errorManager")
local Canvas = {} local Canvas = {}
Canvas.__index = Canvas Canvas.__index = Canvas
---@configDefault false
local sub, rep = string.sub, string.rep local sub, rep = string.sub, string.rep

View File

@@ -1,5 +1,6 @@
local log = require("log") local log = require("log")
local tHex = require("libraries/colorHex") local tHex = require("libraries/colorHex")
---@configDefault false
local maxLines = 10 local maxLines = 10
local isVisible = false local isVisible = false

View File

@@ -1,5 +1,6 @@
local errorManager = require("errorManager") local errorManager = require("errorManager")
local PropertySystem = require("propertySystem") local PropertySystem = require("propertySystem")
---@configDefault false
local protectedNames = { local protectedNames = {
colors = true, colors = true,
@@ -30,13 +31,13 @@ end
local function parseExpression(expr, element, propName) local function parseExpression(expr, element, propName)
local deps = analyzeDependencies(expr) local deps = analyzeDependencies(expr)
if deps.parent and not element.parent then if deps.parent and not element.parent then
errorManager.header = "Reactive evaluation error" errorManager.header = "Reactive evaluation error"
errorManager.error("Expression uses parent but no parent available") errorManager.error("Expression uses parent but no parent available")
return function() return nil end return function() return nil end
end end
expr = expr:gsub("^{(.+)}$", "%1") expr = expr:gsub("^{(.+)}$", "%1")
expr = expr:gsub("([%w_]+)%$([%w_]+)", function(obj, prop) expr = expr:gsub("([%w_]+)%$([%w_]+)", function(obj, prop)
@@ -87,9 +88,34 @@ local function parseExpression(expr, element, propName)
return nil return nil
end end
if objName == "self" then if objName == "self" then
return element.get(propName) -- Check if property exists
if element._properties[propName] then
return element.getResolved(propName)
end
if element._registeredStates and element._registeredStates[propName] then
return element:hasState(propName)
end
local states = element.get("states")
if states and states[propName] ~= nil then
return true
end
errorManager.header = "Reactive evaluation error"
errorManager.error("Property or state '" .. propName .. "' not found in element '" .. element:getType() .. "'")
return nil
elseif objName == "parent" then elseif objName == "parent" then
return element.parent.get(propName) if element.parent._properties[propName] then
return element.parent.getResolved(propName)
end
if element.parent._registeredStates and element.parent._registeredStates[propName] then
return element.parent:hasState(propName)
end
local states = element.parent.get("states")
if states and states[propName] ~= nil then
return true
end
errorManager.header = "Reactive evaluation error"
errorManager.error("Property or state '" .. propName .. "' not found in parent element")
return nil
else else
local target = element.parent:getChild(objName) local target = element.parent:getChild(objName)
if not target then if not target then
@@ -98,7 +124,19 @@ local function parseExpression(expr, element, propName)
return nil return nil
end end
return target.get(propName) if target._properties[propName] then
return target.getResolved(propName)
end
if target._registeredStates and target._registeredStates[propName] then
return target:hasState(propName)
end
local states = target.get("states")
if states and states[propName] ~= nil then
return true
end
errorManager.header = "Reactive evaluation error"
errorManager.error("Property or state '" .. propName .. "' not found in element '" .. objName .. "'")
return nil
end end
end end
}, { __index = mathEnv }) }, { __index = mathEnv })
@@ -154,9 +192,17 @@ local observerCache = setmetatable({}, {
end end
}) })
local valueCache = setmetatable({}, {
__mode = "k",
__index = function(t, k)
t[k] = {}
return t[k]
end
})
local function setupObservers(element, expr, propertyName) local function setupObservers(element, expr, propertyName)
local deps = analyzeDependencies(expr) local deps = analyzeDependencies(expr)
if observerCache[element][propertyName] then if observerCache[element][propertyName] then
for _, observer in ipairs(observerCache[element][propertyName]) do for _, observer in ipairs(observerCache[element][propertyName]) do
observer.target:removeObserver(observer.property, observer.callback) observer.target:removeObserver(observer.property, observer.callback)
@@ -176,14 +222,39 @@ local function setupObservers(element, expr, propertyName)
end end
if target then if target then
local isState = false
if target._properties[prop] then
isState = false
elseif target._registeredStates and target._registeredStates[prop] then
isState = true
else
local states = target.get("states")
if states and states[prop] ~= nil then
isState = true
end
end
local observer = { local observer = {
target = target, target = target,
property = prop, property = isState and "states" or prop,
callback = function() callback = function()
element:updateRender() local oldValue = valueCache[element][propertyName]
local newValue = element.get(propertyName)
if oldValue ~= newValue then
valueCache[element][propertyName] = newValue
if element._observers and element._observers[propertyName] then
for _, obs in ipairs(element._observers[propertyName]) do
obs()
end
end
element:updateRender()
end
end end
} }
target:observe(prop, observer.callback) target:observe(observer.property, observer.callback)
table.insert(observers, observer) table.insert(observers, observer)
end end
end end
@@ -196,7 +267,7 @@ PropertySystem.addSetterHook(function(element, propertyName, value, config)
if type(value) == "string" and value:match("^{.+}$") then if type(value) == "string" and value:match("^{.+}$") then
local expr = value:gsub("^{(.+)}$", "%1") local expr = value:gsub("^{(.+)}$", "%1")
local deps = analyzeDependencies(expr) local deps = analyzeDependencies(expr)
if deps.parent and not element.parent then if deps.parent and not element.parent then
return config.default return config.default
end end
@@ -232,6 +303,8 @@ PropertySystem.addSetterHook(function(element, propertyName, value, config)
end end
return config.default return config.default
end end
valueCache[element][propertyName] = result
return result return result
end end
end end
@@ -255,6 +328,7 @@ BaseElement.hooks = {
end end
end end
observerCache[self] = nil observerCache[self] = nil
valueCache[self] = nil
functionCache[self] = nil functionCache[self] = nil
end end
end end

157
src/plugins/responsive.lua Normal file
View File

@@ -0,0 +1,157 @@
local errorManager = require("errorManager")
---@configDefault false
--- This is the responsive plugin. It provides a fluent builder API for creating responsive states with an intuitive when/apply/otherwise syntax.
---@class BaseElement
local BaseElement = {}
--- Creates a responsive builder for defining responsive states
--- @shortDescription Creates a responsive state builder
--- @param self BaseElement The element to create the builder for
--- @return ResponsiveBuilder builder The responsive builder instance
function BaseElement:responsive()
local builder = {
_element = self,
_rules = {},
_currentStateName = nil,
_currentCondition = nil,
_stateCounter = 0
}
--- Defines a condition for responsive behavior
--- @param condition string|function The condition as string expression or function
--- @return ResponsiveBuilder self For method chaining
function builder:when(condition)
if self._currentCondition then
errorManager.header = "Responsive Builder Error"
errorManager.error("Previous when() must be followed by apply() before starting a new when()")
end
self._stateCounter = self._stateCounter + 1
self._currentStateName = "__responsive_" .. self._stateCounter
self._currentCondition = condition
return self
end
--- Applies properties when the current condition is met
--- @param properties table The properties to apply {property = value, ...}
--- @return ResponsiveBuilder self For method chaining
function builder:apply(properties)
if not self._currentCondition then
errorManager.header = "Responsive Builder Error"
errorManager.error("apply() must follow a when() call")
end
if type(properties) ~= "table" then
errorManager.header = "Responsive Builder Error"
errorManager.error("apply() requires a table of properties")
end
self._element:registerResponsiveState(
self._currentStateName,
self._currentCondition,
100
)
for propName, value in pairs(properties) do
local capitalizedName = propName:sub(1,1):upper() .. propName:sub(2)
local setter = "set" .. capitalizedName .. "State"
if self._element[setter] then
self._element[setter](self._element, self._currentStateName, value)
else
errorManager.header = "Responsive Builder Error"
errorManager.error("Unknown property: " .. propName)
end
end
table.insert(self._rules, {
stateName = self._currentStateName,
condition = self._currentCondition,
properties = properties
})
self._currentCondition = nil
self._currentStateName = nil
return self
end
--- Defines a fallback condition (else case)
--- @param properties table The properties to apply when no other conditions match
--- @return ResponsiveBuilder self For method chaining
function builder:otherwise(properties)
if self._currentCondition then
errorManager.header = "Responsive Builder Error"
errorManager.error("otherwise() cannot be used after when() without apply()")
end
if type(properties) ~= "table" then
errorManager.header = "Responsive Builder Error"
errorManager.error("otherwise() requires a table of properties")
end
self._stateCounter = self._stateCounter + 1
local otherwiseStateName = "__responsive_otherwise_" .. self._stateCounter
local otherRules = {}
for _, rule in ipairs(self._rules) do
table.insert(otherRules, rule.condition)
end
local otherwiseCondition
if type(otherRules[1]) == "string" then
local negatedExprs = {}
for _, cond in ipairs(otherRules) do
table.insert(negatedExprs, "not (" .. cond .. ")")
end
otherwiseCondition = table.concat(negatedExprs, " and ")
else
otherwiseCondition = function(elem)
for _, cond in ipairs(otherRules) do
if cond(elem) then
return false
end
end
return true
end
end
self._element:registerResponsiveState(
otherwiseStateName,
otherwiseCondition,
50
)
for propName, value in pairs(properties) do
local capitalizedName = propName:sub(1,1):upper() .. propName:sub(2)
local setter = "set" .. capitalizedName .. "State"
if self._element[setter] then
self._element[setter](self._element, otherwiseStateName, value)
else
errorManager.header = "Responsive Builder Error"
errorManager.error("Unknown property: " .. propName)
end
end
return self
end
--- Completes the builder (optional, for clarity)
--- @return BaseElement element The original element
function builder:done()
if self._currentCondition then
errorManager.header = "Responsive Builder Error"
errorManager.error("Unfinished when() without apply()")
end
return self._element
end
return builder
end
return {
BaseElement = BaseElement
}

View File

@@ -1,226 +0,0 @@
local PropertySystem = require("propertySystem")
local errorManager = require("errorManager")
---@class BaseFrame : Container
local BaseFrame = {}
function BaseFrame.setup(element)
element.defineProperty(element, "states", {default = {}, type = "table"})
element.defineProperty(element, "stateObserver", {default = {}, type = "table"})
end
--- Initializes a new state for this element
--- @shortDescription Initializes a new state
--- @param self BaseFrame The element to initialize state for
--- @param name string The name of the state
--- @param default any The default value of the state
--- @param persist? boolean Whether to persist the state to disk
--- @param path? string Custom file path for persistence
--- @return BaseFrame self The element instance
function BaseFrame:initializeState(name, default, persist, path)
local states = self.get("states")
if states[name] then
errorManager.error("State '" .. name .. "' already exists")
return self
end
local file = path or "states/" .. self.get("name") .. ".state"
local persistedData = {}
if persist and fs.exists(file) then
local f = fs.open(file, "r")
persistedData = textutils.unserialize(f.readAll()) or {}
f.close()
end
states[name] = {
value = persist and persistedData[name] or default,
persist = persist,
}
return self
end
--- This is the state plugin. It provides a state management system for UI elements with support for
--- persistent states, computed states, and state sharing between elements.
---@class BaseElement
local BaseElement = {}
--- Sets the value of a state
--- @shortDescription Sets a state value
--- @param self BaseElement The element to set state for
--- @param name string The name of the state
--- @param value any The new value for the state
--- @return BaseElement self The element instance
function BaseElement:setState(name, value)
local main = self:getBaseFrame()
local states = main.get("states")
local observers = main.get("stateObserver")
if not states[name] then
errorManager.error("State '"..name.."' not initialized")
end
if states[name].persist then
local file = "states/" .. main.get("name") .. ".state"
local persistedData = {}
if fs.exists(file) then
local f = fs.open(file, "r")
persistedData = textutils.unserialize(f.readAll()) or {}
f.close()
end
persistedData[name] = value
local dir = fs.getDir(file)
if not fs.exists(dir) then
fs.makeDir(dir)
end
local f = fs.open(file, "w")
f.write(textutils.serialize(persistedData))
f.close()
end
states[name].value = value
-- Trigger observers
if observers[name] then
for _, callback in ipairs(observers[name]) do
callback(name, value)
end
end
-- Recompute all computed states
for stateName, state in pairs(states) do
if state.computed then
state.value = state.computeFn(self)
if observers[stateName] then
for _, callback in ipairs(observers[stateName]) do
callback(stateName, state.value)
end
end
end
end
return self
end
--- Gets the value of a state
--- @shortDescription Gets a state value
--- @param self BaseElement The element to get state from
--- @param name string The name of the state
--- @return any value The current state value
function BaseElement:getState(name)
local main = self:getBaseFrame()
local states = main.get("states")
if not states[name] then
errorManager.error("State '"..name.."' not initialized")
end
if states[name].computed then
return states[name].computeFn(self)
end
return states[name].value
end
--- Registers a callback for state changes
--- @shortDescription Watches for state changes
--- @param self BaseElement The element to watch
--- @param stateName string The state to watch
--- @param callback function Called with (element, newValue, oldValue)
--- @return BaseElement self The element instance
function BaseElement:onStateChange(stateName, callback)
local main = self:getBaseFrame()
local state = main.get("states")[stateName]
if not state then
errorManager.error("Cannot observe state '" .. stateName .. "': State not initialized")
return self
end
local observers = main.get("stateObserver")
if not observers[stateName] then
observers[stateName] = {}
end
table.insert(observers[stateName], callback)
return self
end
--- Removes a state change observer
--- @shortDescription Removes a state change observer
--- @param self BaseElement The element to remove observer from
--- @param stateName string The state to remove observer from
--- @param callback function The callback function to remove
--- @return BaseElement self The element instance
function BaseElement:removeStateChange(stateName, callback)
local main = self:getBaseFrame()
local observers = main.get("stateObserver")
if observers[stateName] then
for i, observer in ipairs(observers[stateName]) do
if observer == callback then
table.remove(observers[stateName], i)
break
end
end
end
return self
end
function BaseElement:computed(name, func)
local main = self:getBaseFrame()
local states = main.get("states")
if states[name] then
errorManager.error("Computed state '" .. name .. "' already exists")
return self
end
states[name] = {
computeFn = func,
value = func(self),
computed = true,
}
return self
end
--- Binds a property to a state
--- @param self BaseElement The element to bind
--- @param propertyName string The property to bind
--- @param stateName string The state to bind to (optional, uses propertyName if not provided)
--- @return BaseElement self The element instance
function BaseElement:bind(propertyName, stateName)
stateName = stateName or propertyName
local main = self:getBaseFrame()
local internalCall = false
if self.get(propertyName) ~= nil then
self.set(propertyName, main:getState(stateName))
end
self:onChange(propertyName, function(self, value)
if internalCall then return end
internalCall = true
self:setState(stateName, value)
internalCall = false
end)
self:onStateChange(stateName, function(name, value)
if internalCall then return end
internalCall = true
if self.get(propertyName) ~= nil then
self.set(propertyName, value)
end
internalCall = false
end)
return self
end
return {
BaseElement = BaseElement,
BaseFrame = BaseFrame
}

View File

@@ -1,25 +1,52 @@
local errorManager = require("errorManager") local errorManager = require("errorManager")
---@configDefault false
local defaultTheme = { local defaultTheme = {
default = { default = {
background = colors.lightGray, background = colors.cyan,
foreground = colors.black, foreground = colors.black,
}, },
BaseFrame = { BaseFrame = {
background = colors.white, background = colors.white,
foreground = colors.black, foreground = colors.black,
Frame = { Container = {
default = {
background = colors.cyan,
foreground = colors.black,
},
background = colors.black, background = colors.black,
names = { Button = {
basaltDebugLogClose = { background = colors.cyan,
background = colors.blue, foreground = colors.black,
foreground = colors.white states = {
clicked = {
background = colors.white,
foreground = colors.black,
}
} }
}, },
Input = {
background = colors.cyan,
foreground = colors.black,
},
Label = {
foreground = colors.white,
},
}, },
Button = { Button = {
background = "{self.clicked and colors.black or colors.cyan}", background = colors.cyan,
foreground = "{self.clicked and colors.cyan or colors.black}", foreground = colors.black,
states = {
clicked = {
background = colors.black,
foreground = colors.cyan,
}
}
},
Label = {
foreground = colors.black,
}, },
names = { names = {
@@ -27,10 +54,6 @@ local defaultTheme = {
background = colors.red, background = colors.red,
foreground = colors.white foreground = colors.white
}, },
test = {
background = "{self.clicked and colors.black or colors.green}",
foreground = "{self.clicked and colors.green or colors.black}"
}
}, },
} }
} }
@@ -97,26 +120,6 @@ local function lookUpTemplate(theme, path)
return current return current
end end
local function getDefaultProperties(theme, elementType)
local result = {}
if theme.default then
for k,v in pairs(theme.default) do
if type(v) ~= "table" then
result[k] = v
end
end
if theme.default[elementType] then
for k,v in pairs(theme.default[elementType]) do
if type(v) ~= "table" then
result[k] = v
end
end
end
end
return result
end
local function applyNamedStyles(result, theme, elementType, elementName, themeTable) local function applyNamedStyles(result, theme, elementType, elementName, themeTable)
if theme.default and theme.default.names and theme.default.names[elementName] then if theme.default and theme.default.names and theme.default.names[elementName] then
for k,v in pairs(theme.default.names[elementName]) do for k,v in pairs(theme.default.names[elementName]) do
@@ -140,17 +143,46 @@ end
local function collectThemeProps(theme, path, elementType, elementName) local function collectThemeProps(theme, path, elementType, elementName)
local result = {} local result = {}
local themeTable = lookUpTemplate(theme, path) if theme.default then
if themeTable then for k,v in pairs(theme.default) do
for k,v in pairs(themeTable) do
if type(v) ~= "table" then if type(v) ~= "table" then
result[k] = v result[k] = v
end end
end end
end end
local current = theme
for i = 1, #path do
local types = path[i]
local found = false
if next(result) == nil then for _, elementType in ipairs(types) do
result = getDefaultProperties(theme, elementType) if current[elementType] then
current = current[elementType]
found = true
if current.default then
for k,v in pairs(current.default) do
if type(v) ~= "table" then
result[k] = v
end
end
end
break
end
end
if not found then
current = nil
break
end
end
local themeTable = lookUpTemplate(theme, path)
if themeTable then
for k,v in pairs(themeTable) do
if type(v) ~= "table" or k == "states" then
result[k] = v
end
end
end end
applyNamedStyles(result, theme, elementType, elementName, themeTable) applyNamedStyles(result, theme, elementType, elementName, themeTable)
@@ -164,22 +196,53 @@ end
--- @param applyToChildren boolean? Whether to apply theme to child elements (default: true) --- @param applyToChildren boolean? Whether to apply theme to child elements (default: true)
--- @return BaseElement self The element instance --- @return BaseElement self The element instance
function BaseElement:applyTheme(applyToChildren) function BaseElement:applyTheme(applyToChildren)
local backup = {}
if self._modifiedProperties then
for prop, _ in pairs(self._modifiedProperties) do
backup[prop] = true
end
end
local styles = self:getTheme() local styles = self:getTheme()
if(styles ~= nil) then if(styles ~= nil) then
for prop, value in pairs(styles) do for prop, value in pairs(styles) do
local config = self._properties[prop] if prop ~= "states" and not backup[prop] then
if(config)then local config = self._properties[prop]
if((config.type)=="color")then if(config)then
if(type(value)=="string")then if((config.type)=="color")then
if(colors[value])then if(type(value)=="string")then
value = colors[value] if(colors[value])then
value = colors[value]
end
end
end
self.set(prop, value)
end
end
end
if styles.states then
for stateName, stateConfig in pairs(styles.states) do
for prop, value in pairs(stateConfig) do
if prop ~= "priority" then
local config = self._properties[prop]
local capitalizedName = prop:sub(1,1):upper() .. prop:sub(2)
if(config)then
if((config.type)=="color")then
if(type(value)=="string")then
if(colors[value])then
value = colors[value]
end
end
end
self["set" .. capitalizedName .. "State"](self, stateName, value)
end end
end end
end end
self.set(prop, value)
end end
end end
end end
self._modifiedProperties = backup
if(applyToChildren~=false)then if(applyToChildren~=false)then
if(self:isType("Container"))then if(self:isType("Container"))then
local children = self.get("children") local children = self.get("children")

View File

@@ -1,5 +1,8 @@
local errorManager = require("errorManager") local errorManager = require("errorManager")
local log = require("log") local log = require("log")
---@configDefault false
local XMLNode = { local XMLNode = {
new = function(tag) new = function(tag)
return { return {
@@ -20,15 +23,40 @@ local XMLNode = {
} }
local parseAttributes = function(node, s) local parseAttributes = function(node, s)
local _, _ = string.gsub(s, "(%w+)=([\"'])(.-)%2", function(attribute, _, value) local _, _ = string.gsub(s, "([%w:]+)=([\"'])(.-)%2", function(attribute, _, value)
node:addAttribute(attribute, "\"" .. value .. "\"") node:addAttribute(attribute, "\"" .. value .. "\"")
end) end)
local _, _ = string.gsub(s, "(%w+)={(.-)}", function(attribute, expression) local _, _ = string.gsub(s, "([%w:]+)={(.-)}", function(attribute, expression)
node:addAttribute(attribute, expression) node:addAttribute(attribute, expression)
end) end)
end end
local XMLParser = { local XMLParser = {}
XMLParser = {
_customTagHandlers = {},
--- Registers a custom tag handler
--- @param tagName string The name of the custom tag
--- @param handler function The handler function(node, parent, scope)
registerTagHandler = function(tagName, handler)
XMLParser._customTagHandlers[tagName] = handler
log.info("XMLParser: Registered custom tag handler for '" .. tagName .. "'")
end,
--- Unregisters a custom tag handler
--- @param tagName string The name of the custom tag
unregisterTagHandler = function(tagName)
XMLParser._customTagHandlers[tagName] = nil
log.info("XMLParser: Unregistered custom tag handler for '" .. tagName .. "'")
end,
--- Gets a custom tag handler
--- @param tagName string The name of the custom tag
--- @return function|nil handler The handler function or nil
getTagHandler = function(tagName)
return XMLParser._customTagHandlers[tagName]
end,
parseText = function(xmlText) parseText = function(xmlText)
local stack = {} local stack = {}
local top = XMLNode.new() local top = XMLNode.new()
@@ -120,7 +148,15 @@ local function convertValue(value, scope)
for k,v in pairs(scope) do for k,v in pairs(scope) do
env[k] = v env[k] = v
end end
return load("return " .. cdata, nil, "bt", env)() local fn, err = load("return " .. cdata, nil, "bt", env)
if not fn then
errorManager.error("XMLParser: CDATA syntax error: " .. tostring(err))
end
local success, result = pcall(fn)
if not success then
errorManager.error("XMLParser: CDATA execution error: " .. tostring(result))
end
return result
end end
if value == "true" then if value == "true" then
@@ -168,6 +204,25 @@ local function createTableFromNode(node, scope)
return list return list
end end
local function parseStateAttribute(self, attribute, value, scope)
local propName, stateName = attribute:match("^(.+)State:(.+)$")
if propName and stateName then
stateName = stateName:gsub("^\"", ""):gsub("\"$", "")
local capitalizedName = propName:sub(1,1):upper() .. propName:sub(2)
local methodName = "set"..capitalizedName.."State"
if self[methodName] then
self[methodName](self, stateName, convertValue(value, scope))
return true
else
log.warn("XMLParser: State method '" .. methodName .. "' not found for element '" .. self:getType() .. "'")
return true
end
end
return false
end
local BaseElement = {} local BaseElement = {}
function BaseElement.setup(element) function BaseElement.setup(element)
@@ -183,32 +238,68 @@ end
function BaseElement:fromXML(node, scope) function BaseElement:fromXML(node, scope)
if(node.attributes)then if(node.attributes)then
for k, v in pairs(node.attributes) do for k, v in pairs(node.attributes) do
if(self._properties[k])then if not parseStateAttribute(self, k, v, scope) then
self.set(k, convertValue(v, scope)) if(self._properties[k])then
elseif self[k] then self.set(k, convertValue(v, scope))
if(k:sub(1,2)=="on")then elseif self[k] then
local val = v:gsub("\"", "") if(k:sub(1,2)=="on")then
if(scope[val])then local val = v:gsub("\"", "")
if(type(scope[val]) ~= "function")then if(scope[val])then
errorManager.error("XMLParser: variable '" .. val .. "' is not a function for element '" .. self:getType() .. "' "..k) if(type(scope[val]) ~= "function")then
errorManager.error("XMLParser: variable '" .. val .. "' is not a function for element '" .. self:getType() .. "' "..k)
end
self[k](self, scope[val])
else
errorManager.error("XMLParser: variable '" .. val .. "' not found in scope")
end end
self[k](self, scope[val])
else else
errorManager.error("XMLParser: variable '" .. val .. "' not found in scope") errorManager.error("XMLParser: property '" .. k .. "' not found in element '" .. self:getType() .. "'")
end end
else else
errorManager.error("XMLParser: property '" .. k .. "' not found in element '" .. self:getType() .. "'") local customXML = self.get("customXML")
customXML.attributes[k] = convertValue(v, scope)
end end
else
local customXML = self.get("customXML")
customXML.attributes[k] = convertValue(v, scope)
end end
end end
end end
if(node.children)then if(node.children)then
for _, child in pairs(node.children) do for _, child in pairs(node.children) do
if(self._properties[child.tag])then if child.tag == "state" then
local stateName = child.attributes and child.attributes.name
if not stateName then
errorManager.error("XMLParser: <state> tag requires 'name' attribute")
end
stateName = stateName:gsub("^\"", ""):gsub("\"$", "")
if child.children then
for _, stateChild in ipairs(child.children) do
local propName = stateChild.tag
local value
if stateChild.attributes and stateChild.attributes.value then
value = convertValue(stateChild.attributes.value, scope)
elseif stateChild.value then
value = convertValue(stateChild.value, scope)
else
log.warn("XMLParser: State property '" .. propName .. "' has no value")
value = nil
end
if value ~= nil then
local capitalizedName = propName:sub(1,1):upper() .. propName:sub(2)
local methodName = "set"..capitalizedName.."State"
if self[methodName] then
self[methodName](self, stateName, value)
else
log.warn("XMLParser: State method '" .. methodName .. "' not found for element '" .. self:getType() .. "'")
end
end
end
end
elseif(self._properties[child.tag])then
if(self._properties[child.tag].type == "table")then if(self._properties[child.tag].type == "table")then
self.set(child.tag, createTableFromNode(child, scope)) self.set(child.tag, createTableFromNode(child, scope))
else else
@@ -280,9 +371,15 @@ function Container:fromXML(nodes, scope)
if(nodes.children)then if(nodes.children)then
for _, node in ipairs(nodes.children) do for _, node in ipairs(nodes.children) do
local capitalizedName = node.tag:sub(1,1):upper() .. node.tag:sub(2) local capitalizedName = node.tag:sub(1,1):upper() .. node.tag:sub(2)
if self["add"..capitalizedName] then
local customHandler = XMLParser.getTagHandler(node.tag)
if customHandler then
local result = customHandler(node, self, scope)
elseif self["add"..capitalizedName] then
local element = self["add"..capitalizedName](self) local element = self["add"..capitalizedName](self)
element:fromXML(node, scope) element:fromXML(node, scope)
else
log.warn("XMLParser: Unknown tag '" .. node.tag .. "' - no handler or element found")
end end
end end
end end

View File

@@ -88,6 +88,17 @@ function PropertySystem.defineProperty(class, name, config)
self:_updateProperty(name, value) self:_updateProperty(name, value)
return self return self
end end
class["get" .. capitalizedName .. "State"] = function(self, state, ...)
expect(1, self, "element")
return self.getPropertyState(name, state, ...)
end
class["set" .. capitalizedName .. "State"] = function(self, state, value, ...)
expect(1, self, "element")
self.setPropertyState(name, state, value, ...)
return self
end
end end
--- Combines multiple properties into a single getter and setter --- Combines multiple properties into a single getter and setter
@@ -251,6 +262,8 @@ end
function PropertySystem:__init() function PropertySystem:__init()
self._values = {} self._values = {}
self._observers = {} self._observers = {}
self._states = {}
self._modifiedProperties = {}
self.set = function(name, value, ...) self.set = function(name, value, ...)
local oldValue = self._values[name] local oldValue = self._values[name]
@@ -263,6 +276,7 @@ function PropertySystem:__init()
self:updateRender() self:updateRender()
end end
self._values[name] = applyHooks(self, name, value, config) self._values[name] = applyHooks(self, name, value, config)
self._modifiedProperties[name] = true
if oldValue ~= value and self._observers[name] then if oldValue ~= value and self._observers[name] then
for _, callback in ipairs(self._observers[name]) do for _, callback in ipairs(self._observers[name]) do
callback(self, value, oldValue) callback(self, value, oldValue)
@@ -281,6 +295,69 @@ function PropertySystem:__init()
return config.getter and config.getter(self, value, ...) or value return config.getter and config.getter(self, value, ...) or value
end end
self.setPropertyState = function(name, state, value, ...)
local config = self._properties[name]
if(config~=nil)then
if(config.setter) then
value = config.setter(self, value, ...)
end
value = applyHooks(self, name, value, config)
if not self._states[state] then
self._states[state] = {}
end
self._states[state][name] = value
local currentState = self._values.currentState
if currentState == state then
if config.canTriggerRender then
self:updateRender()
end
if self._observers[name] then
for _, callback in ipairs(self._observers[name]) do
callback(self, value, nil)
end
end
end
end
end
self.getPropertyState = function(name, state, ...)
local stateValue = self._states and self._states[state] and self._states[state][name]
local value = stateValue ~= nil and stateValue or self._values[name]
local config = self._properties[name]
if(config==nil)then errorManager.error("Property not found: "..name) return end
if type(value) == "function" and config.type ~= "function" then
value = value(self)
end
return config.getter and config.getter(self, value, ...) or value
end
self.getResolved = function(name, ...)
local activeStates = self:getActiveStates()
local value = nil
for _, stateInfo in ipairs(activeStates) do
if self._states and self._states[stateInfo.name] and self._states[stateInfo.name][name] ~= nil then
value = self._states[stateInfo.name][name]
break
end
end
if value == nil then
value = self._values[name]
end
local config = self._properties[name]
if(config==nil)then errorManager.error("Property not found: "..name) return end
if type(value) == "function" and config.type ~= "function" then
value = value(self)
end
return config.getter and config.getter(self, value, ...) or value
end
local properties = {} local properties = {}
local currentClass = getmetatable(self).__index local currentClass = getmetatable(self).__index
@@ -356,6 +433,7 @@ function PropertySystem:_updateProperty(name, value)
oldValue = oldValue(self) oldValue = oldValue(self)
end end
self._modifiedProperties[name] = true
self._values[name] = value self._values[name] = value
local newValue = type(value) == "function" and value(self) or value local newValue = type(value) == "function" and value(self) or value
@@ -439,6 +517,8 @@ function PropertySystem:removeProperty(name)
local capitalizedName = name:sub(1,1):upper() .. name:sub(2) local capitalizedName = name:sub(1,1):upper() .. name:sub(2)
self["get" .. capitalizedName] = nil self["get" .. capitalizedName] = nil
self["set" .. capitalizedName] = nil self["set" .. capitalizedName] = nil
self["get" .. capitalizedName .. "State"] = nil
self["set" .. capitalizedName .. "State"] = nil
return self return self
end end

47
themes/classic.json Normal file
View File

@@ -0,0 +1,47 @@
{
"theme": "classic",
"default": {
"background": "black",
"foreground": "lightGray"
},
"BaseFrame": {
"background": "lightGray",
"Container": {
"background": "gray",
"foreground": "white",
"Button" : {
"background" : "black",
"foreground" : "lightGray",
"states": {
"clicked": {
"background": "white",
"foreground": "black"
}
}
},
"Input": {
"background": "black",
"foreground": "lightGray"
}
},
"Button": {
"background": "black",
"foreground": "lightGray",
"states": {
"clicked": {
"background": "white",
"foreground": "black"
}
}
},
"Label": {
"foreground": "black"
},
"names": {
"basaltDebugLog": {
"background": "black",
"foreground": "white"
}
}
}
}

44
themes/dark.json Normal file
View File

@@ -0,0 +1,44 @@
{
"theme": "dark",
"default": {
"background": "white",
"foreground": "black"
},
"BaseFrame": {
"background": "black",
"Container": {
"background": "gray",
"foreground": "white",
"Button": {
"background": "lightGray",
"foreground": "black",
"states": {
"clicked": {
"background": "blue",
"foreground": "white"
}
}
},
"Input": {
"background": "black",
"foreground": "lightGray"
}
},
"Button": {
"background": "gray",
"foreground": "white",
"states": {
"clicked": {
"background": "blue",
"foreground": "white"
}
}
},
"names": {
"basaltDebugLog": {
"background": "red",
"foreground": "white"
}
}
}
}

50
themes/light.json Normal file
View File

@@ -0,0 +1,50 @@
{
"theme": "light",
"default": {
"background": "black",
"foreground": "white"
},
"BaseFrame": {
"background": "lightGray",
"Container": {
"background": "white",
"foreground": "black",
"Button": {
"background": "black",
"foreground": "white",
"states": {
"clicked": {
"background": "lightGray",
"foreground": "black"
}
}
},
"Input": {
"background": "black",
"foreground": "white"
},
"Label": {
"foreground": "black"
}
},
"Button": {
"background": "black",
"foreground": "white",
"states": {
"clicked": {
"background": "white",
"foreground": "black"
}
}
},
"Label": {
"foreground": "black"
},
"names": {
"basaltDebugLog": {
"background": "orange",
"foreground": "white"
}
}
}
}

47
themes/orange.json Normal file
View File

@@ -0,0 +1,47 @@
{
"theme": "orange",
"default": {
"background": "black",
"foreground": "orange"
},
"BaseFrame": {
"background": "white",
"Container": {
"default": {
"background": "black",
"foreground": "orange"
},
"background": "orange",
"foreground": "white",
"Button": {
"background": "black",
"foreground": "orange",
"states": {
"clicked": {
"background": "white",
"foreground": "orange"
}
}
},
"Label": {
"foreground": "black"
}
},
"Button": {
"background": "black",
"foreground": "orange",
"states": {
"clicked": {
"background": "orange",
"foreground": "white"
}
}
},
"names": {
"basaltDebugLog": {
"background": "red",
"foreground": "white"
}
}
}
}

View File

@@ -23,6 +23,8 @@ local eventParser = require("parsers.eventParser")
local globalParser = require("parsers.globalParser") local globalParser = require("parsers.globalParser")
local helper = require("utils.helper")
local markdownGenerator = require("utils.markdownGenerator") local markdownGenerator = require("utils.markdownGenerator")
BasaltDoc.annotationHandlers = {} BasaltDoc.annotationHandlers = {}
@@ -139,12 +141,42 @@ BasaltDoc.registerAnnotation("@skip", function(target, args)
target.skip = true target.skip = true
end) end)
BasaltDoc.registerAnnotation("@note", function(target, args)
if not target.notes then target.notes = {} end
table.insert(target.notes, args)
end)
BasaltDoc.registerAnnotation("@globalDescription", function(target, args) BasaltDoc.registerAnnotation("@globalDescription", function(target, args)
if args and args ~= "" then if args and args ~= "" then
target.description = args target.description = args
end end
end) end)
BasaltDoc.registerAnnotation("@tableType", function(target, args)
if not target.tableTypes then target.tableTypes = {} end
local tableName = args:match("^%s*(%S+)")
if tableName then
target._currentTableType = {
name = tableName,
fields = {}
}
table.insert(target.tableTypes, target._currentTableType)
end
end)
BasaltDoc.registerAnnotation("@tableField", function(target, args)
if target._currentTableType then
local fieldName, fieldType, fieldDesc = args:match("^%s*([%w_]+)%s+([%w_|]+)%s+(.*)")
if fieldName and fieldType then
table.insert(target._currentTableType.fields, {
name = fieldName,
type = fieldType,
description = fieldDesc or ""
})
end
end
end)
if classParser then classParser.setHandlers(BasaltDoc.annotationHandlers) end if classParser then classParser.setHandlers(BasaltDoc.annotationHandlers) end
if functionParser then functionParser.setHandlers(BasaltDoc.annotationHandlers) end if functionParser then functionParser.setHandlers(BasaltDoc.annotationHandlers) end
if propertyParser then propertyParser.setHandlers(BasaltDoc.annotationHandlers) end if propertyParser then propertyParser.setHandlers(BasaltDoc.annotationHandlers) end
@@ -192,12 +224,14 @@ function BasaltDoc.parse(content)
local annotationBuffer = {} local annotationBuffer = {}
local currentClass = nil local currentClass = nil
local firstTag = nil local firstTag = nil
local pendingTableTypes = {}
local blockStartTags = { local blockStartTags = {
["@class"] = true, ["@class"] = true,
["@property"] = true, ["@property"] = true,
["@event"] = true, ["@event"] = true,
["@skip"] = true ["@skip"] = true,
["@tableType"] = true
} }
local i = 1 local i = 1
@@ -225,9 +259,25 @@ function BasaltDoc.parse(content)
if firstTag == "@class" and classParser then if firstTag == "@class" and classParser then
local class = classParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n")) local class = classParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n"))
if class and not class.skip then if class and not class.skip then
if #pendingTableTypes > 0 then
for _, tableType in ipairs(pendingTableTypes) do
table.insert(class.tableTypes, tableType)
end
pendingTableTypes = {}
end
table.insert(ast.classes, class) table.insert(ast.classes, class)
currentClass = class currentClass = class
end end
elseif firstTag == "@tableType" then
local tempTarget = {tableTypes = {}}
if classParser and classParser.handlers then
helper.applyAnnotations(annotationBuffer, tempTarget, classParser.handlers)
end
if tempTarget.tableTypes and #tempTarget.tableTypes > 0 then
for _, tt in ipairs(tempTarget.tableTypes) do
table.insert(pendingTableTypes, tt)
end
end
elseif firstTag == "@property" and currentClass and propertyParser then elseif firstTag == "@property" and currentClass and propertyParser then
local prop = propertyParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n")) local prop = propertyParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n"))
if prop then if prop then

View File

@@ -17,6 +17,7 @@ function classParser.parse(annotations, line)
properties = {}, properties = {},
events = {}, events = {},
functions = {}, functions = {},
tableTypes = {},
skip = false skip = false
} }

View File

@@ -7,7 +7,36 @@ function helper.applyAnnotations(annotations, target, handlers)
local tag, args = ann:match("^%-%-%-?%s*(@%S+)%s*(.*)") local tag, args = ann:match("^%-%-%-?%s*(@%S+)%s*(.*)")
if tag then if tag then
if args == ">" then if args and args:match("^%s*%[%[") then
local blockContent = args:gsub("^%s*%[%[%s*", "")
if blockContent:match("%]%]%s*$") then
args = blockContent:gsub("%]%]%s*$", "")
else
local multiArgs = {}
if blockContent ~= "" then
table.insert(multiArgs, blockContent)
end
i = i + 1
while i <= #annotations do
local nextAnn = annotations[i]
local content = nextAnn:match("^%-%-%-?%s*(.*)") or nextAnn
if content:match("%]%]%s*$") then
local finalContent = content:gsub("%]%]%s*$", "")
if finalContent ~= "" then
table.insert(multiArgs, finalContent)
end
break
else
table.insert(multiArgs, content)
end
i = i + 1
end
args = table.concat(multiArgs, "\n")
end
elseif args == ">" then
local multiArgs = "" local multiArgs = ""
i = i + 1 i = i + 1

View File

@@ -16,6 +16,13 @@ local function processDescription(description)
return table.concat(lines, "\n") return table.concat(lines, "\n")
end end
local function escapeInlineCode(text)
if not text then return text end
text = text:gsub("({%b[]})", "`%1`")
text = text:gsub("(%b[]%s*=%s*[^%s,}]+)", "`%1`")
return text
end
local function generateFunctionMarkdown(class, functions) local function generateFunctionMarkdown(class, functions)
local md = {} local md = {}
@@ -48,7 +55,7 @@ local function generateFunctionMarkdown(class, functions)
if p.optional then paramLine = paramLine .. " *(optional)*" end if p.optional then paramLine = paramLine .. " *(optional)*" end
paramLine = paramLine .. " `" .. p.type .. "`" paramLine = paramLine .. " `" .. p.type .. "`"
if p.description and p.description ~= "" then if p.description and p.description ~= "" then
paramLine = paramLine .. " " .. p.description paramLine = paramLine .. " " .. escapeInlineCode(p.description)
end end
table.insert(md, paramLine) table.insert(md, paramLine)
end end
@@ -63,7 +70,7 @@ local function generateFunctionMarkdown(class, functions)
returnLine = returnLine .. " `" .. r.name .. "`" returnLine = returnLine .. " `" .. r.name .. "`"
end end
if r.description and r.description ~= "" then if r.description and r.description ~= "" then
returnLine = returnLine .. " " .. r.description returnLine = returnLine .. " " .. escapeInlineCode(r.description)
end end
table.insert(md, returnLine) table.insert(md, returnLine)
end end
@@ -72,16 +79,18 @@ local function generateFunctionMarkdown(class, functions)
if f.usage then if f.usage then
table.insert(md, "### Usage") table.insert(md, "### Usage")
table.insert(md, "```lua") for _, usageBlock in ipairs(f.usage) do
for _, usage in ipairs(f.usage) do table.insert(md, "```lua")
if usage == "" then if type(usageBlock) == "string" then
table.insert(md, "") if usageBlock:match("\n") then
else table.insert(md, usageBlock)
table.insert(md, usage) else
table.insert(md, usageBlock)
end
end end
table.insert(md, "```")
table.insert(md, "")
end end
table.insert(md, "```")
table.insert(md, "")
end end
if f.run then if f.run then
@@ -157,6 +166,57 @@ function markdownGenerator.generate(ast)
end end
table.insert(md, "") table.insert(md, "")
if class.usage then
table.insert(md, "## Usage")
for _, usageBlock in ipairs(class.usage) do
table.insert(md, "```lua")
if type(usageBlock) == "string" then
table.insert(md, usageBlock)
end
table.insert(md, "```")
table.insert(md, "")
end
end
if class.run then
table.insert(md, "## Examples (Executable)")
for _, runBlock in ipairs(class.run) do
table.insert(md, "```lua run")
if type(runBlock) == "string" then
table.insert(md, runBlock)
end
table.insert(md, "```")
table.insert(md, "")
end
end
if class.notes then
for _, note in ipairs(class.notes) do
table.insert(md, "> **Note:** " .. note)
table.insert(md, "")
end
end
if #class.tableTypes > 0 then
table.insert(md, "## Table Types")
table.insert(md, "")
for _, tableType in ipairs(class.tableTypes) do
table.insert(md, "### " .. tableType.name)
table.insert(md, "")
if #tableType.fields > 0 then
table.insert(md, "|Property|Type|Description|")
table.insert(md, "|---|---|---|")
for _, field in ipairs(tableType.fields) do
table.insert(md, string.format("|%s|%s|%s|",
field.name or "",
field.type or "any",
field.description or ""))
end
table.insert(md, "")
end
end
end
if not class.skipPropertyList and #class.properties > 0 then if not class.skipPropertyList and #class.properties > 0 then
table.insert(md, "## Properties") table.insert(md, "## Properties")
table.insert(md, "") table.insert(md, "")

View File

@@ -85,16 +85,13 @@ local function collectAllClassNames(folder)
end end
local function getParentProperties(parentClass, allClasses) local function getParentProperties(parentClass, allClasses)
-- Rekursiv alle Properties der Elternklasse(n) holen
local properties = {} local properties = {}
if parentClass then if parentClass then
for _, classContent in pairs(allClasses) do for _, classContent in pairs(allClasses) do
if classContent.name == parentClass then if classContent.name == parentClass then
-- Properties der Elternklasse kopieren
for _, prop in ipairs(classContent.properties) do for _, prop in ipairs(classContent.properties) do
table.insert(properties, prop) table.insert(properties, prop)
end end
-- Auch von der Elternklasse der Elternklasse holen
if classContent.parent then if classContent.parent then
local parentProps = getParentProperties(classContent.parent, allClasses) local parentProps = getParentProperties(classContent.parent, allClasses)
for _, prop in ipairs(parentProps) do for _, prop in ipairs(parentProps) do

View File

@@ -1,5 +1,21 @@
local minify = loadfile("tools/minify.lua")() local minify = loadfile("tools/minify.lua")()
local function loadConfig()
local config = dofile("config.lua")
return config
end
local function isDefaultFile(path, config)
for _, category in pairs(config.categories) do
for fileName, fileInfo in pairs(category.files) do
if fileInfo.path == path and fileInfo.default == true then
return true
end
end
end
return false
end
local function scanDir(dir) local function scanDir(dir)
local files = {} local files = {}
for file in io.popen('find "'..dir..'" -type f -name "*.lua"'):lines() do for file in io.popen('find "'..dir..'" -type f -name "*.lua"'):lines() do
@@ -13,9 +29,11 @@ local function scanDir(dir)
return files return files
end end
local function bundle() local function bundle(coreOnly)
local outputFile = coreOnly and "release/basalt-core.lua" or "release/basalt-full.lua"
local config = coreOnly and loadConfig() or nil
local files = scanDir("src") local files = scanDir("src")
local output = { local output = {
'local minified = true\n', 'local minified = true\n',
'local minified_elementDirectory = {}\n', 'local minified_elementDirectory = {}\n',
@@ -27,51 +45,61 @@ local function bundle()
} }
for _, file in ipairs(files) do for _, file in ipairs(files) do
if not coreOnly or isDefaultFile(file.path, config) then
local elementName = file.path:match("^elements/(.+)%.lua$")
if elementName then
table.insert(output, string.format(
'minified_elementDirectory["%s"] = {}\n',
elementName
))
end
local elementName = file.path:match("^elements/(.+)%.lua$") local pluginName = file.path:match("^plugins/(.+)%.lua$")
if elementName then if pluginName then
table.insert(output, string.format( table.insert(output, string.format(
'minified_elementDirectory["%s"] = {}\n', 'minified_pluginDirectory["%s"] = {}\n',
elementName pluginName
)) ))
end end
local pluginName = file.path:match("^plugins/(.+)%.lua$")
if pluginName then
table.insert(output, string.format(
'minified_pluginDirectory["%s"] = {}\n',
pluginName
))
end end
end end
local includedFiles = {}
for _, file in ipairs(files) do for _, file in ipairs(files) do
local f = io.open(file.fullPath, "r") if not coreOnly or isDefaultFile(file.path, config) then
local content = f:read("*all") table.insert(includedFiles, file)
f:close()
local success, minified = minify(content) local f = io.open(file.fullPath, "r")
if not success then local content = f:read("*all")
print("Failed to minify " .. file.path) f:close()
os.exit(1)
local success, minified = minify(content)
if not success then
print("Failed to minify " .. file.path)
os.exit(1)
end
table.insert(output, string.format(
'project["%s"] = function(...) %s end\n',
file.path, minified
))
end end
table.insert(output, string.format(
'project["%s"] = function(...) %s end\n',
file.path, minified
))
end end
table.insert(output, 'return project["main.lua"]()') table.insert(output, 'return project["main.lua"]()')
local out = io.open("release/basalt.lua", "w") local out = io.open(outputFile, "w")
out:write(table.concat(output)) out:write(table.concat(output))
out:close() out:close()
print("Successfully bundled files:") print("Successfully bundled " .. outputFile .. ":")
for _, file in ipairs(files) do for _, file in ipairs(includedFiles) do
print("- " .. file.path) print("- " .. file.path)
end end
print("Total files: " .. #includedFiles)
end end
bundle() print("=== Building Full Version ===")
bundle(false)
print("\n=== Building Core Version ===")
bundle(true)