--!strict
-- I will document later. For now, good luck! 🫡

-- Parameters when using HexEditor.new(container, params, dependencies):

-- "fillContainer" makes the hex editor fill up the entire container (UDim2.fromScale(1, 1))
-- "fillContainerY" makes the hex hex editor fill up the Y-axis, but take up only as much space
-- in the X-axis as necessary.
export type EditorSizing = "fillContainer" | "fillContainerY"

export type HexEditorNewParams = {
	container: GuiObject,

	-- Default is "fillContainer".
	sizing: EditorSizing?,

	theme: ThemeData,
	layout: LayoutData,

	-- If you don't provide this, the default ones will be used.
	hotkeys: HotkeyData?,
}

-- Table of dependencies so you can mix and match for your use case.
export type HexEditorDependencies = {
	-- This will be used for hotkeys.
	inputReceiver: GuiObject | UserInputService,

	-- This is optional if you want to have right click functionality.
	forms: HexEditorFormProvider?,
}

export type HexEditorFormProvider = {
	promptContextMenu: (InputObject, options: { string }) -> string?,
	promptFillWith: (InputObject) -> buffer?,
	promptInsertNullBytes: (InputObject) -> number?,
}

-- These options are for the overall layout.
export type LayoutData = {
	columns: number,

	showDecodedText: boolean,

	showHeader: boolean,

	showGutter: boolean,
	gutterColumns: number?,
}

-- For customizing hotkeys.
export type HotkeyAction = "undo" | "redo" | "cycleCursorMode" | "selectAll"
export type HotkeyData = {
	{
		action: HotkeyAction,
		keyCode: Enum.KeyCode,
		modifierKeys: { Enum.ModifierKey }?,
	}
}

-- These options are for cosmetic things.
export type ThemeData = {
	cell: CellThemeData,
	modifiedCell: CellThemeData,
	addCell: CellThemeData,

	headerText: Color3?,
	headerActiveText: Color3,
	headerBackground: Color3?,

	gutterText: Color3?,
	gutterActiveText: Color3,
	gutterBackground: Color3?,

	font: Font,
	textSize: number,

	sectionGap: number,
	cellPadding: number,
	halfCellPadding: number,

	-- Properties to assign to the ScrollingFrame after it is created.
	scrollingFrameProperties: { [string]: unknown }?,
}

export type CellThemeData = {
	background: Color3,
	text: Color3,

	hover: Color3,
	hoverText: Color3,

	select: Color3,
	selectText: Color3,

	cursorActive: Color3,
	cursorActiveText: Color3,

	cursor: Color3,
}

--[[ Implementation ]]

-- TODO: Reduce amount of buffer.copy used while editing (main culprits are deletes and inserts).
--       Maybe have some kind of "virtual edits" and instead of using buffer.readX, have
--       a :_readBufferX(offset) method that accounts for these virtual edits and apply
--       the virtual edits as necessary.

local TextService = game:GetService("TextService")

local MAX_UNDOS = 24

-- Only one can be active at a time, so keep track.
local globalHiddenTextBox: TextBox?

export type HexEditorImplementation = {
	--[==[ Static ]==]
	__index: HexEditorImplementation,

	DEFAULT_HOTKEYS: HotkeyData,

	SELECTION_CONTEXT_MENU_OPTIONS: { string },
	SINGLE_BYTE_CONTEXT_MENU_OPTIONS: { string },
	ADD_CELL_CONTEXT_MENU_OPTIONS: { string },

	isCharacterPrintable: (ascii: number) -> boolean,

	--[==[ Non-static ]==]

	new: (buffer, HexEditorNewParams, HexEditorDependencies) -> HexEditor,

	setBuffer: (HexEditor, buffer) -> (),

	-- Gets the buffer the user sees in the editor. DO NOT MODIFY IT.
	getBuffer: (HexEditor) -> buffer,

	-- Gets the underlying buffer used for editing. DO NOT MODIFY IT!
	getRawBuffer: (HexEditor) -> (buffer, number),

	getCursorOffset: (HexEditor) -> number?,

	setTheme: (HexEditor, ThemeData) -> (),
	setLayout: (HexEditor, LayoutData) -> (),

	canUndo: (HexEditor) -> boolean,
	canRedo: (HexEditor) -> boolean,
	undo: (HexEditor) -> (),
	redo: (HexEditor) -> (),

	destroy: (HexEditor) -> (),

	--[==[ Private ]==]

	_setTheme: (HexEditor, ThemeData) -> (),

	-- Hidden text box
	_createHiddenTextBox: (HexEditor) -> (),
	_destroyHiddenTextBox: (HexEditor) -> (),

	-- Cursor stuff
	_cycleCursorMode: (HexEditor) -> (),
	_setCursorState: (HexEditor, offset: number?, view: CursorView, mode: CursorMode?, isInactive: boolean?) -> (),
	_incrementCursor: (HexEditor, amount: number?) -> (),

	-- Edits
	_isTyping: (HexEditor) -> boolean,
	_typeCharacter: (HexEditor, string) -> (),
	_finishTyping: (HexEditor) -> (), -- pushes an action (sometimes)
	_cancelTyping: (HexEditor) -> (),

	-- Operations that DON'T push an action
	_insertAt: (HexEditor, offset: number, value: number?) -> (),
	_insertBuffer: (HexEditor, offset: number, buffer: buffer) -> (),

	_deleteAt: (HexEditor, offset: number) -> (),
	_deleteRegion: (HexEditor, offset: number, length: number) -> (),

	_replaceAt: (HexEditor, offset: number, value: number, doNotModifyCount: boolean?) -> (),
	_replaceBuffer: (HexEditor, offset: number, buffer: buffer, modifyIncrement: number?) -> (),

	_growBuffer: (HexEditor, minimumGrow: number) -> (),

	_incrementModifiedCount: (HexEditor, offset: number, amount: number) -> (),
	_bumpModifiedOffsetsAfter: (HexEditor, after: number, amount: number) -> (),

	_setInModal: (HexEditor, inModal: boolean) -> (),

	--- Operations that DO push an action (lets you undo them)
	_insertBufferWithAction: (HexEditor, offset: number, buffer: buffer) -> (),

	_deleteAtCursorWithAction: (HexEditor) -> (),
	_deleteBeforeCursorWithAction: (HexEditor) -> (),
	_deleteSelectionWithAction: (HexEditor, keepCursor: boolean?) -> (),

	_replaceAtWithAction: (HexEditor, offset: number, value: number) -> (),
	_fillPatternWithAction: (HexEditor, offset: number, length: number, fillPattern: buffer) -> (),

	_pushAction: (HexEditor, Action) -> (),

	-- Selection
	_isCellSelected: (HexEditor, offset: number) -> boolean,
	_startSelection: (HexEditor, offset: number, view: CursorView) -> (),
	_updateSelection: (HexEditor, offset: number, view: CursorView) -> (),
	_endSelection: (HexEditor) -> (),
	_setSelection: (HexEditor, anchor: number, offset: number) -> (),
	_clearSelection: (HexEditor) -> (),

	-- UI
	_updateSelectionCellStyles: (HexEditor, anchor: number?, cursor: number?) -> (),
	_updateCursorStyle: (HexEditor, CursorMode?, state: "modified" | "add" | nil) -> (),
	_updateCursorPosition: (HexEditor) -> (),
	_updateCellStyle: (HexEditor, offset: number, state: ButtonState?) -> (),
	_updateRowStyle: (HexEditor, row: number) -> (),
	_buildHeader: (HexEditor) -> (),
	_buildRow: (HexEditor, rowFromTop: number) -> (),
	_updateRow: (HexEditor, row: number) -> (),
	_updateCell: (HexEditor, offset: number) -> (),
	_rebuildVisibleRows: (HexEditor, forceAfterOffset: number?) -> (),
	_buildCursor: (HexEditor) -> (),
	_buildGutter: (HexEditor) -> (),
	_buildUi: (HexEditor) -> (),
	_clearUi: (HexEditor) -> (),
}
export type HexEditorFields = {
	--[[ Public ]]
	horizontalSpaceUsed: number,

	-- Access at your own risk.
	scrollingFrame: ScrollingFrame?,
	scrollingFrameContent: Frame?,

	-- Events

	-- Parameters:
	--	offset: number?
	cursorMoved: RBXScriptSignal,
	_cursorMoved: BindableEvent,

	-- Parameters:
	--	horizontalSpaceUsed: number
	sizeChanged: RBXScriptSignal,
	_sizeChanged: BindableEvent,

	historyChanged: RBXScriptSignal,
	_historyChanged: BindableEvent,

	--[[ Private ]]

	_dependencies: HexEditorDependencies,

	_container: GuiObject,
	_sizing: EditorSizing,
	_theme: ThemeData,
	_layout: LayoutData,

	_buffer: buffer,
	_bufferSize: number,

	_cellWidth: number,
	_cellHeight: number,
	_halfCellWidth: number,

	_hiddenTextBox: TextBox?,

	_typed: string,
	_inModal: boolean, -- Used so that hidden text box knows not to deselect

	_modifiedOffsets: { [number]: number? }, -- [offset]: times modified
	_undoList: { Action },
	_undoPosition: number,

	_cursorState: { offset: number?, view: CursorView?, mode: CursorMode, isInactive: boolean },
	_selectionAnchor: number?, -- offset
	_selectionPhase: "mousedown" | "selecting" | nil,

	_headerOffset: number,
	_gutterOffset: number,

	_cursor: Frame?,
	_header: Frame?,
	_rows: {
		[number]: {
			frame: Frame,
			gutter: TextLabel?,
			cells: {
				[number]: {
					hex: TextButton,
					ascii: TextButton?,
					connections: {RBXScriptConnection},
				}
			},
		}
	},
	_rowIdentities: {[number]: number},
	_startRow: number,
	_scrollOffset: number,

	_connections: { RBXScriptConnection },
}
export type HexEditor = typeof(setmetatable({} :: HexEditorFields, {} :: HexEditorImplementation))

type InsertAction = {
	type: "insert",
	offset: number,
	value: number,
}
type ReplaceAction = {
	type: "replace",
	offset: number,
	value: number,
	originalValue: number?,
}
type DeleteAction = {
	type: "delete",
	offset: number,
	originalValue: number,
	wasBehind: boolean, -- If the deletion was behind the cursor (in that case move up one when undoing)
}
type RegionDeleteAction = {
	type: "regionDelete",
	offset: number,
	buffer: buffer,
}
type RegionReplaceAction = {
	type: "regionReplace",
	offset: number,
	originalBuffer: buffer,
	buffer: buffer,
}
type RegionInsertAction = {
	type: "regionInsert",
	offset: number,
	buffer: buffer,
}
type Action = InsertAction | ReplaceAction | DeleteAction | RegionDeleteAction | RegionReplaceAction | RegionInsertAction

type Selection = { anchor: number, cursor: number }

type CursorView = "hex" | "ascii"
type CursorMode = "insert" | "replace"

local HexEditor: HexEditorImplementation = {} :: HexEditorImplementation
HexEditor.__index = HexEditor

HexEditor.DEFAULT_HOTKEYS = {
	{
		action = "undo",
		keyCode = Enum.KeyCode.Z,
		modifierKeys = { Enum.ModifierKey.Ctrl },
	},
	{
		action = "redo",
		keyCode = Enum.KeyCode.Y,
		modifierKeys = { Enum.ModifierKey.Ctrl },
	},
	{
		action = "redo",
		keyCode = Enum.KeyCode.Z,
		modifierKeys = { Enum.ModifierKey.Ctrl, Enum.ModifierKey.Shift },
	},
	{
		action = "cycleCursorMode",
		keyCode = Enum.KeyCode.Insert,
	},
	{
		action = "selectAll",
		keyCode = Enum.KeyCode.A,
		modifierKeys = { Enum.ModifierKey.Ctrl },
	},
}

HexEditor.SELECTION_CONTEXT_MENU_OPTIONS = table.freeze({
	"Fill Selection",
	"Delete Selection",
	"Insert Null Bytes",
	"Select All",
})

HexEditor.SINGLE_BYTE_CONTEXT_MENU_OPTIONS = table.freeze({
	"Insert Null Bytes",
	"Delete Byte",
	"Select All",
})

HexEditor.ADD_CELL_CONTEXT_MENU_OPTIONS = table.freeze({
	"Insert Null Bytes",
	"Select All",
})

