Compare commits
76 Commits
v2.0.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 25890131e0 | |||
|
|
278b7ca1da | ||
|
|
5ddca6850a | ||
|
|
0fbd348bba | ||
|
|
6ad7e0f51b | ||
|
|
47354f64ee | ||
|
|
6d347a7a58 | ||
|
|
71d23ee9a2 | ||
|
|
e4ee937f0f | ||
|
|
084c882a23 | ||
|
|
234151d0a0 | ||
|
|
52bef2a613 | ||
|
|
cf082af210 | ||
|
|
424334cfae | ||
|
|
2ca7ad1e4c | ||
|
|
0c80c958c0 | ||
|
|
fd97eda9fe | ||
|
|
083a3b0b7b | ||
|
|
b4c987d2bd | ||
|
|
1ade7ff00a | ||
|
|
24bd3a0398 | ||
|
|
1b2c6021e3 | ||
|
|
5dd2c77dbc | ||
|
|
2b0f14dc56 | ||
|
|
465f544ff7 | ||
|
|
c723c66004 | ||
|
|
6c07a8074a | ||
|
|
250ce886ca | ||
|
|
dc51a73749 | ||
|
|
e605840e06 | ||
|
|
7375c33bbb | ||
|
|
482dc266bc | ||
|
|
09081e9636 | ||
|
|
f14928859c | ||
|
|
82d8d6cc43 | ||
|
|
e92f137fb4 | ||
|
|
278ad08c04 | ||
|
|
5161b51323 | ||
|
|
3e79321143 | ||
|
|
a97f799b4b | ||
|
|
8afdb785e0 | ||
|
|
a967cde115 | ||
|
|
196cf93f68 | ||
|
|
7649c97c71 | ||
|
|
8c73572a6c | ||
|
|
9120191008 | ||
|
|
fd67d0e4c7 | ||
|
|
7871234251 | ||
|
|
4ba25b73b6 | ||
|
|
69a0254f84 | ||
|
|
c6cb1ac670 | ||
|
|
1f5a4b9c3a | ||
|
|
83c27d824f | ||
|
|
fa25007de6 | ||
|
|
535b109dee | ||
|
|
c2621bbafa | ||
|
|
bf8c6d695c | ||
|
|
8eb3a095d3 | ||
|
|
ba82eaf2e9 | ||
|
|
69034e9a2a | ||
|
|
9dbdc82167 | ||
|
|
a94f78c2bc | ||
|
|
7099b5c548 | ||
|
|
400f19d8f1 | ||
|
|
167fc8ef81 | ||
|
|
d2e0c30b80 | ||
|
|
c05dbd3b8c | ||
|
|
5a1ec672a7 | ||
|
|
6f14eadf0a | ||
|
|
4c856bf710 | ||
|
|
41bd5bdf04 | ||
|
|
c7f63b7684 | ||
|
|
eaad81dc1a | ||
|
|
2244c84323 | ||
|
|
9737c4bb64 | ||
|
|
b96875a3e9 |
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -11,8 +11,6 @@ Flexbox2.lua
|
||||
markdown.lua
|
||||
markdown2.lua
|
||||
SplitPane.lua
|
||||
Accordion.lua
|
||||
Stepper.lua
|
||||
Drawer.lua
|
||||
Breadcrumb.lua
|
||||
Dialog.lua
|
||||
DockLayout.lua
|
||||
9469
BasaltLS.lua
9469
BasaltLS.lua
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
# Basalt 2 - A UI Framework for CC:Tweaked
|
||||
|
||||

|
||||
[](https://discord.gg/yNNnmBVBpE)
|
||||

|
||||
[](https://discord.gg/yNNnmBVBpE)
|
||||
[](https://pinestore.cc/projects/15/basalt)
|
||||
[](https://deepwiki.com/Pyroxenium/Basalt2)
|
||||
[](https://xcc.madefor.cc/basalt-generator.html)
|
||||
|
||||
Welcome,
|
||||
|
||||
|
||||
839
config.lua
839
config.lua
@@ -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",
|
||||
},
|
||||
}
|
||||
76
install.lua
76
install.lua
@@ -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
294
layouts/flow.lua
Normal 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
74
layouts/grid.lua
Normal 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
2007
release/basalt-core.lua
Normal file
File diff suppressed because it is too large
Load Diff
4807
release/basalt-full.lua
Normal file
4807
release/basalt-full.lua
Normal file
File diff suppressed because one or more lines are too long
3291
release/basalt.lua
3291
release/basalt.lua
File diff suppressed because one or more lines are too long
@@ -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
472
src/elements/Accordion.lua
Normal 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
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
126
src/elements/Breadcrumb.lua
Normal 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
|
||||
@@ -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
|
||||
@@ -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
241
src/elements/Collection.lua
Normal 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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
364
src/elements/ContextMenu.lua
Normal file
364
src/elements/ContextMenu.lua
Normal 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
298
src/elements/Dialog.lua
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
429
src/elements/ScrollFrame.lua
Normal file
429
src/elements/ScrollFrame.lua
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
233
src/elements/Toast.lua
Normal 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
|
||||
@@ -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
|
||||
@@ -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
117
src/layoutManager.lua
Normal 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
|
||||
144
src/libraries/collectionentry.lua
Normal file
144
src/libraries/collectionentry.lua
Normal 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
|
||||
171
src/main.lua
171
src/main.lua
@@ -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
|
||||
@@ -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={}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
local log = require("log")
|
||||
|
||||
---@configDefault false
|
||||
|
||||
local activeProfiles = setmetatable({}, {__mode = "k"})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
local log = require("log")
|
||||
local tHex = require("libraries/colorHex")
|
||||
---@configDefault false
|
||||
|
||||
local maxLines = 10
|
||||
local isVisible = false
|
||||
|
||||
@@ -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
157
src/plugins/responsive.lua
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
47
themes/classic.json
Normal 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
44
themes/dark.json
Normal 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
50
themes/light.json
Normal 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
47
themes/orange.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ function classParser.parse(annotations, line)
|
||||
properties = {},
|
||||
events = {},
|
||||
functions = {},
|
||||
tableTypes = {},
|
||||
skip = false
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user