diff --git a/src/draggable.luau b/src/draggable.luau index a0a58ef..409a6ff 100644 --- a/src/draggable.luau +++ b/src/draggable.luau @@ -1,93 +1,198 @@ local UserInputService = game and game:GetService("UserInputService") or nil +local function clamp(x, a, b) + if x < a then return a end + if x > b then return b end + return x +end + +local function lerp(a, b, t) + return a + (b - a) * t +end + +local function invLerp(a, b, v) + if a == b then return 0 end + return (v - a) / (b - a) +end + +local function quantize(v, step) + if not step or step <= 0 then return v end + return math.floor((v / step) + 0.5) * step +end + -- Factory: inject Vide functions to avoid cyclic requires -return function(action, cleanup) - return function(opts) - opts = opts or {} - local axis = opts.axis or "both" -- "x" | "y" | "both" +return function(create, source, action, cleanup) + return function(props) + props = props or {} - return action(function(gui) - -- Non-Roblox / unit-test environment: allow requiring this module without crashing. - -- Draggable behaviour is disabled when UserInputService is unavailable. - if not UserInputService then - return - end + local minV = props.min or 0 + local maxV = props.max or 1 + local step = props.step + local value = props.value + assert(value, "vide.slider: props.value (source) is required") - -- Optional hardening: ignore non-GuiObject targets - if typeof(gui) ~= "Instance" or not gui:IsA("GuiObject") then - return - end + local theme = props.theme or {} + local format = props.format or function(v) return tostring(v) end + local onChanged = props.onChanged - local dragging = false - local dragInput - local dragStart - local startPos + local trackRef = source(nil) - local function update(input) - local delta = input.Position - dragStart + local function setFromX(screenX) + local track = trackRef() + if not track then return end - local xOff = startPos.X.Offset - local yOff = startPos.Y.Offset + local absPosX = track.AbsolutePosition.X + local absSizeX = track.AbsoluteSize.X + if absSizeX <= 0 then return end - if axis == "x" or axis == "both" then - xOff = startPos.X.Offset + delta.X - end - if axis == "y" or axis == "both" then - yOff = startPos.Y.Offset + delta.Y - end + local t = clamp((screenX - absPosX) / absSizeX, 0, 1) + local v = lerp(minV, maxV, t) + v = quantize(v, step) - gui.Position = UDim2.new(startPos.X.Scale, xOff, startPos.Y.Scale, yOff) + value(v) + if onChanged then onChanged(v) end + end - if opts.onDrag then - opts.onDrag(gui.Position) - end - end + return create "Frame" { + Name = props.name or "Slider", + Size = props.size or UDim2.fromOffset(516, 90), + Position = props.position, + AnchorPoint = props.anchorPoint, + LayoutOrder = props.layoutOrder, + BackgroundTransparency = 1, - local beganConn = gui.InputBegan:Connect(function(input) - if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then - dragging = true - dragStart = input.Position - startPos = gui.Position + create "TextLabel" { + BackgroundTransparency = 1, + Size = UDim2.fromOffset(516, 24), + TextXAlignment = Enum.TextXAlignment.Left, + FontFace = props.fontFace, + TextSize = props.labelTextSize or 16, + TextColor3 = props.labelTextColor or Color3.fromRGB(235, 238, 245), + Text = props.label or "Slider", + }, - if opts.onDragStart then - opts.onDragStart(startPos) - end + create "TextLabel" { + BackgroundTransparency = 1, + Size = UDim2.fromOffset(516, 24), + TextXAlignment = Enum.TextXAlignment.Right, + FontFace = props.fontFace, + TextSize = props.valueTextSize or 16, + TextColor3 = props.valueTextColor or Color3.fromRGB(200, 206, 220), + Text = function() + return format(value()) + end, + }, - local changedConn - changedConn = input.Changed:Connect(function() - if input.UserInputState == Enum.UserInputState.End then - dragging = false - if changedConn then changedConn:Disconnect() end + create "Frame" { + Name = "Track", + Position = UDim2.fromOffset(0, 38), + Size = UDim2.fromOffset(516, 16), + BackgroundColor3 = theme.trackColor or Color3.fromRGB(20, 22, 28), + BackgroundTransparency = theme.trackTransparency or 0.15, - if opts.onDragEnd then - opts.onDragEnd(gui.Position) - end + action(function(track) + trackRef(track) + + local trackBeganConn = track.InputBegan:Connect(function(input) + if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then + setFromX(input.Position.X) end end) cleanup(function() - if changedConn then changedConn:Disconnect() end + if trackRef() == track then trackRef(nil) end + trackBeganConn:Disconnect() end) - end - end) + end), - local changedConn = gui.InputChanged:Connect(function(input) - if input.UserInputType == Enum.UserInputType.MouseMovement or input.UserInputType == Enum.UserInputType.Touch then - dragInput = input - end - end) + create "UICorner" { CornerRadius = UDim.new(0, theme.cornerRadius or 8) }, + create "UIStroke" { + Thickness = 1, + Color = theme.strokeColor or Color3.fromRGB(255, 255, 255), + Transparency = theme.strokeTransparency or 0.8, + }, - local uisConn = UserInputService.InputChanged:Connect(function(input) - if input == dragInput and dragging then - update(input) - end - end) + create "Frame" { + Name = "Fill", + Size = function() + local t = clamp(invLerp(minV, maxV, value()), 0, 1) + return UDim2.fromScale(t, 1) + end, + BackgroundColor3 = theme.fillColor or Color3.fromRGB(80, 140, 255), + BackgroundTransparency = theme.fillTransparency or 0.15, + create "UICorner" { CornerRadius = UDim.new(0, theme.cornerRadius or 8) }, + }, - cleanup(function() - beganConn:Disconnect() - changedConn:Disconnect() - uisConn:Disconnect() - end) - end) + create "TextButton" { + Name = "Knob", + AutoButtonColor = false, + Text = "", + Size = UDim2.fromOffset(24, 24), + AnchorPoint = Vector2.new(0.5, 0.5), + Position = function() + local t = clamp(invLerp(minV, maxV, value()), 0, 1) + return UDim2.fromScale(t, 0.5) + end, + BackgroundColor3 = theme.knobColor or Color3.fromRGB(235, 238, 245), + BackgroundTransparency = theme.knobTransparency or 0, + + create "UICorner" { CornerRadius = UDim.new(0, theme.knobRadius or 999) }, + create "UIStroke" { Thickness = 1, Color = Color3.fromRGB(0, 0, 0), Transparency = 0.75 }, + + action(function(btn) + -- Non-Roblox / unit-test environment: allow requiring this module without crashing. + -- Slider still renders; dragging is disabled when UserInputService is unavailable. + if not UserInputService then + return + end + + if typeof(btn) ~= "Instance" or not btn:IsA("GuiButton") then + return + end + + local dragInput + local dragging = false + local beganConn + local changedConn + local uisChangedConn + + local function update(input) + setFromX(input.Position.X) + end + + beganConn = btn.InputBegan:Connect(function(input) + if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then + dragging = true + update(input) + + changedConn = input.Changed:Connect(function() + if input.UserInputState == Enum.UserInputState.End then + dragging = false + end + end) + end + end) + + btn.InputChanged:Connect(function(input) + if input.UserInputType == Enum.UserInputType.MouseMovement or input.UserInputType == Enum.UserInputType.Touch then + dragInput = input + end + end) + + uisChangedConn = UserInputService.InputChanged:Connect(function(input) + if input == dragInput and dragging then + update(input) + end + end) + + cleanup(function() + if beganConn then beganConn:Disconnect() end + if changedConn then changedConn:Disconnect() end + if uisChangedConn then uisChangedConn:Disconnect() end + end) + end), + }, + }, + } end end