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
Signalseeded with nothing is now reported instead of built dead. A signal takes its type from the value you create it with, sobs.Signal(None)inferred the type ofNoneitself and could never hold anything else: every laterset()raisedExpected 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 reachesmap()too, which types the derived signal from the transform’s first result: a transform that returnsNonefor 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, ormap()a transform that can returnNone. Passallow_empty=Truewith the type named, as inbs.Signal(None, allow_empty=True, dtype=date), when the value can be empty; give the transform a value for every input, as indue.map(lambda d: d.strftime("%b %d") if d else ""), when it cannot. Signals seeded with a value, andmap()over a transform that returns one, are unaffected. (#481)
Fixed#
TextArea.insert()andappend()no longer write alongside the placeholder. On aTextAreabuilt withplaceholder=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 readType something herewritten by code,valuewent on returning'', andon_inputandon_changestopped firing. The silence was permanent rather than momentary, so every later edit the user made went unannounced too andvaluestayed empty for the rest of the field’s life. Both methods now drop the placeholder first, as assigning tovaluealways did. ATextAreawith no placeholder, or one whose placeholder is already gone, is unaffected, and so isCodeEditor, which has no placeholder to write onto. (#491)A field’s
valuenow follows a write to its boundSignal. Writing to a signal bound to aTextField,PasswordField,PathFieldorSpinnerFieldmoved what the field displayed but not whatvaluereported — the two disagreed about the same state, silently, until the user focused and left the field.valuenow follows a write your code made, includingclear()on a signal declaredallow_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,TimeFieldandSelectalready behaved this way and are untouched. Check this when you upgrade if you handleon_changeon a field your code writes through a bound signal. The change event that used to arrive a focus cycle late now moves withvalue: 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 throughfield.value, which is howForm.set()writes, is unaffected, and so is every change the user makes. (#482)TextAreaandCodeEditornow honor aSignal.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. Since0.4.0wired 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, withwidget.clear(), was always correct and is unchanged. (#490)A
Signala text field made for you can now be cleared.TextField,PasswordField,PathFieldandSpinnerFieldhand you aSignalthrough.signalwhether or not you bound one, and clearing that signal raisedExpected str, got NoneType, telling you to passallow_empty=Trueto aSignal()call you never wrote — advice no edit of yours could follow. Those four now allow empty, sofield.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 isfield.clear(), which always worked.SliderandCheckboxstill 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
Signalcan 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. Passallow_empty=Truewhen the value can also be empty —bs.Signal(date.today(), allow_empty=True)— andclear()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 asNone, 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 aset, 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: withoutallow_empty=True,clear()andset(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)Selectgains the validation and addon surface the other fields already had.select.validandselect.error, both bindableSignals; theon_valid,on_invalidandon_validateevents; andinsert_addon,update_addon,remove_addonandaddons.select.signalalso reads back theSignalthe widget was built with, asNumberField,DateFieldandTimeFieldalready 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 raisesTypeErrornaming 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, andChart,MenuButton,Picture,StatusBarandToolbarstill pass extra options through to what they wrap. (#472)A
SelectButtonbound to aSignalnow 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 leftselectionempty for good, while selectingThreeput the label'Three'into the signal wherevaluereported'3'. The signal now carries the value in both directions, andbutton.signalreturns theSignalyou passed rather than a separate one tracking it. Check this when you upgrade if you seed aSelectButtonsignal 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 nosignal=, or one whose seed already names an option, is unaffected. Clearing the button in code leaves an ordinary signal untouched; declare itallow_empty=Trueand the clear reaches it. (#461)
Fixed#
A validation field no longer stops validating when a
customrule cannot judge a value. Acustomrule 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; setBOOTSTACK_DEBUGfor the traceback. The field shows “Could not check this value (expected: must be over 5)”, carrying themessageyou 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 returnsFalse. This also coversvalidate(), your own call and the one aFormDialogsubmit makes for you, which returnsFalserather than raising. Check this when you upgrade if acustomrule 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
Signalbound to aTextAreaorCodeEditornow works in both directions, and.signalgives you the signal back. Both widgets take atextsignal=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..signalreturnedNonein every case, even when a signal was bound. Edits now travel back as you make them, and.signalreturns what you bound (stillNonewhen nothing is). Writing to a bound signal while aTextArea’s placeholder was showing also left the widget stuck — the text appeared, butvaluereturned''andon_input/on_changestopped 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 raisesTypeError, the same refusal single-line fields have always given. (#486)A
Selectbound to aSignalnow 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 whilevalueandselectionreported 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, matchingvalue=and thesignal=onNumberField,DateFieldandTimeField. Two things to check when you upgrade: what aSelectwrites into its signal changes from the option’s label to its value, andtextsignal=on aSelectnow raises instead of being silently discarded. Clearing the field leaves an ordinary signal untouched; declare itallow_empty=Trueand the clear reaches it.SelectButtonhad the same defect and is fixed with it. (#458)A validation rule on a
Selectnow has somewhere to report.Selectacceptedadd_validation_rule()and ran the rule, but exposed novalidorerrorto read the outcome from and emitted novalid/invalidevents, so a failing rule could reach neither your code nor the screen. It now carries the field family’s full validation surface (seeAdded), sobs.Label(textsignal=select.error)works andon_valid/on_invalidfire as they do onTextField. The rules you attach today run exactly as before — this only gives their outcome somewhere to go. Check this when you upgrade:Selectnow shares the family’sadd_validation_rule(), so passing anything other than a rule-type string raisesTypeErrorinstead of being accepted and silently ignored. (#465)A modal
bs.Windownow 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 in0.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
SelectButtonno longer reports the same selection twice. Every change firedon_changetwo 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 everySelectButton, 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
TimeFieldbound to aSignalno 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, matchingTimeField(value=…)and the already-silentNumberFieldandDateField. Every change after construction is announced exactly as before. (#459)DataTable’scontext_menusoption 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, socontext_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_clickfires no matter which menus you turn off: the option chooses which menus the table offers, not whether a right-click reaches your code. (#456).signalno longer looks like it might hand back nothing on seven widgets.TextField,PasswordField,PathField,SpinnerField,Checkbox,SwitchandToggleButtondocumented.signalas returning aSignalorNone, and none of them can returnNone— 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 returnNoneare unchanged and still say so:TextArea,CodeEditor,NumberField,DateField,TimeField,SelectandSelectButton. Behavior is identical either way. (#460)
0.3.2 — Read-only select fields#
Fixed#
A read-only
Selectcan no longer be changed.read_only=Truewas 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, andread_onlysurvives being combined withsearchableorallow_custom_values, which used to cancel it outright. Readingselect.read_onlynow reports what you set — it previously answeredTruefor everySelect, whether or not one had been asked for.TimeFieldoffers the same kind of dropdown and had the same defect on its ownread_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. ATextAreaanswers 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 aTextAreaorCodeEditorin 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()andask_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=orside=to a child of any layout container correctly raises, but the message recommendedalign_self=andjustify_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. ARoworColumnchild is pointed atgrow=for claiming leftover space along the stacking axis andhorizontal=/vertical=for aligning or stretching across it. A grid cell — aGridchild, a page or a pane, or a child of any container built withlayout="grid"— is pointed athorizontal=/vertical=and at weighting the row or column on the container, becausegrow=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.pdfall work, and missing folders in the path are created for you. Pair it withask_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, returnFalseto keep the dialog open, return anything else to let it close. If you have a button command that returnsFalsefor some other reason, it will now suppress that button rather than being ignored; returnNoneto keep the previous behavior.FormDialogalready treatedFalsethis way for the commands you give it, so this brings the underlyingDialogin line with it — andForm’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.resultis the most recent press that completed, never an older one made against data you have since edited. (#437)
Removed#
DialogButton.closesis gone. It was meant to say whether a button dismisses the dialog, but it could not be honored:FormDialogset 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 returningFalsefrom the command now does — per press, and without the button having to close the window itself. If you passedcloses=Falseto keep a dialog open after a press, returnFalsefrom 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
DataTableno 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 byDataTable(rows=...)without complaint and then cannot be deleted. The same applied to any custom action button you added to aFormDialog, provided it carried aresult=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 noresult=, whose result is the entered data. (#437)Cancelling a
FormDialogafter 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 aDataTablethis 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_integerand 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 returningFalsefrom 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
FormDialogno longer modifies theDialogButtonyou pass it. It rewrotecommanddirectly 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.resultnow gives you the values you put in, not the text shown on screen. Aselectbuilt from[('One', 1), ('Two', 2)]returned'One'where a plainSelectand the same field in aFormboth returned1— 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 onlyselect: 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, matchingFormandSelect. Cancelling still returnsNone, 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 bootstackno longer requiresidlelib, so it works on Linux builds of Python that ship without IDLE.idlelibis 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 importedWidgetRedirectorfrom it at module scope, in code the top-level package reaches unconditionally, so on those systemsimport bootstackraisedModuleNotFoundError: No module named 'idlelib'and nothing in the framework could be used at all. Sinceidlelibis 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 importsidlelibany 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 withselection_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_clickhandler twice — once beforeon_row_double_clickand 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, sinceon_row_double_clicknever 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 inon_row_clickidempotent, or move it to the double-click handler. (#417)
Fixed#
Double-clicking a
DataTablerow now fireson_row_double_click. The binding behind the event was installed only when the table was also built withallow_edit=True, so on a read-only table — the common case — the event had nothing behind it and the handler never ran, whileon_row_clickandon_row_right_clickon 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_editstill controls only whether the built-in edit dialog opens alongside it. (#417)A group header in a grouped
DataTableno longer fires row events carrying an empty record. A group header is not a row and carries no record, buton_row_double_clickandon_row_right_clickonly checked that some tree item was under the pointer, so clicking one emitted aRowEventwhoserecordwas{}and whoseidwasNone— enough to raiseKeyErrorinside a handler doing the documentede.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 withallow_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_clickhas always ignored group headers; both of the others now match it. (#418, #420)An expanded
DataTablegroup 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
DataTablegroup 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
DataTableshowing 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
bno longer collapses anAppShellsidebar. The sidebar toggle is documented as Ctrl+B (Cmd+B on macOS), but off macOS it also fired on a barebtyped into any field — including aTextField,PasswordField,TextAreaorCodeEditor— 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 uppercaseBwas never affected, which is what made the behavior look intermittent. (#403)A
Command+orOption+shortcut now binds the key its own menu label promises. Off macOS these were the last two modifier names left unmapped, soShortcut(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 unmodifieds.Option+Khad 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 matchingon_*(). 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 whileemit()fired on the field’s outer frame. Sofield.emit("change", data=...)never reachedfield.on_change(...), contradictingemit()’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.cancelledbecameTrueeven 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#
InvalidChoiceErrorinbootstack.errors, raised when an argument with a closed set of values is given something outside it. It is both aBootstackErrorand aValueError, 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 themodeonToggleGroupandPathField— are read by comparing against one value, so a near miss such asselection_mode="multiple"used to switch multi-select off without a word. Passing a value outside the documented set now raisesInvalidChoiceErrornaming the value and listing what is accepted. CoversDataTable,ListView,Tree,Gallery,Calendar,DateField,ToggleGroup,PathField,ScrollView,TextArea, andCodeEditor. (#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 abs.Gridrow holding a field beside a multi-lineTextArea. (#394)
Fixed#
Cancelling one subscription no longer silences the others. Calling
cancel()on aSubscription— or letting one fall out of awithblock — stopped every other handler listening to that same event on that widget, with nothing raised to show for it. Twoon_clickhandlers, 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
Formand in a hand-builtRow. 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 — includingSelect— 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,SplitViewand an AppShell page), not justRow. Passingvertical_itemsyourself 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
Signalkept its old date, anon_changehandler never ran, and aFormdid 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
valuetoNone— and the field’s ownclear()method, which does exactly that — silently left the previous date in place, on screen and inform.get(). Clearing now works through every path, includingForm.set({key: None}). The same no-op affectedNoneon the other entry-backed fields (TextField,NumberField,PasswordField,PathField,SpinnerField), which reached empty only when given"";Noneand""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 asform.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 theTabsstrip. 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
ScrollViewconsistently 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 withdetach()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#
tristateworks on acheckboxform editor.0.1.5fixedbs.Checkbox(tristate=True)itself, but a checkbox built by aForm(or byDataTable’s add/edit dialog) still started unchecked: the form supplied an explicitvalue=Falsethat overrode the indeterminate default. (#358)editor_optionsmay set any of the editor’s public keyword arguments. Naming one the form also fills —label,options, or a boolean editor’s caption — raisedTypeError: got multiple values for keyword argument. Those options now override the form’s default instead of colliding with it. Avalueoption seeds the editor only when the form’sdatacarries nothing for that key. (#358)A falsy value no longer disappears from a text field.
bs.TextField,bs.PasswordField, andbs.PathFieldtested the initial value for truthiness, sovalue=0rendered an empty field — and in aForm, 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, adate— keep their type inform.datainstead 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(), andToolbar/StatusBaradd_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
ButtonGroupbutton given both a caption and an icon renders as both. Supplying the caption astextproduced 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
requiredreported it valid — and a form with an untouched required field validated and submitted.textno longer reports the placeholder as content. A field showing only its placeholder returned the hint fromtextwhilevaluereported empty; the two now agree on whether the field holds anything.requiredsurvives an unrecognizededitor=name. An editor name the form does not know falls back to a text field, but therequiredrule was dropped on the way, so a misspelled editor silently let an empty field submit. (#366)A searchable
Selectno 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)Selectvalidation 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
Decimalvalue now respectsvalue_format. It matched none of the formatter’s numeric branches, so the format was silently ignored: a currency field seeded with aDecimaldisplayed the raw number and only started formatting once you edited it.Decimalis 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_rulealready pointed at it. (#355)
Changed#
A format rule no longer rejects an empty field.
email,pattern, andstringLengthdescribe what a value must look like, not that one must be present, so they now pass on an empty field — matchingrange, which already behaved this way. Previously a field with norequired=reported an error while untouched andForm.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 — addrequiredto keep that behavior.compareandcustomare unaffected; both still run on an empty value. (#366)A
Selectno longer rejects a value that is not in its option list. Opening an editor on a stored record whose option had since been retired raisedValueError: '…' is not one of the options— in aForm, and inDataTable’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. Settingtristate=Truepreviously left the checkbox unchecked — the dash indicator never rendered and.valuereturnedFalseinstead ofNone. (#358)ToggleButton.value/.checkedreport the correct state. A toggle built withvalue=True(and visually “on”) wrongly reported.checkedasFalseand.valueasNone. (#359)Non-bool
checked_value/unchecked_valueround-trip onCheckboxandSwitch. A string or other custom on/off value (e.g.checked_value="yes") was silently coerced toTrue/False;.valuenow returns the value you set, matchingToggleButton.
0.1.4 — Select validation fix#
Fixed#
add_validation_ruleworks onSelectfields again. In0.1.3, callingform.field(key).add_validation_rule(...)on aselecteditor — or the same method on a standalonebs.Select— raisedAttributeError: 'Select' object has no attribute 'add_validation_rule'. This was a regression from the0.1.3form rework:field()now returns the public editor widget, andbs.Selectwas the one editor missing the method. It is restored with the same signature as the other field widgets, so custom rules with amessage=andtrigger=work as they did in0.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’seditor_optionsare 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 anumberfieldfailed withunknown option "-step"; only the internalincrementworked), and thetextareaeditor could not acceptshow_borderat all. Editors are now built from their public widgets, sostep,min_value/max_value,show_steppers,show_border,mask, and the slider bounds all work as documented — inForm,FormDialog, and theDataTableadd/edit dialog. This also fixes two latenttextareabugs, where a programmatically set value andrequiredvalidation were ignored. (#353, #354)
0.1.1 — packaging fix#
Fixed#
Declared
pygmentsas a runtime dependency.CodeEditorrequires Pygments for syntax highlighting, but it was not listed in the project dependencies, so a cleanpip install bootstackwould raiseModuleNotFoundError: No module named 'pygments'when constructing aCodeEditor(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 windows —
App,Window, and two navigation shells:AppShell(single sidebar) andWorkbench(two-tier rail + workspaces), plus a borderlessSplashintro 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 state —
Signalfor two-way widget binding; a typed event system (on_change()/on_click()/… returning cancelableSubscriptions or composableStreams); reactiveForm.valid/Form.errors.Theming — light/dark themes,
set_theme/toggle_theme,ThemeToggle, system-appearance following, and a publicbootstack.styleAPI.Data —
bootstack.datasource protocol (memory/SQLite/file-backed) with a filtering DSL (col/any_of/all_of), a non-scalar data bag carried acrossTree/DataTable/ListView, and large-file streaming.Dialogs — verbs (
alert/confirm/ask_*) at the top level plus dialog classes inbootstack.dialogs(Dialog/FormDialog/…).Tooling — a
bootstackCLI (start/run/add/doctor/appicon/…) and application packaging.
Provisional (excluded from the freeze)#
bootstack.dev— the hot-reload workflow (reloadable,is_dev_mode, and thebootstack devcommand) 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:
HStack→Row,VStack→Column,Separator→Divider; addedSpacer. The layout vocabulary moved to screen-axis terms —fill/expand/anchor/stickyare replaced byhorizontal/vertical/growwith edge-name values (left/center/right/stretch). The legacy kwargs now raise.Table→DataTable(and decoupled from any specific data source).Toolbar→CommandBarfor the app-level bar (app.commandbar);app.menu→app.menubar; the standalonebs.MenuBarwas removed in favor ofapp.menubar.Signal.subscribe()now returns a cancelable handle (was a string token).Selection: per-widget
get_selected()/selected_rows/selected_nodeswere unified into a single polymorphic.selectionaccessor acrossListView/DataTable/Tree.Navigation: the single
AppShellwas split intoAppShell(single sidebar) andWorkbench(two-tier workspaces); nav providers becamepage_nav()/list_nav()/tree_nav()/custom_nav()(the oldpanel()is nowcustom_nav()).
Removed / moved#
AppSettingsandsettings=were removed. All former settings are now flatApp(...)/AppShell(...)keyword arguments (theme,locale,remember_window_state, …), with symmetricapp.*properties. Passingsettings=raisesTypeError.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 tobootstack.dialogs; the dialog verbs (alert/confirm/ask_*) stay top-level.Toastwas split intotoast()(function),Notification,Snackbar, andsnackbar().MessageCatalog,IntlFormatter,get_current_app, andImagewere demoted to internal (import widgets/icons via the publicbootstack.imagesAPI).Scaleand theVariantTokentype were removed.
Changed (behavior)#
TimeFieldnow starts empty (it previously defaulted to the current time, which silently defeatedrequired=True).Field validation runs against the field’s typed value; rules are type-aware (a new
rangerule for number/date/time bounds), andfield.valid/field.errorare reactiveSignals.Toolbar.add_widget/StatusBar.add_widgetare now class-based (add_widget(WidgetClass, **kwargs)).