Release Notes#

Notable changes to bootstack, newest first. The project follows Semantic Versioning from 0.1.0 onward; the full file lives at CHANGELOG.md.

0.4.1 — Signal writes and clearing#

Changed#

  • A Signal seeded with nothing is now reported instead of built dead. A signal takes its type from the value you create it with, so bs.Signal(None) inferred the type of None itself and could never hold anything else: every later set() raised Expected NoneType, got str, at an arbitrary write, with a message naming neither the mistake nor the fix. It now raises where the mistake was made. This reaches map() too, which types the derived signal from the transform’s first result: a transform that returns None for the value it is first called with now raises there, instead of building the same dead signal and failing somewhere else later. Check this when you upgrade if you seed a signal from data that can be missing, or map() a transform that can return None. Pass allow_empty=True with the type named, as in bs.Signal(None, allow_empty=True, dtype=date), when the value can be empty; give the transform a value for every input, as in due.map(lambda d: d.strftime("%b %d") if d else ""), when it cannot. Signals seeded with a value, and map() over a transform that returns one, are unaffected. (#481)

Fixed#

  • TextArea.insert() and append() no longer write alongside the placeholder. On a TextArea built with placeholder= and still showing it — a log or output pane you also write to from code is the ordinary case — text written through either method was added to the placeholder instead of replacing it: the screen read Type something herewritten by code, value went on returning '', and on_input and on_change stopped firing. The silence was permanent rather than momentary, so every later edit the user made went unannounced too and value stayed empty for the rest of the field’s life. Both methods now drop the placeholder first, as assigning to value always did. A TextArea with no placeholder, or one whose placeholder is already gone, is unaffected, and so is CodeEditor, which has no placeholder to write onto. (#491)

  • A field’s value now follows a write to its bound Signal. Writing to a signal bound to a TextField, PasswordField, PathField or SpinnerField moved what the field displayed but not what value reported — the two disagreed about the same state, silently, until the user focused and left the field. value now follows a write your code made, including clear() on a signal declared allow_empty=True, while still reporting the last committed value while the user is typing, which is unchanged. A programmatic write made while that field currently has keyboard focus is indistinguishable from typing and still waits for the commit. TextArea, CodeEditor, NumberField, DateField, TimeField and Select already behaved this way and are untouched. Check this when you upgrade if you handle on_change on a field your code writes through a bound signal. The change event that used to arrive a focus cycle late now moves with value: it no longer fires when the user changes nothing, and it now fires when the user types the field back to the text it held before your write. Writing a field through field.value, which is how Form.set() writes, is unaffected, and so is every change the user makes. (#482)

  • TextArea and CodeEditor now honor a Signal.clear(). Clearing a signal bound to either widget left the old text on screen, so the signal read empty and the widget did not; the other seven field widgets that take a signal all honored it. Whether it worked depended on something unrelated to the widget — the same clear was honored or ignored according to what else in your application happened to be bound to that signal. Since 0.4.0 wired these two in both directions, the next edit to a widget left stale this way also pushed its old text back and silently un-emptied the signal. Clearing the widget itself, with widget.clear(), was always correct and is unchanged. (#490)

  • A Signal a text field made for you can now be cleared. TextField, PasswordField, PathField and SpinnerField hand you a Signal through .signal whether or not you bound one, and clearing that signal raised Expected str, got NoneType, telling you to pass allow_empty=True to a Signal() call you never wrote — advice no edit of yours could follow. Those four now allow empty, so field.signal.clear() empties the field and the signal reads '', the empty of a value that lives in the widget’s own variable. A signal you built yourself is unchanged, and so is field.clear(), which always worked. Slider and Checkbox still refuse, and still should: a slider always has a position and a checkbox always has a state, so neither has an empty value its variable could hold. The refusal now names setting a value as the way out, rather than only a constructor you may not own. (#484)

0.4.0 — Signal binding on fields#

Added#

  • A Signal can now hold an empty value, so clearing a field is no longer silent. A signal takes its type from the value you create it with and had no way to say “nothing”, so clearing a bound field left the signal on its last value and nothing watching it heard. Pass allow_empty=True when the value can also be empty — bs.Signal(date.today(), allow_empty=True) — and clear() empties it, in both directions between field and signal. A signal can also start empty, in which case name its type: bs.Signal(None, allow_empty=True, dtype=date). Empty reads as None, except where the signal is the widget’s own variable, as for a text field or radio group, where it reads as '', and a signal holding a set, as a multi-select toggle group does, which empties to the empty set — a falsiness check covers all three. Signals you already have are unchanged: without allow_empty=True, clear() and set(None) raise. Binding one that allows empty to a checkbox, switch, toggle button, slider or progress bar raises, since none of them has an empty state to hold. (#390)

  • Select gains the validation and addon surface the other fields already had. select.valid and select.error, both bindable Signals; the on_valid, on_invalid and on_validate events; and insert_addon, update_addon, remove_addon and addons. select.signal also reads back the Signal the widget was built with, as NumberField, DateField and TimeField already allowed. (#465, #458)

Changed#

  • A keyword a widget does not recognize is now reported instead of ignored. bs.TextField(bogus=1) used to construct as if nothing had happened, and so did a typo of a real parameter: bs.DataTable(densty="compact") silently used default spacing. Every widget you place in a layout now raises TypeError naming itself and the keyword — TextField() got unexpected keyword argument(s): bogus. The top-level windows (App, AppShell, Workbench, Window) report it through the toolkit they are built on, so their message names that rather than the window. Check this when you upgrade: an application that starts today can fail to start after the upgrade — but only where a keyword was already doing nothing, so you get a message pointing at the typo instead of a setting that never applied. Placement keywords (grow=, horizontal=, row=, column= and the rest) are unaffected, and Chart, MenuButton, Picture, StatusBar and Toolbar still pass extra options through to what they wrap. (#472)

  • A SelectButton bound to a Signal now carries the option’s value, not the label shown for it. Wherever a label differed from its value, seeding the signal with a value displayed that raw value and left selection empty for good, while selecting Three put the label 'Three' into the signal where value reported '3'. The signal now carries the value in both directions, and button.signal returns the Signal you passed rather than a separate one tracking it. Check this when you upgrade if you seed a SelectButton signal with anything other than one of its option values — a label, or any other string, now raises; seed it with the option’s value instead. This reaches buttons built from plain strings, where label and value are the same text. A button with no signal=, or one whose seed already names an option, is unaffected. Clearing the button in code leaves an ordinary signal untouched; declare it allow_empty=True and the clear reaches it. (#461)

Fixed#

  • A validation field no longer stops validating when a custom rule cannot judge a value. A custom rule runs a function you supply, and if it raised — comparing a number against text, or meeting an empty field it did not handle — the exception escaped into the event loop and the field silently kept whatever validity it already had. It now reports the value invalid, and the first raise per rule writes one line to stderr naming the exception; set BOOTSTACK_DEBUG for the traceback. The field shows “Could not check this value (expected: must be over 5)”, carrying the message you gave the rule as an expectation rather than as a verdict: a function that raised judged nothing, so your message alone can be false of the value — “must be over 5” on a field holding 6. It is shown alone, as the verdict, only when the function returns False. This also covers validate(), your own call and the one a FormDialog submit makes for you, which returns False rather than raising. Check this when you upgrade if a custom rule is attached to an optional field and your function does not handle an empty value: that field now reports invalid where it previously appeared valid. (#467)

  • A Signal bound to a TextArea or CodeEditor now works in both directions, and .signal gives you the signal back. Both widgets take a textsignal= documented as a two-way binding, but only one direction was wired: the widget followed the signal, while typing left the signal on its old value forever. .signal returned None in every case, even when a signal was bound. Edits now travel back as you make them, and .signal returns what you bound (still None when nothing is). Writing to a bound signal while a TextArea’s placeholder was showing also left the widget stuck — the text appeared, but value returned '' and on_input/on_change stopped firing for everything typed afterwards. Both behave normally now. Check this when you upgrade if you subscribe to a signal bound to one of these two widgets: that subscriber starts firing on user edits, where before it only heard writes your own code made. Binding a signal holding something other than text — bs.TextArea(textsignal=bs.Signal(123)) — now raises TypeError, the same refusal single-line fields have always given. (#486)

  • A Select bound to a Signal now tracks the selection, not the text on screen. Seeding the signal with an option’s value displayed that raw value instead of the option’s label, and setting it later moved the displayed text without moving the selection — so the field showed one option while value and selection reported the previous one, no change event fired, and it did not correct itself. That second half applied to plain string options too. The signal now carries the option’s value in both directions, matching value= and the signal= on NumberField, DateField and TimeField. Two things to check when you upgrade: what a Select writes into its signal changes from the option’s label to its value, and textsignal= on a Select now raises instead of being silently discarded. Clearing the field leaves an ordinary signal untouched; declare it allow_empty=True and the clear reaches it. SelectButton had the same defect and is fixed with it. (#458)

  • A validation rule on a Select now has somewhere to report. Select accepted add_validation_rule() and ran the rule, but exposed no valid or error to read the outcome from and emitted no valid/invalid events, so a failing rule could reach neither your code nor the screen. It now carries the field family’s full validation surface (see Added), so bs.Label(textsignal=select.error) works and on_valid/on_invalid fire as they do on TextField. The rules you attach today run exactly as before — this only gives their outcome somewhere to go. Check this when you upgrade: Select now shares the family’s add_validation_rule(), so passing anything other than a rule-type string raises TypeError instead of being accepted and silently ignored. (#465)

  • A modal bs.Window now hands the grab back to whatever was modal underneath it. Opening a modal window from inside another modal — an “Advanced…” button on a dialog, say — left the dialog underneath on screen and still blocking the code that opened it, but no longer modal: the user could click straight past it into the main window. A closing modal window now returns the grab to its previous holder, and as the same kind, so an application-modal window underneath stays application-modal rather than quietly narrowing. This was the same defect fixed for dialogs in 0.3.1, on the one path that fix did not cover; a window with no modal opener, and any non-modal window, are unaffected. (#444)

  • A SelectButton no longer reports the same selection twice. Every change fired on_change two times, so a handler that saves a record, sends a request or increments a counter did all of it twice for one selection. This applied to every SelectButton, whichever way the options were written, and both to a selection made from the menu and to one set in code. A selection is now announced exactly once; the event, its payload and its timing are unchanged. (#476)

  • A TimeField bound to a Signal no longer reports a change on startup. Seeding the field from its signal announced a change event while the field was being built, for a time nobody had picked — so an application reacting to a time change ran that reaction once at startup. A handler registered on the line after the constructor still received it, because the event waited in the queue until the application began running. TimeField(signal=…) is now silent, matching TimeField(value=…) and the already-silent NumberField and DateField. Every change after construction is announced exactly as before. (#459)

  • DataTable’s context_menus option now works. It was documented and shown in the widget guide but had no effect: every table offered both right-click menus whatever you asked for, so context_menus="none" still opened the column-header menu and the row menu. The option is now honored as documented — 'all' (the default), 'headers' or 'rows' for one menu only, 'none' for neither — and a misspelled value is reported rather than ignored. on_row_right_click fires no matter which menus you turn off: the option chooses which menus the table offers, not whether a right-click reaches your code. (#456)

  • .signal no longer looks like it might hand back nothing on seven widgets. TextField, PasswordField, PathField, SpinnerField, Checkbox, Switch and ToggleButton documented .signal as returning a Signal or None, and none of them can return None — they create a signal on first read whether or not you bound one, so a type checker made you guard a case that cannot happen. The seven now say what they do. Widgets that really can return None are unchanged and still say so: TextArea, CodeEditor, NumberField, DateField, TimeField, Select and SelectButton. Behavior is identical either way. (#460)

0.3.2 — Read-only select fields#

Fixed#

  • A read-only Select can no longer be changed. read_only=True was accepted and then ignored: the dropdown arrow dimmed, so the field looked locked, while clicking its text area still opened the option list and let a new value be chosen. The list now stays shut from both the arrow and the field, and read_only survives being combined with searchable or allow_custom_values, which used to cancel it outright. Reading select.read_only now reports what you set — it previously answered True for every Select, whether or not one had been asked for. TimeField offers the same kind of dropdown and had the same defect on its own read_only, both when passed to the constructor and when set afterwards; a locked time field is now genuinely locked, and its time list stays shut. (#453)

0.3.1 — Dialog keyboard and modality#

Fixed#

  • Pressing Enter in a dialog’s multi-line field no longer submits the dialog. A dialog binds Enter so its default button can be pressed from an input field — which is what lets ask_string() be finished from the keyboard — and that binding stood down only for buttons. A TextArea answers Enter too, by inserting a newline, so the newline went in and the dialog closed on top of it: you were typing a paragraph and the dialog shut under you. Enter is now treated as text wherever it means text, and as a command everywhere else, so a TextArea or CodeEditor in a dialog body behaves the way it does anywhere else. A read-only one is unaffected: nothing there consumes the key, so Enter still presses the default button. The keypad’s Enter key is handled on the same terms rather than assumed to match the main one: on the systems that report it separately, a multi-line field does not answer it at all, so it presses the default button rather than doing nothing at all. (#441)

  • A dialog no longer loses its modality when a second dialog closes on top of it. The inner dialog took over the block on the rest of the app and then released it entirely when it closed, instead of handing it back. The outer dialog stayed on screen and still blocked the code that opened it, while you could click straight past it into the main window and drive the app underneath — modal in appearance only. This was reachable from ordinary code: any dialog button command that shows an alert, a confirmation, or a second dialog. Nesting now restores the previous dialog’s modality at every depth, and closing the outermost one leaves nothing blocked. A window opened with modal="app" keeps the wider block it was created with, rather than coming back narrowed to this application alone. (#440)

  • A dialog’s default button now actually receives keyboard focus. It is documented as focused and triggered by Enter, but only the second half was true: the request was made while the window was still hidden, where it is silently ignored, so a dialog opened with nothing focused. Keyboard users got no focus ring and a Tab order that started from nowhere. The same defect meant the prompts that put you straight into a field — ask_string(), ask_integer(), ask_float() and ask_item() — did not focus their input either, so you could not type into one without clicking it first; those now focus their field, which takes precedence over the default button. ask_date() is unchanged, having no field to focus: its calendar still opens with focus on the window itself. (#439)

  • The error raised for an outdated layout option now names options that exist. Passing fill=, expand=, anchor=, sticky= or side= to a child of any layout container correctly raises, but the message recommended align_self= and justify_self=, which were renamed before release and never shipped. Following the advice produced a second, lower-level error naming an option you had never written. The message now names real options, and which ones it names depends on how the container places the child. A Row or Column child is pointed at grow= for claiming leftover space along the stacking axis and horizontal=/vertical= for aligning or stretching across it. A grid cell — a Grid child, a page or a pane, or a child of any container built with layout="grid" — is pointed at horizontal=/vertical= and at weighting the row or column on the container, because grow= is not honored there: recommending it would have replaced advice that raised with advice that quietly did nothing. Both forms list the values each option takes. (#426)

0.3.0 — Screen capture and dialog results#

Added#

  • Every widget can now save a picture of itself. capture(path) writes the area a widget occupies on screen to an image file and returns the path it wrote — call it on the app for the whole window, or on any single widget for just that part of it. The file extension picks the format, so .png, .jpg, and .pdf all work, and missing folders in the path are created for you. Pair it with ask_save_file() to let the user choose where the picture goes. The window is raised before the picture is taken, and an always-on-top setting the window already had is left exactly as it was found. Capturing a hidden or detached widget raises an error rather than silently saving whatever happened to be behind it. (#427)

Changed#

  • A dialog button’s command can now refuse its own press by returning False. The dialog then records no result and stays open, where previously the return value was ignored and the press completed regardless — recording the button’s result and closing the window even when the command had decided to do nothing. This is how a button rejects the input it was given: validate in the command, return False to keep the dialog open, return anything else to let it close. If you have a button command that returns False for some other reason, it will now suppress that button rather than being ignored; return None to keep the previous behavior. FormDialog already treated False this way for the commands you give it, so this brings the underlying Dialog in line with it — and Form’s own button row, the other place these specifications are used, honors it too, where it previously recorded a result for a press its command had just declined. A form stays on screen after a press, so a refused press there also clears any result an earlier press recorded: form.result is the most recent press that completed, never an older one made against data you have since edited. (#437)

Removed#

  • DialogButton.closes is gone. It was meant to say whether a button dismisses the dialog, but it could not be honored: FormDialog set the same flag internally on every non-cancel button — to stop the window closing before the form had been validated — and could not then tell its own value apart from one you had set. So the same declaration did three different things depending on the button’s role and whether it had a command, and both uses of it inside bootstack were really reaching for something else. What they wanted was a way to say “not this press” rather than “not this button”, which is what returning False from the command now does — per press, and without the button having to close the window itself. If you passed closes=False to keep a dialog open after a press, return False from that button’s command instead. A footer button that never dismisses the dialog is not really a footer button; put it in the dialog body, where it needs no flag at all. (#438)

Fixed#

  • Deleting a record from a DataTable no longer requires the record to be valid. The dialog validated the form for every button except Cancel, so a Delete button — which never reads the form — was refused whenever the record failed validation, and pressing it simply did nothing. This hit exactly the records most likely to need deleting: validation happens in the editors, which only exist while the dialog is open, so it has no say over what is already in the table. A record with a required field left blank, or one missing that field entirely, is accepted by DataTable(rows=...) without complaint and then cannot be deleted. The same applied to any custom action button you added to a FormDialog, provided it carried a result= of its own — which is what marks a button as an action rather than a submission. Validation now runs only for the buttons that submit the form: the standard 'ok', 'submit' and 'save', plus any button with no result=, whose result is the entered data. (#437)

  • Cancelling a FormDialog after a refused button press no longer performs that press. A press the dialog declined still recorded its button’s result, and Cancel could not clear it — so backing out of the dialog handed the caller the refused button’s result as though it had been pressed successfully. On a DataTable this meant that pressing Delete on an invalid record (which did nothing, per the fix above), and then cancelling because the form could not be satisfied, deleted the record at the moment you asked for nothing. A refused press now leaves nothing behind. (#437)

  • The keypad Enter key now submits a dialog you are typing in. In ask_string, ask_integer and any other dialog that puts the cursor straight into a field, only the main Enter key finished the dialog — the keypad one did nothing, so a value typed on the number pad had to be committed with the other hand or with the mouse. Both keys now submit, through the same command and the same refusal path as a click. Once you have clicked or tabbed to a button, that button answers both keys itself. (#437)

  • Enter now presses the button you tabbed to, and nothing else. A dialog bound Enter to its default button for every key press in the window, including one already delivered to a button — buttons answer Enter themselves — so one press ran two commands: the focused button’s, then the default button’s on top of it. It only surfaced on a dialog that stayed open after a press, since otherwise the closing window took the second command with it, which meant a footer button declared closes=False. That declaration is gone in this release, and returning False from a command replaces it, so the same press would have started refusing and running the default button instead. Enter on a focused button now presses that button alone; with the cursor in a field it still presses the default button. (#437)

  • A FormDialog no longer modifies the DialogButton you pass it. It rewrote command directly on your object, so a button specification reused across two dialogs came back altered — and the second dialog then wrapped the first one’s wrapper, leaving the press running against a dialog that was already gone. It works on its own copy now. (#438)

  • FormDialog.result now gives you the values you put in, not the text shown on screen. A select built from [('One', 1), ('Two', 2)] returned 'One' where a plain Select and the same field in a Form both returned 1 — so the three disagreed, and the dialog was the odd one out. It affected every editor whose displayed text differs from its underlying value, not only select: the result was read back after the dialog had already closed, at which point the only thing left to read was the on-screen text, and that arrives as a string whatever the value’s real type was. A date field, for instance, handed back its formatted text rather than a date. The entries are now taken when you press the button, while the form is still on screen, so what you get back is what was entered — same values, same types, matching Form and Select. Cancelling still returns None, and re-using a dialog no longer reports the previous run’s entries — including for a button declared with the 'cancel' role but an 'ok' result, the one combination where the dialog took no entries yet still tried to hand some back. (#428)

0.2.3 — Import without IDLE#

Fixed#

  • import bootstack no longer requires idlelib, so it works on Linux builds of Python that ship without IDLE. idlelib is part of the standard library, but Debian and Ubuntu package IDLE separately — the way they package Tkinter separately — and it is not installed by default. bootstack imported WidgetRedirector from it at module scope, in code the top-level package reaches unconditionally, so on those systems import bootstack raised ModuleNotFoundError: No module named 'idlelib' and nothing in the framework could be used at all. Since idlelib is standard library it is not on PyPI and could not be declared as a dependency, so there was no way to fix this by installing something. That one class is now part of bootstack, alongside the other pieces of the code editor already adapted from IDLE, and nothing in the framework imports idlelib any more. Windows and macOS were never affected: the python.org installers bundle IDLE. (#430)

0.2.2 — DataTable group headers and row events#

Changed#

  • On a read-only DataTable, the second press of a double-click no longer repeats the first press’s action. Because the double-click event is now bound on every table rather than only on editable ones, the second press is delivered as a double-click instead of as another single click. Two visible consequences, both of which bring read-only tables in line with how editable ones have always behaved: double-clicking a column heading now advances the sort once rather than twice, so it flips direction where it previously came back to where it started; and with selection_mode="multi" and selection controls shown, double-clicking a row now leaves it toggled rather than back in its original state. (#417)

  • A double-click also runs your on_row_click handler twice — once before on_row_double_click and once after. That has always been true of the click event: a double-click is reported on the second press while a row click is reported on release, so the order is click, double-click, click. What is new is that pairing the two handlers on one table is worth doing at all, since on_row_double_click never fired on a read-only table before. Single click selects, double click opens is now the natural thing to write — so if the double delivery matters for your handler, keep the work in on_row_click idempotent, or move it to the double-click handler. (#417)

Fixed#

  • Double-clicking a DataTable row now fires on_row_double_click. The binding behind the event was installed only when the table was also built with allow_edit=True, so on a read-only table — the common case — the event had nothing behind it and the handler never ran, while on_row_click and on_row_right_click on the same table kept working. That is what made it look like the event itself was broken rather than absent. The event is public API and does not depend on editing, so it is now bound unconditionally; allow_edit still controls only whether the built-in edit dialog opens alongside it. (#417)

  • A group header in a grouped DataTable no longer fires row events carrying an empty record. A group header is not a row and carries no record, but on_row_double_click and on_row_right_click only checked that some tree item was under the pointer, so clicking one emitted a RowEvent whose record was {} and whose id was None — enough to raise KeyError inside a handler doing the documented e.record["name"]. The right-click half needed no unusual setup, since right-click menus are on by default, so any grouped table was affected; the double-click half also opened a spurious New Record dialog when the table was built with allow_edit=True. A group header also stopped being recorded as the row menu’s target, which could leave a later menu command pointed at a row that carries no record. on_row_click has always ignored group headers; both of the others now match it. (#418, #420)

  • An expanded DataTable group header now shows the correct chevron. Expanding a group with the keyboard, or by double-clicking its header, left the group open while its chevron still pointed at collapsed, so the arrow and the group disagreed until something else repainted the row. Collapsing was never affected. The chevron is now read after the new state has settled rather than during the change, which also covers the keyboard path that has been wrong since grouped tables gained custom chevrons. (#419)

  • Clicking a DataTable group header, or any row on a table showing selection checkboxes, now leaves the keyboard pointed at that row. Ordinary row clicks were never affected. In those two cases the click toggled the row but did not give the table keyboard focus, so the arrow keys did not continue from what had just been clicked, and a following Space or arrow key did nothing to the table at all. With selection checkboxes that applied to every row, leaving Tab as the only way to start driving the table from the keyboard. (#421)

  • A column separator can be dragged again on a DataTable showing selection checkboxes. Dragging one moved nothing at all there, so those columns could not be resized. The click handling that makes a plain click toggle a row was also stopping clicks that landed between two columns rather than on a row, and that press is what begins the resize drag. It now stops only the clicks it actually handles. (#421)

0.2.1 — event and shortcut correctness#

Fixed#

  • Typing a lowercase b no longer collapses an AppShell sidebar. The sidebar toggle is documented as Ctrl+B (Cmd+B on macOS), but off macOS it also fired on a bare b typed into any field — including a TextField, PasswordField, TextArea or CodeEditor — on a machine with NumLock switched on. Windows reports NumLock using the same modifier bit that the shortcut’s macOS half was registered under, so an unmodified keystroke matched it. The macOS shortcut is now registered only on macOS. An uppercase B was never affected, which is what made the behavior look intermittent. (#403)

  • A Command+ or Option+ shortcut now binds the key its own menu label promises. Off macOS these were the last two modifier names left unmapped, so Shortcut(pattern="Command+S") produced an accelerator reading Ctrl+S beside a binding that listened for something else entirely — and on Windows that something else was satisfied by NumLock, so the shortcut fired on an unmodified s. Option+K had the same shape. Both now resolve to Ctrl and Alt off macOS, matching what has always been displayed, and keep their own meanings on macOS. This is the same trap as #403, closed once at the shared modifier map rather than at another call site. (#405)

  • emit() now reaches the handlers registered with the matching on_*(). On the field widgets — TextField, PasswordField, PathField, NumberField, SpinnerField, DateField, TimeField, Select, TextArea, CodeEditor — the text-editing events belong to the entry inside the field, and registering a handler correctly listened there while emit() fired on the field’s outer frame. So field.emit("change", data=...) never reached field.on_change(...), contradicting emit()’s own documentation that the two take the same event name. Both now resolve the target through one shared seam, so they cannot disagree. emit() is documented more plainly at the same time: it announces the framework’s own events, and the names that stand for a real input event instead — click, focus, blur, submit — are not a way to notify listeners. (#396)

  • A window’s transparency setup no longer keeps re-running. On X11, alpha is applied once the window becomes visible and the binding that does it is meant to remove itself afterward. It was removing nothing, so it re-applied on every later visibility change. (#398)

  • A cancellation that fails no longer reports success. Subscription.cancelled became True even when the underlying removal raised, so a subscription that was still delivering events described itself as cancelled. Relatedly, an internal removal that failed partway could leave a handler bound while reporting that it had been removed, or strand the resources behind one that had. (#400)

  • An unbind that matches nothing is now reported under BOOTSTACK_DEBUG. Declining to release resources it cannot prove are unused is the safe behavior, but it was silent, so the drift that caused it was invisible. (#399)

0.2.0 — form and field correctness#

This is the first minor release since 0.1.0, and it carries one change that is not backward compatible: an argument naming a behavior mode now raises on a value outside its documented set, where it used to degrade quietly. See Changed.

Added#

  • InvalidChoiceError in bootstack.errors, raised when an argument with a closed set of values is given something outside it. It is both a BootstackError and a ValueError, so either one catches it. (#381)

Changed#

  • A misspelled mode argument now tells you, instead of quietly doing something else. Arguments that name a behavior mode — selection_mode, sorting_mode, paging_mode, scrollbars, scroll_direction, scrollbar_visibility, and the mode on ToggleGroup and PathField — are read by comparing against one value, so a near miss such as selection_mode="multiple" used to switch multi-select off without a word. Passing a value outside the documented set now raises InvalidChoiceError naming the value and listing what is accepted. Covers DataTable, ListView, Tree, Gallery, Calendar, DateField, ToggleGroup, PathField, ScrollView, TextArea, and CodeEditor. (#381)

  • A field stretched taller than it needs now keeps its entry under its label. Where a field shares a grid row or a 'stretch' cross axis with a taller widget, the extra height used to be inserted between the label and the input, leaving the input floating below its own caption; it now collects beneath the field instead. Affects layouts that pair a field with something taller, such as a bs.Grid row holding a field beside a multi-line TextArea. (#394)

Fixed#

  • Cancelling one subscription no longer silences the others. Calling cancel() on a Subscription — or letting one fall out of a with block — stopped every other handler listening to that same event on that widget, with nothing raised to show for it. Two on_click handlers, cancel the first, and neither ran again. It affected every bootstack event, and so a wide range of behavior that unsubscribes as part of ordinary work: dialogs returning a result, field validation, meters, tab views, calendars, accordions, expanders, page stacks, and the theme toggle. Cancelling now removes exactly the one handler it was asked to. That holds in the cases hardest to get right too: a handler that cancels itself, a handler that cancels another handler while the same event is being delivered, and a replacement handler registered immediately after a cancellation. (#392)

  • Adding a validation rule no longer misaligns a row of fields. A field reserves space for its message as soon as it has a rule, and the fields without one sat about nine pixels lower — both inside a Form and in a hand-built Row. Two separate causes: the entry row absorbed the extra height a form cell gave the field and centered itself in it, and a row centered the shorter fields against their taller neighbors. Input fields — including Select — now align to the top of a row on their own, so a row of them lines up whether or not each one is validated. This applies to every container that lays out as a row (Row, Card, GroupBox, Expander, Tabs, PageStack, SplitView and an AppShell page), not just Row. Passing vertical_items yourself still applies to every child, fields included. (#394)

  • Choosing a date from the calendar reports the change. The picker set the field but announced nothing, so a bound Signal kept its old date, an on_change handler never ran, and a Form did not register the edit — while typing the same date and pressing Return worked. Picking now behaves like any other commit. Date ranges were already correct. (#388)

  • A date field can be cleared. Setting value to None — and the field’s own clear() method, which does exactly that — silently left the previous date in place, on screen and in form.get(). Clearing now works through every path, including Form.set({key: None}). The same no-op affected None on the other entry-backed fields (TextField, NumberField, PasswordField, PathField, SpinnerField), which reached empty only when given ""; None and "" now agree. (#387)

  • Form.set() writes only the fields you name. It walked every field and blanked the ones absent from the dictionary — harmless only because blanking was itself broken. A partial update such as form.set({'date': value}) now leaves the other fields, and the rest of the form data, untouched. (#387)

0.1.8 — macOS sizing on Tcl/Tk 9#

Fixed#

  • The interface is sized correctly on macOS with Tcl/Tk 9. Tk 9 changed the resolution macOS reports, and bootstack read that as a high-density display — so on a Homebrew or conda Python, or any build linked against Tk 9, the whole interface rendered about a third too large. Text, icons, padding, and control sizes are all restored to their intended size, and now match Tk 8.6 exactly. Windows and Linux are unaffected. (#375)

0.1.7 — Tcl/Tk 9 scroll support#

Fixed#

  • Scrolling works again on Tcl/Tk 9. A trackpad, Magic Mouse, or Magic Trackpad reports precise scroll deltas, which Tk 9 delivers as a different event than a mouse wheel. Every scrolling widget listened only for the wheel, so on a Mac running Tk 9 — a Homebrew or conda Python, or any build linked against Tk 9 — scrolling did nothing at all in ScrollView, ListView, Tree, TextArea, CodeEditor, Gallery, and the Tabs strip. Reported against Python 3.14.6 with Tcl/Tk 9.0.3; the same code runs correctly on Python 3.13 only because it ships Tk 8.6. (#372)

  • A wheel notch scrolls by one step on Tk 9, not by a hundred and twenty. Tk 9 normalized wheel deltas across platforms; on macOS the old reading scrolled a full view per notch.

  • Wheel scrolling works on Linux with Tk 9. Tk 9 stopped delivering the X11 wheel buttons to applications, and the affected widgets listened for nothing else there.

  • A wheel notch scrolls a ScrollView consistently across platforms. On Linux one notch moved the view ten times as far as it did elsewhere.

  • A widget detached across a theme change is recolored when you attach it back. Widgets that paint themselves — charts, gauges, and the other canvas-drawn widgets — skip a theme change while they are off screen and repaint when they next become visible. Returning one with attach() was not treated as becoming visible, so a chart hidden with detach() across a theme toggle came back with the old palette and kept it until something else forced a repaint. Showing a page or expanding an accordion section already worked.

0.1.6 — form, field, and validation fixes#

Fixed#

  • tristate works on a checkbox form editor. 0.1.5 fixed bs.Checkbox(tristate=True) itself, but a checkbox built by a Form (or by DataTable’s add/edit dialog) still started unchecked: the form supplied an explicit value=False that overrode the indeterminate default. (#358)

  • editor_options may set any of the editor’s public keyword arguments. Naming one the form also fills — label, options, or a boolean editor’s caption — raised TypeError: got multiple values for keyword argument. Those options now override the form’s default instead of colliding with it. A value option seeds the editor only when the form’s data carries nothing for that key. (#358)

  • A falsy value no longer disappears from a text field. bs.TextField, bs.PasswordField, and bs.PathField tested the initial value for truthiness, so value=0 rendered an empty field — and in a Form, the blank was written back over the record’s value.

  • A form no longer changes the type of the data it was given. Values that are not text — a Decimal, a date — keep their type in form.data instead of being converted to strings at construction.

  • Option dicts aimed at a built widget no longer collide with the framework’s own arguments. The same defect appeared in MenuButton(menu_options=), ButtonGroup.add() / add_all(), RadioGroup.add() / ToggleGroup.add(), and Toolbar / StatusBar add_widget(). In each, your options now win; the few keys a widget must own — where it is parented, how it tracks its selection, the callback that emits its events — raise a clear error naming what to use instead.

  • A ButtonGroup button given both a caption and an icon renders as both. Supplying the caption as text produced an icon-only button with its label crammed into zero padding.

  • A required field with a placeholder no longer passes validation while empty. A field showing only its placeholder was treated as though the hint had been typed, so required reported it valid — and a form with an untouched required field validated and submitted.

  • text no longer reports the placeholder as content. A field showing only its placeholder returned the hint from text while value reported empty; the two now agree on whether the field holds anything.

  • required survives an unrecognized editor= name. An editor name the form does not know falls back to a text field, but the required rule was dropped on the way, so a misspelled editor silently let an empty field submit. (#366)

  • A searchable Select no longer changes its value when you just look. Opening the drop-down and dismissing it without typing or choosing anything replaced the field’s value with the first option in the list. (#355)

  • Select validation rules run against the selected value, not its label. On a decoupled option list — where an option displays 'United States' and stores 'US' — every rule saw the label, so a rule checking the value rejected valid selections. (#355)

  • A Decimal value now respects value_format. It matched none of the formatter’s numeric branches, so the format was silently ignored: a currency field seeded with a Decimal displayed the raw number and only started formatting once you edited it. Decimal is handed to the formatter as-is rather than converted, so a value keeps the precision it was given.

Added#

  • Select.validate() — run a select’s validation rules on demand, matching the other field widgets. add_validation_rule already pointed at it. (#355)

Changed#

  • A format rule no longer rejects an empty field. email, pattern, and stringLength describe what a value must look like, not that one must be present, so they now pass on an empty field — matching range, which already behaved this way. Previously a field with no required= reported an error while untouched and Form.validate() refused to submit, leaving no way forward but typing into a field the form called optional. If you used a format rule as a presence check — stringLength(min=1), or a pattern that cannot match the empty string — add required to keep that behavior. compare and custom are unaffected; both still run on an empty value. (#366)

  • A Select no longer rejects a value that is not in its option list. Opening an editor on a stored record whose option had since been retired raised ValueError: '…' is not one of the options — in a Form, and in DataTable’s add/edit dialog, on ordinary data drift. A later programmatic write of the same value was silently dropped instead, so one value produced two different wrong answers. Such a value is now displayed as given, reads back with its own type, and is not added to the list, so a user cannot pick it. Use a 'custom' validation rule to report one. SelectButton, which maps a value to an option’s label and has no text entry, still rejects. (#355)

0.1.5 — boolean control state fixes#

Fixed#

  • Checkbox(tristate=True) now produces a real indeterminate state. Setting tristate=True previously left the checkbox unchecked — the dash indicator never rendered and .value returned False instead of None. (#358)

  • ToggleButton.value / .checked report the correct state. A toggle built with value=True (and visually “on”) wrongly reported .checked as False and .value as None. (#359)

  • Non-bool checked_value / unchecked_value round-trip on Checkbox and Switch. A string or other custom on/off value (e.g. checked_value="yes") was silently coerced to True / False; .value now returns the value you set, matching ToggleButton.

0.1.4 — Select validation fix#

Fixed#

  • add_validation_rule works on Select fields again. In 0.1.3, calling form.field(key).add_validation_rule(...) on a select editor — or the same method on a standalone bs.Select — raised AttributeError: 'Select' object has no attribute 'add_validation_rule'. This was a regression from the 0.1.3 form rework: field() now returns the public editor widget, and bs.Select was the one editor missing the method. It is restored with the same signature as the other field widgets, so custom rules with a message= and trigger= work as they did in 0.1.2. (#356, #357)

0.1.3 — form editor options fix#

Fixed#

  • Form field editors now accept the editor widget’s public option names. A FieldItem’s editor_options are documented as the editor widget’s keyword arguments, but the form built the internal widgets and forwarded the options unchanged — so the public names raised an error (editor_options={"step": 10} on a numberfield failed with unknown option "-step"; only the internal increment worked), and the textarea editor could not accept show_border at all. Editors are now built from their public widgets, so step, min_value / max_value, show_steppers, show_border, mask, and the slider bounds all work as documented — in Form, FormDialog, and the DataTable add/edit dialog. This also fixes two latent textarea bugs, where a programmatically set value and required validation were ignored. (#353, #354)

0.1.1 — packaging fix#

Fixed#

  • Declared pygments as a runtime dependency. CodeEditor requires Pygments for syntax highlighting, but it was not listed in the project dependencies, so a clean pip install bootstack would raise ModuleNotFoundError: No module named 'pygments' when constructing a CodeEditor (including on the bundled demo’s editing page). Pygments is now installed automatically with bootstack.

0.1.0 — first stable release#

The first stable release of bootstack. The public compose API — everything you import as bootstack as bs plus the curated submodules (bootstack.data, bootstack.style, bootstack.events, bootstack.dialogs, …) — is now frozen under Semantic Versioning. Breaking changes to it will not land before 1.0 except as documented, versioned migrations.

Highlights#

  • Applications and windowsApp, Window, and two navigation shells: AppShell (single sidebar) and Workbench (two-tier rail + workspaces), plus a borderless Splash intro screen. Undecorated windows auto-inject a draggable titlebar and border.

  • A full widget catalog — layout (Row/Column/Grid/Card/ScrollView/ SplitView/Accordion), inputs (TextField/NumberField/DateField/ TextArea/CodeEditor/Slider/…), selection (Checkbox/Switch/Select/ Calendar/…), data display (DataTable/Tree/ListView/Label/Badge/ Gauge/…), media (Picture/Gallery/Carousel/Avatar/Chart), navigation (Tabs/PageStack), and overlays (Tooltip/toast/Notification/Snackbar).

  • Reactive stateSignal for two-way widget binding; a typed event system (on_change()/on_click()/… returning cancelable Subscriptions or composable Streams); reactive Form.valid/Form.errors.

  • Theming — light/dark themes, set_theme/toggle_theme, ThemeToggle, system-appearance following, and a public bootstack.style API.

  • Databootstack.data source protocol (memory/SQLite/file-backed) with a filtering DSL (col/any_of/all_of), a non-scalar data bag carried across Tree/DataTable/ListView, and large-file streaming.

  • Dialogs — verbs (alert/confirm/ask_*) at the top level plus dialog classes in bootstack.dialogs (Dialog/FormDialog/…).

  • Tooling — a bootstack CLI (start/run/add/doctor/appicon/…) and application packaging.

Provisional (excluded from the freeze)#

  • bootstack.dev — the hot-reload workflow (reloadable, is_dev_mode, and the bootstack dev command) is experimental. Its surface is carved out of the 0.1.0 freeze and may change before a later release.

Migrating from the 0.1.0aN alpha series#

Pre-1.0 alphas were never a stable contract; this summarizes the notable breaks for anyone who tracked an alpha. (If you are installing bootstack for the first time, you can ignore this section.)

Renamed#

  • Layout: HStackRow, VStackColumn, SeparatorDivider; added Spacer. The layout vocabulary moved to screen-axis terms — fill/expand/ anchor/sticky are replaced by horizontal/vertical/grow with edge-name values (left/center/right/stretch). The legacy kwargs now raise.

  • TableDataTable (and decoupled from any specific data source).

  • ToolbarCommandBar for the app-level bar (app.commandbar); app.menuapp.menubar; the standalone bs.MenuBar was removed in favor of app.menubar.

  • Signal.subscribe() now returns a cancelable handle (was a string token).

  • Selection: per-widget get_selected() / selected_rows / selected_nodes were unified into a single polymorphic .selection accessor across ListView/DataTable/Tree.

  • Navigation: the single AppShell was split into AppShell (single sidebar) and Workbench (two-tier workspaces); nav providers became page_nav() / list_nav() / tree_nav() / custom_nav() (the old panel() is now custom_nav()).

Removed / moved#

  • AppSettings and settings= were removed. All former settings are now flat App(...) / AppShell(...) keyword arguments (theme, locale, remember_window_state, …), with symmetric app.* properties. Passing settings= raises TypeError.

  • Top-level namespace curated to the compose surface only. Types you reference to configure behavior moved to submodules — e.g. Theme/get_theme_color (bootstack.style), col/SqliteDataSource (bootstack.data), ValidationRule (bootstack.validation), Event/Subscription (bootstack.events), AccentToken (bootstack.types). Dialog classes (Dialog/FormDialog/…) moved to bootstack.dialogs; the dialog verbs (alert/confirm/ask_*) stay top-level.

  • Toast was split into toast() (function), Notification, Snackbar, and snackbar().

  • MessageCatalog, IntlFormatter, get_current_app, and Image were demoted to internal (import widgets/icons via the public bootstack.images API).

  • Scale and the VariantToken type were removed.

Changed (behavior)#

  • TimeField now starts empty (it previously defaulted to the current time, which silently defeated required=True).

  • Field validation runs against the field’s typed value; rules are type-aware (a new range rule for number/date/time bounds), and field.valid / field.error are reactive Signals.

  • Toolbar.add_widget / StatusBar.add_widget are now class-based (add_widget(WidgetClass, **kwargs)).