spinbox.lua

--- A Geyser object to create a spinbox for adjusting a number
-- @classmod spinbox
-- @author Damian Monogue <demonnic@gmail.com>
-- @copyright 2023
-- @license MIT, see https://raw.githubusercontent.com/demonnic/MDK/main/src/scripts/LICENSE.lua
local spinbox = {
  parent = Geyser.Container,
  name = 'SpinboxClass',
  min = 0,
  max = 10,
  delta = 1,
  value = 0,
  activeButtonColor = "gray",
  inactiveButtonColor = "DimGray",
  integer = true,
  upArrowLocation = "https://demonnic.github.io/image-assets/uparrow.png",
  downArrowLocation = "https://demonnic.github.io/image-assets/downarrow.png",
  color = "#202020"
}
spinbox.__index = spinbox
setmetatable(spinbox, spinbox.parent)

local gss = Geyser.StyleSheet
local directory = getMudletHomeDir() .. "/spinbox/"
local saveFile = directory .. "fileLocations.lua"
if not io.exists(directory) then
  lfs.mkdir(directory)
end

--- Creates a new spinbox.
-- @tparam table cons a table containing the options for this spinbox.
-- <table class="tg">
-- <thead>
--   <tr>
--     <th>option name</th>
--     <th>description</th>
--     <th>default</th>
--   </tr>
-- </thead>
-- <tbody>
--   <tr>
--     <td class="tg-1">min</td>
--     <td class="tg-1">The minimum value for this spinbox</td>
--     <td class="tg-1">0</td>
--   </tr>
--   <tr>
--     <td class="tg-2">max</td>
--     <td class="tg-2">The maximum value for this spinbox</td>
--     <td class="tg-2">10</td>
--   </tr>
--   <tr>
--     <td class="tg-1">activeButtonColor</td>
--     <td class="tg-1">The color the up/down buttons should be when they are active/able to be used</td>
--     <td class="tg-1">gray</td>
--   </tr>
--   <tr>
--     <td class="tg-2">inactiveButtonColor</td>
--     <td class="tg-2">The color the up/down buttons should be when they are inactive/unable to be used</td>
--     <td class="tg-2">dimgray</td>
--   </tr>
--   <tr>
--     <td class="tg-1">integer</td>
--     <td class="tg-1">Boolean value. When true, values must always be integers (no decimal place)</td>
--     <td class="tg-1">true</td>
--   </tr>
--   <tr>
--     <td class="tg-2">delta</td>
--     <td class="tg-2">The amount to change the spinbox's value when the up or down button is pressed.</td>
--     <td class="tg-2">1</td>
--   </tr>
--   <tr>
--     <td class="tg-1">upArrowLocation</td>
--     <td class="tg-1">The location of the up arrow image. Either a web URL where it can be downloaded, or the location on disk to read it from</td>
--     <td class="tg-1">https://demonnic.github.io/image-assets/uparrow.png</td>
--   </tr>
--   <tr>
--     <td class="tg-2">downArrowLocation</td>
--     <td class="tg-2">The location of the down arrow image. Either a web URL where it can be downloaded, or the location on disk to read it from</td>
--     <td class="tg-2">https://demonnic.github.io/image-assets/downarrow.png</td>
--   <tr>
--     <td class="tg-1">callBack</td>
--     <td class="tg-1">The function to run when the spinbox's value is updated. Is called with parameters (self.name, value, oldValue)</td>
--     <td class="tg-1">nil</td>
--   </tr>
--   </tr>
--</tbody>
--</table>
-- @param container The Geyser container for this spinbox
function spinbox:new(cons, container)
  cons = cons or {}
  local consType = type(cons)
  if consType ~= "table" then
    printError(f"spinbox:new(cons, container): cons as table of options expected, got {consType}!", true, true)
  end
  cons.name = cons.name or Geyser.nameGen("spinbox")
  local me = self.parent:new(cons, container)
  setmetatable(me, self)
  me:createComponents()
  if me.callBack then
    me:setCallBack(me.callBack)
  end
  me.oldValue = me.value
  return me
end

