TPT Script Server

This page is used for previewing and submitting scripts for use with the Script Manager

Available Scripts

(2) TPTMulti by LBPHacker
(3) set wifi v2 by jacob1
(4) Script Paste by jacob1
(5) Random Element by jacob1
(6) Magical Merge Master 3000 by nucular
(7) More Fuel Mod-heavy by jward212
(9) Breakpoints (BRPT) by boxmein
(10) Cockroaches! by boxmein
(11) Random tree by ssccsscc
(12) Element with random properties by ssccsscc
(15) Minimalistic Element Dehider by nucular
(16) FPS Gauge by mniip
(17) TPT Radio by jward212
(18) Print Debugger by FeynmanLogomaker
(19) Powered BHOL by jacob1
(20) Light and lamps by electronic_steve
(21) Extremely Durable TTAN by QuentinADay
(22) New Buttons by QuentinADay
(23) Pure Energy by QuentinADay
(25) RCA's HUD XV Update I by RCAProduction
(27) 123456787654 vols by mjpowder
(28) Singularity Bomb by QuentinADay
(29) ES wifi set by electronic_steve
(30) Lua Elements Pack by FeynmanLogomaker
(31) stkm gun by jward212
(32) space building materials by kjack1111
(34) Rust bomb by Damian97
(35) Simple command block by ssccsscc
(36) Everlasting Fusion by QuentinADay
(37) Screenshot Organiser by mecha-man
(38) Napalm mod by cccp3
(39) Rocket fuel mod v0.15 by cccp3
(41) Useful web links by jward212
(42) TPT Logic Gates Mod by iamdumb
(43) ESTools by electronic_steve
(44) Head Crabs-HL2 by jward212
(45) Procedural Save Generator by boxmein
(46) smooth colours for nametag by jward212
(47) ZAKPACK by zak03
(49) Performance Monitor by FeynmanLogomaker
(51) Texter by byzod
(52) Texter default fonts by byzod
(53) Schicko's Font Pack for Texter by Schicko
(54) Realistic Element Names by Atomic10
(55) TPT's Mod V.3 Update 1 by Amy
(56) Temporaryaccount-Decorator by Temporaryaccount
(57) random save loader by jward212
(58) Tmp gradient display by ssccsscc
(59) particle re-orderer by mniip
(60) Electric Glow by jacob1
(61) More Fuel Mod-lite by jward212
(63) Rythidium by janekbe04
(64) Simple FPS GUI by Sfsjunior
(65) Enhanced Element Dehider by ChargedCreeper
(66) Graph of average temp by ssccsscc
(67) Template save loader by jacob1
(68) Lua Text Generator by jBot-42
(70) Pixel's Freezer by Pixelguru26
(71) Thingy | Fusion For Ever by TheChosenEvilOne
(72) op explosions by zolly_bro
(73) Scar by DorkyBobster
(74) ScreenShotMod by lill74
(77) Useful Things by TheEvilChosenOne
(78) Alchemy Mod by _MrN_
(79) Nuke v2 by Fnaf65
(80) Compressor mod by TheChosenEvilOne
(81) Custom Render Mode Loader by jacob1
(82) Spacewars by JosephMA
(83) MOAR - Alpha 0.1 by TheChosenEvilOne
(86) Element Creator by cxi
(88) Soapworm by LBPHacker
(90) Pressure Bomb by God_Kra
(91) SMFB by wntjq69
(92) Potato by cxi
(93) Subatomic Pack (BDS) by TPT_PL
(94) Acidic Pack (BDS) by TPT_PL
(95) Starbound Building Materials by Sanpypr
(96) Factory problems by TPT_PL
(97) Gamma Ray-diation by Kostia4381
(98) Magic by livingfossil
(99) Cross-window Copy/Cut/Paste by LBPHacker
(100) Langton's Ant with variations by LBPHacker
(101) Remote particle creator/deleter by TPT_PL
(102) Force fields by electronic_steve
(103) Reinforced Concrete by 12Me21
(105) TPT_PL's Lua Mod by TPT_PL
(106) Nuke v4 by Fnaf65
(107) CHEMMOD V1 by KevAK
(108) Chemicals by Ligan
(109) VonDaniel's Template by VonDaniel
(110) The Inaccurate Radioactivity Toy Mod by TuDoR2007
(112) textmonsterPack by textmonster404
(113) Meteor by TheScienceKid
(114) Tgpm by TuDoR2007
(115) Civilizations by TPT_PL
(116) RAD-MOD 1.2.1B by Kev_AK
(117) MicroLua by RamiLego4Game
(118) Extra customizable HUD by djy0212
(119) Ingame brush editor by ssccsscc
(120) Window Maker by Paul_31415
(121) CHEM-MOD V1.2B by Kev_AK
(122) Rainbow PHOT by Mrprocom
(123) stronger stickmanv by yuval
(124) 3D Pressure Visualizer by mniip
(125) Arkadian Liquid by JanKaszanka
(126) Fuel by nukers473
(127) Immersive Radioactivity v2.1 by Potbelly
(128) ElementLaunchingTool by juh9870
(129) CHEM-MOD_v1.2.2b by KevAK
(130) Slingshot by Mrprocom
(131) Perlin Noise Generator by DoubleF
(132) Element Replace by 159819
(133) Flooder V2 by TheAwesomeMutant
(134) Link Sign GUI by QuanTech
(135) Element dehider by 4e616d65
(136) Subphoton ROM Builder by mad-cow
(137) Hardened Dust by Liftski
(138) Bio-Vir by TheAwesomeMutant
(140) Orbit Simulator by Mrprocom
(141) johnnyou's Font for Texter by johnnyou (49796346)
(142) auto_wifi by phisically
(143) Layering helper by ssccsscc
(144) Layering Helper Extended by LuaMaster
(145) TPT Remade by TuDoR2007
(146) All-seeing sampler by djy0212
(147) Layering helper remastered by ssccsscc
(148) Eraser by thepowdertoy12
(149) EXPLOSIONS by olix3001
(150) Simple rocket fuel mod by ArseniyPlotnikov2006
(151) Pure Fission by Fnaf65
(154) Graph by ssccsscc
(155) Little's Pack! by LittleProgramming
(156) Lead by LoftisGaming
(157) WIFI Tuner by ssccsscc
(158) Previous Brush by 159819
(159) HUD Auto-Hider by Tim
(161) Stack tool by thepowdertoy12
(162) Oil and plastic by ArseniyPlotnikov2006
(163) Colored Ember by DUC
(164) Timer by ssccsscc
(165) Bacteria Mod by TuDoR2007
(166) Noise filter by LBPHacker
(167) Future-proof element dehider by LBPHacker
(168) RadioactiveNuke by DreamingWarlord
(169) Only Hot Element by DreamingWarlord
(170) Philosopher's Stone by Godhydra
(171) Conic section generator by LBPHacker
(172) Interface API by ssccsscc
(173) Metals&Materials by Ferrous26
(174) tpt.all by LBPHacker
(175) The Visual Elements Pack by Goblin01, vvv331
(176) Layering Helper Reforged by PowderNotSolid
(177) SNOWified SING by LBPHacker
(178) FPS Chart by Goblin01
(179) TPT font writer by Goblin01
(180) Simple Ruler by PowderNotSolid
(181) Heat Modifier by DreamingWarlord
(182) TPT Remade II by TuDoR2007
(183) Gravity simulator by ArseniyPlotnikov2k6
(184) Unobtainium by christheboss894
(185) TPTMIDI noteblock in tpt by djy0212
(186) DreamingWarlord's Lua Tool by DreamingWarlord
(187) Elements Tooltip by Goblin01
(189) Explodium script by 0d15ea5ebe57c0debadc0ffee0ddf00d
(190) more powered force elements by 6nop6nop
(191) Yzaak1Scifire Modpack by Yzaak1Scifire
(194) Fluor and more modpack! by galaktor
(195) Hot Powder by lieve_blendi
(196) Star by TUANMINHVIETNAM
(197) Heat Powders by lieve_blendi
(200) Tangeriinium (thx 2 cxi 4 code) by LostEditor
(201) Freezer by lieve_blendi
(202) Powder Power! by TPTSortaGuy
(203) PowderPlus v1.4 by PowderPlus Team
(204) fire by ME
(205) Stacked Goo Animations by Maticzpl
(206) Stickman Control for Android Version by PhauloRiquelme
(207) Spark Removal Button by Xyz
(208) More HEAC's! by Maxhd1234
(209) Immersive Radioactivity v3.0 by Potbelly
(210) Subframe Chipmaker Script by Maticzpl
(211) Realistic Propellants by ArseniyPlotnikov2k6
(212) Mass Equals Gravity by Maticzpl
(213) PhiMod v1 by ArolaunTech
(214) PC Controls for Android by Cracker1000
(217) Single-pixel pipe configurator by LBPHacker
(218) Omega Death Laser Gun by Dogeoum
(219) Notifications by Maticzpl
(221) Powderizer by ArolaunTech
(222) ElemDehider 1.2 by Inventor70
(223) Unobtainum V2 by DoomKittyAttack
(224) Organics Mod v0.2B by PowderPlus Team
(225) Gravity distortion by Avolte55
(226) tmp Wifi by PhauloRiquelme
(227) Alchemagica Mod v1.0 by RebMiami
(228) Fan Elements Mod by RebMiami
(229) Impossibilities by ArolaunTech
(230) Realistic Explosives by ArseniyPlotnikov2k6
(231) libactivation by anamorphic
(232) Alloy Brushes by Maticzpl
(233) Gravity bender by pres
(234) Slow Tick by Pixel
(235) Paste ID by Maticzpl
(236) many things by jadenflp2
(237) Territect by Rebmiami
(238) Better Descriptions v1.0.5 by ashyboi2022
(239) LIGHTNING SPRK by GOLmaster10101
(240) Small Bombs by juh9870
(241) Save Shop by aaccbb
(242) Moving solids v1.3.0 Beta by ArolaunTech
(244) Alchemistry by rdococ
(245) ETRD-IM by aaccbb
(246) RadonX by Justadirtblock
(248) Water-X by deuterium_oxide
(250) Indestructible INSL by CheekyRand0m
(252) Console's Mod by Console/Compec
(255) Slow motion by LBPHacker
(256) Powered Repeller by Hythonia
(257) Zeta's Electric Tools. by Zetalasis
(258) Azure serum (AZSR) by ALumpOfPowderToy
(259) COLORFULSAND by xert
(260) Lightning Circle by defaultuser0
(261) Powder Future Tech by JonaHungary
(262) TPTGlowingSolids by DestinyDyson
(263) Volcano Bomb by I_am_the_NugsWorld
(264) Neon Lights by Rebmiami
(265) Radioactive Materials by xyz
(266) Eater mod by VIPERGAMEZ
(267) the biology mod by someone
(268) Atomic Physics by qe
(269) Pure Radiation by ronansb
(270) Fake Elements by That_PowderToy_Guy
(271) Tachyons and MISC by RamenNoods
(272) Exotic Particles by rdococ
(273) FPS Slider by aaccbb
(274) Enphosian's Radioactive mod pack by Enphosian
(275) TPTMulti v2.2.5 Beta by LBPHacker
(276) ROM Builder by QnpfvTPz
(277) acb's Idea Generator by aaccbb
(278) Bode's Grapher by Bodester
(279) Gradient Tool by Bodester
(280) Better FPS Slider by ngmoco
(281) MyWIFI by Bodester
(282) acb's Slow-Mo Script by aaccbb
(283) Fun n' Chemicals by 0xHenryMC
(284) Resistant coating by git4rker
(285) Nationwide script by Kit237
(286) weird alphabet.lua by alice_loona_ot12
(287) MT's font for texter by Kit237
(288) Compiler for the SCE computer by NoVIcE by NoVIcE
(289) Perlin Noise Map Generator by Kit236
(290) hell fire element by Hecker
(291) Power stuff by William
(292) Missile by BaCOn
(293) Wargame's things by Kit236
(294) Inert Gas by EwguhMicBewguhGuy
(295) Average Temperature Display by tptQuantification
(296) Radicons by stillthere
(297) ROCKET by stillthere
(298) NUUKE! by stillthere
(299) SubpixelScope by Rebmiami
(300) Unobtanium Element Pack by AlgoPowdo
(301) Magnets by TheSuperNova
(302) library007 3000 by 008cff
(303) Element tuner by TheSuperNova
(304) Somethings by TheSuperNova
(305) Realistic Water by yuht
(306) Futurism by TheSuperNova
(307) MMod by MeltedLawnFlamingo
(308) Wierd energy by mellowmonster12
(309) Time Reversing by crocodilea
(310) Weather System by Bigmann
(311) infinite framerate by kayra
(312) Diamondifier mod by Believer0914
(313) Radtech by ReallyJustDont
(314) TPT 'port' of IRC Crackbot game by creator_of_fail
(315) Fireshoas Stuff by Fireshoa
(316) TPTASM: Universal assembler for TPT computers by LBPHacker
(317) Ronansb's Elements by ronansb
(318) AeroStuff v1.2 by AerospaceFan
(319) TPT Font Writer Expanded by budc123
(320) Element Table Pack by RetroBoy2040
(321) One Time Conductor by Believer0914
(322) Extra Debug Info by creator_of_fail
(323) burst v1 by ml_lom
(324) Nuclide Expansion by Alchnoious
(325) Nuclear Elems by xhrqnx2
(326) UM by Sunshine1304
(327) Android stamp rescanner by LBPHacker
(328) Temp Resetting Button by DoobsterBro1234
(329) INFEKTOR_infection_V1 by PowderTechnician1475
(330) Nanotech V2 by TheSuperNova
(331) super explosives by egglegendbro
(332) The Return of the Powder Dinn by savask
(333) INFEKTOR_infection_V2 by PowderTechnician1475
(334) Metallic Diamond by Shyth
(335) stupid stuff mod by stillthere
(336) R3 Keyboard Writer by lesio
(337) Chaos Matter by PstnPopper-2000
(338) VirusStuff by AerospaceFan
(339) acids and reactions by egglegendbro
(340) PipeHUD by JonaHungary
(341) visible warp by TheFPSKiller
(342) Ozone Mod by Aeiouman
(343) Leaf's Beautiful World by Leaf1
(344) Wireless FILT serializator by Houseinik
(345) TINY-PLATFORMER by LogikDOS
(346) RadiThor by linfuciuscont
(347) Aero's Periodic Table Mod by AerospaceFan
(348) MoreCommands by LogikDOS
(349) Line Tool+ by Saturnity
(350) MetalsPack by somedudesaidTPT