function HexEditor.new(b: buffer, params: HexEditorNewParams, dependencies: HexEditorDependencies)
	local self: HexEditor = (setmetatable({}, HexEditor) :: any) :: HexEditor

	-- Events
	self._cursorMoved = Instance.new("BindableEvent")
	self.cursorMoved = self._cursorMoved.Event

	self._sizeChanged = Instance.new("BindableEvent")
	self.sizeChanged = self._sizeChanged.Event

	self._historyChanged = Instance.new("BindableEvent")
	self.historyChanged = self._historyChanged.Event

	-- Fields

	self._dependencies = table.freeze(dependencies)
	self._sizing = params.sizing :: EditorSizing

	self._buffer = b
	self._bufferSize = buffer.len(b)

	self._modifiedOffsets = {}

	self._typed = ""
	self._inModal = false

	self._undoList = {}
	self._undoPosition = 0

	self._cursorState = { offset = nil, view = nil, mode = "replace", isInactive = false }
	self._selectionAnchor = nil
	self._selectionPhase = nil

	self._headerOffset = 0
	self._gutterOffset = 0

	self._rows = {}
	self._rowIdentities = {}

	self._container = params.container

	self._startRow = 0
	self._scrollOffset = 0

	self._layout = params.layout
	self:_setTheme(params.theme)

	self:_buildUi()

	local hotkeys = params.hotkeys or HexEditor.DEFAULT_HOTKEYS
	self._connections = {
		(self._dependencies.inputReceiver.InputBegan :: any):Connect(function(input: InputObject)
			if self._inModal then
				return
			end

			for _, hotkey in hotkeys do
				if input.KeyCode == hotkey.keyCode then
					local run = true
					for _, modifierKey in Enum.ModifierKey:GetEnumItems() do
						if
							input:IsModifierKeyDown(modifierKey)
							~= (not not table.find(hotkey.modifierKeys or {}, modifierKey))
						then
							run = false
							break
						end
					end

					if run then
						if hotkey.action == "undo" then
							self:undo()
						elseif hotkey.action == "redo" then
							self:redo()
						elseif hotkey.action == "cycleCursorMode" then
							if self._cursorState.offset then
								self:_cycleCursorMode()
							end
						elseif hotkey.action == "selectAll" then
							self:_setSelection(0, self._bufferSize - 1)
						end
					end
				end
			end
		end),
	}

	return self
end

--[==[ Public ]==]

function HexEditor:setBuffer(b)
	self._buffer = b
	self._bufferSize = buffer.len(b)
	self:_rebuildVisibleRows(0)
end

function HexEditor:getBuffer()
	if self._bufferSize < buffer.len(self._buffer) then
		local copy = buffer.create(self._bufferSize)
		buffer.copy(copy, 0, self._buffer, 0, self._bufferSize)
		return copy
	else
		return self._buffer
	end
end

function HexEditor:getRawBuffer()
	return self._buffer, self._bufferSize
end

function HexEditor:getCursorOffset()
	return self._cursorState.offset
end

function HexEditor:setTheme(theme)
	self:_setTheme(theme)
	self:_buildUi()
end

function HexEditor:setLayout(layout)
	self._layout = layout
	self:_buildUi()
end

function HexEditor:canUndo()
	return self._undoPosition ~= 0
end

function HexEditor:undo()
	if not self:canUndo() then
		return
	end

	if self:_isTyping() then
		self:_cancelTyping()
	end

	local action = self._undoList[self._undoPosition]
	self._undoPosition -= 1

	if action.type == "insert" then
		self:_deleteAt(action.offset)
	elseif action.type == "delete" then
		self:_insertAt(action.offset, action.originalValue)
	elseif action.type == "replace" then
		self:_incrementModifiedCount(action.offset, -1)
		if action.originalValue then
			self:_replaceAt(action.offset, action.originalValue, true)
		else
			-- It was inserted at the end
			self:_deleteAt(action.offset)
		end
	elseif action.type == "regionInsert" then
		self:_deleteRegion(action.offset, buffer.len(action.buffer))
	elseif action.type == "regionDelete" then
		self:_insertBuffer(action.offset, action.buffer)
		self:_setSelection(action.offset, action.offset + buffer.len(action.buffer) - 1)
		return
	elseif action.type == "regionReplace" then
		self:_replaceBuffer(action.offset, action.originalBuffer, -1)
		self:_setSelection(action.offset, action.offset + buffer.len(action.originalBuffer) - 1)
		return
	end

	if self._selectionAnchor then
		self:_clearSelection()
	end
	self:_setCursorState(action.offset, self._cursorState.view :: CursorView)
	if (action :: any).wasBehind then
		self:_incrementCursor()
	end

	self._historyChanged:Fire()
end

function HexEditor:canRedo()
	return self._undoPosition < #self._undoList
end

function HexEditor:redo()
	if not self:canRedo() then
		return
	end

	if self:_isTyping() then
		self:_cancelTyping()
	end

	self._undoPosition += 1
	local action = self._undoList[self._undoPosition]

	if action.type == "insert" then
		self:_insertAt(action.offset, action.value)
	elseif action.type == "delete" then
		self:_deleteAt(action.offset)
	elseif action.type == "replace" then
		self:_replaceAt(action.offset, action.value)
	elseif action.type == "regionInsert" then
		self:_insertBuffer(action.offset, action.buffer)
		self:_setSelection(action.offset, action.offset + buffer.len(action.buffer) - 1)
		return
	elseif action.type == "regionDelete" then
		self:_deleteRegion(action.offset, buffer.len(action.buffer))
	elseif action.type == "regionReplace" then
		self:_replaceBuffer(action.offset, action.buffer)
		self:_setSelection(action.offset, action.offset + buffer.len(action.buffer) - 1)
		return
	end

	if self._selectionAnchor then
		self:_clearSelection()
	end
	self:_setCursorState(action.offset, self._cursorState.view :: CursorView)
	if action.type == "insert" then
		self:_incrementCursor()
	end

	self._historyChanged:Fire()
end

function HexEditor:destroy()
	self:_destroyHiddenTextBox()
	self:_clearUi()

	for _, connection in self._connections do
		connection:Disconnect()
	end
end

--[==[ Private ]==]

function HexEditor:_setTheme(theme)
	self._theme = theme

	-- Cell stuff

	local textParams = Instance.new("GetTextBoundsParams")
	textParams.Font = theme.font
	textParams.Size = theme.textSize
	textParams.Text = "FF"
	textParams.Width = math.huge
	local bounds = TextService:GetTextBoundsAsync(textParams)
	textParams.Text = "W"
	local halfBounds = TextService:GetTextBoundsAsync(textParams)

	self._cellWidth = bounds.X + 2 * self._theme.cellPadding
	self._cellHeight = bounds.Y + 2 * self._theme.cellPadding
	self._halfCellWidth = halfBounds.X + 2 * self._theme.halfCellPadding

	if self._layout.showGutter then
		assert(self._layout.gutterColumns)

		textParams.Text = string.rep("F", self._layout.gutterColumns)
		local gutterBounds = TextService:GetTextBoundsAsync(textParams)
		-- Add some cell padding is so it doesn't touch the left
		self._gutterOffset = gutterBounds.X + self._theme.sectionGap + 2 * self._theme.cellPadding
	else
		self._gutterOffset = 0
	end
end

function HexEditor:_setCursorState(offset, view, mode, isInactive)
	if self:_isTyping() then
		self:_finishTyping()
	end

	local lastOffset = nil
	if offset ~= self._cursorState.offset and self._cursorState.offset then
		lastOffset = self._cursorState.offset

		local header = self._header
		if header then
			local cell = header:FindFirstChild(tostring(lastOffset % self._layout.columns)) :: TextLabel
			if cell then
				cell.TextColor3 = self._theme.headerText or self._theme.cell.text
			end
		end
	end

	if offset then
		if not self._cursor then
			self:_buildCursor()
		end
		if self._cursorState.isInactive then
			self:_destroyHiddenTextBox()
		elseif not self._hiddenTextBox then
			self:_createHiddenTextBox()
		end

		self._cursorState = {
			offset = offset,
			view = view,
			mode = mode or self._cursorState.mode,
			isInactive = if isInactive ~= nil then isInactive else false,
		} :: any

		-- Make sure cursor always in view
		local scrollingFrame = self.scrollingFrame
		if scrollingFrame then
			local relativeCellOffset = self._headerOffset
				+ (offset // self._layout.columns) * self._cellHeight
				- scrollingFrame.CanvasPosition.Y
				- self._cellHeight
			if relativeCellOffset < 0 then
				scrollingFrame.CanvasPosition = scrollingFrame.CanvasPosition + Vector2.new(0, relativeCellOffset)
			else
				relativeCellOffset += 2 * self._cellHeight
				if relativeCellOffset > scrollingFrame.AbsoluteWindowSize.Y then
					scrollingFrame.CanvasPosition = scrollingFrame.CanvasPosition
						+ Vector2.new(0, relativeCellOffset - scrollingFrame.AbsoluteWindowSize.Y)
				end
			end
		end

		self:_updateCellStyle(offset)
		self:_updateRowStyle(offset // self._layout.columns)

		self:_updateCursorStyle()
		self:_updateCursorPosition()

		local header = self._header
		if header then
			local cell = header:FindFirstChild(tostring(offset % self._layout.columns)) :: TextLabel
			if cell then
				cell.TextColor3 = self._theme.headerActiveText or self._theme.headerText or self._theme.cell.text
			end
		end
	else
		if self._cursor then
			self._cursor:Destroy()
			self._cursor = nil
		end
		if self._hiddenTextBox then
			self:_destroyHiddenTextBox()
		end
		self:_clearSelection()

		self._cursorState.offset = nil
		self._cursorState.view = nil
	end

	if lastOffset then
		self:_updateCellStyle(lastOffset)
		self:_updateRowStyle(lastOffset // self._layout.columns)
	end

	if lastOffset ~= self._cursorState.offset then
		self._cursorMoved:Fire(self._cursorState.offset)
	end
end

function HexEditor:_incrementCursor(amount)
	assert(self._cursorState.offset)

	if self._selectionAnchor then
		self:_clearSelection()
	end

	local offset = self._cursorState.offset + (amount or 1)
	self:_setCursorState(math.clamp(offset, 0, self._bufferSize), self._cursorState.view :: CursorView)
end

function HexEditor:_createHiddenTextBox()
	if self._inModal then
		return
	end
	if self._hiddenTextBox then
		self:_destroyHiddenTextBox()
	end
	if globalHiddenTextBox then
		globalHiddenTextBox:Destroy()
		globalHiddenTextBox = nil
	end

	local hiddenTextBox = Instance.new("TextBox")
	hiddenTextBox.TextSize = 0
	hiddenTextBox.TextTransparency = 1
	hiddenTextBox.BackgroundTransparency = 1
	hiddenTextBox.Size = UDim2.fromOffset(0, 0)
	hiddenTextBox.ClearTextOnFocus = false
	hiddenTextBox.Position = UDim2.fromOffset(-5000, -5000)
	hiddenTextBox.Name = "HiddenTextBox"
	hiddenTextBox.ZIndex = 100
	hiddenTextBox.Parent = self._container

	hiddenTextBox.Size = UDim2.fromOffset(100, 50)
	self._hiddenTextBox = hiddenTextBox
	globalHiddenTextBox = hiddenTextBox

	local function resetText()
		hiddenTextBox.Text = "abc\nac\nabc"
		hiddenTextBox:CaptureFocus()
		hiddenTextBox.SelectionStart = -1
		hiddenTextBox.CursorPosition = 6
	end

	local function tryRegisterType()
		if
			hiddenTextBox.Text ~= "abc\nac\nabc"
			and #hiddenTextBox.Text == 11
			and hiddenTextBox.Text:sub(1, 5) == "abc\na"
			and hiddenTextBox.Text:sub(7, 11) == "c\nabc"
		then
			local inserted = hiddenTextBox.Text:sub(6, 6)

			-- Vim shortcuts only work in hex view because the characters don't register
			if self._cursorState.view == "hex" then
				if inserted == "j" then
					self:_incrementCursor(self._layout.columns)
				elseif inserted == "k" then
					self:_incrementCursor(-self._layout.columns)
				elseif inserted == "h" then
					self:_incrementCursor(-1)
				elseif inserted == "l" then
					self:_incrementCursor(1)
				elseif inserted == "$" then
					local rowEnd = math.min(
						self._bufferSize - 1,
						self._layout.columns * math.ceil(((self._cursorState.offset or 0) + 1) / self._layout.columns)
							- 1
					)
					self:_setCursorState(rowEnd, self._cursorState.view)
				elseif inserted == "^" then
					local rowStart = math.max(
						0,
						self._layout.columns * math.floor((self._cursorState.offset or 0) / self._layout.columns)
					)
					self:_setCursorState(rowStart, self._cursorState.view)
				-- "g" to easy to press, "gg" too much work to implement, so I decide that "S" will work (for start)
				elseif inserted == "S" then
					self:_setCursorState(0, self._cursorState.view)
				elseif inserted == "G" then
					self:_setCursorState(self._bufferSize - 1, self._cursorState.view)
				end
			end

			self:_typeCharacter(inserted)
			resetText()
		elseif hiddenTextBox.Text == "abc\nc\nabc" then
			if self._selectionAnchor then
				self:_deleteSelectionWithAction(true)
			else
				self:_deleteBeforeCursorWithAction()
			end
			resetText()
		else
			resetText()
		end
	end

	hiddenTextBox:GetPropertyChangedSignal("SelectionStart"):Connect(function()
		if hiddenTextBox:IsFocused() then
			resetText()
		end
	end)

	hiddenTextBox:GetPropertyChangedSignal("CursorPosition"):Connect(function()
		if hiddenTextBox:IsFocused() then
			-- If SignalBehavior is immediate, text changes will trigger cursor position behavior the text itself changes.
			-- So we have to add task.defer unfortunately

			task.defer(function()
				if #hiddenTextBox.Text == 10 then
					if hiddenTextBox.CursorPosition == 5 then
						self:_incrementCursor(-1)
					elseif hiddenTextBox.CursorPosition == 2 then
						self:_incrementCursor(-self._layout.columns)
					elseif hiddenTextBox.CursorPosition == 7 then
						self:_incrementCursor(1)
					elseif hiddenTextBox.CursorPosition == 9 then
						self:_incrementCursor(self._layout.columns)
					end
					resetText()
				end
			end)
		end
	end)

	hiddenTextBox:GetPropertyChangedSignal("Text"):Connect(function()
		if #hiddenTextBox.Text == 11 then
			tryRegisterType()
		elseif #hiddenTextBox.Text == 9 then
			if hiddenTextBox.Text == "abc\na\nabc" then
				if self:_isTyping() then
					self:_cancelTyping()
					resetText()
				else
					if self._selectionAnchor then
						self:_deleteSelectionWithAction(true)
					else
						self:_deleteAtCursorWithAction()
					end
					resetText()
				end
			elseif hiddenTextBox.Text == "abc\nc\nabc" then
				if self:_isTyping() then
					self:_cancelTyping()
					resetText()
				else
					if self._selectionAnchor then
						self:_deleteSelectionWithAction(true)
					else
						self:_deleteBeforeCursorWithAction()
					end
					resetText()
				end
			end
		else
			resetText()
		end
	end)

	local recursions = 0
	hiddenTextBox.FocusLost:Connect(function(entered: boolean, input)
		if entered then
			self:_finishTyping()
			self:_setCursorState(self._cursorState.offset, self._cursorState.view :: CursorView) -- To update the color
			resetText()
		else
			if self:_isTyping() then
				self:_cancelTyping()
			end

			-- Don't delete the selection/cursor since the modal puts the editor in the background.
			if self._inModal then
				return
			end

			-- If it is pressing within the editor, do not deselect so .Activated is fired and the cursor mode is cycled
			local scrollingFrame = self.scrollingFrame
			if
				input
				and scrollingFrame
				and (input.UserInputType == Enum.UserInputType.MouseButton1)
				and (scrollingFrame.AbsolutePosition.Y + self._headerOffset <= input.Position.Y)
				and (input.Position.Y <= scrollingFrame.AbsolutePosition.Y + scrollingFrame.AbsoluteSize.Y)
				and (
					( -- In the hex portion
						(
							input.Position.X
							<= scrollingFrame.AbsolutePosition.X
								+ self._gutterOffset
								+ self._cellWidth * self._layout.columns
						) and (scrollingFrame.AbsolutePosition.X + self._gutterOffset <= input.Position.X)
					)
					or ( -- In the text portion
						self._layout.showDecodedText
						and (
							(
								scrollingFrame.AbsolutePosition.X
									+ self._gutterOffset
									+ self._cellWidth * self._layout.columns
									+ self._theme.sectionGap
								<= input.Position.X
							)
							and (
								input.Position.X
								<= scrollingFrame.AbsolutePosition.X
									+ self._gutterOffset
									+ (self._cellWidth + self._halfCellWidth) * self._layout.columns
									+ self._theme.sectionGap
							)
						)
					)
				)
			then
				resetText()
			else
				self:_setCursorState(self._cursorState.offset, self._cursorState.view :: CursorView, nil, true)
			end
		end
	end)

	resetText()
end

function HexEditor:_destroyHiddenTextBox()
	if self._hiddenTextBox then
		self._hiddenTextBox:Destroy()
		if self._hiddenTextBox == globalHiddenTextBox then
			globalHiddenTextBox = nil
		end
		self._hiddenTextBox = nil
	end
end

function HexEditor:_isTyping()
	return #self._typed > 0
end

local function isHex(character: string): boolean
	local ascii = string.byte(character)
	return (0x30 <= ascii and ascii <= 0x39) or (0x41 <= ascii and ascii <= 0x46) or (0x61 <= ascii and ascii <= 0x66)
end

function HexEditor:_typeCharacter(character: string)
	assert(#character == 1)
	assert(self._cursorState.offset)

	if self._cursorState.isInactive then
		return
	end

	self:_clearSelection()

	if self._cursorState.view == "hex" then
		if not isHex(character) then
			return
		end

		character = character:upper()

		if #self._typed >= 2 then
			self:_incrementCursor()
			self._typed = ""
		elseif #self._typed == 1 then
			self._typed ..= character
			self:_finishTyping()
			self:_incrementCursor()
		else
			if self._cursorState.mode == "replace" then
				self._typed = character
			elseif self._cursorState.mode == "insert" then
				self:_insertAt(self._cursorState.offset)
				self._typed = character
				self:_updateCursorStyle(self._cursorState.mode, "modified")
			end

			self:_updateCursorPosition()
			self:_updateCell(self._cursorState.offset)
		end
	elseif self._cursorState.view == "ascii" then
		self._typed = character
		if self._cursorState.mode == "replace" then
			self:_finishTyping()
			self:_incrementCursor()
		elseif self._cursorState.mode == "insert" then
			self:_insertAt(self._cursorState.offset)
			self:_finishTyping()
			self:_incrementCursor()
		end
	end
end

function HexEditor:_finishTyping()
	assert(self._cursorState.offset)

	if self:_isTyping() then
		local value = (
			if self._cursorState.view == "hex" then tonumber(self._typed, 16) else string.byte(self._typed)
		) :: number

		self._typed = ""

		if self._cursorState.mode == "insert" then
			self:_replaceAt(self._cursorState.offset, value)
			self:_pushAction({
				type = "insert",
				offset = self._cursorState.offset,
				value = value,
			})
		else
			self:_replaceAtWithAction(self._cursorState.offset, value)
		end
	end
end

function HexEditor:_cancelTyping()
	if self._cursorState.offset then
		if self._cursorState.mode == "insert" and self:_isTyping() then
			self._typed = ""
			self:_deleteAt(self._cursorState.offset)
			self:_setCursorState(self._cursorState.offset, self._cursorState.view :: CursorView) -- Reset the cursor visually
		else
			self._typed = ""
			self:_updateCell(self._cursorState.offset)
		end
	end
end

function HexEditor:_insertAt(offset: number, value: number?)
	if buffer.len(self._buffer) <= self._bufferSize then
		self:_growBuffer(1)
	end

	local temp = buffer.create(self._bufferSize - offset)
	buffer.copy(temp, 0, self._buffer, offset, buffer.len(temp))
	buffer.copy(self._buffer, offset + 1, temp)
	buffer.writeu8(self._buffer, offset, value or 0)
	self:_bumpModifiedOffsetsAfter(offset - 1, 1)
	self:_incrementModifiedCount(offset, 1)
	self._bufferSize += 1

	self:_rebuildVisibleRows(offset)
end

function HexEditor:_insertBuffer(offset, b)
	if self._bufferSize + buffer.len(b) >= buffer.len(self._buffer) then
		self:_growBuffer((self._bufferSize + buffer.len(b)) - buffer.len(self._buffer))
	end

	local temp = buffer.create(self._bufferSize - offset)
	buffer.copy(temp, 0, self._buffer, offset, buffer.len(temp))
	buffer.copy(self._buffer, offset + buffer.len(b), temp)
	buffer.copy(self._buffer, offset, b)
	self:_bumpModifiedOffsetsAfter(offset - 1, buffer.len(b))
	for i = offset, offset + buffer.len(b) - 1 do
		self:_incrementModifiedCount(i, 1)
	end
	self._bufferSize += buffer.len(b)

	self:_rebuildVisibleRows(offset)

	self:_updateCursorStyle()
end

function HexEditor:_deleteAt(offset: number)
	if offset < self._bufferSize - 1 then
		buffer.copy(self._buffer, offset, self._buffer, offset + 1)
	end

	self._bufferSize -= 1
	self._modifiedOffsets[offset] = nil
	self:_bumpModifiedOffsetsAfter(offset, -1)

	if self._cursorState.offset and self._cursorState.offset > offset then
		self:_setCursorState(self._cursorState.offset - 1, self._cursorState.view :: CursorView)
	end

	self:_rebuildVisibleRows(offset)
end

function HexEditor:_deleteRegion(offset: number, length: number)
	if offset + length < self._bufferSize - 1 then
		buffer.copy(self._buffer, offset, self._buffer, offset + length)
	end

	self._bufferSize -= length
	for offset = offset, offset + length - 1 do
		self._modifiedOffsets[offset] = nil
	end
	self:_bumpModifiedOffsetsAfter(offset, -length)
	self:_rebuildVisibleRows(offset)
end

function HexEditor:_replaceAt(offset, value, doNotModifyCount)
	if offset >= buffer.len(self._buffer) then
		self:_growBuffer(1)
	end

	buffer.writeu8(self._buffer, offset, value)
	if not doNotModifyCount then
		self:_incrementModifiedCount(offset, 1)
	end

	if offset == self._bufferSize then
		self._bufferSize += 1
		self:_rebuildVisibleRows(offset)
	else
		self:_updateCell(offset)
	end
end

function HexEditor:_replaceBuffer(offset, b, modifyIncrement)
	if offset + buffer.len(b) >= buffer.len(self._buffer) then
		self:_growBuffer((offset + buffer.len(b)) - buffer.len(self._buffer))
	end

	local modifyIncrement = modifyIncrement or 1

	buffer.copy(self._buffer, offset, b)

	for i = offset, offset + buffer.len(b) - 1 do
		self:_incrementModifiedCount(i, modifyIncrement)
		self:_updateCell(i)
	end

	-- The cursor may have been inside the replaced region
	self:_updateCursorStyle()
end

-- METHODS THAT PUSH ACTIONS

function HexEditor:_insertBufferWithAction(offset: number, b: buffer)
	self:_insertBuffer(offset, b)
	self:_rebuildVisibleRows(offset)
	self:_pushAction({
		type = "regionInsert",
		offset = offset,
		buffer = b,
	})

	self:_setSelection(offset, offset + buffer.len(b) - 1)
end

function HexEditor:_deleteAtCursorWithAction()
	assert(self._cursorState.offset)
	if self._cursorState.offset >= self._bufferSize then
		return
	end
	local originalValue = buffer.readu8(self._buffer, self._cursorState.offset)
	self:_deleteAt(self._cursorState.offset)
	self:_pushAction({
		type = "delete",
		offset = self._cursorState.offset, -- delete at move the cursor so we dont have to account for the -1
		originalValue = originalValue,
		wasBehind = false,
	})
end

function HexEditor:_deleteBeforeCursorWithAction()
	assert(self._cursorState.offset)
	if self._cursorState.offset <= 0 or self._cursorState.offset > self._bufferSize then
		return
	end
	local originalValue = buffer.readu8(self._buffer, self._cursorState.offset - 1)
	self:_deleteAt(self._cursorState.offset - 1)
	self:_pushAction({
		type = "delete",
		offset = self._cursorState.offset,
		originalValue = originalValue,
		wasBehind = true,
	})
end

function HexEditor:_deleteSelectionWithAction(keepCursor)
	if not self._selectionAnchor then
		return
	end
	if not self._cursorState.offset then
		return
	end
	if self._cursorState.offset == self._selectionAnchor then
		return
	end

	local start = math.min(self._selectionAnchor, self._cursorState.offset)
	local finish = math.max(self._selectionAnchor, self._cursorState.offset)

	-- Add 1 because the selection is inclusive
	local deletedAmount = finish - start + 1
	local deletedBuffer = buffer.create(deletedAmount)
	buffer.copy(deletedBuffer, 0, self._buffer, start, deletedAmount)

	self:_deleteRegion(start, deletedAmount)

	self:_clearSelection()
	self:_setCursorState(if keepCursor then start else nil, self._cursorState.view :: CursorView)
	self:_rebuildVisibleRows(start)

	self:_pushAction({
		type = "regionDelete",
		buffer = deletedBuffer,
		offset = start,
	})
end

function HexEditor:_replaceAtWithAction(offset, value)
	local originalValue = nil
	if self._bufferSize > offset then
		originalValue = buffer.readu8(self._buffer, offset)
	end
	self:_replaceAt(offset, value)
	self:_pushAction({
		type = "replace",
		offset = offset,
		originalValue = originalValue,
		value = value,
	})
end

function HexEditor:_fillPatternWithAction(offset, length, pattern)
	local originalBuffer = buffer.create(length)
	buffer.copy(originalBuffer, 0, self._buffer, offset, length)

	local fillBuffer = buffer.create(length)
	for i = 1, math.ceil(length / buffer.len(pattern)) do
		local start = buffer.len(pattern) * (i - 1)
		local count = math.min(buffer.len(pattern), length - start)

		buffer.copy(fillBuffer, start, pattern, 0, count)
	end

	self:_replaceBuffer(offset, fillBuffer)

	self:_pushAction({
		type = "regionReplace",
		offset = offset,
		originalBuffer = originalBuffer,
		buffer = fillBuffer,
	})
end

function HexEditor:_growBuffer(minimumGrow: number?)
	local size: number
	if buffer.len(self._buffer) < 1024 then
		size = buffer.len(self._buffer) * 2 + (minimumGrow or 0)
	else
		size = buffer.len(self._buffer) + 256 + (minimumGrow or 0)
	end
	if size < 64 then
		size = 64
	end

	local newBuffer = buffer.create(size)
	buffer.copy(newBuffer, 0, self._buffer)
	self._buffer = newBuffer
end

function HexEditor:_incrementModifiedCount(offset: number, amount: number)
	local new: number? = (self._modifiedOffsets[offset] or 0) + amount
	if new <= 0 then
		new = nil
	end
	self._modifiedOffsets[offset] = new
end

function HexEditor:_bumpModifiedOffsetsAfter(after: number, amount: number)
	local newModifiedOffsets = {}
	for k, v in self._modifiedOffsets do
		newModifiedOffsets[k + (if k > after then amount else 0)] = v
	end
	self._modifiedOffsets = newModifiedOffsets
end

function HexEditor:_pushAction(action)
	if self._undoPosition < #self._undoList then
		for i = 1, #self._undoList - self._undoPosition do
			table.remove(self._undoList)
		end
	end

	if #self._undoList >= MAX_UNDOS then
		table.remove(self._undoList, 1)
		self._undoPosition -= 1
	end

	table.insert(self._undoList, action)
	self._undoPosition += 1

	self._historyChanged:Fire()
end

--[[ Selection ]]

function HexEditor:_isCellSelected(offset)
	if self._selectionAnchor then
		return math.min(self._selectionAnchor, self._cursorState.offset or math.huge) <= offset
			and offset <= math.max(self._selectionAnchor, self._cursorState.offset or -math.huge)
	end
	return false
end

function HexEditor:_updateSelectionCellStyles(anchor, cursor)
	local scrollingFrame = self.scrollingFrame
	local selectionAnchor = anchor or self._selectionAnchor
	local cursorOffset = cursor or self._cursorState.offset
	if selectionAnchor ~= cursorOffset and selectionAnchor and cursorOffset and scrollingFrame then
		local start = math.max(
			self._layout.columns * (math.floor(scrollingFrame.CanvasPosition.Y / self._cellHeight) - 1),
			math.min(cursorOffset, selectionAnchor)
		)
		local finish = math.min(
			self._layout.columns
				* (
					math.ceil(
						(scrollingFrame.CanvasPosition.Y + scrollingFrame.AbsoluteWindowSize.Y) / self._cellHeight
					) + 1
				),
			math.max(cursorOffset, selectionAnchor)
		)
		for offset = start, finish do
			self:_updateCellStyle(offset)
		end
	end
end

function HexEditor:_startSelection(offset, view)
	if self._selectionAnchor then
		self:_clearSelection()
	end

	self._selectionAnchor = offset
	self._selectionPhase = "selecting"
	self:_setCursorState(offset, view :: CursorView)

	self:_updateSelectionCellStyles()
end

function HexEditor:_updateSelection(offset, view)
	if self._selectionAnchor and self._cursorState.offset and (self._selectionPhase == "selecting") then
		local lastCursor = self._cursorState
		self:_setCursorState(offset, view :: CursorView)

		if lastCursor.offset then
			if
				math.abs(lastCursor.offset - self._selectionAnchor)
				> math.abs(self._cursorState.offset - self._selectionAnchor)
			then
				for offset = self._cursorState.offset, lastCursor.offset, math.sign(lastCursor.offset - self._cursorState.offset) do
					self:_updateCellStyle(offset)
				end
			end
		end

		self:_updateSelectionCellStyles()
	end
end

function HexEditor:_endSelection()
	if self._selectionAnchor == self._cursorState.offset then
		self:_clearSelection()
	end
	self._selectionPhase = nil
end

function HexEditor:_setSelection(anchor, cursor)
	if self._selectionAnchor then
		self:_clearSelection()
	end

	if anchor == cursor then
		return
	end

	self:_startSelection(anchor, self._cursorState.view :: CursorView)
	self:_updateSelection(cursor, self._cursorState.view :: CursorView)
	self:_endSelection()
end

function HexEditor:_clearSelection()
	if not self._selectionAnchor then
		return
	end

	local cursor = self._cursorState.offset
	local anchor = self._selectionAnchor
	self._selectionAnchor = nil

	if cursor and anchor and cursor ~= anchor then
		self:_updateSelectionCellStyles(cursor, anchor)
	end
end

--[[ UI ]]

function HexEditor:_setInModal(inModal: boolean)
	self._inModal = inModal

	if not inModal then
		if (not self._hiddenTextBox) and self._cursorState.offset then
			self:_createHiddenTextBox()
		end
	else
		self:_destroyHiddenTextBox()
	end
end

type ButtonState = "default" | "hover"

function HexEditor:_updateCellStyle(offset, state)
	local cellThemeData = if offset == self._bufferSize
		then self._theme.addCell
		elseif self._modifiedOffsets[offset] then self._theme.modifiedCell
		else self._theme.cell

	local row = self._rows[offset // self._layout.columns - self._startRow]
	if not row then return end

	local cell = row.cells[offset % self._layout.columns]
	if not cell then return end

	if cell then
		local selected = self:_isCellSelected(offset)

		local backgroundColor = if self._cursorState.offset == offset
			then cellThemeData.cursorActive
			elseif selected then cellThemeData.select
			elseif state == "hover" then cellThemeData.hover
			elseif state == "select" then cellThemeData.select
			else cellThemeData.background
		local textColor = if self._cursorState.offset == offset
			then cellThemeData.cursorActiveText
			elseif selected then cellThemeData.selectText
			elseif state == "hover" then cellThemeData.hoverText
			elseif state == "select" then cellThemeData.selectText
			else self._theme.cell.text

		cell.hex.BackgroundColor3 = backgroundColor
		cell.hex.TextColor3 = textColor
		cell.hex.FontFace = self._theme.font
		cell.hex.TextSize = self._theme.textSize

		if cell.ascii then
			cell.ascii.BackgroundColor3 = backgroundColor
			cell.ascii.TextColor3 = textColor
			cell.ascii.FontFace = self._theme.font
			cell.ascii.TextSize = self._theme.textSize
		end
	end
end

function HexEditor:_updateRowStyle(rowNumber: number)
	local row = self._rows[rowNumber - self._startRow]
	if row and row.gutter then
		local active = self._cursorState.offset and self._cursorState.offset // self._layout.columns == rowNumber
		if active then
			row.gutter.TextColor3 = self._theme.gutterActiveText or self._theme.gutterText or self._theme.cell.text
		else
			row.gutter.TextColor3 = self._theme.gutterText or self._theme.cell.text
		end
	end
end

function HexEditor.isCharacterPrintable(asciiCode: number): boolean
	if 0 <= asciiCode and asciiCode <= 31 then
		return false
	elseif asciiCode >= 127 then
		return false
	else
		return true
	end
end

function HexEditor:_buildHeader()
	assert(not self._header)

	local header = Instance.new("Frame")
	header.BorderSizePixel = 0
	header.BackgroundColor3 = self._theme.headerBackground or self._theme.cell.background
	header.Size = UDim2.fromOffset(
		2 * self._theme.sectionGap + (self._cellWidth + self._halfCellWidth) * self._layout.columns + self._gutterOffset,
		self._cellHeight
	)
	header.ZIndex = 3
	header.Name = "Header"
	header.Parent = self.scrollingFrameContent

	for i = 0, self._layout.columns - 1 do
		local cell = Instance.new("TextLabel")
		cell.BackgroundTransparency = 1
		cell.TextColor3 = self._theme.headerText or self._theme.cell.text
		cell.TextSize = self._theme.textSize
		cell.FontFace = Font.new(self._theme.font.Family, Enum.FontWeight.Bold, self._theme.font.Style)
		cell.Position = UDim2.fromOffset(self._gutterOffset + i * self._cellWidth, 0)
		cell.Size = UDim2.fromOffset(self._cellWidth, self._cellHeight)
		cell.Text = string.format("%02X", i)
		cell.Name = tostring(i)
		cell.Parent = header
	end

	if self._layout.showDecodedText then
		local decodedText = Instance.new("TextLabel")
		decodedText.BackgroundTransparency = 1
		decodedText.TextColor3 = self._theme.headerText or self._theme.cell.text
		decodedText.TextSize = self._theme.textSize
		decodedText.FontFace = Font.new(self._theme.font.Family, Enum.FontWeight.Bold, self._theme.font.Style)
		decodedText.Position =
			UDim2.fromOffset(self._gutterOffset + self._theme.sectionGap + self._layout.columns * self._cellWidth, 0)
		decodedText.Size = UDim2.new(1, 0, 0, self._cellHeight)
		decodedText.Text = "Decoded Text"
		decodedText.TextXAlignment = Enum.TextXAlignment.Left
		decodedText.Parent = header
	end

	self._header = header
	self._headerOffset = self._cellHeight
end

function HexEditor:_buildRow(rowFromTop: number)
	local frame = Instance.new("Frame")
	frame.BackgroundTransparency = 1
	frame.Size = UDim2.fromOffset(self._cellWidth * self._layout.columns, self._cellHeight)
	frame.Name = tostring(rowFromTop)

	local gutter: TextLabel
	if self._layout.showGutter then
		gutter = Instance.new("TextLabel")
		gutter.BackgroundColor3 = self._theme.gutterBackground or self._theme.cell.background
		gutter.BorderSizePixel = 0
		gutter.TextColor3 = self._theme.gutterText or self._theme.cell.text
		gutter.TextSize = self._theme.textSize
		gutter.FontFace = Font.new(self._theme.font.Family, Enum.FontWeight.Bold, self._theme.font.Style)
		gutter.Position = UDim2.fromOffset(0, 0)
		gutter.Size = UDim2.fromOffset(self._gutterOffset - self._theme.sectionGap, self._cellHeight)
		gutter.Name = "Gutter"
		gutter.Parent = frame
	end

	local cells = table.create(self._layout.columns)
	for column = 0, self._layout.columns - 1 do
		local cell = Instance.new("TextButton")
		cell.BorderSizePixel = 0
		cell.AutoButtonColor = false
		cell.Position = UDim2.fromOffset(self._gutterOffset + column * self._cellWidth, 0)
		cell.Size = UDim2.fromOffset(self._cellWidth, self._cellHeight)
		cell.Visible = false
		cell.Parent = frame

		local asciiCell

		if self._layout.showDecodedText then
			asciiCell = cell:Clone()
			asciiCell.Position = UDim2.fromOffset(
				self._gutterOffset
					+ column * self._halfCellWidth
					+ self._cellWidth * self._layout.columns
					+ self._theme.sectionGap,
				0
			)
			asciiCell.Size = UDim2.fromOffset(self._halfCellWidth, self._cellHeight)
			asciiCell.Visible = false
			asciiCell.Parent = frame
		end

		cells[column] = {
			hex = cell,
			ascii = asciiCell,
			connections = {},
		}
	end

	frame.Parent = self.scrollingFrameContent
	self._rows[rowFromTop] = {
		frame = frame,
		gutter = gutter,
		cells = cells,
	}
end

function HexEditor:_updateRow(rowNumber: number)
	local rowFromTop = rowNumber - self._startRow
	local row = self._rows[rowFromTop]
	if not row then return end

	self._rowIdentities[rowFromTop] = rowNumber

	local gutter = row.gutter
	if gutter then
		gutter.Text = string.format("%0" .. tostring(self._layout.gutterColumns) .. "X", rowNumber * self._layout.columns)
	end

	local startOffset = self._layout.columns * rowNumber

	row.frame.Visible = startOffset <= self._bufferSize + 1

	self:_updateRowStyle(rowNumber)

	for column = 0, self._layout.columns - 1 do
		local offset = startOffset + column
		if offset <= self._bufferSize then
			self:_updateCell(offset)
		else
			row.cells[column].hex.Visible = false

			local asciiCell = row.cells[column].ascii
			if asciiCell then
				asciiCell.Visible = false
			end
		end
	end
end

function HexEditor:_updateCell(offset)
	local row = self._rows[offset // self._layout.columns - self._startRow]
	if not row then return end

	local cell = row.cells[offset % self._layout.columns]
	if not cell then return end

	for _, connection in cell.connections do
		connection:Disconnect()
	end
	table.clear(cell.connections)

	local hexCell = cell.hex
	local asciiCell = cell.ascii

	hexCell.Visible = true
	if asciiCell then
		asciiCell.Visible = true
	end

	local isAddCell = offset == self._bufferSize
	if isAddCell then
		hexCell.Text = "+"
		if asciiCell then
			asciiCell.Text = "+"
		end
	else
		local asciiCode = buffer.readu8(self._buffer, offset)
		hexCell.Text = ("%02X"):format(asciiCode)
		if asciiCell then
			asciiCell.Text = if HexEditor.isCharacterPrintable(asciiCode) then string.char(asciiCode) else "·"
		end
	end

	if #self._typed > 0 and self._cursorState.offset == offset then
		hexCell.Text = self._typed .. "_"
	end

	local function generateInputBegan(view: CursorView)
		return function(input: InputObject)
			if input.UserInputType == Enum.UserInputType.MouseMovement then
				if (self._selectionPhase == "mousedown") and self._cursorState.offset and not isAddCell then
					self:_startSelection(self._cursorState.offset, self._cursorState.view :: CursorView)
					self:_updateSelection(offset, view)
				elseif (self._selectionPhase == "selecting") and not isAddCell then
					self:_updateSelection(offset, view)
				elseif self._cursorState.offset ~= offset then
					self:_updateCellStyle(offset, "hover")
				end
			elseif input.UserInputType == Enum.UserInputType.MouseButton1 and not isAddCell then
				if self._selectionPhase == nil then
					self._selectionPhase = "mousedown"

					local previousState = self._cursorState
					self:_clearSelection()
					self:_setCursorState(offset, view)

					local connection: RBXScriptConnection
					connection = input:GetPropertyChangedSignal("UserInputState"):Connect(function()
						if
							not (
								input.UserInputState == Enum.UserInputState.Begin
									or input.UserInputState == Enum.UserInputState.Change
							)
						then
							if self._selectionPhase == "mousedown" then
								if previousState.offset == offset and self._cursorState.view == view and (not previousState.isInactive) then
									self:_cycleCursorMode()
								end
								self._selectionPhase = nil
							end

							self:_endSelection()
							connection:Disconnect()
						end
					end)
				end
			end
		end
	end

	local function generateInputEnded(view: CursorView)
		return function(input: InputObject)
			if input.UserInputType == Enum.UserInputType.MouseMovement then
				if self._cursorState.offset ~= offset then
					self:_updateCellStyle(offset, "default")
				end
			elseif (input.UserInputType == Enum.UserInputType.MouseButton2) and self._dependencies.forms then
				self:_setInModal(true)

				-- Keep a copy of these because they could change in between prompting the user
				local selectionAnchor = self._selectionAnchor
				local cursorOffset = self._cursorState.offset

				local action: string?
				if self:_isCellSelected(offset) then
					action =
						self._dependencies.forms.promptContextMenu(input, HexEditor.SELECTION_CONTEXT_MENU_OPTIONS)
				else
					self:_clearSelection()
					self:_setCursorState(offset, view)
					action = self._dependencies.forms.promptContextMenu(
						input,
						if isAddCell
							then HexEditor.ADD_CELL_CONTEXT_MENU_OPTIONS
							else HexEditor.SINGLE_BYTE_CONTEXT_MENU_OPTIONS
					)
				end

				if action == "Delete Byte" then
					self:_deleteAtCursorWithAction()
				elseif action == "Insert Null Bytes" then
					-- So the user sees where it will insert
					if self._selectionAnchor then
						self:_clearSelection()
						self:_setCursorState(offset, self._cursorState.view :: CursorView)
					end

					local amount = self._dependencies.forms.promptInsertNullBytes(input)
					if amount then
						self:_insertBufferWithAction(offset, buffer.create(amount))
					end
				elseif action == "Fill Selection" then
					local fillPattern = self._dependencies.forms.promptFillWith(input)
					if fillPattern and (buffer.len(fillPattern) > 0) and selectionAnchor and cursorOffset then
						local from = math.min(selectionAnchor, cursorOffset)
						local to = math.max(selectionAnchor, cursorOffset)
						self:_fillPatternWithAction(from, to - from + 1, fillPattern)
					end
				elseif action == "Delete Selection" then
					self:_deleteSelectionWithAction()
				elseif action == "Select All" then
					self:_setSelection(0, self._bufferSize - 1)
				elseif action == "Select None" then
					self:_clearSelection()
				elseif action ~= nil then
					warn(`Unexpected action: {action}`)
				end

				self:_setInModal(false)
			end
		end
	end

	table.insert(cell.connections, hexCell.InputBegan:Connect(generateInputBegan("hex")))
	table.insert(cell.connections, hexCell.InputEnded:Connect(generateInputEnded("hex")))

	if isAddCell then
		table.insert(cell.connections, hexCell.Activated:Connect(function()
			if self._cursorState.offset == offset and self._cursorState.view == "hex" then
				self:_cycleCursorMode()
			else
				self:_clearSelection()
				self:_setCursorState(offset, "hex")
			end
		end))
	end

	if asciiCell then
		table.insert(cell.connections, asciiCell.InputBegan:Connect(generateInputBegan("ascii")))
		table.insert(cell.connections, asciiCell.InputEnded:Connect(generateInputEnded("ascii")))

		if isAddCell then
			table.insert(cell.connections, asciiCell.Activated:Connect(function()
				if self._cursorState.offset == offset and self._cursorState.view == "ascii" then
					self:_cycleCursorMode()
				else
					self:_clearSelection()
					self:_setCursorState(offset, "ascii")
				end
			end))
		end
	end

	self:_updateCellStyle(offset)
end

function HexEditor:_rebuildVisibleRows(forceAfterOffset)
	local scrollingFrame = self.scrollingFrame
	local scrollingFrameContent = self.scrollingFrameContent
	if scrollingFrame and scrollingFrameContent then
		local totalRows = math.ceil((self._bufferSize + 1) / self._layout.columns)
		scrollingFrame.CanvasSize =
			UDim2.fromOffset(0, self._headerOffset + self._cellHeight * totalRows)

		local totalVisibleRows = math.ceil(scrollingFrame.AbsoluteWindowSize.Y / self._cellHeight)

		-- delete extra rows
		for i in self._rows do
			if i >= totalVisibleRows then
				self._rows[i].frame:Destroy()
				self._rows[i] = nil
				self._rowIdentities[i] = nil
			end
		end

		-- add more rows if necessary
		for i = 1, totalVisibleRows do
			if not self._rows[i - 1] then
				self:_buildRow(i - 1)
			end
		end

		local forceAfterRow = if forceAfterOffset then forceAfterOffset // self._layout.columns else math.huge

		for rowFromTop = 0, totalVisibleRows - 1 do
			local rowNumber = self._startRow + rowFromTop
			if (self._rowIdentities[rowFromTop] ~= rowNumber) or (rowNumber >= forceAfterRow) then
				self:_updateRow(rowNumber)
			end

			local row = self._rows[rowFromTop]
			if row then
				row.frame.Position = UDim2.fromOffset(0, self._headerOffset + rowFromTop * self._cellHeight - self._scrollOffset)
			end
		end
	end
end

function HexEditor:_cycleCursorMode()
	if self:_isTyping() then
		self:_finishTyping()
	end

	local mode: CursorMode
	if self._cursorState.mode == "insert" then
		mode = "replace"
	else
		mode = "insert"
	end
	self:_setCursorState(self._cursorState.offset, self._cursorState.view :: CursorView, mode)
end

function HexEditor:_updateCursorStyle(mode, state)
	local mode = mode or self._cursorState.mode
	local state = state
		or (
			if self._cursorState.offset == self._bufferSize
				then "add"
				elseif self._modifiedOffsets[self._cursorState.offset or -1] then "modified"
				else nil
		)
	local cursor = self._cursor
	if cursor then
		if state == "add" then
			cursor.BackgroundColor3 = self._theme.addCell.cursor
		elseif state == "modified" then
			cursor.BackgroundColor3 = self._theme.modifiedCell.cursor
		else
			cursor.BackgroundColor3 = self._theme.cell.cursor
		end

		cursor.Visible = not self._cursorState.isInactive

		if mode == "insert" then
			if self._cursorState.view == "hex" then
				cursor.Size = UDim2.fromOffset(2, self._cellHeight)
			else
				cursor.Size = UDim2.fromOffset(1, self._cellHeight)
			end
		elseif mode == "replace" then
			cursor.Size =
				UDim2.fromOffset(if self._cursorState.view == "hex" then self._cellWidth else self._halfCellWidth, 2)
		end
	end
end

function HexEditor:_updateCursorPosition()
	local cursor = self._cursor :: Frame
	local offset = self._cursorState.offset
	if cursor and offset then
		local positionOffset = if #self._typed > 0 and self._cursorState.mode == "insert" then UDim2.fromOffset(self._cellWidth / 2, 0) else UDim2.fromOffset(0 ,0)
		local column = offset % self._layout.columns
		local row = offset // self._layout.columns - self._startRow
		if self._cursorState.view == "hex" then
			cursor.Position = UDim2.fromOffset(
				self._gutterOffset + column * self._cellWidth,
				self._headerOffset + (row + 1) * self._cellHeight - self._scrollOffset
			) + positionOffset
		elseif self._cursorState.view == "ascii" then
			cursor.Position = UDim2.fromOffset(
				self._gutterOffset
					+ self._cellWidth * self._layout.columns
					+ self._theme.sectionGap
					+ column * self._halfCellWidth,
				self._headerOffset + (row + 1) * self._cellHeight - self._scrollOffset
			) + positionOffset
		end
	end
end

function HexEditor:_buildCursor()
	local cursor = Instance.new("Frame")
	self._cursor = cursor
	cursor.BorderSizePixel = 0
	cursor.AnchorPoint = Vector2.new(0, 1)
	cursor.ZIndex = 2
	cursor.Parent = self.scrollingFrameContent

	self:_updateCursorStyle(self._cursorState.mode)

	-- Blinking
	task.spawn(function()
		while cursor.Parent do
			task.wait(0.5)
			if self._inModal then
				cursor.Visible = false
			else
				cursor.Visible = (not self._cursorState.isInactive) and (not cursor.Visible)
			end
		end
	end)
end

function HexEditor:_buildUi()
	self:_clearUi()

	local scrollingFrame = Instance.new("ScrollingFrame")
	self.scrollingFrame = scrollingFrame
	scrollingFrame.BorderSizePixel = 0
	scrollingFrame.BackgroundTransparency = 1
	scrollingFrame.Size = UDim2.fromScale(1, 1)

	local scrollingFrameProperties = self._theme.scrollingFrameProperties
	if scrollingFrameProperties then
		for property, value in scrollingFrameProperties do
			(scrollingFrame :: any)[property] = value
		end
	end

	local scrollingFrameContent = Instance.new("Frame")
	self.scrollingFrameContent = scrollingFrameContent
	scrollingFrameContent.BackgroundColor3 = self._theme.cell.background
	scrollingFrameContent.ClipsDescendants = true
	scrollingFrameContent.BorderSizePixel = 0
	scrollingFrameContent.ZIndex = 2
	scrollingFrameContent.Size = UDim2.new(1, -scrollingFrame.ScrollBarThickness, 1, 0)

	scrollingFrameContent.Parent = self._container
	scrollingFrame.Parent = self._container
	scrollingFrame:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
		self:_rebuildVisibleRows()
	end)

	scrollingFrame:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
		local startRow = scrollingFrame.CanvasPosition.Y // self._cellHeight
		self._startRow = startRow

		local totalRows = math.ceil((self._bufferSize + 1) / self._layout.columns)
		local extra = (scrollingFrame.AbsoluteWindowSize.Y - self._headerOffset) % self._cellHeight
		local maxScrollPosition = scrollingFrame.AbsoluteCanvasSize.Y - scrollingFrame.AbsoluteWindowSize.Y
		local scrollToBottomAfter = (maxScrollPosition // self._cellHeight - 1) * self._cellHeight + extra + self._cellHeight/2
		if scrollingFrame.CanvasPosition.Y > scrollToBottomAfter then
			self._scrollOffset = self._cellHeight - extra
		else
			self._scrollOffset = 0
		end

		self:_rebuildVisibleRows()
		self:_updateCursorPosition()
	end)

	if self._layout.showHeader then
		self:_buildHeader()
	end

	self:_rebuildVisibleRows()

	self.horizontalSpaceUsed = self._gutterOffset
		+ self._cellWidth * self._layout.columns
		+ (if self._layout.showDecodedText
			then self._halfCellWidth * self._layout.columns + self._theme.sectionGap
			else 0)
		+ scrollingFrame.ScrollBarThickness
		+ 6

	if self._sizing == "fillContainerY" then
		local sizeConstraint = Instance.new("UISizeConstraint")
		sizeConstraint.MaxSize = Vector2.new(self.horizontalSpaceUsed, math.huge)
		sizeConstraint.Parent = scrollingFrame

		local sizeConstraint2 = sizeConstraint:Clone()
		sizeConstraint2.MaxSize = Vector2.new(self.horizontalSpaceUsed - scrollingFrame.ScrollBarThickness, math.huge)
		sizeConstraint2.Parent = scrollingFrameContent
	end

	self._sizeChanged:Fire(self.horizontalSpaceUsed)
end

function HexEditor:_clearUi()
	if self._cursor then
		self._cursor:Destroy()
		self._cursor = nil
	end

	self._rowIdentities = {}

	for _, row in self._rows do
		row.frame:Destroy()
	end
	self._rows = {}

	if self._header then
		self._header:Destroy()
		self._headerOffset = 0
		self._header = nil
	end

	self._startRow = 0
	self._scrollOffset = 0

	if self.scrollingFrameContent then
		self.scrollingFrameContent:Destroy()
		self.scrollingFrameContent = nil
	end

	if self.scrollingFrame then
		self.scrollingFrame:Destroy()
		self.scrollingFrame = nil
	end

	if self._hiddenTextBox then
		self:_destroyHiddenTextBox()
	end

	self.horizontalSpaceUsed = 0
end

return table.freeze(HexEditor)


--!strict

local Theme = require(script.Parent.Parent.Theme)
local Types = require(script.Parent.Types)

local HexEditor = require(script.HexEditor)
local DataInspector = require(script.DataInspector)
local FormProvider = require(script.FormProvider)

export type HexImplementation = {
	__index: HexImplementation,
	from: (buf: buffer, container: Frame, options: Types.BufferViewerFromOptions) -> Hex,
	setValue: (Hex, buf: buffer) -> (),
	getValue: (Hex) -> buffer,
	destroy: (Hex) -> (),

	undo: (Hex) -> (),
	redo: (Hex) -> (),
	canUndo: (Hex) -> boolean,
	canRedo: (Hex) -> boolean,
}

export type HexFields = {
	historyChanged: RBXScriptSignal,

	_container: Frame,
	_editor: HexEditor.HexEditor,
	_dataInspector: DataInspector.DataInspector,
}

export type Hex = typeof(setmetatable({} :: HexFields, {} :: HexImplementation))

local Hex: HexImplementation = {} :: HexImplementation
Hex.__index = Hex

local function generateCellTheme(theme: Theme.Theme, accent: Color3?): HexEditor.CellThemeData
	local mainBackground = if accent
		then accent:Lerp(theme.colors.background, math.min(0.8, 2 * theme.colors.button.transparent.hover))
		else theme.colors.background

	local accent = accent or theme.colors.mainAccent

	local hoverTransparency = math.min(0.75, 1.5 * theme.colors.button.transparent.select)
	local selectTransparency = math.min(0.6, 1.25 * theme.colors.button.transparent.select)

	local hoverBackground = accent:Lerp(theme.colors.background, hoverTransparency)

	local trueSelectBackground = theme.colors.mainAccent:Lerp(theme.colors.background, selectTransparency)
	local selectBackground = accent:Lerp(theme.colors.background, selectTransparency)
	local finalSelectBackground = selectBackground:Lerp(trueSelectBackground, 0.5)

	local cursorBackground =
		accent:Lerp(theme.colors.background, math.min(0.5, 0.75 * theme.colors.button.transparent.select))

	return {
		background = mainBackground,
		text = Theme.improveContrast(theme.colors.text, mainBackground),

		hover = hoverBackground,
		hoverText = Theme.improveContrast(theme.colors.text, hoverBackground),

		select = finalSelectBackground,
		selectText = Theme.improveContrast(theme.colors.text, finalSelectBackground),

		cursorActive = cursorBackground,
		cursorActiveText = Theme.improveContrast(theme.colors.text, cursorBackground),

		cursor = accent,
	}
end

-- Generates themes for hex editor and data inspector
local function generateThemes(theme: Theme.Theme): (HexEditor.ThemeData, DataInspector.ThemeData)
	local accentHue, accentSaturation, accentValue = theme.colors.mainAccent:ToHSV()
	local hoverAccent = Color3.fromHSV((accentHue + 0.525) % 1, accentSaturation, accentValue)
	local addAccent = Color3.fromHSV((accentHue + 0.7) % 1, accentSaturation, accentValue)

	local hexEditorTheme = {
		cell = generateCellTheme(theme),
		modifiedCell = generateCellTheme(theme, hoverAccent),
		addCell = generateCellTheme(theme, addAccent),

		headerText = theme.colors.disabledText,
		headerActiveText = theme.colors.text,
		headerBackground = theme.colors.background,

		gutterText = theme.colors.disabledText,
		gutterActiveText = theme.colors.text,
		gutterBackground = theme.colors.background,

		font = Font.fromEnum(Enum.Font.Code),
		textSize = 14,

		sectionGap = 12,
		cellPadding = 4,
		halfCellPadding = 0,

		scrollingFrameProperties = {
			ScrollBarThickness = 8,
			ScrollBarImageColor3 = Color3.new(0.5, 0.5, 0.5),
		},
	}

	local dataInspectorTheme = {
		background = theme.colors.background,
		bodyPadding = 4,
		editorMargin = 12,

		headerBackground = theme.colors.background,
		headerText = theme.colors.disabledText,

		keyText = theme.colors.text,
		valueText = theme.colors.text,
		lightValueText = theme.colors.disabledText,
		keyValuePadding = 24,
		rowPadding = 4,

		font = Font.fromEnum(Enum.Font.Code),
		textSize = 14,

		scrollingFrameProperties = {
			ScrollBarThickness = 8,
			ScrollBarImageColor3 = Color3.new(0.5, 0.5, 0.5),
		},
	}

	return hexEditorTheme, dataInspectorTheme
end

function Hex.from(buf: buffer, container: Frame, options: Types.BufferViewerFromOptions)
	local self = setmetatable({}, Hex)
	self._theme = options.theme

	self._container = container

	local hexEditorTheme, dataInpsectorTheme = generateThemes(options.theme)
	self._editor = HexEditor.new(buf, {
		container = self._container,
		sizing = "fillContainerY",
		theme = hexEditorTheme,
		layout = {
			columns = 16,
			showHeader = true,
			showGutter = true,
			showDecodedText = true,
			gutterColumns = 6,
		},
	}, {
		inputReceiver = options.inputReceiver,
		forms = FormProvider.generate(self._theme, hexEditorTheme, options.inputReceiver, options.widget),
	})

	self._dataInspector = DataInspector.new(self._editor, {
		container = self._container,
		sizing = "fillContainerY",
		theme = dataInpsectorTheme,
	})

	self.historyChanged = self._editor.historyChanged

	return self
end

function Hex.setValue(self: Hex, buf: buffer)
	self._editor:setBuffer(buf)
end

function Hex.getValue(self: Hex): buffer
	local got, size = self._editor:getRawBuffer()
	local copy = buffer.create(size)
	buffer.copy(copy, 0, got, 0, size)
	return copy
end

function Hex.canUndo(self: Hex)
	return self._editor:canUndo()
end

function Hex.canRedo(self: Hex)
	return self._editor:canRedo()
end

function Hex.redo(self: Hex)
	return self._editor:redo()
end

function Hex.undo(self: Hex)
	return self._editor:undo()
end

function Hex.destroy(self: Hex)
	self._editor:destroy()
	self._dataInspector:destroy()
end

return Hex


--!strict

local UIMessages = require(script.Parent.Parent.UIMessages)
local Theme = require(script.Parent.Parent.Theme)

export type BufferViewerMode = "Hex" | "Deserializer"

export type BufferViewerFromOptions = {
	uiMessages: UIMessages.UIMessages,
	theme: Theme.Theme,
	inputReceiver: GuiObject,
	widget: PluginGui,
}

export type BufferViewer = {
	from: (buf: buffer, container: Frame, options: BufferViewerFromOptions) -> BufferViewer,
	setValue: (BufferViewer, buf: buffer) -> (),
	getValue: (BufferViewer) -> buffer,
	destroy: (BufferViewer) -> (),

	undo: (BufferViewer) -> (),
	redo: (BufferViewer) -> (),
	canUndo: (BufferViewer) -> boolean,
	canRedo: (BufferViewer) -> boolean,
	historyChanged: RBXScriptSignal,
}

return {}


--!strict

-- Keeps track if the UI has a modal or not.

type ModalStateImplementation = {
	__index: ModalStateImplementation,

	new: () -> ModalState,

	push: (ModalState) -> (),
	pop: (ModalState) -> (),
	wrap: <T>(ModalState, action: () -> T) -> T,

	pushId: (ModalState, id: string) -> (),
	popId: (ModalState, id: string) -> (),

	_update: (ModalState) -> (),
}

type ModalStateFields = {
	isInside: boolean,
	_count: number,
	_ids: {[string]: boolean},
}

export type ModalState = typeof(setmetatable({} :: ModalStateFields, {} :: ModalStateImplementation))

local ModalState: ModalStateImplementation = {} :: ModalStateImplementation
ModalState.__index = ModalState

function ModalState.new()
	return setmetatable({
		isInside = false,
		_count = 0,
		_ids = {},
	}, ModalState)
end

function ModalState:_update()
	self.isInside = (self._count > 0) or (not not next(self._ids))
end

function ModalState:push()
	self._count += 1
	self:_update()
end

function ModalState:pop()
	self._count -= 1
	self:_update()
end

function ModalState:pushId(id)
	self._ids[id] = true
	self:_update()
end

function ModalState:popId(id)
	self._ids[id] = nil
	self:_update()
end

function ModalState:wrap<T>(action)
	self:push()
	local result = action() :: any
	self:pop()
	return result
end

return ModalState


-- Helps with popup stuff
--	`clamp` clamps an object so it doesn't go out of bounds of a container
--	`attach` attach an object to another object so it moves with that object
--	`modal` turns an object into a modal so it blocks input from other objects
-- `attach` and `modal` return objects that should be destroyed once they are done with (using `:destroy()`).

local globalTheme = require(script.Parent.Theme).global
local PopupHelper = {}

PopupHelper.DEFAULT_MODAL_ZINDEX = 10

--// Clamp

export type ClampOptions = {
	tryPivot: boolean?, -- Pivot will try to pivot the object about its top-left anchor first before clamping.
	boundaryDistance: number?,
}
-- Clamps a GuiObject to container
-- If no container is provided, clamp to the first ancestor LayerCollector
function PopupHelper.clamp(object: GuiObject, container: GuiBase2d?, options: ClampOptions?)
	options = options or {}
	assert(options)

	container = container or object:FindFirstAncestorWhichIsA("LayerCollector")

	options.boundaryDistance = options.boundaryDistance or 10
	assert(options.boundaryDistance)

	assert(container, `{object} is not parented to a LayerCollector.`)

	if options.tryPivot then
		if object.AbsolutePosition.X + object.AbsoluteSize.X > container.AbsoluteSize.X then
			if object.AbsolutePosition.X - object.AbsoluteSize.X > 0 then
				object.Position -= UDim2.fromOffset(object.AbsoluteSize.X, 0)
			end
		end
		if object.AbsolutePosition.Y + object.AbsoluteSize.Y > container.AbsoluteSize.Y then
			if object.AbsolutePosition.Y - object.AbsoluteSize.Y > 0 then
				object.Position -= UDim2.fromOffset(0, object.AbsoluteSize.Y)
			end
		end
	end

	local maxX = container.AbsoluteSize.X - 2 * options.boundaryDistance
	if
		(object.AbsoluteSize.X >= maxX)
		and (object.AutomaticSize == Enum.AutomaticSize.X or object.AutomaticSize == Enum.AutomaticSize.XY)
	then
		if object.AutomaticSize == Enum.AutomaticSize.X then
			object.AutomaticSize = Enum.AutomaticSize.None
		else
			object.AutomaticSize = Enum.AutomaticSize.Y
		end
		object.Size = UDim2.new(0, maxX, object.Size.Y.Scale, object.Size.Y.Offset)
	end

	local leftDistance = (object.AbsolutePosition.X - container.AbsolutePosition.X)
	local rightDistance = container.AbsoluteSize.X - (object.AbsolutePosition.X + object.AbsoluteSize.X)

	local topDistance = (object.AbsolutePosition.Y - container.AbsolutePosition.Y)
	local bottomDistance = container.AbsoluteSize.Y - (object.AbsolutePosition.Y + object.AbsoluteSize.Y)

	object.Position += UDim2.fromOffset(
		math.max(0, options.boundaryDistance - leftDistance) - math.max(0, options.boundaryDistance - rightDistance),
		math.max(0, options.boundaryDistance - topDistance) - math.max(0, options.boundaryDistance - bottomDistance)
	)
end

--// Bind

local Attachment = {}
Attachment.__index = Attachment

function Attachment:updatePosition()
	if self.options.beforeUpdate then
		if self.options.beforeUpdate(self.options, self.object, self.target) == "manual" then
			return
		end
	end

	local position = self.target.AbsolutePosition
		+ self.target.AbsoluteSize * self.options.anchor
		+ 2 * (self.options.anchor - Vector2.new(0.5, 0.5)) * self.options.offset

	self.object.Position = UDim2.fromOffset(position.X, position.Y)

	if self.options.afterUpdate then
		self.options.afterUpdate(self.options, self.object, self.target)
	end
end

function Attachment:destroy(completely: boolean)
	for _, connection in self._connections do
		connection:Disconnect()
	end
	if completely then
		self.object:Destroy()
	end
	self.object = nil
	self.target = nil
end

--
export type AttachOptions = {
	anchor: Vector2, -- Position relative to what it is being attached to
	offset: Vector2,

	-- Callback called every time the attachment has to change positions
	-- Can be used to dynamically change the attachment (as with SelectStyleState)
	-- If it returns "manual", then the the attachment will not change the position of the object
	beforeUpdate: ((AttachOptions, object: GuiObject, target: GuiObject) -> "manual"?)?,
	-- Callback called after every update.
	afterUpdate: ((AttachOptions, object: GuiObject, target: GuiObject) -> ())?,
}

-- Makes an object always move with a target object at a certain offset
-- Assumes that UDim2.fromOffset(0, 0) will place the object at the top-left corner of the gui
-- Options are required
function PopupHelper.attach(object: GuiObject, target: GuiObject, options: AttachOptions)
	local attachment = setmetatable({
		object = object,
		target = target,

		options = options,
		_connections = {},
	}, Attachment)

	attachment:updatePosition()

	table.insert(
		attachment._connections,
		target:GetPropertyChangedSignal("AbsolutePosition"):Connect(function()
			attachment:updatePosition()
		end)
	)
	table.insert(
		attachment._connections,
		target:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
			attachment:updatePosition()
		end)
	)

	return attachment
end

--// Modal

local Modal = {}
Modal.__index = Modal

function Modal:hide()
	self.object.Visible = false
	self.blockInputFrame.Visible = false
	if self.shadow then
		self.shadow.Visible = false
	end
end

function Modal:reveal()
	self.object.Visible = true
	self.blockInputFrame.Visible = true
	if self.shadow then
		self.shadow.Visible = true
	end
end

function Modal:attachDropShadow()
	self.shadow = PopupHelper.attachDropShadow(self.object)
	self.shadow.Name = `{self.object}_Shadow`
end

function Modal:destroy()
	self.blockInputFrame:Destroy()
	self._cancelled:Destroy()
	if self.shadow then
		self.shadow:Destroy()
		self.shadow = nil
	end
end

export type ModalOptions = {
	zIndex: number?,
	scrimTransparency: number?,
	reparent: boolean?,
	passThroughInput: boolean?,
	includeDropShadow: boolean?,
}
-- Turns an object into a modal and blocks all inputs in container
function PopupHelper.modal(object: GuiObject, container: GuiBase2d, options: ModalOptions?)
	options = options or {}
	assert(options)

	options.zIndex = options.zIndex or PopupHelper.DEFAULT_MODAL_ZINDEX
	assert(options.zIndex)

	options.scrimTransparency = options.scrimTransparency or globalTheme.colors.scrimTransparency
	options.reparent = if options.reparent == nil then true else options.reparent

	local modal = setmetatable({
		object = object,
		blockInputFrame = script.BlockInput:Clone(),
		shadow = nil,
		_cancelled = Instance.new("BindableEvent"),
	}, Modal)

	-- Create block input frame
	modal.blockInputFrame.BackgroundTransparency = options.scrimTransparency
	modal.blockInputFrame.ZIndex = options.zIndex
	if options.passThroughInput then
		modal.blockInputFrame.ClickCapture.Active = false
		modal.blockInputFrame.ClickCapture.Interactable = false
		modal.blockInputFrame.BackgroundTransparency = 1

		modal.blockInputFrame.InputEnded:Connect(function(input)
			if
				input.UserInputType == Enum.UserInputType.MouseButton1
				or input.UserInputType == Enum.UserInputType.MouseButton2
			then
				modal._cancelled:Fire()
			end
		end)
	end
	modal.blockInputFrame.Parent = container

	-- Events
	modal.cancelled = modal._cancelled.Event

	-- Parenting
	if options.reparent then
		object.Parent = container
	end
	object.ZIndex = options.zIndex + 2

	-- Connecting events
	modal.blockInputFrame.ClickCapture.Activated:Connect(function()
		modal._cancelled:Fire()
	end)
	modal.blockInputFrame.ClickCapture.MouseButton2Click:Connect(function()
		modal._cancelled:Fire()
	end)

	if (options.includeDropShadow == nil) or options.includeDropShadow then
		modal:attachDropShadow()
	end
	return modal
end

function PopupHelper.attachDropShadow(object: GuiObject)
	local dropShadow = script.DropShadowTemplate:Clone()
	dropShadow.ZIndex = object.ZIndex - 1

	local function update()
		if dropShadow.Parent then
			dropShadow.Position = UDim2.fromOffset((object.AbsolutePosition.X - dropShadow.Parent.AbsolutePosition.X) - 15, (object.AbsolutePosition.Y - dropShadow.Parent.AbsolutePosition.Y) - 9)
			dropShadow.Size = UDim2.fromOffset(object.AbsoluteSize.X + 30, object.AbsoluteSize.Y + 26)
		end
	end

	dropShadow.Parent = object.Parent
	update()

	object:GetPropertyChangedSignal("AbsoluteSize"):Connect(update)
	object:GetPropertyChangedSignal("AbsolutePosition"):Connect(update)
	object.Destroying:Connect(function()
		dropShadow:Destroy()
	end)

	return dropShadow
end

return PopupHelper


local TweenService = game:GetService("TweenService")
local StyleStateHelper = require(script.Parent.StyleStateHelper)

local BackgroundStyleState = {}
BackgroundStyleState.__index = BackgroundStyleState

type BackgroundStyleStateOptions = {
	style: "default" | "popup" | "image" | "header",
}
function BackgroundStyleState.from(theme, background: Frame, options: BackgroundStyleStateOptions?)
	options = options or {} :: BackgroundStyleStateOptions
	assert(options)

	options.style = options.style or "default"

	local self = setmetatable({
		background = background,
		stroke = background:FindFirstChildOfClass("UIStroke"),
		theme = theme,

		style = options.style,
	}, BackgroundStyleState)

	self:update("instant")
	return self
end

function BackgroundStyleState:update(speed: StyleStateHelper.TransitionSpeed)
	local tweenInfo = StyleStateHelper.getTweenInfoForSpeed(speed)
	local background = if self.style == "default"
		then "background"
		elseif self.style == "image" then "imageBackground"
		elseif self.style == "header" then "headerBackground"
		else "popupBackground"
	local outline = if self.style == "default"
		then "backgroundOutline"
		elseif self.style == "image" then "imageBackgroundOutline"
		elseif self.style == "header" then "backgroundOutline"
		else "popupBackgroundOutline"

	StyleStateHelper.tween(self.background, tweenInfo, {
		BackgroundColor3 = self.theme.colors[background],
	})
	if self.stroke then
		StyleStateHelper.tween(self.stroke, tweenInfo, {
			Color = self.theme.colors[outline],
		})
	end
end

function BackgroundStyleState:destroy(completely: boolean)
	if completely then
		self.background:Destroy()
	end
end

return BackgroundStyleState


-- Handles bread crumbs
-- If the breadcrumbs is too long for the window, it will switch into a dropdown

local TweenService = game:GetService("TweenService")

local StyleStateHelper = require(script.Parent.StyleStateHelper)
local ButtonStyleState = require(script.Parent.ButtonStyleState)
local SelectStyleState = require(script.Parent.Input.SelectStyleState)

local BreadcrumbsStyleState = {}
BreadcrumbsStyleState.__index = BreadcrumbsStyleState

export type BreadcrumbsLayout = "breadcrumbs" | "dropdown"

type Crumb = {
	styleState: ButtonStyleState.ButtonStyleState,
	category: string,
	name: string,

	unselectedIcon: string,
	selectedIcon: string,
}

function BreadcrumbsStyleState.from(theme, frame: Frame & { UIListLayout: UIListLayout })
	local self = setmetatable({
		theme = theme,
		frame = frame,

		disabled = false,

		layout = "breadcrumbs" :: BreadcrumbsLayout,
		_lastContentSizeX = 0,
		_selectStyleState = nil, -- Only used for "dropdown" layout

		crumbs = {},
		currentCrumbIndex = nil,
		_pointers = {}, -- Unused pointers are parented to nil and destroyed when the StyleState is destroyed

		_connections = {},
	}, BreadcrumbsStyleState)

	self._crumbChanged = Instance.new("BindableEvent")
	self.crumbChanged = self._crumbChanged.Event

	task.defer(function()
		self._lastContentSizeX = frame.UIListLayout.AbsoluteContentSize.X

		local function updateLayout()
			if not frame.Visible then
				return
			end

			if self.layout == "breadcrumbs" then
				self._lastContentSizeX = frame.UIListLayout.AbsoluteContentSize.X
			end

			if frame.AbsoluteSize.X + 20 < self._lastContentSizeX then
				self:_setLayout("dropdown")
			else
				self:_setLayout("breadcrumbs")
			end
		end

		table.insert(
			self._connections,
			frame.UIListLayout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(updateLayout)
		)
		table.insert(self._connections, frame:GetPropertyChangedSignal("AbsoluteSize"):Connect(updateLayout))
		table.insert(self._connections, frame:GetPropertyChangedSignal("Visible"):Connect(updateLayout))
	end)

	self:update("instant")
	return self
end

function BreadcrumbsStyleState:update(speed: StyleStateHelper.TransitionSpeed?)
	if self._selectStyleState then
		self._selectStyleState:update(speed)
	end

	if self.layout ~= "breadcrumbs" then
		speed = "instant"
	end

	for _, crumb in self.crumbs do
		crumb.styleState:update(speed)
	end

	local tweenInfo = StyleStateHelper.getTweenInfoForSpeed(speed)
	for _, pointer in self._pointers do
		TweenService:Create(pointer, tweenInfo, {
			ImageColor3 = self.theme.colors.text,
		}):Play()
	end
end

function BreadcrumbsStyleState:jumpTo(crumbIndex: number)
	local lastCrumb = self.crumbs[self.currentCrumbIndex]
	if lastCrumb then
		lastCrumb.styleState.button.TextLabel.FontFace = Font.new(
			script.Crumb.TextLabel.FontFace.Family,
			Enum.FontWeight.Regular,
			script.Crumb.TextLabel.FontFace.Style
		)
		lastCrumb.styleState.button.Icon.Image = lastCrumb.unselectedIcon
	end

	local currentCrumb = self.crumbs[crumbIndex]
	self.currentCrumbIndex = crumbIndex
	currentCrumb.styleState.button.TextLabel.FontFace =
		Font.new(script.Crumb.TextLabel.FontFace.Family, Enum.FontWeight.Bold, script.Crumb.TextLabel.FontFace.Style)
	currentCrumb.styleState.button.Icon.Image = currentCrumb.selectedIcon

	if self._selectStyleState then
		self._selectStyleState:setSelection(currentCrumb.name, {
			index = crumbIndex,
			silently = true,
		})
	end

	self._crumbChanged:Fire(currentCrumb)
end

-- Icon can be an image id or a table with the image ids for the selected and unselected states
function BreadcrumbsStyleState:addCrumb(
	icon: string | { selected: string, unselected: string },
	category: string,
	name: string
)
	local index = nil
	local button = script.Crumb:Clone()
	local styleState = ButtonStyleState.from(self.theme, button, {
		style = "transparent",
	})

	local unselectedIcon: string
	local selectedIcon: string
	if typeof(icon) == "table" then
		unselectedIcon = icon.unselected
		selectedIcon = icon.selected
	else
		unselectedIcon = icon
		selectedIcon = icon
	end

	local crumb: Crumb = {
		styleState = styleState,
		category = category,
		name = name,
		unselectedIcon = unselectedIcon,
		selectedIcon = selectedIcon,

		hovering = false,
		selecting = false,
	}

	button.Icon.Image = unselectedIcon
	button.TextLabel.Text = name

	button.Activated:Connect(function()
		self:jumpTo(index)
	end)

	if self.currentCrumbIndex then
		-- Destroy crumbs that come after
		for i = self.currentCrumbIndex + 1, #self.crumbs do
			self:_cleanupCrumb(self.crumbs[i])
			self.crumbs[i] = nil
		end
	end

	if self.layout == "breadcrumbs" then
		button.Parent = self.frame
	end
	table.insert(self.crumbs, crumb)
	index = #self.crumbs

	self:_tryUpdateSelect()

	self:jumpTo(index)
	self:_updatePointers()
end

function BreadcrumbsStyleState:popCrumb(options: { silently: boolean? }?)
	options = options or {}
	assert(options)

	local crumb = table.remove(self.crumbs)
	self:_cleanupCrumb(crumb)

	local ptr = table.remove(self._pointers)
	if ptr then
		ptr:Destroy()
	end

	if not options.silently then
		if self.currentCrumbIndex > #self.crumbs then
			self:jumpTo(#self.crumbs)
		end
	end

	self:_tryUpdateSelect()
	return crumb
end

function BreadcrumbsStyleState:renameCrumbSafe(index: number, name: string)
	local crumb = self.crumbs[index]
	if crumb then
		crumb.name = name
		crumb.styleState.button.TextLabel.Text = name
	end

	self:_tryUpdateSelect()
end

function BreadcrumbsStyleState:setCrumbNameVisibleSafe(index: number, visible: boolean)
	local crumb = self.crumbs[index]
	if crumb then
		crumb.styleState.button.TextLabel.Visible = visible

		-- AutomaticSize does not shrink it enough for some reason
		-- So manually set the size

		if visible then
			crumb.styleState.button.AutomaticSize = Enum.AutomaticSize.X
			crumb.styleState.button.Size = script.Crumb.Size
			crumb.styleState.button.SizeConstraint = Enum.SizeConstraint.RelativeXY
		else
			crumb.styleState.button.AutomaticSize = Enum.AutomaticSize.None
			crumb.styleState.button.Size = UDim2.new(script.Crumb.Size.Y, script.Crumb.Size.Y)
			crumb.styleState.button.SizeConstraint = Enum.SizeConstraint.RelativeYY
		end
	end
end

function BreadcrumbsStyleState:getCurrentCrumb()
	return self.crumbs[self.currentCrumbIndex]
end

function BreadcrumbsStyleState:setDisabled(disabled: boolean)
	self.disabled = disabled
	for _, crumb in self.crumbs do
		crumb.styleState:setDisabled(disabled)
	end

	return self
end

function BreadcrumbsStyleState:destroy(completely: boolean)
	for _, crumb in self.crumbs do
		self:_cleanupCrumb(crumb)
	end
	for _, pointer in self._pointers do
		pointer:Destroy()
	end
	for _, connection in self._connections do
		connection:Disconnect()
	end

	if completely then
		self.frame:Destroy()
	end
end

-- Private

function BreadcrumbsStyleState:_cleanupCrumb(crumb: Crumb)
	crumb.styleState:destroy(true)
end

function BreadcrumbsStyleState:_updatePointers()
	-- Delete unused pointers
	for i = #self.crumbs, #self._pointers do
		self._pointers[i]:Destroy()
		self._pointers[i] = nil
	end

	for i, crumb in self.crumbs do
		crumb.styleState.button.LayoutOrder = 2 * i
		if i > 1 then
			local pointer = self._pointers[i - 1]
			if not pointer then
				pointer = script.Pointer:Clone()
				table.insert(self._pointers, pointer)
			end
			pointer.LayoutOrder = 2 * i - 1
			pointer.ImageColor3 = self.theme.colors.text

			if self.layout == "breadcrumbs" then
				pointer.Parent = self.frame
			end
		end
	end
end

function BreadcrumbsStyleState:_getSelectOptions(): SelectStyleState.Options
	local options = {}
	for _, crumb in self.crumbs do
		table.insert(options, { name = crumb.name, icon = crumb.unselectedIcon })
	end
	return options
end

function BreadcrumbsStyleState:_tryUpdateSelect()
	if self._selectStyleState then
		self._selectStyleState:changeOptions(self:_getSelectOptions())
	end
end

function BreadcrumbsStyleState:_resetLayout()
	if self.layout == "breadcrumbs" then
		-- Hide everything
		for _, crumb in self.crumbs do
			crumb.styleState.button.Parent = nil
		end
		for _, pointer in self._pointers do
			pointer.Parent = nil
		end
	elseif self.layout == "dropdown" then
		-- Destroy the big button
		self._selectStyleState:destroy(true)
		self._selectStyleState = nil
	end
end

function BreadcrumbsStyleState:_setLayout(layout: BreadcrumbsLayout)
	if layout ~= self.layout then
		self:_resetLayout()
		self.layout = layout

		if layout == "breadcrumbs" then
			for _, crumb in self.crumbs do
				crumb.styleState.button.Parent = self.frame
			end
			for _, pointer in self._pointers do
				pointer.Parent = self.frame
			end
		elseif layout == "dropdown" then
			local button = script.DropdownCrumb:Clone()
			self._selectStyleState = SelectStyleState.from(self.theme, button, {
				default = self.crumbs[self.currentCrumbIndex].name,
				defaultIndex = self.currentCrumbIndex,
				options = self:_getSelectOptions(),
				buttonStyle = "transparent",
				fitButton = false,
			})
			self._selectStyleState.changed:Connect(function(_, index)
				self:jumpTo(index)
			end)
			button.Parent = self.frame
		end
	end
end

return BreadcrumbsStyleState


local StyleStateHelper = require(script.Parent.StyleStateHelper)

local ButtonStyleState = {}
ButtonStyleState.__index = ButtonStyleState

export type ButtonStyle = "primary" | "secondary" | "transparent" | "dormant" | "input"
export type ButtonStyleStateFromOptions = {
	style: ButtonStyle | nil,
	disabled: boolean?,
}
function ButtonStyleState.from(theme, button: TextButton, options: ButtonStyleStateFromOptions?)
	options = options or {}
	assert(options)

	local self = setmetatable({
		theme = theme,
		button = button,
		style = options.style or "secondary",

		disabled = options.disabled or false,
		selecting = false,
		_pressing = false,
		_hovering = false,

		disabledMessage = nil,
	}, ButtonStyleState)

	-- These will have their colors updated based on the state
	self._textLabels = {}
	self._imageLabels = {}
	self._strokes = {}
	self:_findStyleableChildren()

	self._beganConnection = button.InputBegan:Connect(function(input)
		if
			input.UserInputType == Enum.UserInputType.Touch
			or input.UserInputType == Enum.UserInputType.MouseButton1
		then
			self._pressing = true
			self:update("veryFast")

			local connection = nil
			connection = input:GetPropertyChangedSignal("UserInputState"):Connect(function()
				if
					input.UserInputState == Enum.UserInputState.End
					or input.UserInputState == Enum.UserInputState.Cancel
				then
					self._pressing = false
					connection:Disconnect()
					self:update("veryFast")
				end
			end)
		elseif input.UserInputType == Enum.UserInputType.MouseMovement then
			self._hovering = true
			self:update(if self.style == "transparent" then "superDuperFast" else nil)
		end
	end)

	self._endedConnection = button.InputEnded:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseMovement then
			self._hovering = false
			self:update(if self.style == "transparent" then "superDuperFast" else nil)
		end
	end)

	self:update("instant")
	return self
end

function ButtonStyleState:update(speed: StyleStateHelper.TransitionSpeed?)
	local tweenInfo = StyleStateHelper.getTweenInfoForSpeed(speed)
	local state = if self.disabled
		then "disable"
		elseif self.selecting then "select"
		elseif self._pressing then "press"
		elseif self._hovering then "hover"
		else "default"

	if self.style == "transparent" then
		StyleStateHelper.tween(self.button, tweenInfo, {
			BackgroundColor3 =
				if self.selecting then self.theme.colors.button.transparent.selectColor
				else self.theme.colors.button.transparent.baseColor,
			BackgroundTransparency = self.theme.colors.button.transparent[state],
		})

		local color = if self.disabled then self.theme.colors.disabledText else self.theme.colors.text
		self:_updateChildren(tweenInfo, {
			textLabels = color,
			imageLabels = color,
		})
	elseif self.style == "dormant" then
		self:_updateChildren(tweenInfo, {
			textLabels = self.theme.colors.button.dormant[state],
			imageLabels = self.theme.colors.button.dormant[state],
		})
	else
		self:_updateChildren(tweenInfo, {
			body = self.theme.colors.button[self.style].body[state],
			textLabels = self.theme.colors.button[self.style].text[state],
			imageLabels = self.theme.colors.button[self.style].text[state],
			strokes = self.theme.colors.button[self.style].outline[state],
		})
	end
end

function ButtonStyleState:setSelecting(selecting: boolean)
	self.selecting = selecting
	return self
end

function ButtonStyleState:setDisabled(disabled: boolean, disabledMessage: string?)
	self.disabled = disabled
	self.disabledMessage = disabledMessage
	self.button.Active = not disabled

	return self
end

function ButtonStyleState:destroy(completely: boolean)
	self._beganConnection:Disconnect()
	self._endedConnection:Disconnect()

	if completely then
		self.button:Destroy()
	end
end

-- Private
function ButtonStyleState:_findStyleableChildren()
	for _, descendant in self.button:GetDescendants() do
		if not descendant:GetAttribute("Exclude") then
			if descendant:IsA("TextLabel") then
				table.insert(self._textLabels, descendant)
			elseif descendant:IsA("ImageLabel") then
				table.insert(self._imageLabels, descendant)
			elseif descendant:IsA("UIStroke") then
				table.insert(self._strokes, descendant)
			end
		end
	end
end

function ButtonStyleState:_updateChildren(
	tweenInfo: TweenInfo,
	colors: {
		body: Color3?,
		textLabels: Color3?,
		imageLabels: Color3?,
		strokes: Color3?,
	}
)
	if colors.body then
		if self.button.BackgroundColor3 ~= colors.body then
			StyleStateHelper.tween(self.button, tweenInfo, { BackgroundColor3 = colors.body })
		end
	end

	if colors.textLabels then
		if self.button:IsA("TextButton") and self.button.TextTransparency < 1 then
			StyleStateHelper.tween(self.button, tweenInfo, { TextColor3 = colors.textLabels })
		elseif self.button:IsA("ImageButton") and self.button.ImageTransparency < 1 then
			StyleStateHelper.tween(self.button, tweenInfo, { ImageColor3 = colors.textLabels })
		end

		for _, label in self._textLabels do
			if label.TextColor3 ~= colors.textLabels then
				StyleStateHelper.tween(label, tweenInfo, { TextColor3 = colors.textLabels })
			end
		end
	end

	if colors.imageLabels then
		for _, label in self._imageLabels do
			if label.ImageColor3 ~= colors.imageLabels then
				StyleStateHelper.tween(label, tweenInfo, { ImageColor3 = colors.imageLabels })
			end
		end
	end

	if colors.strokes then
		for _, stroke in self._strokes do
			if stroke.Color ~= colors.strokes then
				StyleStateHelper.tween(stroke, tweenInfo, { Color = colors.strokes })
			end
		end
	end
end

export type ButtonStyleState = typeof(setmetatable({}, ButtonStyleState))

return ButtonStyleState


local TweenService = game:GetService("TweenService")
local StyleStateHelper = require(script.Parent.Parent.StyleStateHelper)
local ButtonStyleState = require(script.Parent.Parent.ButtonStyleState)

local CheckboxStyleState = {}
CheckboxStyleState.__index = CheckboxStyleState

export type CheckboxOptions = {
	default: boolean?,

	-- The inputPair will also be used to process clicks.
	-- Useful if you have a label next to the checkbox that you want to be clickable.
	inputPair: GuiButton?,
}
function CheckboxStyleState.from(
	theme,
	checkbox: TextButton & { UIStroke: UIStroke, Check: ImageLabel },
	options: CheckboxOptions?
)
	options = options or {}
	assert(options)

	checkbox.Check:SetAttribute("Exclude", true) -- Exclude it from button theming

	local self = setmetatable({
		value = options.default or false,
		checkbox = checkbox,
		theme = theme,

		_hovering = false,
		_pressing = false,
	}, CheckboxStyleState)

	self._toggled = Instance.new("BindableEvent")
	self.toggled = self._toggled.Event

	self._connections = {
		self.checkbox.Activated:Connect(function()
			self:setValue(not self.value)
		end),
		self.checkbox.InputBegan:Connect(function(input)
			if input.UserInputType == Enum.UserInputType.MouseMovement then
				self._hovering = true
				self:update()
			elseif input.UserInputType == Enum.UserInputType.MouseButton1 then
				self._pressing = true
				self:update()

				local connection: RBXScriptConnection
				connection = input:GetPropertyChangedSignal("UserInputState"):Connect(function()
					if input.UserInputState ~= Enum.UserInputState.Begin and input.UserInputState ~= Enum.UserInputState.Change then
						connection:Disconnect()

						self._pressing = false
						self:update()
					end
				end)
			end
		end),
		self.checkbox.InputEnded:Connect(function(input)
			if input.UserInputType == Enum.UserInputType.MouseMovement then
				self._hovering = false
				self:update()
			end
		end)
	}
	self.checkbox.Check.Visible = self.value

	if options.inputPair then
		table.insert(self._connections, options.inputPair.Activated:Connect(function()
			self:setValue(not self.value)
		end))
	end

	self:update("instant")
	return self
end

function CheckboxStyleState:update(speed: StyleStateHelper.TransitionSpeed)
	local tweenInfo = StyleStateHelper.getTweenInfoForSpeed(speed)

	local style = if self.value then "primary" else "secondary"
	local state = if self._pressing then "press"
		elseif self._hovering then "hover"
		else "default"

	StyleStateHelper.tween(self.checkbox, tweenInfo, {
		BackgroundColor3 = self.theme.colors.button[style].body[state],
	})

	StyleStateHelper.tween(self.checkbox.UIStroke, tweenInfo, {
		Color = self.theme.colors.button[style].outline[state],
	})

	if self.checkbox.Check.Visible then
		StyleStateHelper.tween(self.checkbox.Check, tweenInfo, {
			ImageColor3 = self.theme.colors.button.primary.text.select,
		})
	end
end

function CheckboxStyleState:setValue(value)
	local lastValue = self.value
	self.value = value
	self.checkbox.Check.Visible = value
	self:update()

	if self.value ~= lastValue then
		self._toggled:Fire(self.value)
	end
end

function CheckboxStyleState:destroy(completely: boolean)
	for _, connection in self._connections do
		connection:Disconnect()
	end

	self._toggled:Destroy()
	if completely then
		self.checkbox:Destroy()
	end
end

return CheckboxStyleState