--- Creates the components that make up the spinbox UI.
-- @local
-- Obtains the up and down arrow images specified in the spinbox options.
-- Generates styles for the spinbox.
-- Calculates the height of the up/down buttons and any remainder space.
-- Creates:
--   `self.upButton` - A button with an up arrow image for incrementing the value
--   `self.downButton` - A button with a down arrow image for decrementing the value
--   `self.displayLabel` - A label to display the current spinbox value
--   `self.input` - A command line input to allow directly entering a value
-- Hides the input by default.
-- Applies the generated styles.
function spinbox:createComponents()
  self:obtainImages()
  self:generateStyles()
  self:calculateButtonDimensions()

  self.upButton = self:createButton("up")
  self.downButton = self:createButton("down")

  self.displayLabel = self:createDisplayLabel()

  self.input = self:createInput()
  self.input:hide()

  self:applyStyles()
end

--- Calculates the button height. We use square buttons in this house.
-- @local
-- Calculates the height of the up/down buttons by dividing the spinbox height in half.
-- Stores the remainder (if any) in self.remainder.
-- Stores the calculated button height in self.buttonHeight.
function spinbox:calculateButtonDimensions()
  self.buttonHeight = math.floor(self.get_height() / 2)
  self.remainder = self.get_height() % 2
end

--- Creates a button (up or down arrow) for the spinbox.
-- @param type Either "up" or "down" to specify which direction the arrow should point
-- @return The created Geyser.Label button
-- @local
-- Creates a Geyser.Label button with an up or down arrow image.
-- Positions the button at the top or bottom of the spinbox respectively.
-- Sets a click callback on the button to call increment() or decrement() depending on the type.
-- Returns the created button.
function spinbox:createButton(type)
  local button = Geyser.Label:new({
    name = self.name .. "spinbox_"..type.."Arrow",
    height = self.buttonHeight,
    width = self.buttonHeight,
    x = "100%-" .. self.buttonHeight,
    y = type == "up" and 0 or self.buttonHeight + self.remainder,
  }, self)

  button:setClickCallback(function()
    if type == "up" then
      self:increment()
    else
      self:decrement()
    end
  end)
  return button
end

--- Creates the display label for the spinbox value.
-- @return The created Geyser.Label display label
-- @local
-- Creates a Geyser.Label to display the current spinbox value.
-- Centers the text in the label.
-- Sets a double click callback on the label to show the input, put the current
-- value in it, select the text, and hide the label.
-- Returns the created display label.
function spinbox:createDisplayLabel()
  local displayLabel = Geyser.Label:new({
    name = self.name .. "spinbox_displayLabel",
    x = 0,
    y = 0,
    width = "100%-" .. self.buttonHeight,
    height = "100%",
    message = self.value
  }, self)
  displayLabel:setAlignment("center")
  displayLabel:setDoubleClickCallback(function()
    self.input:show()
    self.input:print(self.value)
    self.input:selectText()
    displayLabel:hide()
  end)
  return displayLabel
end

--- Creates the input for directly entering a spinbox value.
-- @return The created Geyser.CommandLine input
-- @local
-- Creates a Geyser.CommandLine input.
-- Sets an action on the input to:
--   - Attempt to convert the input text to a number
--   - If successful, call setValue() with the number to set the spinbox value
--   - Hide the input
--   - Show the display label
--   - Put the new spinbox value in the input
-- Returns the created input.
function spinbox:createInput()
  local input = Geyser.CommandLine:new({
    x = 0,
    y = 0,
    width = "100%-".. self.buttonHeight,
    height = "100%",
  }, self)
  input:setAction(function(txt)
    txt = tonumber(txt)
    if txt then
      self:setValue(txt)
      input:hide()
    end
    self.displayLabel:show()
    input:print(self.value)
  end)
  return input
end

--- Used to increment the value by the delta amount
-- @local
-- Increments the spinbox value by the delta amount.
-- Checks if the new value would exceed the max, and if so sets it to the max.
-- Updates the display label with the new value.
-- Applies any styles that depend on the value.
function spinbox:increment()
  local val = self.value + self.delta
  if val >= self.max then
    val = self.max
  end
  self.oldValue = self.value
  self.value = val
  self.displayLabel:echo(val)
  self:applyStyles()
  self:handleCallBacks()
