automap.panel

The live party, drawn beside the map.

Gold Box Companion’s HUD is the model and it is the right one: one card per character carrying everything you glance at mid-fight, so that “who is hurt” is answered by looking rather than by reading.

Bars, not just numbers. 5 / 7 has to be read; a bar a third empty does not, and mid-fight that is the whole difference. The numbers stay beside the bar for when the exact value matters.

Everything here is presentation. The decoding is automap/live.py, which has no Qt in it and is tested against captured bytes.

ColumnSplitter is here rather than in automap/window.py because the width a card is drawn to is one of the widths it divides, and RosterPanel reads it. It is the tab’s three columns and not a panel, which is the one thing in this file that is not.

Module Attributes

log

A child of the wish logger, so wish/debuglog.py's handler takes these when the log is on and the level swallows them when it is off -- without automap importing wish.

Functions

child(root, kind, name)

The widget name in the form, or None and a line saying which.

hp_colour(fraction)

shortened(hint, floor)

hint with its height capped at floor.

Classes

Bar

A proportion, with its numbers written across it.

BottomStrip

Where, when, which area, and what is on the party.

CardClassLabel

The classes and level on a roster card: the second thing to give way.

CardNameLabel

The character name on a roster card: the one thing on the top row that gives way.

CharacterCard

One character: name, class and level, AC and THAC0, bars, what is in hand, what is on them, and whether they are on quickfight.

ColumnSplitter

The automapper's three columns, and the widths a user drags them to.

ElidingButton

A push button that gives way instead of holding the window open.

ElidingCheckBox

The same, for a checkbox whose label is a sentence.

ElidingComboBox

A dropdown whose floor is not the length of the area it is showing.

ElidingLabel

A read-out that shortens rather than setting a floor under the window.

IconRow

A few icons in a line, painted from automap.icons.

MessagesPanel

What the tab has done, and what it is waiting for.

NotesPanel

Every note in this area, with its square.

ReadiedLabel

The card's line of what is in hand: bounded in both axes.

RosterPanel

The cards, down the left.

class automap.panel.Bar[source]

Bases: PyQt6.QtWidgets.QWidget

A proportion, with its numbers written across it.

Painted rather than assembled from a QProgressBar because the colour carries meaning here – a hit point bar changes colour as it empties – and a styled progress bar fights the platform theme over exactly that.

__init__(parent=None)[source]
paintEvent(_event)[source]
set(fraction, text, colour)[source]
Parameters:
  • fraction (float)

  • text (str)

  • colour (PyQt6.QtGui.QColor)

Return type:

None

class automap.panel.BottomStrip[source]

Bases: PyQt6.QtCore.QObject

Where, when, which area, and what is on the party.

SHORT = 21

The tallest this strip may hold the window open, whatever the UI font.

__init__(root, parent=None)[source]
Parameters:
  • root (QWidget)

  • parent (QObject | None)

effects

One icon row for the whole roster, above the square and the area name. It was a QLabel writing out Bless   Prayer   Protection from Evil, 10' Radius, and the width of that is what made it unaffordable; Donald settled the shape on #142 (The party effects line is computed every poll and shown nowhere): “I think we should have ONE line for the entire roster with party effects… Icons will take up less space than text-only names of the spells. We can put the name of the spell in the tooltip of the icon.”

show_effects(snap)[source]

One icon per spell the whole party is under, named in the tooltip.

What counts as the whole party is `Snapshot.whole_party_effects`, and it is not only the $FF owner byte the decode named: Bless cast from the adventure menu writes one row per character and no $FF row at all, so a rule that read only the owner byte drew nothing for the one party spell anybody has watched land.

Nothing is drawn when nothing is running. The old shape was one line per character, blank on most of them, and eight blank lines is what got it removed; a strip that says “party effects: none” five times a second is the same mistake with one line instead of eight.

Nothing about the monsters, either. The row briefly counted them – “2 effects on monsters” under the party’s own spells – and Donald cut it on 2026-09-01: hovering the party’s row to be told a number about the orcs answers a question nobody asked, and it says neither which monster nor which spell. It was also unreachable in the one case that might have been interesting, because a party with nothing running draws no icons and a row no icons wide cannot be hovered at all. The combat view is where a monster’s effects will mean something.