+ Submit new script!

Title: Author:
Script:
do
    local haveApis = (event ~= nil) and (evt ~= nil) and (graphics ~= nil)
        and (sim ~= nil) and (tpt ~= nil) and (sim.createLine ~= nil)
        and (ui ~= nil) and (ui.SDLK_c ~= nil)
    if not haveApis then
        local msg = "Line Tool+: required API missing (event/evt/graphics/sim/tpt/ui/sim.createLine), mod disabled"
        if tpt and tpt.log then tpt.log(msg) end
        print(msg)
        return
    end

    local abs, floor, ceil, min, max, sqrt = math.abs, math.floor, math.ceil, math.min, math.max, math.sqrt
    local atan2, sin, cos, rad, deg = math.atan2, math.sin, math.cos, math.rad, math.deg

    local GRAB_R2 = 16
    local COL_CYAN = {0, 255, 255, 255}
    local COL_ORANGE = {255, 140, 0, 255}
    local COL_YELLOW = {255, 255, 0, 255}
    local COL_DIMYELLOW = {180, 180, 60, 255}
    local COL_WHITE_PREVIEW = {255, 255, 255, 180}
    local COL_DIMGRAY = {150, 150, 150, 255}
    local COL_GREEN = {120, 220, 120, 255}

    local CATEGORIES = {"Line", "Shape"}
    local CATEGORY_MODES = {
        Line = {"QUAD", "CUBIC", "ARC", "CUSTOM"},
        Shape = {"CUSTOM", "TRI", "SQUARE", "PENT", "HEX", "OCT", "STAR", "CIRCLE"},
    }
    local DEFAULT_ARC_ANGLE = 90

    local SHAPE_DEFS = {
        TRI    = {n = 3},
        SQUARE = {n = 4, angleOffset = 45},
        PENT   = {n = 5},
        HEX    = {n = 6, angleOffset = 30},
        OCT    = {n = 8, angleOffset = 22.5},
        STAR   = {n = 5, inner = 0.4},
        CIRCLE = {n = 48},
    }

    local DOCK_POSITIONS = {"top-left", "top-center", "top-right", "bottom-left", "bottom-right"}
    local SNAP_VALUES = {5, 10, 15, 30, 45, 90}
    local GRID_SIZE = 8
    local DEFAULT_DOCK = "bottom-left"
    local DEFAULT_SNAP_IDX = 3
    local DEFAULT_CATEGORY = "Line"
    local DEFAULT_MODE = "QUAD"
    local DEFAULT_GRID = false
    local DEFAULT_FILL = false
    local DEFAULT_REPEAT = false
    local DEFAULT_MIRROR = false
    local DEFAULT_MIRROR_AXIS = "V"

    local MOBILE_AUTO = (plat and plat.platform and plat.platform() == "ANDROID") or false

    local K_ESCAPE    = ui.SDLK_ESCAPE

    local BIND_ACTIONS = {
        {name = "toggle",         label = "Toggle tool on/off",   default = ui.SDLK_c},
        {name = "settings",       label = "Open/close settings",  default = ui.SDLK_s},
        {name = "cycle_category", label = "Cycle category",       default = ui.SDLK_m},
        {name = "toggle_closed",  label = "Toggle closed shape",  default = ui.SDLK_l},
        {name = "toggle_grid",    label = "Toggle grid snap",     default = ui.SDLK_g},
        {name = "toggle_fill",    label = "Toggle fill",          default = ui.SDLK_v},
        {name = "toggle_repeat",  label = "Toggle repeat",        default = ui.SDLK_r},
        {name = "cycle_mode",     label = "Cycle mode",           default = ui.SDLK_TAB},
        {name = "select_mode_1",  label = "Select mode 1",        default = ui.SDLK_1},
        {name = "select_mode_2",  label = "Select mode 2",        default = ui.SDLK_2},
        {name = "select_mode_3",  label = "Select mode 3",        default = ui.SDLK_3},
        {name = "select_mode_4",  label = "Select mode 4",        default = ui.SDLK_4},
        {name = "select_mode_5",  label = "Select mode 5",        default = ui.SDLK_5},
        {name = "select_mode_6",  label = "Select mode 6",        default = ui.SDLK_6},
        {name = "select_mode_7",  label = "Select mode 7",        default = ui.SDLK_7},
        {name = "select_mode_8",  label = "Select mode 8",        default = ui.SDLK_8},
        {name = "remove_point",   label = "Remove last point",    default = ui.SDLK_BACKSPACE},
        {name = "commit",         label = "Commit shape",         default = ui.SDLK_RETURN},
        {name = "arc_angle_inc",  label = "Arc angle +5",         default = ui.SDLK_EQUALS},
        {name = "arc_angle_dec",  label = "Arc angle -5",         default = ui.SDLK_MINUS},
        {name = "fine_inc",       label = "Fine adjust +1",       default = ui.SDLK_PERIOD},
        {name = "fine_dec",       label = "Fine adjust -1",       default = ui.SDLK_COMMA},
        {name = "rotate_cw",      label = "Rotate preset +5",     default = ui.SDLK_RIGHTBRACKET},
        {name = "rotate_ccw",     label = "Rotate preset -5",     default = ui.SDLK_LEFTBRACKET},
        {name = "flip_cycle",     label = "Flip (cycle V/H/HV/off)",       default = ui.SDLK_f},
        {name = "flip_h",         label = "Flip horizontal (in-progress)", default = nil},
        {name = "flip_v",         label = "Flip vertical (in-progress)",   default = nil},
        {name = "flip_hv",        label = "Flip both axes (in-progress)",  default = nil},
        {name = "toggle_mirror",       label = "Toggle mirror",      default = ui.SDLK_SEMICOLON},
        {name = "cycle_mirror_axis",   label = "Cycle mirror axis",  default = ui.SDLK_QUOTE},
    }

    local BIND_LOOKUP = {}
    for _, a in ipairs(BIND_ACTIONS) do BIND_LOOKUP[a.name] = a end

    local ROWS_PER_PAGE = 8

    local SPECIAL_KEY_NAMES = {
        [ui.SDLK_TAB] = "Tab",
        [ui.SDLK_RETURN] = "Return",
        [ui.SDLK_ESCAPE] = "Esc",
        [ui.SDLK_BACKSPACE] = "Backspace",
        [ui.SDLK_SPACE] = "Space",
        [ui.SDLK_CAPSLOCK] = "CapsLock",
        [ui.SDLK_F1] = "F1", [ui.SDLK_F2] = "F2", [ui.SDLK_F3] = "F3", [ui.SDLK_F4] = "F4",
        [ui.SDLK_F5] = "F5", [ui.SDLK_F6] = "F6", [ui.SDLK_F7] = "F7", [ui.SDLK_F8] = "F8",
        [ui.SDLK_F9] = "F9", [ui.SDLK_F10] = "F10", [ui.SDLK_F11] = "F11", [ui.SDLK_F12] = "F12",
        [ui.SDLK_INSERT] = "Insert",
        [ui.SDLK_HOME] = "Home",
        [ui.SDLK_PAGEUP] = "PageUp",
        [ui.SDLK_DELETE] = "Delete",
        [ui.SDLK_END] = "End",
        [ui.SDLK_PAGEDOWN] = "PageDown",
        [ui.SDLK_RIGHT] = "Right",
        [ui.SDLK_LEFT] = "Left",
        [ui.SDLK_DOWN] = "Down",
        [ui.SDLK_UP] = "Up",
        [ui.SDLK_LEFTBRACKET] = "[",
        [ui.SDLK_RIGHTBRACKET] = "]",
        [ui.SDLK_BACKSLASH] = "\\",
        [ui.SDLK_SEMICOLON] = ";",
        [ui.SDLK_QUOTE] = "'",
        [ui.SDLK_COMMA] = ",",
        [ui.SDLK_PERIOD] = ".",
        [ui.SDLK_SLASH] = "/",
        [ui.SDLK_MINUS] = "-",
        [ui.SDLK_EQUALS] = "=",
        [ui.SDLK_BACKQUOTE] = "`",
        [ui.SDLK_LCTRL] = "Left Ctrl",
        [ui.SDLK_RCTRL] = "Right Ctrl",
        [ui.SDLK_LSHIFT] = "Left Shift",
        [ui.SDLK_RSHIFT] = "Right Shift",
        [ui.SDLK_LALT] = "Left Alt",
        [ui.SDLK_RALT] = "Right Alt",
        [ui.SDLK_KP_ENTER] = "Numpad Enter",
        [ui.SDLK_KP_PLUS] = "Numpad +",
        [ui.SDLK_KP_MINUS] = "Numpad -",
        [ui.SDLK_KP_DIVIDE] = "Numpad /",
        [ui.SDLK_KP_MULTIPLY] = "Numpad *",
        [ui.SDLK_KP_PERIOD] = "Numpad .",
        [ui.SDLK_KP_0] = "Numpad 0", [ui.SDLK_KP_1] = "Numpad 1", [ui.SDLK_KP_2] = "Numpad 2",
        [ui.SDLK_KP_3] = "Numpad 3", [ui.SDLK_KP_4] = "Numpad 4", [ui.SDLK_KP_5] = "Numpad 5",
        [ui.SDLK_KP_6] = "Numpad 6", [ui.SDLK_KP_7] = "Numpad 7", [ui.SDLK_KP_8] = "Numpad 8",
        [ui.SDLK_KP_9] = "Numpad 9",
    }

    local MODIFIER_KEYS = {
        [ui.SDLK_LCTRL] = true, [ui.SDLK_RCTRL] = true,
        [ui.SDLK_LSHIFT] = true, [ui.SDLK_RSHIFT] = true,
        [ui.SDLK_LALT] = true, [ui.SDLK_RALT] = true,
    }

    local function keyName(kc)
        if kc == nil then return "unbound" end
        local special = SPECIAL_KEY_NAMES[kc]
        if special then return special end
        if kc >= 32 and kc < 127 then
            local ch = string.char(kc)
            if ch ~= " " then return ch:upper() end
        end
        return "key#" .. tostring(kc)
    end

    local function defaultBinds()
        local b = {}
        for _, a in ipairs(BIND_ACTIONS) do b[a.name] = a.default end
        return b
    end

    local SETTINGS_PATH = "line_tool_plus_settings.txt"
    local MANAGER_ID = "line_tool_plus"
    local useManager = (MANAGER ~= nil and MANAGER.savesetting ~= nil and MANAGER.getsetting ~= nil)
    local SETTING_KEYS = {"dock", "snap", "category", "mode", "grid", "fill", "repeat", "binds", "mirror", "mirror_axis", "force_mobile"}

    local state = {
        active = false,
        phase = "EMPTY",
        category = DEFAULT_CATEGORY,
        mode = DEFAULT_MODE,
        A = nil, B = nil,
        H1 = nil, H2 = nil,
        arcAngle = DEFAULT_ARC_ANGLE,
        points = {},
        origin = nil,
        radiusPt = nil,
        drag = nil,
        shiftDown = false,
        showMenu = false,
        menuRects = {},
        menuTab = "general",
        bindPage = 1,
        rebindAction = nil,
        pendingConflict = nil,
        dock = DEFAULT_DOCK,
        snapIdx = DEFAULT_SNAP_IDX,
        grid = DEFAULT_GRID,
        fill = DEFAULT_FILL,
        repeatOn = DEFAULT_REPEAT,
        binds = defaultBinds(),
        mirror = DEFAULT_MIRROR,
        mirrorAxis = DEFAULT_MIRROR_AXIS,
        mobileRects = {},
        flipState = 0,
        forceMobile = "auto",
        isMobile = MOBILE_AUTO,
        currentTick = 0,
        textInputDeadlineTick = 0,
    }

    local function recomputeMobile()
        if state.forceMobile == "on" then
            state.isMobile = true
        elseif state.forceMobile == "off" then
            state.isMobile = false
        else
            state.isMobile = MOBILE_AUTO
        end
    end

    local handleMenuClick, drawMenu, drawGeneralTab, drawKeybindsTab, drawHud, dockOrigin, resetPoints

    local function snapVal() return SNAP_VALUES[state.snapIdx] end
    local function isPoly() return state.mode == "CUSTOM" end
    local function isClosedShape() return state.mode == "CUSTOM" and state.category == "Shape" end
    local function isPreset() return SHAPE_DEFS[state.mode] ~= nil end
    local function canFill() return isPreset() or isClosedShape() end

    local function gridSnap(x, y)
        if not state.grid then return x, y end
        local g = GRID_SIZE
        return floor((x + g / 2) / g) * g, floor((y + g / 2) / g) * g
    end

    local function indexOf(list, val)
        for i, v in ipairs(list) do if v == val then return i end end
        return nil
    end

    local function serializeBinds()
        local parts = {}
        for _, a in ipairs(BIND_ACTIONS) do
            local kc = state.binds[a.name]
            parts[#parts + 1] = a.name .. "=" .. (kc and tostring(kc) or "none")
        end
        return table.concat(parts, "|")
    end

    local function deserializeBinds(s)
        if not s or s == "" then return end
        local sawFlipCycle = false
        for entry in s:gmatch("([^|]+)") do
            local name, val = entry:match("^([%w_]+)=(.+)$")
            if name and val and BIND_LOOKUP[name] then
                if name == "flip_cycle" then sawFlipCycle = true end
                if val == "none" then
                    state.binds[name] = nil
                else
                    local n = tonumber(val)
                    if n then
                        n = floor(n)
                        if MODIFIER_KEYS[n] then
                            state.binds[name] = BIND_LOOKUP[name].default
                        else
                            state.binds[name] = n
                        end
                    end
                end
            end
        end

        if not sawFlipCycle then
            state.binds.toggle_fill = ui.SDLK_v
            state.binds.flip_cycle = ui.SDLK_f
        end
    end

    local function applySetting(k, v)
        if k == "dock" and indexOf(DOCK_POSITIONS, v) then
            state.dock = v
        elseif k == "snap" then
            local n = tonumber(v)
            local idx = n and indexOf(SNAP_VALUES, n)
            if idx then state.snapIdx = idx end
        elseif k == "category" and indexOf(CATEGORIES, v) then
            state.category = v
        elseif k == "mode" then
            local modes = CATEGORY_MODES[state.category]
            if modes and indexOf(modes, v) then state.mode = v end
        elseif k == "grid" then
            state.grid = (v == "true" or v == "1")
        elseif k == "fill" then
            state.fill = (v == "true" or v == "1")
        elseif k == "repeat" then
            state.repeatOn = (v == "true" or v == "1")
        elseif k == "binds" then
            deserializeBinds(v)
        elseif k == "mirror" then
            state.mirror = (v == "true" or v == "1")
        elseif k == "mirror_axis" then
            if v == "H" or v == "V" or v == "HV" or v == "DIAG" then state.mirrorAxis = v end
        elseif k == "force_mobile" then
            if v == "auto" or v == "on" or v == "off" then
                state.forceMobile = v
                recomputeMobile()
            end
        end
    end

    local function currentSettings()
        return {
            dock = state.dock,
            snap = tostring(snapVal()),
            category = state.category,
            mode = state.mode,
            grid = tostring(state.grid),
            fill = tostring(state.fill),
            ["repeat"] = tostring(state.repeatOn),
            binds = serializeBinds(),
            mirror = tostring(state.mirror),
            mirror_axis = state.mirrorAxis,
            force_mobile = state.forceMobile,
        }
    end

    local function loadSettings()
        if useManager then
            for _, k in ipairs(SETTING_KEYS) do
                local v = MANAGER.getsetting(MANAGER_ID, k)
                if v then applySetting(k, v) end
            end
            return
        end
        local f = io.open(SETTINGS_PATH, "r")
        if not f then return end
        for line in f:lines() do
            local k, v = line:match("^%s*(%w+)%s*=%s*(.+)%s*$")
            if k then applySetting(k, v) end
        end
        f:close()
    end

    local function saveSettings()
        local values = currentSettings()
        if useManager then
            for _, k in ipairs(SETTING_KEYS) do
                MANAGER.savesetting(MANAGER_ID, k, values[k])
            end
            return
        end
        local f = io.open(SETTINGS_PATH, "w")
        if not f then return end
        for _, k in ipairs(SETTING_KEYS) do
            f:write(k .. "=" .. values[k] .. "\n")
        end
        f:close()
    end

    local function resetToDefaults()
        state.dock = DEFAULT_DOCK
        state.snapIdx = DEFAULT_SNAP_IDX
        state.category = DEFAULT_CATEGORY
        state.mode = DEFAULT_MODE
        state.grid = DEFAULT_GRID
        state.fill = DEFAULT_FILL
        state.repeatOn = DEFAULT_REPEAT
        state.mirror = DEFAULT_MIRROR
        state.mirrorAxis = DEFAULT_MIRROR_AXIS
        state.forceMobile = "auto"
        recomputeMobile()
        saveSettings()
    end

    loadSettings()

    local function dist(p1, p2)
        local dx, dy = p2.x - p1.x, p2.y - p1.y
        return sqrt(dx * dx + dy * dy)
    end

    local function hitTest(mx, my, hx, hy)
        local dx, dy = mx - hx, my - hy
        return (dx * dx + dy * dy) <= GRAB_R2
    end

    local function snapDeg(degVal, step)
        return floor((degVal + step / 2) / step) * step
    end

    local function snapFromAnchor(anchor, x, y)
        local ddx, ddy = x - anchor.x, y - anchor.y
        local distA = sqrt(ddx * ddx + ddy * ddy)
        local angle = deg(atan2(ddy, ddx))
        local snapped = rad(snapDeg(angle, snapVal()))
        return anchor.x + distA * cos(snapped), anchor.y + distA * sin(snapped)
    end

    local function zoomInfo()
        if not (renderer ~= nil and renderer.zoomEnabled and renderer.zoomEnabled()) then
            return nil
        end
        local winX, winY, factor = renderer.zoomWindow()
        local scopeX, scopeY, size = renderer.zoomScope()
        if not (winX and winY and factor and scopeX and scopeY and size) then
            return nil
        end
        return {
            winX = winX, winY = winY, factor = factor,
            scopeX = scopeX, scopeY = scopeY, size = size,
            winW = size * factor, winH = size * factor,
        }
    end

    local function remapZoomCoords(x, y)
        local zi = zoomInfo()
        if not zi then return x, y end
        if x >= zi.winX and x < zi.winX + zi.winW and y >= zi.winY and y < zi.winY + zi.winH then
            local simX = (x - zi.winX) / zi.factor + zi.scopeX
            local simY = (y - zi.winY) / zi.factor + zi.scopeY
            return simX, simY
        end
        return x, y
    end

    local function zDrawPoint(p, col, size)
        local zi = zoomInfo()
        if not zi then return end
        if p.x < zi.scopeX or p.x >= zi.scopeX + zi.size or p.y < zi.scopeY or p.y >= zi.scopeY + zi.size then
            return
        end
        local sx = zi.winX + (p.x - zi.scopeX) * zi.factor
        local sy = zi.winY + (p.y - zi.scopeY) * zi.factor
        local sSize = max(1, floor(size * zi.factor))
        local half = floor(sSize / 2)
        graphics.fillRect(floor(sx - half), floor(sy - half), sSize, sSize, col[1], col[2], col[3], col[4])
    end

    local function zDrawLine(x1, y1, x2, y2, r, g, b, a)
        local zi = zoomInfo()
        if not zi then return end
        if x1 < zi.scopeX or x1 >= zi.scopeX + zi.size or y1 < zi.scopeY or y1 >= zi.scopeY + zi.size then
            return
        end
        if x2 < zi.scopeX or x2 >= zi.scopeX + zi.size or y2 < zi.scopeY or y2 >= zi.scopeY + zi.size then
            return
        end
        local sx1 = zi.winX + (x1 - zi.scopeX) * zi.factor
        local sy1 = zi.winY + (y1 - zi.scopeY) * zi.factor
        local sx2 = zi.winX + (x2 - zi.scopeX) * zi.factor
        local sy2 = zi.winY + (y2 - zi.scopeY) * zi.factor
        graphics.drawLine(floor(sx1), floor(sy1), floor(sx2), floor(sy2), r, g, b, a)
    end

    local MIRROR_AXES = {"H", "V", "HV", "DIAG"}

    local function mirrorCenter()
        return sim.XRES / 2, sim.YRES / 2
    end

    local function mirrorTwins(p)
        if not state.mirror then return nil end
        local cx, cy = mirrorCenter()
        if state.mirrorAxis == "V" then
            return {{x = 2 * cx - p.x, y = p.y}}
        elseif state.mirrorAxis == "H" then
            return {{x = p.x, y = 2 * cy - p.y}}
        elseif state.mirrorAxis == "HV" then
            return {
                {x = 2 * cx - p.x, y = p.y},
                {x = p.x, y = 2 * cy - p.y},
                {x = 2 * cx - p.x, y = 2 * cy - p.y},
            }
        elseif state.mirrorAxis == "DIAG" then
            return {{x = cx + (p.y - cy), y = cy + (p.x - cx)}}
        end
        return nil
    end

    local function drawDashedLine(x1, y1, x2, y2, r, g, b, a, zoom)
        local dxL, dyL = x2 - x1, y2 - y1
        local len = sqrt(dxL * dxL + dyL * dyL)
        if len < 1 then return end
        local ux, uy = dxL / len, dyL / len
        local seg = 4
        local i = 0
        while i < len do
            local sx = x1 + ux * i
            local sy = y1 + uy * i
            local ex = x1 + ux * min(i + seg, len)
            local ey = y1 + uy * min(i + seg, len)
            if zoom then
                zDrawLine(sx, sy, ex, ey, r, g, b, a)
            else
                graphics.drawLine(floor(sx), floor(sy), floor(ex), floor(ey), r, g, b, a)
            end
            i = i + seg * 2
        end
    end

    local function drawMirrorAxis(zoom)
        if not state.mirror then return end
        local cx, cy = mirrorCenter()
        local W, H = sim.XRES - 1, sim.YRES - 1
        local col = {180, 120, 220, 110}
        if state.mirrorAxis == "H" then
            drawDashedLine(0, cy, W, cy, col[1], col[2], col[3], col[4], zoom)
        elseif state.mirrorAxis == "V" then
            drawDashedLine(cx, 0, cx, H, col[1], col[2], col[3], col[4], zoom)
        elseif state.mirrorAxis == "HV" then
            drawDashedLine(cx, 0, cx, H, col[1], col[2], col[3], col[4], zoom)
            drawDashedLine(0, cy, W, cy, col[1], col[2], col[3], col[4], zoom)
        elseif state.mirrorAxis == "DIAG" then
            local d = min(cx, cy)
            drawDashedLine(cx - d, cy - d, cx + d, cy + d, col[1], col[2], col[3], col[4], zoom)
        end
    end

    local function quadPoint(t, a, h, b)
        local mt = 1 - t
        local x = mt * mt * a.x + 2 * mt * t * h.x + t * t * b.x
        local y = mt * mt * a.y + 2 * mt * t * h.y + t * t * b.y
        return x, y
    end

    local function cubicPoint(t, a, h1, h2, b)
        local mt = 1 - t
        local mt2, t2 = mt * mt, t * t
        local x = mt2 * mt * a.x + 3 * mt2 * t * h1.x + 3 * mt * t2 * h2.x + t2 * t * b.x
        local y = mt2 * mt * a.y + 3 * mt2 * t * h1.y + 3 * mt * t2 * h2.y + t2 * t * b.y
        return x, y
    end

    local function arcParams(a, b, angleDeg)
        local dx, dy = b.x - a.x, b.y - a.y
        local chord = sqrt(dx * dx + dy * dy)
        if chord < 0.0001 or abs(angleDeg) < 0.0001 then
            return nil
        end
        local thetaRad = rad(angleDeg)
        local half = thetaRad / 2
        local r = (chord / 2) / sin(half)
        local h = r * cos(half)
        local perpx, perpy = -dy / chord, dx / chord
        local mx, my = (a.x + b.x) / 2, (a.y + b.y) / 2
        local cx, cy = mx + h * perpx, my + h * perpy
        local a0 = atan2(a.y - cy, a.x - cx)
        return cx, cy, abs(r), a0, thetaRad
    end

    local function arcPoint(t, a, b, angleDeg)
        local cx, cy, r, a0, thetaRad = arcParams(a, b, angleDeg)
        if not cx then
            return a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t
        end
        local ang = a0 + t * thetaRad
        return cx + r * cos(ang), cy + r * sin(ang)
    end

    local function curvePoint(t)
        if state.mode == "QUAD" then
            return quadPoint(t, state.A, state.H1, state.B)
        elseif state.mode == "CUBIC" then
            return cubicPoint(t, state.A, state.H1, state.H2, state.B)
        else
            return arcPoint(t, state.A, state.B, state.arcAngle)
        end
    end

    local function chordEstimate()
        if state.mode == "QUAD" then
            return dist(state.A, state.H1) + dist(state.H1, state.B)
        elseif state.mode == "CUBIC" then
            return dist(state.A, state.H1) + dist(state.H1, state.H2) + dist(state.H2, state.B)
        else
            local cx, cy, r, a0, thetaRad = arcParams(state.A, state.B, state.arcAngle)
            if not cx then return dist(state.A, state.B) end
            return r * abs(thetaRad)
        end
    end

    local function sampleCount(pxPerStep)
        local len = chordEstimate()
        return max(2, ceil(len / pxPerStep))
    end

    local function initHandles()
        if not (state.A and state.B) then return end
        local a, b = state.A, state.B
        if state.mode == "QUAD" then
            state.H1 = {x = (a.x + b.x) / 2, y = (a.y + b.y) / 2}
            state.H2 = nil
        elseif state.mode == "CUBIC" then
            state.H1 = {x = a.x + (b.x - a.x) / 3, y = a.y + (b.y - a.y) / 3}
            state.H2 = {x = a.x + (b.x - a.x) * 2 / 3, y = a.y + (b.y - a.y) * 2 / 3}
        else
            state.H1, state.H2 = nil, nil
            state.arcAngle = DEFAULT_ARC_ANGLE
        end
    end

    function resetPoints()
        state.A, state.B, state.H1, state.H2 = nil, nil, nil, nil
        state.points = {}
        state.origin, state.radiusPt = nil, nil
        state.drag = nil
        state.phase = "EMPTY"
        state.flipState = 0
    end

    local function activePoints()
        if isPoly() then return state.points end
        if isPreset() then
            local out = {}
            if state.origin then out[#out + 1] = state.origin end
            if state.radiusPt then out[#out + 1] = state.radiusPt end
            return out
        end
        local out = {}
        if state.A then out[#out + 1] = state.A end
        if state.B then out[#out + 1] = state.B end
        if state.H1 then out[#out + 1] = state.H1 end
        if state.H2 then out[#out + 1] = state.H2 end
        return out
    end

    local function flipInPlace(axis)
        local pts = activePoints()
        if #pts == 0 then return end
        local cx, cy = 0, 0
        for _, p in ipairs(pts) do cx = cx + p.x; cy = cy + p.y end
        cx = cx / #pts; cy = cy / #pts
        for _, p in ipairs(pts) do
            if axis == "V" or axis == "HV" then p.x = 2 * cx - p.x end
            if axis == "H" or axis == "HV" then p.y = 2 * cy - p.y end
        end
    end

    local FLIP_DELTA = { [0] = "V", [1] = "HV", [2] = "V", [3] = "HV" }
    local function cycleFlip()
        local pts = activePoints()
        if #pts == 0 then return end
        local s = state.flipState or 0
        flipInPlace(FLIP_DELTA[s])
        state.flipState = (s + 1) % 4
    end

    local function shapeVertices()
        if not (state.origin and state.radiusPt) then return nil end
        local def = SHAPE_DEFS[state.mode]
        if not def then return nil end
        local ox, oy = state.origin.x, state.origin.y
        local dx, dy = state.radiusPt.x - ox, state.radiusPt.y - oy
        local R = sqrt(dx * dx + dy * dy)
        if R < 0.5 then return nil end
        local baseAng = atan2(dy, dx)
        local offset = rad(def.angleOffset or 0)
        local verts = {}
        if def.inner then
            local step = math.pi / def.n
            local innerR = R * def.inner
            for i = 0, def.n * 2 - 1 do
                local ang = baseAng + offset + i * step
                local r = (i % 2 == 0) and R or innerR
                verts[#verts + 1] = {x = ox + r * cos(ang), y = oy + r * sin(ang)}
            end
        else
            local step = (2 * math.pi) / def.n
            for i = 0, def.n - 1 do
                local ang = baseAng + offset + i * step
                verts[#verts + 1] = {x = ox + R * cos(ang), y = oy + R * sin(ang)}
            end
        end
        return verts
    end

    local function toElementId(v)
        if type(v) == "number" then return v end
        if type(v) ~= "string" then return 0 end
        if elements and elements[v] then return elements[v] end
        if tpt.element then
            local short = v:match("_PT_(.+)$") or v
            local ok, id = pcall(tpt.element, short)
            if ok and id then return id end
        end
        return 0
    end

    local function selectedElemName()
        local sel = tpt.selectedl
        if type(sel) == "string" and #sel > 0 then
            local short = sel:match("_PT_(.+)$") or sel
            return short
        end
        if type(sel) == "number" and elements and elements.property then
            local ok, name = pcall(elements.property, sel, "Name")
            if ok and type(name) == "string" and #name > 0 then return name end
        end
        return "?"
    end

    local function rotatePreset(degDelta)
        if not (state.origin and state.radiusPt) then return end
        local ox, oy = state.origin.x, state.origin.y
        local dx, dy = state.radiusPt.x - ox, state.radiusPt.y - oy
        local ang = atan2(dy, dx) + rad(degDelta)
        local r = sqrt(dx * dx + dy * dy)
        state.radiusPt.x = ox + r * cos(ang)
        state.radiusPt.y = oy + r * sin(ang)
    end

    local function stampLine(x1, y1, x2, y2, sel, rx, ry, shape)
        local ix1, iy1 = floor(x1 + 0.5), floor(y1 + 0.5)
        local ix2, iy2 = floor(x2 + 0.5), floor(y2 + 0.5)
        if ix1 == ix2 and iy1 == iy2 then return end
        sim.createLine(ix1, iy1, ix2, iy2, rx, ry, sel, shape)
    end

    local function fillPolygon(verts, sel)
        if not verts or #verts < 3 then return end
        local minY, maxY = verts[1].y, verts[1].y
        for _, v in ipairs(verts) do
            if v.y < minY then minY = v.y end
            if v.y > maxY then maxY = v.y end
        end
        minY = max(0, floor(minY))
        maxY = min(sim.YRES - 1, ceil(maxY))
        for y = minY, maxY do
            local xs = {}
            for i = 1, #verts do
                local a = verts[i]
                local b = verts[(i % #verts) + 1]
                local ay, by = a.y, b.y
                if (ay <= y and by > y) or (by <= y and ay > y) then
                    local t = (y - ay) / (by - ay)
                    xs[#xs + 1] = a.x + t * (b.x - a.x)
                end
            end
            table.sort(xs)
            for i = 1, #xs - 1, 2 do
                local x1 = max(0, floor(xs[i] + 0.5))
                local x2 = min(sim.XRES - 1, floor(xs[i + 1] + 0.5))
                if x2 >= x1 then
                    sim.createLine(x1, y, x2, y, 0, 0, sel, 0)
                end
            end
        end
    end

    local function stampLineMirrored(x1, y1, x2, y2, sel, rx, ry, shape)
        stampLine(x1, y1, x2, y2, sel, rx, ry, shape)
        local twinsA = mirrorTwins({x = x1, y = y1})
        local twinsB = mirrorTwins({x = x2, y = y2})
        if not (twinsA and twinsB) then return end
        for i, ta in ipairs(twinsA) do
            local tb = twinsB[i]
            stampLine(ta.x, ta.y, tb.x, tb.y, sel, rx, ry, shape)
        end
    end

    local function fillPolygonMirrored(verts, sel)
        fillPolygon(verts, sel)
        if not state.mirror then return end
        local axes = {}
        if state.mirrorAxis == "HV" then axes = {"H", "V", "HV"}
        elseif state.mirrorAxis == "H" then axes = {"H"}
        elseif state.mirrorAxis == "V" then axes = {"V"}
        elseif state.mirrorAxis == "DIAG" then axes = {"DIAG"} end
        local cx, cy = mirrorCenter()
        for _, ax in ipairs(axes) do
            local twin = {}
            for _, v in ipairs(verts) do
                if ax == "H" then twin[#twin + 1] = {x = 2 * cx - v.x, y = v.y}
                elseif ax == "V" then twin[#twin + 1] = {x = v.x, y = 2 * cy - v.y}
                elseif ax == "HV" then twin[#twin + 1] = {x = 2 * cx - v.x, y = 2 * cy - v.y}
                elseif ax == "DIAG" then twin[#twin + 1] = {x = cx + (v.y - cy), y = cy + (v.x - cx)} end
            end
            fillPolygon(twin, sel)
        end
    end

    local function commit()
        local ok, err = pcall(function()
            local sel = toElementId(tpt.selectedl)
            local rx, ry = tpt.brushx, tpt.brushy
            local shape = tpt.brushID or 0
            if isPreset() then
                local verts = shapeVertices()
                if not verts or #verts < 2 then return end
                if state.fill then fillPolygonMirrored(verts, sel) end
                for i = 1, #verts do
                    local a = verts[i]
                    local b = verts[(i % #verts) + 1]
                    stampLineMirrored(a.x, a.y, b.x, b.y, sel, rx, ry, shape)
                end
            elseif isPoly() then
                local pts = state.points
                if #pts < 2 then return end
                if state.fill and isClosedShape() and #pts >= 3 then
                    fillPolygonMirrored(pts, sel)
                end
                for i = 1, #pts - 1 do
                    local a, b = pts[i], pts[i + 1]
                    stampLineMirrored(a.x, a.y, b.x, b.y, sel, rx, ry, shape)
                end
                if isClosedShape() and #pts >= 3 then
                    local a, b = pts[#pts], pts[1]
                    stampLineMirrored(a.x, a.y, b.x, b.y, sel, rx, ry, shape)
                end
            else
                local steps = sampleCount(0.5)
                local px, py = curvePoint(0)
                for i = 1, steps do
                    local t = i / steps
                    local cx, cy = curvePoint(t)
                    stampLineMirrored(px, py, cx, cy, sel, rx, ry, shape)
                    px, py = cx, cy
                end
            end
        end)
        if not ok then
            print("Line Tool+: commit error - " .. tostring(err))
        end
        if state.repeatOn then
            state.drag = nil
        else
            resetPoints()
        end
    end

    local function setMode(name)
        local modes = CATEGORY_MODES[state.category]
        if not (modes and indexOf(modes, name)) then return end
        state.mode = name
        resetPoints()
        saveSettings()
    end

    local function cycleMode(delta)
        local modes = CATEGORY_MODES[state.category]
        local idx = indexOf(modes, state.mode) or 1
        idx = ((idx - 1 + delta) % #modes) + 1
        state.mode = modes[idx]
        resetPoints()
        saveSettings()
    end

    local function setCategory(name)
        if not indexOf(CATEGORIES, name) then return end
        state.category = name
        local modes = CATEGORY_MODES[name]
        if not indexOf(modes, state.mode) then state.mode = modes[1] end
        resetPoints()
        saveSettings()
    end

    local function cycleCategory()
        local idx = indexOf(CATEGORIES, state.category) or 1
        idx = (idx % #CATEGORIES) + 1
        setCategory(CATEGORIES[idx])
    end

    local function toggleClosed()
        if state.mode == "CUSTOM" then
            state.category = (state.category == "Line") and "Shape" or "Line"
        else
            state.category = "Line"; state.mode = "CUSTOM"
        end
        resetPoints()
        saveSettings()
    end

    local TEXT_INPUT_DECAY_TICKS = 60
    event.register(evt.textinput, function()
        state.textInputDeadlineTick = state.currentTick + TEXT_INPUT_DECAY_TICKS
    end)
    event.register(evt.textediting, function()
        state.textInputDeadlineTick = state.currentTick + TEXT_INPUT_DECAY_TICKS
    end)

    event.register(evt.keypress, function(key, scan, rep, shift, ctrl, alt)

        if state.currentTick < state.textInputDeadlineTick then
            return
        end

        state.shiftDown = shift

        if state.rebindAction then
            if rep then return false end
            if key == K_ESCAPE then
                state.rebindAction = nil
                return false
            end
            local actingOn = state.rebindAction
            if MODIFIER_KEYS[key] then
                state.pendingConflict = {action = actingOn, key = key, reason = "modifier"}
                state.rebindAction = nil
                return false
            end
            local conflictWith = nil
            for _, a in ipairs(BIND_ACTIONS) do
                if a.name ~= actingOn and state.binds[a.name] == key then
                    conflictWith = a.name
                    break
                end
            end
            if conflictWith then
                state.pendingConflict = {action = actingOn, key = key, other = conflictWith}
                state.rebindAction = nil
            else
                state.binds[actingOn] = key
                state.rebindAction = nil
                saveSettings()
            end
            return false
        end

        if state.pendingConflict then
            if not rep and key == K_ESCAPE then
                state.pendingConflict = nil
            end
            return false
        end

        if key == state.binds.toggle and not ctrl and not alt then
            if rep then return false end
            if state.active then
                state.active = false
                state.showMenu = false
                resetPoints()
            else
                state.active = true
                resetPoints()
            end
            return false
        end
        if not state.active then return end
        if ctrl or alt then return end

        if state.showMenu then
            if key == K_ESCAPE or key == state.binds.settings then
                if rep then return false end
                state.showMenu = false
                return false
            end
            return false
        end

        if key == state.binds.settings then
            if rep then return false end
            state.showMenu = true
            return false
        end

        if key == state.binds.cycle_category then
            if rep then return false end
            cycleCategory()
            return false
        end

        if key == state.binds.toggle_closed then
            if rep then return false end
            toggleClosed()
            return false
        end

        if key == state.binds.toggle_grid then
            if rep then return false end
            state.grid = not state.grid
            saveSettings()
            return false
        end

        if key == state.binds.toggle_fill then
            if rep then return false end
            state.fill = not state.fill
            saveSettings()
            return false
        end

        if key == state.binds.toggle_repeat then
            if rep then return false end
            state.repeatOn = not state.repeatOn
            saveSettings()
            return false
        end

        if key == state.binds.cycle_mode then
            if rep then return false end
            cycleMode(1)
            return false
        end

        local modes = CATEGORY_MODES[state.category]
        if key == state.binds.select_mode_1 and modes[1] then setMode(modes[1]); return false end
        if key == state.binds.select_mode_2 and modes[2] then setMode(modes[2]); return false end
        if key == state.binds.select_mode_3 and modes[3] then setMode(modes[3]); return false end
        if key == state.binds.select_mode_4 and modes[4] then setMode(modes[4]); return false end
        if key == state.binds.select_mode_5 and modes[5] then setMode(modes[5]); return false end
        if key == state.binds.select_mode_6 and modes[6] then setMode(modes[6]); return false end
        if key == state.binds.select_mode_7 and modes[7] then setMode(modes[7]); return false end
        if key == state.binds.select_mode_8 and modes[8] then setMode(modes[8]); return false end

        if key == K_ESCAPE then
            if rep then return false end
            if isPoly() then
                if #state.points > 0 then resetPoints() end
            elseif isPreset() then
                if state.phase == "NEED_RADIUS" then
                    state.origin = nil
                    state.phase = "EMPTY"
                elseif state.phase == "ADJUST" then
                    resetPoints()
                end
            else
                if state.phase == "NEED_B" then
                    state.A = nil
                    state.phase = "EMPTY"
                elseif state.phase == "ADJUST" then
                    resetPoints()
                end
            end
            return false
        end

        if key == state.binds.remove_point and isPoly() then
            if rep then return false end
            if #state.points > 0 then
                state.points[#state.points] = nil
                if #state.points == 0 then state.phase = "EMPTY" end
            end
            return false
        end

        if key == state.binds.commit then
            if rep then return false end

            local didCommit = false
            if isPoly() then
                if #state.points >= 2 then commit(); didCommit = true end
            elseif isPreset() then
                if state.phase == "ADJUST" then commit(); didCommit = true end
            elseif state.phase == "ADJUST" then
                commit()
                didCommit = true
            end
            if didCommit then return false end
            return
        elseif state.phase == "ADJUST" and state.mode == "ARC" then
            if key == state.binds.arc_angle_inc then
                state.arcAngle = state.arcAngle + 5
                return false
            elseif key == state.binds.arc_angle_dec then
                state.arcAngle = state.arcAngle - 5
                return false
            elseif key == state.binds.fine_inc then
                state.arcAngle = state.arcAngle + 1
                return false
            elseif key == state.binds.fine_dec then
                state.arcAngle = state.arcAngle - 1
                return false
            end
        elseif state.phase == "ADJUST" and isPreset() then
            if key == state.binds.rotate_cw then
                rotatePreset(5); return false
            elseif key == state.binds.rotate_ccw then
                rotatePreset(-5); return false
            elseif key == state.binds.fine_inc then
                rotatePreset(1); return false
            elseif key == state.binds.fine_dec then
                rotatePreset(-1); return false
            end
        end

        if key == state.binds.flip_cycle then if not rep then cycleFlip() end; return false end
        if key == state.binds.flip_h then if not rep then flipInPlace("H"); state.flipState = 0 end; return false end
        if key == state.binds.flip_v then if not rep then flipInPlace("V"); state.flipState = 0 end; return false end
        if key == state.binds.flip_hv then if not rep then flipInPlace("HV"); state.flipState = 0 end; return false end

        if key == state.binds.toggle_mirror then
            if not rep then state.mirror = not state.mirror; saveSettings() end
            return false
        end
        if key == state.binds.cycle_mirror_axis then
            if not rep then
                local i = indexOf(MIRROR_AXES, state.mirrorAxis) or 1
                state.mirrorAxis = MIRROR_AXES[(i % #MIRROR_AXES) + 1]
                saveSettings()
            end
            return false
        end
    end)

    event.register(evt.keyrelease, function(key, scan, rep, shift, ctrl, alt)
        state.shiftDown = shift
    end)

    local function hitPolyPoint(x, y)
        for i, p in ipairs(state.points) do
            if hitTest(x, y, p.x, p.y) then return i end
        end
        return nil
    end

    event.register(evt.mousedown, function(x, y, button)

        if not state.active then
            if state.isMobile and not state.showMenu and button == 1 then
                for _, r in ipairs(state.mobileRects) do
                    if x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 then
                        if r.action == "activate" then
                            state.active = true
                            resetPoints()
                        end
                        return false
                    end
                end
            end
            return
        end

        if state.isMobile and not state.showMenu and button == 1 then
            for _, r in ipairs(state.mobileRects) do
                if x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 then
                    if r.action == "menu" then state.showMenu = true
                    elseif r.action == "cycle" then cycleMode(1)
                    elseif r.action == "flip" then cycleFlip()
                    elseif r.action == "commit" then
                        if isPoly() and #state.points >= 2 then commit()
                        elseif isPreset() and state.phase == "ADJUST" then commit()
                        elseif state.phase == "ADJUST" then commit()
                        end
                    elseif r.action == "undo" then
                        if isPoly() and #state.points > 0 then
                            state.points[#state.points] = nil
                            if #state.points == 0 then state.phase = "EMPTY" end
                        end
                    elseif r.action == "cancel" then
                        resetPoints()
                    elseif r.action == "off" then
                        state.active = false
                        state.showMenu = false
                        resetPoints()
                    end
                    return false
                end
            end
        end

        x, y = remapZoomCoords(x, y)

        if state.showMenu then
            if button == 1 then handleMenuClick(x, y) end
            return false
        end
        if x < 0 or x >= sim.XRES or y < 0 or y >= sim.YRES then
            return
        end

        if isPreset() then
            if button == 1 then
                if state.phase == "EMPTY" then
                    local ox, oy = gridSnap(x, y)
                    state.origin = {x = ox, y = oy}
                    state.phase = "NEED_RADIUS"
                    return false
                elseif state.phase == "NEED_RADIUS" then
                    local rx2, ry2 = x, y
                    if state.shiftDown and state.origin then
                        rx2, ry2 = snapFromAnchor(state.origin, x, y)
                    end
                    rx2, ry2 = gridSnap(rx2, ry2)
                    state.radiusPt = {x = rx2, y = ry2}
                    state.phase = "ADJUST"
                    return false
                elseif state.phase == "ADJUST" then
                    if state.radiusPt and hitTest(x, y, state.radiusPt.x, state.radiusPt.y) then
                        state.drag = "R"
                    elseif state.origin and hitTest(x, y, state.origin.x, state.origin.y) then
                        state.drag = "O"
                    elseif state.shiftDown then
                        state.drag = "TRANSLATE"
                        state.translateStart = {x = x, y = y}
                    end
                    return false
                end
            elseif button == 3 then
                if state.phase == "ADJUST" then commit() end
                return false
            end
            return false
        end

        if isPoly() then
            if button == 1 then
                local hit = hitPolyPoint(x, y)
                if hit then
                    state.drag = "P" .. hit
                elseif state.shiftDown and #state.points > 0 then

                    state.drag = "MAYBE_TRANSLATE_OR_ADD"
                    state.dragStart = {x = x, y = y}
                else
                    local px, py = gridSnap(x, y)
                    state.points[#state.points + 1] = {x = px, y = py}
                    state.phase = "COLLECT"
                end
                return false
            elseif button == 3 then
                if #state.points >= 2 then commit() end
                return false
            end
            return false
        end

        if button == 1 then
            if state.phase == "EMPTY" then
                local ax, ay = gridSnap(x, y)
                state.A = {x = ax, y = ay}
                state.phase = "NEED_B"
                state.drag = "PLACING_B"
                state.dragStart = {x = x, y = y}
                state.B = {x = ax, y = ay}
                return false
            elseif state.phase == "NEED_B" then
                local bx, by = gridSnap(x, y)
                state.B = {x = bx, y = by}
                state.phase = "ADJUST"
                initHandles()
                return false
            elseif state.phase == "ADJUST" then
                if state.H1 and hitTest(x, y, state.H1.x, state.H1.y) then
                    state.drag = "H1"
                elseif state.H2 and hitTest(x, y, state.H2.x, state.H2.y) then
                    state.drag = "H2"
                elseif state.A and hitTest(x, y, state.A.x, state.A.y) then
                    state.drag = "A"
                elseif state.B and hitTest(x, y, state.B.x, state.B.y) then
                    state.drag = "B"
                elseif state.shiftDown then
                    state.drag = "TRANSLATE"
                    state.translateStart = {x = x, y = y}
                end
                return false
            end
        elseif button == 3 then
            if state.phase == "ADJUST" then
                commit()
                return false
            end
        end
    end)

    event.register(evt.mouseup, function(x, y, button, reason)
        if state.active then
            x, y = remapZoomCoords(x, y)
            local drag = state.drag
            if drag == "PLACING_B" and button == 1 then
                if state.B and state.dragStart and dist(state.dragStart, {x = x, y = y}) >= 3 then
                    state.phase = "ADJUST"
                    initHandles()
                else
                    state.B = nil
                    state.phase = "NEED_B"
                end
            elseif drag == "MAYBE_TRANSLATE_OR_ADD" then
                local px, py = x, y
                if state.shiftDown and #state.points > 0 then
                    local last = state.points[#state.points]
                    px, py = snapFromAnchor(last, x, y)
                end
                px, py = gridSnap(px, py)
                state.points[#state.points + 1] = {x = px, y = py}
                state.phase = "COLLECT"
            end
        end
        state.drag = nil
    end)

    local cursor = {x = -1, y = -1, inSim = false}

    event.register(evt.mousemove, function(x, y, dx, dy)
        local prevX, prevY = cursor.x, cursor.y
        x, y = remapZoomCoords(x, y)
        cursor.x, cursor.y = x, y
        cursor.inSim = (x >= 0 and x < sim.XRES and y >= 0 and y < sim.YRES)
        if not (state.active and state.drag) then return end
        local drag = state.drag
        local rdx = (prevX and (x - prevX)) or dx
        local rdy = (prevY and (y - prevY)) or dy
        local hx, hy = x, y
        if state.shiftDown and (drag == "H1" or drag == "H2") and state.A then
            hx, hy = snapFromAnchor(state.A, x, y)
        end
        hx, hy = gridSnap(hx, hy)
        local sx, sy = gridSnap(x, y)
        if drag == "H1" then
            state.H1.x, state.H1.y = hx, hy
        elseif drag == "H2" then
            state.H2.x, state.H2.y = hx, hy
        elseif drag == "A" then
            state.A.x, state.A.y = sx, sy
        elseif drag == "B" then
            state.B.x, state.B.y = sx, sy
        elseif drag == "O" then
            if state.origin then
                local ddx = sx - state.origin.x
                local ddy = sy - state.origin.y
                state.origin.x, state.origin.y = sx, sy
                if state.radiusPt then
                    state.radiusPt.x = state.radiusPt.x + ddx
                    state.radiusPt.y = state.radiusPt.y + ddy
                end
            end
        elseif drag == "R" then
            local rx2, ry2 = x, y
            if state.shiftDown and state.origin then
                rx2, ry2 = snapFromAnchor(state.origin, x, y)
            end
            rx2, ry2 = gridSnap(rx2, ry2)
            if state.radiusPt then
                state.radiusPt.x, state.radiusPt.y = rx2, ry2
            end
        elseif drag == "PLACING_B" then
            local bx, by = x, y
            if state.shiftDown and state.A then
                bx, by = snapFromAnchor(state.A, x, y)
            end
            bx, by = gridSnap(bx, by)
            if state.B then
                state.B.x, state.B.y = bx, by
            end
        elseif drag == "TRANSLATE" then
            for _, p in ipairs(activePoints()) do
                p.x = p.x + rdx
                p.y = p.y + rdy
            end
        elseif drag == "MAYBE_TRANSLATE_OR_ADD" then
            if state.dragStart and dist(state.dragStart, {x = x, y = y}) >= 3 then
                state.drag = "TRANSLATE"
                state.translateStart = {x = x, y = y}
                for _, p in ipairs(activePoints()) do
                    p.x = p.x + rdx
                    p.y = p.y + rdy
                end
            end
        else
            local i = tonumber(drag:match("^P(%d+)$"))
            if i and state.points[i] then
                state.points[i].x, state.points[i].y = sx, sy
            end
        end
    end)

    local function drawPoint(p, col, size)
        local half = floor(size / 2)
        graphics.fillRect(floor(p.x - half), floor(p.y - half), size, size, col[1], col[2], col[3], col[4])
    end

    local function previewPoint(p, col, size, zoom)
        if zoom then
            zDrawPoint(p, col, size)
        else
            drawPoint(p, col, size)
        end
    end

    local function previewLine(x1, y1, x2, y2, col, zoom)
        if zoom then
            zDrawLine(x1, y1, x2, y2, col[1], col[2], col[3], col[4])
        else
            graphics.drawLine(floor(x1), floor(y1), floor(x2), floor(y2), col[1], col[2], col[3], col[4])
        end
        if not state.mirror then return end
        local twinsA = mirrorTwins({x = x1, y = y1})
        local twinsB = mirrorTwins({x = x2, y = y2})
        if not (twinsA and twinsB) then return end
        local dimCol = {floor(col[1] * 0.55), floor(col[2] * 0.55), floor(col[3] * 0.55), col[4]}
        for i, ta in ipairs(twinsA) do
            local tb = twinsB[i]
            if zoom then
                zDrawLine(ta.x, ta.y, tb.x, tb.y, dimCol[1], dimCol[2], dimCol[3], dimCol[4])
            else
                graphics.drawLine(floor(ta.x), floor(ta.y), floor(tb.x), floor(tb.y), dimCol[1], dimCol[2], dimCol[3], dimCol[4])
            end
        end
    end

    local function drawHandleLabel(p, a, name)
        local ddx, ddy = p.x - a.x, p.y - a.y
        local distA = sqrt(ddx * ddx + ddy * ddy)
        local angle = deg(atan2(ddy, ddx))
        if angle < 0 then angle = angle + 360 end
        local txt = string.format("%s %d%s %dpx", name, floor(angle + 0.5), string.char(176), floor(distA + 0.5))
        local _, th = graphics.textSize(txt)
        graphics.drawText(floor(p.x + 6), floor(p.y - th - 2), txt, COL_DIMYELLOW[1], COL_DIMYELLOW[2], COL_DIMYELLOW[3], COL_DIMYELLOW[4])
    end

    local function drawCurvePreview(zoom)
        local steps = sampleCount(2)
        local px, py = curvePoint(0)
        for i = 1, steps do
            local t = i / steps
            local cx, cy = curvePoint(t)
            previewLine(px, py, cx, cy, COL_WHITE_PREVIEW, zoom)
            px, py = cx, cy
        end
    end

    local function drawLineAdjustOverlay(zoom)
        local endpointSize = (state.phase == "ADJUST") and 5 or 3
        if state.A then previewPoint(state.A, COL_CYAN, endpointSize, zoom) end
        if state.B then previewPoint(state.B, COL_ORANGE, endpointSize, zoom) end

        if state.phase == "ADJUST" then
            drawCurvePreview(zoom)
            if state.mode == "ARC" then
                if not zoom then
                    local mx, my = (state.A.x + state.B.x) / 2, (state.A.y + state.B.y) / 2
                    local txt = "angle: " .. floor(state.arcAngle + 0.5) .. string.char(176)
                    graphics.drawText(floor(mx + 6), floor(my - 10), txt, COL_DIMYELLOW[1], COL_DIMYELLOW[2], COL_DIMYELLOW[3], COL_DIMYELLOW[4])
                end
            else
                if state.H1 then
                    previewPoint(state.H1, COL_YELLOW, 5, zoom)
                    if not zoom then drawHandleLabel(state.H1, state.A, "H1") end
                end
                if state.H2 then
                    previewPoint(state.H2, COL_YELLOW, 5, zoom)
                    if not zoom then drawHandleLabel(state.H2, state.A, "H2") end
                end
            end
        end
    end

    local function drawPresetPreview(mouseX, mouseY, hoverActive, zoom)
        if not state.origin then return end
        local ox, oy = state.origin.x, state.origin.y
        local rp = state.radiusPt
        if not rp and hoverActive then
            local hx, hy = mouseX, mouseY
            if state.shiftDown then hx, hy = snapFromAnchor(state.origin, mouseX, mouseY) end
            rp = {x = hx, y = hy}
        end
        if rp then
            local savedRP = state.radiusPt
            state.radiusPt = rp
            local verts = shapeVertices()
            state.radiusPt = savedRP
            if verts then
                for i = 1, #verts do
                    local a = verts[i]
                    local b = verts[(i % #verts) + 1]
                    previewLine(a.x, a.y, b.x, b.y, COL_WHITE_PREVIEW, zoom)
                end
            end
            previewLine(ox, oy, rp.x, rp.y, {COL_DIMYELLOW[1], COL_DIMYELLOW[2], COL_DIMYELLOW[3], 140}, zoom)
            previewPoint(rp, COL_ORANGE, 5, zoom)
        end
        previewPoint(state.origin, COL_CYAN, 5, zoom)
    end

    local function drawPolyPreview(mouseX, mouseY, hoverActive, zoom)
        local pts = state.points
        for i = 1, #pts - 1 do
            local a, b = pts[i], pts[i + 1]
            previewLine(a.x, a.y, b.x, b.y, COL_WHITE_PREVIEW, zoom)
        end
        if isClosedShape() and #pts >= 3 then
            local a, b = pts[#pts], pts[1]
            previewLine(a.x, a.y, b.x, b.y, {COL_GREEN[1], COL_GREEN[2], COL_GREEN[3], 180}, zoom)
        end
        if hoverActive and #pts > 0 and not state.drag then
            local last = pts[#pts]
            local tx, ty = mouseX, mouseY
            if state.shiftDown then tx, ty = snapFromAnchor(last, mouseX, mouseY) end
            previewLine(last.x, last.y, tx, ty, {COL_DIMYELLOW[1], COL_DIMYELLOW[2], COL_DIMYELLOW[3], 160}, zoom)
        end
        for i, p in ipairs(pts) do
            local col = (i == 1) and COL_CYAN or ((i == #pts) and COL_ORANGE or COL_YELLOW)
            previewPoint(p, col, 5, zoom)
        end
    end

    function dockOrigin(w, h)
        local TOP_MARGIN = 34
        local SIDE_MARGIN = 2
        local BOTTOM_MARGIN = 4
        local xr, yr = sim.XRES, sim.YRES
        if state.dock == "top-left" then
            return SIDE_MARGIN, TOP_MARGIN
        elseif state.dock == "top-right" then
            return xr - w - SIDE_MARGIN, TOP_MARGIN
        elseif state.dock == "top-center" then
            return floor((xr - w) / 2), TOP_MARGIN
        elseif state.dock == "bottom-left" then
            return SIDE_MARGIN, yr - h - BOTTOM_MARGIN
        elseif state.dock == "bottom-right" then
            return xr - w - SIDE_MARGIN, yr - h - BOTTOM_MARGIN
        end
        return SIDE_MARGIN, TOP_MARGIN
    end

    local function modeListLabel()
        local modes = CATEGORY_MODES[state.category]
        local parts = {}
        for i, m in ipairs(modes) do
            if m == state.mode then
                parts[i] = "[" .. m .. "]"
            else
                parts[i] = m
            end
        end
        return table.concat(parts, " ")
    end

    local READY_HINT = "Enter or Right-Click to place"

    local function phaseStatus()
        if isPreset() then
            if state.phase == "EMPTY" then return "click to place center" end
            if state.phase == "NEED_RADIUS" then return "click to set radius (angle sets rotation)" end
            if state.phase == "ADJUST" then
                local r = 0
                if state.origin and state.radiusPt then
                    r = floor(dist(state.origin, state.radiusPt) + 0.5)
                end
                return "radius: " .. r .. "px  |  " .. READY_HINT
            end
            return ""
        end
        if isPoly() then
            local n = #state.points
            if n == 0 then return "click to place point 1" end
            if n == 1 then return "point 1 set  |  click to add more (needs 2+ to place)" end
            local msg = "points: " .. n .. "  |  " .. READY_HINT
            if isClosedShape() then
                msg = msg .. "  |  will close to P1"
            end
            return msg
        end
        if state.phase == "EMPTY" then return "click start point" end
        if state.phase == "NEED_B" then return "click end point" end
        if state.phase == "ADJUST" then
            if state.mode == "ARC" then
                return "angle: " .. floor(state.arcAngle + 0.5) .. "deg (+/- 5, ,/. 1)  |  " .. READY_HINT
            end
            return "drag handles or endpoints  |  " .. READY_HINT
        end
        return ""
    end

    local function phaseHotkeys()
        if isPreset() then
            if state.phase == "ADJUST" then
                return "drag center/handle  [ ]:rot5" .. string.char(176) .. "  , .:rot1" .. string.char(176) .. "  Shift+drag:snap" .. snapVal() .. string.char(176) .. "  Esc:cancel"
            end
            return "drag center or handle  Shift+drag:snap" .. snapVal() .. string.char(176) .. "  Esc:cancel"
        end
        if isPoly() then
            return "Click + Drag: Place/Move  Bksp:undo  Esc:clear"
        end
        if state.mode == "ARC" then
            return "drag A/B  +/-:5" .. string.char(176) .. "  , .:1" .. string.char(176) .. "  Shift+drag:snap" .. snapVal() .. string.char(176) .. "  Esc:cancel"
        end
        return "drag handles  Shift+drag:snap" .. snapVal() .. string.char(176) .. "  Esc:cancel"
    end

    local MODE_TLDR = {
        QUAD    = "curve with 1 shaping handle",
        CUBIC   = "curve with 2 shaping handles",
        ARC     = "circle arc between 2 points",
        TRI     = "triangle from center + radius",
        SQUARE  = "square from center + radius",
        PENT    = "pentagon (5 sides)",
        HEX     = "hexagon (6 sides)",
        OCT     = "octagon (8 sides)",
        STAR    = "5-point star",
        CIRCLE  = "circle (many small segments)",
    }

    local function modeTldr()
        if state.mode == "CUSTOM" then
            if state.category == "Shape" then
                return "Custom shape that automatically closes"
            end
            return "many points, straight segments"
        end
        return MODE_TLDR[state.mode] or ""
    end

    function drawHud()
        local elemName = selectedElemName()
        local titleMain = "LINE+  CAT: " .. state.category .. "  >  [" .. state.mode .. "]"
        local titleElem = "  elem: " .. elemName
        local titleTldr = "  -- " .. modeTldr()

        local flagParts = {}
        if state.grid then flagParts[#flagParts + 1] = "GRID:" .. GRID_SIZE .. "px" end
        if state.fill and canFill() then flagParts[#flagParts + 1] = "FILL" end
        if state.repeatOn then flagParts[#flagParts + 1] = "REPEAT" end
        if state.mirror then flagParts[#flagParts + 1] = "MIRROR:" .. state.mirrorAxis end
        local flagsRow = (#flagParts > 0) and ("[" .. table.concat(flagParts, "] [") .. "]") or nil

        local modesRow = "modes:  " .. modeListLabel()
        local modeCount = #CATEGORY_MODES[state.category]
        local function hk(action, label)
            local kc = state.binds[action]
            if kc == nil then return nil end
            return keyName(kc) .. ":" .. label
        end
        local hkParts = {}
        local addHk = function(a, l) local s = hk(a, l); if s then hkParts[#hkParts + 1] = s end end
        addHk("cycle_category", "cat")
        local cycleKc = state.binds.cycle_mode
        if cycleKc then
            hkParts[#hkParts + 1] = keyName(cycleKc) .. "/1-" .. modeCount .. ":pick"
        else
            hkParts[#hkParts + 1] = "1-" .. modeCount .. ":pick"
        end
        addHk("toggle_fill", "fill")
        addHk("toggle_repeat", "rep")
        addHk("flip_cycle", "flip")
        addHk("toggle_mirror", "mirror")
        addHk("settings", "settings")
        addHk("toggle", "exit")
        local globalKeys = table.concat(hkParts, "  ")
        local phaseKeys = phaseHotkeys()
        local status = phaseStatus()

        local row1Text = titleMain .. titleElem .. titleTldr
        local lines = {
            {text = row1Text, col = COL_CYAN},
        }
        if flagsRow then
            lines[#lines + 1] = {text = flagsRow, col = COL_GREEN}
        end
        lines[#lines + 1] = {text = modesRow,   col = COL_DIMGRAY}
        lines[#lines + 1] = {text = globalKeys, col = COL_DIMGRAY}
        lines[#lines + 1] = {text = phaseKeys,  col = COL_DIMGRAY}
        lines[#lines + 1] = {text = status,     col = COL_YELLOW}

        local widest = 0
        for _, ln in ipairs(lines) do
            if #ln.text > widest then widest = #ln.text end
        end
        local lineH = 12
        local w = widest * 6 + 8
        local h = lineH * #lines + 2
        local x, y = dockOrigin(w, h)
        graphics.fillRect(x, y - 2, w, h, 0, 0, 0, 180)
        graphics.drawRect(x, y - 2, w, h, 0, 0, 0, 255)

        graphics.drawText(x + 4, y, titleMain,
            COL_CYAN[1], COL_CYAN[2], COL_CYAN[3], COL_CYAN[4])
        local mainW = graphics.textSize(titleMain)
        graphics.drawText(x + 4 + mainW, y, titleElem,
            COL_ORANGE[1], COL_ORANGE[2], COL_ORANGE[3], COL_ORANGE[4])
        local elemW = graphics.textSize(titleElem)
        graphics.drawText(x + 4 + mainW + elemW, y, titleTldr,
            COL_DIMGRAY[1], COL_DIMGRAY[2], COL_DIMGRAY[3], COL_DIMGRAY[4])

        for i = 2, #lines do
            local ln = lines[i]
            graphics.drawText(x + 4, y + (i - 1) * lineH, ln.text, ln.col[1], ln.col[2], ln.col[3], ln.col[4])
        end
    end

    local function drawTabBar(x, y, w)
        local tabH = 14
        local halfW = floor(w / 2)
        local genColor = (state.menuTab == "general") and COL_CYAN or COL_DIMGRAY
        local kbColor = (state.menuTab == "keybinds") and COL_CYAN or COL_DIMGRAY
        graphics.fillRect(x, y, halfW, tabH - 1, 20, 20, 20, 200)
        graphics.fillRect(x + halfW, y, w - halfW, tabH - 1, 20, 20, 20, 200)
        graphics.drawText(x + 4, y + 3, "General",
            genColor[1], genColor[2], genColor[3], genColor[4])
        graphics.drawText(x + halfW + 4, y + 3, "Keybinds",
            kbColor[1], kbColor[2], kbColor[3], kbColor[4])
        state.menuRects[#state.menuRects + 1] = {
            x1 = x, y1 = y, x2 = x + halfW, y2 = y + tabH - 1, action = "tab:general",
        }
        state.menuRects[#state.menuRects + 1] = {
            x1 = x + halfW, y1 = y, x2 = x + w, y2 = y + tabH - 1, action = "tab:keybinds",
        }
        return tabH
    end

    function drawGeneralTab()
        local title = "LINE TOOL+ SETTINGS"
        local rows = {
            {label = "Category:  " .. state.category,                    action = "category"},
            {label = "Mode:      " .. state.mode,                        action = "mode"},
            {label = "Dock:      " .. state.dock,                        action = "dock"},
            {label = "Snap:      " .. snapVal() .. "deg",                action = "snap"},
            {label = "Grid:      " .. (state.grid and "on (8px snap)" or "off"),  action = "grid"},
            {label = "Fill:      " .. (state.fill and "on" or "off"),    action = "fill"},
            {label = "Repeat:    " .. (state.repeatOn and "on" or "off"),action = "repeat"},
            {label = "Mirror:    " .. (state.mirror and "on" or "off"),  action = "mirror"},
            {label = "Mirror ax: " .. state.mirrorAxis,                  action = "mirror_axis"},
            {label = "Mobile UI: " .. state.forceMobile ..
                (state.forceMobile == "auto" and (state.isMobile and " (on)" or " (off)") or ""),
                                                                         action = "mobile_ui"},
            {label = "[ Reset to defaults ]",                            action = "reset"},
            {label = "[ Close ]",                                        action = "close"},
        }
        local hint = state.isMobile
            and "tap a row to cycle  |  tap [ Close ] to close"
            or ("click a row to cycle  |  Esc or " .. keyName(state.binds.settings) .. " closes")
        local pad = 8
        local rowH = 14
        local titleH = 14
        local tabH = 14
        local hintH = 12
        local gap = 6

        local widest = #title
        for _, r in ipairs(rows) do
            if #r.label > widest then widest = #r.label end
        end
        if #hint > widest then widest = #hint end

        local w = widest * 6 + pad * 2
        local rowsH = rowH * #rows
        local h = pad + titleH + gap + tabH + gap + rowsH + gap + hintH + pad
        local x = floor((sim.XRES - w) / 2)
        local y = floor((sim.YRES - h) / 2)

        graphics.fillRect(x, y, w, h, 0, 0, 0, 220)
        graphics.drawRect(x, y, w, h, 200, 200, 200, 255)

        graphics.drawText(x + pad, y + pad + 2, title,
            COL_CYAN[1], COL_CYAN[2], COL_CYAN[3], COL_CYAN[4])

        local tabY = y + pad + titleH + gap
        drawTabBar(x + pad, tabY, w - pad * 2)

        local rowsTop = tabY + tabH + gap
        local rowLeft = x + pad
        local rowW = w - pad * 2
        for i, r in ipairs(rows) do
            local ry = rowsTop + (i - 1) * rowH
            graphics.fillRect(rowLeft, ry, rowW, rowH - 1, 30, 30, 30, 200)
            graphics.drawText(rowLeft + 4, ry + 3, r.label,
                COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])
            state.menuRects[#state.menuRects + 1] = {
                x1 = rowLeft, y1 = ry,
                x2 = rowLeft + rowW, y2 = ry + rowH - 1,
                action = r.action,
            }
        end

        graphics.drawText(x + pad, y + h - pad - hintH + 2, hint,
            COL_DIMGRAY[1], COL_DIMGRAY[2], COL_DIMGRAY[3], COL_DIMGRAY[4])
    end

    function drawKeybindsTab()
        local title = "LINE TOOL+ SETTINGS"
        local pad = 8
        local rowH = 14
        local titleH = 14
        local tabH = 14
        local hintH = 12
        local navH = 14
        local gap = 6

        local totalPages = ceil(#BIND_ACTIONS / ROWS_PER_PAGE)
        if state.bindPage > totalPages then state.bindPage = totalPages end
        if state.bindPage < 1 then state.bindPage = 1 end
        local startIdx = (state.bindPage - 1) * ROWS_PER_PAGE + 1
        local endIdx = min(startIdx + ROWS_PER_PAGE - 1, #BIND_ACTIONS)

        local widestLabel = 0
        for _, a in ipairs(BIND_ACTIONS) do
            if #a.label > widestLabel then widestLabel = #a.label end
        end

        local labelColW = widestLabel * 6 + 8
        local keyColW = 100
        local changeBtnW = 50
        local clearBtnW = 44
        local colGap = 6

        local rowW = labelColW + colGap + keyColW + colGap + changeBtnW + colGap + clearBtnW
        local hint = state.isMobile
            and "tap Change/Clear on a row  |  switch to General tab, then [ Close ]"
            or ("click Change/Clear on a row  |  Esc or " .. keyName(state.binds.settings) .. " closes")
        local navLabel = "Page " .. state.bindPage .. "/" .. totalPages

        local headerWidest = max(#title, #hint)
        local w = max(headerWidest * 6 + pad * 2, rowW + pad * 2)
        local rowsH = rowH * (endIdx - startIdx + 1)
        local h = pad + titleH + gap + tabH + gap + rowsH + gap + navH + gap + hintH + pad
        local x = floor((sim.XRES - w) / 2)
        local y = floor((sim.YRES - h) / 2)

        graphics.fillRect(x, y, w, h, 0, 0, 0, 220)
        graphics.drawRect(x, y, w, h, 200, 200, 200, 255)

        graphics.drawText(x + pad, y + pad + 2, title,
            COL_CYAN[1], COL_CYAN[2], COL_CYAN[3], COL_CYAN[4])

        local tabY = y + pad + titleH + gap
        drawTabBar(x + pad, tabY, w - pad * 2)

        local rowsTop = tabY + tabH + gap
        local rowLeft = x + pad
        for i = startIdx, endIdx do
            local a = BIND_ACTIONS[i]
            local ry = rowsTop + (i - startIdx) * rowH
            graphics.fillRect(rowLeft, ry, rowW, rowH - 1, 30, 30, 30, 200)
            graphics.drawText(rowLeft + 4, ry + 3, a.label,
                COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])

            local keyX = rowLeft + labelColW + colGap
            graphics.drawText(keyX + 4, ry + 3, keyName(state.binds[a.name]),
                COL_CYAN[1], COL_CYAN[2], COL_CYAN[3], COL_CYAN[4])

            local changeX = keyX + keyColW + colGap
            graphics.fillRect(changeX, ry, changeBtnW, rowH - 1, 30, 60, 30, 200)
            graphics.drawText(changeX + 4, ry + 3, "Change",
                COL_GREEN[1], COL_GREEN[2], COL_GREEN[3], COL_GREEN[4])
            state.menuRects[#state.menuRects + 1] = {
                x1 = changeX, y1 = ry, x2 = changeX + changeBtnW, y2 = ry + rowH - 1,
                action = "bind:change:" .. a.name,
            }

            local clearX = changeX + changeBtnW + colGap
            local unclearable = (a.name == "toggle" or a.name == "settings")
            if unclearable then
                graphics.fillRect(clearX, ry, clearBtnW, rowH - 1, 40, 40, 40, 200)
                graphics.drawText(clearX + 4, ry + 3, "locked",
                    COL_DIMGRAY[1], COL_DIMGRAY[2], COL_DIMGRAY[3], COL_DIMGRAY[4])
            else
                graphics.fillRect(clearX, ry, clearBtnW, rowH - 1, 60, 30, 30, 200)
                graphics.drawText(clearX + 4, ry + 3, "Clear",
                    COL_ORANGE[1], COL_ORANGE[2], COL_ORANGE[3], COL_ORANGE[4])
                state.menuRects[#state.menuRects + 1] = {
                    x1 = clearX, y1 = ry, x2 = clearX + clearBtnW, y2 = ry + rowH - 1,
                    action = "bind:clear:" .. a.name,
                }
            end
        end

        local navY = rowsTop + rowsH + gap
        local prevX1, prevX2 = rowLeft, rowLeft + 50
        local nextX1, nextX2 = rowLeft + rowW - 50, rowLeft + rowW
        graphics.fillRect(prevX1, navY, prevX2 - prevX1, navH - 1, 30, 30, 30, 200)
        graphics.drawText(prevX1 + 4, navY + 3, "< Prev",
            COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])
        state.menuRects[#state.menuRects + 1] = {
            x1 = prevX1, y1 = navY, x2 = prevX2, y2 = navY + navH - 1, action = "bind:prevpage",
        }

        graphics.drawText(rowLeft + floor(rowW / 2) - floor(#navLabel * 6 / 2), navY + 3, navLabel,
            COL_DIMGRAY[1], COL_DIMGRAY[2], COL_DIMGRAY[3], COL_DIMGRAY[4])

        graphics.fillRect(nextX1, navY, nextX2 - nextX1, navH - 1, 30, 30, 30, 200)
        graphics.drawText(nextX1 + 4, navY + 3, "Next >",
            COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])
        state.menuRects[#state.menuRects + 1] = {
            x1 = nextX1, y1 = navY, x2 = nextX2, y2 = navY + navH - 1, action = "bind:nextpage",
        }

        graphics.drawText(x + pad, y + h - pad - hintH + 2, hint,
            COL_DIMGRAY[1], COL_DIMGRAY[2], COL_DIMGRAY[3], COL_DIMGRAY[4])
    end

    function drawMenu()
        state.menuRects = {}

        if state.rebindAction then
            local a = BIND_LOOKUP[state.rebindAction]
            local line1 = "Binding: " .. (a and a.label or state.rebindAction)
            local line2 = state.isMobile
                and "Press a key (hardware keyboard)"
                or "Press any key..."
            local line3 = state.isMobile
                and "(tap Cancel below)"
                or "(Esc to cancel)"
            local widest = max(#line1, #line2, #line3, 20)
            local w = widest * 6 + 16
            local h = 14 * 3 + 16 + (state.isMobile and 20 or 0)
            local x = floor((sim.XRES - w) / 2)
            local y = floor((sim.YRES - h) / 2)
            graphics.fillRect(x, y, w, h, 0, 0, 0, 220)
            graphics.drawRect(x, y, w, h, 200, 200, 200, 255)
            graphics.drawText(x + 8, y + 8, line1,
                COL_CYAN[1], COL_CYAN[2], COL_CYAN[3], COL_CYAN[4])
            graphics.drawText(x + 8, y + 22, line2,
                COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])
            graphics.drawText(x + 8, y + 36, line3,
                COL_DIMGRAY[1], COL_DIMGRAY[2], COL_DIMGRAY[3], COL_DIMGRAY[4])
            if state.isMobile then
                local btnW, btnH = 60, 16
                local btnX = x + floor((w - btnW) / 2)
                local btnY = y + h - btnH - 4
                graphics.fillRect(btnX, btnY, btnW, btnH, 60, 30, 30, 220)
                graphics.drawRect(btnX, btnY, btnW, btnH, 200, 200, 200, 255)
                local ct = "Cancel"
                local ctw = graphics.textSize(ct)
                graphics.drawText(btnX + floor((btnW - ctw) / 2), btnY + 4, ct,
                    COL_ORANGE[1], COL_ORANGE[2], COL_ORANGE[3], COL_ORANGE[4])
                state.menuRects[#state.menuRects + 1] = {
                    x1 = btnX, y1 = btnY, x2 = btnX + btnW, y2 = btnY + btnH,
                    action = "rebind:cancel",
                }
            end
            return
        end

        if state.pendingConflict then
            local pc = state.pendingConflict
            local isModifier = (pc.reason == "modifier")
            local line1, line2
            if isModifier then
                line1 = keyName(pc.key) .. " can't be bound."
                line2 = "Modifier keys aren't allowed."
            else
                local otherLabel = (BIND_LOOKUP[pc.other] and BIND_LOOKUP[pc.other].label) or pc.other
                line1 = "Already bound to " .. otherLabel .. "."
                line2 = "Replace?"
            end
            local widest = max(#line1, #line2, 20)
            local w = widest * 6 + 16
            local h = 14 * 2 + 16 + 18
            local x = floor((sim.XRES - w) / 2)
            local y = floor((sim.YRES - h) / 2)
            graphics.fillRect(x, y, w, h, 0, 0, 0, 220)
            graphics.drawRect(x, y, w, h, 200, 200, 200, 255)
            graphics.drawText(x + 8, y + 8, line1,
                COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])
            graphics.drawText(x + 8, y + 22, line2,
                COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])

            local btnY = y + h - 22
            if isModifier then
                local okW = 40
                local okX1 = x + floor((w - okW) / 2)
                local okX2 = okX1 + okW
                graphics.fillRect(okX1, btnY, okW, 16, 30, 30, 60, 220)
                graphics.drawText(okX1 + 12, btnY + 3, "OK",
                    COL_CYAN[1], COL_CYAN[2], COL_CYAN[3], COL_CYAN[4])
                state.menuRects[#state.menuRects + 1] = {
                    x1 = okX1, y1 = btnY, x2 = okX2, y2 = btnY + 16, action = "conflict:no",
                }
            else
                local yesX1, yesX2 = x + 8, x + 8 + 40
                local noX1, noX2 = x + w - 48, x + w - 8
                graphics.fillRect(yesX1, btnY, yesX2 - yesX1, 16, 30, 60, 30, 220)
                graphics.drawText(yesX1 + 10, btnY + 3, "Yes",
                    COL_GREEN[1], COL_GREEN[2], COL_GREEN[3], COL_GREEN[4])
                graphics.fillRect(noX1, btnY, noX2 - noX1, 16, 60, 30, 30, 220)
                graphics.drawText(noX1 + 12, btnY + 3, "No",
                    COL_ORANGE[1], COL_ORANGE[2], COL_ORANGE[3], COL_ORANGE[4])

                state.menuRects[#state.menuRects + 1] = {
                    x1 = yesX1, y1 = btnY, x2 = yesX2, y2 = btnY + 16, action = "conflict:yes",
                }
                state.menuRects[#state.menuRects + 1] = {
                    x1 = noX1, y1 = btnY, x2 = noX2, y2 = btnY + 16, action = "conflict:no",
                }
            end
            return
        end

        if state.menuTab == "keybinds" then
            drawKeybindsTab()
        else
            drawGeneralTab()
        end
    end

    function handleMenuClick(mx, my)
        for _, r in ipairs(state.menuRects) do
            if mx >= r.x1 and mx <= r.x2 and my >= r.y1 and my <= r.y2 then
                if r.action == "category" then
                    cycleCategory()
                elseif r.action == "mode" then
                    cycleMode(1)
                elseif r.action == "dock" then
                    local i = indexOf(DOCK_POSITIONS, state.dock) or 1
                    state.dock = DOCK_POSITIONS[(i % #DOCK_POSITIONS) + 1]
                    saveSettings()
                elseif r.action == "snap" then
                    state.snapIdx = (state.snapIdx % #SNAP_VALUES) + 1
                    saveSettings()
                elseif r.action == "grid" then
                    state.grid = not state.grid
                    saveSettings()
                elseif r.action == "fill" then
                    state.fill = not state.fill
                    saveSettings()
                elseif r.action == "repeat" then
                    state.repeatOn = not state.repeatOn
                    saveSettings()
                elseif r.action == "mirror" then
                    state.mirror = not state.mirror
                    saveSettings()
                elseif r.action == "mirror_axis" then
                    local i = indexOf(MIRROR_AXES, state.mirrorAxis) or 1
                    state.mirrorAxis = MIRROR_AXES[(i % #MIRROR_AXES) + 1]
                    saveSettings()
                elseif r.action == "mobile_ui" then
                    if state.forceMobile == "auto" then state.forceMobile = "on"
                    elseif state.forceMobile == "on" then state.forceMobile = "off"
                    else state.forceMobile = "auto" end
                    recomputeMobile()
                    saveSettings()
                elseif r.action == "reset" then
                    resetToDefaults()
                    resetPoints()
                elseif r.action == "close" then
                    state.showMenu = false
                elseif r.action == "tab:general" then
                    state.menuTab = "general"
                elseif r.action == "tab:keybinds" then
                    state.menuTab = "keybinds"
                elseif r.action == "bind:prevpage" then
                    state.bindPage = max(1, state.bindPage - 1)
                elseif r.action == "bind:nextpage" then
                    local totalPages = ceil(#BIND_ACTIONS / ROWS_PER_PAGE)
                    state.bindPage = min(totalPages, state.bindPage + 1)
                elseif r.action == "conflict:yes" then
                    local pc = state.pendingConflict
                    if pc then
                        state.binds[pc.other] = nil
                        state.binds[pc.action] = pc.key
                        saveSettings()
                    end
                    state.pendingConflict = nil
                elseif r.action == "conflict:no" then
                    state.pendingConflict = nil
                elseif r.action == "rebind:cancel" then
                    state.rebindAction = nil
                else
                    local changeName = r.action:match("^bind:change:(.+)$")
                    local clearName = r.action:match("^bind:clear:(.+)$")
                    if changeName and BIND_LOOKUP[changeName] then
                        state.rebindAction = changeName
                    elseif clearName and BIND_LOOKUP[clearName] then
                        if clearName ~= "toggle" and clearName ~= "settings" then
                            state.binds[clearName] = nil
                            saveSettings()
                        end
                    end
                end
                return true
            end
        end
        return false
    end

    local MOBILE_TOOLBAR_BUTTONS = {
        {action = "menu",   label = "Menu"},
        {action = "cycle",  label = "Mode"},
        {action = "flip",   label = "Flip"},
        {action = "commit", label = "OK"},
        {action = "undo",   label = "Undo"},
        {action = "cancel", label = "X"},
        {action = "off",    label = "Off"},
    }

    local function drawMobileToolbar()
        state.mobileRects = {}
        local btnW, btnH, gap = 56, 20, 4
        local n = #MOBILE_TOOLBAR_BUTTONS
        local totalW = n * btnW + (n - 1) * gap
        local x0 = floor((sim.XRES - totalW) / 2)
        local y0 = 4
        for i, b in ipairs(MOBILE_TOOLBAR_BUTTONS) do
            local bx = x0 + (i - 1) * (btnW + gap)
            graphics.fillRect(bx, y0, btnW, btnH, 30, 30, 30, 220)
            graphics.drawRect(bx, y0, btnW, btnH, 200, 200, 200, 255)
            local tw = graphics.textSize(b.label)
            graphics.drawText(bx + floor((btnW - tw) / 2), y0 + 5, b.label,
                COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])
            state.mobileRects[#state.mobileRects + 1] = {
                x1 = bx, y1 = y0, x2 = bx + btnW, y2 = y0 + btnH, action = b.action,
            }
        end
    end

    local function drawMobileActivate()
        state.mobileRects = {}
        local btnW, btnH = 15, 15
        local x0 = sim.XRES + 1
        local y0 = floor(sim.YRES / 2) - floor(btnH / 2)
        graphics.fillRect(x0, y0, btnW, btnH, 30, 30, 30, 220)
        graphics.drawRect(x0, y0, btnW, btnH, 200, 200, 200, 255)
        local label = "L+"
        local tw = graphics.textSize(label)
        graphics.drawText(x0 + floor((btnW - tw) / 2), y0 + 4, label,
            COL_YELLOW[1], COL_YELLOW[2], COL_YELLOW[3], COL_YELLOW[4])
        state.mobileRects[#state.mobileRects + 1] = {
            x1 = x0, y1 = y0, x2 = x0 + btnW, y2 = y0 + btnH, action = "activate",
        }
    end

    local function drawOverlays(zoom)
        drawMirrorAxis(zoom)
        if isPreset() then
            drawPresetPreview(cursor.x, cursor.y, cursor.inSim, zoom)
        elseif isPoly() then
            drawPolyPreview(cursor.x, cursor.y, cursor.inSim, zoom)
        else
            drawLineAdjustOverlay(zoom)
        end
    end

    event.register(evt.tick, function()

        state.currentTick = state.currentTick + 1

        if not state.active then
            if state.isMobile and not state.showMenu then
                drawMobileActivate()
            end
            return
        end

        drawHud()

        drawOverlays(false)

        if zoomInfo() then
            drawOverlays(true)
        end

        if state.isMobile and not state.showMenu then
            drawMobileToolbar()
        end

        if state.showMenu then drawMenu() end
    end)

    if state.isMobile then
        print("Line Tool+ v2.0 loaded -- tap the L+ button (right sidebar) to activate")
    else
        print("Line Tool+ v2.0 loaded -- press " .. keyName(state.binds.toggle) .. " to activate")
    end
end

Description:

Changelog: