refactor!: Replace InputBorder enum with a class hierarchy - #6773
Open
ndonkoHenri wants to merge 26 commits into
Open
refactor!: Replace InputBorder enum with a class hierarchy#6773ndonkoHenri wants to merge 26 commits into
InputBorder enum with a class hierarchy#6773ndonkoHenri wants to merge 26 commits into
Conversation
`InputBorder` was an enum (`OUTLINE`/`UNDERLINE`/`NONE`) paired with five loose properties on every form field: `border_radius`, `border_width`, `border_color`, `focused_border_width` and `focused_border_color`. That shape could not express Flutter's API — `gap_padding` was unavailable, `UnderlineInputBorder`'s corner radius was silently ignored, and the error and disabled borders could not be styled at all — and every new Flutter border property would have required another top-level property on each control. `InputBorder` is now a base class with `OutlineInputBorder`, `UnderlineInputBorder` and `InputBorder.none()`, mirroring Flutter's classes and their defaults. `FormFieldControl.border` and `Dropdown.border` accept either a single border or a `ControlState` dictionary, so the focused, error and disabled borders become stylable. The five loose properties are removed. Behavior changes that follow from the new shape: * A border with no explicit `side` defers to the Material theme per state instead of always painting black, which fixes dark mode and custom themes. * `DropdownM2` gains `menu_border_radius` for the open menu, which the shared `border_radius` used to shape alongside the field. * `CupertinoTextField` translates the value to its box decoration: `InputBorder.none()` now actually removes the border where the enum value was ignored, and an outline without a `side` keeps the native iOS hairline. The M3 `Dropdown` no longer duplicates the border-building logic: it shares `parseFormFieldBorders` with `buildInputDecoration`, which also populates the `errorBorder`, `focusedErrorBorder` and `disabledBorder` slots.
Adds the 1.0.0 breaking-change guide for the `InputBorder` class hierarchy, covering the border styles, corner radius, per-state borders, the `DropdownM2` menu radius split, and code that reads or compares borders rather than setting them — `InputBorder` is no longer an enum, so comparisons against its members raise. Splits the former single `InputBorder` type page into a union index plus `OutlineInputBorder` and `UnderlineInputBorder` pages, following the `OutlinedBorder` layout. `NoInputBorder` is deliberately absent: the class is private and reached through `InputBorder.none()`. New example apps: `types/input_border/showcase` for the three border styles, `types/input_border/styling` for custom sides, radii and per-state borders, and `material/dropdownm2/styling` showing the field border and the menu radius side by side. `DropdownM2` had no examples before. Release notes gain a 1.0.x section and the missing 0.86.x patch entries.
The msgpack encoder emits a nested dataclass unconditionally, including when it equals its field's `default_factory` product, while the list, dict and scalar branches beside it prune values that match their defaults. That asymmetry is load-bearing rather than accidental. The encoder also writes the `__prev_*` snapshots that are the differ's only model of client state, and in-place mutations of a nested value produce nested-path patch ops, which require the client to already hold the parent key. Pruning here without a differ that emits whole-value replaces for pruned fields leaves the client applying a patch into a key it never received. Record the constraint at the branch so it is not removed as a stray inconsistency.
`CupertinoTextField` decorates with a `BoxDecoration`, which holds a single static border, so a `ControlState` dictionary passed to `border` collapsed to its `DEFAULT` entry and the remaining states were silently dropped. The control already rebuilds on focus change and knows whether it is disabled, so the applicable entry can be resolved at build time: `DISABLED` takes precedence, then `FOCUSED`, then `DEFAULT`. As on the Material side, a state entry without a `side` inherits the default entry's side. `ERROR` remains unsupported because the control does not render an error state at all. The translation also moves out of `build()` into `parseFormFieldBoxBorder` in `utils/form_field.dart`, beside the Material `parseFormFieldBorders`. Both consume the same wire shape, so keeping them adjacent makes it harder for one to drift when a border type is added or its defaults change.
Inherited properties are documented on the class that declares them, so
`border` was described only on `FormFieldControl`, in terms of a Material
input decoration: theme-resolved sides and a slot per interactive state.
`CupertinoTextField` renders a box decoration instead, where an outline
without a side keeps the platform border, an underline draws one edge, and
there is no error state to style.
Redeclare the property so the control documents its own behaviour, and drop
the class-level note it replaces. The field is redeclared `kw_only=True`:
`FormFieldControl` is a keyword-only dataclass while this control is not, so
without it the property would become the first positional parameter and
`CupertinoTextField("hello")` would set the border rather than the text.
Flutter defaults `RoundedRectangleBorder.borderRadius` to `BorderRadius.zero`, a static constructor constant, but the property was declared `Optional` with a `None` default — which reads as "no radius configured" when the shape always applies zero. Declaring it `BorderRadiusValue = 0` makes the signature state what the widget does. Rendering is unchanged: the Dart parser already falls back to `BorderRadius.zero` for an absent key, and the encoder prunes a value equal to its declared default, so the unset case and an explicit `radius=0` both put nothing on the wire. `BeveledRectangleBorder` and `ContinuousRectangleBorder` inherit the field. The `copy()` signatures keep `Optional`/`None`, where `None` means "keep the current value" rather than "no radius". Passing `radius=None` explicitly still works at runtime but is now a type error.
The rendered signature already shows `field(default_factory=OutlineInputBorder)`, so the trailing "Defaults to ..." line restated it. Removed from `FormFieldControl.border` and `Dropdown.border`, matching how `CupertinoTextField.border` documents the same property.
Properties whose real default is a fixed constant were declared `Optional[X] = None`, so the signature said "unset" while the widget always applied a value. Several docstrings had to spell the truth out in prose — "Defaults to opaque black", "If not set, the effective default is `4.0`" — which is the tell that the signature was wrong. Declare those defaults concretely so the signature states what the control does. `Optional = None` stays wherever a widget resolves the value at runtime from the theme, the platform or its own state, because there `None` is honest: `Paint.gradient` and `CupertinoAppBar.brightness` are untouched, for example. Paint: `color`, `blend_mode`, `anti_alias`, `stroke_cap`, `stroke_join`, `stroke_miter_limit`, `stroke_width`, `style`. Controls: `Button.autofocus`, `FormFieldControl.fit_parent_size`, `Semantics.container`, `BasePage.show_semantics_debugger`, `Text.no_wrap`, `GridView.clip_behavior`, `ExpansionPanelList.spacing`, the three `CupertinoAppBar.automatic*` flags, `canvas.Path.Rect.border_radius` and `canvas.Text.max_width`. Rendering is unchanged throughout: every Dart parser already falls back to the same constant when the key is absent, and the encoder prunes a value equal to its declared default, so the unset case and an explicitly-passed default now encode identically while non-default values still transmit. Prose that only restated a default is dropped, since the signature carries it. Passing `None` explicitly still works at runtime but is now a type error.
The rendered signature already carries the default, so prose repeating it was redundant on `RoundedRectangleBorder.radius`, `OutlineInputBorder.border_radius` and `gap_padding`, matching the `border` properties. Also reference `InputBorder` from the Dart doc comment instead of naming it in prose.
Deploying flet-website-v2 with
|
| Latest commit: |
ac01bf6
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4a1be7a5.flet-website-v2.pages.dev |
| Branch Preview URL: | https://fix-textfield-border.flet-website-v2.pages.dev |
Root changelog covers the two user-facing breaking changes: the `InputBorder` class hierarchy with per-state borders and the removed loose properties, and the properties that now declare their constant Flutter default rather than `Optional = None`, so reading one returns the value the control applies. The Dart package changelog covers only what extension authors must know: the `FormFieldInputBorder` enum and its parse helpers are gone, replaced by `parseInputBorder()`, `parseFormFieldBorders()` and `parseFormFieldBoxBorder()`. Also link the pull request from the migration guide's references.
`CupertinoTextField` decorates with a `BoxDecoration`, which holds a single static border, so `parseFormFieldBoxBorder` resolves the applicable `ControlState` entry itself rather than handing the framework a slot per state. That resolution treated `disabled` and `focused` as peers, so a disabled control whose border map omits `DISABLED` fell through to the `FOCUSED` entry instead of the default one. `InputDecorator` short-circuits on disabled and never consults focus, and the Material path inherits that by populating the border slots; make the Cupertino path agree. Reachable because `CupertinoTextField` clears `canRequestFocus` in `didUpdateWidget`, after this build has already chosen a border, so disabling a focused field painted the focused border until the focus listener caught up.
The class hierarchy replaced the `InputBorder` enum and the five loose border properties outright, so every app styling a form field had to be edited before it would run. Keep the old spellings working for three minor releases instead. `InputBorder.OUTLINE`, `UNDERLINE` and `NONE` resolve again — a metaclass serves them, returning the equivalent class instance — so old code not only runs but produces new-API values. `border_radius`, `border_width`, `border_color`, `focused_border_width` and `focused_border_color` return to `FormFieldControl` and `Dropdown`, and `DropdownM2.border_radius` becomes an alias for `menu_border_radius`. All of them warn through `V.deprecated` and are scheduled for removal in `1.3.0`. Old and new values combine per the repo's non-copying model: Python sends both and Dart prefers the new one, so a `border` carrying a side or per-state entries ignores the deprecated properties, while a bare `border=InputBorder.UNDERLINE` still picks up a legacy `border_color`. That requires `border` to be `Optional` again — with a `default_factory` the client cannot tell a set value from a default, so the fallback could never fire; it becomes non-optional once the deprecations are removed. What stays breaking is what no shim can cover: `InputBorder` is no longer iterable, its members have no `.value` or `.name`, identity comparison no longer holds, and the rendering changes remain. The changelog now separates those from the deprecations, and the guide documents both.
# Conflicts: # CHANGELOG.md # website/docs/updates/breaking-changes/index.md # website/docs/updates/release-notes.md # website/sidebars.yml
…ckage import The matplotlib and plotly guards caught ImportError only, so a dependency that was installed but failed to import propagated out of `import flet_charts` and took the whole package down. That is the Android case: Flet ships site-packages inside a zip, and matplotlib reads mpl-data/matplotlibrc through a real __file__ path, so `import matplotlib` raises NotADirectoryError. Every app importing flet_charts crashed at startup — including apps that never drew a matplotlib chart, since flet_charts/__init__.py imports matplotlib_chart eagerly. Catch Exception instead, fold matplotlib.use() into the same guard (backend selection touches matplotlib's config and can fail on its own), and name the underlying cause in the deferred ModuleNotFoundError. Also documents `adb logcat -s flet.python` in the Android ADB tips — the tag dart_bridge writes the app's stdout/stderr to, and the quickest way to capture a startup traceback. The Troubleshooting table already pointed at that section for tracebacks; it had no logcat entry to point at.
JavaScriptMode is exported from flet_webview but had no docs page, so the :attr:`flet_webview.JavaScriptMode.UNRESTRICTED` reference in WebView.set_javascript_mode rendered as raw reST text.
The iOS publish guide had no logging guidance at all — the Android one at least had ADB tips. Flet's dart_bridge sends the app's stdout/stderr to Apple's unified log via os_log, and to a console.log in the app container. Verified on a booted simulator running a Flet app: `sender == "dart_bridge"` is the predicate that isolates the Python output (97 lines vs 419 when filtering by process, the rest being UIKit/FrontBoard chatter), and the console.log path resolves through `simctl get_app_container`.
The two platform guides described the same task differently: iOS had a top-level "Reading your app's output" section, while the Android equivalent sat as item 5 of "ADB Tips", a generic adb primer where nobody looking for app output would think to check. Android now has the section too, immediately before Troubleshooting like iOS's, and the two follow the same shape: where stdout/stderr goes, the streaming command, why that filter, the dump-and-exit variant, the console.log alternative, and what changes on a physical device. ADB Tips item 5 and the Troubleshooting row now point at it. Placeholders follow each guide's own term: <applicationId> on Android (matching the Extract packages section), <bundleId> on iOS.
"Console output" in the build guide said output is redirected to a console.log file, full stop. Since 0.86 the dart_bridge also sends it to logcat on Android and the unified log on iOS — and on Android that is the better route, because console.log sits in private storage and needs root. The three sections answer different questions, so they stay separate and now point at each other: the build guide covers reaching the output from inside your app (StoragePaths / FLET_APP_CONSOLE / the sys.exit(100) window), and the platform guides cover reading it off a device from your machine.
macOS had no output guidance at all, and it behaves like iOS: dart_bridge sends the app's stdout/stderr to the unified log and to a console.log in the app's cache dir. The commands are simpler though — plain `log stream` / `log show`, no `xcrun simctl spawn`. Verified against a packaged app on this machine: `sender == "dart_bridge"` isolates the Python output, stdout arrives at default level and stderr as an error, and console.log lands at ~/Library/Caches/<bundleId>/console.log with FLET_APP_CONSOLE pointing at it. Also documents the desktop-specific surprise: running the packaged binary from a terminal shows none of it. The probe printed five lines; all five went to console.log and the terminal showed only Flutter's own message. Placed before Code signing — everything from there on is distribution, and this is a development concern. The Console output hub in index.md now lists all three platforms.
The three border kinds were constructed two different ways: two classes and
a factory on the base class, `InputBorder.none()`, which also read oddly next
to the legacy `InputBorder.NONE` the metaclass still serves. `NoInputBorder`
is now public and exported, so all three read the same:
ft.TextField(border=ft.NoInputBorder())
`InputBorder.none()` is dropped rather than kept as an alias — it only ever
existed on this branch, so nothing released depends on it. The legacy
`InputBorder.NONE` still resolves with its deprecation warning, now naming
`NoInputBorder()` as the replacement.
Also gives the metaclass the standard "type object 'InputBorder' has no
attribute 'none'" message instead of a bare AttributeError(name), which
matters more now that `none` is a plausible thing to reach for.
Docs follow: a types/noinputborder.md page under the InputBorder sidebar
group, the migration guide's examples and xrefs, the CupertinoTextField
border docstring, the CHANGELOG entries, and the two examples that used the
factory. Dart is untouched — the wire format is still `_type: "none"`.
Verified: crocodocs generate resolves flet.NoInputBorder to
/docs/types/noinputborder with no dangling xrefs, ruff check/format are
clean, and the 247 Python tests pass.
…tom sheet An unconfigured form field no longer paints a hardcoded opaque black border. With `border` unset, `enabledBorder` is left null so Flutter's InputDecorator resolves the side per state, which on Material 3 gives `colorScheme.outline` for the enabled state. Nine screenshot goldens carried the old black outline; the only delta in each is (0,0,0,255) -> the themed gray. Disabled fields are unchanged, having always been theme-resolved. The docs GIFs written by `create_gif` are refreshed for the same reason - they showed the old black border. The two cupertino goldens are unrelated to the border work: 9f463fd switched CupertinoBottomSheet to `MaterialType.transparency`, and its opaque canvas had been painting over the action sheet's insets and rounded corners. Those goldens were never regenerated for it. The eleven PNGs are taken verbatim from CI, which produced them byte-identically across two runs.
Every workflow keyed its concurrency group on `github.event.pull_request.head.ref || github.ref_name`. For a same-repo branch with an open PR, the `push` and `pull_request` events resolve that expression to the *same* string, so the two runs of one commit landed in a single group and `cancel-in-progress` killed one of them. The cancelled run still reports a check on the PR head SHA, which is why every PR carried a red `Run zizmor` (zizmor has no path filter, so it collided on every push) and why PRs whose last pushed commit matched the other workflows' path filters showed whole walls of 2-3s "failing" jobs. Key the group on `github.ref` instead. It differs per event (`refs/heads/<branch>` vs `refs/pull/<n>/merge`), so a pull_request run can no longer cancel the push run of the same commit, while pushing again to the same branch still supersedes the previous run as before. It is also the correct unit of work: a push run builds the branch head and a pull_request run builds the merge commit, so the two were never interchangeable. As a bonus it stops fork PRs from sharing a group whenever two contributors happen to use the same branch name, and separates tags from branches. That alone would leave both runs of a commit executing to completion instead of one being cancelled, so remove the overlap where it is expensive: - `flet-test.yml`, `macos-integration-tests.yml` and `flet-build-test.yml` drop their `push` trigger. These carry the macOS, Windows and emulator legs, and their head-of-branch build duplicates the merge build the pull request already runs. All three already skipped `main`, so `pull_request` was their only non-duplicate coverage; `workflow_dispatch` remains for running them on a branch before a pull request exists. - `zizmor.yml` moves its `push` trigger to `main` only. It has no path filter, so it is the one workflow that ran twice on *every* push. - `ci.yml` keeps its `push` trigger unchanged: it is comparatively cheap and carries the tag-driven publish jobs. While here, focus the path filters, which were wrong in both directions: - `sdk/python/templates/**` was watched by `ci.yml` alone, yet it is what `flet build` renders. A templates-only change ran neither `flet-build-test` nor `flet-test`; both now watch it. - `flet-test.yml` gains `sdk/python/packages/flet-desktop/**`. The counter app's dev dependencies are `flet-cli`, `flet-desktop` and `flet[test]`, but only the first was listed. - `zizmor.yml` gains `paths: ['.github/**']`. It only ever reads workflow and action definitions, and no `action.yml` in this repo lives outside `.github/`, so its verdict cannot change unless that tree changes. Also drop the `/blog/flet-1-0` link from the release notes. The 1.0 announcement post does not exist yet, so Docusaurus failed the build on `onBrokenLinks: throw`, taking `Build Documentation` and the Cloudflare Pages preview down with it on every PR branched off main. Confirmed against main directly: a `workflow_dispatch` run of `ci.yml` on main fails on this one job and nothing else.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
InputBorderwas an enum (OUTLINE/UNDERLINE/NONE) paired with five loose properties on every form field:border_radius,border_width,border_color,focused_border_widthandfocused_border_color. That shape could not express Flutter's API —gap_paddingwas unavailable,UnderlineInputBorder's corner radius was silently ignored, and the error and disabled borders could not be styled at all — and every new Flutter border property would have required another top-level property on each control.InputBorderis now a base class withOutlineInputBorder,UnderlineInputBorderandInputBorder.none(), mirroring Flutter's classes and their defaults.borderaccepts either a single border or aControlStatedictionary, so the focused, error and disabled borders become stylable.Migration guide:
website/docs/updates/breaking-changes/v1-0-0/inputborder-class-hierarchy.md.Nothing is removed in 1.0.0
The old spellings keep working for three minor releases, so existing apps run unedited:
InputBorder.OUTLINE,UNDERLINEandNONEstill resolve. A metaclass serves them, returning the equivalent class instance — so old code not only runs but produces new-API values, andInputBorder.OUTLINE == OutlineInputBorder()holds.border_radius,border_width,border_color,focused_border_widthandfocused_border_colorstill apply onTextField,Dropdown,DropdownM2andCupertinoTextField.DropdownM2.border_radiusis an alias for the newmenu_border_radius.All of them warn through
V.deprecatedand are scheduled for removal in1.3.0.Old and new combine through the repo's non-copying model: Python sends both, Dart prefers the new one. A
bordercarrying a side or per-state entries ignores the deprecated properties entirely, while a bareborder=ft.InputBorder.UNDERLINEstill picks up a legacyborder_color. This is whyborderisOptionalagain — with adefault_factorythe client cannot distinguish a set value from a default, so the fallback could never fire. It becomes non-optional when the deprecations are removed.What is still breaking
Only what a shim cannot cover:
InputBorderis not iterable, its members have no.valueor.name, andInputBorder.OUTLINE is InputBorder.OUTLINEis nowFalse— each access returns a new instance, so compare with==.sidetakes its colour and weight from the Material theme per state instead of always painting black, so dark mode and custom themes work. An underline finally honours itsborder_radius.DropdownM2's open menu is shaped bymenu_border_radiusrather than the field's radius. OnCupertinoTextField,InputBorder.none()now actually removes the border (the enum value was ignored) and an outline without asidekeeps the native iOS one.Optional[...] = None, so reading one returns the value the control applies instead ofNone: the eightPaintstyle properties,RoundedRectangleBorder.radius,Button.autofocus,Text.no_wrap,GridView.clip_behavior,Semantics.container,ExpansionPanelList.spacing,TextField.fit_parent_size,Page.show_semantics_debugger, the threeCupertinoAppBar.automatic*flags,Path.Rect.border_radiusandcanvas.Text.max_width. Rendering is unchanged, and properties a widget resolves at runtime from the theme, the platform or its own state keepNone.Also in this PR
A protocol constraint recorded in
protocol.py. The encoder emits nested dataclasses unconditionally, including when they equal their field'sdefault_factoryproduct, while the list, dict and scalar branches beside it prune. That asymmetry is load-bearing: the differ patches nested fields in place with nested-path ops, which require the client to already hold the parent key, so pruning is only safe alongside a differ that emits whole-value replaces for pruned fields.Docs and examples
InputBordertype page split into a union index withOutlineInputBorderandUnderlineInputBorderpages.NoInputBorderis intentionally absent — the class is private and reached throughInputBorder.none().types/input_border/showcase(the three border styles),types/input_border/styling(custom sides, radii and per-state borders), andmaterial/dropdownm2/styling(field border vs menu radius —DropdownM2had no examples before).Summary by Sourcery
Adopt a Flutter-aligned input border class hierarchy with state-aware styling while providing a migration path for the deprecated enum and border properties.
New Features:
InputBorder,OutlineInputBorder, andUnderlineInputBorderclasses, including custom sides, radii, gap padding, and borderless input.DropdownM2.menu_border_radiusconfiguration and expose the new border classes through the Python SDK and Dart protocol.Bug Fixes:
Enhancements:
Documentation:
Chores: