From c43726bc753ef8d4563594b122dd2bd872a32095 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 20 Aug 2023 12:37:40 -0700 Subject: [PATCH] Confirmation behaviors (#774) --- CHANGELOG.md | 2 + plugin/src/App/Components/TextInput.lua | 103 ++++++++++++++++++ .../src/App/StatusPages/Settings/Setting.lua | 89 +++++++++------ plugin/src/App/StatusPages/Settings/init.lua | 46 +++++++- plugin/src/App/Theme.lua | 28 +++++ plugin/src/App/init.lua | 27 +++++ plugin/src/PatchSet.lua | 24 +++- plugin/src/Settings.lua | 2 + 8 files changed, 281 insertions(+), 40 deletions(-) create mode 100644 plugin/src/App/Components/TextInput.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 2079b0610..7f26d92e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ * Don't override the initial enabled state for source diffing ([#760]) * Added support for `Terrain.MaterialColors` ([#770]) * Allow `Terrain` to be specified without a classname ([#771]) +* Add Confirmation Behavior setting ([#774]) [#761]: https://github.com/rojo-rbx/rojo/pull/761 [#745]: https://github.com/rojo-rbx/rojo/pull/745 @@ -54,6 +55,7 @@ [#760]: https://github.com/rojo-rbx/rojo/pull/760 [#770]: https://github.com/rojo-rbx/rojo/pull/770 [#771]: https://github.com/rojo-rbx/rojo/pull/771 +[#774]: https://github.com/rojo-rbx/rojo/pull/774 ## [7.3.0] - April 22, 2023 * Added `$attributes` to project format. ([#574]) diff --git a/plugin/src/App/Components/TextInput.lua b/plugin/src/App/Components/TextInput.lua new file mode 100644 index 000000000..94a76a970 --- /dev/null +++ b/plugin/src/App/Components/TextInput.lua @@ -0,0 +1,103 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) +local Flipper = require(Packages.Flipper) + +local Theme = require(Plugin.App.Theme) +local Assets = require(Plugin.Assets) +local bindingUtil = require(Plugin.App.bindingUtil) + +local SlicedImage = require(script.Parent.SlicedImage) + +local SPRING_PROPS = { + frequency = 5, + dampingRatio = 1, +} + +local e = Roact.createElement + +local TextInput = Roact.Component:extend("TextInput") + +function TextInput:init() + self.motor = Flipper.GroupMotor.new({ + hover = 0, + enabled = self.props.enabled and 1 or 0, + }) + self.binding = bindingUtil.fromMotor(self.motor) +end + +function TextInput:didUpdate(lastProps) + if lastProps.enabled ~= self.props.enabled then + self.motor:setGoal({ + enabled = Flipper.Spring.new(self.props.enabled and 1 or 0), + }) + end +end + +function TextInput:render() + return Theme.with(function(theme) + theme = theme.TextInput + + local bindingHover = bindingUtil.deriveProperty(self.binding, "hover") + local bindingEnabled = bindingUtil.deriveProperty(self.binding, "enabled") + + return e(SlicedImage, { + slice = Assets.Slices.RoundedBorder, + color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BorderColor, theme.Disabled.BorderColor), + transparency = self.props.transparency, + + size = self.props.size or UDim2.new(1, 0, 1, 0), + position = self.props.position, + layoutOrder = self.props.layoutOrder, + anchorPoint = self.props.anchorPoint, + }, { + HoverOverlay = e(SlicedImage, { + slice = Assets.Slices.RoundedBackground, + color = theme.ActionFillColor, + transparency = Roact.joinBindings({ + hover = bindingHover:map(function(value) + return 1 - value + end), + transparency = self.props.transparency, + }):map(function(values) + return bindingUtil.blendAlpha({ theme.ActionFillTransparency, values.hover, values.transparency }) + end), + size = UDim2.new(1, 0, 1, 0), + zIndex = -1, + }), + Input = e("TextBox", { + BackgroundTransparency = 1, + Size = UDim2.fromScale(1, 1), + Text = self.props.text, + PlaceholderText = self.props.placeholder, + Font = Enum.Font.GothamMedium, + TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Disabled.TextColor, theme.Enabled.TextColor), + PlaceholderColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Disabled.PlaceholderColor, theme.Enabled.PlaceholderColor), + TextSize = 18, + TextEditable = self.props.enabled, + ClearTextOnFocus = self.props.clearTextOnFocus, + + [Roact.Event.MouseEnter] = function() + self.motor:setGoal({ + hover = Flipper.Spring.new(1, SPRING_PROPS), + }) + end, + + [Roact.Event.MouseLeave] = function() + self.motor:setGoal({ + hover = Flipper.Spring.new(0, SPRING_PROPS), + }) + end, + + [Roact.Event.FocusLost] = function(rbx) + self.props.onEntered(rbx.Text) + end, + }), + Children = Roact.createFragment(self.props[Roact.Children]), + }) + end) +end + +return TextInput diff --git a/plugin/src/App/StatusPages/Settings/Setting.lua b/plugin/src/App/StatusPages/Settings/Setting.lua index 79e83e23a..2532222d0 100644 --- a/plugin/src/App/StatusPages/Settings/Setting.lua +++ b/plugin/src/App/StatusPages/Settings/Setting.lua @@ -32,6 +32,7 @@ local Setting = Roact.Component:extend("Setting") function Setting:init() self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) + self.inputSize, self.setInputSize = Roact.createBinding(Vector2.new(0, 0)) self:setState({ setting = Settings:get(self.props.id), @@ -65,43 +66,56 @@ function Setting:render() self.setContainerSize(object.AbsoluteSize) end, }, { - Input = if self.props.options ~= nil then - e(Dropdown, { - locked = self.props.locked, - options = self.props.options, - active = self.state.setting, - transparency = self.props.transparency, - position = UDim2.new(1, 0, 0.5, 0), - anchorPoint = Vector2.new(1, 0.5), - onClick = function(option) - Settings:set(self.props.id, option) - end, - }) - else - e(Checkbox, { - locked = self.props.locked, - active = self.state.setting, - transparency = self.props.transparency, - position = UDim2.new(1, 0, 0.5, 0), - anchorPoint = Vector2.new(1, 0.5), - onClick = function() - local currentValue = Settings:get(self.props.id) - Settings:set(self.props.id, not currentValue) + RightAligned = Roact.createElement("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 1, 0), + }, { + Layout = e("UIListLayout", { + VerticalAlignment = Enum.VerticalAlignment.Center, + HorizontalAlignment = Enum.HorizontalAlignment.Right, + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 2), + [Roact.Change.AbsoluteContentSize] = function(rbx) + self.setInputSize(rbx.AbsoluteContentSize) end, }), - Reset = if self.props.onReset then e(IconButton, { - icon = Assets.Images.Icons.Reset, - iconSize = 24, - color = theme.BackButtonColor, - transparency = self.props.transparency, - visible = self.props.showReset, - - position = UDim2.new(1, -32 - (self.props.options ~= nil and 120 or 40), 0.5, 0), - anchorPoint = Vector2.new(0, 0.5), + Input = + if self.props.input ~= nil then + self.props.input + elseif self.props.options ~= nil then + e(Dropdown, { + locked = self.props.locked, + options = self.props.options, + active = self.state.setting, + transparency = self.props.transparency, + onClick = function(option) + Settings:set(self.props.id, option) + end, + }) + else + e(Checkbox, { + locked = self.props.locked, + active = self.state.setting, + transparency = self.props.transparency, + onClick = function() + local currentValue = Settings:get(self.props.id) + Settings:set(self.props.id, not currentValue) + end, + }), + + Reset = if self.props.onReset then e(IconButton, { + icon = Assets.Images.Icons.Reset, + iconSize = 24, + color = theme.BackButtonColor, + transparency = self.props.transparency, + visible = self.props.showReset, + layoutOrder = -1, - onClick = self.props.onReset, - }) else nil, + onClick = self.props.onReset, + }) else nil, + }), Text = e("Frame", { Size = UDim2.new(1, 0, 1, 0), @@ -133,12 +147,15 @@ function Setting:render() TextWrapped = true, RichText = true, - Size = self.containerSize:map(function(value) + Size = Roact.joinBindings({ + containerSize = self.containerSize, + inputSize = self.inputSize, + }):map(function(values) local desc = (if self.props.experimental then "[Experimental] " else "") .. self.props.description - local offset = (self.props.onReset and 34 or 0) + (self.props.options ~= nil and 120 or 40) + local offset = values.inputSize.X + 5 local textBounds = getTextBounds( desc, 14, Enum.Font.Gotham, 1.2, - Vector2.new(value.X - offset, math.huge) + Vector2.new(values.containerSize.X - offset, math.huge) ) return UDim2.new(1, -offset, 0, textBounds.Y) end), diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index 298e39a42..3a08b1e74 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -12,6 +12,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 TextInput = require(Plugin.App.Components.TextInput) local Setting = require(script.Setting) local e = Roact.createElement @@ -25,6 +26,7 @@ local function invertTbl(tbl) end local invertedLevels = invertTbl(Log.Level) +local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId" } local function Navbar(props) return Theme.with(function(theme) @@ -104,12 +106,50 @@ function SettingsPage:render() layoutOrder = 2, }), + ConfirmationBehavior = e(Setting, { + id = "confirmationBehavior", + name = "Confirmation Behavior", + description = "When to prompt for confirmation before syncing", + transparency = self.props.transparency, + layoutOrder = 3, + + options = confirmationBehaviors, + }), + + LargeChangesConfirmationThreshold = e(Setting, { + id = "largeChangesConfirmationThreshold", + name = "Confirmation Threshold", + description = "How many modified instances to be considered a large change", + transparency = self.props.transparency, + layoutOrder = 4, + visible = Settings:getBinding("confirmationBehavior"):map(function(value) + return value == "Large Changes" + end), + input = e(TextInput, { + size = UDim2.new(0, 40, 0, 28), + text = Settings:getBinding("largeChangesConfirmationThreshold"):map(function(value) + return tostring(value) + end), + transparency = self.props.transparency, + enabled = true, + onEntered = function(text) + local number = tonumber(string.match(text, "%d+")) + if number then + Settings:set("largeChangesConfirmationThreshold", math.clamp(number, 1, 999)) + else + -- Force text back to last valid value + Settings:set("largeChangesConfirmationThreshold", Settings:get("largeChangesConfirmationThreshold")) + end + end, + }), + }), + PlaySounds = e(Setting, { id = "playSounds", name = "Play Sounds", description = "Toggle sound effects", transparency = self.props.transparency, - layoutOrder = 3, + layoutOrder = 5, }), OpenScriptsExternally = e(Setting, { @@ -119,7 +159,7 @@ function SettingsPage:render() locked = self.props.syncActive, experimental = true, transparency = self.props.transparency, - layoutOrder = 4, + layoutOrder = 6, }), TwoWaySync = e(Setting, { @@ -129,7 +169,7 @@ function SettingsPage:render() locked = self.props.syncActive, experimental = true, transparency = self.props.transparency, - layoutOrder = 5, + layoutOrder = 7, }), LogLevel = e(Setting, { diff --git a/plugin/src/App/Theme.lua b/plugin/src/App/Theme.lua index 2bb355d9e..00aab81fe 100644 --- a/plugin/src/App/Theme.lua +++ b/plugin/src/App/Theme.lua @@ -74,6 +74,20 @@ local lightTheme = strict("LightTheme", { IconColor = Color3.fromHex("EEEEEE"), }, }, + TextInput = { + Enabled = { + TextColor = Color3.fromHex("000000"), + PlaceholderColor = Color3.fromHex("8C8C8C"), + BorderColor = Color3.fromHex("ACACAC"), + }, + Disabled = { + TextColor = Color3.fromHex("393939"), + PlaceholderColor = Color3.fromHex("8C8C8C"), + BorderColor = Color3.fromHex("AFAFAF"), + }, + ActionFillColor = Color3.fromHex("000000"), + ActionFillTransparency = 0.9, + }, AddressEntry = { TextColor = Color3.fromHex("000000"), PlaceholderColor = Color3.fromHex("8C8C8C") @@ -170,6 +184,20 @@ local darkTheme = strict("DarkTheme", { IconColor = Color3.fromHex("484848"), }, }, + TextInput = { + Enabled = { + TextColor = Color3.fromHex("FFFFFF"), + PlaceholderColor = Color3.fromHex("8B8B8B"), + BorderColor = Color3.fromHex("535353"), + }, + Disabled = { + TextColor = Color3.fromHex("484848"), + PlaceholderColor = Color3.fromHex("8B8B8B"), + BorderColor = Color3.fromHex("5A5A5A"), + }, + ActionFillColor = Color3.fromHex("FFFFFF"), + ActionFillTransparency = 0.9, + }, AddressEntry = { TextColor = Color3.fromHex("FFFFFF"), PlaceholderColor = Color3.fromHex("8B8B8B") diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 1dc88c1ea..786b559ea 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -57,6 +57,7 @@ function App:init() self.confirmationBindable = Instance.new("BindableEvent") self.confirmationEvent = self.confirmationBindable.Event + self.knownProjects = {} self.notifId = 0 self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string) @@ -416,6 +417,8 @@ function App:startSession() }) self:addNotification("Connecting to session...") elseif status == ServeSession.Status.Connected then + self.knownProjects[details] = true + local address = ("%s:%s"):format(host, port) self:setState({ appStatus = AppStatus.Connected, @@ -462,6 +465,30 @@ function App:startSession() return "Accept" end + local confirmationBehavior = Settings:get("confirmationBehavior") + if confirmationBehavior == "Initial" then + -- Only confirm if we haven't synced this project yet this session + if self.knownProjects[serverInfo.projectName] then + Log.trace("Accepting patch without confirmation because project has already been connected and behavior is set to Initial") + return "Accept" + end + elseif confirmationBehavior == "Large Changes" then + -- Only confirm if the patch impacts many instances + if PatchSet.countInstances(patch) < Settings:get("largeChangesConfirmationThreshold") then + Log.trace("Accepting patch without confirmation because patch is small and behavior is set to Large Changes") + return "Accept" + end + elseif confirmationBehavior == "Unlisted PlaceId" then + -- Only confirm if the current placeId is not in the servePlaceIds allowlist + if serverInfo.expectedPlaceIds then + local isListed = table.find(serverInfo.expectedPlaceIds, game.PlaceId) ~= nil + if isListed then + Log.trace("Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId") + return "Accept" + end + end + end + -- The datamodel name gets overwritten by Studio, making confirmation of it intrusive -- and unnecessary. This special case allows it to be accepted without confirmation. if diff --git a/plugin/src/PatchSet.lua b/plugin/src/PatchSet.lua index a9bebd802..a7829de05 100644 --- a/plugin/src/PatchSet.lua +++ b/plugin/src/PatchSet.lua @@ -116,7 +116,7 @@ function PatchSet.containsId(patchSet, instanceMap, id) end --[[ - Tells whether the given PatchSet contains changes to the given instance. + Tells whether the given PatchSet contains changes to the given instance. If the given InstanceMap does not contain the instance, this function always returns false. ]] function PatchSet.containsInstance(patchSet, instanceMap, instance) @@ -235,6 +235,28 @@ function PatchSet.countChanges(patch) return count end +--[[ + Count the number of instances affected by the given PatchSet. +]] +function PatchSet.countInstances(patch) + local count = 0 + + -- Added instances + for _ in patch.added do + count += 1 + end + -- Removed instances + for _ in patch.removed do + count += 1 + end + -- Updated instances + for _ in patch.updated do + count += 1 + end + + return count +end + --[[ Merge multiple PatchSet objects into the given PatchSet. ]] diff --git a/plugin/src/Settings.lua b/plugin/src/Settings.lua index f6368a4d5..194dd4c7a 100644 --- a/plugin/src/Settings.lua +++ b/plugin/src/Settings.lua @@ -14,6 +14,8 @@ local defaultSettings = { twoWaySync = false, showNotifications = true, syncReminder = true, + confirmationBehavior = "Initial", + largeChangesConfirmationThreshold = 5, playSounds = true, typecheckingEnabled = false, logLevel = "Info",