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}", ''], ["{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 Connection = { Disconnect: (self: Connection) -> (), } --- Signal type Signal = { Connect: (self: Signal, func: (Variant...) -> ()) -> Connection, Once: (self: Signal, func: (Variant...) -> ()) -> Connection, Wait: (self: Signal) -> Variant..., } ---------------------- export type IconState = "Deselected" | "Selected" | "Viewing" export type Events = "selected" | "deselected" | "toggled" | "viewingStarted" | "viewingEnded" | "notified" export type Alignment = "Left" | "Center" | "Right" export type EventSource = "User" | "OneClick" | "AutoDeselect" | "HideParentFeature" | "Overflow" export type Modification = { any } type StaticFunctions = { getIcons: typeof( --[[ Returns a dictionary of icons where the key is the icon's UID and value the icon. ]] function(): { Icon } return (nil :: any) :: { Icon } end ), getIcon: typeof( --[[ Returns an icon of the given name or UID. ]] function(nameOrUID: string): Icon? return nil :: any end ), setTopbarEnabled: typeof( --[[ When set to false 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: Signal, deselected: Signal, toggled: Signal, viewingStarted: Signal, viewingEnded: Signal, notified: Signal, } export type Icon = Methods & StaticFunctions --typeof(setmetatable({} :: Fields, MT)) export type StaticIcon = { new: typeof( --[[ Constructs an empty 32x32 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" } }