end

--- Used to decrement the value by the delta amount
-- @local
-- Decrements the spinbox value by the delta amount.
-- Checks if the new value would be below the min, and if so sets it to the min.
-- Updates the display label with the new value.
-- Applies any styles that depend on the value.
function spinbox:decrement()
  local val = self.value - self.delta
  if val <= self.min then
    val = self.min
  end
  self.oldValue = self.value
  self.value = val
  self.displayLabel:echo(val)
  self:applyStyles()
  self:handleCallBacks()
end

--- Used to directly set the value of the spinbox.
-- @param value The new value to set
-- Rounds the value to an integer if the spinbox is integer only.
-- Checks if the new value is within the min/max range and clamps it if not.
-- Updates the display label with the new value.
-- Applies any styles that depend on the value.
function spinbox:setValue(value)
  if self.integer then
    value = math.floor(value)
  end
  if value >= self.max then
    value = self.max
  elseif value <= self.min then
    value = self.min
  end
  self.oldValue = self.value
  self.value = value
  self.displayLabel:echo(value)
  self:applyStyles()
  self:handleCallBacks()
end

--- Obtains the up and down arrow images for the spinbox.
-- @local
-- Gets the previously saved file locations.
-- Checks if the up arrow image exists at the upArrowLocation.
-- If not, it will download the image from a URL or copy a local file. It saves
-- the new location.
-- Does the same for the down arrow image and downArrowLocation.
-- Saves any new locations to the save file.
-- Sets self.upArrowFile and self.downArrowFile to the locations of the images.
function spinbox:obtainImages()
  local locations = self:getFileLocs()
  local upURL = self.upArrowLocation
  local downURL = self.downArrowLocation
  local upFile = locations[upURL]
  local downFile = locations[downURL]
  local locationsChanged = false
  if not (upFile and io.exists(upFile)) then
    if not upFile then
      upFile = directory .. self.name .. "/uparrow.png"
      locations[upURL] = upFile
      locationsChanged = true
    end
    if upURL:match("^http") then
      self:downloadFile(upURL, upFile)
    elseif io.exists(upURL) then
      upFile = upURL
      locations[upURL] = upFile
      locationsChanged = true
    end
  end
  if not (downFile and io.exists(downFile)) then
    if not downFile then
      downFile = directory .. self.name .. "/downarrow.png"
      locations[downURL] = downFile
      locationsChanged = true
    end
    if downURL:match("^http") then
      self:downloadFile(downURL, downFile)
    elseif io.exists(downURL) then
      downFile = downURL
      locations[downURL] = downFile
      locationsChanged = true
    end
  end
  self.upArrowFile = upFile
  self.downArrowFile = downFile
  if locationsChanged then
    table.save(saveFile, locations)
  end
end

