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: |
git config --global user.name 'github-actions[bot]'
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 commit -m "Update config, BasaltLS definitions, bundle and changelog" || exit 0
git add config.lua BasaltLS.lua release/basalt-full.lua release/basalt-core.lua CHANGELOG.md
git commit -m "Update config, BasaltLS definitions, full and core bundles, and changelog" || exit 0
git push

4
.gitignore vendored
View File

@@ -11,8 +11,6 @@ Flexbox2.lua
markdown.lua
markdown2.lua
SplitPane.lua
Accordion.lua
Stepper.lua
Drawer.lua
Breadcrumb.lua
Dialog.lua
DockLayout.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
![GitHub Repo stars](https://img.shields.io/github/stars/Pyroxenium/Basalt2?style=for-the-badge)
[![Discord](https://img.shields.io/discord/976905222251233320?label=Discord&style=for-the-badge)](https://discord.gg/yNNnmBVBpE)
![GitHub Repo stars](https://img.shields.io/github/stars/Pyroxenium/Basalt2)
[![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)
[![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,

View File

@@ -1,448 +1,517 @@
return {
["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"] = {
["files"] = {
["colorHex"] = {
["default"] = true,
["size"] = 132,
["path"] = "libraries/colorHex.lua",
["requires"] = {
},
["description"] = "",
},
["utils"] = {
["default"] = true,
["size"] = 2661,
["path"] = "libraries/utils.lua",
["description"] = "",
["requires"] = {
},
["description"] = "",
},
["expect"] = {
["default"] = true,
["size"] = 846,
["path"] = "libraries/expect.lua",
["description"] = "",
["requires"] = {
},
},
["collectionentry"] = {
["default"] = true,
["size"] = 3551,
["path"] = "libraries/collectionentry.lua",
["description"] = "",
["requires"] = {
},
},
["colorHex"] = {
["default"] = true,
["size"] = 132,
["path"] = "libraries/colorHex.lua",
["description"] = "",
["requires"] = {
},
},
},
["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"] = {
["files"] = {
["SideNav"] = {
["default"] = true,
["size"] = 20221,
["path"] = "elements/SideNav.lua",
["ScrollFrame"] = {
["default"] = false,
["size"] = 17831,
["path"] = "elements/ScrollFrame.lua",
["description"] = "A scrollable container that automatically displays scrollbars when content overflows.",
["requires"] = {
[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"] = {
["default"] = true,
["size"] = 10012,
["size"] = 18777,
["path"] = "elements/BaseElement.lua",
["description"] = "The base class for all UI elements in Basalt.",
["requires"] = {
},
["description"] = "The base class for all UI elements in Basalt.",
},
["Button"] = {
["default"] = true,
["size"] = 2540,
["size"] = 2461,
["path"] = "elements/Button.lua",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "The Button is a standard button element with click handling and state management.",
},
["Switch"] = {
["default"] = true,
["size"] = 3269,
["path"] = "elements/Switch.lua",
["requires"] = {
[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"] = {
["default"] = false,
["size"] = 21649,
["size"] = 21675,
["path"] = "elements/BigFont.lua",
["description"] = "",
["requires"] = {
[1] = "VisualElement",
},
["description"] = "",
},
["Frame"] = {
["Container"] = {
["default"] = true,
["size"] = 6508,
["path"] = "elements/Frame.lua",
["size"] = 27731,
["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"] = {
[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.",
["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",
},
["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"] = {
["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 releasePath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/release/basalt.lua"
local devPath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/src/"
local configPath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/config.lua"
local luaLSPath = "https://raw.githubusercontent.com/Pyroxenium/Basalt2/refs/heads/main/BasaltLS.lua"
local fullPath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/release/basalt-full.lua"
local corePath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/release/basalt-core.lua"
local devPath = "https://git.liulikeji.cn/GitHub/Basalt2/raw/branch/main/src/"
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 config
@@ -24,22 +25,37 @@ if(args[1] == "-h")or(args[1] == "--help")then
print("Usage: install.lua [options]")
print("Options:")
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")
return
end
if(args[1] == "-r")or(args[1] == "--release")then
print("Installing release version...")
local request = http.get(releasePath)
print("Installing core release version...")
local request = http.get(corePath)
if not request then
error("Failed to download Basalt")
error("Failed to download Basalt Core")
end
local file = fs.open(args[2] or "basalt.lua", "w")
file.write(request.readAll())
file.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
end
@@ -83,7 +99,7 @@ if(args[1] == "-d")or(args[1] == "--dev")then
end
local basaltRequest = http.get(releasePath)
local basaltRequest = http.get(fullPath)
if not basaltRequest then
error("Failed to download Basalt")
end
@@ -131,7 +147,10 @@ local function getScreenPosition(index)
end
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)
local height = getChildrenHeight(self)
local scrollOffset = self:getOffsetY()
@@ -235,17 +254,19 @@ installScreen:addLabel(coloring)
local versionDropdown = installScreen:addDropDown()
:setPosition("{parent.width - self.width - 1}", 4)
:setSize(15, 1)
:setSize(20, 1)
:setBackground(colors.black)
:setForeground(colors.white)
:addItem("Release")
:addItem("Release (Core)")
:addItem("Release (Full)")
:addItem("Dev")
:addItem("Custom")
:selectItem(1)
local versionDesc = installScreen:addLabel("versionDesc")
:setWidth("{parent.width - 2}")
: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)
:setSize("{parent.width - 4}", 3)
:setBackground(colors.lightGray)
@@ -285,8 +306,14 @@ local installPathInput = installScreen:addInput()
:setForeground(colors.white)
versionDropdown:onSelect(function(self, index, item)
if(item.text == "Release") then
versionDesc:setText("The Release version is the most stable and tested version of Basalt. It is recommended for production use.")
if(item.text == "Release (Core)") then
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)
luaLSCheckbox:setVisible(false)
luaMinifyCheckbox:setVisible(false)
@@ -298,7 +325,7 @@ versionDropdown:onSelect(function(self, index, item)
luaMinifyCheckbox:setVisible(true)
singleFileProject:setVisible(true)
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)
luaLSCheckbox:setVisible(false)
luaMinifyCheckbox:setVisible(false)
@@ -419,9 +446,10 @@ local function updateProgress(progressBar, current, total)
progressBar:setProgress(math.ceil((current / total) * 100))
end
local function installRelease(installPath, log, progressBar)
logMessage(log, "Installing Release version...")
local function installRelease(installPath, log, progressBar, isCore)
logMessage(log, "Installing Release " .. (isCore and "Core" or "Full") .. " version...")
local releasePath = isCore and corePath or fullPath
local request = http.get(releasePath)
if not request then
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 loadedProject = {}\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
@@ -642,7 +670,7 @@ local function installBasalt()
installButton:setVisible(false)
local selection = versionDropdown:getSelectedItems()[1]
if(selection==nil)then
selection = "Release"
selection = "Release (Core)"
else
selection = selection.text
end
@@ -652,8 +680,10 @@ local function installBasalt()
else
path = path:gsub(".lua", "")
end
if(selection == "Release")then
installRelease(path..".lua", log, progressBar)
if(selection == "Release (Core)")then
installRelease(path..".lua", log, progressBar, true)
elseif(selection == "Release (Full)")then
installRelease(path..".lua", log, progressBar, false)
elseif(selection == "Dev")then
installDev(path, log, progressBar)
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._plugins = {}
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 pluginsDirectory = fs.combine(dir, "plugins")
@@ -29,7 +39,9 @@ if fs.exists(elementsDirectory) then
ElementManager._elements[name] = {
class = nil,
plugins = {},
loaded = false
loaded = false,
source = "local",
path = nil
}
end
end
@@ -66,7 +78,9 @@ if(minified)then
ElementManager._elements[name:gsub(".lua", "")] = {
class = nil,
plugins = {},
loaded = false
loaded = false,
source = "local",
path = nil
}
end
if(minified_pluginDirectory==nil)then
@@ -90,20 +104,225 @@ if(minified)then
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.
--- @param name string The name of the element to load
--- @usage ElementManager.loadElement("Button")
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
package.path = main.."rom/?"
local element = require(fs.combine("elements", name))
package.path = defaultPath
local source = ElementManager._elements[name].source or "local"
local element
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] = {
class = element,
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
for _, plugin in pairs(ElementManager._plugins[name]) do
@@ -148,6 +367,17 @@ end
--- @param name string The name of the element to get
--- @return table Element The element class
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
ElementManager.loadElement(name)
end
@@ -167,4 +397,55 @@ function ElementManager.getAPI(name)
return ElementManager._APIs[name]
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

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
--- 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 local chart = main:addBarChart()
--- @usage
--- @usage -- Add two data series with different colors
--- @usage chart:addSeries("input", " ", colors.green, colors.green, 5)
--- @usage chart:addSeries("output", " ", colors.red, colors.red, 5)
--- @usage
--- @usage -- Continuously update the chart with random data
--- @usage basalt.schedule(function()
--- @usage while true do
--- @usage chart:addPoint("input", math.random(1,100))
--- @usage chart:addPoint("output", math.random(1,100))
--- @usage sleep(2)
--- @usage end
--- @usage end)
--- @usage [[
--- -- Create a bar chart
--- local chart = main:addBarChart()
---
--- -- Add two data series with different colors
--- chart:addSeries("input", " ", colors.green, colors.green, 5)
--- chart:addSeries("output", " ", colors.red, colors.red, 5)
---
--- -- Continuously update the chart with random data
--- basalt.schedule(function()
--- while true do
--- chart:addPoint("input", math.random(1,100))
--- chart:addPoint("output", math.random(1,100))
--- sleep(2)
--- end
--- end)
--- ]]
--- @class BarChart : Graph
local BarChart = setmetatable({}, BaseGraph)
BarChart.__index = BarChart
@@ -52,11 +54,11 @@ end
function BarChart:render()
VisualElement.render(self)
local width = self.get("width")
local height = self.get("height")
local minVal = self.get("minValue")
local maxVal = self.get("maxValue")
local series = self.get("series")
local width = self.getResolved("width")
local height = self.getResolved("height")
local minVal = self.getResolved("minValue")
local maxVal = self.getResolved("maxValue")
local series = self.getResolved("series")
local activeSeriesCount = 0
local seriesList = {}

View File

@@ -34,6 +34,13 @@ BaseElement.defineProperty(BaseElement, "eventCallbacks", {default = {}, type =
--- @property enabled boolean BaseElement Controls event processing for this element
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
--- @shortDescription Registers a new event listener for the element (on class level)
--- @param class table The class to register
@@ -93,6 +100,8 @@ function BaseElement:init(props, basalt)
self._values.id = uuid()
self.basalt = basalt
self._registeredEvents = {}
self._registeredStates = {}
self._cachedActiveStates = nil
local currentClass = getmetatable(self).__index
@@ -134,6 +143,7 @@ function BaseElement:postInit()
return self
end
self._postInitialized = true
self._modifiedProperties = {}
if(self._props)then
for k,v in pairs(self._props)do
self.set(k, v)
@@ -197,17 +207,282 @@ function BaseElement:registerCallback(event, callback)
return self
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
--- @shortDescription Triggers event callbacks with provided arguments
--- @param event string The event to fire
--- @param ... any Additional arguments to pass to the callbacks
--- @return table self The BaseElement instance
function BaseElement:fireEvent(event, ...)
if self.get("eventCallbacks")[event] then
for _, callback in ipairs(self.get("eventCallbacks")[event]) do
local result = callback(self, ...)
return result
if self.getResolved("eventCallbacks")[event] then
local lastResult
for _, callback in ipairs(self.getResolved("eventCallbacks")[event]) do
lastResult = callback(self, ...)
end
return lastResult
end
return self
end
@@ -218,7 +493,7 @@ end
--- @return boolean? handled Whether the event was handled
--- @protected
function BaseElement:dispatchEvent(event, ...)
if self.get("enabled") == false then
if self.getResolved("enabled") == false then
return false
end
if self[event] then

View File

@@ -1,6 +1,5 @@
local elementManager = require("elementManager")
local Container = elementManager.getElement("Container")
local errorManager = require("errorManager")
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.

View File

@@ -146,35 +146,37 @@ local VisualElement = elementManager.getElement("VisualElement")
---@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.
--- @usage -- Create a large welcome message
--- @usage local main = basalt.getMainFrame()
--- @usage local title = main:addBigFont()
--- @usage :setPosition(3, 3)
--- @usage :setFontSize(2) -- Makes text twice as large
--- @usage :setText("Welcome!")
--- @usage :setForeground(colors.yellow) -- Make text yellow
--- @usage
--- @usage -- For animated text
--- @usage basalt.schedule(function()
--- @usage while true do
--- @usage title:setForeground(colors.yellow)
--- @usage sleep(0.5)
--- @usage title:setForeground(colors.orange)
--- @usage sleep(0.5)
--- @usage end
--- @usage end)
--- @usage [[
--- -- Create a large welcome message
--- local main = basalt.getMainFrame()
--- local title = main:addBigFont()
--- :setPosition(3, 3)
--- :setFontSize(2) -- Makes text twice as large
--- :setText("Welcome!")
--- :setForeground(colors.yellow) -- Make text yellow
---
--- -- For animated text
--- basalt.schedule(function()
--- while true do
--- title:setForeground(colors.yellow)
--- sleep(0.5)
--- title:setForeground(colors.orange)
--- sleep(0.5)
--- end
--- end)
--- ]]
---@class BigFont : VisualElement
local BigFont = setmetatable({}, VisualElement)
BigFont.__index = BigFont
---@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)
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
end})
---@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)
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
end})
@@ -198,10 +200,10 @@ function BigFont:init(props, basalt)
VisualElement.init(self, props, basalt)
self.set("type", "BigFont")
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)
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
@@ -210,11 +212,12 @@ end
function BigFont:render()
VisualElement.render(self)
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
local text = self.bigfontText[1][i]:sub(1, self.get("width"))
local fg = self.bigfontText[2][i]:sub(1, self.get("width"))
local bg = self.bigfontText[3][i]:sub(1, self.get("width"))
local text = self.bigfontText[1][i]:sub(1, width)
local fg = self.bigfontText[2][i]:sub(1, width)
local bg = self.bigfontText[3][i]:sub(1, width)
self:blit(x, y + i - 1, text, fg, bg)
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.
--- 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 local button = parent:addButton()
--- @usage :setPosition(5, 5)
--- @usage :setText("Click me!")
--- @usage :setBackground(colors.blue)
--- @usage :setForeground(colors.white)
--- @usage
--- @usage -- Add click handling
--- @usage button:onClick(function(self, button, x, y)
--- @usage -- Change appearance when clicked
--- @usage self:setBackground(colors.green)
--- @usage self:setText("Success!")
--- @usage
--- @usage -- Revert after delay
--- @usage basalt.schedule(function()
--- @usage sleep(1)
--- @usage self:setBackground(colors.blue)
--- @usage self:setText("Click me!")
--- @usage end)
--- @usage end)
--- @usage [[
--- -- Create a simple action button
--- local button = parent:addButton()
--- :setPosition(5, 5)
--- :setText("Click me!")
--- :setBackground(colors.blue)
--- :setForeground(colors.white)
---
--- -- Add click handling
--- button:onClick(function(self, button, x, y)
--- -- Change appearance when clicked
--- self:setBackground(colors.green)
--- self:setText("Success!")
---
--- -- Revert after delay
--- basalt.schedule(function()
--- sleep(1)
--- self:setBackground(colors.blue)
--- self:setText("Click me!")
--- end)
--- end)
--- ]]
---@class Button : VisualElement
local Button = setmetatable({}, VisualElement)
Button.__index = Button
@@ -59,10 +61,10 @@ end
--- @protected
function Button:render()
VisualElement.render(self)
local text = self.get("text")
text = text:sub(1, self.get("width"))
local xO, yO = getCenteredPosition(text, self.get("width"), self.get("height"))
self:textFg(xO, yO, text, self.get("foreground"))
local text = self.getResolved("text")
text = text:sub(1, self.getResolved("width"))
local xO, yO = getCenteredPosition(text, self.getResolved("width"), self.getResolved("height"))
self:textFg(xO, yO, text, self.getResolved("foreground"))
end
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.
--- 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 local checkbox = parent:addCheckBox()
--- @usage :setText("Enable Feature")
--- @usage :setCheckedText("✓")
--- @usage :onChange("checked", function(self, checked)
--- @usage -- React to checkbox state changes
--- @usage if checked then
--- @usage -- Handle enabled state
--- @usage else
--- @usage -- Handle disabled state
--- @usage end
--- @usage end)
--- @usage [[
--- -- Create a checkbox for a setting
--- local checkbox = parent:addCheckBox()
--- :setText("Enable Feature")
--- :setCheckedText("✓")
--- :onChange("checked", function(self, checked)
--- -- React to checkbox state changes
--- if checked then
--- -- Handle enabled state
--- else
--- -- Handle disabled state
--- end
--- end)
--- ]]
--- @class CheckBox : VisualElement
local CheckBox = setmetatable({}, VisualElement)
CheckBox.__index = CheckBox
@@ -22,18 +24,18 @@ CheckBox.__index = CheckBox
CheckBox.defineProperty(CheckBox, "checked", {default = false, type = "boolean", canTriggerRender = true})
---@property text string empty Text shown when the checkbox is unchecked
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)
if(self.get("autoSize"))then
if(self.getResolved("autoSize"))then
self.set("width", width)
end
return value
end})
---@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)
local text = self.get("text")
local text = self.getResolved("text")
local width = math.max(#value, #text)
if(self.get("autoSize"))then
if(self.getResolved("autoSize"))then
self.set("width", width)
end
return value
@@ -72,7 +74,7 @@ end
--- @protected
function CheckBox:mouse_click(button, x, y)
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
end
return false
@@ -83,12 +85,12 @@ end
function CheckBox:render()
VisualElement.render(self)
local checked = self.get("checked")
local defaultText = self.get("text")
local checkedText = self.get("checkedText")
local text = string.sub(checked and checkedText or defaultText, 1, self.get("width"))
local checked = self.getResolved("checked")
local defaultText = self.getResolved("text")
local checkedText = self.getResolved("checkedText")
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
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 List = require("elements/List")
local DropDown = require("elements/DropDown")
local tHex = require("libraries/colorHex")
---@configDescription A ComboBox that combines dropdown selection with editable text input
---@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.
--- Supports auto-completion, custom styling, and both single and multi-selection modes.
--- @usage -- Create a searchable country selector
--- @usage local combo = main:addComboBox()
--- @usage :setPosition(5, 5)
--- @usage :setSize(20, 1) -- Height will expand when opened
--- @usage :setItems({
--- @usage {text = "Germany"},
--- @usage {text = "France"},
--- @usage {text = "Spain"},
--- @usage {text = "Italy"}
--- @usage })
--- @usage :setPlaceholder("Select country...")
--- @usage :setAutoComplete(true) -- Enable filtering while typing
--- @usage
--- @usage -- Handle selection changes
--- @usage combo:onChange(function(self, value)
--- @usage -- value will be the selected country
--- @usage basalt.debug("Selected:", value)
--- @usage end)
--- @usage [[
--- -- Create a searchable country selector
--- local combo = main:addComboBox()
--- :setPosition(5, 5)
--- :setSize(20, 1) -- Height will expand when opened
--- :setItems({
--- {text = "Germany"},
--- {text = "France"},
--- {text = "Spain"},
--- {text = "Italy"}
--- })
--- :setSelectedText("Select country...") -- Placeholder text
--- :setAutoComplete(true) -- Enable filtering while typing
---
--- -- Handle selection changes
--- combo:onChange(function(self, value)
--- -- value will be the selected country
--- basalt.debug("Selected:", value)
--- end)
--- ]]
---@class ComboBox : DropDown
local ComboBox = setmetatable({}, DropDown)
ComboBox.__index = ComboBox
@@ -32,19 +34,15 @@ ComboBox.__index = ComboBox
---@property editable boolean true Enables direct text input in the field
ComboBox.defineProperty(ComboBox, "editable", {default = true, type = "boolean", canTriggerRender = true})
---@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
ComboBox.defineProperty(ComboBox, "cursorPos", {default = 1, type = "number"})
---@property viewOffset number 0 Horizontal scroll position for viewing long text
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
ComboBox.defineProperty(ComboBox, "autoComplete", {default = false, type = "boolean"})
---@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("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
end
@@ -109,10 +78,10 @@ end
--- @shortDescription Filters items for auto-complete
--- @private
function ComboBox:getFilteredItems()
local allItems = self.get("items") or {}
local currentText = self.get("text"):lower()
local allItems = self.getResolved("items") or {}
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
end
@@ -137,19 +106,19 @@ end
--- @shortDescription Updates dropdown with filtered items
--- @private
function ComboBox:updateFilteredDropdown()
if not self.get("autoComplete") then return end
if not self.getResolved("autoComplete") then return end
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
self.set("isOpen", true)
self:setState("opened")
self.set("manuallyOpened", false)
local dropdownHeight = self.get("dropdownHeight") or 5
local dropdownHeight = self.getResolved("dropdownHeight") or 5
local actualHeight = math.min(dropdownHeight, #filteredItems)
self.set("height", 1 + actualHeight)
else
self.set("isOpen", false)
self:unsetState("opened")
self.set("manuallyOpened", false)
self.set("height", 1)
end
@@ -159,15 +128,15 @@ end
--- @shortDescription Updates the viewport
--- @private
function ComboBox:updateViewport()
local text = self.get("text")
local cursorPos = self.get("cursorPos")
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
local text = self.getResolved("text")
local cursorPos = self.getResolved("cursorPos")
local width = self.getResolved("width")
local dropSymbol = self.getResolved("dropSymbol")
local textWidth = width - #dropSymbol
if textWidth < 1 then textWidth = 1 end
local viewOffset = self.get("viewOffset")
local viewOffset = self.getResolved("viewOffset")
if cursorPos - viewOffset > textWidth then
viewOffset = cursorPos - textWidth
@@ -182,18 +151,18 @@ end
--- @shortDescription Handles character input
--- @param char string The character that was typed
function ComboBox:char(char)
if not self.get("editable") then return end
if not self.get("focused") then return end
if not self.getResolved("editable") then return end
if not self:hasState("focused") then return end
local text = self.get("text")
local cursorPos = self.get("cursorPos")
local text = self.getResolved("text")
local cursorPos = self.getResolved("cursorPos")
local newText = text:sub(1, cursorPos - 1) .. char .. text:sub(cursorPos)
self.set("text", newText)
self.set("cursorPos", cursorPos + 1)
self:updateViewport()
if self.get("autoComplete") then
if self.getResolved("autoComplete") then
self:updateFilteredDropdown()
else
self:updateRender()
@@ -205,11 +174,11 @@ end
--- @param key number The key code that was pressed
--- @param held boolean Whether the key is being held
function ComboBox:key(key, held)
if not self.get("editable") then return end
if not self.get("focused") then return end
if not self.getResolved("editable") then return end
if not self:hasState("focused") then return end
local text = self.get("text")
local cursorPos = self.get("cursorPos")
local text = self.getResolved("text")
local cursorPos = self.getResolved("cursorPos")
if key == keys.left then
self.set("cursorPos", math.max(1, cursorPos - 1))
@@ -224,7 +193,7 @@ function ComboBox:key(key, held)
self.set("cursorPos", cursorPos - 1)
self:updateViewport()
if self.get("autoComplete") then
if self.getResolved("autoComplete") then
self:updateFilteredDropdown()
else
self:updateRender()
@@ -236,7 +205,7 @@ function ComboBox:key(key, held)
self.set("text", newText)
self:updateViewport()
if self.get("autoComplete") then
if self.getResolved("autoComplete") then
self:updateFilteredDropdown()
else
self:updateRender()
@@ -249,7 +218,11 @@ function ComboBox:key(key, held)
self.set("cursorPos", #text + 1)
self:updateViewport()
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()
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
local relX, relY = self:getRelativePosition(x, y)
local width = self.get("width")
local dropSymbol = self.get("dropSymbol")
local width = self.getResolved("width")
local dropSymbol = self.getResolved("dropSymbol")
local isOpen = self:hasState("opened")
if relY == 1 then
if relX >= width - #dropSymbol + 1 and relX <= width then
local isCurrentlyOpen = self.get("isOpen")
self.set("isOpen", not isCurrentlyOpen)
if self.get("isOpen") then
local allItems = self.get("items") or {}
local dropdownHeight = self.get("dropdownHeight") or 5
if isOpen then
self:unsetState("opened")
self.set("height", 1)
self.set("manuallyOpened", false)
else
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)
else
self.set("height", 1)
self.set("manuallyOpened", false)
end
self:updateRender()
return true
end
if relX <= width - #dropSymbol and self.get("editable") then
local text = self.get("text")
local viewOffset = self.get("viewOffset")
if relX <= width - #dropSymbol and self.getResolved("editable") then
local text = self.getResolved("text")
local viewOffset = self.getResolved("viewOffset")
local maxPos = #text + 1
local targetPos = math.min(maxPos, viewOffset + relX)
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()
return true
end
return true
elseif self.get("isOpen") and relY > 1 and self.get("selectable") then
local itemIndex = (relY - 1) + self.get("offset")
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
elseif isOpen and relY > 1 then
return DropDown.mouse_click(self, button, x, y)
end
return false
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
--- @shortDescription Renders the ComboBox
--- @protected
function ComboBox:render()
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 fg = isFocused and self.get("focusedForeground") or self.get("foreground")
local text = self.getResolved("text")
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 textWidth = width - #dropSymbol
if #text == 0 and not isFocused and #placeholder > 0 then
displayText = placeholder
fg = self.get("placeholderColor")
if #text == 0 and not isFocused and #selectedText > 0 then
displayText = selectedText
fg = colors.gray
end
if #displayText > 0 then
@@ -371,64 +387,45 @@ function ComboBox:render()
string.rep(tHex[fg], width),
string.rep(tHex[bg], width))
if isFocused and self.get("editable") then
local cursorPos = self.get("cursorPos")
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, self.get("foreground"))
self:setCursor(cursorX, 1, true, fg)
end
end
if isOpen then
local items
if self.get("autoComplete") and not self.get("manuallyOpened") then
local actualHeight = self.getResolved("height")
local items = self.getResolved("items")
if self.getResolved("autoComplete") and not self.getResolved("manuallyOpened") then
items = self:getFilteredItems()
else
items = self.get("items")
end
local dropdownHeight = math.min(self.get("dropdownHeight"), #items)
if dropdownHeight > 0 then
local offset = self.get("offset")
local dropdownHeight = math.min(self.getResolved("dropdownHeight"), #items)
for i = 1, dropdownHeight do
local itemIndex = i + offset
if items[itemIndex] then
local item = items[itemIndex]
local itemText = item.text or ""
local isSelected = item.selected or false
local originalItems = self._values.items
self._values.items = items
self.set("height", dropdownHeight)
local itemBg = isSelected and self.get("selectedBackground") or self.get("background")
local itemFg = isSelected and self.get("selectedForeground") or self.get("foreground")
List.render(self, 1)
if #itemText > width then
itemText = itemText:sub(1, width)
end
self._values.items = originalItems
self.set("height", actualHeight)
itemText = itemText .. string.rep(" ", width - #itemText)
self:blit(1, i + 1, itemText,
string.rep(tHex[itemFg], width),
string.rep(tHex[itemBg], width))
end
self:blit(1, 1, fullText,
string.rep(tHex[fg], width),
string.rep(tHex[bg], width))
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
--- Called when the ComboBox gains focus
--- @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
return ComboBox

View File

@@ -1,18 +1,13 @@
local elementManager = require("elementManager")
local errorManager = require("errorManager")
local VisualElement = elementManager.getElement("VisualElement")
local LayoutManager = require("layoutManager")
local expect = require("libraries/expect")
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
---@configDefault true
--- A fundamental layout element that manages child UI components. Containers handle element organization, event propagation,
--- 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
--- A fundamental layout element that manages child UI components. Containers handle element organization, event propagation, rendering hierarchy, and coordinate space management.
---@class Container : VisualElement
local Container = setmetatable({}, VisualElement)
Container.__index = Container
@@ -37,11 +32,11 @@ Container.defineProperty(Container, "focusedChild", {default = nil, type = "tabl
if oldChild:isType("Container") then
oldChild.set("focusedChild", nil, true)
end
oldChild.set("focused", false, true)
oldChild:setFocused(false, true)
end
if value and not internal then
value.set("focused", true, true)
value:setFocused(true, true)
if self.parent then
self.parent:setFocusedChild(self)
end
@@ -78,7 +73,7 @@ for k, _ in pairs(elementManager:getElementList()) do
expect(1, self, "table")
local element = self.basalt.create(k, ...)
self:addChild(element)
element:postInit()
--element:postInit()
return element
end
Container["addDelayed"..capitalizedName] = function(self, prop)
@@ -109,10 +104,12 @@ function Container:init(props, basalt)
self:observe("width", function()
self.set("childrenSorted", false)
self.set("childrenEventsSorted", false)
self:updateRender()
end)
self:observe("height", function()
self.set("childrenSorted", false)
self.set("childrenEventsSorted", false)
self:updateRender()
end)
end
@@ -124,8 +121,8 @@ function Container:isChildVisible(child)
if not child:isType("VisualElement") then return false end
if(child.get("visible") == false)then return false end
if(child._destroyed)then return false end
local containerW, containerH = self.get("width"), self.get("height")
local offsetX, offsetY = self.get("offsetX"), self.get("offsetY")
local containerW, containerH = self.getResolved("width"), self.getResolved("height")
local offsetX, offsetY = self.getResolved("offsetX"), self.getResolved("offsetY")
local childX, childY = child.get("x"), child.get("y")
local childW, childH = child.get("width"), child.get("height")
@@ -209,8 +206,12 @@ end
--- @shortDescription Updates child element ordering
--- @return Container self For method chaining
function Container:sortChildren()
self.set("visibleChildren", sortAndFilterChildren(self, self._values.children))
self.set("childrenSorted", true)
if self._layoutInstance then
self:updateLayout()
end
self.set("visibleChildren", sortAndFilterChildren(self, self._values.children))
return self
end
@@ -300,7 +301,6 @@ function Container:unregisterChildEvent(child, eventName)
end
end
self.set("childrenEventsSorted", false)
self:updateRender()
break
end
end
@@ -353,7 +353,7 @@ local function convertMousePosition(self, event, ...)
local args = {...}
if event and event:find("mouse_") then
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)
args = {button, relX, relY}
end
@@ -368,7 +368,13 @@ end
--- @return boolean handled Whether the event was handled
--- @return table? child The child that handled the 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
local events = children[event]
for i = #events, 1, -1 do
@@ -428,6 +434,7 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Container:mouse_up(button, x, y)
self:mouse_release(button, x, y)
if VisualElement.mouse_up(self, button, x, y) then
local args = convertMousePosition(self, "mouse_up", button, x, y)
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
local args = convertMousePosition(self, "mouse_scroll", direction, x, y)
local success, child = self:callChildrenEvent(true, "mouse_scroll", table.unpack(args))
return success
return true
end
return false
end
@@ -503,8 +510,8 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Container:key(key)
if self.get("focusedChild") then
return self.get("focusedChild"):dispatchEvent("key", key)
if self.getResolved("focusedChild") then
return self.getResolved("focusedChild"):dispatchEvent("key", key)
end
return true
end
@@ -514,8 +521,8 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Container:char(char)
if self.get("focusedChild") then
return self.get("focusedChild"):dispatchEvent("char", char)
if self.getResolved("focusedChild") then
return self.getResolved("focusedChild"):dispatchEvent("char", char)
end
return true
end
@@ -525,8 +532,8 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Container:key_up(key)
if self.get("focusedChild") then
return self.get("focusedChild"):dispatchEvent("key_up", key)
if self.getResolved("focusedChild") then
return self.getResolved("focusedChild"):dispatchEvent("key_up", key)
end
return true
end
@@ -542,8 +549,8 @@ end
--- @return Container self The container instance
--- @protected
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))
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
--- @protected
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
@@ -582,7 +589,7 @@ end
--- @return Container self The container instance
--- @protected
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
@@ -596,7 +603,7 @@ function Container:textBg(x, y, text, bg)
end
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
@@ -610,7 +617,7 @@ function Container:drawText(x, y, text)
end
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
@@ -623,7 +630,7 @@ function Container:drawFg(x, y, fg)
end
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
@@ -644,7 +651,7 @@ end
--- @return Container self The container instance
--- @protected
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
@@ -667,15 +674,15 @@ end
--- @protected
function Container:render()
VisualElement.render(self)
if not self.get("childrenSorted")then
if not self.getResolved("childrenSorted")then
self:sortChildren()
end
if not self.get("childrenEventsSorted")then
if not self.getResolved("childrenEventsSorted")then
for event in pairs(self._values.childrenEvents) do
self:sortChildrenEvents(event)
end
end
for _, child in ipairs(self.get("visibleChildren")) do
for _, child in ipairs(self.getResolved("visibleChildren")) do
if child == self then
errorManager.error("CIRCULAR REFERENCE DETECTED!")
return
@@ -685,6 +692,47 @@ function Container:render()
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
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 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.
---@configDefault false
--- 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, making it ideal for:
--- - Integration with existing CC programs and APIs
--- - Custom drawing operations
--- - Terminal emulation
--- - Complex text manipulation
--- The Display maintains its own terminal buffer and can be manipulated using familiar CC terminal methods.
--- @usage -- Create a display for a custom terminal
--- @usage local display = main:addDisplay()
--- @usage :setSize(30, 10)
--- @usage :setPosition(2, 2)
--- @usage
--- @usage -- Get the window object for CC API operations
--- @usage local win = display:getWindow()
--- @usage
--- @usage -- Use standard CC terminal operations
--- @usage win.setTextColor(colors.yellow)
--- @usage win.setBackgroundColor(colors.blue)
--- @usage win.clear()
--- @usage win.setCursorPos(1, 1)
--- @usage win.write("Hello World!")
--- @usage
--- @usage -- Or use the helper method
--- @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)
--- 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.
--- @usage [[
--- -- Create a display for a custom terminal
--- local display = main:addDisplay()
--- :setSize(30, 10)
--- :setPosition(2, 2)
---
--- -- Get the window object for CC API operations
--- local win = display:getWindow()
---
--- -- Use standard CC terminal operations
--- win.setTextColor(colors.yellow)
--- win.setBackgroundColor(colors.blue)
--- win.clear()
--- win.setCursorPos(1, 1)
--- win.write("Hello World!")
---
--- -- Or use the helper method
--- display:write(1, 2, "Direct write", colors.red, colors.black)
---
--- -- Useful for external APIs
--- local paintutils = require("paintutils")
--- paintutils.drawLine(1, 1, 10, 1, colors.red, win)
--- ]]
---@class Display : VisualElement
local Display = setmetatable({}, VisualElement)
Display.__index = Display
@@ -57,7 +50,7 @@ end
function Display:init(props, basalt)
VisualElement.init(self, props, basalt)
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 blit = self._window.blit
local write = self._window.write
@@ -70,7 +63,7 @@ function Display:init(props, basalt)
end
self._window.getPosition = function(self)
return self.get("x"), self.get("y")
return self.getResolved("x"), self.getResolved("y")
end
self._window.setVisible = function(visible)
@@ -78,7 +71,7 @@ function Display:init(props, basalt)
end
self._window.isVisible = function(self)
return self.get("visible")
return self.getResolved("visible")
end
self._window.blit = function(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)
local window = self._window
if window then
window.reposition(1, 1, width, self.get("height"))
window.reposition(1, 1, width, self.getResolved("height"))
end
end)
self:observe("height", function(self, height)
local window = self._window
if window then
window.reposition(1, 1, self.get("width"), height)
window.reposition(1, 1, self.getResolved("width"), height)
end
end)
end

View File

@@ -1,67 +1,70 @@
local VisualElement = require("elements/VisualElement")
local List = require("elements/List")
local tHex = require("libraries/colorHex")
---@configDescription A DropDown menu that shows a list of selectable items
---@configDefault false
--- Item Properties:
--- Property|Type|Description
--- -------|------|-------------
--- text|string|The display text for the item
--- separator|boolean|Makes item a divider line
--- callback|function|Function called when selected
--- foreground|color|Normal text color
--- background|color|Normal background color
--- selectedForeground|color|Text color when selected
--- selectedBackground|color|Background when selected
---@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
--- 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
--- @usage local dropdown = main:addDropDown()
--- @usage :setPosition(5, 5)
--- @usage :setSize(20, 1) -- Height expands when opened
--- @usage :setSelectedText("Select an option...")
--- @usage
--- @usage -- Add items with different styles and callbacks
--- @usage dropdown:setItems({
--- @usage {
--- @usage text = "Category A",
--- @usage background = colors.blue,
--- @usage foreground = colors.white
--- @usage },
--- @usage { separator = true, text = "-" }, -- Add a separator
--- @usage {
--- @usage text = "Option 1",
--- @usage callback = function(self)
--- @usage -- Handle selection
--- @usage basalt.debug("Selected Option 1")
--- @usage end
--- @usage },
--- @usage {
--- @usage text = "Option 2",
--- @usage -- Custom colors when selected
--- @usage selectedBackground = colors.green,
--- @usage selectedForeground = colors.white
--- @usage }
--- @usage })
--- @usage
--- @usage -- Listen for selections
--- @usage dropdown:onChange(function(self, value)
--- @usage basalt.debug("Selected:", value)
--- @usage end)
--- @run [[
--- local basalt = require("basalt")
--- local main = basalt.getMainFrame()
---
--- -- Create a styled dropdown menu
--- local dropdown = main:addDropDown()
--- :setPosition(5, 5)
--- :setSize(20, 1) -- Height expands when opened
--- :setSelectedText("Select an option...")
---
--- -- Add items with different styles and callbacks
--- dropdown:setItems({
--- {
--- text = "Category A",
--- background = colors.blue,
--- foreground = colors.white
--- },
--- { separator = true, text = "-" }, -- Add a separator
--- {
--- text = "Option 1",
--- callback = function(self)
--- -- Handle selection
--- basalt.LOGGER.debug("Selected Option 1")
--- end
--- },
--- {
--- text = "Option 2",
--- -- Custom colors when selected
--- selectedBackground = colors.green,
--- selectedForeground = colors.white
--- }
--- })
---
--- -- Listen for selections
--- dropdown:onChange(function(self, value)
--- basalt.LOGGER.debug("Selected:", value)
--- end)
---
--- basalt.run()
--- ]]
---@class DropDown : List
local DropDown = setmetatable({}, List)
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
DropDown.defineProperty(DropDown, "dropdownHeight", {default = 5, type = "number"})
---@property selectedText string "" Text shown when no selection made
DropDown.defineProperty(DropDown, "selectedText", {default = "", type = "string"})
---@property dropSymbol string "\31" Indicator for dropdown state
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
--- @shortDescription Creates a new DropDown instance
@@ -84,6 +87,7 @@ end
function DropDown:init(props, basalt)
List.init(self, props, basalt)
self.set("type", "DropDown")
self:registerState("opened", nil, 200)
return self
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
local relX, relY = self:getRelativePosition(x, y)
local isOpen = self:hasState("opened")
if relY == 1 then
self.set("isOpen", not self.get("isOpen"))
if not self.get("isOpen") then
if isOpen then
self.set("height", 1)
self:unsetState("opened")
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
return true
elseif self.get("isOpen") and relY > 1 and self.get("selectable") then
local itemIndex = (relY - 1) + self.get("offset")
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
elseif isOpen and relY > 1 then
return List.mouse_click(self, button, x, y - 1)
end
return false
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
--- @protected
function DropDown:render()
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()
if #selectedItems > 0 then
local selectedItem = selectedItems[1]
text = selectedItem.text or ""
text = text:sub(1, self.get("width") - 2)
text = text:sub(1, width - 2)
end
self:blit(1, 1, text .. string.rep(" ", self.get("width") - #text - 1) .. (self.get("isOpen") and "\31" or "\17"),
string.rep(tHex[self.get("foreground")], self.get("width")),
string.rep(tHex[self.get("background")], self.get("width")))
if self.get("isOpen") then
local items = self.get("items")
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
if isOpen then
local actualHeight = height
local dropdownHeight = math.min(self.getResolved("dropdownHeight"), #self.getResolved("items"))
self.set("height", dropdownHeight)
List.render(self, 1)
self.set("height", actualHeight)
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
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
---@property draggable boolean false Whether the frame is draggable
Frame.defineProperty(Frame, "draggable", {default = false, type = "boolean", setter=function(self, value)
if value then
self:listenEvent("mouse_click", true)
self:listenEvent("mouse_up", true)
self:listenEvent("mouse_drag", true)
end
return value
end})
Frame.defineProperty(Frame, "draggable", {default = false, type = "boolean"})
---@property draggingMap table {} The map of dragging positions
Frame.defineProperty(Frame, "draggingMap", {default = {{x=1, y=1, width="width", height=1}}, type = "table"})
---@property scrollable boolean false Whether the frame is scrollable
Frame.defineProperty(Frame, "scrollable", {default = false, type = "boolean", setter=function(self, value)
if value then
self:listenEvent("mouse_scroll", true)
end
return value
end})
Frame.defineProperty(Frame, "scrollable", {default = false, type = "boolean"})
Frame.defineEvent(Frame, "mouse_click")
Frame.defineEvent(Frame, "mouse_drag")
Frame.defineEvent(Frame, "mouse_up")
Frame.defineEvent(Frame, "mouse_scroll")
--- Creates a new Frame instance
--- @shortDescription Creates a new Frame instance
@@ -36,7 +29,6 @@ function Frame.new()
self.class = Frame
self.set("width", 12)
self.set("height", 6)
self.set("background", colors.gray)
self.set("z", 10)
return self
end
@@ -59,22 +51,22 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Frame:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then
if self.get("draggable") then
if self:isInBounds(x, y) then
if self.getResolved("draggable") then
local relX, relY = self:getRelativePosition(x, y)
local draggingMap = self.get("draggingMap")
local draggingMap = self.getResolved("draggingMap")
for _, map in ipairs(draggingMap) do
local width = map.width or 1
local height = map.height or 1
if type(width) == "string" and width == "width" then
width = self.get("width")
width = self.getResolved("width")
elseif type(width) == "function" then
width = width(self)
end
if type(height) == "string" and height == "height" then
height = self.get("height")
height = self.getResolved("height")
elseif type(height) == "function" then
height = height(self)
end
@@ -82,8 +74,8 @@ function Frame:mouse_click(button, x, y)
local mapY = map.y or 1
if relX >= map.x and relX <= map.x + width - 1 and
relY >= mapY and relY <= mapY + height - 1 then
self.dragStartX = x - self.get("x")
self.dragStartY = y - self.get("y")
self.dragStartX = x - self.getResolved("x")
self.dragStartY = y - self.getResolved("y")
self.dragging = true
return true
end
@@ -117,7 +109,7 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
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 newY = y - self.dragStartY
@@ -125,10 +117,7 @@ function Frame:mouse_drag(button, x, y)
self.set("y", newY)
return true
end
if not self.dragging then
return Container.mouse_drag(self, button, x, y)
end
return false
return Container.mouse_drag(self, button, x, y)
end
--- @shortDescription Calculates the total height of all children elements
@@ -136,7 +125,7 @@ end
--- @protected
function Frame:getChildrenHeight()
local maxHeight = 0
local children = self.get("children")
local children = self.getResolved("children")
for _, child in ipairs(children) do
if child.get("visible") then
@@ -153,6 +142,17 @@ function Frame:getChildrenHeight()
return maxHeight
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
--- @param direction number The scroll direction
--- @param x number The x position of the scroll
@@ -160,18 +160,17 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Frame:mouse_scroll(direction, x, y)
if Container.mouse_scroll(self, direction, x, y) then
return true
end
if(VisualElement.mouse_scroll(self, direction, x, y))then
local args = convertMousePosition(self, "mouse_scroll", direction, x, y)
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 currentOffset = self.get("offsetY")
local currentOffset = self.getResolved("offsetY")
local maxScroll = math.max(0, childrenHeight - height)
local newOffset = currentOffset + direction
@@ -181,7 +180,6 @@ function Frame:mouse_scroll(direction, x, y)
return true
end
end
return false
end

View File

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

View File

@@ -1,12 +1,9 @@
local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement")
local tHex = require("libraries/colorHex")
---@configDescription An element that displays an image in bimg format
---@configDefault false
--- @configDescription An element that displays an image in bimg format
--- @configDefault false
--- 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
--- 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
---@class Image : VisualElement
local Image = setmetatable({}, VisualElement)
Image.__index = Image
@@ -56,7 +53,7 @@ end
--- @param height number The new height of the image
--- @return Image self The Image instance
function Image:resizeImage(width, height)
local frames = self.get("bimg")
local frames = self.getResolved("bimg")
for frameIndex, frame in ipairs(frames) do
local newFrame = {}
@@ -89,7 +86,7 @@ end
--- @return number width The width of the image
--- @return number height The height of the image
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
return #bimg[1][1][1], #bimg[1]
end
@@ -102,7 +99,7 @@ end
--- @return number? bg Background color
--- @return string? char Character at position
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
local text = frame[y][1]
@@ -119,10 +116,10 @@ function Image:getPixelData(x, y)
end
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
frame = {}
self.get("bimg")[self.get("currentFrame")] = frame
self.getResolved("bimg")[self.getResolved("currentFrame")] = frame
end
if not frame[y] then
frame[y] = {"", "", ""}
@@ -131,9 +128,9 @@ local function ensureFrame(self, y)
end
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 maxHeight = neededHeight
@@ -167,13 +164,13 @@ end
--- @return Image self The Image instance
function Image:setText(x, y, text)
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()
if y > imgHeight then return self end
end
local frame = ensureFrame(self, y)
if self.get("autoResize") then
if self.getResolved("autoResize") then
updateFrameSize(self, x + #text - 1, y)
else
local maxLen = #frame[y][1]
@@ -196,7 +193,7 @@ end
--- @return string text The text at the specified position
function Image:getText(x, y, length)
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
local text = frame[y][1]
@@ -217,13 +214,13 @@ end
--- @return Image self The Image instance
function Image:setFg(x, y, pattern)
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()
if y > imgHeight then return self end
end
local frame = ensureFrame(self, y)
if self.get("autoResize") then
if self.getResolved("autoResize") then
updateFrameSize(self, x + #pattern - 1, y)
else
local maxLen = #frame[y][2]
@@ -246,7 +243,7 @@ end
--- @return string fg The foreground color pattern
function Image:getFg(x, y, length)
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
local fg = frame[y][2]
@@ -267,13 +264,13 @@ end
--- @return Image self The Image instance
function Image:setBg(x, y, pattern)
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()
if y > imgHeight then return self end
end
local frame = ensureFrame(self, y)
if self.get("autoResize") then
if self.getResolved("autoResize") then
updateFrameSize(self, x + #pattern - 1, y)
else
local maxLen = #frame[y][3]
@@ -296,7 +293,7 @@ end
--- @return string bg The background color pattern
function Image:getBg(x, y, length)
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
local bg = frame[y][3]
@@ -328,10 +325,10 @@ end
--- @shortDescription Advances to the next frame in the animation
--- @return Image self The Image instance
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 current = self.get("currentFrame")
local frames = self.getResolved("bimg")
local current = self.getResolved("currentFrame")
local next = current + 1
if next > #frames then next = 1 end
@@ -343,7 +340,7 @@ end
--- @shortDescription Adds a new frame to the image
--- @return Image self The Image instance
function Image:addFrame()
local frames = self.get("bimg")
local frames = self.getResolved("bimg")
local width = frames.width or #frames[1][1][1]
local height = frames.height or #frames[1]
local frame = {}
@@ -363,7 +360,7 @@ end
--- @param frame table The new frame data
--- @return Image self The Image instance
function Image:updateFrame(frameIndex, frame)
local frames = self.get("bimg")
local frames = self.getResolved("bimg")
frames[frameIndex] = frame
self:updateRender()
return self
@@ -374,8 +371,8 @@ end
--- @param frameIndex number The index of the frame to get
--- @return table frame The frame data
function Image:getFrame(frameIndex)
local frames = self.get("bimg")
return frames[frameIndex or self.get("currentFrame")]
local frames = self.getResolved("bimg")
return frames[frameIndex or self.getResolved("currentFrame")]
end
--- Gets the metadata of the image
@@ -383,7 +380,7 @@ end
--- @return table metadata The metadata of the image
function Image:getMetadata()
local metadata = {}
local bimg = self.get("bimg")
local bimg = self.getResolved("bimg")
for k,v in pairs(bimg)do
if(type(v)=="string")then
metadata[k] = v
@@ -404,7 +401,7 @@ function Image:setMetadata(key, value)
end
return self
end
local bimg = self.get("bimg")
local bimg = self.getResolved("bimg")
if(type(value)=="string")then
bimg[key] = value
end
@@ -416,13 +413,13 @@ end
function Image:render()
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
local offsetX = self.get("offsetX")
local offsetY = self.get("offsetY")
local elementWidth = self.get("width")
local elementHeight = self.get("height")
local offsetX = self.getResolved("offsetX")
local offsetY = self.getResolved("offsetY")
local elementWidth = self.getResolved("width")
local elementHeight = self.getResolved("height")
for y = 1, elementHeight do
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"})
---@property placeholderColor color gray Color of the placeholder text
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
Input.defineProperty(Input, "pattern", {default = nil, type = "string"})
---@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.defineEvent(Input, "mouse_click")
Input.defineEvent(Input, "mouse_up")
Input.defineEvent(Input, "key")
Input.defineEvent(Input, "char")
Input.defineEvent(Input, "paste")
@@ -65,7 +62,7 @@ end
--- @param blink boolean Whether the cursor should blink
--- @param color number The color of the cursor
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)
end
@@ -74,11 +71,11 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Input:char(char)
if not self.get("focused") then return false end
local text = self.get("text")
local pos = self.get("cursorPos")
local maxLength = self.get("maxLength")
local pattern = self.get("pattern")
if not self:hasState("focused") then return false end
local text = self.getResolved("text")
local pos = self.getResolved("cursorPos")
local maxLength = self.getResolved("maxLength")
local pattern = self.getResolved("pattern")
if maxLength and #text >= maxLength 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:updateViewport()
local relPos = self.get("cursorPos") - self.get("viewOffset")
self:setCursor(relPos, 1, true, self.get("cursorColor") or self.get("foreground"))
local relPos = self.getResolved("cursorPos") - self.getResolved("viewOffset")
self:setCursor(relPos, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
VisualElement.char(self, char)
return true
end
@@ -98,11 +95,11 @@ end
--- @return boolean handled Whether the event was handled
--- @protected
function Input:key(key, held)
if not self.get("focused") then return false end
local pos = self.get("cursorPos")
local text = self.get("text")
local viewOffset = self.get("viewOffset")
local width = self.get("width")
if not self:hasState("focused") then return false end
local pos = self.getResolved("cursorPos")
local text = self.getResolved("text")
local viewOffset = self.getResolved("viewOffset")
local width = self.getResolved("width")
if key == keys.left then
if pos > 1 then
@@ -127,8 +124,8 @@ function Input:key(key, held)
end
end
local relativePos = self.get("cursorPos") - self.get("viewOffset")
self:setCursor(relativePos, 1, true, self.get("cursorColor") or self.get("foreground"))
local relativePos = self.getResolved("cursorPos") - self.getResolved("viewOffset")
self:setCursor(relativePos, 1, true, self.getResolved("cursorColor") or self.getResolved("foreground"))
VisualElement.key(self, key, held)
return true
end
@@ -142,15 +139,15 @@ end
function Input:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y)
local text = self.get("text")
local viewOffset = self.get("viewOffset")
local text = self.getResolved("text")
local viewOffset = self.getResolved("viewOffset")
local maxPos = #text + 1
local targetPos = math.min(maxPos, viewOffset + relX)
self.set("cursorPos", targetPos)
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
end
@@ -161,10 +158,10 @@ end
--- @shortDescription Updates the input's viewport
--- @return Input self The updated instance
function Input:updateViewport()
local width = self.get("width")
local cursorPos = self.get("cursorPos")
local viewOffset = self.get("viewOffset")
local textLength = #self.get("text")
local width = self.getResolved("width")
local cursorPos = self.getResolved("cursorPos")
local viewOffset = self.getResolved("viewOffset")
local textLength = #self.getResolved("text")
if cursorPos - viewOffset >= width then
self.set("viewOffset", cursorPos - width + 1)
@@ -172,7 +169,7 @@ function Input:updateViewport()
self.set("viewOffset", cursorPos - 1)
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
end
@@ -181,7 +178,7 @@ end
--- @protected
function Input:focus()
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()
end
@@ -189,18 +186,18 @@ end
--- @protected
function Input:blur()
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()
end
--- @shortDescription Handles paste events
--- @protected
function Input:paste(content)
if not self.get("focused") then return false end
local text = self.get("text")
local pos = self.get("cursorPos")
local maxLength = self.get("maxLength")
local pattern = self.get("pattern")
if not self:hasState("focused") then return false end
local text = self.getResolved("text")
local pos = self.getResolved("cursorPos")
local maxLength = self.getResolved("maxLength")
local pattern = self.getResolved("pattern")
local newText = text:sub(1, pos - 1) .. content .. text:sub(pos)
if maxLength and #newText > maxLength then
newText = newText:sub(1, maxLength)
@@ -216,31 +213,28 @@ end
--- @shortDescription Renders the input element
--- @protected
function Input:render()
local text = self.get("text")
local viewOffset = self.get("viewOffset")
local width = self.get("width")
local placeholder = self.get("placeholder")
local focusedBg = self.get("focusedBackground")
local focusedFg = self.get("focusedForeground")
local focused = self.get("focused")
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")])
local text = self.getResolved("text")
local viewOffset = self.getResolved("viewOffset")
local placeholder = self.getResolved("placeholder")
local focused = self:hasState("focused")
local width, height = self.getResolved("width"), self.getResolved("height")
local replaceChar = self.getResolved("replaceChar")
self:multiBlit(1, 1, width, height, " ", tHex[self.getResolved("foreground")], tHex[self.getResolved("background")])
if #text == 0 and #placeholder ~= 0 and self.get("focused") == false then
self:textFg(1, 1, placeholder:sub(1, width), self.get("placeholderColor"))
if #text == 0 and #placeholder ~= 0 and not focused then
self:textFg(1, 1, placeholder:sub(1, width), self.getResolved("placeholderColor"))
return
end
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
local visibleText = text:sub(viewOffset + 1, viewOffset + width)
if replaceChar and #replaceChar > 0 then
visibleText = replaceChar:rep(#visibleText)
end
self:textFg(1, 1, visibleText, self.get("foreground"))
self:textFg(1, 1, visibleText, self.getResolved("foreground"))
end
return Input

View File

@@ -3,8 +3,7 @@ local VisualElement = elementManager.getElement("VisualElement")
local wrapText = require("libraries/utils").wrapText
---@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
--- resizes its width based on the text content.
--- This is the label class. It provides a simple text display element that automatically resizes its width based on the text content.
---@class Label : VisualElement
local Label = setmetatable({}, VisualElement)
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
Label.defineProperty(Label, "text", {default = "Label", type = "string", canTriggerRender = true, setter = function(self, value)
if(type(value)=="function")then value = value() end
if(self.get("autoSize"))then
if(self.getResolved("autoSize"))then
self.set("width", #value)
else
self.set("height", #wrapText(value, self.get("width")))
self.set("height", #wrapText(value, self.getResolved("width")))
end
return value
end})
@@ -23,9 +22,9 @@ end})
---@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)
if(value)then
self.set("width", #self.get("text"))
self.set("width", #self.getResolved("text"))
else
self.set("height", #wrapText(self.get("text"), self.get("width")))
self.set("height", #wrapText(self.getResolved("text"), self.getResolved("width")))
end
return value
end})
@@ -38,7 +37,6 @@ function Label.new()
local self = setmetatable({}, Label):__init()
self.class = Label
self.set("z", 3)
self.set("foreground", colors.black)
self.set("backgroundEnabled", false)
return self
end
@@ -50,10 +48,6 @@ end
--- @protected
function Label:init(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")
return self
end
@@ -62,8 +56,8 @@ end
--- @shortDescription Gets the wrapped lines of the Label
--- @return table wrappedText The wrapped lines of the Label
function Label:getWrappedText()
local text = self.get("text")
local wrappedText = wrapText(text, self.get("width"))
local text = self.getResolved("text")
local wrappedText = wrapText(text, self.getResolved("width"))
return wrappedText
end
@@ -71,13 +65,13 @@ end
--- @protected
function Label:render()
VisualElement.render(self)
local text = self.get("text")
if(self.get("autoSize"))then
self:textFg(1, 1, text, self.get("foreground"))
local text = self.getResolved("text")
if(self.getResolved("autoSize"))then
self:textFg(1, 1, text, self.getResolved("foreground"))
else
local wrappedText = wrapText(text, self.get("width"))
local wrappedText = wrapText(text, self.getResolved("width"))
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

View File

@@ -3,20 +3,22 @@ local VisualElement = elementManager.getElement("VisualElement")
local Graph = elementManager.getElement("Graph")
local tHex = require("libraries/colorHex")
--- @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.
--- @usage local chart = main:addLineChart()
--- @usage :addSeries("input", " ", colors.green, colors.green, 10)
--- @usage :addSeries("output", " ", colors.red, colors.red, 10)
--- @usage
--- @usage basalt.schedule(function()
--- @usage while true do
--- @usage chart:addPoint("input", math.random(1,100))
--- @usage chart:addPoint("output", math.random(1,100))
--- @usage sleep(2)
--- @usage end
--- @usage end)
--- @usage [[
--- local chart = main:addLineChart()
--- :addSeries("input", " ", colors.green, colors.green, 10)
--- :addSeries("output", " ", colors.red, colors.red, 10)
---
--- basalt.schedule(function()
--- while true do
--- chart:addPoint("input", math.random(1,100))
--- chart:addPoint("output", math.random(1,100))
--- sleep(2)
--- end
--- end)
--- ]]
--- @class LineChart : Graph
local LineChart = setmetatable({}, Graph)
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 x = math.floor(x1 + dx * 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])
end
end
@@ -62,11 +64,11 @@ end
function LineChart:render()
VisualElement.render(self)
local width = self.get("width")
local height = self.get("height")
local minVal = self.get("minValue")
local maxVal = self.get("maxValue")
local series = self.get("series")
local width = self.getResolved("width")
local height = self.getResolved("height")
local minVal = self.getResolved("minValue")
local maxVal = self.getResolved("maxValue")
local series = self.getResolved("series")
for _, s in pairs(series) do
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
--- This is the list class. It provides a scrollable list of selectable items with support for
--- custom item rendering, separators, and selection handling.
---@class List : VisualElement
local List = setmetatable({}, VisualElement)
---@class List : Collection
local List = setmetatable({}, Collection)
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
List.defineProperty(List, "offset", {default = 0, type = "number", canTriggerRender = true})
---@property selectedBackground color blue Background color for selected items
List.defineProperty(List, "selectedBackground", {default = colors.blue, type = "color"})
---@property selectedForeground color white Text color for selected items
List.defineProperty(List, "selectedForeground", {default = colors.white, type = "color"})
List.defineProperty(List, "offset", {
default = 0,
type = "number",
canTriggerRender = true,
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_up")
List.defineEvent(List, "mouse_drag")
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
--- @shortDescription Creates a new List instance
@@ -34,7 +71,6 @@ function List.new()
self.set("width", 16)
self.set("height", 8)
self.set("z", 5)
self.set("background", colors.gray)
return self
end
@@ -44,73 +80,25 @@ end
--- @return List self The initialized instance
--- @protected
function List:init(props, basalt)
VisualElement.init(self, props, basalt)
Collection.init(self, props, basalt)
self._entrySchema = entrySchema
self.set("type", "List")
return self
end
--- Adds an item to the list
--- @shortDescription Adds an item to the list
--- @param text string|table The item to add (string or item table)
--- @return List self The List instance
--- @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)
self:observe("items", function()
local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
if self.getResolved("offset") > maxOffset then
self.set("offset", maxOffset)
end
end
return selected
end
end)
--- Gets first selected item
--- @shortDescription Gets first selected item
--- @return table? selected The first item
function List:getSelectedItem()
local items = self.get("items")
for i, item in ipairs(items) do
if type(item) == "table" and item.selected then
return item
self:observe("height", function()
local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
if self.getResolved("offset") > maxOffset then
self.set("offset", maxOffset)
end
end
return nil
end)
return self
end
--- @shortDescription Handles mouse click events
@@ -120,40 +108,98 @@ end
--- @return boolean Whether the event was handled
--- @protected
function List:mouse_click(button, x, y)
if self:isInBounds(x, y) and self.get("selectable") then
local _, index = self:getRelativePosition(x, y)
local adjustedIndex = index + self.get("offset")
local items = self.get("items")
if Collection.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y)
local width = self.getResolved("width")
local items = self.getResolved("items")
local height = self.getResolved("height")
local showScrollBar = self.getResolved("showScrollBar")
if adjustedIndex <= #items then
local item = items[adjustedIndex]
if type(item) == "string" then
item = {text = item}
items[adjustedIndex] = item
if showScrollBar and #items > height and relX == width then
local maxOffset = #items - height
local handleSize = math.max(1, math.floor((height / #items) * height))
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
return true
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(items) do
if type(otherItem) == "table" then
otherItem.selected = false
if self.getResolved("selectable") then
local adjustedIndex = relY + self.getResolved("offset")
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
item.selected = not item.selected
item.selected = not item.selected
if item.callback then
item.callback(self)
if item.callback then
item.callback(self)
end
self:fireEvent("select", adjustedIndex, item)
self:updateRender()
end
self:fireEvent("mouse_click", button, x, y)
self:fireEvent("select", adjustedIndex, item)
self:updateRender()
end
return true
end
return false
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
--- @param direction number The direction of the scroll (1 for down, -1 for up)
--- @param x number The x-coordinate of the scroll
@@ -161,13 +207,12 @@ end
--- @return boolean Whether the event was handled
--- @protected
function List:mouse_scroll(direction, x, y)
if self:isInBounds(x, y) then
local offset = self.get("offset")
local maxOffset = math.max(0, #self.get("items") - self.get("height"))
if Collection.mouse_scroll(self, direction, x, y) then
local offset = self.getResolved("offset")
local maxOffset = math.max(0, #self.getResolved("items") - self.getResolved("height"))
offset = math.min(maxOffset, math.max(0, offset + direction))
self.set("offset", offset)
self:fireEvent("mouse_scroll", direction, x, y)
return true
end
return false
@@ -187,7 +232,7 @@ end
--- @shortDescription Scrolls the list to the bottom
--- @return List self The List instance
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)
return self
end
@@ -200,51 +245,166 @@ function List:scrollToTop()
return self
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
--- @protected
function List:render()
VisualElement.render(self)
function List:render(vOffset)
vOffset = vOffset or 0
Collection.render(self)
local items = self.get("items")
local height = self.get("height")
local offset = self.get("offset")
local width = self.get("width")
local items = self.getResolved("items")
local height = self.getResolved("height")
local offset = self.getResolved("offset")
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
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")
local separatorChar = ((item.text or "-") ~= "" and item.text or "-"):sub(1,1)
local separatorText = string.rep(separatorChar, contentWidth)
local fg = item.fg or listFg
local bg = item.bg or listBg
self:textBg(1, i, string.rep(" ", width), bg)
self:textFg(1, i, separatorText:sub(1, width), fg)
self:textBg(1, i + vOffset, string.rep(" ", contentWidth), bg)
self:textFg(1, i + vOffset, separatorText, fg)
else
local text = item.text
local text = item.text or ""
local isSelected = item.selected
local bg = isSelected and
(item.selectedBackground or self.get("selectedBackground")) or
(item.background or self.get("background"))
(item.selectedBg or self.getResolved("selectedBackground")) or
(item.bg or listBg)
local fg = isSelected and
(item.selectedForeground or self.get("selectedForeground")) or
(item.foreground or self.get("foreground"))
(item.selectedFg or self.getResolved("selectedForeground")) or
(item.fg or listFg)
self:textBg(1, i, string.rep(" ", width), bg)
self:textFg(1, i, text:sub(1, width), fg)
local displayText = text
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
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
return List
return List

View File

@@ -3,8 +3,7 @@ local List = require("elements/List")
local tHex = require("libraries/colorHex")
---@configDescription 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.
--- 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.
---@class Menu : List
local Menu = setmetatable({}, List)
Menu.__index = Menu
@@ -12,6 +11,51 @@ Menu.__index = Menu
---@property separatorColor color gray The color used for separator items in the menu
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
--- @shortDescription Creates a new Menu instance
--- @return Menu self The newly created Menu instance
@@ -21,7 +65,7 @@ function Menu.new()
self.class = Menu
self.set("width", 30)
self.set("height", 1)
self.set("background", colors.gray)
self.set("z", 8)
return self
end
@@ -32,84 +76,213 @@ end
--- @protected
function Menu:init(props, basalt)
List.init(self, props, basalt)
self._entrySchema = entrySchema
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
end
--- Sets the menu items
--- @shortDescription Sets the menu items and calculates total width
--- @param items table[] List of items with {text, separator, callback, foreground, background} properties
--- @return Menu self The Menu instance
--- @usage menu:setItems({{text="File"}, {separator=true}, {text="Edit"}})
function Menu:setItems(items)
local listItems = {}
--- Calculates the total width of all menu items with spacing
--- @shortDescription Calculates total width of menu items
--- @return number totalWidth The total width of all items
function Menu:getTotalWidth()
local items = self.getResolved("items")
local spacing = self.getResolved("spacing")
local totalWidth = 0
for _, item in ipairs(items) do
if item.separator then
table.insert(listItems, {text = item.text or "|", selectable = false})
totalWidth = totalWidth + 1
for i, item in ipairs(items) do
if type(item) == "table" then
totalWidth = totalWidth + #item.text
else
local text = " " .. item.text .. " "
item.text = text
table.insert(listItems, item)
totalWidth = totalWidth + #text
totalWidth = totalWidth + #tostring(item) + 2
end
if i < #items then
totalWidth = totalWidth + spacing
end
end
self.set("width", totalWidth)
return List.setItems(self, listItems)
return totalWidth
end
--- @shortDescription Renders the menu horizontally with proper spacing and colors
--- @protected
function Menu:render()
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
for i, item in ipairs(self.get("items")) do
for i, item in ipairs(items) do
if type(item) == "string" then
item = {text = " "..item.." "}
self.get("items")[i] = item
items[i] = item
end
local isSelected = item.selected
local fg = item.selectable == false and self.get("separatorColor") or
(isSelected and (item.selectedForeground or self.get("selectedForeground")) or
(item.foreground or self.get("foreground")))
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))
itemPositions[i] = {
startX = currentX,
endX = currentX + #item.text - 1,
text = item.text,
item = item
}
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
--- @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
function Menu:mouse_click(button, x, y)
if not VisualElement.mouse_click(self, button, x, y) then return false end
if(self.get("selectable") == false) then return false end
local openDropdown = self.getResolved("openDropdown")
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 offset = self.getResolved("horizontalOffset")
local spacing = self.getResolved("spacing")
local items = self.getResolved("items")
local virtualX = relX + offset
local currentX = 1
for i, item in ipairs(self.get("items")) do
if relX >= currentX and relX < currentX + #item.text then
for i, item in ipairs(items) do
local itemWidth = #item.text
if virtualX >= currentX and virtualX < currentX + itemWidth then
if item.selectable ~= false then
if type(item) == "string" then
item = {text = item}
self.get("items")[i] = item
items[i] = item
end
if not self.get("multiSelection") then
for _, otherItem in ipairs(self.get("items")) do
if item.dropdown and #item.dropdown > 0 then
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
otherItem.selected = false
end
@@ -125,7 +298,105 @@ function Menu:mouse_click(button, x, y)
end
return true
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
return false
end

View File

@@ -1,11 +1,65 @@
local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement")
local errorManager = require("errorManager")
--- @configDescription A program that runs in a window
---@configDefault false
--- This is the program class. It provides a program that runs in a window.
---@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)
Program.__index = Program
@@ -200,15 +254,15 @@ function Program:init(props, basalt)
VisualElement.init(self, props, basalt)
self.set("type", "Program")
self:observe("width", function(self, width)
local program = self.get("program")
local program = self.getResolved("program")
if program then
program:resize(width, self.get("height"))
program:resize(width, self.getResolved("height"))
end
end)
self:observe("height", function(self, height)
local program = self.get("program")
local program = self.getResolved("program")
if program then
program:resize(self.get("width"), height)
program:resize(self.getResolved("width"), height)
end
end)
return self
@@ -226,7 +280,7 @@ function Program:execute(path, env, addEnvironment, ...)
local program = BasaltProgram.new(self, env, addEnvironment)
self.set("program", program)
program:setArgs(...)
program:run(path, self.get("width"), self.get("height"), ...)
program:run(path, self.getResolved("width"), self.getResolved("height"), ...)
self:updateRender()
return self
end
@@ -235,7 +289,7 @@ end
--- @shortDescription Stops the program
--- @return Program self The Program instance
function Program:stop()
local program = self.get("program")
local program = self.getResolved("program")
if program then
program:stop()
self.set("running", false)
@@ -278,11 +332,11 @@ end
--- @return any result The event result
--- @protected
function Program:dispatchEvent(event, ...)
local program = self.get("program")
local program = self.getResolved("program")
local result = VisualElement.dispatchEvent(self, event, ...)
if program then
program:resume(event, ...)
if(self.get("focused"))then
if(self:hasState("focused"))then
local cursorBlink = program.window.getCursorBlink()
local cursorX, cursorY = program.window.getCursorPos()
self:setCursor(cursorX, cursorY, cursorBlink, program.window.getTextColor())
@@ -296,7 +350,7 @@ end
--- @protected
function Program:focus()
if(VisualElement.focus(self))then
local program = self.get("program")
local program = self.getResolved("program")
if program then
local cursorBlink = program.window.getCursorBlink()
local cursorX, cursorY = program.window.getCursorPos()
@@ -309,7 +363,7 @@ end
--- @protected
function Program:render()
VisualElement.render(self)
local program = self.get("program")
local program = self.getResolved("program")
if program then
local _, height = program.window.getSize()
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
--- with optional percentage display and customizable colors.
--- @usage local progressBar = main:addProgressBar()
--- @usage progressBar:setDirection("up")
--- @usage progressBar:setProgress(50)
--- @usage [[
--- local progressBar = main:addProgressBar()
--- progressBar:setDirection("up")
--- progressBar:setProgress(50)
--- ]]
---@class ProgressBar : VisualElement
local ProgressBar = setmetatable({}, VisualElement)
ProgressBar.__index = ProgressBar
@@ -45,29 +47,30 @@ end
--- @protected
function ProgressBar:render()
VisualElement.render(self)
local width = self.get("width")
local height = self.get("height")
local progress = math.min(100, math.max(0, self.get("progress")))
local width = self.getResolved("width")
local height = self.getResolved("height")
local progress = math.min(100, math.max(0, self.getResolved("progress")))
local fillWidth = math.floor((width * progress) / 100)
local fillHeight = math.floor((height * progress) / 100)
local direction = self.get("direction")
local progressColor = self.get("progressColor")
local direction = self.getResolved("direction")
local progressColor = self.getResolved("progressColor")
local foreground = self.getResolved("foreground")
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
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
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
self:multiBlit(1, 1, width, fillHeight, " ", tHex[self.get("foreground")], tHex[progressColor])
self:multiBlit(1, 1, width, fillHeight, " ", tHex[foreground], tHex[progressColor])
end
if self.get("showPercentage") then
if self.getResolved("showPercentage") then
local text = tostring(progress).."%"
local x = math.floor((width - #text) / 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

View File

@@ -1,6 +1,7 @@
local VisualElement = require("elements/VisualElement")
local tHex = require("libraries/colorHex")
---@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
---@class ScrollBar : VisualElement
@@ -79,8 +80,8 @@ function ScrollBar:attach(element, config)
self.set("maxValue", config.max or 100)
element:observe(config.property, function(_, value)
if value then
local min = self.get("minValue")
local max = self.get("maxValue")
local min = self.getResolved("minValue")
local max = self.getResolved("maxValue")
if min == max then return end
self.set("value", math.floor(
@@ -95,28 +96,28 @@ end
--- @shortDescription Updates the attached element's property based on the ScrollBar value
--- @return ScrollBar self The ScrollBar instance
function ScrollBar:updateAttachedElement()
local element = self.get("attachedElement")
local element = self.getResolved("attachedElement")
if not element then return end
local value = self.get("value")
local min = self.get("minValue")
local max = self.get("maxValue")
local value = self.getResolved("value")
local min = self.getResolved("minValue")
local max = self.getResolved("maxValue")
if type(min) == "function" then min = min() end
if type(max) == "function" then max = max() end
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
end
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
local function getRelativeScrollPosition(self, 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
--- @shortDescription Handles mouse click events
@@ -128,8 +129,8 @@ end
function ScrollBar:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then
local size = getScrollbarSize(self)
local value = self.get("value")
local handleSize = self.get("handleSize")
local value = self.getResolved("value")
local handleSize = self.getResolved("handleSize")
local handlePos = math.floor((value / 100) * (size - handleSize)) + 1
local relPos = getRelativeScrollPosition(self, x, y)
@@ -154,8 +155,8 @@ end
function ScrollBar:mouse_drag(button, x, y)
if(VisualElement.mouse_drag(self, button, x, y))then
local size = getScrollbarSize(self)
local handleSize = self.get("handleSize")
local dragMultiplier = self.get("dragMultiplier")
local handleSize = self.getResolved("handleSize")
local dragMultiplier = self.getResolved("dragMultiplier")
local relPos = getRelativeScrollPosition(self, x, y)
relPos = math.max(1, math.min(size, relPos))
@@ -178,8 +179,8 @@ end
function ScrollBar:mouse_scroll(direction, x, y)
if not self:isInBounds(x, y) then return false end
direction = direction > 0 and -1 or 1
local step = self.get("step")
local currentValue = self.get("value")
local step = self.getResolved("step")
local currentValue = self.getResolved("value")
local newValue = currentValue - direction * step
self.set("value", math.min(100, math.max(0, newValue)))
@@ -193,21 +194,23 @@ function ScrollBar:render()
VisualElement.render(self)
local size = getScrollbarSize(self)
local value = self.get("value")
local handleSize = self.get("handleSize")
local symbol = self.get("symbol")
local symbolColor = self.get("symbolColor")
local symbolBackgroundColor = self.get("symbolBackgroundColor")
local bgSymbol = self.get("backgroundSymbol")
local isVertical = self.get("orientation") == "vertical"
local value = self.getResolved("value")
local handleSize = self.getResolved("handleSize")
local symbol = self.getResolved("symbol")
local symbolColor = self.getResolved("symbolColor")
local symbolBackgroundColor = self.getResolved("symbolBackgroundColor")
local bgSymbol = self.getResolved("backgroundSymbol")
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
for i = 1, size do
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
self:blit(i, 1, bgSymbol, tHex[self.get("foreground")], tHex[self.get("background")])
self:blit(i, 1, bgSymbol, tHex[foreground], tHex[background])
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 tHex = require("libraries/colorHex")
---@configDescription A SideNav element that provides sidebar navigation with multiple content areas.
---@configDefault false
--- 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
local SideNav = setmetatable({}, Container)
SideNav.__index = SideNav
@@ -59,7 +154,7 @@ end
--- @param title string The title of the navigation item
--- @return table tabHandler The navigation item handler proxy for adding elements
function SideNav:newTab(title)
local tabs = self.get("tabs") or {}
local tabs = self.getResolved("tabs") or {}
local tabId = #tabs + 1
table.insert(tabs, {
@@ -69,7 +164,7 @@ function SideNav:newTab(title)
self.set("tabs", tabs)
if not self.get("activeTab") then
if not self.getResolved("activeTab") then
self.set("activeTab", tabId)
end
self:updateTabVisibility()
@@ -120,7 +215,7 @@ end
--- @return table element The created element
function SideNav:addElement(elementType, tabId)
local element = Container.addElement(self, elementType)
local targetTab = tabId or self.get("activeTab")
local targetTab = tabId or self.getResolved("activeTab")
if targetTab then
element._tabId = targetTab
self:updateTabVisibility()
@@ -135,7 +230,7 @@ end
function SideNav:addChild(child)
Container.addChild(self, child)
if not child._tabId then
local tabs = self.get("tabs") or {}
local tabs = self.getResolved("tabs") or {}
if #tabs > 0 then
child._tabId = 1
self:updateTabVisibility()
@@ -154,7 +249,7 @@ end
--- @shortDescription Sets the active navigation item
--- @param tabId number The ID of the navigation item to activate
function SideNav:setActiveTab(tabId)
local oldTab = self.get("activeTab")
local oldTab = self.getResolved("activeTab")
if oldTab == tabId then return self end
self.set("activeTab", tabId)
self:updateTabVisibility()
@@ -171,7 +266,7 @@ function SideNav:isChildVisible(child)
return false
end
if child._tabId then
return child._tabId == self.get("activeTab")
return child._tabId == self.getResolved("activeTab")
end
return true
end
@@ -185,11 +280,11 @@ function SideNav:getContentXOffset()
end
function SideNav:_getSidebarMetrics()
local tabs = self.get("tabs") or {}
local height = self.get("height") or 1
local sidebarWidth = self.get("sidebarWidth") or 12
local scrollOffset = self.get("sidebarScrollOffset") or 0
local sidebarPos = self.get("sidebarPosition") or "left"
local tabs = self.getResolved("tabs") or {}
local height = self.getResolved("height") or 1
local sidebarWidth = self.getResolved("sidebarWidth") or 12
local scrollOffset = self.getResolved("sidebarScrollOffset") or 0
local sidebarPos = self.getResolved("sidebarPosition") or "left"
local positions = {}
local actualY = 1
@@ -253,7 +348,7 @@ function SideNav:mouse_click(button, x, y)
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1
local width = self.getResolved("width") or 1
local inSidebar = false
if metrics.sidebarPosition == "right" then
@@ -278,7 +373,7 @@ end
function SideNav:getRelativePosition(x, y)
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
return VisualElement.getRelativePosition(self)
@@ -361,7 +456,7 @@ function SideNav:mouse_up(button, x, y)
end
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1
local width = self.getResolved("width") or 1
local inSidebar = false
if metrics.sidebarPosition == "right" then
@@ -380,7 +475,7 @@ function SideNav:mouse_release(button, x, y)
VisualElement.mouse_release(self, button, x, y)
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1
local width = self.getResolved("width") or 1
local inSidebar = false
if metrics.sidebarPosition == "right" then
@@ -399,7 +494,7 @@ function SideNav:mouse_move(_, x, y)
if VisualElement.mouse_move(self, _, x, y) then
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1
local width = self.getResolved("width") or 1
local inSidebar = false
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
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1
local width = self.getResolved("width") or 1
local inSidebar = false
if metrics.sidebarPosition == "right" then
@@ -447,7 +542,7 @@ end
--- @return SideNav self For method chaining
function SideNav:scrollSidebar(direction)
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 newOffset = currentOffset + (direction * 2)
@@ -461,7 +556,7 @@ function SideNav:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then
local baseRelX, baseRelY = VisualElement.getRelativePosition(self, x, y)
local metrics = self:_getSidebarMetrics()
local width = self.get("width") or 1
local width = self.getResolved("width") or 1
local inSidebar = false
if metrics.sidebarPosition == "right" then
@@ -508,23 +603,25 @@ end
--- @protected
function SideNav:render()
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 sidebarW = metrics.sidebarWidth or 12
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
local activeTab = self.get("activeTab")
local activeTab = self.getResolved("activeTab")
for _, pos in ipairs(metrics.positions) do
local bgColor = (pos.id == activeTab) and self.get("activeTabBackground") or self.get("sidebarBackground")
local fgColor = (pos.id == activeTab) and self.get("activeTabTextColor") or self.get("foreground")
local bgColor = (pos.id == activeTab) and self.getResolved("activeTabBackground") or sidebarBackground
local fgColor = (pos.id == activeTab) and self.getResolved("activeTabTextColor") or foreground
local itemHeight = pos.displayHeight or (pos.y2 - pos.y1 + 1)
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
local displayTitle = pos.title
@@ -535,16 +632,16 @@ function SideNav:render()
VisualElement.textFg(self, 2, pos.y1, displayTitle, fgColor)
end
if not self.get("childrenSorted") then
if not self.getResolved("childrenSorted") then
self:sortChildren()
end
if not self.get("childrenEventsSorted") then
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.get("visibleChildren") or {}) do
for _, child in ipairs(self.getResolved("visibleChildren") or {}) do
if child == self then error("CIRCULAR REFERENCE DETECTED!") return end
child:render()
child:postRender()

View File

@@ -1,5 +1,6 @@
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,
--- with customizable colors and value ranges.
@@ -58,9 +59,9 @@ end
--- @return number value The current value (0 to max)
--- @usage local value = slider:getValue()
function Slider:getValue()
local step = self.get("step")
local max = self.get("max")
local maxSteps = self.get("horizontal") and self.get("width") or self.get("height")
local step = self.getResolved("step")
local max = self.getResolved("max")
local maxSteps = self.getResolved("horizontal") and self.getResolved("width") or self.getResolved("height")
return math.floor((step - 1) * (max / (maxSteps - 1)))
end
@@ -73,8 +74,8 @@ end
function Slider:mouse_click(button, x, y)
if self:isInBounds(x, y) then
local relX, relY = self:getRelativePosition(x, y)
local pos = self.get("horizontal") and relX or relY
local maxSteps = self.get("horizontal") and self.get("width") or self.get("height")
local pos = self.getResolved("horizontal") and relX or relY
local maxSteps = self.getResolved("horizontal") and self.getResolved("width") or self.getResolved("height")
self.set("step", math.min(maxSteps, math.max(1, pos)))
self:updateRender()
@@ -92,8 +93,8 @@ Slider.mouse_drag = Slider.mouse_click
--- @protected
function Slider:mouse_scroll(direction, x, y)
if self:isInBounds(x, y) then
local step = self.get("step")
local maxSteps = self.get("horizontal") and self.get("width") or self.get("height")
local step = self.getResolved("step")
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:updateRender()
return true
@@ -105,23 +106,23 @@ end
--- @protected
function Slider:render()
VisualElement.render(self)
local width = self.get("width")
local height = self.get("height")
local horizontal = self.get("horizontal")
local step = self.get("step")
local width = self.getResolved("width")
local height = self.getResolved("height")
local horizontal = self.getResolved("horizontal")
local step = self.getResolved("step")
local barChar = horizontal and "\140" or " "
local text = string.rep(barChar, horizontal and width or height)
if horizontal then
self:textFg(1, 1, text, self.get("barColor"))
self:textBg(step, 1, " ", self.get("sliderColor"))
self:textFg(1, 1, text, self.getResolved("barColor"))
self:textBg(step, 1, " ", self.getResolved("sliderColor"))
else
local bg = self.get("background")
local bg = self.getResolved("background")
for y = 1, height do
self:textBg(1, y, " ", bg)
end
self:textBg(1, step, " ", self.get("sliderColor"))
self:textBg(1, step, " ", self.getResolved("sliderColor"))
end
end

View File

@@ -2,6 +2,7 @@ local elementManager = require("elementManager")
local VisualElement = elementManager.getElement("VisualElement")
local tHex = require("libraries/colorHex")
---@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.
---@class Switch : VisualElement
@@ -52,7 +53,7 @@ end
--- @protected
function Switch:mouse_click(button, x, y)
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
end
return false
@@ -61,20 +62,21 @@ end
--- @shortDescription Renders the Switch
--- @protected
function Switch:render()
local checked = self.get("checked")
local text = self.get("text")
local switchWidth = self.get("width")
local switchHeight = self.get("height")
local checked = self.getResolved("checked")
local text = self.getResolved("text")
local switchWidth = self.getResolved("width")
local switchHeight = self.getResolved("height")
local foreground = self.getResolved("foreground")
local bgColor = checked and self.get("onBackground") or self.get("offBackground")
self:multiBlit(1, 1, switchWidth, switchHeight, " ", tHex[self.get("foreground")], tHex[bgColor])
local bgColor = checked and self.getResolved("onBackground") or self.getResolved("offBackground")
self:multiBlit(1, 1, switchWidth, switchHeight, " ", tHex[foreground], tHex[bgColor])
local sliderSize = math.floor(switchWidth / 2)
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
self:textFg(switchWidth + 2, 1, text, self.get("foreground"))
self:textFg(switchWidth + 2, 1, text, foreground)
end
end

View File

@@ -2,10 +2,105 @@ local elementManager = require("elementManager")
local VisualElement = require("elements/VisualElement")
local Container = elementManager.getElement("Container")
local tHex = require("libraries/colorHex")
local log = require("log")
---@configDescription A TabControl element that provides tabbed interface with multiple content areas.
---@configDefault false
--- 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
local TabControl = setmetatable({}, Container)
TabControl.__index = TabControl
@@ -60,7 +155,7 @@ end
--- @param title string The title of the tab
--- @return table tabHandler The tab handler proxy for adding elements to the new tab
function TabControl:newTab(title)
local tabs = self.get("tabs") or {}
local tabs = self.getResolved("tabs") or {}
local tabId = #tabs + 1
table.insert(tabs, {
@@ -70,7 +165,7 @@ function TabControl:newTab(title)
self.set("tabs", tabs)
if not self.get("activeTab") then
if not self.getResolved("activeTab") then
self.set("activeTab", tabId)
end
self:updateTabVisibility()
@@ -121,7 +216,7 @@ end
--- @return table element The created element
function TabControl:addElement(elementType, tabId)
local element = Container.addElement(self, elementType)
local targetTab = tabId or self.get("activeTab")
local targetTab = tabId or self.getResolved("activeTab")
if targetTab then
element._tabId = targetTab
self:updateTabVisibility()
@@ -136,7 +231,7 @@ end
function TabControl:addChild(child)
Container.addChild(self, child)
if not child._tabId then
local tabs = self.get("tabs") or {}
local tabs = self.getResolved("tabs") or {}
if #tabs > 0 then
child._tabId = 1
self:updateTabVisibility()
@@ -155,7 +250,7 @@ end
--- @shortDescription Sets the active tab
--- @param tabId number The ID of the tab to activate
function TabControl:setActiveTab(tabId)
local oldTab = self.get("activeTab")
local oldTab = self.getResolved("activeTab")
if oldTab == tabId then return self end
self.set("activeTab", tabId)
self:updateTabVisibility()
@@ -172,7 +267,7 @@ function TabControl:isChildVisible(child)
return false
end
if child._tabId then
return child._tabId == self.get("activeTab")
return child._tabId == self.getResolved("activeTab")
end
return true
end
@@ -186,15 +281,15 @@ function TabControl:getContentYOffset()
end
function TabControl:_getHeaderMetrics()
local tabs = self.get("tabs") or {}
local width = self.get("width") or 1
local minTabH = self.get("tabHeight") or 1
local scrollable = self.get("scrollableTab")
local tabs = self.getResolved("tabs") or {}
local width = self.getResolved("width") or 1
local minTabH = self.getResolved("tabHeight") or 1
local scrollable = self.getResolved("scrollableTab")
local positions = {}
if scrollable then
local scrollOffset = self.get("tabScrollOffset") or 0
local scrollOffset = self.getResolved("tabScrollOffset") or 0
local actualX = 1
local totalWidth = 0
@@ -405,10 +500,10 @@ end
--- @param direction number -1 to scroll left, 1 to scroll right
--- @return TabControl self For method chaining
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 currentOffset = self.get("tabScrollOffset") or 0
local currentOffset = self.getResolved("tabScrollOffset") or 0
local maxScroll = metrics.maxScroll or 0
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
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)
return true
end
@@ -454,18 +549,20 @@ end
--- @protected
function TabControl:render()
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 headerH = metrics.headerHeight or 1
VisualElement.multiBlit(self, 1, 1, width, headerH, " ", tHex[self.get("foreground")], tHex[self.get("headerBackground")])
local activeTab = self.get("activeTab")
VisualElement.multiBlit(self, 1, 1, width, headerH, " ", tHex[foreground], tHex[headerBackground])
local activeTab = self.getResolved("activeTab")
for _, pos in ipairs(metrics.positions) do
local bgColor = (pos.id == activeTab) and self.get("activeTabBackground") or self.get("headerBackground")
local fgColor = (pos.id == activeTab) and self.get("activeTabTextColor") or self.get("foreground")
local bgColor = (pos.id == activeTab) and self.getResolved("activeTabBackground") or headerBackground
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 textStartInTitle = 1 + (pos.startClip or 0)
@@ -481,16 +578,16 @@ function TabControl:render()
end
end
if not self.get("childrenSorted") then
if not self.getResolved("childrenSorted") then
self:sortChildren()
end
if not self.get("childrenEventsSorted") then
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.get("visibleChildren") or {}) do
for _, child in ipairs(self.getResolved("visibleChildren") or {}) do
if child == self then error("CIRCULAR REFERENCE DETECTED!") return end
child:render()
child:postRender()

View File

@@ -1,13 +1,40 @@
local VisualElement = require("elements/VisualElement")
local Collection = require("elements/Collection")
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,
--- row selection, and scrolling capabilities.
--- @usage local people = container:addTable():setWidth(40)
--- @usage people:setColumns({{name="Name",width=12}, {name="Age",width=10}, {name="Country",width=15}})
--- @usage people:setData({{"Alice", 30, "USA"}, {"Bob", 25, "UK"}})
---@class Table : VisualElement
local Table = setmetatable({}, VisualElement)
--- 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.
--- @usage [[
--- local peopleTable = main:addTable()
--- :setPosition(1, 2)
--- :setSize(49, 10)
--- :setColumns({
--- {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
---@property columns table {} List of column definitions with {name, width} properties
@@ -27,34 +54,55 @@ Table.defineProperty(Table, "columns", {default = {}, type = "table", canTrigger
end
return t
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
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
Table.defineProperty(Table, "gridColor", {default = colors.gray, type = "color"})
---@property sortColumn number? nil Currently sorted column index
Table.defineProperty(Table, "sortColumn", {default = nil, type = "number", canTriggerRender = true})
---@property sortDirection string "asc" Sort direction ("asc" or "desc")
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
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_drag")
Table.defineEvent(Table, "mouse_up")
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
--- @shortDescription Creates a new Table instance
--- @return Table self The newly created Table instance
@@ -74,96 +122,165 @@ end
--- @return Table self The initialized instance
--- @protected
function Table:init(props, basalt)
VisualElement.init(self, props, basalt)
Collection.init(self, props, basalt)
self._entrySchema = entrySchema
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
end
--- Adds a new column to the table
--- @shortDescription Adds a new column to the table
--- @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
function Table:addColumn(name, width)
local columns = self.get("columns")
local columns = self.getResolved("columns")
table.insert(columns, {name = name, width = width})
self.set("columns", columns)
return self
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
--- @shortDescription Sets a custom sort function for a column
--- @param columnIndex number The index of the column
--- @param sortFn function Function that takes (rowA, rowB) and returns comparison result
--- @return Table self The Table instance
function Table:setColumnSortFunction(columnIndex, sortFn)
local customSorts = self.get("customSortFunction")
local customSorts = self.getResolved("customSortFunction")
customSorts[columnIndex] = sortFn
self.set("customSortFunction", customSorts)
return self
end
--- Adds data with both display and sort values
--- @shortDescription Adds formatted data with raw sort values
--- @param displayData table The formatted data for display
--- @param sortData table The raw data for sorting (optional)
--- Set data with automatic formatting
--- @shortDescription Sets table data with optional column formatters
--- @param rawData table The raw data array (array of row arrays)
--- @param formatters table? Optional formatter functions for columns {[2] = function(value) return value end}
--- @return Table self The Table instance
function Table:setFormattedData(displayData, sortData)
local enrichedData = {}
--- @usage table:setData({{...}}, {[1] = tostring, [2] = function(age) return age.."y" end})
function Table:setData(rawData, formatters)
self:clearData()
for i, row in ipairs(displayData) do
local enrichedRow = {}
for j, cell in ipairs(row) do
enrichedRow[j] = cell
for _, row in ipairs(rawData) do
local cells = {}
local sortValues = {}
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
if sortData and sortData[i] then
enrichedRow._sortValues = sortData[i]
end
table.insert(enrichedData, enrichedRow)
Collection.addItem(self, {
cells = cells,
_sortValues = sortValues,
text = table.concat(cells, " ")
})
end
self.set("data", enrichedData)
return self
end
--- Set data with automatic formatting
--- @shortDescription Sets table data with optional column formatters
--- @param rawData table The raw data array
--- @param formatters table Optional formatter functions for columns {[2] = function(value) return value end}
--- @return Table self The Table instance
function Table:setData(rawData, formatters)
if not formatters then
self.set("data", rawData)
return self
end
--- Gets all table data
--- @shortDescription Gets all rows as array of cell arrays
--- @return table data Array of row cell arrays
function Table:getData()
local items = self.getResolved("items")
local data = {}
local formattedData = {}
for i, row in ipairs(rawData) do
local formattedRow = {}
for j, cell in ipairs(row) do
if formatters[j] then
formattedRow[j] = formatters[j](cell)
else
formattedRow[j] = cell
end
for _, item in ipairs(items) do
local cells = item._data and item._data.cells or item.cells
if cells then
table.insert(data, cells)
end
table.insert(formattedData, formattedRow)
end
return self:setFormattedData(formattedData, rawData)
return data
end
--- @shortDescription Calculates column widths for rendering
@@ -241,33 +358,38 @@ end
--- @param columnIndex number The index of the column to sort by
--- @param fn function? Optional custom sorting function
--- @return Table self The Table instance
function Table:sortData(columnIndex, fn)
local data = self.get("data")
local direction = self.get("sortDirection")
local customSorts = self.get("customSortFunction")
function Table:sortByColumn(columnIndex, fn)
local items = self.getResolved("items")
local direction = self.getResolved("sortDirection")
local customSorts = self.getResolved("customSortFunction")
local sortFn = fn or customSorts[columnIndex]
if sortFn then
table.sort(data, function(a, b)
table.sort(items, function(a, b)
return sortFn(a, b, direction)
end)
else
table.sort(data, function(a, b)
if not a or not b then return false end
table.sort(items, function(a, b)
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
if a._sortValues and a._sortValues[columnIndex] then
valueA = a._sortValues[columnIndex]
if aSortValues and aSortValues[columnIndex] then
valueA = aSortValues[columnIndex]
else
valueA = a[columnIndex]
valueA = aCells[columnIndex]
end
if b._sortValues and b._sortValues[columnIndex] then
valueB = b._sortValues[columnIndex]
if bSortValues and bSortValues[columnIndex] then
valueB = bSortValues[columnIndex]
else
valueB = b[columnIndex]
valueB = bCells[columnIndex]
end
if type(valueA) == "number" and type(valueB) == "number" then
@@ -287,67 +409,154 @@ function Table:sortData(columnIndex, fn)
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
end
--- @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
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 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
local columns = self.get("columns")
local width = self.get("width")
local columns = self.getResolved("columns")
local calculatedColumns = self:calculateColumnWidths(columns, width)
local currentX = 1
for i, col in ipairs(calculatedColumns) do
local colWidth = col.visibleWidth or col.width or 10
if relX >= currentX and relX < currentX + colWidth then
if self.get("sortColumn") == i then
self.set("sortDirection", self.get("sortDirection") == "asc" and "desc" or "asc")
if self.getResolved("sortColumn") == i then
self.set("sortDirection", self.getResolved("sortDirection") == "asc" and "desc" or "asc")
else
self.set("sortColumn", i)
self.set("sortDirection", "asc")
end
self:sortData(i)
break
self:sortByColumn(i)
self:updateRender()
return true
end
currentX = currentX + colWidth
end
return true
end
if relY > 1 then
local rowIndex = relY - 2 + self.get("scrollOffset")
if rowIndex >= 0 and rowIndex < #self.get("data") then
self.set("selectedRow", rowIndex + 1)
local rowIndex = relY - 2 + self.getResolved("offset")
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
return true
end
return true
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
--- @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
function Table:mouse_scroll(direction, x, y)
if(VisualElement.mouse_scroll(self, direction, x, y))then
local data = self.get("data")
local height = self.get("height")
local visibleRows = height - 2
local maxScroll = math.max(0, #data - visibleRows - 1)
local newOffset = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction))
if Collection.mouse_scroll(self, direction, x, y) then
local items = self.getResolved("items")
local height = self.getResolved("height")
local visibleRows = height - 1 -- Subtract header
local maxOffset = math.max(0, #items - visibleRows)
local newOffset = math.min(maxOffset, math.max(0, self.getResolved("offset") + direction))
self.set("scrollOffset", newOffset)
self.set("offset", newOffset)
self:updateRender()
return true
end
return false
@@ -356,21 +565,27 @@ end
--- @shortDescription Renders the table with headers, data and scrollbar
--- @protected
function Table:render()
VisualElement.render(self)
local columns = self.get("columns")
local data = self.get("data")
local selected = self.get("selectedRow")
local sortCol = self.get("sortColumn")
local scrollOffset = self.get("scrollOffset")
local height = self.get("height")
local width = self.get("width")
Collection.render(self)
local columns = self.getResolved("columns")
local items = self.getResolved("items")
local sortCol = self.getResolved("sortColumn")
local offset = self.getResolved("offset")
local height = self.getResolved("height")
local width = self.getResolved("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 lastVisibleColumn = #calculatedColumns
for i, col in ipairs(calculatedColumns) do
if totalWidth + col.visibleWidth > width then
if totalWidth + col.visibleWidth > contentWidth then
lastVisibleColumn = i - 1
break
end
@@ -382,38 +597,73 @@ function Table:render()
if i > lastVisibleColumn then break end
local text = col.name
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
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
end
if currentX <= contentWidth then
self:textBg(currentX, 1, string.rep(" ", contentWidth - currentX + 1), background)
end
for y = 2, height do
local rowIndex = y - 2 + scrollOffset
local rowData = data[rowIndex + 1]
local rowIndex = y - 2 + offset
local item = items[rowIndex + 1]
if rowData and (rowIndex + 1) <= #data then
currentX = 1
local bg = (rowIndex + 1) == selected and self.get("selectedColor") or self.get("background")
if item then
local cells = item._data and item._data.cells or item.cells
local isSelected = item._data and item._data.selected or item.selected
for i, col in ipairs(calculatedColumns) do
if i > lastVisibleColumn then break end
local cellText = tostring(rowData[i] or "")
local paddedText = cellText .. string.rep(" ", col.visibleWidth - #cellText)
if i < lastVisibleColumn then
paddedText = string.sub(paddedText, 1, col.visibleWidth - 1) .. " "
if cells then
currentX = 1
local bg = isSelected and self.getResolved("selectedBackground") or background
for i, col in ipairs(calculatedColumns) do
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
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)
currentX = currentX + col.visibleWidth
if currentX <= contentWidth then
self:textBg(currentX, y, string.rep(" ", contentWidth - currentX + 1), bg)
end
end
else
self:blit(1, y, string.rep(" ", self.get("width")),
string.rep(tHex[self.get("foreground")], self.get("width")),
string.rep(tHex[self.get("background")], self.get("width")))
self:blit(1, y, string.rep(" ", contentWidth),
string.rep(tHex[foreground], contentWidth),
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

View File

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

View File

@@ -1,6 +1,7 @@
local elementManager = require("elementManager")
local BaseElement = elementManager.getElement("BaseElement")
---@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.
---@class Timer : BaseElement
@@ -43,7 +44,7 @@ end
function Timer:start()
if not self.running then
self.running = true
local time = self.get("interval")
local time = self.getResolved("interval")
self.timerId = os.startTimer(time)
end
return self
@@ -69,12 +70,12 @@ function Timer:dispatchEvent(event, ...)
local timerId = select(1, ...)
if timerId == self.timerId then
self.action()
local amount = self.get("amount")
local amount = self.getResolved("amount")
if amount > 0 then
self.set("amount", amount - 1)
end
if amount ~= 0 then
self.timerId = os.startTimer(self.get("interval"))
self.timerId = os.startTimer(self.getResolved("interval"))
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 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.
---@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,
--- with support for selection and scrolling.
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
--- 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
local Tree = setmetatable({}, VisualElement)
Tree.__index = Tree
@@ -13,7 +129,7 @@ Tree.__index = Tree
---@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)
if #value > 0 then
self.get("expandedNodes")[value[1]] = true
self.getResolved("expandedNodes")[value[1]] = true
end
return value
end})
@@ -21,16 +137,47 @@ end})
Tree.defineProperty(Tree, "selectedNode", {default = nil, type = "table", canTriggerRender = true})
---@property expandedNodes table {} Table of nodes that are currently expanded
Tree.defineProperty(Tree, "expandedNodes", {default = {}, type = "table", canTriggerRender = true})
---@property scrollOffset number 0 Current vertical scroll position
Tree.defineProperty(Tree, "scrollOffset", {default = 0, type = "number", canTriggerRender = true})
---@property offset number 0 Current vertical scroll position
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
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
Tree.defineProperty(Tree, "selectedForegroundColor", {default = colors.white, type = "color"})
---@property selectedBackgroundColor color lightBlue background color of selected node
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_drag")
Tree.defineEvent(Tree, "mouse_up")
Tree.defineEvent(Tree, "mouse_scroll")
--- Creates a new Tree instance
@@ -63,7 +210,7 @@ end
--- @param node table The node to expand
--- @return Tree self The Tree instance
function Tree:expandNode(node)
self.get("expandedNodes")[node] = true
self.getResolved("expandedNodes")[node] = true
self:updateRender()
return self
end
@@ -73,7 +220,7 @@ end
--- @param node table The node to collapse
--- @return Tree self The Tree instance
function Tree:collapseNode(node)
self.get("expandedNodes")[node] = nil
self.getResolved("expandedNodes")[node] = nil
self:updateRender()
return self
end
@@ -83,7 +230,7 @@ end
--- @param node table The node to toggle
--- @return Tree self The Tree instance
function Tree:toggleNode(node)
if self.get("expandedNodes")[node] then
if self.getResolved("expandedNodes")[node] then
self:collapseNode(node)
else
self:expandNode(node)
@@ -91,19 +238,6 @@ function Tree:toggleNode(node)
return self
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
--- @shortDescription Handles mouse click events for node selection and expansion
--- @param button number The button that was clicked
@@ -114,8 +248,54 @@ end
function Tree:mouse_click(button, x, y)
if VisualElement.mouse_click(self, button, x, y) then
local relX, relY = self:getRelativePosition(x, y)
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local visibleIndex = relY + self.get("scrollOffset")
local width = self.getResolved("width")
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
local nodeInfo = flatNodes[visibleIndex]
@@ -142,6 +322,82 @@ function Tree:onSelect(callback)
return self
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
--- @param direction number The scroll direction (1 for up, -1 for down)
--- @param x number The x position of the scroll
@@ -150,11 +406,17 @@ end
--- @protected
function Tree:mouse_scroll(direction, x, y)
if VisualElement.mouse_scroll(self, direction, x, y) then
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local maxScroll = math.max(0, #flatNodes - self.get("height"))
local newScroll = math.min(maxScroll, math.max(0, self.get("scrollOffset") + direction))
local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local height = self.getResolved("height")
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
end
return false
@@ -166,9 +428,21 @@ end
--- @return number height The height of the tree
function Tree:getNodeSize()
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
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
height = #flatNodes
return width, height
@@ -179,15 +453,22 @@ end
function Tree:render()
VisualElement.render(self)
local flatNodes = flattenTree(self.get("nodes"), self.get("expandedNodes"))
local height = self.get("height")
local selectedNode = self.get("selectedNode")
local expandedNodes = self.get("expandedNodes")
local scrollOffset = self.get("scrollOffset")
local horizontalOffset = self.get("horizontalOffset")
local flatNodes = flattenTree(self.getResolved("nodes"), self.getResolved("expandedNodes"))
local height = self.getResolved("height")
local width = self.getResolved("width")
local selectedNode = self.getResolved("selectedNode")
local expandedNodes = self.getResolved("expandedNodes")
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
local nodeInfo = flatNodes[y + scrollOffset]
for y = 1, contentHeight do
local nodeInfo = flatNodes[y + offset]
if nodeInfo then
local node = nodeInfo.node
local level = nodeInfo.level
@@ -199,21 +480,65 @@ function Tree:render()
end
local isSelected = node == selectedNode
local _bg = isSelected and self.get("selectedBackgroundColor") or (node.background or node.bg or self.get("background"))
local _fg = isSelected and self.get("selectedForegroundColor") or (node.foreground or node.fg or self.get("foreground"))
local _bg = isSelected and self.getResolved("selectedBackgroundColor") or (node.background or node.bg or self.getResolved("background"))
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 text = sub(fullText, horizontalOffset + 1, horizontalOffset + self.get("width"))
local paddedText = text .. string.rep(" ", self.get("width") - #text)
local text = sub(fullText, horizontalOffset + 1, horizontalOffset + contentWidth)
local paddedText = text .. string.rep(" ", contentWidth - #text)
local bg = tHex[_bg]:rep(#paddedText) or tHex[colors.black]:rep(#paddedText)
local fg = tHex[_fg]:rep(#paddedText) or tHex[colors.white]:rep(#paddedText)
self:blit(1, y, paddedText, fg, bg)
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
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
return Tree

View File

@@ -22,6 +22,12 @@ VisualElement.defineProperty(VisualElement, "z", {default = 1, type = "number",
return value
end})
VisualElement.defineProperty(VisualElement, "constraints", {
default = {},
type = "table"
})
---@property width number 1 The width of the element
VisualElement.defineProperty(VisualElement, "width", {default = 1, type = "number", canTriggerRender = true})
---@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})
---@property foreground color white The text/foreground color
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
VisualElement.defineProperty(VisualElement, "backgroundEnabled", {default = true, type = "boolean", canTriggerRender = true})
---@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})
---@property borderColor color white Border color
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
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)
end
if(value==false)then
self.set("clicked", false)
self:unsetState("clicked")
end
return value
end})
@@ -82,6 +64,9 @@ end})
---@property ignoreOffset boolean false Whether to ignore the parent's offset
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
VisualElement.combineProperties(VisualElement, "position", "x", "y")
---@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")
---@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 onDrag {button, x, y} Fired when mouse moves while clicked
---@event onScroll {direction, x, y} Fired on mouse scroll
@@ -136,6 +121,12 @@ end
function VisualElement:init(props, basalt)
BaseElement.init(self, props, basalt)
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()
if self.parent then
self.parent.set("childrenSorted", false)
@@ -163,6 +154,530 @@ function VisualElement:init(props, basalt)
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
--- @param x number The x position to draw
--- @param y number The y position to draw
@@ -246,9 +761,9 @@ end
--- @param y number The y position to check
--- @return boolean isInBounds Whether the coordinates are within the bounds of the element
function VisualElement:isInBounds(x, y)
local xPos, yPos = self.get("x"), self.get("y")
local width, height = self.get("width"), self.get("height")
if(self.get("ignoreOffset"))then
local xPos, yPos = self.getResolved("x"), self.getResolved("y")
local width, height = self.getResolved("width"), self.getResolved("height")
if(self.getResolved("ignoreOffset"))then
if(self.parent)then
x = x - self.parent.get("offsetX")
y = y - self.parent.get("offsetY")
@@ -267,7 +782,7 @@ end
--- @protected
function VisualElement:mouse_click(button, x, y)
if self:isInBounds(x, y) then
self.set("clicked", true)
self:setState("clicked")
self:fireEvent("mouse_click", button, self:getRelativePosition(x, y))
return true
end
@@ -282,7 +797,8 @@ end
--- @protected
function VisualElement:mouse_up(button, x, y)
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))
return true
end
@@ -296,7 +812,8 @@ end
--- @protected
function VisualElement:mouse_release(button, x, y)
self:fireEvent("mouse_release", button, self:getRelativePosition(x, y))
self.set("clicked", false)
self:unsetState("clicked")
self:unsetState("dragging")
end
---@shortDescription Handles a mouse move event
@@ -307,7 +824,7 @@ end
--- @protected
function VisualElement:mouse_move(_, x, y)
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(not hover)then
self.set("hover", true)
@@ -344,13 +861,51 @@ end
--- @return boolean drag Whether the element was dragged
--- @protected
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))
return true
end
return false
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
--- @protected
function VisualElement:focus()
@@ -362,13 +917,19 @@ end
function VisualElement:blur()
self:fireEvent("blur")
-- 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
--- Adds or updates a drawable character border around the element using the canvas plugin.
--- 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
--- Gets whether this element is focused
--- @shortDescription Checks if element is focused
--- @return boolean isFocused
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
function VisualElement:addBorder(colorOrOptions, sideOptions)
local col = nil
@@ -410,7 +971,7 @@ end
--- @param key number The key that was pressed
--- @protected
function VisualElement:key(key, held)
if(self.get("focused"))then
if(self:hasState("focused"))then
self:fireEvent("key", key, held)
end
end
@@ -419,7 +980,7 @@ end
--- @param key number The key that was released
--- @protected
function VisualElement:key_up(key)
if(self.get("focused"))then
if(self:hasState("focused"))then
self:fireEvent("key_up", key)
end
end
@@ -428,7 +989,7 @@ end
--- @param char string The character that was pressed
--- @protected
function VisualElement:char(char)
if(self.get("focused"))then
if(self:hasState("focused"))then
self:fireEvent("char", char)
end
end
@@ -438,8 +999,9 @@ end
--- @return number x The x position
--- @return number y The y position
function VisualElement:calculatePosition()
local x, y = self.get("x"), self.get("y")
if not self.get("ignoreOffset") then
self:resolveAllConstraints()
local x, y = self.getResolved("x"), self.getResolved("y")
if not self.getResolved("ignoreOffset") then
if self.parent ~= nil then
local xO, yO = self.parent.get("offsetX"), self.parent.get("offsetY")
x = x - xO
@@ -456,7 +1018,7 @@ end
---@return number x The absolute x position
---@return number y The absolute y position
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
xPos = xPos + x - 1
end
@@ -477,13 +1039,13 @@ end
--- Returns the relative position of the element or the given coordinates.
--- @shortDescription Returns the relative position of the element
---@param x? number x position
---@param y? number y position
---@return number x The relative x position
---@return number y The relative y position
--- @param x? number x position
--- @param y? number y position
--- @return number x The relative x position
--- @return number y The relative y position
function VisualElement:getRelativePosition(x, y)
if (x == nil) or (y == nil) then
x, y = self.get("x"), self.get("y")
x, y = self.getResolved("x"), self.getResolved("y")
end
local parentX, parentY = 1, 1
@@ -491,7 +1053,7 @@ function VisualElement:getRelativePosition(x, y)
parentX, parentY = self.parent:getRelativePosition()
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),
y - (elementY - 1) - (parentY - 1)
end
@@ -531,31 +1093,36 @@ end
--- @shortDescription Renders the element
--- @protected
function VisualElement:render()
if(not self.get("backgroundEnabled"))then return end
local width, height = self.get("width"), self.get("height")
local fgHex = tHex[self.get("foreground")]
local bgHex = tHex[self.get("background")]
if(not self.getResolved("backgroundEnabled"))then return end
local width, height = self.getResolved("width"), self.getResolved("height")
local fgHex = tHex[self.getResolved("foreground")]
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)
if (self.get("borderTop") or self.get("borderBottom") or self.get("borderLeft") or self.get("borderRight")) then
local bColor = self.get("borderColor") or self.get("foreground")
if (bTop or bBottom or bLeft or bRight) then
local bColor = self.getResolved("borderColor") or self.getResolved("foreground")
local bHex = tHex[bColor] or fgHex
if self.get("borderTop") then
if bTop then
self:textFg(1,1,("\131"):rep(width), bColor)
end
if self.get("borderBottom") then
if bBottom then
self:multiBlit(1,height,width,1,"\143", bgHex, bHex)
end
if self.get("borderLeft") then
if bLeft then
self:multiBlit(1,1,1,height,"\149", bHex, bgHex)
end
if self.get("borderRight") then
if bRight then
self:multiBlit(width,1,1,height,"\149", bgHex, bHex)
end
-- Corners
if self.get("borderTop") and self.get("borderLeft") 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 self.get("borderBottom") and self.get("borderLeft") 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 bTop and bLeft then self:blit(1,1,"\151", bHex, bgHex) end
if bTop and bRight then self:blit(width,1,"\148", bgHex, bHex) end
if bBottom and bLeft then self:blit(1,height,"\138", bgHex, bHex) end
if bBottom and bRight then self:blit(width,height,"\133", bgHex, bHex) end
end
end
@@ -565,8 +1132,9 @@ function VisualElement:postRender()
end
function VisualElement:destroy()
self:_removeAllConstraintObservers()
self.set("visible", false)
BaseElement.destroy(self)
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")
function basalt.triggerEvent(eventName, ...)
expect(1, eventName, "string")
if basalt._events[eventName] then
for _, callback in ipairs(basalt._events[eventName]) do
local ok, err = pcall(callback, ...)
@@ -432,4 +431,174 @@ function basalt.triggerEvent(eventName, ...)
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

View File

@@ -1,5 +1,8 @@
---@configDefault false
local registeredAnimations = {}
local easings = {
local easings = {}
easings = {
linear = function(progress)
return progress
end,
@@ -17,6 +20,171 @@ local easings = {
return 2 * progress * progress
end
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
}
@@ -64,7 +232,7 @@ function AnimationInstance:start()
if self.handlers.start then
self.handlers.start(self)
end
return self
return self
end
--- Updates the animation
@@ -520,6 +688,27 @@ Animation.registerAnimation("marquee", {
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
--- @class VisualElement
local VisualElement = {hooks={}}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
local errorManager = require("errorManager")
local PropertySystem = require("propertySystem")
---@configDefault false
local protectedNames = {
colors = true,
@@ -30,13 +31,13 @@ end
local function parseExpression(expr, element, propName)
local deps = analyzeDependencies(expr)
if deps.parent and not element.parent then
errorManager.header = "Reactive evaluation error"
errorManager.error("Expression uses parent but no parent available")
return function() return nil end
end
expr = expr:gsub("^{(.+)}$", "%1")
expr = expr:gsub("([%w_]+)%$([%w_]+)", function(obj, prop)
@@ -87,9 +88,34 @@ local function parseExpression(expr, element, propName)
return nil
end
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
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
local target = element.parent:getChild(objName)
if not target then
@@ -98,7 +124,19 @@ local function parseExpression(expr, element, propName)
return nil
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
}, { __index = mathEnv })
@@ -154,9 +192,17 @@ local observerCache = setmetatable({}, {
end
})
local valueCache = setmetatable({}, {
__mode = "k",
__index = function(t, k)
t[k] = {}
return t[k]
end
})
local function setupObservers(element, expr, propertyName)
local deps = analyzeDependencies(expr)
if observerCache[element][propertyName] then
for _, observer in ipairs(observerCache[element][propertyName]) do
observer.target:removeObserver(observer.property, observer.callback)
@@ -176,14 +222,39 @@ local function setupObservers(element, expr, propertyName)
end
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 = {
target = target,
property = prop,
property = isState and "states" or prop,
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
}
target:observe(prop, observer.callback)
target:observe(observer.property, observer.callback)
table.insert(observers, observer)
end
end
@@ -196,7 +267,7 @@ PropertySystem.addSetterHook(function(element, propertyName, value, config)
if type(value) == "string" and value:match("^{.+}$") then
local expr = value:gsub("^{(.+)}$", "%1")
local deps = analyzeDependencies(expr)
if deps.parent and not element.parent then
return config.default
end
@@ -232,6 +303,8 @@ PropertySystem.addSetterHook(function(element, propertyName, value, config)
end
return config.default
end
valueCache[element][propertyName] = result
return result
end
end
@@ -255,6 +328,7 @@ BaseElement.hooks = {
end
end
observerCache[self] = nil
valueCache[self] = nil
functionCache[self] = nil
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")
---@configDefault false
local defaultTheme = {
default = {
background = colors.lightGray,
background = colors.cyan,
foreground = colors.black,
},
BaseFrame = {
background = colors.white,
foreground = colors.black,
Frame = {
Container = {
default = {
background = colors.cyan,
foreground = colors.black,
},
background = colors.black,
names = {
basaltDebugLogClose = {
background = colors.blue,
foreground = colors.white
Button = {
background = colors.cyan,
foreground = colors.black,
states = {
clicked = {
background = colors.white,
foreground = colors.black,
}
}
},
Input = {
background = colors.cyan,
foreground = colors.black,
},
Label = {
foreground = colors.white,
},
},
Button = {
background = "{self.clicked and colors.black or colors.cyan}",
foreground = "{self.clicked and colors.cyan or colors.black}",
background = colors.cyan,
foreground = colors.black,
states = {
clicked = {
background = colors.black,
foreground = colors.cyan,
}
}
},
Label = {
foreground = colors.black,
},
names = {
@@ -27,10 +54,6 @@ local defaultTheme = {
background = colors.red,
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
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)
if theme.default and theme.default.names and theme.default.names[elementName] then
for k,v in pairs(theme.default.names[elementName]) do
@@ -140,17 +143,46 @@ end
local function collectThemeProps(theme, path, elementType, elementName)
local result = {}
local themeTable = lookUpTemplate(theme, path)
if themeTable then
for k,v in pairs(themeTable) do
if theme.default then
for k,v in pairs(theme.default) do
if type(v) ~= "table" then
result[k] = v
end
end
end
local current = theme
for i = 1, #path do
local types = path[i]
local found = false
if next(result) == nil then
result = getDefaultProperties(theme, elementType)
for _, elementType in ipairs(types) do
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
applyNamedStyles(result, theme, elementType, elementName, themeTable)
@@ -164,22 +196,53 @@ end
--- @param applyToChildren boolean? Whether to apply theme to child elements (default: true)
--- @return BaseElement self The element instance
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()
if(styles ~= nil) then
for prop, value in pairs(styles) do
local config = self._properties[prop]
if(config)then
if((config.type)=="color")then
if(type(value)=="string")then
if(colors[value])then
value = colors[value]
if prop ~= "states" and not backup[prop] then
local config = self._properties[prop]
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(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
self.set(prop, value)
end
end
end
self._modifiedProperties = backup
if(applyToChildren~=false)then
if(self:isType("Container"))then
local children = self.get("children")

View File

@@ -1,5 +1,8 @@
local errorManager = require("errorManager")
local log = require("log")
---@configDefault false
local XMLNode = {
new = function(tag)
return {
@@ -20,15 +23,40 @@ local XMLNode = {
}
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 .. "\"")
end)
local _, _ = string.gsub(s, "(%w+)={(.-)}", function(attribute, expression)
local _, _ = string.gsub(s, "([%w:]+)={(.-)}", function(attribute, expression)
node:addAttribute(attribute, expression)
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)
local stack = {}
local top = XMLNode.new()
@@ -120,7 +148,15 @@ local function convertValue(value, scope)
for k,v in pairs(scope) do
env[k] = v
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
if value == "true" then
@@ -168,6 +204,25 @@ local function createTableFromNode(node, scope)
return list
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 = {}
function BaseElement.setup(element)
@@ -183,32 +238,68 @@ end
function BaseElement:fromXML(node, scope)
if(node.attributes)then
for k, v in pairs(node.attributes) do
if(self._properties[k])then
self.set(k, convertValue(v, scope))
elseif self[k] then
if(k:sub(1,2)=="on")then
local val = v:gsub("\"", "")
if(scope[val])then
if(type(scope[val]) ~= "function")then
errorManager.error("XMLParser: variable '" .. val .. "' is not a function for element '" .. self:getType() .. "' "..k)
if not parseStateAttribute(self, k, v, scope) then
if(self._properties[k])then
self.set(k, convertValue(v, scope))
elseif self[k] then
if(k:sub(1,2)=="on")then
local val = v:gsub("\"", "")
if(scope[val])then
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
self[k](self, scope[val])
else
errorManager.error("XMLParser: variable '" .. val .. "' not found in scope")
errorManager.error("XMLParser: property '" .. k .. "' not found in element '" .. self:getType() .. "'")
end
else
errorManager.error("XMLParser: property '" .. k .. "' not found in element '" .. self:getType() .. "'")
local customXML = self.get("customXML")
customXML.attributes[k] = convertValue(v, scope)
end
else
local customXML = self.get("customXML")
customXML.attributes[k] = convertValue(v, scope)
end
end
end
if(node.children)then
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
self.set(child.tag, createTableFromNode(child, scope))
else
@@ -280,9 +371,15 @@ function Container:fromXML(nodes, scope)
if(nodes.children)then
for _, node in ipairs(nodes.children) do
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)
element:fromXML(node, scope)
else
log.warn("XMLParser: Unknown tag '" .. node.tag .. "' - no handler or element found")
end
end
end

View File

@@ -88,6 +88,17 @@ function PropertySystem.defineProperty(class, name, config)
self:_updateProperty(name, value)
return self
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
--- Combines multiple properties into a single getter and setter
@@ -251,6 +262,8 @@ end
function PropertySystem:__init()
self._values = {}
self._observers = {}
self._states = {}
self._modifiedProperties = {}
self.set = function(name, value, ...)
local oldValue = self._values[name]
@@ -263,6 +276,7 @@ function PropertySystem:__init()
self:updateRender()
end
self._values[name] = applyHooks(self, name, value, config)
self._modifiedProperties[name] = true
if oldValue ~= value and self._observers[name] then
for _, callback in ipairs(self._observers[name]) do
callback(self, value, oldValue)
@@ -281,6 +295,69 @@ function PropertySystem:__init()
return config.getter and config.getter(self, value, ...) or value
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 currentClass = getmetatable(self).__index
@@ -356,6 +433,7 @@ function PropertySystem:_updateProperty(name, value)
oldValue = oldValue(self)
end
self._modifiedProperties[name] = true
self._values[name] = 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)
self["get" .. capitalizedName] = nil
self["set" .. capitalizedName] = nil
self["get" .. capitalizedName .. "State"] = nil
self["set" .. capitalizedName .. "State"] = nil
return self
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 helper = require("utils.helper")
local markdownGenerator = require("utils.markdownGenerator")
BasaltDoc.annotationHandlers = {}
@@ -139,12 +141,42 @@ BasaltDoc.registerAnnotation("@skip", function(target, args)
target.skip = true
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)
if args and args ~= "" then
target.description = args
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 functionParser then functionParser.setHandlers(BasaltDoc.annotationHandlers) end
if propertyParser then propertyParser.setHandlers(BasaltDoc.annotationHandlers) end
@@ -192,12 +224,14 @@ function BasaltDoc.parse(content)
local annotationBuffer = {}
local currentClass = nil
local firstTag = nil
local pendingTableTypes = {}
local blockStartTags = {
["@class"] = true,
["@property"] = true,
["@event"] = true,
["@skip"] = true
["@skip"] = true,
["@tableType"] = true
}
local i = 1
@@ -225,9 +259,25 @@ function BasaltDoc.parse(content)
if firstTag == "@class" and classParser then
local class = classParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n"))
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)
currentClass = class
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
local prop = propertyParser.parse(annotationBuffer, table.concat(annotationBuffer, "\n"))
if prop then

View File

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

View File

@@ -7,7 +7,36 @@ function helper.applyAnnotations(annotations, target, handlers)
local tag, args = ann:match("^%-%-%-?%s*(@%S+)%s*(.*)")
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 = ""
i = i + 1

View File

@@ -16,6 +16,13 @@ local function processDescription(description)
return table.concat(lines, "\n")
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 md = {}
@@ -48,7 +55,7 @@ local function generateFunctionMarkdown(class, functions)
if p.optional then paramLine = paramLine .. " *(optional)*" end
paramLine = paramLine .. " `" .. p.type .. "`"
if p.description and p.description ~= "" then
paramLine = paramLine .. " " .. p.description
paramLine = paramLine .. " " .. escapeInlineCode(p.description)
end
table.insert(md, paramLine)
end
@@ -63,7 +70,7 @@ local function generateFunctionMarkdown(class, functions)
returnLine = returnLine .. " `" .. r.name .. "`"
end
if r.description and r.description ~= "" then
returnLine = returnLine .. " " .. r.description
returnLine = returnLine .. " " .. escapeInlineCode(r.description)
end
table.insert(md, returnLine)
end
@@ -72,16 +79,18 @@ local function generateFunctionMarkdown(class, functions)
if f.usage then
table.insert(md, "### Usage")
table.insert(md, "```lua")
for _, usage in ipairs(f.usage) do
if usage == "" then
table.insert(md, "")
else
table.insert(md, usage)
for _, usageBlock in ipairs(f.usage) do
table.insert(md, "```lua")
if type(usageBlock) == "string" then
if usageBlock:match("\n") then
table.insert(md, usageBlock)
else
table.insert(md, usageBlock)
end
end
table.insert(md, "```")
table.insert(md, "")
end
table.insert(md, "```")
table.insert(md, "")
end
if f.run then
@@ -157,6 +166,57 @@ function markdownGenerator.generate(ast)
end
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
table.insert(md, "## Properties")
table.insert(md, "")

View File

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

View File

@@ -1,5 +1,21 @@
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 files = {}
for file in io.popen('find "'..dir..'" -type f -name "*.lua"'):lines() do
@@ -13,9 +29,11 @@ local function scanDir(dir)
return files
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 output = {
'local minified = true\n',
'local minified_elementDirectory = {}\n',
@@ -27,51 +45,61 @@ local function bundle()
}
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$")
if elementName then
table.insert(output, string.format(
'minified_elementDirectory["%s"] = {}\n',
elementName
))
end
local pluginName = file.path:match("^plugins/(.+)%.lua$")
if pluginName then
table.insert(output, string.format(
'minified_pluginDirectory["%s"] = {}\n',
pluginName
))
local pluginName = file.path:match("^plugins/(.+)%.lua$")
if pluginName then
table.insert(output, string.format(
'minified_pluginDirectory["%s"] = {}\n',
pluginName
))
end
end
end
local includedFiles = {}
for _, file in ipairs(files) do
local f = io.open(file.fullPath, "r")
local content = f:read("*all")
f:close()
if not coreOnly or isDefaultFile(file.path, config) then
table.insert(includedFiles, file)
local success, minified = minify(content)
if not success then
print("Failed to minify " .. file.path)
os.exit(1)
local f = io.open(file.fullPath, "r")
local content = f:read("*all")
f:close()
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
table.insert(output, string.format(
'project["%s"] = function(...) %s end\n',
file.path, minified
))
end
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:close()
print("Successfully bundled files:")
for _, file in ipairs(files) do
print("Successfully bundled " .. outputFile .. ":")
for _, file in ipairs(includedFiles) do
print("- " .. file.path)
end
print("Total files: " .. #includedFiles)
end
bundle()
print("=== Building Full Version ===")
bundle(false)
print("\n=== Building Core Version ===")
bundle(true)