From dafe801b737b571be4d5defe196421c4f0cc4999 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Fri, 7 Oct 2022 19:31:14 -0400 Subject: [PATCH] Add tooltips to buttons (#637) * Add tooltips * Fix whitespace * Avoid overloaded word canvas * Clean render function * Switch folder to fragment --- .../src/App/Components/BorderedContainer.lua | 1 + plugin/src/App/Components/Checkbox.lua | 5 + plugin/src/App/Components/IconButton.lua | 2 + plugin/src/App/Components/TextButton.lua | 2 + plugin/src/App/Components/Tooltip.lua | 226 ++++++++++++++++++ plugin/src/App/StatusPages/Connected.lua | 5 + plugin/src/App/StatusPages/Error.lua | 5 + plugin/src/App/StatusPages/NotConnected.lua | 9 + plugin/src/App/StatusPages/Settings/init.lua | 5 + plugin/src/App/init.lua | 183 +++++++------- 10 files changed, 354 insertions(+), 89 deletions(-) create mode 100644 plugin/src/App/Components/Tooltip.lua diff --git a/plugin/src/App/Components/BorderedContainer.lua b/plugin/src/App/Components/BorderedContainer.lua index 73fd2bf1b..401d56767 100644 --- a/plugin/src/App/Components/BorderedContainer.lua +++ b/plugin/src/App/Components/BorderedContainer.lua @@ -26,6 +26,7 @@ local function BorderedContainer(props) Content = e("Frame", { Size = UDim2.new(1, 0, 1, 0), BackgroundTransparency = 1, + ZIndex = 2, }, props[Roact.Children]), Border = e(SlicedImage, { diff --git a/plugin/src/App/Components/Checkbox.lua b/plugin/src/App/Components/Checkbox.lua index 631164ee1..21ec56b39 100644 --- a/plugin/src/App/Components/Checkbox.lua +++ b/plugin/src/App/Components/Checkbox.lua @@ -10,6 +10,7 @@ local Theme = require(Plugin.App.Theme) local bindingUtil = require(Plugin.App.bindingUtil) local SlicedImage = require(script.Parent.SlicedImage) +local Tooltip = require(script.Parent.Tooltip) local e = Roact.createElement @@ -52,6 +53,10 @@ function Checkbox:render() [Roact.Event.Activated] = self.props.onClick, }, { + StateTip = e(Tooltip.Trigger, { + text = if self.props.active then "Enabled" else "Disabled", + }), + Active = e(SlicedImage, { slice = Assets.Slices.RoundedBackground, color = theme.Active.BackgroundColor, diff --git a/plugin/src/App/Components/IconButton.lua b/plugin/src/App/Components/IconButton.lua index 169e38671..130f76e46 100644 --- a/plugin/src/App/Components/IconButton.lua +++ b/plugin/src/App/Components/IconButton.lua @@ -75,6 +75,8 @@ function IconButton:render() BackgroundTransparency = 1, }), + + Children = Roact.createFragment(self.props[Roact.Children]), }) end diff --git a/plugin/src/App/Components/TextButton.lua b/plugin/src/App/Components/TextButton.lua index d33ddf9f2..5551f64c5 100644 --- a/plugin/src/App/Components/TextButton.lua +++ b/plugin/src/App/Components/TextButton.lua @@ -131,6 +131,8 @@ function TextButton:render() zIndex = -2, }), + + Children = Roact.createFragment(self.props[Roact.Children]), }) end) end diff --git a/plugin/src/App/Components/Tooltip.lua b/plugin/src/App/Components/Tooltip.lua new file mode 100644 index 000000000..e30a36031 --- /dev/null +++ b/plugin/src/App/Components/Tooltip.lua @@ -0,0 +1,226 @@ +local TextService = game:GetService("TextService") +local HttpService = game:GetService("HttpService") + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) +local Theme = require(Plugin.App.Theme) + +local BorderedContainer = require(Plugin.App.Components.BorderedContainer) + +local e = Roact.createElement + +local DELAY = 0.75 -- How long to hover before a popup is shown (seconds) +local TEXT_PADDING = Vector2.new(8 * 2, 6 * 2) -- Padding for the popup text containers +local TAIL_SIZE = 16 -- Size of the triangle tail piece +local X_OFFSET = 30 -- How far right (from left) the tail will be (assuming enough space) +local Y_OVERLAP = 10 -- Let the triangle tail piece overlap the target a bit to help "connect" it + +local TooltipContext = Roact.createContext({}) + +local function Popup(props) + local textSize = TextService:GetTextSize( + props.Text, 16, Enum.Font.GothamMedium, Vector2.new(math.min(props.parentSize.X, 160), math.huge) + ) + TEXT_PADDING + + local trigger = props.Trigger:getValue() + + local spaceBelow = props.parentSize.Y - (trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y - Y_OVERLAP + TAIL_SIZE) + local spaceAbove = trigger.AbsolutePosition.Y + Y_OVERLAP - TAIL_SIZE + + -- If there's not enough space below, and there's more space above, then show the tooltip above the trigger + local displayAbove = spaceBelow < textSize.Y and spaceAbove > spaceBelow + + local X = math.clamp(props.Position.X - X_OFFSET, 0, props.parentSize.X - textSize.X) + local Y = 0 + + if displayAbove then + Y = math.max(trigger.AbsolutePosition.Y - TAIL_SIZE - textSize.Y + Y_OVERLAP, 0) + else + Y = math.min(trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y + TAIL_SIZE - Y_OVERLAP, props.parentSize.Y - textSize.Y) + end + + return Theme.with(function(theme) + return e(BorderedContainer, { + position = UDim2.fromOffset(X, Y), + size = UDim2.fromOffset(textSize.X, textSize.Y), + transparency = props.transparency, + }, { + Label = e("TextLabel", { + BackgroundTransparency = 1, + Position = UDim2.fromScale(0.5, 0.5), + Size = UDim2.new(1, -TEXT_PADDING.X, 1, -TEXT_PADDING.Y), + AnchorPoint = Vector2.new(0.5, 0.5), + Text = props.Text, + TextSize = 16, + Font = Enum.Font.GothamMedium, + TextWrapped = true, + TextXAlignment = Enum.TextXAlignment.Left, + TextColor3 = theme.Button.Bordered.Enabled.TextColor, + TextTransparency = props.transparency, + }), + + Tail = e("ImageLabel", { + ZIndex = 100, + Position = + if displayAbove then + UDim2.new( + 0, math.clamp(props.Position.X - X, 6, textSize.X-6), + 1, -3 + ) + else + UDim2.new( + 0, math.clamp(props.Position.X - X, 6, textSize.X-6), + 0, -TAIL_SIZE+3 + ), + Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE), + AnchorPoint = Vector2.new(0.5, 0), + Rotation = if displayAbove then 180 else 0, + BackgroundTransparency = 1, + Image = "rbxassetid://10983945016", + ImageColor3 = theme.BorderedContainer.BackgroundColor, + ImageTransparency = props.transparency, + }, { + Border = e("ImageLabel", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + Image = "rbxassetid://10983946430", + ImageColor3 = theme.BorderedContainer.BorderColor, + ImageTransparency = props.transparency, + }), + }) + }) + end) +end + +local Provider = Roact.Component:extend("TooltipManager") + +function Provider:init() + self:setState({ + tips = {}, + addTip = function(id: string, data: { Text: string, Position: Vector2, Trigger: any }) + self:setState(function(state) + state.tips[id] = data + return state + end) + end, + removeTip = function(id: string) + self:setState(function(state) + state.tips[id] = nil + return state + end) + end, + }) +end + +function Provider:render() + return Roact.createElement(TooltipContext.Provider, { + value = self.state, + }, self.props[Roact.Children]) +end + +local Container = Roact.Component:extend("TooltipContainer") + +function Container:init() + self:setState({ + size = Vector2.new(200, 100), + }) +end + +function Container:render() + return Roact.createElement(TooltipContext.Consumer, { + render = function(context) + local tips = context.tips + local popups = {} + + for key, value in tips do + popups[key] = e(Popup, { + Text = value.Text or "", + Position = value.Position or Vector2.zero, + Trigger = value.Trigger, + + parentSize = self.state.size, + }) + end + + return e("Frame", { + [Roact.Change.AbsoluteSize] = function(rbx) + self:setState({ + size = rbx.AbsoluteSize, + }) + end, + ZIndex = 100, + BackgroundTransparency = 1, + Size = UDim2.fromScale(1, 1), + }, popups) + end, + }) +end + +local Trigger = Roact.Component:extend("TooltipTrigger") + +function Trigger:init() + self.id = HttpService:GenerateGUID(false) + self.ref = Roact.createRef() + self.mousePos = Vector2.zero + + self.destroy = function() + self.props.context.removeTip(self.id) + end +end + +function Trigger:willUnmount() + if self.showDelayThread then + task.cancel(self.showDelayThread) + end + if self.destroy then + self.destroy() + end +end + +function Trigger:render() + return e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + ZIndex = self.props.zIndex or 100, + [Roact.Ref] = self.ref, + + [Roact.Event.MouseMoved] = function(_rbx, x, y) + self.mousePos = Vector2.new(x, y) + end, + [Roact.Event.MouseEnter] = function() + self.showDelayThread = task.delay(DELAY, function() + self.props.context.addTip(self.id, { + Text = self.props.text, + Position = self.mousePos, + Trigger = self.ref, + }) + end) + end, + [Roact.Event.MouseLeave] = function() + if self.showDelayThread then + task.cancel(self.showDelayThread) + end + self.props.context.removeTip(self.id) + end, + }) +end + +local function TriggerConsumer(props) + return Roact.createElement(TooltipContext.Consumer, { + render = function(context) + local innerProps = table.clone(props) + innerProps.context = context + + return e(Trigger, innerProps) + end, + }) +end + +return { + Provider = Provider, + Container = Container, + Trigger = TriggerConsumer, +} diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index 810ce0953..ac302d467 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -10,6 +10,7 @@ local Assets = require(Plugin.Assets) local Header = require(Plugin.App.Components.Header) local IconButton = require(Plugin.App.Components.IconButton) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) +local Tooltip = require(Plugin.App.Components.Tooltip) local e = Roact.createElement @@ -90,6 +91,10 @@ local function ConnectionDetails(props) anchorPoint = Vector2.new(1, 0.5), onClick = props.onDisconnect, + }, { + Tip = e(Tooltip.Trigger, { + text = "Disconnect from the Rojo sync server" + }), }), Padding = e("UIPadding", { diff --git a/plugin/src/App/StatusPages/Error.lua b/plugin/src/App/StatusPages/Error.lua index 8af669c60..138e19397 100644 --- a/plugin/src/App/StatusPages/Error.lua +++ b/plugin/src/App/StatusPages/Error.lua @@ -11,6 +11,7 @@ local Theme = require(Plugin.App.Theme) local TextButton = require(Plugin.App.Components.TextButton) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local Tooltip = require(Plugin.App.Components.Tooltip) local e = Roact.createElement @@ -123,6 +124,10 @@ function ErrorPage:render() transparency = self.props.transparency, layoutOrder = 1, onClick = self.props.onClose, + }, { + Tip = e(Tooltip.Trigger, { + text = "Dismiss message" + }), }), Layout = e("UIListLayout", { diff --git a/plugin/src/App/StatusPages/NotConnected.lua b/plugin/src/App/StatusPages/NotConnected.lua index 08684b558..40861ba58 100644 --- a/plugin/src/App/StatusPages/NotConnected.lua +++ b/plugin/src/App/StatusPages/NotConnected.lua @@ -10,6 +10,7 @@ local Theme = require(Plugin.App.Theme) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local TextButton = require(Plugin.App.Components.TextButton) local Header = require(Plugin.App.Components.Header) +local Tooltip = require(Plugin.App.Components.Tooltip) local PORT_WIDTH = 74 local DIVIDER_WIDTH = 1 @@ -116,6 +117,10 @@ function NotConnectedPage:render() transparency = self.props.transparency, layoutOrder = 1, onClick = self.props.onNavigateSettings, + }, { + Tip = e(Tooltip.Trigger, { + text = "View and modify plugin settings" + }), }), Connect = e(TextButton, { @@ -124,6 +129,10 @@ function NotConnectedPage:render() transparency = self.props.transparency, layoutOrder = 2, onClick = self.props.onConnect, + }, { + Tip = e(Tooltip.Trigger, { + text = "Connect to a Rojo sync server" + }), }), Layout = e("UIListLayout", { diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index 3b4b339e6..1d484423b 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -11,6 +11,7 @@ local Theme = require(Plugin.App.Theme) local IconButton = require(Plugin.App.Components.IconButton) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local Tooltip = require(Plugin.App.Components.Tooltip) local Setting = require(script.Setting) local e = Roact.createElement @@ -44,6 +45,10 @@ local function Navbar(props) anchorPoint = Vector2.new(0, 0.5), onClick = props.onBack, + }, { + Tip = e(Tooltip.Trigger, { + text = "Back" + }), }), Text = e("TextLabel", { diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 931860772..f141d943f 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -22,6 +22,7 @@ local Theme = require(script.Theme) local Page = require(script.Page) local Notifications = require(script.Notifications) +local Tooltip = require(script.Components.Tooltip) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioToolbar = require(script.Components.Studio.StudioToolbar) local StudioToggleButton = require(script.Components.Studio.StudioToggleButton) @@ -333,108 +334,112 @@ function App:render() value = self.props.plugin, }, { e(Theme.StudioProvider, nil, { - gui = e(StudioPluginGui, { - id = pluginName, - title = pluginName, - active = self.state.guiEnabled, - - initDockState = Enum.InitialDockState.Right, - initEnabled = false, - overridePreviousState = false, - floatingSize = Vector2.new(300, 200), - minimumSize = Vector2.new(300, 120), - - zIndexBehavior = Enum.ZIndexBehavior.Sibling, - - onInitialState = function(initialState) - self:setState({ - guiEnabled = initialState, - }) - end, - - onClose = function() - self:setState({ - guiEnabled = false, - }) - end, - }, { - NotConnectedPage = createPageElement(AppStatus.NotConnected, { - host = self.host, - onHostChange = self.setHost, - port = self.port, - onPortChange = self.setPort, - - onConnect = function() - self:startSession() - end, - - onNavigateSettings = function() - self:setState({ - appStatus = AppStatus.Settings, - }) - end, - }), - - Connecting = createPageElement(AppStatus.Connecting), + e(Tooltip.Provider, nil, { + gui = e(StudioPluginGui, { + id = pluginName, + title = pluginName, + active = self.state.guiEnabled, - Connected = createPageElement(AppStatus.Connected, { - projectName = self.state.projectName, - address = self.state.address, - patchInfo = self.patchInfo, + initDockState = Enum.InitialDockState.Right, + initEnabled = false, + overridePreviousState = false, + floatingSize = Vector2.new(300, 200), + minimumSize = Vector2.new(300, 120), - onDisconnect = function() - self:endSession() - end, - }), + zIndexBehavior = Enum.ZIndexBehavior.Sibling, - Settings = createPageElement(AppStatus.Settings, { - onBack = function() + onInitialState = function(initialState) self:setState({ - appStatus = AppStatus.NotConnected, + guiEnabled = initialState, }) end, - }), - - Error = createPageElement(AppStatus.Error, { - errorMessage = self.state.errorMessage, onClose = function() self:setState({ - appStatus = AppStatus.NotConnected, - toolbarIcon = Assets.Images.PluginButton, + guiEnabled = false, }) end, + }, { + Tooltips = e(Tooltip.Container, nil), + + NotConnectedPage = createPageElement(AppStatus.NotConnected, { + host = self.host, + onHostChange = self.setHost, + port = self.port, + onPortChange = self.setPort, + + onConnect = function() + self:startSession() + end, + + onNavigateSettings = function() + self:setState({ + appStatus = AppStatus.Settings, + }) + end, + }), + + Connecting = createPageElement(AppStatus.Connecting), + + Connected = createPageElement(AppStatus.Connected, { + projectName = self.state.projectName, + address = self.state.address, + patchInfo = self.patchInfo, + + onDisconnect = function() + self:endSession() + end, + }), + + Settings = createPageElement(AppStatus.Settings, { + onBack = function() + self:setState({ + appStatus = AppStatus.NotConnected, + }) + end, + }), + + Error = createPageElement(AppStatus.Error, { + errorMessage = self.state.errorMessage, + + onClose = function() + self:setState({ + appStatus = AppStatus.NotConnected, + toolbarIcon = Assets.Images.PluginButton, + }) + end, + }), + + Background = Theme.with(function(theme) + return e("Frame", { + Size = UDim2.new(1, 0, 1, 0), + BackgroundColor3 = theme.BackgroundColor, + ZIndex = 0, + BorderSizePixel = 0, + }) + end), }), - Background = Theme.with(function(theme) - return e("Frame", { - Size = UDim2.new(1, 0, 1, 0), - BackgroundColor3 = theme.BackgroundColor, - ZIndex = 0, - BorderSizePixel = 0, - }) - end), - }), - - RojoNotifications = e("ScreenGui", {}, { - layout = e("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - HorizontalAlignment = Enum.HorizontalAlignment.Right, - VerticalAlignment = Enum.VerticalAlignment.Bottom, - Padding = UDim.new(0, 5), - }), - padding = e("UIPadding", { - PaddingTop = UDim.new(0, 5); - PaddingBottom = UDim.new(0, 5); - PaddingLeft = UDim.new(0, 5); - PaddingRight = UDim.new(0, 5); - }), - notifs = e(Notifications, { - soundPlayer = self.props.soundPlayer, - notifications = self.state.notifications, - onClose = function(index) - self:closeNotification(index) - end, + RojoNotifications = e("ScreenGui", {}, { + layout = e("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Right, + VerticalAlignment = Enum.VerticalAlignment.Bottom, + Padding = UDim.new(0, 5), + }), + padding = e("UIPadding", { + PaddingTop = UDim.new(0, 5); + PaddingBottom = UDim.new(0, 5); + PaddingLeft = UDim.new(0, 5); + PaddingRight = UDim.new(0, 5); + }), + notifs = e(Notifications, { + soundPlayer = self.props.soundPlayer, + notifications = self.state.notifications, + onClose = function(index) + self:closeNotification(index) + end, + }), }), }),