Repository: 1ForeverHD/TopbarPlus
Branch: main
Commit: f44992bf0299
Files: 45
Total size: 228.5 KB
Directory structure:
gitextract_lekhork2/
├── .gitattributes
├── .github/
│ └── workflows/
│ └── BuildRelease.yml
├── .gitignore
├── .vscode/
│ ├── extensions.json
│ └── settings.json
├── LICENSE
├── PackageLink.model.json
├── README.md
├── aftman.toml
├── default.project.json
├── docs/
│ ├── api.md
│ ├── contributing.md
│ ├── features.md
│ ├── index.md
│ ├── installation.md
│ ├── javascripts/
│ │ └── tags.js
│ └── third_parties.md
├── mkdocs.yml
├── rotriever.toml
├── selene.toml
├── serve.project.json
├── src/
│ ├── Attribute.lua
│ ├── Elements/
│ │ ├── Caption.lua
│ │ ├── Container.lua
│ │ ├── Dropdown.lua
│ │ ├── Indicator.lua
│ │ ├── Menu.lua
│ │ ├── Notice.lua
│ │ ├── Selection.lua
│ │ └── Widget.lua
│ ├── Features/
│ │ ├── Gamepad.lua
│ │ ├── Overflow.lua
│ │ └── Themes/
│ │ ├── Classic.lua
│ │ ├── Default.lua
│ │ └── init.lua
│ ├── Packages/
│ │ ├── GoodSignal.lua
│ │ └── Janitor.lua
│ ├── Reference.lua
│ ├── Types.lua
│ ├── Utility.lua
│ ├── VERSION.lua
│ └── init.lua
├── wally.toml
├── withLink.project.json
└── withoutLink.project.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
*.html linguist-detectable=false
================================================
FILE: .github/workflows/BuildRelease.yml
================================================
name: Build and Release
on:
push:
tags:
- "v*"
jobs:
build:
name: Create release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: ok-nick/setup-aftman@v0.4.2
- name: Build asset
run: rojo build --output Icon.rbxm withLink.project.json
- name: Git Release
uses: anton-yurchenko/git-release@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: |
./Icon.rbxm
================================================
FILE: .gitignore
================================================
# Project place file
/Icon.rbxm
# macOS
.DS_Store
# Rojo
sourcemap.json
# Built documentation
/site
# Roblox Studio lock files
/*.rbxlx.lock
/*.rbxl.lock
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
"johnnymorganz.luau-lsp",
"evaera.vscode-rojo",
"kampfkarren.selene-vscode",
"johnnymorganz.stylua"
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"robloxLsp.diagnostics.disable": [
"undefined-global"
]
}
================================================
FILE: LICENSE
================================================
TopbarPlus Credit
==================================
By using TopbarPlus in your experience or application, you agree to either:
1. Keep Attribute unchanged, or
2. To credit TopbarPlus in your experience's description, or in a devforum
post linked from your experience's description.
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
================================================
FILE: PackageLink.model.json
================================================
{
"ClassName": "PackageLink"
}
================================================
FILE: README.md
================================================
https://devforum.roblox.com/t/topbarplus/1017485
================================================
FILE: aftman.toml
================================================
# This file lists tools managed by Aftman, a cross-platform toolchain manager.
# For more information, see https://github.com/LPGhatguy/aftman
# To add a new tool, add an entry to this table.
[tools]
rojo = "rojo-rbx/rojo@7.4.1"
selene = "Kampfkarren/selene@0.26.1"
stylua = "JohnnyMorganz/StyLua@0.20.0"
wally = "UpliftGames/Wally@0.3.2"
================================================
FILE: default.project.json
================================================
{
"name": "topbarplus",
"tree": {
"$path": "src"
}
}
================================================
FILE: docs/api.md
================================================
[themes]: https://1foreverhd.github.io/TopbarPlus/features/#modify-theme
[alignments]: https://1foreverhd.github.io/TopbarPlus/features/#alignments
[font family]: https://create.roblox.com/docs/reference/engine/datatypes/Font/#fromEnum
[toggle keys]: https://1foreverhd.github.io/TopbarPlus/features/#toggle-keys
[captions]: https://1foreverhd.github.io/TopbarPlus/features/#captions
[icon event]: https://1foreverhd.github.io/TopbarPlus/api/#events
[menus]: https://1foreverhd.github.io/TopbarPlus/features/#menus
[dropdowns]: https://1foreverhd.github.io/TopbarPlus/features/#dropdowns
[numberSpinner]: https://devforum.roblox.com/t/numberspinner-module/1105961
## Functions
#### getIcons
```lua
local icons = Icon.getIcons()
```
Returns a dictionary of icons where the key is the icon's UID and value the icon.
----
#### getIcon
```lua
local icon = Icon.getIcon(nameOrUID)
```
Returns an icon of the given name or UID.
----
#### setTopbarEnabled
```lua
Icon.setTopbarEnabled(bool)
```
When set to ``false`` all TopbarPlus ScreenGuis are hidden. This does not impact Roblox's Topbar.
----
#### modifyBaseTheme
```lua
Icon.modifyBaseTheme(modifications)
```
Updates the appearance of *all* icons. See [themes] for more details.
----
#### setDisplayOrder
```lua
Icon.setDisplayOrder(integer)
```
Sets the base DisplayOrder of all TopbarPlus ScreenGuis.
----
## Constructors
#### new
```lua
local icon = Icon.new()
```
Constructs an empty ``32x32`` icon on the topbar.
----
## Methods
#### setName
{chainable}
```lua
icon:setName(name)
```
Sets the name of the Widget instance. This can be used in conjunction with ``Icon.getIcon(name)``.
----
#### getInstance
```lua
local instance = icon:getInstance(instanceName)
```
Returns the first descendant found within the widget of name ``instanceName``.
----
#### modifyTheme
{chainable}
```lua
icon:modifyTheme(modifications)
```
Updates the appearance of the icon. See [themes] for more details.
----
#### modifyChildTheme
{chainable}
```lua
icon:modifyChildTheme(modifications)
```
Updates the appearance of all icons that are parented to this icon (for example when a menu or dropdown). See [themes] for more details.
----
#### setEnabled
{chainable}
```lua
icon:setEnabled(bool)
```
When set to ``false`` the icon will be disabled and hidden.
----
#### select
{chainable}
```lua
icon:select()
```
Selects the icon (as if it were clicked once).
----
#### deselect
{chainable}
```lua
icon:deselect()
```
Deselects the icon (as if it were clicked, then clicked again).
----
#### notify
{chainable}
```lua
icon:notify(clearNoticeEvent)
```
Prompts a notice bubble which accumulates the further it is prompted. If the icon belongs to a dropdown or menu, then the notice will appear on the parent icon when the parent icon is deselected.
----
#### clearNotices
{chainable}
```lua
icon:clearNotices()
```
----
#### disableOverlay
{chainable}
```lua
icon:disableStateOverlay(bool)
```
When set to ``true``, disables the shade effect which appears when the icon is pressed and released.
----
#### setImage
{chainable} {toggleable}
```lua
icon:setImage(imageId, iconState)
```
Applies an image to the icon based on the given ``imageId``. ``imageId`` can be an assetId or a complete asset string.
----
#### setLabel
{chainable} {toggleable}
```lua
icon:setLabel(text, iconState)
```
----
#### setOrder
{chainable} {toggleable}
```lua
icon:setOrder(order, iconState)
```
----
#### setCornerRadius
{chainable} {toggleable}
```lua
icon:setCornerRadius(scale, offset, iconState)
```
----
#### align
{chainable}
```lua
icon:align(alignment)
```
This enables you to set the icon to the ``"Left"`` (default), ``"Center"`` or ``"Right"`` side of the screen. See [alignments] for more details.
----
#### setWidth
{chainable} {toggleable}
```lua
icon:setWidth(minimumSize, iconState)
```
This sets the minimum width the icon can be (it can be larger for instance when setting a long label). The default width is ``44``.
----
#### setImageScale
{chainable} {toggleable}
```lua
icon:setImageScale(number, iconState)
```
How large the image is relative to the icon. The default value is ``0.5``.
----
#### setImageRatio
{chainable} {toggleable}
```lua
icon:setImageRatio(number, iconState)
```
How stretched the image will appear. The default value is ``1`` (a perfect square).
----
#### setTextSize
{chainable} {toggleable}
```lua
icon:setTextSize(number, iconState)
```
The size of the icon labels' text. The default value is ``16``.
----
#### setTextColor
{chainable} {toggleable}
```lua
icon:setTextColor(color, iconState)
```
The color of the icon labels' text.
----
#### setTextFont
{chainable} {toggleable}
```lua
icon:setTextFont(font, fontWeight, fontStyle, iconState)
```
Sets the labels FontFace. ``font`` can be a [font family] name (such as `"Creepster"`), a font enum (such as `Enum.Font.Bangers`), a font ID (such as `12187370928`) or [font family] link (such as `"rbxasset://fonts/families/Sarpanch.json"`).
----
#### bindToggleItem
{chainable}
```lua
icon:bindToggleItem(guiObjectOrLayerCollector)
```
Binds a GuiObject or LayerCollector to appear and disappeared when the icon is toggled.
----
#### unbindToggleItem
{chainable}
```lua
icon:unbindToggleItem(guiObjectOrLayerCollector)
```
Unbinds the given GuiObject or LayerCollector from the toggle.
----
#### bindEvent
{chainable}
```lua
icon:bindEvent(iconEventName, callback)
```
Connects to an [icon event] with ``iconEventName``. It's important to remember all event names are in camelCase. ``callback`` is called with arguments ``(self, ...)`` when the event is triggered.
----
#### unbindEvent
{chainable}
```lua
icon:unbindEvent(iconEventName)
```
Unbinds the connection of the associated ``iconEventName``.
----
#### bindToggleKey
{chainable}
```lua
icon:bindToggleKey(keyCodeEnum)
```
Binds a [keycode](https://developer.roblox.com/en-us/api-reference/enum/KeyCode) which toggles the icon when pressed. See [toggle keys] for more details.
----
#### unbindToggleKey
{chainable}
```lua
icon:unbindToggleKey(keyCodeEnum)
```
Unbinds the given keycode.
----
#### call
{chainable}
```lua
icon:call(func)
```
Calls the function immediately via ``task.spawn``. The first argument passed is the icon itself. This is useful when needing to extend the behaviour of an icon while remaining in the chain.
----
#### addToJanitor
{chainable}
```lua
icon:addToJanitor(userdata)
```
Passes the given userdata to the icons janitor to be destroyed/disconnected on the icons destruction. If a function is passed, it will be called when the icon is destroyed.
----
#### lock
{chainable}
```lua
icon:lock()
```
Prevents the icon being toggled by user-input (such as clicking) however the icon can still be toggled via localscript using methods such as ``icon:select()``.
----
#### unlock
{chainable}
```lua
icon:unlock()
```
Re-enables user-input to toggle the icon again.
----
#### debounce
{chainable} {yields}
```lua
icon:debounce(seconds)
```
Locks the icon, yields for the given time, then unlocks the icon, effectively shorthand for ``icon:lock() task.wait(seconds) icon:unlock()``. This is useful for applying cooldowns (to prevent an icon from being pressed again) after an icon has been selected or deselected.
----
#### autoDeselect
{chainable}
```lua
icon:autoDeselect(true)
```
When set to ``true`` (the default) the icon is deselected when another icon (with autoDeselect enabled) is pressed. Set to ``false`` to prevent the icon being deselected when another icon is selected (a useful behaviour in dropdowns).
----
#### oneClick
{chainable}
```lua
icon:oneClick(bool)
```
When set to true the icon will automatically deselect when selected. This creates the effect of a single click button.
----
#### setCaption
{chainable}
```lua
icon:setCaption(text)
```
Sets a caption. To remove, pass ``nil`` as ``text``. See [captions] for more details.
----
#### setCaptionHint
{chainable}
```lua
icon:setCaptionHint(keyCodeEnum)
```
This customizes the appearance of the caption's hint without having to use ``icon:bindToggleKey``.
----
#### setDropdown
{chainable}
```lua
icon:setDropdown(arrayOfIcons)
```
Creates a vertical dropdown based upon the given ``table array`` of ``icons``. Pass an empty table ``{}`` to remove the dropdown. See [dropdowns] for more details.
----
#### joinDropdown
{chainable}
```lua
icon:joinDropdown(parentIcon)
```
Joins the dropdown of `parentIcon`. This is what ``icon:setDropdown`` calls internally on the icons within its array.
----
#### setMenu
{chainable}
```lua
icon:setMenu(arrayOfIcons)
```
Creates a horizontal menu based upon the given array of icons. Pass an empty table ``{}`` to remove the menu. See [menus] for more details.
----
#### setFixedMenu
{chainable}
```lua
icon:setFixedMenu(arrayOfIcons)
```
Creates a menu that is always selected and has its close button hidden. Pass an empty table ``{}`` to remove the menu. See [menus] for more details.
----
#### joinMenu
{chainable}
```lua
icon:joinMenu(parentIcon)
```
Joins the menu of `parentIcon`. This is what ``icon:setMenu`` calls internally on the icons within its array.
----
#### leave
{chainable}
```lua
icon:leave()
```
Unparents an icon from a parentIcon if it belongs to a dropdown or menu.
----
#### convertLabelToNumberSpinner
{chainable}
```lua
icon:convertLabelToNumberSpinner(numberSpinner, readyCallback)
```
Accepts a [numberSpinner] and converts the icon's label into that spinner. For example:
```lua
Icon.new()
:align("Right")
:setLabel("Points")
:setWidth(80)
:call(function(pointsIcon)
local NumberSpinner = require(ReplicatedStorage.NumberSpinner)
local numberSpinner = NumberSpinner.new()
pointsIcon:convertLabelToNumberSpinner(numberSpinner, function()
numberSpinner.Name = "LabelSpinner"
numberSpinner.Prefix = "$"
numberSpinner.Commas = true
numberSpinner.Decimals = 0
numberSpinner.Duration = 0.25
while true do
numberSpinner.Value = math.random(1,1000)
task.wait(1)
end
end)
end)
```
!!! warning
Any changes to the NumberSpinner must be made within ``readyCallback`` otherwise you risk breaking the icon's appearance
----
#### destroy
{chainable}
```lua
icon:destroy()
```
Clears all connections and destroys all instances associated with the icon.
----
## Events
#### selected
```lua
icon.selected:Connect(function(fromSource)
-- fromSource can be useful for checking if the behaviour was triggered by a user (such as clicking)
-- fromSource values include "User", "OneClick", "AutoDeselect", "HideParentFeature", "Overflow"
local sourceName = fromSource or "Unknown"
print("The icon was selected by the "..sourceName)
end)
```
----
#### deselected
```lua
icon.deselected:Connect(function(fromSource)
local sourceName = fromSource or "Unknown"
print("The icon was deselected by the "..sourceName)
end)
```
----
#### toggled
```lua
icon.toggled:Connect(function(isSelected, fromSource)
local stateName = (isSelected and "selected") or "deselected"
print(`The icon was {stateName}!`)
end)
```
----
#### viewingStarted
```lua
icon.viewingStarted:Connect(function()
print("A mouse, long-pressed finger or gamepad selection is hovering over the icon")
end)
```
----
#### viewingEnded
```lua
icon.viewingEnded:Connect(function()
print("The input is no longer viewing (hovering over) the icon")
end)
```
----
#### notified
```lua
icon.notified:Connect(function()
print("New notice")
end)
```
----
## Properties
#### name
{read-only}
```lua
local string = icon.name --[default: "Widget"]
```
----
#### isSelected
{read-only}
```lua
local bool = icon.isSelected
```
----
#### isEnabled
{read-only}
```lua
local bool = icon.isEnabled
```
----
#### totalNotices
{read-only}
```lua
local int = icon.totalNotices
```
----
#### locked
{read-only}
```lua
local bool = icon.locked
```
================================================
FILE: docs/contributing.md
================================================
[discussion thread]: https://devforum.roblox.com/t/topbarplus-v2-construct-dynamic-and-intuitive-topbar-icons/1017485
[Python]: https://www.python.org/
[Material for MKDocs]: https://squidfunk.github.io/mkdocs-material/
[ForeverHD on the devforum]: https://devforum.roblox.com/u/ForeverHD/summary
[TopbarPlus repository]: https://github.com/1ForeverHD/TopbarPlus
[open an issue]: https://github.com/1ForeverHD/TopbarPlus/issues
## Bug Reports
- To submit a bug report, [open an issue] or create a response at the [discussion thread].
- Ensure your report includes a detailed explanation of the problem with any relavent images, videos, etc (such as console errors).
- Aim to include a link to a stipped-down uncopylocked Roblox place which reproduces the bug.
## Questions and Feedback
- Be sure to first check out the documentation before asking a question.
- We recommend asking all questions and posting feedback to the [discussion thread].
## Submitting a resource (video tutorial, port, etc)
- Fancy making a tutorial or resource for TopbarPlus? Feel free to get in touch and we can provide tips, best practices, etc.
- We'll feature approved resources on the [discussion thread].
- To submit a resource, [open an issue], or reach out on the [discussion thread] or to [ForeverHD on the devforum].
## Suggestions and Code
- TopbarPlus is completely free and open source; any suggestions and code contributions are greatly appreciated!
- To make a suggestion, [open an issue] or create a response at the [discussion thread].
- For large contributions (like a new feature) please open an issue before beginning a code contribution to ensure it's discussed through fully (we wouldn't want to waste your time!).
- For smaller contributions (a few lines of code, fixing typos, etc) feel free to send a pull request right away.
- Make sure to merge your pull requests into the #development branch.
- Some tools you'll find useful when working on this project:
- [Rojo](https://rojo.space/docs/)
- [Material for MKDocs]
- [Roblox LSP](https://devforum.roblox.com/t/roblox-lsp-full-intellisense-for-roblox-and-luau/717745)
## Documentation
- If you find any problems in the documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them.
- To test documentation:
1. Install [Python] (which comes with pip).
2. Install [Material for MKDocs].
3. Visit the [TopbarPlus repository].
4. Click *Fork* in the top right corner.
5. Clone this fork into your local repository.
6. Change directory to this clone ``cd TopbarPlus``.
7. Swap to the development branch ``git checkout development``.
8. Call ``mkdocs serve`` within your terminal.
9. Open your local website (it will look something like ``http://0.0.0.0:8000``)
10. Any changes to ``mkdocs.yml`` or the files within ``docs`` will now update live to this local site.
!!! important
All pull requests must be made to the ***development*** branch.
================================================
FILE: docs/features.md
================================================
[icon states]: https://1foreverhd.github.io/TopbarPlus/#states
[v3 Playground]: https://www.roblox.com/games/117501901079852/TopbarPlus
### Images
```lua
Icon.new:setImage(shopImageId)
```
------------------------------
### Labels
```lua
icon:setLabel("Shop")
```
```lua
icon:setImage(shopImageId)
icon:setLabel("Shop")
```
------------------------------
### Alignments
```lua
-- Aligns the icon to the left bounds of the screen
-- This is the default behaviour so you do not need to do anything
-- This was formerly called :setLeft()
icon:align("Left")
```
```lua
-- Aligns the icon in the middle of the screen
-- This was formerly called :setMid()
icon:align("Center")
```
```lua
-- Aligns the icon to the right bounds of the screen
-- This was formerly called :setRight()
icon:align("Right")
```
------------------------------
### Notices
```lua
icon:notify()
```
------------------------------
### Captions
```lua
icon:setCaption("Open Shop")
```
------------------------------
### Dropdowns
Dropdowns are vertical navigation frames that contain an array of icons:
```lua
Icon.new()
:setLabel("Example")
:modifyTheme({"Dropdown", "MaxIcons", 3})
:modifyChildTheme({"Widget", "MinimumWidth", 158})
:setDropdown({
Icon.new()
:setLabel("Category 1")
,
Icon.new()
:setLabel("Category 2")
,
Icon.new()
:setLabel("Category 3")
,
Icon.new()
:setLabel("Category 4")
,
})
```
!!! warning
Icons containing a dropdown can join other menus but not dropdowns.
------------------------------
### Menus
Menus are horizontal navigation frames that contain an array of icons:
```lua
Icon.new()
:setLabel("Example")
:modifyTheme({"Menu", "MaxIcons", 2})
:setMenu({
Icon.new()
:setLabel("Item 1")
,
Icon.new()
:setLabel("Item 2")
,
Icon.new()
:setLabel("Item 3")
,
Icon.new()
:setLabel("Item 4")
,
})
```
------------------------------
### Fixed Menus
Fixed Menus are the same as normal menus, except forcefully opened (selected), with their close button hidden:
```lua
Icon.new()
:modifyTheme({"Menu", "MaxIcons", 3})
:setFixedMenu({
Icon.new()
:setLabel("Item 1")
,
Icon.new()
:setLabel("Item 2")
,
Icon.new()
:setLabel("Item 3")
,
Icon.new()
:setLabel("Item 4")
,
Icon.new()
:setLabel("Item 5")
,
})
```
------------------------------
### Modify Theme
You can modify the appearance of an icon doing:
```lua
icon:modifyTheme(modifications)
```
You can modify the appearance of *all* icons doing:
```lua
Icon.modifyBaseTheme(modifications)
```
``modifications`` can be either a single array describing a change, or a *colllection* of these arrays. For example, both the following are valid:
```lua
-- Single array
icon:modifyTheme({"IconLabel", "TextSize", 16})
-- Collection of arrays
icon:modifyTheme({
{"Widget", "MinimumWidth", 290},
{"IconCorners", "CornerRadius", UDim.new(0, 0)}
})
```
A modification array has 4 components:
```lua
{name, property, value, iconState}
```
> **1. `name`** {required}
This can be:
- "Widget" (which is the icon container frame)
- The name of an instance within the widget such as ``IconGradient``, ``IconSpot``, ``Menu``, etc
- A 'collective' - the value of an attribute called 'Collective' applied to some instances. This enables them to be acted upon all at once. For example, 'IconCorners'.
> **2. `property`** {required}
This can be either:
- A property from the instance (Name, BackgroundColor3, Text, etc)
- Or if the property doesn't exist, an attribute of that property name will be set
> **3. `value`** {required}
The value you want the property to become (``"Hello"``, ``Color3.fromRGB(255, 100, 50)``, etc)
> **4. `iconState`** {optional}
This determines *when* the modification is applied. See [icon states] for more details.
You can find example arrays under the 'Default' module:
------------------------------
### One Click Icons
You can convert icons into single click icons (icons which instantly
deselect when selected) by doing:
```lua
icon:oneClick()
```
For example:
```lua
Icon.new()
:setImage(shopImageId)
:setLabel("Shop")
:bindEvent("deselected", function()
shop.Visible = not shop.Visible
end)
:oneClick()
```
------------------------------
### Toggle Items
Binds a GuiObject (such as a frame) to appear or disappear when the icon is toggled
```lua
icon:bindToggleItem(shopFrame)
```
It is equivalent to doing:
```lua
icon.deselected:Connect(function()
shopFrame.Visible = false
end)
icon.selected:Connect(function()
shopFrame.Visible = true
end)
```
------------------------------
### Toggle Keys
Binds a [keycode](https://developer.roblox.com/en-us/api-reference/enum/KeyCode) which toggles the icon when pressed. Also creates a caption hint of that keycode binding.
```lua
Icon.new()
:setLabel("Shop")
:bindToggleKey(Enum.KeyCode.V)
:setCaption("Open Shop")
```
------------------------------
### Gamepad & Console Support
TopbarPlus comes with inbuilt support for gamepads (such as Xbox and PlayStation
controllers) and console screens:
To highlight the last-selected icon (or left-most if none have been selected yet) users simply press DPadUp or navigate to the topbar via the virtual cursor.
To change the default trigger keycode (from DPadUp) do:
```lua
Icon.highlightKey = Enum.KeyCode.NEW_KEYCODE
```
------------------------------
### Overflows
When accounting for device types and screen sizes, icons may occasionally overlap. This is especially common for phones when they enter portrait mode. TopbarPlus solves this with overflows:
Overflows will appear when left-set or right-set icons exceed the boundary of the closest opposite-aligned icon or viewport.
If a center-aligned icon exceeds the bounds of another icon, its alignment will be set to the alignment of the icon it exceeded:
------------------------------
These examples and more can be tested, viewed and edited at the [v3 Playground].
================================================
FILE: docs/index.md
================================================
[icon:setOrder]: https://1foreverhd.github.io/TopbarPlus/api/#setorder
[Feature Guide]: https://1foreverhd.github.io/TopbarPlus/features
[Icon API]: https://1foreverhd.github.io/TopbarPlus/api
[TopbarPlus DevForum Thread]: https://devforum.roblox.com/t/topbarplus/1017485
### About
TopbarPlus is a module enabling the construction of dynamic topbar icons. These icons can be enhanced with features and methods like themes, dropdowns and menus to expand upon their appearance and behaviour.
TopbarPlus fully supports PC, Mobile, Tablet and Gamepads (Consoles), and comes with internal features such as 'overflows' to ensure icons remain within suitable bounds.
----------
### Construction
Creating an icon is as simple as:
``` lua
-- Within a LocalScript in StarterPlayerScripts and assuming the Icon package is placed in ReplicatedStorage
local Icon = require(game:GetService("ReplicatedStorage").Icon)
local icon = Icon.new()
```
This constructs an empty ``32x32`` icon on the topbar.
!!! info
The order icons appear are determined by their construction sequence. Icons constructed first will have a smaller order therefore will appear left of icons with a higher order. You can modify this behaviour using [icon:setOrder]. Icon orders by default are ``1+(totalCreatedIcons*0.01)``, so 1.01, 1.02, 1.03, etc.
To add an image and label, do:
```lua
icon:setImage(imageId)
icon:setLabel("Label")
```
----------
### Chaining
These methods are 'chainable' therefore can alternatively be called doing:
```lua
Icon.new()
:setImage(imageId)
:setLabel("Label")
```
You may want to act upon nested icons. You can achieve this using ``:call``
which returns the icon as the first argument within the function you pass:
```lua
Icon.new()
:setName("TestIcon")
:call(function(icon)
print(icon.name)
-- This will print 'TestIcon'!
end)
```
!!! info
Chainable methods have a ``chainable`` tag next to their name within the API Icon docs.
----------
### States
Sometimes you'll want an item to appear only when *deselected* and similarily only when *selected*. You can achieve this by specifying a string value within the ``iconState`` parameter of methods containing the ``toggleable`` tag. These are:
```lua
"Deselected" -- Applies the value when the icon is deselected (i.e. not pressed)
"Selected" -- Applies the value when the icon is selected (i.e. pressed)
"Viewing" -- Formerly known as Hovering, applies the value when a cursor is hovering above, a controller highlighting, or touchpad (mobile) long-pressing (but before releasing) an icon
```
!!! info
If no ``iconState`` is specified (i.e. is nil) the value will be applied to all states.
```lua
-- It doesn't matter if you do "deselected", "Deselected" or "dEsElEcTeD"; iconStates are not case sensitive
Icon.new()
:setImage(4882429582)
:setLabel("Closed", "Deselected")
:setLabel("Open", "Selected")
:setLabel("Viewing", "Viewing")
```
----------
### Additional
By default icons will deselect when another icon is selected. You can disable this behaviour doing:
```lua
icon:autoDeselect(false)
```
You can enhance icons further with features like modifyTheme, dropdowns and menus, or by binding GuiObjects and KeyCodes to their toggle. This and much more can be achieved by exploring the [Feature Guide] and [Icon API].
Have a question or issue? Feel free to reach out at the [TopbarPlus DevForum Thread].
================================================
FILE: docs/installation.md
================================================
#### Take the model
{recommended}
1. Take the [TopbarPlus model](https://create.roblox.com/store/asset/92368439343389/TopbarPlus).
2. Open the toolbox and navigate to Inventory -> My Models.
3. Click TopbarPlus to insert into your game and place anywhere within ``ReplicatedStorage`` or ``Workspace``.
4. TopbarPlus is a package so you can update it instantly (instead of re-adding) by right-clicking the Icon module and selecting an option such as 'Get Latest Package':
5. You can receive automatic updates by enabling 'AutoUpdate' within the PackageLink:
!!! info
All v3 updates will be backwards compatible so you don't need to worry about updates interfering with your code.
!!! warning
Try not to modify any code within the Icon package otherwise it will break the package link.
!!! important
As of 7th June 2025 public packages haven't been rolled out by Roblox. Only after their full release will you be able to benefit from easily installable updates. For the time being, attempting to use 'Get Latest Package' and other package features will throw an error.
-------------------------------------
#### Download from Releases
1. Visit the [latest release](https://github.com/1ForeverHD/TopbarPlus/releases/latest).
2. Under *Assets*, download ``TopbarPlus.rbxm``.
3. Within studio, navigate to MODEL -> Model and insert the file anywhere within ``ReplicatedStorage``.
-------------------------------------
#### With Rojo
1. Setup with [Rojo](https://rojo.space/).
2. Visit the [TopbarPlus repository](https://github.com/1ForeverHD/TopbarPlus).
3. Click *Fork* in the top right corner.
4. Clone this fork into your local repository.
5. Modify the ``serve.project.json`` file to your desired location (by default TopbarPlus is built directly into ``Workspace``).
6. Call ``rojo serve`` (terminal or VSC plugin) and connect to the rojo studio plugin.
-------------------------------------
#### With Wally
TopbarPlus is now on Wally! You can find it [here](https://wally.run/package/1foreverhd/topbarplus).
================================================
FILE: docs/javascripts/tags.js
================================================
const style = `.tag {
color: #ffffff;
line-height: .8rem;
padding: 5px;
margin-left: 7px !important;
margin: 0 !important;
background-clip: padding-box;
border-radius: 3px;
display: inline-block;
font-size: .7rem;
font-family: "Roboto";
font-weight: normal;
}
.static {
background-color: rgb(38, 70, 83);
}
.read-only {
background-color: rgb(42, 157, 143);
}
.client-only {
background-color: rgb(89, 140, 206);
}
.server-only {
background-color: rgb(89, 140, 206);
}
.toggleable {
background-color: rgb(178, 92, 162);
}
.chainable {
background-color: rgb(122, 103, 231);
}
.recommended {
background-color: rgb(126, 194, 136);
}
.required {
background-color: rgb(231, 101, 104);
}
.optional {
background-color: rgb(188, 176, 116);
}
.unstable {
background-color: rgb(204, 134, 80);
}
.deprecated {
background-color: rgb(227, 87, 75);
}
.yields {
background-color: rgb(163, 149, 79);
}
.critical {
background-color: rgb(255, 0, 0);
}
h4 {
display: inline;
}`
var replaceStuff = [
["{read-only}", '
read-only
'], ["{static}", 'static
'], ["{server-only}", 'server-only
'], ["{client-only}", 'client-only
'], ["{deprecated}", 'deprecated
'], ["{yields}", 'yields
'], ["{critical}", 'critical
'], ["{chainable}", 'chainable
'], ["{required}", 'required
'], ["{optional}", 'optional
'], ["{recommended}", 'recommended
'], ["{unstable}", 'unstable
'], ["{toggleable}", 'toggleable
'], ]; function replace(element) { for (var i = 0; i < replaceStuff.length; i++) { var from = replaceStuff[i][0] var to = replaceStuff[i][1] if ((element.innerHTML && element.innerHTML.includes(from))) { element.innerHTML = element.innerHTML.replace(from, to) element.style.display = "inline" } } } const styleElement = document.createElement("style") styleElement.innerHTML = style document.head.appendChild(styleElement) window.onload = function WindowLoad(event) { var elems = document.body.getElementsByTagName("p") for (var i = 0; i < elems.length; i++) { replace(elems.item(i)) } } ================================================ FILE: docs/third_parties.md ================================================ TopbarPlus supports the use of multiple Icon packages within a single experience assuming all required packages are ``v3.0.0`` or above. When a package is required it will 'check' to see if a TopbarPlus package has already been required within the experience. If one has, it cancels loading itself and will instead refer to the already initialized package. This prevents weird quirks from occuring and means third party applications, libraries etc that use TopbarPlus can be used safely without interferring with your own implementation of TopbarPlus. You don't have to do anything to support multiple packages. Simply use TopbarPlus as normal. ================================================ FILE: mkdocs.yml ================================================ site_name: TopbarPlus v3 site_description: Documentation for TopbarPlus v3 site_author: Ben Horton site_url: https://1ForeverHD.github.io/TopbarPlus/ repo_name: 1ForeverHD/TopbarPlus repo_url: https://github.com/1ForeverHD/TopbarPlus edit_uri: "" theme: logo: https://user-images.githubusercontent.com/51117782/104590568-71724f80-5663-11eb-9bc1-344fc2a4193c.png favicon: https://user-images.githubusercontent.com/51117782/113474423-cefa8900-9467-11eb-8678-d69cbb0b3966.png name: material features: - navigation.tabs #- navigation.instant #- navigation.sections palette: # Light mode - media: "(prefers-color-scheme: light)" scheme: default primary: blue accent: blue toggle: icon: material/weather-sunny name: Switch to dark mode # Dark mode - media: "(prefers-color-scheme: dark)" scheme: slate primary: blue accent: blue toggle: icon: material/weather-night name: Switch to light mode highlightjs: true hljs_languages: - lua extra_javascript: - javascripts/tags.js extra: social: - icon: fontawesome/brands/github-alt link: https://github.com/1ForeverHD/ - icon: fontawesome/brands/twitter link: https://twitter.com/ForeverHD_ - icon: fontawesome/brands/youtube link: https://www.youtube.com/channel/UCj9QhyYCvhAwiBHA5B88pYg markdown_extensions: - admonition - codehilite: guess_lang: false - toc: permalink: true - pymdownx.superfences nav: - Home: - Introduction: index.md - Features: features.md - Installation: installation.md - Third Parties: third_parties.md - API: api.md - Contributing: contributing.md ================================================ FILE: rotriever.toml ================================================ [package] name = "TopbarPlus" version = "3.0.0" license = "MPL2" authors = ["1ForeverHD"] content_root = "src" [dependencies] ================================================ FILE: selene.toml ================================================ std = "roblox" ================================================ FILE: serve.project.json ================================================ { "name": "topbarplus", "tree": { "$className": "DataModel", "Workspace": { "$className": "Workspace", "TopbarPlus": { "$className": "Folder", "Icon": { "$path": "src", "PackageLink": { "$path": "PackageLink.model.json" } } } } } } ================================================ FILE: src/Attribute.lua ================================================ --[[ TopbarPlus was developed by ForeverHD and is possible thanks to HD Admin. By using TopbarPlus in your experience or application, you agree to either: 1. Keep Attribute unchanged, or 2. If an experience, to credit TopbarPlus in your description, or in a devforum post linked from your experience's description. v3 has involved over 350 hours of work to develop, so please consider supporting its development by reporting any issues or feedback you have at its repository: https://github.com/1ForeverHD/TopbarPlus You can get in touch with me on Discord via the social link here: https://create.roblox.com/store/asset/92368439343389/TopbarPlus Many thanks! ~Ben, June 10th 2025 ]] task.defer(function() local RunService = game:GetService("RunService") local VERSION = require(script.Parent.VERSION) local appVersion = VERSION.getAppVersion() local latestVersion = VERSION.getLatestVersion() local isOutdated = not VERSION.isUpToDate() if not RunService:IsStudio() then print(`🍍 Running TopbarPlus {appVersion} by @ForeverHD & HD Admin`) end if isOutdated then warn(`A new version of TopbarPlus ({latestVersion}) is available: https://devforum.roblox.com/t/topbarplus/1017485`) end end) return {} ================================================ FILE: src/Elements/Caption.lua ================================================ local CAPTION_COLOR = Color3.fromRGB(39, 41, 48) local TEXT_SIZE = 15 return function(icon) -- Credit to lolmansReturn and Canary Software for -- retrieving these values local clickRegion = icon:getInstance("ClickRegion") local caption = Instance.new("CanvasGroup") caption.Name = "Caption" caption.AnchorPoint = Vector2.new(0.5, 0) caption.BackgroundTransparency = 1 caption.BorderSizePixel = 0 caption.GroupTransparency = 1 caption.Position = UDim2.fromOffset(0, 0) caption.Visible = true caption.ZIndex = 30 caption.Parent = clickRegion local box = Instance.new("Frame") box.Name = "Box" box.AutomaticSize = Enum.AutomaticSize.XY box.BackgroundColor3 = CAPTION_COLOR box.Position = UDim2.fromOffset(4, 7) box.ZIndex = 12 box.Parent = caption local header = Instance.new("TextLabel") header.Name = "Header" header.FontFace = Font.new( "rbxasset://fonts/families/BuilderSans.json", Enum.FontWeight.Medium, Enum.FontStyle.Normal ) header.Text = "Caption" header.TextColor3 = Color3.fromRGB(255, 255, 255) header.TextSize = TEXT_SIZE header.TextTruncate = Enum.TextTruncate.None header.TextWrapped = false header.TextXAlignment = Enum.TextXAlignment.Left header.AutomaticSize = Enum.AutomaticSize.X header.BackgroundTransparency = 1 header.LayoutOrder = 1 header.Size = UDim2.fromOffset(0, 16) header.ZIndex = 18 header.Parent = box local layout = Instance.new("UIListLayout") layout.Name = "Layout" layout.Padding = UDim.new(0, 8) layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Parent = box local UICorner = Instance.new("UICorner") UICorner.Name = "CaptionCorner" UICorner.Parent = box local padding = Instance.new("UIPadding") padding.Name = "Padding" padding.PaddingBottom = UDim.new(0, 12) padding.PaddingLeft = UDim.new(0, 12) padding.PaddingRight = UDim.new(0, 12) padding.PaddingTop = UDim.new(0, 12) padding.Parent = box local hotkeys = Instance.new("Frame") hotkeys.Name = "Hotkeys" hotkeys.AutomaticSize = Enum.AutomaticSize.Y hotkeys.BackgroundTransparency = 1 hotkeys.LayoutOrder = 3 hotkeys.Size = UDim2.fromScale(1, 0) hotkeys.Visible = false hotkeys.Parent = box local layout1 = Instance.new("UIListLayout") layout1.Name = "Layout1" layout1.Padding = UDim.new(0, 6) layout1.FillDirection = Enum.FillDirection.Vertical layout1.HorizontalAlignment = Enum.HorizontalAlignment.Center layout1.HorizontalFlex = Enum.UIFlexAlignment.None layout1.ItemLineAlignment = Enum.ItemLineAlignment.Automatic layout1.VerticalFlex = Enum.UIFlexAlignment.None layout1.SortOrder = Enum.SortOrder.LayoutOrder layout1.Parent = hotkeys local keyTag1 = Instance.new("ImageLabel") keyTag1.Name = "Key1" keyTag1.Image = "rbxasset://textures/ui/Controls/key_single.png" keyTag1.ImageTransparency = 0.7 keyTag1.ScaleType = Enum.ScaleType.Slice keyTag1.SliceCenter = Rect.new(5, 5, 23, 24) keyTag1.AutomaticSize = Enum.AutomaticSize.X keyTag1.BackgroundTransparency = 1 keyTag1.LayoutOrder = 1 keyTag1.Size = UDim2.fromOffset(0, 30) keyTag1.ZIndex = 15 keyTag1.Parent = hotkeys local inset = Instance.new("UIPadding") inset.Name = "Inset" inset.PaddingLeft = UDim.new(0, 8) inset.PaddingRight = UDim.new(0, 8) inset.Parent = keyTag1 local labelContent = Instance.new("TextLabel") labelContent.AutoLocalize = false labelContent.Name = "LabelContent" labelContent.FontFace = Font.new( "rbxasset://fonts/families/GothamSSm.json", Enum.FontWeight.Medium, Enum.FontStyle.Normal ) labelContent.Text = "" labelContent.TextColor3 = Color3.fromRGB(189, 190, 190) labelContent.TextSize = TEXT_SIZE labelContent.AutomaticSize = Enum.AutomaticSize.X labelContent.BackgroundTransparency = 1 labelContent.Position = UDim2.fromOffset(0, -1) labelContent.Size = UDim2.fromScale(1, 1) labelContent.ZIndex = 16 labelContent.Parent = keyTag1 local caret = Instance.new("ImageLabel") caret.Name = "Caret" caret.Image = "rbxassetid://101906294438076" caret.ImageColor3 = CAPTION_COLOR caret.AnchorPoint = Vector2.new(0, 0.5) caret.BackgroundTransparency = 1 caret.Position = UDim2.new(0, 0, 0, 4) caret.Size = UDim2.fromOffset(16, 8) caret.ZIndex = 12 caret.Parent = caption local dropShadow = Instance.new("ImageLabel") dropShadow.Visible = true dropShadow.Name = "DropShadow" dropShadow.Image = "rbxassetid://124920646932671" dropShadow.ImageColor3 = Color3.fromRGB(0, 0, 0) dropShadow.ImageTransparency = 0.45 dropShadow.ScaleType = Enum.ScaleType.Slice dropShadow.SliceCenter = Rect.new(12, 12, 13, 13) dropShadow.BackgroundTransparency = 1 dropShadow.Position = UDim2.fromOffset(0, 5) dropShadow.Size = UDim2.new(1, 0, 0, 48) dropShadow.Parent = caption box:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() dropShadow.Size = UDim2.new(1, 0, 0, box.AbsoluteSize.Y + 8) end) -- It's important we match the sizes as this is not -- handles within clipOutside (as it assumes the sizes -- are already the same) local captionJanitor = icon.captionJanitor local _, captionClone = icon:clipOutside(caption) captionClone.AutomaticSize = Enum.AutomaticSize.None local function matchSize() local absolute = caption.AbsoluteSize captionClone.Size = UDim2.fromOffset(absolute.X, absolute.Y) end captionJanitor:add(caption:GetPropertyChangedSignal("AbsoluteSize"):Connect(matchSize)) matchSize() -- This handles the appearing/disappearing/positioning of the caption local isCompletelyEnabled = false local captionHeader = caption.Box.Header local UserInputService = game:GetService("UserInputService") local function updateHotkey(keyCodeEnum) local hasKeyboard = UserInputService.KeyboardEnabled local text = caption:GetAttribute("CaptionText") or "" local hideHeader = text == "_hotkey_" if not hasKeyboard and hideHeader then icon:setCaption() return end captionHeader.Text = text captionHeader.Visible = not hideHeader if keyCodeEnum then labelContent.Text = keyCodeEnum.Name hotkeys.Visible = true end if not hasKeyboard then hotkeys.Visible = false end end caption:GetAttributeChangedSignal("CaptionText"):Connect(updateHotkey) local EASING_STYLE = Enum.EasingStyle.Quad local TWEEN_SPEED = 0.2 local TWEEN_INFO_IN = TweenInfo.new(TWEEN_SPEED, EASING_STYLE, Enum.EasingDirection.In) local TWEEN_INFO_OUT = TweenInfo.new(TWEEN_SPEED, EASING_STYLE, Enum.EasingDirection.Out) local TweenService = game:GetService("TweenService") local RunService = game:GetService("RunService") local function getCaptionPosition(customEnabled) local enabled = if customEnabled ~= nil then customEnabled else isCompletelyEnabled local yOut = 2 local yIn = yOut + 8 local yOffset = if enabled then yIn else yOut return UDim2.new(0.5, 0, 1, yOffset) end local function updatePosition(forcedEnabled) -- Ignore changes if not enabled to reduce redundant calls if not isCompletelyEnabled then return end -- Currently the one thing which isn't accounted for are the bounds of the screen -- This would be an issue if someone sets a long caption text for the left or -- right most icon local enabled = if forcedEnabled ~= nil then forcedEnabled else isCompletelyEnabled local startPosition = getCaptionPosition(not enabled) local endPosition = getCaptionPosition(enabled) -- It's essential we reset the carets position to prevent the x sizing bounds -- of the caption from infinitely scaling up if enabled then local caretY = caret.Position.Y.Offset caret.Position = UDim2.fromOffset(0, caretY) caption.AutomaticSize = Enum.AutomaticSize.XY caption.Size = UDim2.fromOffset(32, 53) else local absolute = caption.AbsoluteSize caption.AutomaticSize = Enum.AutomaticSize.Y caption.Size = UDim2.fromOffset(absolute.X, absolute.Y) end -- We initially default to the opposite state local previousCaretX local function updateCaret() local caretX = clickRegion.AbsolutePosition.X - caption.AbsolutePosition.X + clickRegion.AbsoluteSize.X/2 - caret.AbsoluteSize.X/2 local caretY = caret.Position.Y.Offset local newCaretPosition = UDim2.fromOffset(caretX, caretY) if previousCaretX ~= caretX then -- Again, it's essential we reset the caret if -- a difference in X position is detected otherwise -- a slight quirk with AutomaticCanvas can cause -- the caption to infinitely scale previousCaretX = caretX caret.Position = UDim2.fromOffset(0, caretY) task.wait() end caret.Position = newCaretPosition end captionClone.Position = startPosition updateCaret() -- Now we tween into the new state local tweenInfo = (enabled and TWEEN_INFO_IN) or TWEEN_INFO_OUT local tween = TweenService:Create(captionClone, tweenInfo, {Position = endPosition}) local updateCaretConnection = RunService.Heartbeat:Connect(updateCaret) tween:Play() tween.Completed:Once(function() updateCaretConnection:Disconnect() end) end captionJanitor:add(clickRegion:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() updatePosition() end)) updatePosition(false) captionJanitor:add(icon.toggleKeyAdded:Connect(updateHotkey)) for keyCodeEnum, _ in pairs(icon.bindedToggleKeys) do updateHotkey(keyCodeEnum) break end captionJanitor:add(icon.fakeToggleKeyChanged:Connect(updateHotkey)) local fakeToggleKey = icon.fakeToggleKey if fakeToggleKey then updateHotkey(fakeToggleKey) end local function setCaptionEnabled(enabled) if isCompletelyEnabled == enabled then return end local joinedFrame = icon.joinedFrame if joinedFrame and string.match(joinedFrame.Name, "Dropdown") then enabled = false end isCompletelyEnabled = enabled local newTransparency = (enabled and 0) or 1 local tweenInfo = (enabled and TWEEN_INFO_IN) or TWEEN_INFO_OUT local tweenTransparency = TweenService:Create(caption, tweenInfo, { GroupTransparency = newTransparency }) tweenTransparency:Play() if enabled then captionClone:SetAttribute("ForceUpdate", true) end updatePosition() updateHotkey() end local WAIT_DURATION = 0.5 local RECOVER_PERIOD = 0.3 local Icon = require(icon.iconModule) captionJanitor:add(icon.stateChanged:Connect(function(stateName) if stateName == "Viewing" then local lastClock = Icon.captionLastClosedClock local clockDifference = (lastClock and os.clock() - lastClock) or 999 local waitDuration = (clockDifference < RECOVER_PERIOD and 0) or WAIT_DURATION task.delay(waitDuration, function() if icon.activeState == "Viewing" then setCaptionEnabled(true) end end) else Icon.captionLastClosedClock = os.clock() setCaptionEnabled(false) end end)) return caption end ================================================ FILE: src/Elements/Container.lua ================================================ local hasBecomeOldTheme = false local previousInsetHeight = 0 return function(Icon) -- Has to be included for the time being due to this bug mentioned here: -- https://devforum.roblox.com/t/bug/2973508/7 local GuiService = game:GetService("GuiService") local Players = game:GetService("Players") local UserInputService = game:GetService("UserInputService") local container = {} local Signal = require(script.Parent.Parent.Packages.GoodSignal) local insetChanged = Signal.new() local guiInset = GuiService:GetGuiInset() local startInset = 0 local yDownOffset = 0 local ySizeOffset = 0 local checkCount = 0 local isConsoleScreen = false local isUsingVR = false local function checkInset(status) local currentHeight = GuiService.TopbarInset.Height local isOldTopbar = currentHeight <= 36 -- These additional checks are needed to ensure *it is actually* the old topbar -- and not a client which takes a really long time to load -- There's unfortunately no APIs to do this a prettier way isConsoleScreen = GuiService:IsTenFootInterface() isUsingVR = UserInputService.VREnabled Icon.isOldTopbar = isOldTopbar checkCount += 1 if currentHeight == 0 and status == nil then task.defer(function() task.wait(8) checkInset("ForceConvertToOld") end) elseif checkCount == 1 then task.delay(5, function() local localPlayer = Players.LocalPlayer localPlayer:WaitForChild("PlayerGui") if checkCount == 1 then checkInset() end end) end -- Conver to old theme if verified if Icon.isOldTopbar and not isConsoleScreen and not isUsingVR and hasBecomeOldTheme == false and (currentHeight ~= 0 or status == "ForceConvertToOld") then hasBecomeOldTheme = true task.defer(function() -- If oldtopbar, apply the Classic theme local themes = script.Parent.Parent.Features.Themes local Classic = require(themes.Classic) Icon.modifyBaseTheme(Classic) -- Also configure the oldtopbar correctly local function decideToHideTopbar() if GuiService.MenuIsOpen then Icon.setTopbarEnabled(false, true) else Icon.setTopbarEnabled() end end GuiService:GetPropertyChangedSignal("MenuIsOpen"):Connect(decideToHideTopbar) decideToHideTopbar() end) end -- Modify the offsets slightly depending on device type guiInset = GuiService:GetGuiInset() startInset = if isOldTopbar then 12 else guiInset.Y - 50 yDownOffset = if isOldTopbar then 2 else 0 --if isOldTopbar then 2 else 0 ySizeOffset = -2 if isConsoleScreen then startInset = 10 yDownOffset = 0 ---9 end if GuiService.TopbarInset.Height == 0 and not hasBecomeOldTheme then yDownOffset += 13 ySizeOffset = 50 end -- Now inform other areas of the change insetChanged:Fire(guiInset) local insetHeight = guiInset.Y if insetHeight ~= previousInsetHeight then previousInsetHeight = insetHeight task.defer(function() Icon.insetHeightChanged:Fire(insetHeight) end) end end GuiService:GetPropertyChangedSignal("TopbarInset"):Connect(checkInset) checkInset("FirstTime") local screenGui = Instance.new("ScreenGui") insetChanged:Connect(function() screenGui:SetAttribute("StartInset", startInset) end) screenGui.Name = "TopbarStandard" screenGui.Enabled = true screenGui.DisplayOrder = Icon.baseDisplayOrder screenGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling screenGui.IgnoreGuiInset = true screenGui.ResetOnSpawn = false screenGui.ScreenInsets = Enum.ScreenInsets.TopbarSafeInsets container[screenGui.Name] = screenGui Icon.baseDisplayOrderChanged:Connect(function() screenGui.DisplayOrder = Icon.baseDisplayOrder end) local holders = Instance.new("Frame") holders.Name = "Holders" holders.BackgroundTransparency = 1 insetChanged:Connect(function() local holderY = if isUsingVR then 36 else 56 local holderSize = if isConsoleScreen then UDim2.new(1, 0, 0, holderY) else UDim2.new(1, 0, 1, ySizeOffset) holders.Position = UDim2.new(0, 0, 0, yDownOffset) holders.Size = holderSize end) holders.Visible = true holders.ZIndex = 1 holders.Parent = screenGui local screenGuiCenter = screenGui:Clone() local holdersCenter = screenGuiCenter.Holders local function updateCenteredHoldersHeight() holdersCenter.Size = UDim2.new(1, 0, 0, GuiService.TopbarInset.Height+ySizeOffset) end screenGuiCenter.Name = "TopbarCentered" screenGuiCenter.DisplayOrder = Icon.baseDisplayOrder screenGuiCenter.ScreenInsets = Enum.ScreenInsets.None Icon.baseDisplayOrderChanged:Connect(function() screenGuiCenter.DisplayOrder = Icon.baseDisplayOrder end) container[screenGuiCenter.Name] = screenGuiCenter insetChanged:Connect(updateCenteredHoldersHeight) updateCenteredHoldersHeight() local screenGuiClipped = screenGui:Clone() screenGuiClipped.Name = screenGuiClipped.Name.."Clipped" screenGuiClipped.DisplayOrder = (Icon.baseDisplayOrder + 1) Icon.baseDisplayOrderChanged:Connect(function() screenGuiClipped.DisplayOrder = (Icon.baseDisplayOrder + 1) end) container[screenGuiClipped.Name] = screenGuiClipped local screenGuiCenterClipped = screenGuiCenter:Clone() screenGuiCenterClipped.Name = screenGuiCenterClipped.Name.."Clipped" screenGuiCenterClipped.DisplayOrder = (Icon.baseDisplayOrder + 1) Icon.baseDisplayOrderChanged:Connect(function() screenGuiCenterClipped.DisplayOrder = (Icon.baseDisplayOrder + 1) end) container[screenGuiCenterClipped.Name] = screenGuiCenterClipped local holderReduction = -24 local left = Instance.new("ScrollingFrame") left:SetAttribute("IsAHolder", true) left.Name = "Left" insetChanged:Connect(function() left.Position = UDim2.fromOffset(startInset, 0) end) left.Size = UDim2.new(1, holderReduction, 1, 0) left.BackgroundTransparency = 1 left.Visible = true left.ZIndex = 1 left.Active = false left.ClipsDescendants = true left.HorizontalScrollBarInset = Enum.ScrollBarInset.None left.CanvasSize = UDim2.new(0, 0, 1, -1) -- This -1 prevents a dropdown scrolling appearance bug left.AutomaticCanvasSize = Enum.AutomaticSize.X left.ScrollingDirection = Enum.ScrollingDirection.X left.ScrollBarThickness = 0 left.BorderSizePixel = 0 left.Selectable = false left.ScrollingEnabled = false--true left.ElasticBehavior = Enum.ElasticBehavior.Never left.Parent = holders local UIListLayout = Instance.new("UIListLayout") insetChanged:Connect(function() UIListLayout.Padding = UDim.new(0, startInset) end) UIListLayout.FillDirection = Enum.FillDirection.Horizontal UIListLayout.SortOrder = Enum.SortOrder.LayoutOrder UIListLayout.VerticalAlignment = Enum.VerticalAlignment.Bottom UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left UIListLayout.Parent = left local center = left:Clone() insetChanged:Connect(function() center.UIListLayout.Padding = UDim.new(0, startInset) end) center.ScrollingEnabled = false center.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center center.Name = "Center" center.Parent = holdersCenter local right = left:Clone() insetChanged:Connect(function() right.UIListLayout.Padding = UDim.new(0, startInset) end) right.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right right.Name = "Right" right.AnchorPoint = Vector2.new(1, 0) right.Position = UDim2.new(1, -12, 0, 0) right.Parent = holders -- This is important so that all elements update instantly insetChanged:Fire(guiInset) return container end ================================================ FILE: src/Elements/Dropdown.lua ================================================ local TweenService = game:GetService("TweenService") local RunService = game:GetService("RunService") local Themes = require(script.Parent.Parent.Features.Themes) local PADDING = 0 -- used to be 8 return function(icon) local dropdown = Instance.new("Frame") -- Instance.new("CanvasGroup") dropdown.Name = "Dropdown" dropdown.AutomaticSize = Enum.AutomaticSize.X dropdown.BackgroundTransparency = 1 dropdown.BorderSizePixel = 0 dropdown.AnchorPoint = Vector2.new(0.5, 0) dropdown.Position = UDim2.new(0.5, 0, 1, 10) dropdown.ZIndex = -2 dropdown.ClipsDescendants = true dropdown.Parent = icon.widget -- Account for PreferredTransparency which can be set by every player local GuiService = game:GetService("GuiService") icon:setBehaviour("Dropdown", "BackgroundTransparency", function(value) local preference = GuiService.PreferredTransparency local newValue = value * preference if value == 1 then return value end return newValue end) icon.janitor:add(GuiService:GetPropertyChangedSignal("PreferredTransparency"):Connect(function() icon:refreshAppearance(dropdown, "BackgroundTransparency") end)) local UICorner = Instance.new("UICorner") UICorner.Name = "DropdownCorner" UICorner.CornerRadius = UDim.new(0, 10) UICorner.Parent = dropdown local dropdownScroller = Instance.new("ScrollingFrame") dropdownScroller.Name = "DropdownScroller" dropdownScroller.AutomaticSize = Enum.AutomaticSize.X dropdownScroller.BackgroundTransparency = 1 dropdownScroller.BorderSizePixel = 0 dropdownScroller.AnchorPoint = Vector2.new(0, 0) dropdownScroller.Position = UDim2.new(0, 0, 0, 0) dropdownScroller.ZIndex = -1 dropdownScroller.ClipsDescendants = true dropdownScroller.Visible = true dropdownScroller.VerticalScrollBarInset = Enum.ScrollBarInset.None --ScrollBar dropdownScroller.VerticalScrollBarPosition = Enum.VerticalScrollBarPosition.Right dropdownScroller.Active = false dropdownScroller.ScrollingEnabled = true dropdownScroller.AutomaticCanvasSize = Enum.AutomaticSize.Y dropdownScroller.ScrollBarThickness = 5 dropdownScroller.ScrollBarImageColor3 = Color3.fromRGB(255, 255, 255) dropdownScroller.ScrollBarImageTransparency = 0.8 dropdownScroller.CanvasSize = UDim2.new(0, 0, 0, 0) dropdownScroller.Selectable = false dropdownScroller.Active = true dropdownScroller.Parent = dropdown local TweenDuration = Instance.new("NumberValue") -- this helps to change the speed to open / close in modifyTheme() TweenDuration.Name = "DropdownSpeed" TweenDuration.Value = 0.07 TweenDuration.Parent = dropdown local dropdownPadding = Instance.new("UIPadding") dropdownPadding.Name = "DropdownPadding" dropdownPadding.PaddingTop = UDim.new(0, PADDING) dropdownPadding.PaddingBottom = UDim.new(0, PADDING) dropdownPadding.Parent = dropdownScroller local dropdownList = Instance.new("UIListLayout") dropdownList.Name = "DropdownList" dropdownList.FillDirection = Enum.FillDirection.Vertical dropdownList.SortOrder = Enum.SortOrder.LayoutOrder dropdownList.HorizontalAlignment = Enum.HorizontalAlignment.Center dropdownList.HorizontalFlex = Enum.UIFlexAlignment.SpaceEvenly dropdownList.Parent = dropdownScroller local dropdownJanitor = icon.dropdownJanitor local Icon = require(icon.iconModule) icon.dropdownChildAdded:Connect(function(childIcon) local _, modificationUID = childIcon:modifyTheme({ {"Widget", "BorderSize", 0}, {"IconCorners", "CornerRadius", UDim.new(0, 10)}, {"Widget", "MinimumWidth", 190}, {"Widget", "MinimumHeight", 58}, {"IconLabel", "TextSize", 20}, {"IconOverlay", "Size", UDim2.new(1, 0, 1, 0)}, {"PaddingLeft", "Size", UDim2.fromOffset(25, 0)}, {"Notice", "Position", UDim2.new(1, -24, 0, 5)}, {"ContentsList", "HorizontalAlignment", Enum.HorizontalAlignment.Left}, {"Selection", "Size", UDim2.new(1, -PADDING, 1, -PADDING)}, {"Selection", "Position", UDim2.new(0, PADDING/2, 0, PADDING/2)}, }) task.defer(function() childIcon.joinJanitor:add(function() childIcon:removeModification(modificationUID) end) end) end) icon.dropdownSet:Connect(function(arrayOfIcons) for i, otherIconUID in pairs(icon.dropdownIcons) do local otherIcon = Icon.getIconByUID(otherIconUID) otherIcon:destroy() end if type(arrayOfIcons) == "table" then for i, otherIcon in pairs(arrayOfIcons) do otherIcon:joinDropdown(icon) end end end) local function updateMaxIcons() --icon:modifyTheme({"Dropdown", "Visible", icon.isSelected}) local maxIcons = dropdown:GetAttribute("MaxIcons") if not maxIcons then return 0 end local children = {} for _, child in pairs(dropdownScroller:GetChildren()) do if child:IsA("GuiObject") and child.Visible then table.insert(children, child) end end table.sort(children, function(a, b) return a.AbsolutePosition.Y < b.AbsolutePosition.Y end) local totalHeight = 0 local maxIconsRoundedUp = math.ceil(maxIcons) for i = 1, maxIconsRoundedUp do local child = children[i] if not child then break end local height = child.AbsoluteSize.Y local isReduced = i == maxIconsRoundedUp and maxIconsRoundedUp ~= maxIcons if isReduced then height *= (maxIcons - maxIconsRoundedUp + 1) end totalHeight += height end totalHeight += dropdownPadding.PaddingTop.Offset + dropdownPadding.PaddingBottom.Offset return totalHeight end local openTween = nil local closeTween = nil local currentSpeedMultiplier = nil local currentTweenInfo = nil local function getTweenInfo() local speedMultiplier = Themes.getInstanceValue(dropdown, "MaxIcons") or 1 if currentSpeedMultiplier and currentSpeedMultiplier == speedMultiplier and currentTweenInfo then return currentTweenInfo end local newTweenInfo = TweenInfo.new( TweenDuration.Value * speedMultiplier, Enum.EasingStyle.Exponential, Enum.EasingDirection.Out ) currentTweenInfo = newTweenInfo currentSpeedMultiplier = speedMultiplier return newTweenInfo end local function updateVisibility() -- Update visibiliy of dropdown using tween transition local tweenInfo = getTweenInfo() if openTween then openTween:Cancel() openTween = nil end if closeTween then closeTween:Cancel() closeTween = nil end if icon.isSelected then local height = updateMaxIcons() dropdown.Visible = true dropdown.BackgroundTransparency = 0 -- no transparency so it looks solid dropdown.Size = UDim2.new(0, dropdown.Size.X.Offset, 0, 0) -- reset height to 0 before tween openTween = TweenService:Create(dropdown, tweenInfo, {Size = UDim2.new(0, dropdown.Size.X.Offset, 0, height)}) openTween:Play() openTween.Completed:Connect(function() openTween = nil end) else local closeTweenInfo = TweenInfo.new(0) closeTween = TweenService:Create(dropdown, closeTweenInfo, {Size = UDim2.new(0, dropdown.Size.X.Offset, 0, 0)}) closeTween:Play() closeTween.Completed:Connect(function() closeTween = nil end) end end dropdownJanitor:add(icon.toggled:Connect(updateVisibility)) updateVisibility() --task.delay(0.2, updateVisibility) local function updateChildSize() local tweenInfo = getTweenInfo() if not icon.isSelected then return end if openTween then openTween:Cancel() openTween = nil end if closeTween then closeTween:Cancel() closeTween = nil end RunService.Heartbeat:Wait() local height = updateMaxIcons() openTween = TweenService:Create(dropdown, tweenInfo, {Size = UDim2.new(0, dropdown.Size.X.Offset, 0, height)}) openTween:Play() openTween.Completed:Connect(function() openTween = nil end) end dropdownJanitor:add(icon.toggled:Connect(updateVisibility)) -- Ensures canvas and size stay synced (original updateMaxIcons logic) local updateCount = 0 local isUpdating = false -- This updates the scrolling frame to only display a scroll -- length equal to the distance produced by its MaxIcons local function updateMaxIconsListener() updateCount += 1 if isUpdating then return end local myUpdateCount = updateCount isUpdating = true task.defer(function() isUpdating = false if updateCount ~= myUpdateCount then updateMaxIconsListener() end end) local maxIcons = dropdown:GetAttribute("MaxIcons") if not maxIcons then return end local orderedInstances = {} for _, child in pairs(dropdownScroller:GetChildren()) do if child:IsA("GuiObject") and child.Visible then table.insert(orderedInstances, {child, child.AbsolutePosition.Y}) end end table.sort(orderedInstances, function(a, b) return a[2] < b[2] end) local totalHeight = 0 local hasSetNextSelection = false local maxIconsRoundedUp = math.ceil(maxIcons) for i = 1, maxIconsRoundedUp do local group = orderedInstances[i] if not group then break end local child = group[1] local height = child.AbsoluteSize.Y local isReduced = i == maxIconsRoundedUp and maxIconsRoundedUp ~= maxIcons if isReduced then height = height * (maxIcons - maxIconsRoundedUp + 1) end totalHeight += height if isReduced then continue end local iconUID = child:GetAttribute("WidgetUID") local childIcon = iconUID and Icon.getIconByUID(iconUID) if childIcon then local nextSelection = nil if not hasSetNextSelection then hasSetNextSelection = true nextSelection = icon:getInstance("ClickRegion") end childIcon:getInstance("ClickRegion").NextSelectionUp = nextSelection end end totalHeight += dropdownPadding.PaddingTop.Offset + dropdownPadding.PaddingBottom.Offset dropdownScroller.Size = UDim2.fromOffset(0, totalHeight) end dropdownJanitor:add(dropdownScroller:GetPropertyChangedSignal("AbsoluteCanvasSize"):Connect(updateMaxIconsListener)) dropdownJanitor:add(dropdownScroller.ChildAdded:Connect(updateMaxIconsListener)) dropdownJanitor:add(dropdownScroller.ChildRemoved:Connect(updateChildSize)) -- rezise the dropdown when icon delects or adds dropdownJanitor:add(dropdownScroller.ChildRemoved:Connect(updateMaxIconsListener)) dropdownJanitor:add(dropdown:GetAttributeChangedSignal("MaxIcons"):Connect(updateMaxIconsListener)) dropdownJanitor:add(dropdown:GetAttributeChangedSignal("MaxIcons"):Connect(updateChildSize)) dropdownJanitor:add(icon.childThemeModified:Connect(updateMaxIconsListener)) updateMaxIconsListener() -- Ensures each child listens to visibility changes local function connectVisibilityListeners(child) if child:IsA("GuiObject") then child:GetPropertyChangedSignal("Visible"):Connect(updateChildSize) child:GetPropertyChangedSignal("Size"):Connect(updateChildSize) -- -- update max icons when child size changes end end -- For existing children for _, child in pairs(dropdownScroller:GetChildren()) do connectVisibilityListeners(child) end -- For new children dropdownScroller.ChildAdded:Connect(function(child) RunService.Heartbeat:Wait() connectVisibilityListeners(child) updateChildSize() end) -- On start, hide dropdown (prevent it showing as opened) dropdown.Visible = false return dropdown end ================================================ FILE: src/Elements/Indicator.lua ================================================ return function(icon, Icon) local widget = icon.widget local contents = icon:getInstance("Contents") local indicator = Instance.new("Frame") indicator.Name = "Indicator" indicator.LayoutOrder = 9999999 indicator.ZIndex = 6 indicator.Size = UDim2.new(0, 42, 0, 42) indicator.BorderColor3 = Color3.fromRGB(0, 0, 0) indicator.BackgroundTransparency = 1 indicator.Position = UDim2.new(1, 0, 0.5, 0) indicator.BorderSizePixel = 0 indicator.BackgroundColor3 = Color3.fromRGB(0, 0, 0) indicator.Parent = contents local indicatorButton = Instance.new("Frame") indicatorButton.Name = "IndicatorButton" indicatorButton.BorderColor3 = Color3.fromRGB(0, 0, 0) indicatorButton.AnchorPoint = Vector2.new(0.5, 0.5) indicatorButton.BorderSizePixel = 0 indicatorButton.BackgroundColor3 = Color3.fromRGB(0, 0, 0) indicatorButton.Parent = indicator local GuiService = game:GetService("GuiService") local GamepadService = game:GetService("GamepadService") local ourClickRegion = icon:getInstance("ClickRegion") local function selectionChanged() local selectedClickRegion = GuiService.SelectedObject if selectedClickRegion == ourClickRegion then indicatorButton.BackgroundTransparency = 1 indicatorButton.Position = UDim2.new(0.5, -2, 0.5, 0) indicatorButton.Size = UDim2.fromScale(1.2, 1.2) else indicatorButton.BackgroundTransparency = 0.75 indicatorButton.Position = UDim2.new(0.5, 2, 0.5, 0) indicatorButton.Size = UDim2.fromScale(1, 1) end end icon.janitor:add(GuiService:GetPropertyChangedSignal("SelectedObject"):Connect(selectionChanged)) selectionChanged() local imageLabel = Instance.new("ImageLabel") imageLabel.LayoutOrder = 2 imageLabel.ZIndex = 15 imageLabel.AnchorPoint = Vector2.new(0.5, 0.5) imageLabel.Size = UDim2.new(0.5, 0, 0.5, 0) imageLabel.BackgroundTransparency = 1 imageLabel.Position = UDim2.new(0.5, 0, 0.5, 0) imageLabel.Image = "rbxasset://textures/ui/Controls/XboxController/DPadUp@2x.png" imageLabel.Parent = indicatorButton local UICorner = Instance.new("UICorner") UICorner.CornerRadius = UDim.new(1, 0) UICorner.Parent = indicatorButton local UserInputService = game:GetService("UserInputService") local function setIndicatorVisible(visibility) if visibility == nil then visibility = indicator.Visible end if GamepadService.GamepadCursorEnabled then visibility = false end if visibility then icon:modifyTheme({"PaddingRight", "Size", UDim2.new(0, 0, 1, 0)}, "IndicatorPadding") elseif indicator.Visible then icon:removeModification("IndicatorPadding") end icon:modifyTheme({"Indicator", "Visible", visibility}) icon.updateSize:Fire() end icon.janitor:add(GamepadService:GetPropertyChangedSignal("GamepadCursorEnabled"):Connect(setIndicatorVisible)) icon.indicatorSet:Connect(function(keyCode) local visibility = false if keyCode then imageLabel.Image = UserInputService:GetImageForKeyCode(keyCode) visibility = true end setIndicatorVisible(visibility) end) local function updateSize() local ySize = widget.AbsoluteSize.Y*0.96 indicator.Size = UDim2.new(0, ySize, 0, ySize) end widget:GetPropertyChangedSignal("AbsoluteSize"):Connect(updateSize) updateSize() return indicator end ================================================ FILE: src/Elements/Menu.lua ================================================ return function(icon) local menu = Instance.new("ScrollingFrame") menu.Name = "Menu" menu.BackgroundTransparency = 1 menu.Visible = true menu.ZIndex = 1 menu.Size = UDim2.fromScale(1, 1) menu.ClipsDescendants = true menu.TopImage = "" menu.BottomImage = "" menu.HorizontalScrollBarInset = Enum.ScrollBarInset.Always menu.CanvasSize = UDim2.new(0, 0, 1, -1) -- This -1 prevents a dropdown scrolling appearance bug menu.ScrollingEnabled = true menu.ScrollingDirection = Enum.ScrollingDirection.X menu.ZIndex = 20 menu.ScrollBarThickness = 3 menu.ScrollBarImageColor3 = Color3.fromRGB(255, 255, 255) menu.ScrollBarImageTransparency = 0.8 menu.BorderSizePixel = 0 menu.Selectable = false local Icon = require(icon.iconModule) local menuUIListLayout = Icon.container.TopbarStandard:FindFirstChild("UIListLayout", true):Clone() menuUIListLayout.Name = "MenuUIListLayout" menuUIListLayout.VerticalAlignment = Enum.VerticalAlignment.Center menuUIListLayout.Parent = menu local menuGap = Instance.new("Frame") menuGap.Name = "MenuGap" menuGap.BackgroundTransparency = 1 menuGap.Visible = false menuGap.AnchorPoint = Vector2.new(0, 0.5) menuGap.ZIndex = 5 menuGap.Parent = menu local hasStartedMenu = false local Themes = require(script.Parent.Parent.Features.Themes) local function totalChildrenChanged() local menuJanitor = icon.menuJanitor local totalIcons = #icon.menuIcons if hasStartedMenu then if totalIcons <= 0 then menuJanitor:clean() hasStartedMenu = false end return end hasStartedMenu = true -- Listen for changes menuJanitor:add(icon.toggled:Connect(function() if #icon.menuIcons > 0 then icon.updateSize:Fire() end end)) -- Modify appearance of menu icon when joined local _, modificationUID = icon:modifyTheme({ {"Menu", "Active", true}, }) task.defer(function() menuJanitor:add(function() icon:removeModification(modificationUID) end) end) -- For right-aligned icons, this ensures their menus -- close button appear instantly when selected (instead -- of partially hidden from view) local previousCanvasX = menu.AbsoluteCanvasSize.X local function rightAlignCanvas() if icon.alignment == "Right" then local newCanvasX = menu.AbsoluteCanvasSize.X local difference = previousCanvasX - newCanvasX previousCanvasX = newCanvasX menu.CanvasPosition = Vector2.new(menu.CanvasPosition.X - difference, 0) end end menuJanitor:add(icon.selected:Connect(rightAlignCanvas)) menuJanitor:add(menu:GetPropertyChangedSignal("AbsoluteCanvasSize"):Connect(rightAlignCanvas)) -- Apply a close selected image if the user hasn't applied thier own local stateGroup = icon:getStateGroup() local imageDeselected = Themes.getThemeValue(stateGroup, "IconImage", "Image", "Deselected") local imageSelected = Themes.getThemeValue(stateGroup, "IconImage", "Image", "Selected") if imageDeselected == imageSelected then local fontLink = "rbxasset://fonts/families/FredokaOne.json" local fontFace = Font.new(fontLink, Enum.FontWeight.Light, Enum.FontStyle.Normal) icon:removeModificationWith("IconLabel", "Text", "Viewing") icon:removeModificationWith("IconLabel", "Image", "Viewing") icon:modifyTheme({ {"IconLabel", "FontFace", fontFace, "Selected"}, {"IconLabel", "Text", "X", "Selected"}, {"IconLabel", "TextSize", 20, "Selected"}, {"IconLabel", "TextStrokeTransparency", 0.8, "Selected"}, {"IconImage", "Image", "", "Selected"}, }) end -- Change order of spot when alignment changes local menuGap = icon:getInstance("MenuGap") local function updateAlignent() local alignment = icon.alignment local spotIndex = -99999 local gapIndex = -99998 if alignment == "Right" then spotIndex = 99999 gapIndex = 99998 end icon:modifyTheme({"IconSpot", "LayoutOrder", spotIndex}) menuGap.LayoutOrder = gapIndex end menuJanitor:add(icon.alignmentChanged:Connect(updateAlignent)) updateAlignent() -- This updates the scrolling frame to only display a scroll -- length equal to the distance produced by its MaxIcons menu:GetAttributeChangedSignal("MenuCanvasWidth"):Connect(function() local canvasWidth = menu:GetAttribute("MenuCanvasWidth") local canvasY = menu.CanvasSize.Y menu.CanvasSize = UDim2.new(0, canvasWidth, canvasY.Scale, canvasY.Offset) end) menuJanitor:add(icon.updateMenu:Connect(function() local maxIcons = menu:GetAttribute("MaxIcons") if not maxIcons then return end local orderedInstances = {} for _, child in pairs(menu:GetChildren()) do local widgetUID = child:GetAttribute("WidgetUID") if widgetUID and child.Visible then table.insert(orderedInstances, {child, child.AbsolutePosition.X}) end end table.sort(orderedInstances, function(groupA, groupB) return groupA[2] < groupB[2] end) local totalWidth = 0 for i = 1, maxIcons do local group = orderedInstances[i] if not group then break end local child = group[1] local width = child.AbsoluteSize.X + menuUIListLayout.Padding.Offset totalWidth += width end menu:SetAttribute("MenuWidth", totalWidth) end)) local function startMenuUpdate() task.delay(0.1, function() icon.startMenuUpdate:Fire() end) end menuJanitor:add(menu.ChildAdded:Connect(startMenuUpdate)) menuJanitor:add(menu.ChildRemoved:Connect(startMenuUpdate)) menuJanitor:add(menu:GetAttributeChangedSignal("MaxIcons"):Connect(startMenuUpdate)) menuJanitor:add(menu:GetAttributeChangedSignal("MaxWidth"):Connect(startMenuUpdate)) startMenuUpdate() end icon.menuChildAdded:Connect(totalChildrenChanged) icon.menuSet:Connect(function(arrayOfIcons) -- Reset any previous icons for i, otherIconUID in pairs(icon.menuIcons) do local otherIcon = Icon.getIconByUID(otherIconUID) otherIcon:destroy() end -- Apply new icons if type(arrayOfIcons) == "table" then for i, otherIcon in pairs(arrayOfIcons) do otherIcon:joinMenu(icon) end end end) return menu end ================================================ FILE: src/Elements/Notice.lua ================================================ return function(icon, Icon) local notice = Instance.new("Frame") notice.Name = "Notice" notice.ZIndex = 25 notice.AutomaticSize = Enum.AutomaticSize.X notice.BorderColor3 = Color3.fromRGB(0, 0, 0) notice.BorderSizePixel = 0 notice.BackgroundTransparency = 0.1 notice.BackgroundColor3 = Color3.fromRGB(255, 255, 255) notice.Visible = false notice.Parent = icon.widget local UICorner = Instance.new("UICorner") UICorner.CornerRadius = UDim.new(1, 0) UICorner.Parent = notice local UIStroke = Instance.new("UIStroke") UIStroke.Parent = notice local noticeLabel = Instance.new("TextLabel") noticeLabel.Name = "NoticeLabel" noticeLabel.ZIndex = 26 noticeLabel.AnchorPoint = Vector2.new(0.5, 0.5) noticeLabel.AutomaticSize = Enum.AutomaticSize.X noticeLabel.Size = UDim2.new(1, 0, 1, 0) noticeLabel.BackgroundTransparency = 1 noticeLabel.Position = UDim2.new(0.5, 0, 0.515, 0) noticeLabel.BackgroundColor3 = Color3.fromRGB(0, 0, 0) noticeLabel.FontSize = Enum.FontSize.Size14 noticeLabel.TextColor3 = Color3.fromRGB(0, 0, 0) noticeLabel.Text = "1" noticeLabel.TextWrapped = true noticeLabel.TextWrap = true noticeLabel.Font = Enum.Font.Arial noticeLabel.Parent = notice local iconModule = script.Parent.Parent local packages = iconModule.Packages local Janitor = require(packages.Janitor) local Signal = require(packages.GoodSignal) local Utility = require(iconModule.Utility) icon.noticeChanged:Connect(function(totalNotices) -- Notice amount if not totalNotices then return end local exceeded99 = totalNotices > 99 local noticeDisplay = (exceeded99 and "99+") or totalNotices noticeLabel.Text = noticeDisplay if exceeded99 then noticeLabel.TextSize = 11 end -- Should enable local enabled = true if totalNotices < 1 then enabled = false end local parentIcon = Icon.getIconByUID(icon.parentIconUID) local dropdownOrMenuActive = #icon.dropdownIcons > 0 or #icon.menuIcons > 0 if icon.isSelected and dropdownOrMenuActive then enabled = false elseif parentIcon and not parentIcon.isSelected then enabled = false end Utility.setVisible(notice, enabled, "NoticeHandler") end) icon.noticeStarted:Connect(function(customClearSignal, noticeId) if not customClearSignal then customClearSignal = icon.deselected end local parentIcon = Icon.getIconByUID(icon.parentIconUID) if parentIcon then parentIcon:notify(customClearSignal) end local noticeJanitor = icon.janitor:add(Janitor.new()) local noticeComplete = noticeJanitor:add(Signal.new()) noticeJanitor:add(icon.endNotices:Connect(function() noticeComplete:Fire() end)) noticeJanitor:add(customClearSignal:Connect(function() noticeComplete:Fire() end)) noticeId = noticeId or Utility.generateUID() icon.notices[noticeId] = { completeSignal = noticeComplete, clearNoticeEvent = customClearSignal, } local function updateNotice() icon.noticeChanged:Fire(icon.totalNotices) end icon.notified:Fire(noticeId) icon.totalNotices += 1 updateNotice() noticeComplete:Once(function() noticeJanitor:destroy() icon.totalNotices -= 1 icon.notices[noticeId] = nil updateNotice() end) end) -- Establish the notice notice:SetAttribute("ClipToJoinedParent", true) icon:clipOutside(notice) return notice end ================================================ FILE: src/Elements/Selection.lua ================================================ return function(Icon) -- Credit to lolmansReturn and Canary Software for -- retrieving these values local selectionContainer = Instance.new("Frame") selectionContainer.Name = "SelectionContainer" selectionContainer.Visible = false local selection = Instance.new("Frame") selection.Name = "Selection" selection.BackgroundColor3 = Color3.fromRGB(255, 255, 255) selection.BackgroundTransparency = 1 selection.BorderColor3 = Color3.fromRGB(0, 0, 0) selection.BorderSizePixel = 0 selection.Parent = selectionContainer local UIStroke = Instance.new("UIStroke") UIStroke.Name = "UIStroke" UIStroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border UIStroke.Color = Color3.fromRGB(255, 255, 255) UIStroke.Thickness = 3 UIStroke.Parent = selection local selectionGradient = Instance.new("UIGradient") selectionGradient.Name = "SelectionGradient" selectionGradient.Parent = UIStroke local UICorner = Instance.new("UICorner") UICorner:SetAttribute("Collective", "IconCorners") UICorner.Name = "UICorner" UICorner.CornerRadius = UDim.new(1, 0) UICorner.Parent = selection local RunService = game:GetService("RunService") local GuiService = game:GetService("GuiService") local rotationSpeed = 1 selection:GetAttributeChangedSignal("RotationSpeed"):Connect(function() rotationSpeed = selection:GetAttribute("RotationSpeed") end) RunService.Heartbeat:Connect(function() if not GuiService.SelectedObject then return end selectionGradient.Rotation = (os.clock() * rotationSpeed * 100) % 360 end) return selectionContainer end ================================================ FILE: src/Elements/Widget.lua ================================================ -- I named this 'Widget' instead of 'Icon' to make a clear difference between the icon *object* and -- the icon (aka Widget) instance. -- This contains the core components of the icon such as the button, image, label and notice. It's -- also responsible for handling the automatic resizing of the widget (based upon image visibility and text length) return function(icon, Icon) local widget = Instance.new("Frame") widget:SetAttribute("WidgetUID", icon.UID) widget.Name = "Widget" widget.BackgroundTransparency = 1 widget.Visible = true widget.ZIndex = 20 widget.Active = false widget.ClipsDescendants = true local button = Instance.new("Frame") button.Name = "IconButton" button.Visible = true button.ZIndex = 2 button.BorderSizePixel = 0 button.Parent = widget button.ClipsDescendants = true button.Active = false -- This is essential for mobile scrollers to work when dragging icon.deselected:Connect(function() button.ClipsDescendants = true task.delay(0.2, function() if icon.isSelected then button.ClipsDescendants = false end end) end) -- Account for PreferredTransparency which can be set by every player local GuiService = game:GetService("GuiService") icon:setBehaviour("IconButton", "BackgroundTransparency", function(value) local preference = GuiService.PreferredTransparency local newValue = value * preference if value == 1 then return value end return newValue end) icon.janitor:add(GuiService:GetPropertyChangedSignal("PreferredTransparency"):Connect(function() icon:refreshAppearance(button, "BackgroundTransparency") end)) local iconCorner = Instance.new("UICorner") iconCorner:SetAttribute("Collective", "IconCorners") iconCorner.Name = "UICorner" iconCorner.Parent = button local menu = require(script.Parent.Menu)(icon) local menuUIListLayout = menu.MenuUIListLayout local menuGap = menu.MenuGap menu.Parent = button local iconSpot = Instance.new("Frame") iconSpot.Name = "IconSpot" iconSpot.BackgroundColor3 = Color3.fromRGB(225, 225, 225) iconSpot.BackgroundTransparency = 0.9 iconSpot.Visible = true iconSpot.AnchorPoint = Vector2.new(0, 0.5) iconSpot.ZIndex = 5 iconSpot.Parent = menu local iconSpotCorner = iconCorner:Clone() iconSpotCorner.Parent = iconSpot local overlay = iconSpot:Clone() overlay.UICorner.Name = "OverlayUICorner" overlay.Name = "IconOverlay" overlay.BackgroundColor3 = Color3.fromRGB(255, 255, 255) overlay.ZIndex = iconSpot.ZIndex + 1 overlay.Size = UDim2.new(1, 0, 1, 0) overlay.Position = UDim2.new(0, 0, 0, 0) overlay.AnchorPoint = Vector2.new(0, 0) overlay.Visible = false overlay.Parent = iconSpot local clickRegion = Instance.new("TextButton") clickRegion:SetAttribute("CorrespondingIconUID", icon.UID) clickRegion.Name = "ClickRegion" clickRegion.BackgroundTransparency = 1 clickRegion.Visible = true clickRegion.Text = "" clickRegion.ZIndex = 20 clickRegion.Selectable = true clickRegion.SelectionGroup = true clickRegion.Parent = iconSpot local Gamepad = require(script.Parent.Parent.Features.Gamepad) Gamepad.registerButton(clickRegion) local clickRegionCorner = iconCorner:Clone() clickRegionCorner.Parent = clickRegion local contents = Instance.new("Frame") contents.Name = "Contents" contents.BackgroundTransparency = 1 contents.Size = UDim2.fromScale(1, 1) contents.Parent = iconSpot local contentsList = Instance.new("UIListLayout") contentsList.Name = "ContentsList" contentsList.FillDirection = Enum.FillDirection.Horizontal contentsList.VerticalAlignment = Enum.VerticalAlignment.Center contentsList.SortOrder = Enum.SortOrder.LayoutOrder contentsList.VerticalFlex = Enum.UIFlexAlignment.SpaceEvenly contentsList.Padding = UDim.new(0, 3) contentsList.Parent = contents local paddingLeft = Instance.new("Frame") paddingLeft.Name = "PaddingLeft" paddingLeft.LayoutOrder = 1 paddingLeft.ZIndex = 5 paddingLeft.BorderColor3 = Color3.fromRGB(0, 0, 0) paddingLeft.BackgroundTransparency = 1 paddingLeft.BorderSizePixel = 0 paddingLeft.BackgroundColor3 = Color3.fromRGB(255, 255, 255) paddingLeft.Parent = contents local paddingCenter = Instance.new("Frame") paddingCenter.Name = "PaddingCenter" paddingCenter.LayoutOrder = 3 paddingCenter.ZIndex = 5 paddingCenter.Size = UDim2.new(0, 0, 1, 0) paddingCenter.BorderColor3 = Color3.fromRGB(0, 0, 0) paddingCenter.BackgroundTransparency = 1 paddingCenter.BorderSizePixel = 0 paddingCenter.BackgroundColor3 = Color3.fromRGB(255, 255, 255) paddingCenter.Parent = contents local paddingRight = Instance.new("Frame") paddingRight.Name = "PaddingRight" paddingRight.LayoutOrder = 5 paddingRight.ZIndex = 5 paddingRight.BorderColor3 = Color3.fromRGB(0, 0, 0) paddingRight.BackgroundTransparency = 1 paddingRight.BorderSizePixel = 0 paddingRight.BackgroundColor3 = Color3.fromRGB(255, 255, 255) paddingRight.Parent = contents local iconLabelContainer = Instance.new("Frame") iconLabelContainer.Name = "IconLabelContainer" iconLabelContainer.LayoutOrder = 4 iconLabelContainer.ZIndex = 3 iconLabelContainer.AnchorPoint = Vector2.new(0, 0.5) iconLabelContainer.Size = UDim2.new(0, 0, 0.5, 0) iconLabelContainer.BackgroundTransparency = 1 iconLabelContainer.Position = UDim2.new(0.5, 0, 0.5, 0) iconLabelContainer.Parent = contents local iconLabel = Instance.new("TextLabel") local viewportX = workspace.CurrentCamera.ViewportSize.X+200 iconLabel.Name = "IconLabel" iconLabel.LayoutOrder = 4 iconLabel.ZIndex = 15 iconLabel.AnchorPoint = Vector2.new(0, 0) iconLabel.Size = UDim2.new(0, viewportX, 1, 0) iconLabel.ClipsDescendants = false iconLabel.BackgroundTransparency = 1 iconLabel.Position = UDim2.fromScale(0, 0) iconLabel.RichText = true iconLabel.TextColor3 = Color3.fromRGB(255, 255, 255) iconLabel.TextXAlignment = Enum.TextXAlignment.Left iconLabel.Text = "" iconLabel.TextWrapped = true iconLabel.TextWrap = true iconLabel.TextScaled = false iconLabel.Active = false iconLabel.AutoLocalize = true iconLabel.Parent = iconLabelContainer local iconImage = Instance.new("ImageLabel") iconImage.Name = "IconImage" iconImage.LayoutOrder = 2 iconImage.ZIndex = 15 iconImage.AnchorPoint = Vector2.new(0, 0.5) iconImage.Size = UDim2.new(0, 0, 0.5, 0) iconImage.BackgroundTransparency = 1 iconImage.Position = UDim2.new(0, 11, 0.5, 0) iconImage.ScaleType = Enum.ScaleType.Stretch iconImage.Active = false iconImage.Parent = contents local iconImageCorner = iconCorner:Clone() iconImageCorner:SetAttribute("Collective", nil) iconImageCorner.CornerRadius = UDim.new(0, 0) iconImageCorner.Name = "IconImageCorner" iconImageCorner.Parent = iconImage local TweenService = game:GetService("TweenService") local resizingCount = 0 local function handleLabelAndImageChangesUnstaggered(forceUpdateString) -- We defer changes by a frame to eliminate all but 1 requests which -- could otherwise stack up to 20+ requests in a single frame -- We then repeat again once to account for any final changes -- Deferring is also essential because properties are set immediately -- afterwards (therefore calculations will use the correct values) task.defer(function() local indicator = icon.indicator local usingIndicator = indicator and indicator.Visible local usingText = usingIndicator or iconLabel.Text ~= "" local usingImage = iconImage.Image ~= "" and iconImage.Image ~= nil local _alignment = Enum.HorizontalAlignment.Center local NORMAL_BUTTON_SIZE = UDim2.fromScale(1, 1) local buttonSize = NORMAL_BUTTON_SIZE if usingImage and not usingText then iconLabelContainer.Visible = false iconImage.Visible = true paddingLeft.Visible = false paddingCenter.Visible = false paddingRight.Visible = false elseif not usingImage and usingText then iconLabelContainer.Visible = true iconImage.Visible = false paddingLeft.Visible = true paddingCenter.Visible = false paddingRight.Visible = true elseif usingImage and usingText then iconLabelContainer.Visible = true iconImage.Visible = true paddingLeft.Visible = true paddingCenter.Visible = not usingIndicator paddingRight.Visible = not usingIndicator _alignment = Enum.HorizontalAlignment.Left end button.Size = buttonSize local function getItemWidth(item) local targetWidth = item:GetAttribute("TargetWidth") or item.AbsoluteSize.X return targetWidth end local contentsPadding = contentsList.Padding.Offset local initialWidgetWidth = contentsPadding --0 local textWidth = iconLabel.TextBounds.X iconLabelContainer.Size = UDim2.new(0, textWidth, iconLabel.Size.Y.Scale, 0) for _, child in pairs(contents:GetChildren()) do if child:IsA("GuiObject") and child.Visible == true then local itemWidth = getItemWidth(child) initialWidgetWidth += itemWidth + contentsPadding end end local widgetMinimumWidth = widget:GetAttribute("MinimumWidth") local widgetMinimumHeight = widget:GetAttribute("MinimumHeight") local widgetBorderSize = widget:GetAttribute("BorderSize") local widgetWidth = math.clamp(initialWidgetWidth, widgetMinimumWidth, viewportX) local menuIcons = icon.menuIcons local additionalWidth = 0 local hasMenu = #menuIcons > 0 local showMenu = hasMenu and icon.isSelected if showMenu then for _, frame in pairs(menu:GetChildren()) do if frame ~= iconSpot and frame:IsA("GuiObject") and frame.Visible then additionalWidth += getItemWidth(frame) + menuUIListLayout.Padding.Offset end end if not iconSpot.Visible then widgetWidth -= (getItemWidth(iconSpot) + menuUIListLayout.Padding.Offset*2 + widgetBorderSize) end additionalWidth -= (widgetBorderSize*0.5) widgetWidth += additionalWidth - (widgetBorderSize*0.75) end menuGap.Visible = showMenu and iconSpot.Visible local desiredWidth = widget:GetAttribute("DesiredWidth") if desiredWidth and widgetWidth < desiredWidth then widgetWidth = desiredWidth end icon.updateMenu:Fire() local preWidth = math.max(widgetWidth-additionalWidth, widgetMinimumWidth) local spotWidth = preWidth-(widgetBorderSize*2) local menuWidth = menu:GetAttribute("MenuWidth") local totalMenuWidth = menuWidth and menuWidth + spotWidth + menuUIListLayout.Padding.Offset + 10 if totalMenuWidth then local maxWidth = menu:GetAttribute("MaxWidth") if maxWidth then totalMenuWidth = math.max(maxWidth, widgetMinimumWidth) end menu:SetAttribute("MenuCanvasWidth", widgetWidth) if totalMenuWidth < widgetWidth then widgetWidth = totalMenuWidth end end local style = Enum.EasingStyle.Quint local direction = Enum.EasingDirection.Out local spotWidthMax = math.max(spotWidth, getItemWidth(iconSpot), iconSpot.AbsoluteSize.X) local widgetWidthMax = math.max(widgetWidth, getItemWidth(widget), widget.AbsoluteSize.X) local SPEED = 750 local spotTweenInfo = TweenInfo.new(spotWidthMax/SPEED, style, direction) local widgetTweenInfo = TweenInfo.new(widgetWidthMax/SPEED, style, direction) TweenService:Create(iconSpot, spotTweenInfo, { Position = UDim2.new(0, widgetBorderSize, 0.5, 0), Size = UDim2.new(0, spotWidth, 1, -widgetBorderSize*2), }):Play() TweenService:Create(clickRegion, spotTweenInfo, { Size = UDim2.new(0, spotWidth, 1, 0), }):Play() local newWidgetSize = UDim2.fromOffset(widgetWidth, widgetMinimumHeight) local updateInstantly = widget.Size.Y.Offset ~= widgetMinimumHeight if updateInstantly then widget.Size = newWidgetSize end widget:SetAttribute("TargetWidth", newWidgetSize.X.Offset) local movingTween = TweenService:Create(widget, widgetTweenInfo, { Size = newWidgetSize, }) movingTween:Play() resizingCount += 1 for i = 1, widgetTweenInfo.Time * 100 do task.delay(i/100, function() Icon.iconChanged:Fire(icon) end) end task.delay(widgetTweenInfo.Time-0.2, function() resizingCount -= 1 task.defer(function() if resizingCount == 0 then icon.resizingComplete:Fire() end end) end) icon:updateParent() end) end local Utility = require(script.Parent.Parent.Utility) local handleLabelAndImageChanges = Utility.createStagger(0.01, handleLabelAndImageChangesUnstaggered) local firstTimeSettingFontFace = true icon:setBehaviour("IconLabel", "Text", handleLabelAndImageChanges) icon:setBehaviour("IconLabel", "FontFace", function(value) local previousFontFace = iconLabel.FontFace if previousFontFace == value then return end task.spawn(function() --[[ local fontLink = value.Family if string.match(fontLink, "rbxassetid://") then local ContentProvider = game:GetService("ContentProvider") local assets = {fontLink} ContentProvider:PreloadAsync(assets) end--]] -- Afaik there's no way to determine when a Font Family has -- loaded (even with ContentProvider), so we just have to try -- a few times and hope it loads within the refresh period handleLabelAndImageChanges() if firstTimeSettingFontFace then firstTimeSettingFontFace = false for i = 1, 10 do task.wait(1) handleLabelAndImageChanges() end end end) end) local function updateBorderSize() task.defer(function() local borderOffset = widget:GetAttribute("BorderSize") local alignment = icon.alignment local alignmentOffset = (iconSpot.Visible == false and 0) or (alignment == "Right" and -borderOffset) or borderOffset menu.Position = UDim2.new(0, alignmentOffset, 0, 0) menuGap.Size = UDim2.fromOffset(borderOffset, 0) menuUIListLayout.Padding = UDim.new(0, 0) handleLabelAndImageChanges() end) end icon:setBehaviour("Widget", "BorderSize", updateBorderSize) icon:setBehaviour("IconSpot", "Visible", updateBorderSize) icon.startMenuUpdate:Connect(handleLabelAndImageChanges) icon.updateSize:Connect(handleLabelAndImageChanges) icon:setBehaviour("ContentsList", "HorizontalAlignment", handleLabelAndImageChanges) icon:setBehaviour("Widget", "Visible", handleLabelAndImageChanges) icon:setBehaviour("Widget", "DesiredWidth", handleLabelAndImageChanges) icon:setBehaviour("Widget", "MinimumWidth", handleLabelAndImageChanges) icon:setBehaviour("Widget", "MinimumHeight", handleLabelAndImageChanges) icon:setBehaviour("Indicator", "Visible", handleLabelAndImageChanges) icon:setBehaviour("IconImageRatio", "AspectRatio", handleLabelAndImageChanges) icon:setBehaviour("IconImage", "Image", function(value) local textureId = (tonumber(value) and "http://www.roblox.com/asset/?id="..value) or value or "" if iconImage.Image ~= textureId then handleLabelAndImageChanges() end return textureId end) icon.alignmentChanged:Connect(function(newAlignment) if newAlignment == "Center" then newAlignment = "Left" end menuUIListLayout.HorizontalAlignment = Enum.HorizontalAlignment[newAlignment] updateBorderSize() end) -- Localization support (refresh icon size whenever player changes language changes in-game) local Players = game:GetService("Players") local localPlayer = Players.LocalPlayer local lastLocaleId = localPlayer.LocaleId icon.janitor:add(localPlayer:GetPropertyChangedSignal("LocaleId"):Connect(function() task.delay(0.2, function() local newLocaleId = localPlayer.LocaleId if newLocaleId ~= lastLocaleId then lastLocaleId = newLocaleId icon:refresh() task.wait(0.5) icon:refresh() end end) end)) local iconImageScale = Instance.new("NumberValue") iconImageScale.Name = "IconImageScale" iconImageScale.Parent = iconImage iconImageScale:GetPropertyChangedSignal("Value"):Connect(function() iconImage.Size = UDim2.new(iconImageScale.Value, 0, iconImageScale.Value, 0) end) local UIAspectRatioConstraint = Instance.new("UIAspectRatioConstraint") UIAspectRatioConstraint.Name = "IconImageRatio" UIAspectRatioConstraint.AspectType = Enum.AspectType.FitWithinMaxSize UIAspectRatioConstraint.DominantAxis = Enum.DominantAxis.Height UIAspectRatioConstraint.Parent = iconImage local iconGradient = Instance.new("UIGradient") iconGradient.Name = "IconGradient" iconGradient.Enabled = true iconGradient.Parent = button local iconSpotGradient = Instance.new("UIGradient") iconSpotGradient.Name = "IconSpotGradient" iconSpotGradient.Enabled = true iconSpotGradient.Parent = iconSpot return widget end ================================================ FILE: src/Features/Gamepad.lua ================================================ -- As the name suggests, this handles everything related to gamepads -- (i.e. Xbox or Playstation controllers) and their navigation -- I created a separate module for gamepads (and not touchpads or -- keyboards) because gamepads are greatly more unqiue and require -- additional tailored programming -- SERVICES local GamepadService = game:GetService("GamepadService") local UserInputService = game:GetService("UserInputService") local GuiService = game:GetService("GuiService") -- LOCAL local DEFAULT_HIGHLIGHT_KEY = Enum.KeyCode.DPadUp -- The default key to highlight the topbar icon local GAMEPAD_INPUT = Enum.PreferredInput.Gamepad local Gamepad = {} local Icon -- FUNCTIONS -- This is called upon the Icon initializing function Gamepad.start(incomingIcon) -- Public variables Icon = incomingIcon Icon.highlightKey = if Icon.highlightKey ~= nil then Icon.highlightKey else DEFAULT_HIGHLIGHT_KEY -- What controller key to highlight the topbar (or set to false to disable) Icon.highlightIcon = false -- Change to a specific icon if you'd like to highlight a specific icon instead of the left-most -- We defer so the developer can make changes before the -- gamepad controls are initialized task.delay(1, function() -- Some local utility local iconsDict = Icon.iconsDictionary local function getIconFromSelectedObject() local clickRegion = GuiService.SelectedObject local iconUID = clickRegion and clickRegion:GetAttribute("CorrespondingIconUID") local icon = iconUID and iconsDict[iconUID] return icon end -- This enables users to instantly open up their last selected icon local previousHighlightedIcon local usedIndicatorOnce = DEFAULT_HIGHLIGHT_KEY ~= Icon.highlightKey local usedBOnce = DEFAULT_HIGHLIGHT_KEY ~= Icon.highlightKey local Selection = require(script.Parent.Parent.Elements.Selection) local function updateSelectedObject() local icon = getIconFromSelectedObject() local isUsingGamepad = UserInputService.PreferredInput == GAMEPAD_INPUT if icon then if isUsingGamepad then local clickRegion = icon:getInstance("ClickRegion") local selection = icon.selection if not selection then selection = icon.janitor:add(Selection(Icon)) selection:SetAttribute("IgnoreVisibilityUpdater", true) selection.Parent = icon.widget icon.selection = selection icon:refreshAppearance(selection) --icon:clipOutside(selection) end clickRegion.SelectionImageObject = selection.Selection end if previousHighlightedIcon and previousHighlightedIcon ~= icon then previousHighlightedIcon:setIndicator() end local newIndicator = if isUsingGamepad and not usedBOnce and not icon.parentIconUID then Enum.KeyCode.ButtonB else nil previousHighlightedIcon = icon Icon.lastHighlightedIcon = icon icon:setIndicator(newIndicator) else local newIndicator = if isUsingGamepad and not usedIndicatorOnce then Icon.highlightKey else nil if not previousHighlightedIcon then previousHighlightedIcon = Gamepad.getIconToHighlight() end if newIndicator == Icon.highlightKey then -- We only display the highlightKey once to show -- the user how to highlight the topbar icon usedIndicatorOnce = true else --usedBOnce = true end if previousHighlightedIcon then previousHighlightedIcon:setIndicator(newIndicator) end end end GuiService:GetPropertyChangedSignal("SelectedObject"):Connect(updateSelectedObject) -- This listens for a gamepad being present/added/removed local function preferredInputChanged() local preferredInput = UserInputService.PreferredInput local isUsingGamepad = preferredInput == GAMEPAD_INPUT if not isUsingGamepad then usedIndicatorOnce = false usedBOnce = false end updateSelectedObject() end UserInputService:GetPropertyChangedSignal("PreferredInput"):Connect(preferredInputChanged) preferredInputChanged() -- This allows for easy highlighting of the topbar when the -- when ``Icon.highlightKey`` (i.e. DPadUp) is pressed. -- If you'd like to disable, do ``Icon.highlightKey = false`` UserInputService.InputBegan:Connect(function(input, touchingAnObject) if input.UserInputType == Enum.UserInputType.MouseButton1 then -- Sometimes the Roblox gamepad glitches when combined with a cursor -- This fixes that by unhighlighting if the cursor is pressed down -- (i.e. a mouse click) local icon = getIconFromSelectedObject() if icon then GuiService.SelectedObject = nil end return end if input.KeyCode ~= Icon.highlightKey then return end local iconToHighlight = Gamepad.getIconToHighlight() if iconToHighlight then if GamepadService.GamepadCursorEnabled then task.wait(0.2) GamepadService:DisableGamepadCursor() end local clickRegion = iconToHighlight:getInstance("ClickRegion") GuiService.SelectedObject = clickRegion end end) end) end function Gamepad.getIconToHighlight() -- If an icon has already been selected, returns the last selected icon -- Else if more than 0 icons, it selects the left-most icon local iconsDict = Icon.iconsDictionary local iconToHighlight = Icon.highlightIcon or Icon.lastHighlightedIcon if not iconToHighlight then local currentX for _, icon in pairs(iconsDict) do if icon.parentIconUID then continue end local thisX = icon.widget.AbsolutePosition.X if not currentX or thisX < currentX then iconToHighlight = icon currentX = iconToHighlight.widget.AbsolutePosition.X end end end return iconToHighlight end -- This called when the icon's ClickRegion is created function Gamepad.registerButton(buttonInstance) -- This provides a basic level of support for controllers by making -- the icons easy to highlight via the virtual cursor, then -- when selected, focuses in on the selected icon and hops -- between other nearby icons simply by toggling the joystick local inputBegan = false buttonInstance.InputBegan:Connect(function(input) -- Two wait frames required to ensure inputBegan is detected within -- UserInputService.InputBegan. We do this because object.InputBegan -- does not return the correct input objects (unlike the service) inputBegan = true task.wait() task.wait() inputBegan = false end) local connection = UserInputService.InputBegan:Connect(function(input) task.wait() if input.KeyCode == Enum.KeyCode.ButtonA and inputBegan then -- We focus on an icon when selected via the virtual cursor task.wait(0.2) GamepadService:DisableGamepadCursor() GuiService.SelectedObject = buttonInstance return end local isSelected = GuiService.SelectedObject == buttonInstance local unselectKeyCodes = {"ButtonB", "ButtonSelect"} local keyName = input.KeyCode.Name if table.find(unselectKeyCodes, keyName) and isSelected then -- We unfocus when back button is pressed, but ignore -- if the virtual cursor is disabled otherwise it will be -- impossible to select the topbar if not(keyName == "ButtonSelect" and not GamepadService.GamepadCursorEnabled) then GuiService.SelectedObject = nil end end end) buttonInstance.Destroying:Once(function() connection:Disconnect() end) end return Gamepad ================================================ FILE: src/Features/Overflow.lua ================================================ -- When designing your game for many devices and screen sizes, icons may occasionally -- particularly for smaller devices like phones, overlap with other icons or the bounds -- of the screen. The overflow handler solves this challenge by moving the out-of-bounds -- icon into an overflow menu (with a limited scrolling canvas) preventing overlaps occuring -- LOCAL local Overflow = {} local holders = {} local orderedAvailableIcons = {} local iconsDict local currentCamera = workspace.CurrentCamera local overflowIcons = {} local overflowIconUIDs = {} local Utility = require(script.Parent.Parent.Utility) local beginCheckingCenterIcons = false local beganSecondaryCenterCheck = false local Icon -- FUNCTIONS -- This is called upon the Icon initializing function Overflow.start(incomingIcon) Icon = incomingIcon iconsDict = Icon.iconsDictionary local primaryScreenGui for _, screenGui in pairs(Icon.container) do if primaryScreenGui == nil and screenGui.ScreenInsets == Enum.ScreenInsets.TopbarSafeInsets then primaryScreenGui = screenGui end for _, holder in pairs(screenGui.Holders:GetChildren()) do if holder:GetAttribute("IsAHolder") then holders[holder.Name] = holder end end end -- We listen for changes in icons (such as them being added, removed, -- the setting of a different alignment, the widget size changing, etc) local beginOverflow = false local updateBoundaries = Utility.createStagger(0.1, function(ignoreAvailable) if not beginOverflow then return end if not ignoreAvailable then Overflow.updateAvailableIcons("Center") end Overflow.updateBoundary("Left") Overflow.updateBoundary("Right") end) task.delay(0.5, function() beginOverflow = true updateBoundaries() end) task.delay(2, function() -- This is essential to prevent central icons begin added -- left or right due to incomplete UIListLayout calculations -- within the first few frames beginCheckingCenterIcons = true updateBoundaries() end) Icon.iconAdded:Connect(updateBoundaries) Icon.iconRemoved:Connect(updateBoundaries) Icon.iconChanged:Connect(updateBoundaries) currentCamera:GetPropertyChangedSignal("ViewportSize"):Connect(function() updateBoundaries(true) end) primaryScreenGui:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() updateBoundaries(true) end) end function Overflow.getWidth(icon, getMaxWidth) local widget = icon.widget return widget:GetAttribute("TargetWidth") or widget.AbsoluteSize.X end function Overflow.getAvailableIcons(alignment) local ourOrderedIcons = orderedAvailableIcons[alignment] if not ourOrderedIcons then ourOrderedIcons = Overflow.updateAvailableIcons(alignment) end return ourOrderedIcons end function Overflow.updateAvailableIcons(alignment) -- We only track items that are directly on the topbar (i.e. not within a parent icon) local ourTotal = 0 local ourOrderedIcons = {} for _, icon in pairs(iconsDict) do local parentUID = icon.parentIconUID local isDirectlyOnTopbar = not parentUID or overflowIconUIDs[parentUID] local isOverflow = overflowIconUIDs[icon.UID] if isDirectlyOnTopbar and icon.alignment == alignment and not isOverflow and icon.isEnabled then table.insert(ourOrderedIcons, icon) ourTotal += 1 end end -- Ignore if no icons are available if ourTotal <= 0 then return {} end -- This sorts these icons by smallest order, or if equal, left-most position -- (even for the right alignment because all icons are sorted left-to-right) table.sort(ourOrderedIcons, function(iconA, iconB) local orderA = iconA.widget.LayoutOrder local orderB = iconB.widget.LayoutOrder local hasParentA = iconA.parentIconUID local hasParentB = iconB.parentIconUID if hasParentA == hasParentB then if orderA < orderB then return true end if orderA > orderB then return false end return iconA.widget.AbsolutePosition.X < iconB.widget.AbsolutePosition.X elseif hasParentB then return false elseif hasParentA then return true end return nil end) -- Finish up orderedAvailableIcons[alignment] = ourOrderedIcons return ourOrderedIcons end function Overflow.getRealXPositions(alignment, orderedIcons) -- We calculate the the absolute position of icons instead of reading -- directly to determine where they would be if not within an overflow local isLeft = alignment == "Left" local holder = holders[alignment] local holderXPos = holder.AbsolutePosition.X local holderXSize = holder.AbsoluteSize.X local holderUIList = holder.UIListLayout local topbarInset = holderUIList.Padding.Offset local absoluteX = (isLeft and holderXPos) or holderXPos + holderXSize local realXPositions = {} if isLeft then Utility.reverseTable(orderedIcons) end for i = #orderedIcons, 1, -1 do local icon = orderedIcons[i] local sizeX = Overflow.getWidth(icon) if not isLeft then absoluteX -= sizeX end realXPositions[icon.UID] = absoluteX if isLeft then absoluteX += sizeX end absoluteX += (isLeft and topbarInset) or -topbarInset end return realXPositions end function Overflow.updateBoundary(alignment) -- We only track items that are directly on the topbar (i.e. not within a parent icon) or within an overflow local holder = holders[alignment] local holderUIList = holder.UIListLayout local holderXPos = holder.AbsolutePosition.X local holderXSize = holder.AbsoluteSize.X local topbarInset = holderUIList.Padding.Offset local topbarPadding = holderUIList.Padding.Offset local BOUNDARY_GAP = topbarInset local ourOrderedIcons = Overflow.updateAvailableIcons(alignment) local boundWidth = 0 local ourTotal = 0 for _, icon in pairs(ourOrderedIcons) do boundWidth += Overflow.getWidth(icon) + topbarPadding ourTotal += 1 end if ourTotal <= 0 then return end -- These are the icons with menus which icons will be moved into -- when overflowing local isCentral = alignment == "Center" local isLeft = alignment == "Left" local isRight = not isLeft local overflowIcon = overflowIcons[alignment] if not overflowIcon and not isCentral and #ourOrderedIcons > 0 then local order = (isLeft and -9999999) or 9999999 overflowIcon = Icon.new()--:setLabel(`{alignment}`) overflowIcon:setImage(6069276526, "Deselected") overflowIcon:setName("Overflow"..alignment) overflowIcon:setOrder(order) overflowIcon:setAlignment(alignment) overflowIcon:autoDeselect(false) overflowIcon.isAnOverflow = true --overflowIcon:freezeMenu() overflowIcon:select("OverflowStart", overflowIcon) overflowIcon:setEnabled(false) overflowIcons[alignment] = overflowIcon overflowIconUIDs[overflowIcon.UID] = true if not Icon.closeableOverflowMenus then local iconSpot = overflowIcon:getInstance("IconSpot") iconSpot.Visible = false end end -- The default boundary is the point where both the left-most-right-icon -- and left-most-right-icon meet OR the opposite side of the screen local oppositeAlignment = (alignment == "Left" and "Right") or "Left" local oppositeOrderedIcons = Overflow.updateAvailableIcons(oppositeAlignment) local nearestOppositeIcon = (isLeft and oppositeOrderedIcons[1]) or (isRight and oppositeOrderedIcons[#oppositeOrderedIcons]) local oppositeOverflowIcon = overflowIcons[oppositeAlignment] local boundary = (isLeft and holderXPos + holderXSize) or holderXPos if nearestOppositeIcon then local oppositeRealXPositions = Overflow.getRealXPositions(oppositeAlignment, oppositeOrderedIcons) local oppositeX = oppositeRealXPositions[nearestOppositeIcon.UID] local oppositeXSize = Overflow.getWidth(nearestOppositeIcon) boundary = (isLeft and oppositeX - BOUNDARY_GAP) or oppositeX + oppositeXSize + BOUNDARY_GAP end -- We get the left-most icon (if left alignment) or right-most-icon (if -- right alignment) of the central icons group to see if we need to change -- the boundary (if the central icon boundary is smaller than the alignment -- boundary then we use the central) local totalChecks = 0 local usingNearestCenter = false local function checkToShiftCentralIcon() local centerOrderedIcons = Overflow.getAvailableIcons("Center") local centerPos = (isLeft and 1) or #centerOrderedIcons local nearestCenterIcon = centerOrderedIcons[centerPos] local function secondaryCheck() if not beganSecondaryCenterCheck then beganSecondaryCenterCheck = true task.delay(3, Overflow.updateBoundary, alignment) end end if nearestCenterIcon and not nearestCenterIcon.hasRelocatedInOverflow then local ourNearestIcon = (isLeft and ourOrderedIcons[#ourOrderedIcons]) or (isRight and ourOrderedIcons[1]) local centralNearestXPos = nearestCenterIcon.widget.AbsolutePosition.X local ourNearestXPos = ourNearestIcon.widget.AbsolutePosition.X local ourNearestXSize = Overflow.getWidth(ourNearestIcon) local centerBoundary = (isLeft and centralNearestXPos-BOUNDARY_GAP) or centralNearestXPos + Overflow.getWidth(nearestCenterIcon) + BOUNDARY_GAP local removeBoundary = (isLeft and ourNearestXPos + ourNearestXSize) or ourNearestXPos local hasShifted = false if isLeft then if centerBoundary < removeBoundary then if not beginCheckingCenterIcons then secondaryCheck() return end nearestCenterIcon:align("Left") nearestCenterIcon.hasRelocatedInOverflow = true hasShifted = true end elseif isRight then if centerBoundary > removeBoundary then if not beginCheckingCenterIcons or removeBoundary < 0 then secondaryCheck() return end nearestCenterIcon:align("Right") nearestCenterIcon.hasRelocatedInOverflow = true hasShifted = true end end if hasShifted then totalChecks += 1 if totalChecks <= 4 then Overflow.updateAvailableIcons("Center") checkToShiftCentralIcon() end end end end checkToShiftCentralIcon() --[[ This updates the maximum size of the overflow menus The menu determines its bounds from the smallest of either: 1. The closest center-aligned icon (i.e. the boundary) 2. The edge of the opposite overflow menu UNLESS... 3. ... the edge exceeds more than half the screenGui --]] if overflowIcon then local menuBoundary = boundary local menu = overflowIcon:getInstance("Menu") local holderXEndPos = holderXPos + holderXSize local menuWidth = holderXSize if menu and oppositeOverflowIcon then local oppositeWidget = oppositeOverflowIcon.widget local oppositeXPos = oppositeWidget.AbsolutePosition.X local oppositeXSize = Overflow.getWidth(oppositeOverflowIcon) local oppositeBoundary = (isLeft and oppositeXPos - BOUNDARY_GAP) or oppositeXPos + oppositeXSize + BOUNDARY_GAP local oppositeMenu = oppositeOverflowIcon:getInstance("Menu") local isDominant = menu.AbsoluteCanvasSize.X >= oppositeMenu.AbsoluteCanvasSize.X if not usingNearestCenter then local halfwayXPos = holderXPos + holderXSize/2 local halfwayBoundary = (isLeft and halfwayXPos - BOUNDARY_GAP/2) or halfwayXPos + BOUNDARY_GAP/2 menuBoundary = halfwayBoundary if isDominant then menuBoundary = oppositeBoundary end end menuWidth = (isLeft and menuBoundary - holderXPos) or (holderXEndPos - menuBoundary) end local currentMaxWidth = menu and menu:GetAttribute("MaxWidth") menuWidth = Utility.round(menuWidth) if menu and currentMaxWidth ~= menuWidth then menu:SetAttribute("MaxWidth", menuWidth) end end -- Parent ALL icons of that alignment into the overflow if at least on -- sibling exceeds the bounds. -- We calculate the the absolute position of icons instead of reading -- directly to determine where they would be if not within an overflow local joinOverflow = false local realXPositions = Overflow.getRealXPositions(alignment, ourOrderedIcons) for i = #ourOrderedIcons, 1, -1 do local icon = ourOrderedIcons[i] local widgetX = Overflow.getWidth(icon) local xPos = realXPositions[icon.UID] if (isLeft and xPos + widgetX >= boundary) or (isRight and xPos <= boundary) then joinOverflow = true end end for i = #ourOrderedIcons, 1, -1 do local icon = ourOrderedIcons[i] local isOverflow = overflowIconUIDs[icon.UID] if not isOverflow then if joinOverflow and not icon.parentIconUID then icon:joinMenu(overflowIcon) elseif not joinOverflow and icon.parentIconUID then icon:leave() end end end -- Hide the overflows when not in use if overflowIcon.isEnabled ~= joinOverflow then overflowIcon:setEnabled(joinOverflow) end -- Have the menus auto selected if overflowIcon.isEnabled and not overflowIcon.overflowAlreadyOpened then overflowIcon.overflowAlreadyOpened = true overflowIcon:select() end end return Overflow ================================================ FILE: src/Features/Themes/Classic.lua ================================================ -- This is to provide backwards compatability with the old Roblox -- topbar while experiences transition over to the new topbar -- You don't need to apply this yourself, topbarplus automatically -- applies it if the old roblox topbar is detected return { {"Selection", "Size", UDim2.new(1, -6, 1, -5)}, {"Selection", "Position", UDim2.new(0, 3, 0, 3)}, {"Widget", "MinimumWidth", 32, "Deselected"}, {"Widget", "MinimumHeight", 32, "Deselected"}, {"Widget", "BorderSize", 0, "Deselected"}, {"IconCorners", "CornerRadius", UDim.new(0, 9), "Deselected"}, {"IconButton", "BackgroundTransparency", 0.5, "Deselected"}, {"IconLabel", "TextSize", 14, "Deselected"}, {"Dropdown", "BackgroundTransparency", 0.5, "Deselected"}, {"Notice", "Position", UDim2.new(1, -12, 0, -3), "Deselected"}, {"Notice", "Size", UDim2.new(0, 15, 0, 15), "Deselected"}, {"NoticeLabel", "TextSize", 11, "Deselected"}, {"IconSpot", "BackgroundColor3", Color3.fromRGB(0, 0, 0), "Selected"}, {"IconSpot", "BackgroundTransparency", 0.702, "Selected"}, {"IconSpotGradient", "Enabled", false, "Selected"}, {"IconOverlay", "BackgroundTransparency", 0.97, "Selected"}, } ================================================ FILE: src/Features/Themes/Default.lua ================================================ -- Themes in v3 work simply by applying the value (agument[3]) -- to the property (agument[2]) of an instance within the icon which -- matches the name of argument[1]. Argument[1] can also be used to -- specify a collection of instances with a corresponding 'collective' -- value. A colletive is simply an attribute applied to some instances -- within the icon to group them together (such as "IconCorners"). -- If the property (argument[2]) does not exist within the instance, -- it will instead be applied as an attribute on the instance: -- (i.e. ``instance:SetAttribute(argument[2], [argument[3])``) -- Use argument[4] to specify a state: "Deselected", "Selected" -- or "Viewing". If argument[4] is empty the state will default -- to "Deselected". -- I've designed themes this way so you have full control over -- the appearance of the widget and its descendants return { -- When no state is specified the modification is applied to *all* states (Deselected, Selected and Viewing) {"IconCorners", "CornerRadius", UDim.new(1, 0)}, {"Selection", "RotationSpeed", 1}, {"Selection", "Size", UDim2.new(1, 0, 1, 1)}, {"Selection", "Position", UDim2.new(0, 0, 0, 0)}, {"SelectionGradient", "Color", ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 255, 255)), ColorSequenceKeypoint.new(1, Color3.fromRGB(86, 86, 86)), })}, -- When the icon is deselected {"IconImage", "Image", "", "Deselected"}, {"IconLabel", "Text", "", "Deselected"}, {"IconLabel", "Position", UDim2.fromOffset(0, 0), "Deselected"}, -- 0, -1 {"Widget", "DesiredWidth", 44, "Deselected"}, {"Widget", "MinimumWidth", 44, "Deselected"}, {"Widget", "MinimumHeight", 44, "Deselected"}, {"Widget", "BorderSize", 4, "Deselected"}, {"IconButton", "BackgroundColor3", Color3.fromRGB(18, 18, 21), "Deselected"}, {"IconButton", "BackgroundTransparency", 0.08, "Deselected"}, {"IconImageScale", "Value", 0.5, "Deselected"}, {"IconImageCorner", "CornerRadius", UDim.new(0, 0), "Deselected"}, {"IconImage", "ImageColor3", Color3.fromRGB(255, 255, 255), "Deselected"}, {"IconImage", "ImageTransparency", 0, "Deselected"}, {"IconImageRatio", "AspectRatio", 1, "Deselected"}, {"IconLabel", "FontFace", Font.new("rbxasset://fonts/families/BuilderSans.json", Enum.FontWeight.Bold, Enum.FontStyle.Normal), "Deselected"}, {"IconLabel", "TextSize", 16, "Deselected"}, {"IconSpot", "BackgroundTransparency", 1, "Deselected"}, {"IconOverlay", "BackgroundTransparency", 0.85, "Deselected"}, {"IconSpotGradient", "Enabled", false, "Deselected"}, {"IconGradient", "Enabled", false, "Deselected"}, {"ClickRegion", "Active", true, "Deselected"}, -- This is set to false within scrollers to ensure scroller can be dragged on mobile {"Menu", "Active", false, "Deselected"}, {"ContentsList", "HorizontalAlignment", Enum.HorizontalAlignment.Center, "Deselected"}, {"Dropdown", "BackgroundColor3", Color3.fromRGB(18, 18, 21), "Deselected"}, {"Dropdown", "BackgroundTransparency", 0.08, "Deselected"}, {"Dropdown", "MaxIcons", 4.5, "Deselected"}, {"Menu", "MaxIcons", 4, "Deselected"}, {"Notice", "Position", UDim2.new(1, -12, 0, -1), "Deselected"}, {"Notice", "Size", UDim2.new(0, 20, 0, 20), "Deselected"}, {"NoticeLabel", "TextSize", 13, "Deselected"}, {"PaddingLeft", "Size", UDim2.new(0, 9, 1, 0), "Deselected"}, {"PaddingRight", "Size", UDim2.new(0, 11, 1, 0), "Deselected"}, -- When the icon is selected -- Selected also inherits everything from Deselected if nothing is set {"IconSpot", "BackgroundTransparency", 0.7, "Selected"}, {"IconSpot", "BackgroundColor3", Color3.fromRGB(255, 255, 255), "Selected"}, {"IconSpotGradient", "Enabled", true, "Selected"}, {"IconSpotGradient", "Rotation", 45, "Selected"}, {"IconSpotGradient", "Color", ColorSequence.new(Color3.fromRGB(96, 98, 100), Color3.fromRGB(77, 78, 80)), "Selected"}, -- When a cursor is hovering above, a controller highlighting, or touchpad (mobile) pressing (but not released) --{"IconSpot", "BackgroundTransparency", 0.75, "Viewing"}, } ================================================ FILE: src/Features/Themes/init.lua ================================================ -- The functions here are dedicated solely to managing theme state -- and updating the appearance of instances to match that state. -- You don't need to use any of these functions, the useful ones -- have been abstracted as icon methods -- LOCAL local Themes = {} local Utility = require(script.Parent.Parent.Utility) local baseTheme = require(script.Default) -- FUNCTIONS function Themes.getThemeValue(stateGroup, instanceName, property, iconState) if stateGroup then for _, detail in pairs(stateGroup) do local checkingInstanceName, checkingPropertyName, checkingValue = unpack(detail) if instanceName == checkingInstanceName and property == checkingPropertyName then return checkingValue end end end return nil end function Themes.getInstanceValue(instance, property) local success, value = pcall(function() return instance[property] end) if not success then value = instance:GetAttribute(property) end return value end function Themes.getRealInstance(instance) if not instance:GetAttribute("IsAClippedClone") then return end local originalInstance = instance:FindFirstChild("OriginalInstance") if not originalInstance then return end return originalInstance.Value end function Themes.getClippedClone(instance) if not instance:GetAttribute("HasAClippedClone") then return end local clippedClone = instance:FindFirstChild("ClippedClone") if not clippedClone then return end return clippedClone.Value end function Themes.refresh(icon, instance, specificProperty) -- Some instances such as notices need immediate refreshing upon creation as -- they're added in after the initial refresh period if specificProperty then local stateGroup = icon:getStateGroup() local value = Themes.getThemeValue(stateGroup, instance.Name, specificProperty) or Themes.getInstanceValue(instance, specificProperty) Themes.apply(icon, instance, specificProperty, value, true) return end -- If no property is specified we update all properties that exist within -- the applied theme appearance local stateGroup = icon:getStateGroup() if not stateGroup then return end local validInstances = {[instance.Name] = instance} for _, child in pairs(instance:GetDescendants()) do local collective = child:GetAttribute("Collective") if collective then validInstances[collective] = child end validInstances[child.Name] = child end for _, detail in pairs(stateGroup) do local checkingInstanceName, checkingPropertyName, checkingValue = unpack(detail) local instanceToUpdate = validInstances[checkingInstanceName] if instanceToUpdate then Themes.apply(icon, instanceToUpdate.Name, checkingPropertyName, checkingValue, true) end end return end function Themes.apply(icon, collectiveOrInstanceNameOrInstance, property, value, forceApply) -- This is responsible for **applying** appearance changes to instances within the icon -- however it IS NOT responsible for updating themes. Use :modifyTheme for that. -- This also calls callbacks given by :setBehaviour before applying these property changes -- to the given instances if icon.isDestroyed then return end local instances local collectiveOrInstanceName = collectiveOrInstanceNameOrInstance if typeof(collectiveOrInstanceNameOrInstance) == "Instance" then instances = {collectiveOrInstanceNameOrInstance} collectiveOrInstanceName = collectiveOrInstanceNameOrInstance.Name else instances = icon:getInstanceOrCollective(collectiveOrInstanceNameOrInstance) end local key = collectiveOrInstanceName.."-"..property local customBehaviour = icon.customBehaviours[key] for _, instance in pairs(instances) do local clippedClone = Themes.getClippedClone(instance) if clippedClone then -- This means theme effects are applied to both the original -- instance and its clone (instead of just the instance). -- This is important for some properties such as position -- and size which might be dictated by the clone table.insert(instances, clippedClone) end end for _, instance in pairs(instances) do if property == "Position" and Themes.getClippedClone(instance) then -- The clone manages the position of the real instance so ignore continue elseif property == "Size" and Themes.getRealInstance(instance) then -- The real instance manages the size of the clone so ignore continue end local currentValue = Themes.getInstanceValue(instance, property) if not forceApply and value == currentValue then continue end if customBehaviour then local newValue = customBehaviour(value, instance, property) if newValue ~= nil then value = newValue end end local success = pcall(function() instance[property] = value end) if not success then -- If property is not a real property, we set -- the value as an attribute instead. This is useful -- for instance in :setWidth where we also want to -- specify a desired width for every state which can -- then be easily read by the widget element instance:SetAttribute(property, value) end end end function Themes.getModifications(modifications) if typeof(modifications[1]) ~= "table" then -- This enables users to do :modifyTheme({a,b,c,d}) -- in addition of :modifyTheme({{a,b,c,d}}) modifications = {modifications} end return modifications end function Themes.merge(detail, modification, callback) local instanceName, property, value, stateName = table.unpack(modification) local checkingInstanceName, checkingPropertyName, _, checkingStateName = table.unpack(detail) if instanceName == checkingInstanceName and property == checkingPropertyName and Themes.statesMatch(stateName, checkingStateName) then detail[3] = value if callback then callback(detail) end return true end return false end function Themes.modify(icon, modifications, modificationsUID) -- This is what the 'old set' used to do (although for clarity that behaviour has now been -- split into two methods, .modifyTheme and .apply). -- modifyTheme is responsible for UPDATING the internal values within a theme for a particular -- state, then checking to see if the appearance of the icon needs to be updated. -- If no iconState is specified, the change is applied to both Deselected and Selected -- A modification can also be 'undone' using :removeModification and passing in -- the UID returned from this method task.spawn(function() modificationsUID = modificationsUID or Utility.generateUID() modifications = Themes.getModifications(modifications) for _, modification in pairs(modifications) do local instanceName, property, value, iconState = table.unpack(modification) if iconState == nil then -- If no state specified, apply to all states Themes.modify(icon, {instanceName, property, value, "Selected"}, modificationsUID) Themes.modify(icon, {instanceName, property, value, "Viewing"}, modificationsUID) end local chosenState = Utility.formatStateName(iconState or "Deselected") local stateGroup = icon:getStateGroup(chosenState) local function nowSetIt() if chosenState == icon.activeState then Themes.apply(icon, instanceName, property, value) end end local function updateRecord() for stateName, detail in pairs(stateGroup) do local didMerge = Themes.merge(detail, modification, function(detail) detail[5] = modificationsUID nowSetIt() end) if didMerge then return end end local detail = {instanceName, property, value, chosenState, modificationsUID} table.insert(stateGroup, detail) nowSetIt() end updateRecord() end end) return modificationsUID end function Themes.remove(icon, modificationsUID) for iconState, stateGroup in pairs(icon.appearance) do for i = #stateGroup, 1, -1 do local detail = stateGroup[i] local checkingUID = detail[5] if checkingUID == modificationsUID then table.remove(stateGroup, i) end end end Themes.rebuild(icon) end function Themes.removeWith(icon, instanceName, property, state) for iconState, stateGroup in pairs(icon.appearance) do if state == iconState or not state then for i = #stateGroup, 1, -1 do local detail = stateGroup[i] local detailName = detail[1] local detailProperty = detail[2] if detailName == instanceName and detailProperty == property then table.remove(stateGroup, i) end end end end Themes.rebuild(icon) end function Themes.change(icon) -- This changes the theme to the appearance of whatever -- state is currently active local stateGroup = icon:getStateGroup() for _, detail in pairs(stateGroup) do local instanceName, property, value = unpack(detail) Themes.apply(icon, instanceName, property, value) end end function Themes.set(icon, theme) -- This is responsible for processing the final appearance of a given theme (such as -- ensuring Deselected merge into missing Selected, saving that internal state, -- then checking to see if the appearance of the icon needs to be updated local themesJanitor = icon.themesJanitor themesJanitor:clean() themesJanitor:add(icon.stateChanged:Connect(function() Themes.change(icon) end)) if typeof(theme) == "Instance" and theme:IsA("ModuleScript") then theme = require(theme) end icon.appliedTheme = theme Themes.rebuild(icon) end function Themes.statesMatch(state1, state2) -- States match if they have the same name OR if nil (because unspecified represents all states) local state1lower = (state1 and string.lower(state1)) local state2lower = (state2 and string.lower(state2)) return state1lower == state2lower or not state1 or not state2 end function Themes.rebuild(icon) -- A note for my future self: this code can be optimised further by -- converting appearance into a instanceName-property dictionary -- as apposed to an array of every potential change. When converting -- in the future, .modify and .apply would also have to be updated. local appliedTheme = icon.appliedTheme local statesArray = {"Deselected", "Selected", "Viewing"} local function generateTheme() for _, stateName in pairs(statesArray) do -- This applies themes in layers -- The last layers take higher priority as they overwrite -- any duplicate earlier applied effects local stateAppearance = {} local function updateDetails(theme, incomingStateName) -- This ensures there's always a base 'default' layer if not theme then return end for _, detail in pairs(theme) do local modificationsUID = detail[5] local detailStateName = detail[4] if Themes.statesMatch(incomingStateName, detailStateName) then local key = detail[1].."-"..detail[2] local newDetail = Utility.copyTable(detail) newDetail[5] = modificationsUID stateAppearance[key] = newDetail end end end -- First we apply the base theme (i.e. the Default module) if stateName == "Selected" then updateDetails(baseTheme, "Deselected") end updateDetails(baseTheme, "Empty") updateDetails(baseTheme, stateName) -- Next we apply any custom themes by the games developer if appliedTheme ~= baseTheme then if stateName == "Selected" then updateDetails(appliedTheme, "Deselected") end updateDetails(baseTheme, "Empty") updateDetails(appliedTheme, stateName) end -- Finally we apply any modifications that have already been made -- Modifiers are all the changes made using icon:modifyTheme(...) local alreadyAppliedTheme = {} local alreadyAppliedGroup = icon.appearance[stateName] if alreadyAppliedGroup then for _, modifier in pairs(alreadyAppliedGroup) do local modificationsUID = modifier[5] if modificationsUID ~= nil then local modification = {modifier[1], modifier[2], modifier[3], stateName, modificationsUID} table.insert(alreadyAppliedTheme, modification) end end end updateDetails(alreadyAppliedTheme, stateName) -- This now converts it into our final appearance local finalStateAppearance = {} for _, detail in pairs(stateAppearance) do table.insert(finalStateAppearance, detail) end icon.appearance[stateName] = finalStateAppearance end Themes.change(icon) end generateTheme() end return Themes ================================================ FILE: src/Packages/GoodSignal.lua ================================================ -------------------------------------------------------------------------------- -- Batched Yield-Safe Signal Implementation -- -- This is a Signal class which has effectively identical behavior to a -- -- normal RBXScriptSignal, with the only difference being a couple extra -- -- stack frames at the bottom of the stack trace when an error is thrown. -- -- This implementation caches runner coroutines, so the ability to yield in -- -- the signal handlers comes at minimal extra cost over a naive signal -- -- implementation that either always or never spawns a thread. -- -- -- -- API: -- -- local Signal = require(THIS MODULE) -- -- local sig = Signal.new() -- -- local connection = sig:Connect(function(arg1, arg2, ...) ... end) -- -- sig:Fire(arg1, arg2, ...) -- -- connection:Disconnect() -- -- sig:DisconnectAll() -- -- local arg1, arg2, ... = sig:Wait() -- -- -- -- Licence: -- -- Licenced under the MIT licence. -- -- -- -- Authors: -- -- stravant - July 31st, 2021 - Created the file. -- -------------------------------------------------------------------------------- -- The currently idle thread to run the next handler on local freeRunnerThread = nil -- Function which acquires the currently idle handler runner thread, runs the -- function fn on it, and then releases the thread, returning it to being the -- currently idle one. -- If there was a currently idle runner thread already, that's okay, that old -- one will just get thrown and eventually GCed. local function acquireRunnerThreadAndCallEventHandler(fn, ...) local acquiredRunnerThread = freeRunnerThread freeRunnerThread = nil fn(...) -- The handler finished running, this runner thread is free again. freeRunnerThread = acquiredRunnerThread end -- Coroutine runner that we create coroutines of. The coroutine can be -- repeatedly resumed with functions to run followed by the argument to run -- them with. local function runEventHandlerInFreeThread() -- Note: We cannot use the initial set of arguments passed to -- runEventHandlerInFreeThread for a call to the handler, because those -- arguments would stay on the stack for the duration of the thread's -- existence, temporarily leaking references. Without access to raw bytecode -- there's no way for us to clear the "..." references from the stack. while true do acquireRunnerThreadAndCallEventHandler(coroutine.yield()) end end -- Connection class local Connection = {} Connection.__index = Connection function Connection.new(signal, fn) return setmetatable({ _connected = true, _signal = signal, _fn = fn, _next = false, }, Connection) end function Connection:Disconnect() self._connected = false -- Unhook the node, but DON'T clear it. That way any fire calls that are -- currently sitting on this node will be able to iterate forwards off of -- it, but any subsequent fire calls will not hit it, and it will be GCed -- when no more fire calls are sitting on it. if self._signal._handlerListHead == self then self._signal._handlerListHead = self._next else local prev = self._signal._handlerListHead while prev and prev._next ~= self do prev = prev._next end if prev then prev._next = self._next end end end Connection.Destroy = Connection.Disconnect -- Make Connection strict setmetatable(Connection, { __index = function(tb, key) error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) end, __newindex = function(tb, key, value) error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) end }) -- Signal class local Signal = {} Signal.__index = Signal function Signal.new() return setmetatable({ _handlerListHead = false, }, Signal) end function Signal:Connect(fn) local connection = Connection.new(self, fn) if self._handlerListHead then connection._next = self._handlerListHead self._handlerListHead = connection else self._handlerListHead = connection end return connection end -- Disconnect all handlers. Since we use a linked list it suffices to clear the -- reference to the head handler. function Signal:DisconnectAll() self._handlerListHead = false end Signal.Destroy = Signal.DisconnectAll -- Signal:Fire(...) implemented by running the handler functions on the -- coRunnerThread, and any time the resulting thread yielded without returning -- to us, that means that it yielded to the Roblox scheduler and has been taken -- over by Roblox scheduling, meaning we have to make a new coroutine runner. function Signal:Fire(...) local item = self._handlerListHead while item do if item._connected then if not freeRunnerThread then freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) -- Get the freeRunnerThread to the first yield coroutine.resume(freeRunnerThread) end task.spawn(freeRunnerThread, item._fn, ...) end item = item._next end end -- Implement Signal:Wait() in terms of a temporary connection using -- a Signal:Connect() which disconnects itself. function Signal:Wait() local waitingCoroutine = coroutine.running() local cn; cn = self:Connect(function(...) cn:Disconnect() task.spawn(waitingCoroutine, ...) end) return coroutine.yield() end -- Implement Signal:Once() in terms of a connection which disconnects -- itself before running the handler. function Signal:Once(fn) local cn; cn = self:Connect(function(...) if cn._connected then cn:Disconnect() end fn(...) end) return cn end -- Make signal strict setmetatable(Signal, { __index = function(tb, key) error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) end, __newindex = function(tb, key, value) error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) end }) return Signal ================================================ FILE: src/Packages/Janitor.lua ================================================ --[[ ------------------------------------- This package was modified by ForeverHD. PACKAGE MODIFICATIONS: 1. Added pascalCase aliases for all methods 2. Modified behaviour of :add so that it takes both objects and promises (previously only objects) 3. Slight change to how promises are tracked 4. Added isAnInstanceBeingDestroyed check to line 228 5. Added 'OriginalTraceback' to help determine where an error was added to the janitor 6. Likely some additional changes which weren't record here 7. Removed comments as these were detected by Moonwave ------------------------------------- --]] -- Janitor -- Original by Validark -- Modifications by pobammer -- roblox-ts support by OverHash and Validark -- LinkToInstance fixed by Elttob. local RunService = game:GetService("RunService") local Heartbeat = RunService.Heartbeat local function getPromiseReference() return false end local IndicesReference = newproxy(true) getmetatable(IndicesReference).__tostring = function() return "IndicesReference" end local LinkToInstanceIndex = newproxy(true) getmetatable(LinkToInstanceIndex).__tostring = function() return "LinkToInstanceIndex" end local METHOD_NOT_FOUND_ERROR = "Object %s doesn't have method %s, are you sure you want to add it? Traceback: %s" local NOT_A_PROMISE = "Invalid argument #1 to 'Janitor:AddPromise' (Promise expected, got %s (%s))" local Janitor = { IGNORE_MEMORY_DEBUG = true, ClassName = "Janitor"; __index = { CurrentlyCleaning = true; [IndicesReference] = nil; }; } local TypeDefaults = { ["function"] = true; ["Promise"] = "cancel"; RBXScriptConnection = "Disconnect"; } function Janitor.new() return setmetatable({ CurrentlyCleaning = false; [IndicesReference] = nil; }, Janitor) end function Janitor.Is(Object) return type(Object) == "table" and getmetatable(Object) == Janitor end Janitor.is = Janitor.Is function Janitor.__index:Add(Object, MethodName, Index) if Index then self:Remove(Index) local This = self[IndicesReference] if not This then This = {} self[IndicesReference] = This end This[Index] = Object end local objectType = typeof(Object) if objectType == "table" and string.match(tostring(Object), "Promise") then objectType = "Promise" --local status = Object:getStatus() --print("status =", status, status == "Rejected") end MethodName = MethodName or TypeDefaults[objectType] or "Destroy" if type(Object) ~= "function" and not Object[MethodName] then warn(string.format(METHOD_NOT_FOUND_ERROR, tostring(Object), tostring(MethodName), debug.traceback(nil :: any, 2))) end local OriginalTraceback = debug.traceback("") self[Object] = {MethodName, OriginalTraceback} return Object end Janitor.__index.Give = Janitor.__index.Add -- My version of Promise has PascalCase, but I converted it to use lowerCamelCase for this release since obviously that's important to do. function Janitor.__index:AddPromise(PromiseObject) local Promise = getPromiseReference() if Promise then if not Promise.is(PromiseObject) then error(string.format(NOT_A_PROMISE, typeof(PromiseObject), tostring(PromiseObject))) end if PromiseObject:getStatus() == Promise.Status.Started then local Id = newproxy(false) local NewPromise = self:Add(Promise.new(function(Resolve, _, OnCancel) if OnCancel(function() PromiseObject:cancel() end) then return end Resolve(PromiseObject) end), "cancel", Id) NewPromise:finallyCall(self.Remove, self, Id) return NewPromise else return PromiseObject end else return PromiseObject end end Janitor.__index.GivePromise = Janitor.__index.AddPromise -- This will assume whether or not the object is a Promise or a regular object. function Janitor.__index:AddObject(Object) local Id = newproxy(false) local Promise = getPromiseReference() if Promise and Promise.is(Object) then if Object:getStatus() == Promise.Status.Started then local NewPromise = self:Add(Promise.resolve(Object), "cancel", Id) NewPromise:finallyCall(self.Remove, self, Id) return NewPromise, Id else return Object end else return self:Add(Object, false, Id), Id end end Janitor.__index.GiveObject = Janitor.__index.AddObject function Janitor.__index:Remove(Index) local This = self[IndicesReference] if This then local Object = This[Index] if Object then local ObjectDetail = self[Object] local MethodName = ObjectDetail and ObjectDetail[1] if MethodName then if MethodName == true then Object() else local ObjectMethod = Object[MethodName] if ObjectMethod then ObjectMethod(Object) end end self[Object] = nil end This[Index] = nil end end return self end function Janitor.__index:Get(Index) local This = self[IndicesReference] if This then return This[Index] end return nil end function Janitor.__index:Cleanup() if not self.CurrentlyCleaning then self.CurrentlyCleaning = nil for Object, ObjectDetail in next, self do if Object == IndicesReference then continue end -- Weird decision to rawset directly to the janitor in Agent. This should protect against it though. local TypeOf = type(Object) if TypeOf == "string" or TypeOf == "number" then self[Object] = nil continue end local MethodName = ObjectDetail[1] local OriginalTraceback = ObjectDetail[2] local function warnUser(warning) local cleanupLine = debug.traceback("", 3)--string.gsub(debug.traceback("", 3), "%c", "") local addedLine = OriginalTraceback warn("-------- Janitor Error --------".."\n"..tostring(warning).."\n"..cleanupLine..""..addedLine) end if MethodName == true then local success, warning = pcall(Object) if not success then warnUser(warning) end else local ObjectMethod = Object[MethodName] if ObjectMethod then local success, warning = pcall(ObjectMethod, Object) local isAnInstanceBeingDestroyed = typeof(Object) == "Instance" and ObjectMethod == "Destroy" if not success and not isAnInstanceBeingDestroyed then warnUser(warning) end end end self[Object] = nil end local This = self[IndicesReference] if This then for Index in next, This do This[Index] = nil end self[IndicesReference] = {} end self.CurrentlyCleaning = false end end Janitor.__index.Clean = Janitor.__index.Cleanup function Janitor.__index:Destroy() self:Cleanup() --table.clear(self) --setmetatable(self, nil) end Janitor.__call = Janitor.__index.Cleanup local Disconnect = {Connected = true} Disconnect.__index = Disconnect function Disconnect:Disconnect() if self.Connected then self.Connected = false self.Connection:Disconnect() end end function Disconnect:__tostring() return "Disconnect<" .. tostring(self.Connected) .. ">" end function Janitor.__index:LinkToInstance(Object, AllowMultiple) local Connection local IndexToUse = AllowMultiple and newproxy(false) or LinkToInstanceIndex local IsNilParented = Object.Parent == nil local ManualDisconnect = setmetatable({}, Disconnect) local function ChangedFunction(_DoNotUse, NewParent) if ManualDisconnect.Connected then _DoNotUse = nil IsNilParented = NewParent == nil if IsNilParented then coroutine.wrap(function() Heartbeat:Wait() if not ManualDisconnect.Connected then return elseif not Connection.Connected then self:Cleanup() else while IsNilParented and Connection.Connected and ManualDisconnect.Connected do Heartbeat:Wait() end if ManualDisconnect.Connected and IsNilParented then self:Cleanup() end end end)() end end end Connection = Object.AncestryChanged:Connect(ChangedFunction) ManualDisconnect.Connection = Connection if IsNilParented then ChangedFunction(nil, Object.Parent) end Object = nil return self:Add(ManualDisconnect, "Disconnect", IndexToUse) end function Janitor.__index:LinkToInstances(...) local ManualCleanup = Janitor.new() for _, Object in ipairs({...}) do ManualCleanup:Add(self:LinkToInstance(Object, true), "Disconnect") end return ManualCleanup end for FunctionName, Function in next, Janitor.__index do local NewFunctionName = string.sub(string.lower(FunctionName), 1, 1) .. string.sub(FunctionName, 2) Janitor.__index[NewFunctionName] = Function end return Janitor ================================================ FILE: src/Reference.lua ================================================ -- This module enables you to place Icon wherever you like within the data model while -- still enabling third-party applications (such as HDAdmin/Nanoblox) to locate it -- This is necessary to prevent two TopbarPlus applications initiating at runtime which would -- cause icons to overlap with each other local replicatedStorage = game:GetService("ReplicatedStorage") local Reference = {} Reference.objectName = "TopbarPlusReference" function Reference.addToReplicatedStorage() local existingItem = replicatedStorage:FindFirstChild(Reference.objectName) if existingItem then return false end local objectValue = Instance.new("ObjectValue") objectValue.Name = Reference.objectName objectValue.Value = script.Parent objectValue.Parent = replicatedStorage return objectValue end function Reference.getObject() local objectValue = replicatedStorage:FindFirstChild(Reference.objectName) if objectValue then return objectValue end return false end return Reference ================================================ FILE: src/Types.lua ================================================ --!strict -- GoodSignal Types (...but simpler!) --- Connection type Connectionfalse all TopbarPlus ScreenGuis are hidden.
This does not impact Roblox's Topbar.
]]
function(enabled: boolean)
end
),
modifyBaseTheme: typeof(
--[[
Updates the appearance of all icons.
]]
function(modifications: { Modification })
end
),
setDisplayOrder: typeof(
--[[
Sets the base DisplayOrder of all TopbarPlus ScreenGuis.
]]
function(order: number)
end
),
}
type Methods = {
-- CLASS FUNCTIONS
setName: typeof(
--[[
Sets the name of the Widget instance. This can be used in conjunction with Icon.getIcon(name)
]]
function(self: Icon, name: string): Icon
return nil :: any
end
),
getInstance: typeof(
--[[
Returns the first descendant found within the widget of name instanceName.
]]
function(self: Icon, instanceName: string): Instance?
return (nil :: any) :: Instance?
end
),
modifyTheme: typeof(
--[[
Updates the appearance of the icon.
]]
function(self: Icon, modifications: {Modification} | Modification): Icon
return nil :: any
end
),
modifyChildTheme: typeof(
--[[
Updates the appearance of all icons that are parented to this icon (for example when a menu or dropdown).
]]
function(self: Icon, modifications: { Modification }): Icon
return nil :: any
end
),
setEnabled: typeof(
--[[
When set to false the icon will be disabled and hidden.
]]
function(self: Icon, enabled: boolean): Icon
return nil :: any
end
),
select: typeof(
--[[
Selects the icon (as if it were clicked once).
]]
function(self: Icon): Icon
return nil :: any
end
),
deselect: typeof(
--[[
Deselects the icon (as if it were clicked, then clicked again).
]]
function(self: Icon): Icon
return nil :: any
end
),
notify: typeof(
--[[
Prompts a notice bubble which accumulates the further it is prompted.
If the icon belongs to a dropdown or menu, then the notice will appear on the parent icon when the parent icon is deselected.
]]
function(self: Icon, clearNoticeEvent: Signal?): Icon
return nil :: any
end
),
clearNotices: typeof(
--[[
]]
function(self: Icon): Icon
return nil :: any
end
),
disableOverlay: typeof(
--[[
When set to true, disables the shade effect which appears when the icon is pressed and released.
]]
function(self: Icon, disabled: boolean): Icon
return nil :: any
end
),
setImage: typeof(
--[[
Applies an image to the icon based on the given imageId. imageId can be an assetId or a complete asset string.
]]
function(self: Icon, imageId: string | number, iconState: IconState?): Icon
return nil :: any
end
),
setLabel: typeof(
--[[
]]
function(self: Icon, text: string, iconState: IconState?): Icon
return nil :: any
end
),
setOrder: typeof(
--[[
]]
function(self: Icon, order: number, iconState: IconState?): Icon
return nil :: any
end
),
setCornerRadius: typeof(
--[[
]]
function(self: Icon, udim: UDim2, iconState: IconState?): Icon
return nil :: any
end
),
align: typeof(
--[[
This enables you to set the icon to the "Left" (default), "Center" or "Right" side of the screen.
]]
function(self: Icon, alignment: Alignment?): Icon
return nil :: any
end
),
setWidth: typeof(
--[[
This sets the minimum width the icon can be (it can be larger for instance when setting a long label). The default width is 44.
]]
function(self: Icon, minimumSize: number, iconState: IconState?): Icon
return nil :: any
end
),
setImageScale: typeof(
--[[
How large the image is relative to the icon. The default value is 0.5.
]]
function(self: Icon, scale: number, iconState: IconState?): Icon
return nil :: any
end
),
setImageRatio: typeof(
--[[
How stretched the image will appear. The default value is 1 (a perfect square).
]]
function(self: Icon, ratio: number, iconState: IconState?): Icon
return nil :: any
end
),
setTextSize: typeof(
--[[
The size of the icon labels' text. The default value is 16.
]]
function(self: Icon, textSize: number, iconState: IconState?): Icon
return nil :: any
end
),
setTextColor: typeof(
--[[
The color of the icon labels' text
]]
function(self: Icon, color: Color3, iconState: IconState?): Icon
return nil :: any
end
),
setTextFont: typeof(
--[[
Sets the labels FontFace.
font can be a font family name (such as "Creepster"),
a font enum (such as Enum.Font.Bangers),
a font ID (such as 12187370928),
or font family link (such as "rbxasset://fonts/families/Sarpanch.json").
]]
function(self: Icon, font: string | Enum.Font, fontWeight: Enum.FontWeight?, fontStyle: Enum.FontSize?, iconState: IconState?): Icon
return nil :: any
end
),
bindToggleItem: typeof(
--[[
Binds a GuiObject or LayerCollector to appear and disappeared when the icon is toggled.
]]
function(self: Icon, guiObjectOrLayerCollector: GuiObject | LayerCollector): Icon
return nil :: any
end
),
unbindToggleItem: typeof(
--[[
Unbinds the given GuiObject or LayerCollector from the toggle.
]]
function(self: Icon, guiObjectOrLayerCollector: GuiObject | LayerCollector): Icon
return nil :: any
end
),
bindEvent: typeof(
--[[
Connects to an icon event with iconEventName.
It's important to remember all event names are in camelCase.
callback is called with arguments (self, ...) when the event is triggered.
]]
function(self: Icon, event: Events, callback: (...any) -> ()): Icon
return nil :: any
end
),
unbindEvent: typeof(
--[[
Unbinds the connection of the associated iconEventName.
]]
function(self: Icon, event: Events): Icon
return nil :: any
end
),
bindToggleKey: typeof(
--[[
Binds a keycode which toggles the icon when pressed.
]]
function(self: Icon, keycode: Enum.KeyCode): Icon
return nil :: any
end
),
unbindToggleKey: typeof(
--[[
Unbinds the given keycode.
]]
function(self: Icon, keycode: Enum.KeyCode): Icon
return nil :: any
end
),
call: typeof(
--[[
Calls the function immediately via task.spawn.
The first argument passed is the icon itself.
This is useful when needing to extend the behaviour of an icon while remaining in the chain.
]]
function(self: Icon, func: (self: Icon) -> (...any), ...: any): Icon
return nil :: any
end
),
addToJanitor: typeof(
--[[
Passes the given userdata to the icons janitor to be destroyed/disconnected on the icons destruction.
If a function is passed, it will be called when the icon is destroyed.
]]
function(self: Icon, userdata: unknown): Icon
return nil :: any
end
),
lock: typeof(
--[[
Prevents the icon being toggled by user-input (such as clicking), however, the icon can still be toggled via localscript using methods such as icon:select().
]]
function(self: Icon): Icon
return nil :: any
end
),
unlock: typeof(
--[[
Re-enables user-input to toggle the icon again.
]]
function(self: Icon): Icon
return nil :: any
end
),
debounce: typeof(
--[[
Locks the icon, yields for the given time, then unlocks the icon, effectively shorthand for icon:lock() task.wait(seconds) icon:unlock().
This is useful for applying cooldowns (to prevent an icon from being pressed again) after an icon has been selected or deselected.
]]
function(self: Icon, seconds: number): Icon
return nil :: any
end
),
autoDeselect: typeof(
--[[
When set to true (the default) the icon is deselected when another icon (with autoDeselect enabled) is pressed.
Set to false to prevent the icon being deselected when another icon is selected (a useful behaviour in dropdowns).
]]
function(self: Icon, enabled: boolean?): Icon
return nil :: any
end
),
oneClick: typeof(
--[[
When set to true the icon will automatically deselect when selected.
This creates the effect of a single click button.
]]
function(self: Icon, enabled: boolean?): Icon
return nil :: any
end
),
setCaption: typeof(
--[[
Sets a caption. To remove, pass nil as text.
]]
function(self: Icon, text: string?): Icon
return nil :: any
end
),
setCaptionHint: typeof(
--[[
This customizes the appearance of the caption's hint without having to use icon:bindToggleKey.
]]
function(self: Icon, keyCode: Enum.KeyCode): Icon
return nil :: any
end
),
setDropdown: typeof(
--[[
Creates a vertical dropdown based upon the given table array of icons.
Pass an empty table {} to remove the dropdown.
]]
function(self: Icon, icons: { Icon }): Icon
return nil :: any
end
),
joinDropdown: typeof(
--[[
Joins the dropdown of parentIcon.
This is what icon:setDropdown calls internally on the icons within its array.
]]
function(self: Icon, parent: Icon): Icon
return nil :: any
end
),
setMenu: typeof(
--[[
Creates a horizontal menu based upon the given array of icons.
Pass an empty table {} to remove the menu.
]]
function(self: Icon, icons: { Icon }): Icon
return nil :: any
end
),
setFixedMenu: typeof(
--[[
Creates a menu that is always selected and has it's close button hidden.
Pass an empty table {} to remove the menu.
]]
function(self: Icon, icons: { Icon }): Icon
return nil :: any
end
),
joinMenu: typeof(
--[[
Joins the menu of parentIcon.
This is what icon:setMenu calls internally on the icons within its array.
]]
function(self: Icon, parentIcon: Icon): Icon
return nil :: any
end
),
leave: typeof(
--[[
Unparents an icon from a parentIcon if it belongs to a dropdown or menu.
]]
function(self: Icon): Icon
return nil :: any
end
),
convertLabelToNumberSpinner: typeof(
--[[
Unparents an icon from a parentIcon if it belongs to a dropdown or menu.
]]
function(self: Icon, numberSpinner: any, func: (...any) -> (...any), ...: any): Icon
return nil :: any
end
),
destroy: typeof(
--[[
Clears all connections and destroys all instances associated with the icon.
]]
function(self: Icon): Icon
return nil :: any
end
),
} & StaticFunctions
type Fields = {
-- CLASS PROPERTIES
name: string,
isSelected: boolean,
isEnabled: boolean,
totalNotices: number,
locked: boolean,
-- CLASS EVENTS
selected: Signal32x32 icon on the topbar.
]]
function(): Icon
return (nil :: any) :: Icon
end
),
} & StaticFunctions
return {}
================================================
FILE: src/Utility.lua
================================================
-- Just generic utility functions which I use and repeat across all my projects
-- LOCAL
local Utility = {}
local Players = game:GetService("Players")
local localPlayer = Players.LocalPlayer
-- FUNCTIONS
function Utility.createStagger(delayTime, callback, delayInitially)
-- This creates and returns a function which when called
-- acts identically to callback, however will only be called
-- for a maximum of once per delayTime. If the returned function
-- is called more than once during the delayTime, then it will
-- wait until the expiryTime then perform another recall.
-- This is useful for visual interfaces and effects which may be
-- triggered multiple times within a frame or short period, but which
-- we don't necessary need to (for performance reasons).
local staggerActive = false
local multipleCalls = false
if not delayTime or delayTime == 0 then
-- We make 0.01 instead of 0 because devices can now run at
-- different frame rates
delayTime = 0.01
end
local function staggeredCallback(...)
if staggerActive then
multipleCalls = true
return
end
local packedArgs = table.pack(...)
staggerActive = true
multipleCalls = false
task.spawn(function()
if delayInitially then
task.wait(delayTime)
end
callback(table.unpack(packedArgs))
end)
task.delay(delayTime, function()
staggerActive = false
if multipleCalls then
-- This means it has been called at least once during
-- the stagger period, so call again
staggeredCallback(table.unpack(packedArgs))
end
end)
end
return staggeredCallback
end
function Utility.round(n)
-- Credit to Darkmist101 for this
return math.floor(n + 0.5)
end
function Utility.reverseTable(t)
for i = 1, math.floor(#t/2) do
local j = #t - i + 1
t[i], t[j] = t[j], t[i]
end
end
function Utility.copyTable(t)
-- Credit to Stephen Leitnick (September 13, 2017) for this function from TableUtil
assert(type(t) == "table", "First argument must be a table")
local tCopy = table.create(#t)
for k,v in pairs(t) do
if (type(v) == "table") then
tCopy[k] = Utility.copyTable(v)
else
tCopy[k] = v
end
end
return tCopy
end
local validCharacters = {"a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","1","2","3","4","5","6","7","8","9","0","<",">","?","@","{","}","[","]","!","(",")","=","+","~","#"}
function Utility.generateUID(length)
length = length or 8
local UID = ""
local list = validCharacters
local total = #list
for i = 1, length do
local randomCharacter = list[math.random(1, total)]
UID = UID..randomCharacter
end
return UID
end
local instanceTrackers = {}
function Utility.setVisible(instance, bool, sourceUID)
-- This effectively works like a buff object but
-- incredibly simplified. It stacks false values
-- so that if there is more than more than, the
-- instance remains hidden even if set visible true
local tracker = instanceTrackers[instance]
if not tracker then
tracker = {}
instanceTrackers[instance] = tracker
instance.Destroying:Once(function()
instanceTrackers[instance] = nil
end)
end
if not bool then
tracker[sourceUID] = true
else
tracker[sourceUID] = nil
end
local isVisible = bool
if bool then
for sourceUID, _ in pairs(tracker) do
isVisible = false
break
end
end
instance.Visible = isVisible
end
function Utility.formatStateName(incomingStateName)
return string.upper(string.sub(incomingStateName, 1, 1))..string.lower(string.sub(incomingStateName, 2))
end
function Utility.localPlayerRespawned(callback)
-- The client localscript may be located under a ScreenGui with ResetOnSpawn set to true
-- In these scenarios, traditional methods like CharacterAdded won't be called by the
-- time the localscript has been destroyed, therefore we listen for removing instead
-- If humanoid and health == 0, then reset/died normally, else was
-- forcefully reset via a method such as LoadCharacter
-- We wrap this behaviour in case any additional quirks need to be accounted for
localPlayer.CharacterRemoving:Connect(callback)
end
function Utility.getClippedContainer(screenGui)
-- We always want clipped items to display in front hence
-- why we have this
local clippedContainer = screenGui:FindFirstChild("ClippedContainer")
if not clippedContainer then
clippedContainer = Instance.new("Folder")
clippedContainer.Name = "ClippedContainer"
clippedContainer.Parent = screenGui
end
return clippedContainer
end
local Janitor = require(script.Parent.Packages.Janitor)
local GuiService = game:GetService("GuiService")
function Utility.clipOutside(icon, instance)
local cloneJanitor = icon.janitor:add(Janitor.new())
instance.Destroying:Once(function()
cloneJanitor:Destroy()
end)
icon.janitor:add(instance)
local originalParent = instance.Parent
local clone = cloneJanitor:add(Instance.new("Frame"))
clone:SetAttribute("IsAClippedClone", true)
clone.Name = instance.Name
clone.AnchorPoint = instance.AnchorPoint
clone.Size = instance.Size
clone.Position = instance.Position
clone.BackgroundTransparency = 1
clone.LayoutOrder = instance.LayoutOrder
clone.Parent = originalParent
local valueInstance = Instance.new("ObjectValue")
valueInstance.Name = "OriginalInstance"
valueInstance.Value = instance
valueInstance.Parent = clone
local valueInstanceCopy = valueInstance:Clone()
instance:SetAttribute("HasAClippedClone", true)
valueInstanceCopy.Name = "ClippedClone"
valueInstanceCopy.Value = clone
valueInstanceCopy.Parent = instance
local screenGui
local Icon = require(icon.iconModule)
local container = Icon.container
local function updateScreenGui()
local originalScreenGui = originalParent:FindFirstAncestorWhichIsA("ScreenGui")
screenGui = if string.match(originalScreenGui.Name, "Clipped") then originalScreenGui else container[originalScreenGui.Name.."Clipped"]
instance.AnchorPoint = Vector2.new(0, 0)
instance.Parent = Utility.getClippedContainer(screenGui)
end
cloneJanitor:add(icon.alignmentChanged:Connect(updateScreenGui))
updateScreenGui()
-- Lets copy over children that modify size
for _, child in pairs(instance:GetChildren()) do
if child:IsA("UIAspectRatioConstraint") then
child:Clone().Parent = clone
end
end
-- If the icon is hidden, its important we are too (as
-- setting a parent to visible = false no longer makes
-- this hidden)
local widget = icon.widget
local isOutsideParent = false
local ignoreVisibilityUpdater = instance:GetAttribute("IgnoreVisibilityUpdater")
local function updateVisibility()
if ignoreVisibilityUpdater then
return
end
local isVisible = widget.Visible
if isOutsideParent then
isVisible = false
end
Utility.setVisible(instance, isVisible, "ClipHandler")
end
cloneJanitor:add(widget:GetPropertyChangedSignal("Visible"):Connect(updateVisibility))
local previousScroller
local function checkIfOutsideParentXBounds()
-- Defer so that roblox's properties reflect their true values
task.defer(function()
-- If the instance is within a parent item (such as a dropdown or menu)
-- then we hide it if it exceeds the bounds of that parent
local parentInstance
local ourUID = icon.UID
local nextIconUID = ourUID
local shouldClipToParent = instance:GetAttribute("ClipToJoinedParent")
if shouldClipToParent then
for i = 1, 10 do -- This is safer than while true do and should never be > 4 parents
local nextIcon = Icon.getIconByUID(nextIconUID)
if not nextIcon then
break
end
local nextParentInstance = nextIcon.joinedFrame
nextIconUID = nextIcon.parentIconUID
if not nextParentInstance then
break
end
parentInstance = nextParentInstance
if parentInstance and parentInstance.Name == "DropdownScroller" then
break
end
end
end
if not parentInstance then
isOutsideParent = false
updateVisibility()
return
end
local pos = instance.AbsolutePosition
local halfSize = instance.AbsoluteSize/2
local parentPos = parentInstance.AbsolutePosition
local parentSize = parentInstance.AbsoluteSize
local posHalf = (pos + halfSize)
local exceededLeft = posHalf.X < parentPos.X
local exceededRight = posHalf.X > (parentPos.X + parentSize.X)
local exceededTop = posHalf.Y < parentPos.Y
local exceededBottom = posHalf.Y > (parentPos.Y + parentSize.Y)
local hasExceeded = exceededLeft or exceededRight or exceededTop or exceededBottom
if hasExceeded ~= isOutsideParent then
isOutsideParent = hasExceeded
updateVisibility()
end
if parentInstance:IsA("ScrollingFrame") and previousScroller ~= parentInstance then
previousScroller = parentInstance
local connection = parentInstance:GetPropertyChangedSignal("AbsoluteWindowSize"):Connect(function()
checkIfOutsideParentXBounds()
end)
cloneJanitor:add(connection, "Disconnect", "TrackUtilityScroller-"..ourUID)
end
end)
end
local camera = workspace.CurrentCamera
local additionalOffsetX = instance:GetAttribute("AdditionalOffsetX") or 0
local function trackProperty(property)
local absoluteProperty = "Absolute"..property
local function updateProperty()
local cloneValue = clone[absoluteProperty]
local absoluteValue = UDim2.fromOffset(cloneValue.X, cloneValue.Y)
if property == "Position" then
-- This binds the instances within the bounds of the screen
local SIDE_PADDING = 4
local limitX = camera.ViewportSize.X - instance.AbsoluteSize.X - SIDE_PADDING
local inputX = absoluteValue.X.Offset
if inputX < SIDE_PADDING then
inputX = SIDE_PADDING
elseif inputX > limitX then
inputX = limitX
end
absoluteValue = UDim2.fromOffset(inputX, absoluteValue.Y.Offset)
-- AbsolutePosition does not perfectly match with TopbarInsets enabled
-- This corrects this
local topbarInset = GuiService.TopbarInset
local viewportWidth = workspace.CurrentCamera.ViewportSize.X
local guiWidth = screenGui.AbsoluteSize.X
local guiOffset = screenGui.AbsolutePosition.X
--local widthDifference = guiOffset - topbarInset.Min.X
local oldTopbarCenterOffset = 0--widthDifference/30
local offsetX = if Icon.isOldTopbar then guiOffset else viewportWidth - guiWidth - oldTopbarCenterOffset
-- Also add additionalOffset
offsetX -= additionalOffsetX
absoluteValue += UDim2.fromOffset(-offsetX, topbarInset.Height)
-- Finally check if within its direct parents bounds
checkIfOutsideParentXBounds()
end
instance[property] = absoluteValue
end
-- This defer is essential as the listener may be in a different screenGui to the actor
local updatePropertyStaggered = Utility.createStagger(0.01, updateProperty)
cloneJanitor:add(clone:GetPropertyChangedSignal(absoluteProperty):Connect(updatePropertyStaggered))
cloneJanitor:add(clone:GetAttributeChangedSignal("ForceUpdate"):Connect(function()
updatePropertyStaggered()
end))
-- This is to patch a weirddddd bug with ScreenGuis with SreenInsets set to
-- 'TopbarSafeInsets'. For some reason the absolute position of gui instances
-- within this type of screenGui DO NOT accurately update to match their new
-- real world position; instead they jump around almost randomly for a few frames.
-- I have spent way too many hours trying to solve this bug, I think the only way
-- for the time being is to not use ScreenGuis with TopbarSafeInsets, but I don't
-- have time to redesign the entire system around that at the moment.
-- Here's a GIF of this bug: https://i.imgur.com/VitHdC1.gif
local updatePropertyPatch = Utility.createStagger(0.5, updateProperty, true)
cloneJanitor:add(clone:GetPropertyChangedSignal(absoluteProperty):Connect(updatePropertyPatch))
-- When the screenGui is resized (such as when chat is hidden/shown), we need
-- to update the position of the clone. Ths especially fixes the following:
-- https://devforum.roblox.com/t/bug/1017485/1732
if property == "Position" then
cloneJanitor:add(screenGui:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
updatePropertyStaggered()
end))
end
end
task.delay(0.1, checkIfOutsideParentXBounds)
checkIfOutsideParentXBounds()
updateVisibility()
trackProperty("Position")
-- Track visiblity changes
cloneJanitor:add(instance:GetPropertyChangedSignal("Visible"):Connect(function()
--print("Visiblity changed:", instance, clone, instance.Visible)
--clone.Visible = instance.Visible
end))
-- To ensure accurate positioning, it's important the clone also remains the same size as the instance
local shouldTrackCloneSize = instance:GetAttribute("TrackCloneSize")
if shouldTrackCloneSize then
trackProperty("Size")
else
cloneJanitor:add(instance:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
local absolute = instance.AbsoluteSize
clone.Size = UDim2.fromOffset(absolute.X, absolute.Y)
end))
end
return clone
end
function Utility.joinFeature(originalIcon, parentIcon, iconsArray, scrollingFrameOrFrame)
-- This is resonsible for moving the icon under a feature like a dropdown
local joinJanitor = originalIcon.joinJanitor
joinJanitor:clean()
if not scrollingFrameOrFrame then
originalIcon:leave()
return
end
originalIcon.parentIconUID = parentIcon.UID
originalIcon.joinedFrame = scrollingFrameOrFrame
local function updateAlignent()
local parentAlignment = parentIcon.alignment
if parentAlignment == "Center" then
parentAlignment = "Left"
end
originalIcon:setAlignment(parentAlignment, true)
end
joinJanitor:add(parentIcon.alignmentChanged:Connect(updateAlignent))
updateAlignent()
originalIcon:modifyTheme({"IconButton", "BackgroundTransparency", 1}, "JoinModification")
originalIcon:modifyTheme({"ClickRegion", "Active", false}, "JoinModification")
if parentIcon.childModifications then
-- We defer so that the default values (such as dropdown
-- minimum width can be applied before any custom
-- child modifications from the user)
task.defer(function()
originalIcon:modifyTheme(parentIcon.childModifications, parentIcon.childModificationsUID)
end)
end
--
local clickRegion = originalIcon:getInstance("ClickRegion")
local function makeSelectable()
clickRegion.Selectable = parentIcon.isSelected
end
joinJanitor:add(parentIcon.toggled:Connect(makeSelectable))
task.defer(makeSelectable)
joinJanitor:add(function()
clickRegion.Selectable = true
end)
--
-- We track icons in arrays and dictionaries using their UID instead of the icon
-- itself to prevent heavy cyclical tables when printing the icons
local originalIconUID = originalIcon.UID
table.insert(iconsArray, originalIconUID)
parentIcon:autoDeselect(false)
parentIcon.childIconsDict[originalIconUID] = true
if not parentIcon.isEnabled then
parentIcon:setEnabled(true)
end
originalIcon.joinedParent:Fire(parentIcon)
-- This is responsible for removing it from that feature and updating
-- their parent icon so its informed of the icon leaving it
joinJanitor:add(function()
local joinedFrame = originalIcon.joinedFrame
if not joinedFrame then
return
end
for i, iconUID in pairs(iconsArray) do
if iconUID == originalIconUID then
table.remove(iconsArray, i)
break
end
end
local Icon = require(originalIcon.iconModule)
local parentIcon = Icon.getIconByUID(originalIcon.parentIconUID)
if not parentIcon then
return
end
originalIcon:setAlignment(originalIcon.originalAlignment)
originalIcon.parentIconUID = false
originalIcon.joinedFrame = false
--originalIcon:setBehaviour("IconButton", "BackgroundTransparency", nil, true)
originalIcon:removeModification("JoinModification")
local parentHasNoChildren = true
local parentChildIcons = parentIcon.childIconsDict
parentChildIcons[originalIconUID] = nil
for childIconUID, _ in pairs(parentChildIcons) do
parentHasNoChildren = false
break
end
if parentHasNoChildren and not parentIcon.isAnOverflow then
parentIcon:setEnabled(false)
end
updateAlignent()
end)
end
return Utility
================================================
FILE: src/VERSION.lua
================================================
--!strict
-- LOCAL
local VERSION = {}
-- SHARED
VERSION.appVersion = "v3.4.0"
VERSION.latestVersion = nil :: string?
-- FUNCTIONS
function VERSION.getLatestVersion(): string?
local DEVELOPMENT_PLACE_ID = 117501901079852
local latestVersion = VERSION.latestVersion
if latestVersion then
return latestVersion
end
local placeName = ""
while true do
local success, hdDevelopmentDetails = pcall(function()
return game:GetService("MarketplaceService"):GetProductInfo(DEVELOPMENT_PLACE_ID)
end)
if success and hdDevelopmentDetails then
placeName = hdDevelopmentDetails.Name
break
end
task.wait(1)
end
latestVersion = string.match(placeName, "^TopbarPlus (.*)$")
if latestVersion then
latestVersion = latestVersion:gsub("%s+", "") -- Remove all whitespace (spaces, tabs, newlines)
end
VERSION.latestVersion = latestVersion
return latestVersion
end
function VERSION.getAppVersion()
return VERSION.appVersion
end
function VERSION.isUpToDate()
local latestVersion = VERSION.getLatestVersion()
local appVersion = VERSION.getAppVersion()
return latestVersion ~= nil and latestVersion == appVersion
end
return VERSION
================================================
FILE: src/init.lua
================================================
--!nonstrict
--[[
The majority of this code is an interface designed to make it easy for you to
work with TopbarPlus (most methods for instance reference :modifyTheme()).
The processing overhead mainly consists of applying themes and calculating
appearance (such as size and width of labels) which is handled in about
200 lines of code here and the Widget UI module. This has been achieved
in v3 by outsourcing a majority of previous calculations to inbuilt Roblox
features like UIListLayouts.
v3 provides inbuilt support for controllers (simply press DPadUp),
touch devices (phones, tablets , etc), localization (automatic resizing
of widgets, autolocalize for relevant labels), backwards compatability
with the old topbar, and more.
My primary goals for the v3 re-write have been to:
1. Improve code readability and organisation (reduced lines of code within
Icon+IconController from 3200 to ~950, separated UI elements, etc)
2. Improve ease-of-use (themes now actually make sense and can account
for any modifications you want, converted to a package for
quick installation and easy-comparisons of new updates, etc)
3. Provide support for all key features of the new Roblox topbar
while improving performance of the module (deferring and collecting
changes then calling as a singular, utilizing inbuilt Roblox features
such as UILIstLayouts, etc)
--]]
-- SERVICES
local UserInputService = game:GetService("UserInputService")
local ContentProvider = game:GetService("ContentProvider")
local StarterGui = game:GetService("StarterGui")
local Players = game:GetService("Players")
local Types = require(script.Types)
-- TYPES
export type Icon = Types.Icon
-- REFERENCE HANDLER
-- Multiple Icons packages may exist at runtime (for instance if the developer additionally uses HD Admin)
-- therefore this ensures that the first required package becomes the dominant and only functioning module
local iconModule = script
local Reference = require(iconModule.Reference)
local referenceObject = Reference.getObject()
local leadPackage = referenceObject and referenceObject.Value
if leadPackage and leadPackage ~= iconModule then
return require(leadPackage) :: Types.StaticIcon
end
if not referenceObject then
Reference.addToReplicatedStorage()
end
-- MODULES
local Signal = require(iconModule.Packages.GoodSignal)
local Janitor = require(iconModule.Packages.Janitor)
local Utility = require(iconModule.Utility)
local Themes = require(iconModule.Features.Themes)
local Gamepad = require(iconModule.Features.Gamepad)
local Overflow = require(iconModule.Features.Overflow)
local Icon = {}
Icon.__index = Icon
--- LOCAL
local localPlayer = Players.LocalPlayer
local themes = iconModule.Features.Themes
local iconsDict = {}
local anyIconSelected = Signal.new()
local elements = iconModule.Elements
local totalCreatedIcons = 0
local preferredInput = {
mobile = Enum.PreferredInput.Touch,
desktop = Enum.PreferredInput.KeyboardAndMouse,
console = Enum.PreferredInput.Gamepad
}
-- PUBLIC VARIABLES
Icon.baseDisplayOrderChanged = Signal.new()
Icon.baseDisplayOrder = 10
Icon.baseTheme = require(themes.Default)
Icon.isOldTopbar = false -- Logic has been moved to Container
Icon.iconsDictionary = iconsDict
Icon.insetHeightChanged = Signal.new()
Icon.container = require(elements.Container)(Icon)
Icon.topbarEnabled = true
Icon.iconAdded = Signal.new()
Icon.iconRemoved = Signal.new()
Icon.iconChanged = Signal.new()
-- PUBLIC FUNCTIONS
function Icon.getIcons()
return Icon.iconsDictionary
end
function Icon.getIconByUID(UID)
local match = Icon.iconsDictionary[UID]
if match then
return match
end
return nil
end
function Icon.getIcon(nameOrUID)
local match = Icon.getIconByUID(nameOrUID)
if match then
return match
end
for _, icon in pairs(iconsDict) do
if icon.name == nameOrUID then
return icon
end
end
return nil
end
function Icon.setTopbarEnabled(bool, isInternal)
if typeof(bool) ~= "boolean" then
bool = Icon.topbarEnabled
end
if not isInternal then
Icon.topbarEnabled = bool
end
for _, screenGui in pairs(Icon.container) do
screenGui.Enabled = bool
end
end
function Icon.modifyBaseTheme(modifications)
modifications = Themes.getModifications(modifications)
for _, modification in pairs(modifications) do
for _, detail in pairs(Icon.baseTheme) do
Themes.merge(detail, modification)
end
end
for _, icon in pairs(iconsDict) do
icon:setTheme(Icon.baseTheme)
end
end
function Icon.setDisplayOrder(int)
Icon.baseDisplayOrder = int
Icon.baseDisplayOrderChanged:Fire(int)
end
-- SETUP
task.defer(Gamepad.start, Icon)
task.defer(Overflow.start, Icon)
task.defer(function()
local playerGui = localPlayer:WaitForChild("PlayerGui")
for _, screenGui in pairs(Icon.container) do
screenGui.Parent = playerGui
end
require(iconModule.Attribute)
end)
-- CONSTRUCTOR
function Icon.new()
local self = {}
setmetatable(self, Icon)
--- Janitors (for cleanup)
local janitor = Janitor.new()
self.janitor = janitor
self.themesJanitor = janitor:add(Janitor.new())
self.singleClickJanitor = janitor:add(Janitor.new())
self.captionJanitor = janitor:add(Janitor.new())
self.joinJanitor = janitor:add(Janitor.new())
self.menuJanitor = janitor:add(Janitor.new())
self.dropdownJanitor = janitor:add(Janitor.new())
-- Register
local iconUID = Utility.generateUID()
iconsDict[iconUID] = self
janitor:add(function()
iconsDict[iconUID] = nil
end)
-- Signals (events)
self.selected = janitor:add(Signal.new())
self.deselected = janitor:add(Signal.new())
self.toggled = janitor:add(Signal.new())
self.viewingStarted = janitor:add(Signal.new())
self.viewingEnded = janitor:add(Signal.new())
self.stateChanged = janitor:add(Signal.new())
self.notified = janitor:add(Signal.new())
self.noticeStarted = janitor:add(Signal.new())
self.noticeChanged = janitor:add(Signal.new())
self.endNotices = janitor:add(Signal.new())
self.toggleKeyAdded = janitor:add(Signal.new())
self.fakeToggleKeyChanged = janitor:add(Signal.new())
self.alignmentChanged = janitor:add(Signal.new())
self.updateSize = janitor:add(Signal.new())
self.resizingComplete = janitor:add(Signal.new())
self.joinedParent = janitor:add(Signal.new())
self.menuSet = janitor:add(Signal.new())
self.dropdownSet = janitor:add(Signal.new())
self.updateMenu = janitor:add(Signal.new())
self.startMenuUpdate = janitor:add(Signal.new())
self.childThemeModified = janitor:add(Signal.new())
self.indicatorSet = janitor:add(Signal.new())
self.dropdownChildAdded = janitor:add(Signal.new())
self.menuChildAdded = janitor:add(Signal.new())
-- Properties
self.iconModule = iconModule
self.UID = iconUID
self.isEnabled = true
self.enabled = self.isEnabled -- Backwards compatability
self.isSelected = false
self.isViewing = false
self.joinedFrame = false
self.parentIconUID = false
self.deselectWhenOtherIconSelected = true
self.totalNotices = 0
self.activeState = "Deselected"
self.alignment = ""
self.originalAlignment = ""
self.appliedTheme = {}
self.appearance = {}
self.cachedInstances = {}
self.cachedNamesToInstances = {}
self.cachedCollectives = {}
self.bindedToggleKeys = {}
self.customBehaviours = {}
self.toggleItems = {}
self.bindedEvents = {}
self.notices = {}
self.menuIcons = {}
self.dropdownIcons = {}
self.childIconsDict = {}
self.creationTime = os.clock()
-- Widget is the new name for an icon
local widget = janitor:add(require(elements.Widget)(self, Icon))
self.widget = widget
self:setAlignment()
-- It's important we set an order otherwise icons will not align
-- correctly within menus
totalCreatedIcons += 1
local ourOrder = 1+(totalCreatedIcons*0.01)
self:setOrder(ourOrder, "deselected")
self:setOrder(ourOrder, "selected")
-- This applies the default them
self:setTheme(Icon.baseTheme)
-- Button Clicked (for states "Selected" and "Deselected")
local clickRegion = self:getInstance("ClickRegion")
local hasUsedMouseButton1Click = false
local lastToggleTime = 0
local DEBOUNCE_TIME = 0.1 -- 100ms debounce to prevent rapid toggles
local function handleToggle()
if self.locked then
return
end
-- Debounce logic to prevent rapid toggling
local currentTime = tick()
if currentTime - lastToggleTime < DEBOUNCE_TIME then
return
end
lastToggleTime = currentTime
if self.isSelected then
self:deselect("User", self)
else
self:select("User", self)
end
end
clickRegion.MouseButton1Click:Connect(function()
hasUsedMouseButton1Click = true
handleToggle()
end)
clickRegion.TouchTap:Connect(function()
-- This resolves the bug report by @28Pixels:
-- https://devforum.roblox.com/t/topbarplus/1017485/1104
-- Only use TouchTap if MouseButton1Click has never fired
-- This handles edge cases where ONLY TouchTap works
-- Also prevents double-toggle bug with multi-touch on mobile
-- Credit to @sayer80 for this fix
if not hasUsedMouseButton1Click then
handleToggle()
end
end)
-- Keys can be bound to toggle between Selected and Deselected
janitor:add(UserInputService.InputBegan:Connect(function(input, touchingAnObject)
if self.locked then
return
end
if self.bindedToggleKeys[input.KeyCode] and not touchingAnObject then
handleToggle()
end
end))
-- Button Hovering (for state "Viewing")
-- Hovering is a state only for devices with keyboards
-- and controllers (not touchpads)
local function viewingStarted(dontSetState)
if self.locked then
return
end
self.isViewing = true
self.viewingStarted:Fire(true)
if not dontSetState then
self:setState("Viewing", "User", self)
end
end
local function viewingEnded()
if self.locked then
return
end
self.isViewing = false
self.viewingEnded:Fire(true)
self:setState(nil, "User", self)
end
self.joinedParent:Connect(function()
if self.isViewing then
viewingEnded()
end
end)
clickRegion.MouseEnter:Connect(function()
local dontSetState = UserInputService.PreferredInput ~= preferredInput.desktop
viewingStarted(dontSetState)
end)
local touchCount = 0
janitor:add(UserInputService.TouchEnded:Connect(viewingEnded))
clickRegion.MouseLeave:Connect(viewingEnded)
clickRegion.SelectionGained:Connect(viewingStarted)
clickRegion.SelectionLost:Connect(viewingEnded)
clickRegion.MouseButton1Down:Connect(function()
if not self.locked and UserInputService.PreferredInput == preferredInput.mobile then
touchCount += 1
local myTouchCount = touchCount
task.delay(0.2, function()
if myTouchCount == touchCount then
viewingStarted()
end
end)
end
end)
clickRegion.MouseButton1Up:Connect(function()
touchCount += 1
end)
-- Handle overlay on viewing
local iconOverlay = self:getInstance("IconOverlay")
self.viewingStarted:Connect(function()
iconOverlay.Visible = not self.overlayDisabled
end)
self.viewingEnded:Connect(function()
iconOverlay.Visible = false
end)
-- Deselect when another icon is selected
janitor:add(anyIconSelected:Connect(function(incomingIcon)
if incomingIcon ~= self and self.deselectWhenOtherIconSelected and incomingIcon.deselectWhenOtherIconSelected then
self:deselect("AutoDeselect", incomingIcon)
end
end))
-- This checks if the script calling this module is a descendant of a ScreenGui
-- with 'ResetOnSpawn' set to true. If it is, then we destroy the icon the
-- client respawns. This solves one of the most asked about questions on the post
-- The only caveat this may not work if the player doesn't uniquely name their ScreenGui and the frames
-- the LocalScript rests within
local source = debug.info(2, "s")
local sourcePath = string.split(source, ".")
local origin = game
local originsScreenGui
for i, sourceName in pairs(sourcePath) do
origin = origin:FindFirstChild(sourceName)
if not origin then
break
end
if origin:IsA("ScreenGui") then
originsScreenGui = origin
end
end
if origin and originsScreenGui and originsScreenGui.ResetOnSpawn == true then
self.originsScreenGui = originsScreenGui
Utility.localPlayerRespawned(function()
self:destroy()
end)
end
-- Additional children behaviour when toggled (mostly notices)
self.toggled:Connect(function(isSelected)
self.noticeChanged:Fire(self.totalNotices)
for childIconUID, _ in pairs(self.childIconsDict) do
local childIcon = Icon.getIconByUID(childIconUID)
childIcon.noticeChanged:Fire(childIcon.totalNotices)
if not isSelected and childIcon.isSelected then
-- If an icon within a menu or dropdown is also
-- a dropdown or menu, then close it
for _, _ in pairs(childIcon.childIconsDict) do
childIcon:deselect("HideParentFeature", self)
end
end
end
end)
-- This closes/reopens the chat or playerlist if the icon is a dropdown
-- In the future I'd prefer to use the position+size of the chat
-- to determine whether to close dropdown (instead of non-right-set)
-- but for reasons mentioned here it's unreliable at the time of
-- writing this: https://devforum.roblox.com/t/here/2794915
-- I could also make this better by accounting for multiple
-- dropdowns being open (not just this one) but this will work
-- fine for almost every use case for now.
self.selected:Connect(function()
local isDropdown = #self.dropdownIcons > 0
if isDropdown then
if StarterGui:GetCore("ChatActive") and self.alignment ~= "Right" then
self.chatWasPreviouslyActive = true
StarterGui:SetCore("ChatActive", false)
end
if StarterGui:GetCoreGuiEnabled("PlayerList") and self.alignment ~= "Left" then
self.playerlistWasPreviouslyActive = true
StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.PlayerList, false)
end
end
end)
self.deselected:Connect(function()
if self.chatWasPreviouslyActive then
self.chatWasPreviouslyActive = nil
StarterGui:SetCore("ChatActive", true)
end
if self.playerlistWasPreviouslyActive then
self.playerlistWasPreviouslyActive = nil
StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.PlayerList, true)
end
end)
-- There's a rare occassion where the appearance is not
-- fully set to deselected so this ensures the icons
-- appearance is fully as it should be
task.delay(0.1, function()
if self.activeState == "Deselected" then
self.stateChanged:Fire("Deselected")
self:refresh()
end
end)
-- Call icon added
Icon.iconAdded:Fire(self)
return self
end
-- METHODS
function Icon:setName(name)
self.widget.Name = name
self.name = name
return self
end
function Icon:setState(incomingStateName, fromSource, sourceIcon)
-- This is responsible for acknowleding a change in stage (such as from "Deselected" to "Viewing" when
-- a users mouse enters the widget), then informing other systems of this state change to then act upon
-- (such as the theme handler applying the theme which corresponds to that state).
if not incomingStateName then
incomingStateName = (self.isSelected and "Selected") or "Deselected"
end
local stateName = Utility.formatStateName(incomingStateName)
local previousStateName = self.activeState
if previousStateName == stateName then
return
end
local currentIsSelected = self.isSelected
self.activeState = stateName
if stateName == "Deselected" then
self.isSelected = false
if currentIsSelected then
self.toggled:Fire(false, fromSource, sourceIcon)
self.deselected:Fire(fromSource, sourceIcon)
end
self:_setToggleItemsVisible(false, fromSource, sourceIcon)
elseif stateName == "Selected" then
self.isSelected = true
if not currentIsSelected then
self.toggled:Fire(true, fromSource, sourceIcon)
self.selected:Fire(fromSource, sourceIcon)
anyIconSelected:Fire(self, fromSource, sourceIcon)
end
self:_setToggleItemsVisible(true, fromSource, sourceIcon)
end
self.stateChanged:Fire(stateName, fromSource, sourceIcon)
end
function Icon:getInstance(name)
-- This enables us to easily retrieve instances located within the icon simply by passing its name.
-- Every important/significant instance is named uniquely therefore this is no worry of overlap.
-- We cache the result for more performant retrieval in the future.
local instance = self.cachedNamesToInstances[name]
if instance then
return instance
end
local function cacheInstance(childName, child)
local currentCache = self.cachedInstances[child]
if not currentCache then
local collectiveName = child:GetAttribute("Collective")
local cachedCollective = collectiveName and self.cachedCollectives[collectiveName]
if cachedCollective then
table.insert(cachedCollective, child)
end
self.cachedNamesToInstances[childName] = child
self.cachedInstances[child] = true
child.Destroying:Once(function()
self.cachedNamesToInstances[childName] = nil
self.cachedInstances[child] = nil
end)
end
end
local widget = self.widget
cacheInstance("Widget", widget)
if name == "Widget" then
return widget
end
local returnChild
local function scanChildren(parentInstance)
for _, child in pairs(parentInstance:GetChildren()) do
local widgetUID = child:GetAttribute("WidgetUID")
if widgetUID and widgetUID ~= self.UID then
-- This prevents instances within other icons from being recorded
-- (for instance when other icons are added to this icons menu)
continue
end
-- If the child is a fake placeholder instance (such as dropdowns, notices, etc)
-- then its important we scan the real original instance instead of this clone
local realChild = Themes.getRealInstance(child)
if realChild then
child = realChild
end
-- Finally scan its children
scanChildren(child)
if child:IsA("GuiBase") or child:IsA("UIBase") or child:IsA("ValueBase") then
local childName = child.Name
cacheInstance(childName, child)
if childName == name then
returnChild = child
end
end
end
end
scanChildren(widget)
return returnChild
end
function Icon:getCollective(name)
-- A collective is an array of instances within the Widget that have been
-- grouped together based on a given name. This just makes it easy
-- to act on multiple instances at once which share similar behaviours.
-- For instance, if we want to change the icons corner size, all corner instances
-- with the attribute "Collective" and value "WidgetCorner" could be updated
-- instantly by doing Themes.apply(icon, "WidgetCorner", newSize)
local collective = self.cachedCollectives[name]
if collective then
return collective
end
collective = {}
for instance, _ in pairs(self.cachedInstances) do
if instance:GetAttribute("Collective") == name then
table.insert(collective, instance)
end
end
self.cachedCollectives[name] = collective
return collective
end
function Icon:getInstanceOrCollective(collectiveOrInstanceName)
-- Similar to :getInstance but also accounts for 'Collectives', such as UICorners and returns
-- an array of instances instead of a single instance
local instances = {}
local instance = self:getInstance(collectiveOrInstanceName)
if instance then
table.insert(instances, instance)
end
if #instances == 0 then
instances = self:getCollective(collectiveOrInstanceName)
end
return instances
end
function Icon:getStateGroup(iconState)
local chosenState = iconState or self.activeState
local stateGroup = self.appearance[chosenState]
if not stateGroup then
stateGroup = {}
self.appearance[chosenState] = stateGroup
end
return stateGroup
end
function Icon:refreshAppearance(instance, specificProperty)
Themes.refresh(self, instance, specificProperty)
return self
end
function Icon:refresh()
self:refreshAppearance(self.widget)
self.updateSize:Fire()
return self
end
function Icon:updateParent()
local parentIcon = Icon.getIconByUID(self.parentIconUID)
if parentIcon then
parentIcon.updateSize:Fire()
end
end
function Icon:setBehaviour(collectiveOrInstanceName, property, callback, refreshAppearance)
-- You can specify your own custom callback to handle custom logic just before
-- an instances property is changed by using :setBehaviour()
local key = collectiveOrInstanceName.."-"..property
self.customBehaviours[key] = callback
if refreshAppearance then
local instances = self:getInstanceOrCollective(collectiveOrInstanceName)
for _, instance in pairs(instances) do
self:refreshAppearance(instance, property)
end
end
end
function Icon:modifyTheme(modifications, customModificationUID)
local modificationUID = Themes.modify(self, modifications, customModificationUID)
return self, modificationUID
end
function Icon:modifyChildTheme(modifications, modificationUID)
-- Same as modifyTheme except for its children (i.e. icons
-- within its dropdown or menu)
self.childModifications = modifications
self.childModificationsUID = modificationUID
for childIconUID, _ in pairs(self.childIconsDict) do
local childIcon = Icon.getIconByUID(childIconUID)
childIcon:modifyTheme(modifications, modificationUID)
end
self.childThemeModified:Fire()
return self
end
function Icon:removeModification(modificationUID)
Themes.remove(self, modificationUID)
return self
end
function Icon:removeModificationWith(instanceName, property, state)
Themes.removeWith(self, instanceName, property, state)
return self
end
function Icon:setTheme(theme)
Themes.set(self, theme)
return self
end
function Icon:setEnabled(bool)
self.isEnabled = bool
self.enabled = self.isEnabled
self.widget.Visible = bool
self:updateParent()
return self
end
function Icon:select(fromSource, sourceIcon)
self:setState("Selected", fromSource, sourceIcon)
return self
end
function Icon:deselect(fromSource, sourceIcon)
self:setState("Deselected", fromSource, sourceIcon)
return self
end
function Icon:notify(customClearSignal, noticeId)
-- Generates a notification which appears in the top right of the icon. Useful for example for prompting
-- users of changes/updates within your UI such as a Catalog
-- 'customClearSignal' is a signal object (e.g. icon.deselected) or
-- Roblox event (e.g. Instance.new("BindableEvent").Event)
local notice = self.notice
if not notice then
notice = require(elements.Notice)(self, Icon)
self.notice = notice
end
self.noticeStarted:Fire(customClearSignal, noticeId)
return self
end
function Icon:clearNotices()
self.endNotices:Fire()
return self
end
function Icon:disableOverlay(bool)
self.overlayDisabled = bool
return self
end
Icon.disableStateOverlay = Icon.disableOverlay
function Icon:setImage(imageId, iconState)
self:modifyTheme({"IconImage", "Image", imageId, iconState})
-- This code ensures icon images are preloaded if they haven't been fetched yet
task.spawn(function()
local newIdContent = if tonumber(imageId) then `rbxassetid://{imageId}` else imageId
local initialAssetFetchStatus = ContentProvider:GetAssetFetchStatus(newIdContent)
if initialAssetFetchStatus ~= Enum.AssetFetchStatus.Success then
pcall(ContentProvider.PreloadAsync, ContentProvider, { newIdContent })
end
end)
return self
end
function Icon:setLabel(text, iconState)
self:modifyTheme({"IconLabel", "Text", text, iconState})
return self
end
function Icon:setOrder(int, iconState)
-- We multiply by 100 to allow for custom increments inbetween
-- (.01, .02, etc) as LayoutOrders only support integers
local newInt = int*100
self:modifyTheme({"IconSpot", "LayoutOrder", newInt, iconState})
self:modifyTheme({"Widget", "LayoutOrder", newInt, iconState})
return self
end
function Icon:setCornerRadius(udim, iconState)
self:modifyTheme({"IconCorners", "CornerRadius", udim, iconState})
return self
end
function Icon:align(leftCenterOrRight, isFromParentIcon)
-- Determines the side of the screen the icon will be ordered
local direction = tostring(leftCenterOrRight):lower()
if direction == "mid" or direction == "centre" then
direction = "center"
end
if direction ~= "left" and direction ~= "center" and direction ~= "right" then
direction = "left"
end
local screenGui = (direction == "center" and Icon.container.TopbarCentered) or Icon.container.TopbarStandard
local holders = screenGui.Holders
local finalDirection = string.upper(string.sub(direction, 1, 1))..string.sub(direction, 2)
if not isFromParentIcon then
self.originalAlignment = finalDirection
end
local joinedFrame = self.joinedFrame
local alignmentHolder = holders[finalDirection]
self.screenGui = screenGui
self.alignmentHolder = alignmentHolder
if not self.isDestroyed then
self.widget.Parent = joinedFrame or alignmentHolder
end
self.alignment = finalDirection
self.alignmentChanged:Fire(finalDirection)
Icon.iconChanged:Fire(self)
return self
end
Icon.setAlignment = Icon.align
function Icon:setLeft()
self:setAlignment("Left")
return self
end
function Icon:setMid()
self:setAlignment("Center")
return self
end
function Icon:setRight()
self:setAlignment("Right")
return self
end
function Icon:setWidth(offsetMinimum, iconState)
-- This sets a minimum X offset size for the widget, useful
-- for example if you're constantly changing the label
-- but don't want the icon to resize every time
self:modifyTheme({"Widget", "DesiredWidth", offsetMinimum, iconState})
return self
end
function Icon:setImageScale(number, iconState)
self:modifyTheme({"IconImageScale", "Value", number, iconState})
return self
end
function Icon:setImageRatio(number, iconState)
self:modifyTheme({"IconImageRatio", "AspectRatio", number, iconState})
return self
end
function Icon:setTextSize(number, iconState)
self:modifyTheme({"IconLabel", "TextSize", number, iconState})
return self
end
function Icon:setTextFont(font, fontWeight, fontStyle, iconState)
fontWeight = fontWeight or Enum.FontWeight.Regular
fontStyle = fontStyle or Enum.FontStyle.Normal
local fontFace
local fontType = typeof(font)
if fontType == "number" then
fontFace = Font.fromId(font, fontWeight, fontStyle)
elseif fontType == "EnumItem" then
fontFace = Font.fromEnum(font)
elseif fontType == "string" then
if not font:match("rbxasset") then
fontFace = Font.fromName(font, fontWeight, fontStyle)
end
end
if not fontFace then
fontFace = Font.new(font, fontWeight, fontStyle)
end
self:modifyTheme({"IconLabel", "FontFace", fontFace, iconState})
return self
end
function Icon:setTextColor(Color, iconState)
if Color == nil or Color == "" or (type(Color) ~= "userdata" or typeof(Color) ~= "Color3") then
if Color ~= nil and Color ~= "" then
warn("setTextColor item must be a Color3 value! Changed the color to white.")
end
Color = Color3.fromRGB(255, 255, 255)
end
self:modifyTheme({"IconLabel", "TextColor3", Color, iconState})
return self
end
function Icon:bindToggleItem(guiObjectOrLayerCollector)
if not guiObjectOrLayerCollector:IsA("GuiObject") and not guiObjectOrLayerCollector:IsA("LayerCollector") then
error("Toggle item must be a GuiObject or LayerCollector!")
end
self.toggleItems[guiObjectOrLayerCollector] = true
self:_updateSelectionInstances()
return self
end
function Icon:unbindToggleItem(guiObjectOrLayerCollector)
self.toggleItems[guiObjectOrLayerCollector] = nil
self:_updateSelectionInstances()
return self
end
function Icon:_updateSelectionInstances()
-- This is to assist with controller navigation and selection
-- It converts the value true to an array
for guiObjectOrLayerCollector, _ in pairs(self.toggleItems) do
local buttonInstancesArray = {}
for _, instance in pairs(guiObjectOrLayerCollector:GetDescendants()) do
if (instance:IsA("TextButton") or instance:IsA("ImageButton")) and instance.Active then
table.insert(buttonInstancesArray, instance)
end
end
self.toggleItems[guiObjectOrLayerCollector] = buttonInstancesArray
end
end
function Icon:_setToggleItemsVisible(bool, fromSource, sourceIcon)
for toggleItem, _ in pairs(self.toggleItems) do
if not sourceIcon or sourceIcon == self or sourceIcon.toggleItems[toggleItem] == nil then
local property = "Visible"
if toggleItem:IsA("LayerCollector") then
property = "Enabled"
end
toggleItem[property] = bool
end
end
end
function Icon:bindEvent(iconEventName, eventFunction)
local event = self[iconEventName]
assert(event and typeof(event) == "table" and event.Connect, "argument[1] must be a valid topbarplus icon event name!")
assert(typeof(eventFunction) == "function", "argument[2] must be a function!")
self.bindedEvents[iconEventName] = event:Connect(function(...)
eventFunction(self, ...)
end)
return self
end
function Icon:unbindEvent(iconEventName)
local eventConnection = self.bindedEvents[iconEventName]
if eventConnection then
eventConnection:Disconnect()
self.bindedEvents[iconEventName] = nil
end
return self
end
function Icon:bindToggleKey(keyCodeEnum)
assert(typeof(keyCodeEnum) == "EnumItem", "argument[1] must be a KeyCode EnumItem!")
self.bindedToggleKeys[keyCodeEnum] = true
self.toggleKeyAdded:Fire(keyCodeEnum)
self:setCaption("_hotkey_")
return self
end
function Icon:unbindToggleKey(keyCodeEnum)
assert(typeof(keyCodeEnum) == "EnumItem", "argument[1] must be a KeyCode EnumItem!")
self.bindedToggleKeys[keyCodeEnum] = nil
return self
end
function Icon:call(callback, ...)
local packedArgs = table.pack(...)
task.spawn(function()
callback(self, table.unpack(packedArgs))
end)
return self
end
function Icon:addToJanitor(callback, methodName, index)
self.janitor:add(callback, methodName, index)
return self
end
function Icon:lock()
-- This disables all user inputs related to the icon (such as clicking buttons, pressing keys, etc)
local clickRegion = self:getInstance("ClickRegion")
clickRegion.Visible = false
self.locked = true
return self
end
function Icon:unlock()
local clickRegion = self:getInstance("ClickRegion")
clickRegion.Visible = true
self.locked = false
return self
end
function Icon:debounce(seconds)
self:lock()
task.wait(seconds)
self:unlock()
return self
end
function Icon:autoDeselect(bool)
-- When set to true the icon will deselect itself automatically whenever
-- another icon is selected
if bool == nil then
bool = true
end
self.deselectWhenOtherIconSelected = bool
return self
end
function Icon:oneClick(bool)
-- When set to true the icon will automatically deselect when selected, this creates
-- the effect of a single click button
local singleClickJanitor = self.singleClickJanitor
singleClickJanitor:clean()
if bool or bool == nil then
singleClickJanitor:add(self.selected:Connect(function()
self:deselect("OneClick", self)
end))
end
self.oneClickEnabled = true
return self
end
function Icon:setCaption(text)
if text == "_hotkey_" and (self.captionText) then
return self
end
local captionJanitor = self.captionJanitor
self.captionJanitor:clean()
if not text or text == "" then
self.caption = nil
self.captionText = nil
return self
end
local caption = captionJanitor:add(require(elements.Caption)(self))
caption:SetAttribute("CaptionText", text)
self.caption = caption
self.captionText = text
return self
end
function Icon:setCaptionHint(keyCodeEnum)
assert(typeof(keyCodeEnum) == "EnumItem", "argument[1] must be a KeyCode EnumItem!")
self.fakeToggleKey = keyCodeEnum
self.fakeToggleKeyChanged:Fire(keyCodeEnum)
self:setCaption("_hotkey_")
return self
end
function Icon:leave()
local joinJanitor = self.joinJanitor
joinJanitor:clean()
return self
end
function Icon:joinMenu(parentIcon)
Utility.joinFeature(self, parentIcon, parentIcon.menuIcons, parentIcon:getInstance("Menu"))
parentIcon.menuChildAdded:Fire(self)
return self
end
function Icon:setMenu(arrayOfIcons)
self.menuSet:Fire(arrayOfIcons)
return self
end
function Icon:setFixedMenu(arrayOfIcons)
self:freezeMenu(arrayOfIcons)
self:setMenu(arrayOfIcons)
end
Icon.setFrozenMenu = Icon.setFixedMenu
function Icon:freezeMenu()
-- A frozen menu is a menu which is permanently locked in the
-- the selected state (with its toggle hidden)
self:select("FrozenMenu", self)
self:bindEvent("deselected", function(icon)
icon:select("FrozenMenu", self)
end)
self:modifyTheme({"IconSpot", "Visible", false})
end
function Icon:joinDropdown(parentIcon)
parentIcon:getDropdown()
Utility.joinFeature(self, parentIcon, parentIcon.dropdownIcons, parentIcon:getInstance("DropdownScroller"))
parentIcon.dropdownChildAdded:Fire(self)
return self
end
function Icon:getDropdown()
local dropdown = self.dropdown
if not dropdown then
dropdown = require(elements.Dropdown)(self)
self.dropdown = dropdown
self:clipOutside(dropdown)
end
return dropdown
end
function Icon:setDropdown(arrayOfIcons)
self:getDropdown()
self.dropdownSet:Fire(arrayOfIcons)
return self
end
function Icon:clipOutside(instance)
-- This is essential for items such as notices and dropdowns which will exceed the bounds of the widget. This is an issue
-- because the widget must have ClipsDescendents enabled to hide items for instance when the menu is closing or opening.
-- This creates an invisible frame which matches the size and position of the instance, then the instance is parented outside of
-- the widget and tracks the clone to match its size and position. In order for themes, etc to work the applying system checks
-- to see if an instance is a clone, then if it is, it applies it to the original instance instead of the clone.
local instanceClone = Utility.clipOutside(self, instance)
self:refreshAppearance(instance)
return self, instanceClone
end
function Icon:setIndicator(keyCode)
-- An indicator is a direction button prompt with an image of the given keycode. This is useful for instance
-- with controllers to show the user what button to press to highlight the topbar. You don't need
-- to set an indicator for controllers as this is handled internally within the Gamepad module
local indicator = self.indicator
if not indicator then
indicator = self.janitor:add(require(elements.Indicator)(self, Icon))
self.indicator = indicator
end
self.indicatorSet:Fire(keyCode)
end
function Icon:convertLabelToNumberSpinner(numberSpinner, callback)
task.defer(function()
local label = self:getInstance("IconLabel")
label.Transparency = 1
numberSpinner.Parent = label.Parent
numberSpinner.Size = UDim2.fromScale(1, 1)
numberSpinner.AnchorPoint = Vector2.new(0.5, 0.5)
numberSpinner.Position = UDim2.new(0.5, 0, 0.5, 0)
numberSpinner.TextXAlignment = Enum.TextXAlignment.Center
numberSpinner.ClipsDescendants = false
local propertiesToChangeLabel = {
"FontFace",
"BorderSizePixel",
"BorderColor3",
"Rotation",
"TextStrokeTransparency",
"TextStrokeColor3",
"TextStrokeTransparency",
"TextColor3",
}
for _, property in ipairs(propertiesToChangeLabel) do
numberSpinner[property] = label[property]
self:addToJanitor(label:GetPropertyChangedSignal(property):Connect(function()
numberSpinner[property] = label[property]
end))
end
local minDigits = 0
local maxDigits = 8
local function getSpinnerSizeAndDigitCount()
local TotalSize = 0
local numOfDigits = 0
for i, child in numberSpinner.Frame:GetChildren() do
local name = string.lower(child.Name)
if name == "digit" then
TotalSize += child.AbsoluteSize.X
numOfDigits += 1
elseif name == "prefix" or name == "suffix" or name == "comma" then
if child.Text ~= "" then
TotalSize += child.AbsoluteSize.X
numOfDigits += 1
end
end
end
return TotalSize, numOfDigits
end
local function getLabelParentContainerXSize()
local firstParent = label.Parent
local nextParent = firstParent and firstParent.Parent
if nextParent == nil then
return 0
end
if nextParent.IconImage.Visible == true then
return numberSpinner.Frame.AbsoluteSize.X + label.Parent.Parent.IconImage.AbsoluteSize.X
else
return nextParent.AbsoluteSize.X
end
end
local function getNumberSpinnerXSize()
return numberSpinner.Frame.AbsoluteSize.X
end
local function adjustSize()
local totalDigitXSize, numOfDigits = getSpinnerSizeAndDigitCount()
if numOfDigits < 18 then
self:setLabel(numberSpinner.Value)
end
local NumberSpinnerXSize = getNumberSpinnerXSize()
while totalDigitXSize < NumberSpinnerXSize and self.isDestroyed ~= true do
task.wait(0.05)
if numOfDigits > minDigits and numOfDigits < maxDigits then
numberSpinner.TextSize = label.TextSize
break
else
numberSpinner.TextSize += 1
end
NumberSpinnerXSize = getNumberSpinnerXSize()
totalDigitXSize, numOfDigits = getSpinnerSizeAndDigitCount()
end
local labelParentContainerXSize = getLabelParentContainerXSize()
while totalDigitXSize > labelParentContainerXSize and self.isDestroyed ~= true do
task.wait(0.05)
if numOfDigits < maxDigits and numOfDigits > minDigits then
numberSpinner.TextSize = label.TextSize
break
else
numberSpinner.TextSize -= 1
end
labelParentContainerXSize = getLabelParentContainerXSize()
totalDigitXSize, numOfDigits = getSpinnerSizeAndDigitCount()
end
end
self:addToJanitor(numberSpinner.Frame.ChildAdded:Connect(adjustSize))
self:addToJanitor(numberSpinner.Frame.ChildRemoved:Connect(adjustSize))
self:addToJanitor(self.iconAdded:Connect(function()
task.wait(1)
adjustSize()
end))
self:updateParent()
-- This corrects text to the size of a normal label
numberSpinner.Name = "LabelSpinner"
numberSpinner.Prefix = "$"
numberSpinner.Commas = true
numberSpinner.Decimals = 0
numberSpinner.Duration = 0.25
numberSpinner.Value = 10
task.wait(0.2)
if typeof(callback) == "function" then
callback()
end
end)
return self
end
-- DESTROY/CLEANUP
function Icon:destroy()
if self.isDestroyed then
return
end
self:clearNotices()
if self.parentIconUID then
self:leave()
end
self.isDestroyed = true
self.janitor:clean()
Icon.iconRemoved:Fire(self)
end
Icon.Destroy = Icon.destroy
return Icon :: Types.StaticIcon
================================================
FILE: wally.toml
================================================
[package]
name = "1foreverhd/topbarplus"
description = "Construct dynamic and intuitive topbar icons. Enhance the appearance and behaviour of these icons with features such as themes, dropdowns and menus."
license = "MPL2"
version = "3.4.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = [
"**",
]
include = [
"default.project.json",
"src",
"src/**",
"LICENSE",
"wally.toml",
]
[dependencies]
================================================
FILE: withLink.project.json
================================================
{
"name": "topbarplus",
"tree": {
"$path": "src"
}
}
================================================
FILE: withoutLink.project.json
================================================
{
"name": "topbarplus",
"globIgnorePaths": ["**/PackageLink.model.json"],
"tree": {
"$path": "src"
}
}