--- Handles the actual download of a file from a url
-- @param url The url to download the file from
-- @param fileName The location to save the downloaded file
-- @local
-- Creates any missing directories in the file path.
-- Registers named event handlers to handle the download completing or erroring.
-- The completion handler stops the error handler.
-- The error handler prints an error message and stops the completion handler.
-- Downloads the file from the url to the fileName location.
function spinbox:downloadFile(url, fileName)
  local parts = fileName:split("/")
  parts[#parts] = nil
  local dirName = table.concat(parts, "/") .. "/"
  if not io.exists(dirName) then
    lfs.mkdir(dirName)
  end
  local uname = "spinbox"
  local handlerName = self.name .. url
  local handler = function(event, ...)
    local args = {...}
    local file = #args == 1 and args[1] or args[2]
    if file ~= fileName then
      return true
    end
    if event == "sysDownloadDone" then
      debugc(f"INFO:Spinbox successfully downloaded {file}")
      stopNamedEventHandler(uname, handlerName .. "error")
      return false
    end
    cecho(f"\n<red>ERROR:<reset>Spinbox had an issue downloading an image file to {file}: {args[1]}\n")
    stopNamedEventHandler(uname, handlerName .. "done")
  end
  registerNamedEventHandler(uname, handlerName .. "done", "sysDownloadDone", handler, true)
  registerNamedEventHandler(uname, handlerName .. "error", "sysDownloadError", handler, true)
  downloadFile(fileName, url)
end

--- Responsible for reading the file locations from disk and returning them
-- @local
function spinbox:getFileLocs()
  local locations = {}
  if io.exists(saveFile) then
    table.load(saveFile, locations)
  end
  return locations
end

--- (Re)generates the stylesheets for the spinbox
-- Should not need to call but if you change something and it doesn't take effect
-- you can try calling this followed by applyStyles
function spinbox:generateStyles()
  self.baseStyle = gss:new([[
    border-radius: 2px;
    border-color: black;
  ]])
  self.activeStyle = gss:new(f[[
    background-color: {self.activeButtonColor};
  ]], self.baseStyle)
  self.inactiveStyle = gss:new(f[[
    background-color: {self.inactiveButtonColor};
  ]], self.baseStyle)
  self.upStyle = gss:new(f[[
    border-image: url("{self.upArrowFile}");
  ]])
  self.downStyle = gss:new(f[[
    border-image: url("{self.downArrowFile}");
  ]])
  self.displayStyle = gss:new(f[[
    background-color: {Geyser.Color.hex(self.color)};
    text-align: center;
  ]], self.baseStyle)
end

--- Applies updated stylesheets to the components of the spinbox
-- Should not need to call this directly
function spinbox:applyStyles()
  if self.value >= self.max then
    self.upStyle:setParent(self.inactiveStyle)
  else
    self.upStyle:setParent(self.activeStyle)
  end
  if self.value <= self.min then
    self.downStyle:setParent(self.inactiveStyle)
  else
    self.downStyle:setParent(self.activeStyle)
  end
  self.upButton:setStyleSheet(self.upStyle:getCSS())
  self.downButton:setStyleSheet(self.downStyle:getCSS())
  self.displayLabel:setStyleSheet(self.displayStyle:getCSS())
end

--- sets the color for active buttons on the spinbox
-- @param color any valid color formatting string, such a "red" or "#880000" or "<128,0,0>" or a table of colors, like {128, 0,0}. See Geyser.Color.parse at https://www.mudlet.org/geyser/files/geyser/GeyserColor.html#Geyser.Color.parse
function spinbox:setActiveButtonColor(color)
  local colorType = type(color)
  local hex
  if colorType == "table" then
    hex = Geyser.Color.hex(unpack(color))
  else
    hex = Geyser.Color.hex(color)
  end
  self.activeButtonColor = hex
  self.activeStyle:set("background-color", hex)
  self:applyStyles()
end

--- sets the color for inactive buttons on the spinbox
-- @param color any valid color formatting string, such a "<red>" or "red" or "<128,0,0>" or a table of colors, like {128, 0,0}. See Geyser.Color.parse at https://www.mudlet.org/geyser/files/geyser/GeyserColor.html#Geyser.Color.parse
function spinbox:setInactiveButtonColor(color)
  local colorType = type(color)
  local hex
  if colorType == "table" then
    hex = Geyser.Color.hex(unpack(color))
  else
    hex = Geyser.Color.hex(color)
  end
  self.inactiveButtonColor = hex
  self.inactiveStyle:set("background-color", hex)
  self:applyStyles()
end

-- internal function that handles calling a registered callback and raising an event any time the
-- spinbox value is changed, whether using the buttons or the :set function.
function spinbox:handleCallBacks()
  raiseEvent("spinbox updated", self.name, self.value, self.oldValue)
  if self.callBack then
    local ok, err = pcall(self.callBack, self.name, self.value, self.oldValue)
    if not ok then
      printError(f"Had an issue running the callback handler for spinbox named {self.name}: {err}", true, true)
    end
  end
end

--- Set a callback function for the spinbox to call any time the value of the spinbox is changed
-- the function will be called as func(self.value, self.name)
function spinbox:setCallBack(func)
  local funcType = type(func)
  if funcType ~= "function" then
    printError(f"spinbox:setCallBack(func): func as function required, got {funcType}", true, true)
  end
  self.callBack = func
  return true
end

return spinbox
generated by LDoc 1.5.0 Last updated 2023-05-29 18:41:27