Files
fs25-adsmartpickup/docs/superpowers/plans/2026-09-29-flow-field-list-edit-panel.md
T

38 KiB

Flows/Fields: farvet liste + redigeringsvindue — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Flows- og Fields-fanen viser kun en farvet liste med Start/Stop pr. række; klik på en række åbner et redigeringsvindue (panel i samme frame) med de nuværende redigeringslinjer.

Architecture: Rene statusfunktioner (ADFlowView.flowStatus, ADFieldView.fieldStatus) afgør niveau/farve og testes med luajit. GUI: rækkehjælperne får et mål-layout (self.rowTarget), så den eksisterende resumé-/guide-kode kan tegne i vinduets egen ScrollingLayout. Vinduet er et skjult overlay i SmartPickupFrame.xml, styret af en ny mixin gui/SmartPickupEditPanel.lua.

Tech Stack: FS25 Lua (GIANTS GUI: Bitmap/Text/ScrollingLayout, TabbedMenuFrameElement menuButtonInfo), luajit-tests i tests/.

Spec: docs/superpowers/specs/2026-09-29-flow-field-list-edit-panel-design.md

Global Constraints

  • Ingen ændring af flow-/mark-logik, guiderne eller Oversigt-tavlen/flåde-visningen.
  • Rækker er ≈835 px brede (som lagervisningen).
  • Farver: grøn {0.30, 0.75, 0.35, 1} = running, gul {0.95, 0.80, 0.25, 1} = waiting, rød {0.90, 0.35, 0.30, 1} = error, grå {0.55, 0.55, 0.55, 1} = stopped.
  • Al GUI-kode i self:safe(...); ingen utilsigtede globals (tests/test_noStrayGlobals.lua).
  • Byg kun når alle tests er grønne: for t in tests/test_*.lua; do luajit $t >/dev/null || echo FAIL $t; done. Zip: cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip ., kopi til ~/FS25-data/mods/, commit, push remote gitea main (Basic-auth header med token fra ~/credentials-vault.md sektion Gitea, linje med "API-token").
  • l10n: nye tekster i både l10n/l10n_da.xml og l10n/l10n_en.xml, præfiks spu_.

Review Focus

  • Klik på listen bag et åbent vindue: skal intet gøre (vinduet ejer klik) — pinned af makeRowClickable-regel i Task 2 (klik tæller kun inde i rækkens eget layout, og listen ignorerer klik når vinduet er åbent).
  • ESC med vindue åbent: lukker vinduet, ikke ESC-menuen; med et [Edit]-trin åbent: lukker trinnet først — Task 3.
  • Flow/mark slettet mens vinduet er åbent: vinduet lukker sig med besked — Task 3 (refreshEditPanel).
  • Start/Stop på en række for et andet flow end det valgte: må ikke påvirke det valgte — Task 4 (handling bærer sit eget id).
  • Flow startet men alle traktorer afvist ("ingen vogn"): rød, ikke grå — pinned af test i Task 1.

Task 1: Rene statusfunktioner for flows (+ fælles farver)

Files:

  • Modify: FS25_ADSmartPickup/adFlowView.lua (tilføj i bunden)
  • Test: tests/test_flowStatus.lua (ny)

Interfaces:

  • Produces: ADFlowView.LEVEL_COLORS = {running={...}, waiting={...}, error={...}, stopped={...}}; ADFlowView.flowStatus(s) -> level hvor s = {running=int, total=int, stopped=bool, canStop=bool, warning=string|nil, reason=string|nil, lastStartFailed=bool} og level ∈ {"running","waiting","error","stopped"}.

  • Step 1: Write the failing test — tests/test_flowStatus.lua:

-- Kør: luajit tests/test_flowStatus.lua   (fra repo-roden)
dofile("FS25_ADSmartPickup/adFlowView.lua")
local V = ADFlowView
local failures = 0
local function check(name, actual, expected)
    if actual == expected then print("OK   " .. name) else
        failures = failures + 1
        print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
    end
end
check("kører", V.flowStatus({running = 3, total = 4, canStop = true}), "running")
check("kører men holder (begrundelse)", V.flowStatus({running = 2, total = 2, canStop = true, reason = "intet at hente"}), "waiting")
check("startet, venter på vogn", V.flowStatus({running = 0, total = 2, canStop = true}), "waiting")
check("advarsel", V.flowStatus({running = 1, total = 2, canStop = true, warning = "mål mangler"}), "error")
check("start afvist (ingen vogn)", V.flowStatus({running = 0, total = 2, lastStartFailed = true}), "error")
check("stoppet", V.flowStatus({running = 0, total = 2, stopped = true}), "stopped")
check("aldrig startet", V.flowStatus({running = 0, total = 2}), "stopped")
check("stoppet vinder over gammel afvisning", V.flowStatus({running = 0, total = 2, stopped = true, lastStartFailed = true}), "stopped")
check("farver findes", V.LEVEL_COLORS.waiting ~= nil and V.LEVEL_COLORS.stopped ~= nil, true)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
  • Step 2: Run luajit tests/test_flowStatus.lua — Expected: FAIL (flowStatus nil).

  • Step 3: Implement — append to adFlowView.lua:

-- ---- status pr. flow (Flows-listen) ----

ADFlowView.LEVEL_COLORS = {
    running = {0.30, 0.75, 0.35, 1},
    waiting = {0.95, 0.80, 0.25, 1},
    error = {0.90, 0.35, 0.30, 1},
    stopped = {0.55, 0.55, 0.55, 1},
}

-- s = {running, total, stopped, canStop, warning, reason, lastStartFailed} -> "running"|"waiting"|"error"|"stopped"
function ADFlowView.flowStatus(s)
    if s.stopped then return "stopped" end
    if s.warning ~= nil then return "error" end
    if (s.running or 0) == 0 and s.lastStartFailed then return "error" end
    if (s.running or 0) > 0 and s.reason == nil then return "running" end
    if (s.running or 0) > 0 or s.canStop then return "waiting" end
    return "stopped"
end
  • Step 4: Run all tests — Expected: ALLE TESTS OK in test_flowStatus, no FAIL elsewhere.
  • Step 5: Commit git add tests/test_flowStatus.lua FS25_ADSmartPickup/adFlowView.lua && git commit -m "feat(flows): ren statusfunktion + farver til Flows-listen"

Task 2: Rækker kan tegnes i et andet layout

Files:

  • Modify: FS25_ADSmartPickup/gui/SmartPickupFrame.lua (new: felter; addRow; clearRows; colorRows)
  • Modify: FS25_ADSmartPickup/gui/SmartPickupFlowTab.lua (makeRowClickable)

Interfaces:

  • Produces: self.rowTarget (standard nil = self.boxLayout); SmartPickupFrame:withRowTarget(layout, fn) kører fn(self) med rækker klonet ind i layout; self.panelRows (kloner i vinduet) adskilt fra self.clonedRows; SmartPickupFrame:clearPanelRows(); SmartPickupFrame:colorLayout(layout). Hver klon får row.spuLayout = layout.

  • Step 1: addRow — replace body:

function SmartPickupFrame:addRow(prefab, data)
    local layout = self.rowTarget or self.boxLayout
    local row = prefab:clone(layout)
    row.spuLayout = layout
    FocusManager:loadElementFromCustomValues(row)
    table.insert(layout == self.boxLayout and self.clonedRows or self.panelRows, row)
    local option = row:getDescendantByName("option")
    if option ~= nil then
        option:setTexts({self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")})
        self.rowData[option] = data
    end
    return row
end

-- Rækker klonet ind i et andet layout (redigeringsvinduet) mens fn kører.
function SmartPickupFrame:withRowTarget(layout, fn)
    local previous = self.rowTarget
    self.rowTarget = layout
    local ok, err = pcall(fn, self)
    self.rowTarget = previous
    if not ok then error(err, 0) end
end

function SmartPickupFrame:clearPanelRows()
    for _, row in ipairs(self.panelRows or {}) do
        FocusManager:removeElement(row)
        row:delete()
    end
    self.panelRows = {}
end
  • Step 2: new — next to clonedRows = {} initialisation add self.panelRows = {} (find clonedRows in SmartPickupFrame.new and add on the same pattern).
  • Step 3: colorRows — split into colorLayout(layout) and keep colorRows() calling self:colorLayout(self.boxLayout):
function SmartPickupFrame:colorLayout(layout)
    local colors = InGameMenuSettingsFrame ~= nil and InGameMenuSettingsFrame.COLOR_ALTERNATING or nil
    if colors == nil or layout == nil then return end
    local unpackFn = table.unpack or unpack
    local isEvenRow = false
    for _, element in ipairs(layout.elements) do
        if element.setImageColor ~= nil and colors[isEvenRow] ~= nil then
            element:setImageColor(nil, unpackFn(colors[isEvenRow]))
        end
        isEvenRow = not isEvenRow
    end
end

function SmartPickupFrame:colorRows()
    self:colorLayout(self.boxLayout)
end
  • Step 4: makeRowClickable — clicks count inside the row's own layout, and list rows ignore clicks while the panel is open:
        local layout = element.spuLayout or frame.boxLayout
        local blockedByPanel = frame.editPanelOpen and layout == frame.boxLayout
        if not used and not eventUsed and not blockedByPanel and isUp and button == MOUSE_LEFT and (element.getIsVisible == nil or element:getIsVisible())
            and isInside(element, posX, posY) and isInside(layout, posX, posY) then

(replace the existing if not used and not eventUsed and isUp ... condition lines; keep the body.)

  • Step 5: Run all tests — no FAIL (behaviour unchanged: rowTarget nil everywhere).
  • Step 6: Commit git commit -am "refactor(gui): rækker kan klones ind i et andet layout (redigeringsvindue)"

Task 3: Redigeringsvinduet (overlay + mixin)

Files:

  • Modify: FS25_ADSmartPickup/gui/SmartPickupFrame.xml (overlay efter boxLayout)
  • Modify: FS25_ADSmartPickup/gui/guiProfiles.xml (profiler)
  • Create: FS25_ADSmartPickup/gui/SmartPickupEditPanel.lua
  • Modify: FS25_ADSmartPickup/adSmartPickup.lua (source efter SmartPickupFlowGuide.lua)
  • Modify: FS25_ADSmartPickup/gui/SmartPickupFrame.lua (rebuild: fyld vinduet; refreshButtons: vinduets knapper; refreshLive: refreshEditPanel; onFrameClose: luk vinduet)
  • Modify: l10n/l10n_da.xml, l10n/l10n_en.xml

Interfaces:

  • Consumes: Task 2 (withRowTarget, clearPanelRows, colorLayout, editPanelOpen).

  • Produces: SmartPickupFrame:openEditPanel(kind, id) (kind ∈ {"flow","field"}), closeEditPanel(), fillEditPanel(adEnv), refreshEditPanel(adEnv), felterne self.editPanelOpen, self.editPanelKind, self.editPanelId; hook-tabel SmartPickupFrame.EDIT_PANEL_KINDS[kind] = {title(frame,id)->string, exists(frame,id)->bool, fill(frame,adEnv,id), status(frame,adEnv,id)->{text,level}, startStop(frame,id), startStopText(frame,adEnv,id)->string, canDelete=bool, delete(frame,id)} — Task 4/7 registrerer "flow"/"field".

  • Step 1: Profiles — add to guiProfiles.xml (before </Profiles>):

    <!-- redigeringsvindue (v1.58): mørk flade + panel; størrelser sættes i Lua ud fra boxLayout -->
    <Profile name="spuOverlayBackdrop" extends="spuBar">
        <imageColor value="0 0 0 0.65"/>
        <positionOrigin value="topLeft"/>
        <size value="100px 100px"/>
    </Profile>
    <Profile name="spuOverlayPanel" extends="spuBar">
        <imageColor value="0.09 0.09 0.09 0.97"/>
        <positionOrigin value="topLeft"/>
        <size value="100px 100px"/>
    </Profile>
    <Profile name="spuPanelTitle" extends="spuCellBold">
        <textSize value="20px"/>
    </Profile>
  • Step 2: XML overlay — directly after </ScrollingLayout> of boxLayout:
        <!-- redigeringsvindue (skjult; placeres/størrelse sættes i SmartPickupEditPanel.lua) -->
        <GuiElement profile="baseReference" id="editOverlay" visible="false">
            <Bitmap profile="spuOverlayBackdrop" id="editBackdrop"/>
            <Bitmap profile="spuOverlayPanel" id="editPanel">
                <Text profile="spuPanelTitle" id="editTitle" position="20px -16px"/>
                <Text profile="spuCell" id="editStatus" position="20px -46px"/>
                <ScrollingLayout profile="fs25_settingsLayout" id="editLayout" position="0px -80px"/>
            </Bitmap>
        </GuiElement>
  • Step 3: Mixin — create gui/SmartPickupEditPanel.lua:
-- Smart Pickup — redigeringsvindue (v1.58)
-- Klik på en række i Flows/Fields åbner et panel oven på listen. Indholdet tegnes af den eksisterende
-- resumé-/guide-kode i vinduets egen ScrollingLayout (SmartPickupFrame:withRowTarget). Knapper i bunden via
-- menuButtonInfo: Luk (MENU_BACK = ESC), Start/Stop, Slet. Hver art registrerer sig i EDIT_PANEL_KINDS.
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).

SmartPickupFrame.EDIT_PANEL_KINDS = SmartPickupFrame.EDIT_PANEL_KINDS or {}
SmartPickupFrame.EDIT_PANEL_WIDTH, SmartPickupFrame.EDIT_PANEL_HEIGHT = 0.78, 0.86 -- andel af listens flade

function SmartPickupFrame:initEditPanelButtons(i18n)
    self.panelCloseButtonInfo = {inputAction = InputAction.MENU_BACK, text = i18n:getText("spu_panel_close"), callback = function() self:onClickPanelClose() end}
    self.panelStartButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_button_start"), callback = function() self:onClickPanelStartStop() end}
    self.panelDeleteButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_button_delete"), callback = function() self:onClickPanelDelete() end}
    self.panelButtons = {self.panelCloseButtonInfo, self.panelStartButtonInfo, self.panelDeleteButtonInfo}
end

function SmartPickupFrame:editPanelKind()
    return self.editPanelOpen and SmartPickupFrame.EDIT_PANEL_KINDS[self.editPanelKind] or nil
end

-- Vinduet dækker listens flade; panelet er centreret i den.
function SmartPickupFrame:layoutEditPanel()
    local box = self.boxLayout
    local x, y, w, h = box.position[1], box.position[2], box.size[1], box.size[2]
    self.editBackdrop:setPosition(x, y)
    self.editBackdrop:setSize(w, h)
    local pw, ph = w * SmartPickupFrame.EDIT_PANEL_WIDTH, h * SmartPickupFrame.EDIT_PANEL_HEIGHT
    self.editPanel:setPosition(x + (w - pw) / 2, y - (h - ph) / 2)
    self.editPanel:setSize(pw, ph)
    local top = 80 * g_pixelSizeScaledY
    self.editLayout:setSize(pw, ph - top)
end

function SmartPickupFrame:openEditPanel(kind, id)
    if SmartPickupFrame.EDIT_PANEL_KINDS[kind] == nil then return end
    self.editPanelOpen, self.editPanelKind, self.editPanelId = true, kind, id
    self:closeGuide()
    self.editOverlay:setVisible(true)
    self:layoutEditPanel()
    self:requestRebuild()
end

function SmartPickupFrame:closeEditPanel()
    if not self.editPanelOpen then return end
    self.editPanelOpen, self.editPanelKind, self.editPanelId = false, nil, nil
    self:closeGuide()
    self:clearPanelRows()
    if self.editOverlay ~= nil then self.editOverlay:setVisible(false) end
    self:requestRebuild()
end

-- Kaldes fra rebuild (efter listen): vinduets rækker genopbygges i editLayout.
function SmartPickupFrame:fillEditPanel(adEnv)
    self:clearPanelRows()
    local hooks = self:editPanelKind()
    if hooks == nil then return end
    if not hooks.exists(self, self.editPanelId) then
        self.statusOverride = self.i18n:getText("spu_panel_gone")
        self.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS
        self.editPanelOpen, self.editPanelKind, self.editPanelId = false, nil, nil
        self.editOverlay:setVisible(false)
        return
    end
    self.editTitle:setText(hooks.title(self, self.editPanelId))
    self:withRowTarget(self.editLayout, function(frame)
        if frame.guide ~= nil then
            frame:fillGuideRows(adEnv)
        else
            hooks.fill(frame, adEnv, frame.editPanelId)
        end
    end)
    self:colorLayout(self.editLayout)
    self.editLayout:invalidateLayout()
    self:refreshEditPanel(adEnv)
end

-- Hvert sekund: status + knaptekster.
function SmartPickupFrame:refreshEditPanel(adEnv)
    local hooks = self:editPanelKind()
    if hooks == nil or adEnv == nil then return end
    if not hooks.exists(self, self.editPanelId) then self:closeEditPanel() return end
    local status = hooks.status(self, adEnv, self.editPanelId)
    self.editStatus:setText(status.text or "")
    local color = ADFlowView.LEVEL_COLORS[status.level] or ADFlowView.LEVEL_COLORS.stopped
    if self.editStatus.setTextColor ~= nil then self.editStatus:setTextColor(color[1], color[2], color[3], color[4]) end
    self.panelStartButtonInfo.text = hooks.startStopText(self, adEnv, self.editPanelId)
    self.panelDeleteButtonInfo.disabled = not hooks.canDelete
end

function SmartPickupFrame:onClickPanelClose()
    self:safe(function(frame)
        -- et åbent [Edit]-trin lukkes først (kladden kasseres som ved Annullér)
        if frame.guide ~= nil then
            frame:closeGuide()
            frame:requestRebuild()
            return
        end
        frame:closeEditPanel()
    end)
end

function SmartPickupFrame:onClickPanelStartStop()
    self:safe(function(frame)
        local hooks = frame:editPanelKind()
        if hooks ~= nil then hooks.startStop(frame, frame.editPanelId) end
        frame:requestRebuild()
    end)
end

function SmartPickupFrame:onClickPanelDelete()
    self:safe(function(frame)
        local hooks = frame:editPanelKind()
        if hooks ~= nil and hooks.canDelete then hooks.delete(frame, frame.editPanelId) end
    end)
end
  • Step 4: Wire into frame —
    • adSmartPickup.lua: after source(ADSmartPickup.dir .. "gui/SmartPickupFlowGuide.lua") add source(ADSmartPickup.dir .. "gui/SmartPickupEditPanel.lua").
    • SmartPickupFrame.new: where initFlowButtons(i18n) is called, also call self:initEditPanelButtons(i18n).
    • rebuild: in the Flows branch and the Fields branch, after self.boxLayout:invalidateLayout() add if self.editPanelOpen then self:fillEditPanel(adEnv) end.
    • refreshButtons: first branch: if self.editPanelOpen then wanted = self.guide ~= nil and self:getGuideButtons() or self.panelButtons elseif self:hasGuideOn(...) — and in the guide-in-panel case replace the guide list's self.backButtonInfo by self.panelCloseButtonInfo (build self.panelGuideButtons once: copy of getGuideButtons() with index 1 swapped).
    • refreshLive: at the end if self.editPanelOpen then self:refreshEditPanel(adEnv) end.
    • onFrameClose: before closeGuide() add self.editPanelOpen, self.editPanelKind, self.editPanelId = false, nil, nil; if self.editOverlay ~= nil then self.editOverlay:setVisible(false) end; self:clearPanelRows().
    • switchTab: if self.editPanelOpen then self:closeEditPanel() end at the top.
    • Guide rendering while panel is open: fillFlowTabRows/fillFieldRows start with if self:hasGuideOn(TAB) and self:fillGuideRows(adEnv) then return end — guard with not self.editPanelOpen and so the guide draws only in the panel.
  • Step 5: l10n — add spu_panel_close ("Luk"/"Close"), spu_panel_gone ("Posten findes ikke længere — vinduet er lukket"/"That item no longer exists — window closed"), spu_button_delete if not present (check grep spu_button_delete l10n/l10n_da.xml first; reuse existing delete text key if it exists).
  • Step 6: Run all tests (noStrayGlobals must pass) and validate XML: python3 -c "import xml.dom.minidom as m; [m.parse(f) for f in ('FS25_ADSmartPickup/gui/SmartPickupFrame.xml','FS25_ADSmartPickup/gui/guiProfiles.xml','FS25_ADSmartPickup/l10n/l10n_da.xml','FS25_ADSmartPickup/l10n/l10n_en.xml')]".
  • Step 7: Commit git commit -am "feat(gui): redigeringsvindue (overlay) med Luk/ESC, Start/Stop, Slet"

Task 4: Flows-fanen som farvet liste + "flow" i vinduet

Files:

  • Modify: FS25_ADSmartPickup/gui/SmartPickupFrame.xml (ny flowListPrefab, fjernes ved initialize)
  • Modify: FS25_ADSmartPickup/gui/SmartPickupFrame.lua (initialize-listen: "flowListPrefab"; onClickStartStop husker afvisning)
  • Modify: FS25_ADSmartPickup/gui/SmartPickupFlowGuide.lua (fillFlowTabRows, refreshFlowTab, initFlowButtons, registrér EDIT_PANEL_KINDS.flow)
  • Modify: l10n/*.xml

Interfaces:

  • Consumes: ADFlowView.flowStatus, ADFlowView.LEVEL_COLORS (Task 1); openEditPanel, EDIT_PANEL_KINDS (Task 3); makeRowClickable (Task 2).

  • Produces: SmartPickupFrame:flowLevel(adEnv, run) -> level, self.lastStartFailed[runId] = bool, SmartPickupFrame:toggleRun(adEnv, runId).

  • Step 1: Prefab — in the XML after flowPrefab:

        <!-- Flows-listen (v1.58): ● · navn · varer · kører/i alt · status · [Start]/[Stop]. ~835 px. -->
        <Bitmap profile="fs25_multiTextOptionContainer" id="flowListPrefab">
            <Bitmap profile="spuDot" name="dot" position="20px 0px"/>
            <Text profile="spuCellBold" name="c1" position="38px 0px" size="200px 40px" textMaxWidth="192px"/>
            <Text profile="spuCell" name="c2" position="238px 0px" size="170px 40px" textMaxWidth="162px"/>
            <Text profile="spuCell" name="c3" position="408px 0px" size="70px 40px" textMaxWidth="62px"/>
            <Text profile="spuCell" name="c4" position="478px 0px" size="252px 40px" textMaxWidth="244px"/>
            <Text profile="spuCellBold" name="c5" position="730px 0px" size="100px 40px" textMaxWidth="92px"/>
        </Bitmap>
  • Step 2: Toggle + afvisning — in SmartPickupFlowGuide.lua add:
-- Start/Stop for ét flow (rækkens knap og vinduets knap). Husker om sidste start blev helt afvist (rød).
function SmartPickupFrame:toggleRun(adEnv, runId)
    local run = ADRuns.getRun(ADRunsController.store, runId)
    if adEnv == nil or run == nil then return end
    self.lastStartFailed = self.lastStartFailed or {}
    if ADRunsController.getRunStatus(adEnv, run).canStop then
        ADRunsController.stopRun(adEnv, run.id)
        self.lastStartFailed[run.id] = nil
        self.statusOverride = nil
    else
        local started, skipped, reasons = self:summarizeStartResults(ADRunsController.startRun(adEnv, run.id))
        self.lastStartFailed[run.id] = started == 0 and skipped > 0
        local text = string.format(self.i18n:getText("spu_status_started"), started, skipped)
        self:showOverride(text, reasons)
        if skipped > 0 and ADFieldJobs ~= nil and ADFieldJobs.notify ~= nil then
            ADFieldJobs.notify(string.format("%s: %s — %s", tostring(run.name), text, table.concat(reasons, "; ")), started == 0)
        end
    end
    ADRunsController.save()
end

function SmartPickupFrame:flowLevel(adEnv, run)
    local row = ADRunsController.getFlowRow(adEnv, run)
    local status = ADRunsController.getRunStatus(adEnv, run)
    return ADFlowView.flowStatus({running = row.running, total = row.total, stopped = run.stopped == true,
        canStop = status.canStop == true, warning = row.warning, reason = row.reason,
        lastStartFailed = (self.lastStartFailed or {})[run.id] == true})
end

And make onClickStartStop (Frame.lua) call frame:toggleRun(adEnv, run.id) instead of its inline start/stop body (keep ADRunsController.save() + requestRebuild() there).

  • Step 3: List — replace fillFlowTabRows body after the guide guard:
function SmartPickupFrame:fillFlowTabRows(adEnv)
    if not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FLOWS) and self:fillGuideRows(adEnv) then return end
    self.flowListRows = {}
    local i18n = self.i18n
    if #ADRunsController.store.runs == 0 then
        self:addTextRow(i18n:getText("spu_noRuns"))
        return
    end
    self:addTextRow(i18n:getText("spu_flowList_hint"))
    for _, group in ipairs(ADFlowView.boardGroups(ADRunsController.store.runs)) do
        self:addTextRow(i18n:getText(group.kind == "outbound" and "spu_board_outbound" or "spu_board_supply"))
        for _, run in ipairs(group.runs) do
            local row = self:addRow(self.flowListPrefab, {kind = "flowList"})
            local entry = {runId = run.id, row = row, dot = row:getDescendantByName("dot"),
                cells = {row:getDescendantByName("c1"), row:getDescendantByName("c2"), row:getDescendantByName("c3"),
                    row:getDescendantByName("c4"), row:getDescendantByName("c5")}}
            local runId = run.id
            local actionCell = entry.cells[5]
            self:makeRowClickable(row, function(frame)
                -- klik på [Start]/[Stop]-feltet = handling; ellers åbn vinduet
                if frame.lastClickX ~= nil and actionCell.absPosition ~= nil and frame.lastClickX >= actionCell.absPosition[1] then
                    frame:toggleRun(frame.getAdEnv(), runId)
                    frame:requestRebuild()
                else
                    frame.selectedRunId = runId
                    frame:openEditPanel("flow", runId)
                end
            end)
            table.insert(self.flowListRows, entry)
        end
    end
    self:refreshFlowListRows(adEnv)
end

function SmartPickupFrame:refreshFlowListRows(adEnv)
    local unpackFn = table.unpack or unpack
    for _, entry in ipairs(self.flowListRows or {}) do
        local run = ADRuns.getRun(ADRunsController.store, entry.runId)
        if run ~= nil then
            local cells = self:flowCells(adEnv, run)
            local level = self:flowLevel(adEnv, run)
            local color = ADFlowView.LEVEL_COLORS[level]
            local canStop = ADRunsController.getRunStatus(adEnv, run).canStop
            entry.cells[1]:setText(tostring(run.name))
            entry.cells[2]:setText(tostring(cells[2]))
            entry.cells[3]:setText(tostring(cells[5]))
            entry.cells[4]:setText(tostring(cells[6]))
            entry.cells[5]:setText(self.i18n:getText(canStop and "spu_flowList_stop" or "spu_flowList_start"))
            if entry.dot ~= nil and entry.dot.setImageColor ~= nil then entry.dot:setImageColor(nil, unpackFn(color)) end
            if entry.cells[4].setTextColor ~= nil then entry.cells[4]:setTextColor(unpackFn(color)) end
        end
    end
end

In makeRowClickable (Task 2's function) store the click x before setting pendingClick: frame.lastClickX = posX.

  • Step 4: refreshFlowTab — replace body:
function SmartPickupFrame:refreshFlowTab(adEnv)
    if not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FLOWS) then
        self:setStatusText(self.statusOverride or "")
        return
    end
    self:refreshFlowListRows(adEnv)
    local running, total = 0, 0
    for _, run in ipairs(ADRunsController.store.runs) do
        total = total + 1
        if ADRunsController.getRunStatus(adEnv, run).running > 0 then running = running + 1 end
    end
    self:setStatusText(self.statusOverride or string.format(self.i18n:getText("spu_flowList_status"), running, total))
end
  • Step 5: Buttons + panel kind — in initFlowButtons: self.flowButtons = {self.backButtonInfo, self.flowNewButtonInfo}. Register:
SmartPickupFrame.EDIT_PANEL_KINDS.flow = {
    canDelete = true,
    exists = function(frame, id) return ADRuns.getRun(ADRunsController.store, id) ~= nil end,
    title = function(frame, id)
        local run = ADRuns.getRun(ADRunsController.store, id)
        return string.format(frame.i18n:getText("spu_panel_flowTitle"), tostring(run ~= nil and run.name or "?"))
    end,
    fill = function(frame, adEnv, id)
        local run = ADRuns.getRun(ADRunsController.store, id)
        if run ~= nil then frame:fillFlowSummary(adEnv, run) end
    end,
    status = function(frame, adEnv, id)
        local run = ADRuns.getRun(ADRunsController.store, id)
        return {text = tostring(frame:flowCells(adEnv, run)[6]), level = frame:flowLevel(adEnv, run)}
    end,
    startStopText = function(frame, adEnv, id)
        local run = ADRuns.getRun(ADRunsController.store, id)
        return frame.i18n:getText(ADRunsController.getRunStatus(adEnv, run).canStop and "spu_button_stop" or "spu_button_start")
    end,
    startStop = function(frame, id) frame:toggleRun(frame.getAdEnv(), id) end,
    delete = function(frame, id) frame.selectedRunId = id; frame:onClickDelete() end,
}

In fillFlowSummary drop the first heading row (the panel title shows the name): remove the spu_guide_flow_section line. Add "Opsæt flow" as a last link row in the summary: self:addLinkRow(i18n:getText("spu_guide_flow_open"), function(frame) frame.selectedRunId = runId; frame:onClickFlowSetup() end). After a delete (onClickDelete's YesNo callback path) the run no longer exists → refreshEditPanel closes the panel (Review Focus).

  • Step 6: l10n — spu_flowList_hint ("Klik på et flow for at rette det. [Start]/[Stop] styrer flowet direkte." / en), spu_flowList_start ("[Start]"), spu_flowList_stop ("[Stop]"), spu_flowList_status ("Flows i gang: %d af %d" / "Flows running: %d of %d"), spu_panel_flowTitle ("Rediger flow: %s" / "Edit flow: %s").
  • Step 7: Run all tests + XML check.
  • Step 8: Commit git commit -am "feat(flows): Flows-fanen som farvet liste med Start/Stop pr. række; klik åbner redigeringsvinduet"

Task 5: Etape 1 build + in-game test

  • Step 1: bump modDesc.xml <version> to 1.58.0.0; build zip; copy to ~/FS25-data/mods/; unzip -p ~/FS25-data/mods/FS25_ADSmartPickup.zip modDesc.xml | grep -o '<version>[^<]*' shows 1.58.0.0.
  • Step 2: commit + push gitea main.
  • Step 3: In-game checklist (brugeren): liste med prikker/farver; [Start]/[Stop] på en række virker og rører ikke andre flows; klik på navnet åbner vinduet; [Edit] → Gem inde i vinduet; Luk og ESC lukker vinduet (ESC med åbent trin lukker trinnet først); klik på listen bagved gør intet; Slet flow fra vinduet lukker det; log uden GUI-fejl. Ret layout-afvigelser (størrelser/placering) efter screenshot før Task 6.

Task 6: Ren statusfunktion for marker

Files:

  • Modify: FS25_ADSmartPickup/adFieldView.lua
  • Test: tests/test_fieldStatus.lua (ny)

Interfaces:

  • Produces: ADFieldView.fieldStatus(s) -> level hvor s = {hasMarker=bool, working=bool, awaiting=bool, startRequested=bool, reasonKey=string|nil}; ADFieldView.ERROR_REASONS (sæt af reason-nøgler).

  • Step 1: Test — tests/test_fieldStatus.lua:

-- Kør: luajit tests/test_fieldStatus.lua   (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldView.lua")
local V = ADFieldView
local failures = 0
local function check(name, actual, expected)
    if actual == expected then print("OK   " .. name) else
        failures = failures + 1
        print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
    end
end
check("mangler markør", V.fieldStatus({hasMarker = false}), "error")
check("arbejder", V.fieldStatus({hasMarker = true, working = true}), "running")
check("arbejder men venter på maskine", V.fieldStatus({hasMarker = true, working = true, reasonKey = "spu_field_reason_allBusy"}), "waiting")
check("for bred", V.fieldStatus({hasMarker = true, working = true, reasonKey = "spu_field_reason_tooWide"}), "error")
check("ingen maskine", V.fieldStatus({hasMarker = true, reasonKey = "spu_field_reason_noRig"}), "error")
check("regn", V.fieldStatus({hasMarker = true, working = true, reasonKey = "spu_field_reason_rain"}), "waiting")
check("venter på spilleren (høster)", V.fieldStatus({hasMarker = true, awaiting = true}), "waiting")
check("start bestilt", V.fieldStatus({hasMarker = true, startRequested = true}), "waiting")
check("i ro", V.fieldStatus({hasMarker = true}), "stopped")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)

(If adFieldView.lua needs other modules loaded, add the same dofiles as tests/test_adFieldView.lua uses.)

  • Step 2: Run — FAIL (fieldStatus nil).
  • Step 3: Implement — append to adFieldView.lua:
-- ---- status pr. mark (Fields-listen) ----

-- Årsager der betyder "kan ikke" (rød) — resten er "venter" (gul).
ADFieldView.ERROR_REASONS = {
    spu_field_reason_noRig = true, spu_field_reason_cannotDo = true, spu_field_reason_tooWide = true,
    spu_field_reason_tooNarrow = true, spu_field_reason_widthUnknown = true, spu_field_reason_noSowFruit = true,
}

-- s = {hasMarker, working, awaiting, startRequested, reasonKey} -> "running"|"waiting"|"error"|"stopped"
function ADFieldView.fieldStatus(s)
    if not s.hasMarker then return "error" end
    if s.reasonKey ~= nil and ADFieldView.ERROR_REASONS[s.reasonKey] then return "error" end
    if s.awaiting or s.startRequested then return "waiting" end
    if s.working then return s.reasonKey ~= nil and "waiting" or "running" end
    if s.reasonKey ~= nil then return "waiting" end
    return "stopped"
end
  • Step 4: Run all tests — green.
  • Step 5: Commit git commit -am "feat(fields): ren statusfunktion til Fields-listen"

Task 7: Fields-fanen som farvet liste + "field" i vinduet

Files:

  • Modify: FS25_ADSmartPickup/gui/SmartPickupFrame.xml (fieldPrefab: dot forrest, c7 = handling; kolonner forskydes)
  • Modify: FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua (fieldCells, fillFieldRows, refreshFieldRows, onClickFieldStartStop tager id, register EDIT_PANEL_KINDS.field)
  • Modify: FS25_ADSmartPickup/gui/SmartPickupFieldGuide.lua (getFieldButtons uden Start/Stop når listen vises)
  • Modify: l10n/*.xml

Interfaces:

  • Consumes: ADFieldView.fieldStatus (Task 6); openEditPanel/EDIT_PANEL_KINDS (Task 3); makeRowClickable + frame.lastClickX (Task 2/4); ADFlowView.LEVEL_COLORS (Task 1).

  • Produces: SmartPickupFrame:fieldLevel(id) -> level; SmartPickupFrame:toggleField(id).

  • Step 1: Prefab — fieldPrefab becomes (≈835 px):

        <Bitmap profile="fs25_multiTextOptionContainer" id="fieldPrefab">
            <Bitmap profile="spuDot" name="dot" position="20px 0px"/>
            <Text profile="spuCellBold" name="c1" position="38px 0px" size="82px 40px" textMaxWidth="76px"/>
            <Text profile="spuCell" name="c2" position="120px 0px" size="120px 40px" textMaxWidth="112px"/>
            <Text profile="spuCell" name="c3" position="240px 0px" size="100px 40px" textMaxWidth="92px"/>
            <Text profile="spuCell" name="c4" position="340px 0px" size="70px 40px" textMaxWidth="62px"/>
            <Text profile="spuCell" name="c5" position="410px 0px" size="200px 40px" textMaxWidth="192px"/>
            <Text profile="spuCell" name="c6" position="610px 0px" size="120px 40px" textMaxWidth="112px"/>
            <Text profile="spuCellBold" name="c7" position="730px 0px" size="100px 40px" textMaxWidth="92px"/>
        </Bitmap>

The header row (column titles) passes a 7th title "".

  • Step 2: Toggle + level — refactor onClickFieldStartStop into toggleField(id) (same body, id parameter instead of frame.selectedFieldId); onClickFieldStartStop calls self:toggleField(self.selectedFieldId). Add:
function SmartPickupFrame:fieldLevel(id)
    local field, info = ADFieldJobs.getField(id), ADFields.getInfo(id)
    if field == nil or info == nil then return "stopped" end
    local reason = type(field.reason) == "table" and field.reason.key or nil
    return ADFieldView.fieldStatus({hasMarker = info.markerId ~= nil, working = ADFieldModel.isWorking(field),
        awaiting = ADFieldJobs.awaitingPlayer(id) ~= nil, startRequested = ADFieldJobs.isStartRequested ~= nil and ADFieldJobs.isStartRequested(id),
        reasonKey = reason})
end

function SmartPickupFrame:fieldActionText(id)
    local field = ADFieldJobs.getField(id)
    if ADFieldJobs.awaitingPlayer(id) ~= nil then return self.i18n:getText("spu_field_button_takeover") end
    return self.i18n:getText(field ~= nil and ADFieldModel.isWorking(field) and "spu_flowList_stop" or "spu_flowList_start")
end

(If ADFieldJobs.isStartRequested does not exist, leave the guarded call — it evaluates to false.)

  • Step 3: List rows — in fillFieldRows: guide guard gets not self.editPanelOpen and; fieldCells appends self:fieldActionText(info.id) as 7th cell and no longer prefixes "> "; row click:
                self:makeRowClickable(cells[1].parent, function(frame)
                    if frame.lastClickX ~= nil and cells[7] ~= nil and cells[7].absPosition ~= nil and frame.lastClickX >= cells[7].absPosition[1] then
                        frame:toggleField(fieldId)
                    else
                        frame.selectedFieldId = fieldId
                        frame:openEditPanel("field", fieldId)
                    end
                end)

Remove the trailing fillFieldOverview call from fillFieldRows (it now lives in the panel). In refreshFieldRows after setCells color the dot and c5 by self:fieldLevel(entry.id) (same code as refreshFlowListRows; entry.cells[1].parent:getDescendantByName("dot")).

  • Step 4: Panel kind —
SmartPickupFrame.EDIT_PANEL_KINDS.field = {
    canDelete = false,
    exists = function(frame, id) return ADFields.getInfo(id) ~= nil end,
    title = function(frame, id) return string.format(frame.i18n:getText("spu_panel_fieldTitle"), id) end,
    fill = function(frame, adEnv, id) frame:fillFieldOverview(adEnv, ADFields.getInfo(id)) end,
    status = function(frame, adEnv, id)
        return {text = frame:fieldStatusText(ADFieldJobs.getField(id), ADFields.getInfo(id), false), level = frame:fieldLevel(id)}
    end,
    startStopText = function(frame, adEnv, id) return frame:fieldActionText(id) end,
    startStop = function(frame, id) frame:toggleField(id) end,
    delete = function() end,
}

fillFieldOverview's own heading row (spu_field_section) is dropped when drawn in the panel: guard it with if not self.editPanelOpen then ... end. getFieldButtons: list view keeps Back, Opsæt mark (needs selected field → move "Opsæt mark" into the panel as a link row like flows), Flåde; Start/Stop leaves the bottom bar.

  • Step 5: l10n — spu_panel_fieldTitle ("Mark F%d" / "Field F%d").
  • Step 6: Run all tests + XML check.
  • Step 7: Commit git commit -am "feat(fields): Fields-fanen som farvet liste med Start/Stop pr. række; klik åbner redigeringsvinduet"

Task 8: Etape 2 build + in-game test

  • Step 1: bump to 1.59.0.0; build; deploy; verify version in zip; commit + push.
  • Step 2: In-game checklist: Fields-liste med prikker/farver; [Start]/[Stop]/[Overtag] pr. række; klik åbner vinduet med markens linjer; [Ret] → Gem i vinduet; Flåde-visning uændret; Luk/ESC.
  • Step 3: Update memory project_fs25_adsmartpickup.md with the new GUI structure (EditPanel mixin, rowTarget) and the Obsidian note projekter/FS25 silo-patches + AD Smart Pickup.md with a line about v1.58/1.59.