A party effect no badge covers is drawn nowhere, and that is worth saying out loud rather than letting it look like a party with nothing running. automap/live.py’s badge set is graded from the spell table – no save this project holds carries a party-wide effect at all – so an id turning up here means the set is a glyph short, and it goes to the debug log once per id for whoever comes to choose one.

Return type:

None

show_state(state, snap=None)[source]

The map’s own state answers “where”; the snapshot answers the rest.

The square and facing come from AutomapState on purpose: it prefers the game’s own status line, which is right the moment the screen settles, where the memory copy at $49C0 lags a move.

Return type:

None

class automap.panel.CardClassLabel[source]

Bases: automap.panel.ElidingLabel

The classes and level on a roster card: the second thing to give way.

CardNameLabel yields first and yields everything, which is enough on a machine whose fonts are this one’s. It was not enough on Windows: CI reported the Level up button drawn 99 of 102px inside a 220px column with the name already down to LAD..., because the classes and the button alone are wider than the column there. A plain QLabel cannot give way, so the button was the thing that got cut – which is the whole of #168, reappearing on a platform the fix was not measured on.

So the order is: the name goes to nothing, then the classes shorten, and the button is never touched. That is Donald’s priority read down – MU/C is worse than a shortened name and better than a control cut in half, and the button is the one thing on the row a player has to be able to hit.

SQUEEZED = 0 for the same reason as the name’s: any floor measured here is a floor that cuts the button on a machine with a wider font.

SQUEEZED = 0
class automap.panel.CardNameLabel[source]

Bases: automap.panel.ElidingLabel

The character name on a roster card: the one thing on the top row that gives way.

The row holds the name, the classes and level, and the Level up button when the character has earned one, inside a column capped at 220px. The classes and the level are the character; the button is a control, and a control cut in half reads Lev (#168). So the name yields all of the width, down to nothing if that is what it takes, and says it has by drawing an ellipsis.

Donald’s reasoning, and it is what makes a shortened name the cheap side of the trade: “The level up button will never be there for very long. It will be clicked as soon as it appears. So, cutting off the name for a little while is fine.” A name is also the one thing on the row still recognisable from its first few letters – LADY KATH... is unmistakably LADY KATHERINE, where MU/C for MU/C/T  L8 is not.

SQUEEZED = 0 rather than a number measured here: the classes label and the button are both set in points, so how many pixels they take is the machine’s business, and any floor big enough on this one is a floor that cuts the button on a machine with a wider font.

SQUEEZED = 0

The widest this name may hold the row open. Zero, deliberately.

class automap.panel.CharacterCard[source]

Bases: PyQt6.QtCore.QObject

One character: name, class and level, AC and THAC0, bars, what is in hand, what is on them, and whether they are on quickfight.

The Level Up button lives here and nowhere else. It sits at the right end of the class-and-level line, and it is hidden – not disabled – unless that character has the experience for another level. A button that is there only when it can be used needs no label saying which character it means: the card is the answer.

And it is hidden for a title whose trainer nobody has measured, which is every title but Pool of Radiance – levelling, set from the window. The refusal is automap.actions.level_up_blockers and it is enforced at the write as well; hiding the button is so that the feature is not offered and then withdrawn (#16).

__init__(root, index, parent=None)[source]
Parameters:
  • root (QWidget)

  • index (int)

  • parent (QObject | None)

classmethod chosen_class(who)[source]

Which class the button will raise. For the tooltip only – the window asks the record the same question before it writes.

Return type:

str | None

level_up_requested

The slot, and nothing else. The player is not asked which class. A multi-class character with two ready gets the one whose threshold after the level is highest, which keeps the trainer’s experience clamp as high as it goes and so usually leaves the other class still qualified; pressing again takes that one. goldbox.levelup.best_next_class is the rule and docs/135-levelling.md is why.

alias of int

levelling

Whether levelling is possible in this title at all. True until the window says otherwise, because a card built without one is Pool of Radiance’s – the same default every other per-title reader takes.

readied_items: tuple[str, ...]

What is in hand, under the bars. Readied only – the whole inventory would swamp the card – and a blank line for a character carrying nothing readied: the absence is the information, and the word “none” is not. The line stays either way, so the cards below do not shift when a sword is put away.

ready: tuple[str, ...]
static ready_to_level(who)[source]

Which of this character’s classes have the experience for a level.

Every class is measured against the whole stored number. The trainer does not divide experience between a multi-class character’s classes – LADY KATHERINE, magic-user 1 / thief 7 with 70,100 points, was offered thief 8, whose threshold is 70,001 – so this asks each class the same question the school does.

Return type:

tuple[str, …]

show_character(who)[source]
Return type:

None

show_readied(items)[source]

One line of what is in hand, shortened to the room the card has.

The label holds the whole string and ReadiedLabel draws as much of it as fits, so nothing here measures a font: the elide is the painter’s and the card’s floor is a constant. The full list stays readable because the label sets no tooltip of its own and so answers with the frame’s, which already carries it.

Return type:

None

class automap.panel.ColumnSplitter[source]

Bases: PyQt6.QtCore.QObject

The automapper’s three columns, and the widths a user drags them to.

The roster, the map and the reading column used to be three cells of a grid, two of them capped at a width chosen once. #162 made the two dividers draggable and the widths remembered, and Donald settled both of the questions that came with it:

  • “a dragged width on a column should be remembered when Wish opens again”Settings.automap_columns, three numbers in the JSON;

  • “Sure, let the user drag it down to nothing. As long as they can drag it back out when they do that.”

The second is the whole of the difficulty, because a column dragged shut has no width left to grab: what the user aims at afterwards is the divider, and if that went with the column the panel would be gone for good and the only way back would be editing the settings file by hand. Two things keep it reachable, and both are tested in tests/test_columns.py against a window built from a settings file that already holds a zero:

  • the handle is HANDLE wide rather than the style’s, and Qt keeps drawing it at the edge of a collapsed pane – measured at QRect(0, 0, 6, h) with the roster at zero;

  • the width is restored with QSplitter.setSizes, which honours a zero without hiding anything, so the divider is in the window on the first frame of a fresh start.

A column with a floor – the roster’s is the width of a card, the reading column’s is AutomapBinding.SIDE_SQUEEZED – is therefore either wider than its floor or shut, with nothing in between. That is Qt’s own collapsing and it is the behaviour Donald asked for: dragging inwards past half the floor shuts the column, and dragging outwards opens it at the floor again.

COLUMNS = 3
HANDLE = 6

How wide the divider is. Qt’s style answers 4 here, which is enough while there is a column beside it to aim at – but a column dragged shut leaves the divider as the only thing left to grab, and it is then the whole of the way back. Set rather than inherited for that one case.

MAP_ASKS = 1

The map asks for nothing of its own at startup, because the window has not been shown yet and there is no width to divide. The stretch factors below hand it everything the two side columns do not want, on the first frame and on every resize after.

MAP_AT = 1

Left to right, and the order the widths are written to the settings file in.

ROSTER = 220

The roster’s width before anybody drags it, and the width a card is drawn to. Unchanged: Donald ruled on #162 that a multiclass character whose name will not fit is a corner case, that the default stays where it is, and that dragging is the answer for whoever meets it (#168).

ROSTER_AT = 0

Left to right, and the order the widths are written to the settings file in.

SIDE = 460

And the reading column’s. The panels hold short rows; past this they are mostly paper.

SIDE_AT = 2

Left to right, and the order the widths are written to the settings file in.

__init__(root, settings, parent=None)[source]
Parameters:
  • root (QWidget)

  • parent (QObject | None)

defaults()[source]

What the columns open at with nothing remembered.

Return type:

list[int]

restore()[source]

Open at the remembered widths, or at the defaults.

Called before the window is shown, which is deliberate: setSizes records what each column asked for and the splitter divides the real width against those the moment there is one, so the first frame the user sees is already the right shape rather than the default shape corrected afterwards.

Return type:

None

widths()[source]

What the three columns are, left to right, right now.

Return type:

list[int]

class automap.panel.ElidingButton[source]

Bases: PyQt6.QtWidgets.QPushButton

A push button that gives way instead of holding the window open.

SQUEEZED = 64

A word and an ellipsis. Below this a button is a smear, and the row of them says nothing at all.

__init__(*args, **kwargs)[source]
minimumSizeHint()[source]
Return type:

PyQt6.QtCore.QSize

paintEvent(_event)[source]
class automap.panel.ElidingCheckBox[source]

Bases: PyQt6.QtWidgets.QCheckBox

The same, for a checkbox whose label is a sentence.

SQUEEZED = 96

Wider than a button’s floor because the box itself eats the first 20.

__init__(*args, **kwargs)[source]
minimumSizeHint()[source]
Return type:

PyQt6.QtCore.QSize

paintEvent(_event)[source]
class automap.panel.ElidingComboBox[source]

Bases: PyQt6.QtWidgets.QComboBox

A dropdown whose floor is not the length of the area it is showing.

SQUEEZED = 110

Enough for a short area name. The whole row – maps and disk – is the item’s tooltip whatever width the box is.

__init__(*args, **kwargs)[source]
minimumSizeHint()[source]
Return type:

PyQt6.QtCore.QSize

paintEvent(_event)[source]
class automap.panel.ElidingLabel[source]

Bases: PyQt6.QtWidgets.QLabel

A read-out that shortens rather than setting a floor under the window.

The text a label is holding changes while the program runs – the bottom strip’s party effects line grows as spells land on the party – so a label that sets the window’s minimum width moves that minimum under the player.

SQUEEZED = 44
elided_text()[source]

What this label will actually draw in the room it has.

Split out of paintEvent so a test can ask for it: text() is the whole string whatever the width, and the shortening is otherwise visible only in pixels.

Return type:

str

minimumSizeHint()[source]
Return type:

PyQt6.QtCore.QSize

paintEvent(event)[source]
class automap.panel.IconRow[source]

Bases: PyQt6.QtWidgets.QWidget

A few icons in a line, painted from automap.icons.

Painted rather than assembled from QLabel pixmaps so the row costs one widget however many icons it holds, and so a card whose conditions change does not rebuild its layout.

__init__(size=ICON_SIZE, colour=MUTED, parent=None)[source]
Parameters:
  • size (int)

  • colour (PyQt6.QtGui.QColor)

minimumSizeHint()[source]
Return type:

PyQt6.QtCore.QSize

names: tuple[str, ...]
paintEvent(_event)[source]
set_icons(names)[source]
Return type:

None

sizeHint()[source]
Return type:

PyQt6.QtCore.QSize

class automap.panel.MessagesPanel[source]

Bases: PyQt6.QtCore.QObject

What the tab has done, and what it is waiting for.

Not a pop-up. An action’s result is something you asked for; putting it behind a modal box interrupts the game in the other window to tell you what you already wanted to know, and has to be dismissed before the map is usable again. It belongs on the page. Only a genuinely irreversible action still asks first, and that question is a dialog because it needs an answer.

The connection’s own state feeds the same panel, so “something else is attached to the emulator” is one more line here rather than a second mechanism in the status bar.

LIMIT = 200

Kept lines. Long enough for a session’s worth of actions, short enough that the panel never becomes the reason the window is slow.

__init__(root, parent=None)[source]
Parameters:
  • root (QWidget)

  • parent (QObject | None)

lines()[source]

Every line, oldest first. What a test reads.

Return type:

list[str]

say(text, detail='', alarm=False, dedup=True)[source]

One line, timestamped. Repeats of the last line are dropped.

The connection says the same thing on every tick while it waits, and a panel that wrote “waiting for the game” five times a second would bury the line you wanted.

The combat log passes `dedup=False`, because there the repeat is the point: “MAGNUS MISSES.” twice running is two misses, and swallowing the second would defeat the feature that exists to catch them.

Parameters:
Return type:

None

class automap.panel.NotesPanel[source]

Bases: PyQt6.QtCore.QObject

Every note in this area, with its square.

This is what makes notes useful for finding something again, which the icons on the map alone do not solve: the map answers “what is here”, the list answers “where was that trainer”.

Clicking a row emits chosen; the window flashes the square. The list is rebuilt only when the notes actually change, so a poll does not throw away the row you were about to click.

__init__(root, parent=None)[source]
Parameters:
  • root (QWidget)

  • parent (QObject | None)

chosen

alias of int

show_notes(notes)[source]
Return type:

None

class automap.panel.ReadiedLabel[source]

Bases: automap.panel.ElidingLabel

The card’s line of what is in hand: bounded in both axes.

Width is the first of the two. The item names are read off the player’s disk, and a card whose floor was the width of BANDED MAIL +1, SHIELD +2, LONG SWORD +3 would put that string under the whole window (#41). The floor is zero rather than ElidingLabel’s 44 because the condition badges share this row and are drawn at a fixed width: this line takes whatever they leave, however little that is. Donald settled the order – “I would rather see active effects than readied items. That is a fine trade-off as far as space goes.” (#161)

Height is the reason for the subclass. There are eight of these, one per party slot, in a column that does not scroll, so a line that insisted on its own height would add eight of them to the window’s floor – and the roster is already the tallest thing on the automapper page with a full party. SHORT = 0 says the line gives way first: it is the least important row on the card, and it is only ever squeezed by a window already pushed to its minimum. Anywhere above that floor the line is drawn in full.

SHORT = 0

The tallest this line may hold the window open. Zero, deliberately.

SQUEEZED = 0

the badges beside it are drawn whole and this takes the remainder.

Type:

The widest this line may hold the row open. Zero

minimumSizeHint()[source]
Return type:

PyQt6.QtCore.QSize

class automap.panel.RosterPanel[source]

Bases: PyQt6.QtCore.QObject

The cards, down the left. Manages the 8 pre-created cards in the unified form.

level_up_requested carries the slot up from whichever card was clicked, and nothing else: which class gets the level is decided from the record. The panel does not run the action itself – it has no target and no confirmation dialog, and both belong to the window.

__init__(root, parent=None)[source]
Parameters:
  • root (QWidget)

  • parent (QObject | None)

ask_for_room(showing)[source]

Ask the layout for the width a card needs, once there is one.

A QScrollArea reports a small minimum in both axes. Hiding the height is the whole point – eight cards in a column that could not scroll put a 944px floor under the window (#135) – but hiding the width is not: with the cards behind the scroll area the roster column collapsed to the width of its own heading, and a card was cut off somewhere in the middle of the name.

So the column asks for a card’s width, and only while it has something to show; an empty roster asks for nothing, which is what the cards themselves used to do by being hidden.

ColumnSplitter.ROSTER is where that width lives now. It was the column’s own maximumWidth in the form until #162 made the divider draggable, and a cap is the one thing a draggable column cannot have. What it means here is unchanged – a card is drawn to it – and it is a floor rather than a cap in both directions: the column can be dragged wider, and Qt’s own collapsing still shuts it altogether.

Parameters:

showing (int)

Return type:

None

cards: list[CharacterCard]
level_up_requested

alias of int

scroll

The column the cards scroll inside, and the column that holds it.

set_levelling(allowed)[source]

Whether this title can be levelled at all, and so whether the Level up button belongs on a card. Held for cards not built yet.

Parameters:

allowed (bool)

Return type:

None

set_message(text)[source]

No party to show. Says why rather than showing empty cards.

Parameters:

text (str)

Return type:

None

set_stale(stale)[source]

The last good snapshot, held while the game is in a menu or loading.

Saying so beats pretending: during a disk load these numbers are seconds old.

Parameters:

stale (bool)

Return type:

None

show_snapshot(snap)[source]
Return type:

None

automap.panel.child(root, kind, name)[source]

The widget name in the form, or None and a line saying which.

Every panel in this file is wired to wish/window.ui by objectName, and every one of them carries on without a widget it cannot find – which is right, because a panel has to survive being built against a smaller form, and wrong when nobody is told.

strip_effects is why this exists. It was deleted from the form in 72ee9a9 while the layout was being simplified, and for months afterwards BottomStrip worked out the party’s active effects five times a second and wrote them into None. Nothing errored and nothing was logged, so a player who cast Bless saw nothing and no report ever said why – #142 (The party effects line is computed every poll and shown nowhere).

The debug log and not a dialog: a missing widget is a fault in the build, not something the player did or can do anything about.

Parameters:
  • root (PyQt6.QtWidgets.QWidget)

  • name (str)

automap.panel.hp_colour(fraction)[source]
Parameters:

fraction (float)

Return type:

PyQt6.QtGui.QColor

automap.panel.log = <Logger wish.automap.panel (Level 51)>[source]

A child of the wish logger, so wish/debuglog.py’s handler takes these when the log is on and the level swallows them when it is off – without automap importing wish.

automap.panel.shortened(hint, floor)[source]

hint with its height capped at floor. Never heightens anything.

The height twin of _squeezed, and the same argument in the other axis: the window’s minimum height was the sum of the row heights its bars happened to want, so a larger UI font dragged the window down past a 720-high screen with nothing open at all – 662 at the base font here and 805 at ten points more (#77).

A row that is given less height than its font wants clips rather than eliding, so unlike the width caps this one is only ever reached by a window squeezed to its floor. Everything above the floor lays out exactly as it did.

Parameters:
  • hint (PyQt6.QtCore.QSize)

  • floor (int)

Return type:

PyQt6.QtCore.QSize