From a0185b253803fc854941d56f7053617bdbe78d89 Mon Sep 17 00:00:00 2001 From: jappeace-sloth Date: Tue, 14 Jul 2026 19:35:40 +0200 Subject: [PATCH 1/2] Add wsWidth: a fixed-width style property through all bridges Resolves #245 (needed by kbeacon-ota-tool's beacon table, whose content-sized row cells could not align into columns): - WidgetStyle gains wsWidth (platform-native units like wsPadding), applied by Render.applyStyle and lerped by keyframe animations. - Android sets ViewGroup.LayoutParams(width, WRAP_CONTENT), the same pattern the gravity case already uses; iOS pins a width anchor constraint (replacing any earlier one on re-render); watchOS frames the SwiftUI node. - The positional WidgetStyle constructions in tests are converted to defaultStyle record updates, so future style fields stop breaking every construction site (this bit twice during #243). Prompt: just go implement this too if you need it for my request: https://github.com/jappeace/hatter/issues/245 the point of this ota app is alos to sorta fuzz hatter for obviously missing features Co-Authored-By: Claude Fable 5 --- Changelog.md | 7 ++++++ cbits/ui_bridge_android.c | 13 +++++++++++ include/UIBridge.h | 1 + ios/Hatter/UIBridgeIOS.m | 18 +++++++++++++++ src/Hatter/Animation.hs | 3 +++ src/Hatter/Render.hs | 3 +++ src/Hatter/UIBridge.hs | 2 ++ src/Hatter/Widget.hs | 6 +++++ test/CounterDemoMain.hs | 2 +- test/Test/ActionTests.hs | 9 ++++---- test/Test/AnimationTests.hs | 5 +++++ test/Test/WidgetTests.hs | 30 ++++++++++++++++--------- watchos/Hatter/ContentView.swift | 3 +++ watchos/Hatter/WatchUIBridgeState.swift | 3 +++ watchos/Hatter/WatchUINode.swift | 1 + 15 files changed, 90 insertions(+), 16 deletions(-) diff --git a/Changelog.md b/Changelog.md index 7d4671a1..0c359efa 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,13 @@ ## Unreleased +### Added + +- `wsWidth`: a fixed-width style property (platform-native units, + like `wsPadding`) applied through all platform bridges and + animatable via keyframes, so sibling rows can align into table + columns (#245). + ### Changed - BREAKING: text color moved from `WidgetStyle` into the text-bearing diff --git a/cbits/ui_bridge_android.c b/cbits/ui_bridge_android.c index 6b0f686a..24bf3f6a 100644 --- a/cbits/ui_bridge_android.c +++ b/cbits/ui_bridge_android.c @@ -741,6 +741,19 @@ static void android_set_num_prop(int32_t nodeId, int32_t propId, double value) LOGI("setNumProp(node=%d, padding=%d)", nodeId, px); break; } + case UI_PROP_WIDTH: { + /* Fixed width in raw pixels (same unit convention as + * UI_PROP_PADDING); height stays WRAP_CONTENT. The gravity + * case above uses the same LayoutParams pattern. */ + jint px = (jint)value; + jobject layoutParams = (*env)->NewObject(env, + g_class_ViewGroup_LayoutParams, g_ctor_ViewGroup_LayoutParams, + px, (jint)-2); /* width, WRAP_CONTENT */ + (*env)->CallVoidMethod(env, view, g_method_setLayoutParams, layoutParams); + (*env)->DeleteLocalRef(env, layoutParams); + LOGI("setNumProp(node=%d, width=%d)", nodeId, px); + break; + } case UI_PROP_INPUT_TYPE: { /* Haskell 0 = InputText -> Android TYPE_CLASS_TEXT (1) * Haskell 1 = InputNumber -> Android TYPE_CLASS_NUMBER | diff --git a/include/UIBridge.h b/include/UIBridge.h index 42523b66..397b9ee9 100644 --- a/include/UIBridge.h +++ b/include/UIBridge.h @@ -39,6 +39,7 @@ #define UI_PROP_TRANSLATE_Y 10 #define UI_PROP_AUTO_FOCUS 11 #define UI_PROP_TOUCH_PASSTHROUGH 12 +#define UI_PROP_WIDTH 13 /* Event types */ #define UI_EVENT_CLICK 0 diff --git a/ios/Hatter/UIBridgeIOS.m b/ios/Hatter/UIBridgeIOS.m index f449756e..1af999bc 100644 --- a/ios/Hatter/UIBridgeIOS.m +++ b/ios/Hatter/UIBridgeIOS.m @@ -580,6 +580,24 @@ static void ios_set_num_prop(int32_t nodeId, int32_t propId, double value) LOGI("setNumProp(node=%d, padding=%.1f)", nodeId, value); break; } + case UI_PROP_WIDTH: { + /* Fixed width in points; a constraint overrides the + * intrinsic content width inside the UIStackView layout. + * Deactivate any width constraint from an earlier render + * before pinning the new one. */ + CGFloat pt = (CGFloat)value; + NSArray *existing = [view.constraints copy]; + for (NSLayoutConstraint *constraint in existing) { + if (constraint.firstAttribute == NSLayoutAttributeWidth + && constraint.firstItem == view + && constraint.secondItem == nil) { + constraint.active = NO; + } + } + [view.widthAnchor constraintEqualToConstant:pt].active = YES; + LOGI("setNumProp(node=%d, width=%.1f)", nodeId, value); + break; + } case UI_PROP_SCALE_TYPE: { /* Haskell 0 = ScaleFit, 1 = ScaleFill, 2 = ScaleNone */ if ([view isKindOfClass:[UIImageView class]]) { diff --git a/src/Hatter/Animation.hs b/src/Hatter/Animation.hs index c1859913..93a66e51 100644 --- a/src/Hatter/Animation.hs +++ b/src/Hatter/Animation.hs @@ -187,6 +187,7 @@ defaultStyleEmpty = WidgetStyle , wsBackgroundColor = Nothing , wsTranslateX = Nothing , wsTranslateY = Nothing + , wsWidth = Nothing , wsTouchPassthrough = Nothing } @@ -217,6 +218,8 @@ interpolateStyle nodeId fromStyle toStyle progress = do (wsTranslateX fromStyle) (wsTranslateX toStyle) lerpNumProp Bridge.PropTranslateY (wsTranslateY fromStyle) (wsTranslateY toStyle) + lerpNumProp Bridge.PropWidth + (wsWidth fromStyle) (wsWidth toStyle) -- TouchPassthrough is boolean — snaps to target, no interpolation. case wsTouchPassthrough toStyle of Just enabled -> diff --git a/src/Hatter/Render.hs b/src/Hatter/Render.hs index 63dd387e..06552dff 100644 --- a/src/Hatter/Render.hs +++ b/src/Hatter/Render.hs @@ -170,6 +170,9 @@ applyStyle nodeId style = do case wsTranslateY style of Just ty -> Bridge.setNumProp nodeId Bridge.PropTranslateY ty Nothing -> pure () + case wsWidth style of + Just width -> Bridge.setNumProp nodeId Bridge.PropWidth width + Nothing -> pure () case wsTouchPassthrough style of Just enabled -> Bridge.setNumProp nodeId Bridge.PropTouchPassthrough (if enabled then 1.0 else 0.0) diff --git a/src/Hatter/UIBridge.hs b/src/Hatter/UIBridge.hs index 9ee6045f..321ac83f 100644 --- a/src/Hatter/UIBridge.hs +++ b/src/Hatter/UIBridge.hs @@ -86,6 +86,7 @@ data PropId | PropTranslateY | PropAutoFocus | PropTouchPassthrough + | PropWidth deriving (Show, Eq, Enum, Bounded) -- | Map a 'PropId' to its C integer code. @@ -110,6 +111,7 @@ propIdToInt PropTranslateX = 9 propIdToInt PropTranslateY = 10 propIdToInt PropAutoFocus = 11 propIdToInt PropTouchPassthrough = 12 +propIdToInt PropWidth = 13 -- | Event types corresponding to @UI_EVENT_*@ in @UIBridge.h@. data EventType diff --git a/src/Hatter/Widget.hs b/src/Hatter/Widget.hs index d0440f3f..1d271146 100644 --- a/src/Hatter/Widget.hs +++ b/src/Hatter/Widget.hs @@ -201,6 +201,10 @@ data WidgetStyle = WidgetStyle , wsTranslateY :: Maybe Double -- ^ Vertical translation offset in platform-native units. -- Moves the widget without affecting sibling layout. + , wsWidth :: Maybe Double + -- ^ Fixed width in platform-native units (px on Android, pt on + -- iOS), like 'wsPadding'. Meaningful on any node, so sibling + -- rows can align into table columns (#245). , wsTouchPassthrough :: Maybe Bool -- ^ When 'True', the widget does not intercept touches, allowing -- sibling views underneath (in a 'Stack') to receive them. @@ -217,6 +221,7 @@ defaultStyle = WidgetStyle , wsBackgroundColor = Nothing , wsTranslateX = Nothing , wsTranslateY = Nothing + , wsWidth = Nothing , wsTouchPassthrough = Nothing } @@ -325,6 +330,7 @@ lerpStyle t from to = WidgetStyle , wsBackgroundColor = lerpMaybeColor (wsBackgroundColor from) (wsBackgroundColor to) , wsTranslateX = lerpMaybeDouble (wsTranslateX from) (wsTranslateX to) , wsTranslateY = lerpMaybeDouble (wsTranslateY from) (wsTranslateY to) + , wsWidth = lerpMaybeDouble (wsWidth from) (wsWidth to) , wsTouchPassthrough = snapMaybe (wsTouchPassthrough from) (wsTouchPassthrough to) } where diff --git a/test/CounterDemoMain.hs b/test/CounterDemoMain.hs index cb92c6e5..03a7dfa4 100644 --- a/test/CounterDemoMain.hs +++ b/test/CounterDemoMain.hs @@ -32,7 +32,7 @@ counterView :: IORef Int -> Action -> Action -> IO Widget counterView counterState onIncrement onDecrement = do n <- readIORef counterState pure $ column - [ Styled (WidgetStyle (Just 16.0) (Just AlignCenter) (Just (Color 0 255 0 255)) Nothing Nothing Nothing) + [ Styled ((defaultStyle { wsPadding = Just 16.0, wsTextAlign = Just AlignCenter, wsBackgroundColor = Just (Color 0 255 0 255) })) (Text TextConfig { tcLabel = "Counter: " <> Text.pack (show n) , tcFontConfig = Just (FontConfig 24.0) diff --git a/test/Test/ActionTests.hs b/test/Test/ActionTests.hs index 7b8a3700..18c4898f 100644 --- a/test/Test/ActionTests.hs +++ b/test/Test/ActionTests.hs @@ -20,7 +20,8 @@ import Hatter , runActionM ) import Hatter.Widget - ( ButtonConfig(..) + ( defaultStyle + , ButtonConfig(..) , InputType(..) , LayoutSettings(..) , TextConfig(..) @@ -294,7 +295,7 @@ incrementalRenderTests = testGroup "Incremental rendering" , testCase "styled unchanged keeps same node ID" $ do ((), rs) <- withActions (pure ()) - let style = WidgetStyle (Just 10.0) Nothing Nothing Nothing Nothing Nothing + let style = defaultStyle { wsPadding = Just 10.0 } widget = Styled style (Text TextConfig { tcLabel = "styled", tcFontConfig = Nothing, tcTextColor = Nothing }) renderWidget rs widget tree1 <- readIORef (rsRenderedTree rs) @@ -310,7 +311,7 @@ incrementalRenderTests = testGroup "Incremental rendering" , testCase "styled child change updates in-place" $ do ((), rs) <- withActions (pure ()) - let style = WidgetStyle (Just 10.0) Nothing Nothing Nothing Nothing Nothing + let style = defaultStyle { wsPadding = Just 10.0 } widget1 = Styled style (Text TextConfig { tcLabel = "before", tcFontConfig = Nothing, tcTextColor = Nothing }) renderWidget rs widget1 tree1 <- readIORef (rsRenderedTree rs) @@ -329,7 +330,7 @@ incrementalRenderTests = testGroup "Incremental rendering" , testCase "styled child type change reapplies style" $ do (clickAction, rs) <- withActions $ createAction (pure ()) - let style = WidgetStyle (Just 10.0) Nothing Nothing Nothing Nothing Nothing + let style = defaultStyle { wsPadding = Just 10.0 } widget1 = Styled style (Text TextConfig { tcLabel = "txt", tcFontConfig = Nothing, tcTextColor = Nothing }) renderWidget rs widget1 -- Re-render with different child type but same style diff --git a/test/Test/AnimationTests.hs b/test/Test/AnimationTests.hs index fa0c55e7..0eb1e43a 100644 --- a/test/Test/AnimationTests.hs +++ b/test/Test/AnimationTests.hs @@ -675,6 +675,11 @@ smartConstructorTests = testGroup "Smart constructors" to = defaultStyle { wsPadding = Just 90 } wsPadding (lerpStyle 0.0 from to) @?= Just 10 wsPadding (lerpStyle 1.0 from to) @?= Just 90 + + , testCase "lerpStyle interpolates widths" $ do + let from = defaultStyle { wsWidth = Just 100 } + to = defaultStyle { wsWidth = Just 200 } + wsWidth (lerpStyle 0.5 from to) @?= Just 150 , testCase "lerpStyle interpolates colors" $ do let red = Color 255 0 0 255 blue = Color 0 0 255 255 diff --git a/test/Test/WidgetTests.hs b/test/Test/WidgetTests.hs index b5cf06b1..fdc7ab4f 100644 --- a/test/Test/WidgetTests.hs +++ b/test/Test/WidgetTests.hs @@ -566,14 +566,14 @@ styledTests :: TestTree styledTests = testGroup "Styled" [ testCase "Styled Text renders without error" $ do ((), rs) <- withActions (pure ()) - renderWidget rs $ Styled (WidgetStyle (Just 8.0) Nothing Nothing Nothing Nothing Nothing) + renderWidget rs $ Styled (defaultStyle { wsPadding = Just 8.0 }) (Text TextConfig { tcLabel = "styled", tcFontConfig = Just (FontConfig 20.0), tcTextColor = Nothing }) , testCase "Styled Button fires callback" $ do ref <- newIORef (0 :: Int) (clickHandle, rs) <- withActions $ createAction (modifyIORef' ref (+ 1)) - renderWidget rs $ Styled (WidgetStyle Nothing Nothing Nothing Nothing Nothing Nothing) + renderWidget rs $ Styled (defaultStyle) (Button ButtonConfig { bcLabel = "tap", bcAction = clickHandle , bcFontConfig = Just (FontConfig 16.0), bcTextColor = Nothing }) @@ -597,8 +597,8 @@ styledTests = testGroup "Styled" , testCase "nested Styled applies both styles" $ do ((), rs) <- withActions (pure ()) renderWidget rs $ - Styled (WidgetStyle (Just 12.0) Nothing Nothing Nothing Nothing Nothing) - (Styled (WidgetStyle Nothing Nothing Nothing Nothing Nothing Nothing) + Styled (defaultStyle { wsPadding = Just 12.0 }) + (Styled (defaultStyle) (Text TextConfig { tcLabel = "double styled", tcFontConfig = Just (FontConfig 18.0), tcTextColor = Nothing })) , testCase "defaultStyle is a no-op" $ do @@ -626,14 +626,14 @@ textAlignTests :: TestTree textAlignTests = testGroup "TextAlignment" [ testCase "Styled with AlignCenter renders without error" $ do ((), rs) <- withActions (pure ()) - renderWidget rs $ Styled (WidgetStyle Nothing (Just AlignCenter) Nothing Nothing Nothing Nothing) + renderWidget rs $ Styled (defaultStyle { wsTextAlign = Just AlignCenter }) (Text TextConfig { tcLabel = "centered", tcFontConfig = Nothing, tcTextColor = Nothing }) , testCase "Styled with AlignCenter on Button fires callback" $ do ref <- newIORef (0 :: Int) (clickHandle, rs) <- withActions $ createAction (modifyIORef' ref (+ 1)) - renderWidget rs $ Styled (WidgetStyle Nothing (Just AlignCenter) Nothing Nothing Nothing Nothing) + renderWidget rs $ Styled (defaultStyle { wsTextAlign = Just AlignCenter }) (Button ButtonConfig { bcLabel = "tap", bcAction = clickHandle, bcFontConfig = Nothing, bcTextColor = Nothing }) dispatchEvent rs (actionId clickHandle) @@ -645,7 +645,7 @@ textAlignTests = testGroup "TextAlignment" , testCase "Styled with AlignEnd renders without error" $ do ((), rs) <- withActions (pure ()) - renderWidget rs $ Styled (WidgetStyle Nothing (Just AlignEnd) Nothing Nothing Nothing Nothing) + renderWidget rs $ Styled (defaultStyle { wsTextAlign = Just AlignEnd }) (Text TextConfig { tcLabel = "end aligned", tcFontConfig = Nothing, tcTextColor = Nothing }) ] @@ -669,14 +669,14 @@ colorTests = testGroup "Colors" , testCase "Styled with backgroundColor renders without error" $ do ((), rs) <- withActions (pure ()) - renderWidget rs $ Styled (WidgetStyle Nothing Nothing (Just (Color 0 255 0 255)) Nothing Nothing Nothing) + renderWidget rs $ Styled (defaultStyle { wsBackgroundColor = Just (Color 0 255 0 255) }) (Text TextConfig { tcLabel = "green bg", tcFontConfig = Nothing, tcTextColor = Nothing }) , testCase "config text color and styled background together" $ do ref <- newIORef (0 :: Int) (clickHandle, rs) <- withActions $ createAction (modifyIORef' ref (+ 1)) - renderWidget rs $ Styled (WidgetStyle Nothing Nothing (Just (Color 0 255 0 255)) Nothing Nothing Nothing) + renderWidget rs $ Styled (defaultStyle { wsBackgroundColor = Just (Color 0 255 0 255) }) (Button ButtonConfig { bcLabel = "colored", bcAction = clickHandle , bcFontConfig = Nothing, bcTextColor = Just (Color 255 0 0 255) }) @@ -687,11 +687,19 @@ colorTests = testGroup "Colors" , testCase "defaultStyle has no colors" $ do wsBackgroundColor defaultStyle @?= Nothing + , testCase "defaultStyle has no width" $ + wsWidth defaultStyle @?= Nothing + + , testCase "Styled with a fixed width renders without error" $ do + ((), rs) <- withActions (pure ()) + renderWidget rs $ Styled (defaultStyle { wsWidth = Just 120.0 }) + (Text TextConfig { tcLabel = "cell", tcFontConfig = Nothing, tcTextColor = Nothing }) + , testCase "nested Styled with a colored text child renders" $ do ((), rs) <- withActions (pure ()) renderWidget rs $ - Styled (WidgetStyle (Just 4.0) Nothing Nothing Nothing Nothing Nothing) - (Styled (WidgetStyle Nothing Nothing (Just (Color 0 0 255 255)) Nothing Nothing Nothing) + Styled (defaultStyle { wsPadding = Just 4.0 }) + (Styled (defaultStyle { wsBackgroundColor = Just (Color 0 0 255 255) }) (coloredText (Color 255 0 0 255) "nested colors")) , testCase "colorFromText parses #RRGGBB" $ diff --git a/watchos/Hatter/ContentView.swift b/watchos/Hatter/ContentView.swift index b99ad841..dd8b269c 100644 --- a/watchos/Hatter/ContentView.swift +++ b/watchos/Hatter/ContentView.swift @@ -12,6 +12,9 @@ struct NodeView: View { .ifLet(node.padding) { view, pad in view.padding(pad) } + .ifLet(node.width) { view, width in + view.frame(width: width) + } .ifLet(node.textColor.flatMap { Color(hex: $0) }) { view, color in view.foregroundColor(color) } diff --git a/watchos/Hatter/WatchUIBridgeState.swift b/watchos/Hatter/WatchUIBridgeState.swift index 2b07f80a..b664bcfe 100644 --- a/watchos/Hatter/WatchUIBridgeState.swift +++ b/watchos/Hatter/WatchUIBridgeState.swift @@ -98,6 +98,9 @@ class WatchUIBridgeState: ObservableObject { os_log("setNumProp(node=%d, autoFocus=%.0f) — no-op on watchOS", log: bridgeLog, type: .info, nodeId, value) case 12: // UI_PROP_TOUCH_PASSTHROUGH (no-op on watchOS — ZStack hit testing is automatic) os_log("setNumProp(node=%d, touchPassthrough=%.0f) — no-op on watchOS", log: bridgeLog, type: .info, nodeId, value) + case 13: // UI_PROP_WIDTH + os_log("setNumProp(node=%d, width=%.1f)", log: bridgeLog, type: .info, nodeId, value) + node.width = CGFloat(value) default: os_log("setNumProp: unknown propId %d", log: bridgeLog, type: .info, propId) } diff --git a/watchos/Hatter/WatchUINode.swift b/watchos/Hatter/WatchUINode.swift index 26911523..fc391b93 100644 --- a/watchos/Hatter/WatchUINode.swift +++ b/watchos/Hatter/WatchUINode.swift @@ -11,6 +11,7 @@ class WatchUINode: ObservableObject, Identifiable { @Published var hint: String = "" @Published var fontSize: CGFloat? @Published var padding: CGFloat? + @Published var width: CGFloat? @Published var inputType: Int32 = 0 @Published var callbackId: Int32 = -1 @Published var textColor: String? From cea0a8022d0aa19af469e74d56c35900a3a0c612 Mon Sep 17 00:00:00 2001 From: jappeace-sloth Date: Tue, 14 Jul 2026 20:01:37 +0200 Subject: [PATCH 2/2] Fix CounterDemoMain's missing defaultStyle import The positional-to-record conversion left the demo without the defaultStyle import; nix-build (library + suite) stayed green while every platform job compiling the demos failed. The demo typecheck sweep now runs before push, which is what should have caught it. Co-Authored-By: Claude Fable 5 --- test/CounterDemoMain.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/CounterDemoMain.hs b/test/CounterDemoMain.hs index 03a7dfa4..3cefdf44 100644 --- a/test/CounterDemoMain.hs +++ b/test/CounterDemoMain.hs @@ -10,7 +10,7 @@ import Data.Text qualified as Text import Foreign.Ptr (Ptr) import Hatter (startMobileApp, platformLog, loggingMobileContext, MobileApp(..), newActionState, runActionM, createAction, Action) import Hatter.AppContext (AppContext) -import Hatter.Widget (ButtonConfig(..), Color(..), FontConfig(..), TextAlignment(..), TextConfig(..), Widget(..), WidgetStyle(..), column, row) +import Hatter.Widget (ButtonConfig(..), Color(..), FontConfig(..), TextAlignment(..), TextConfig(..), Widget(..), WidgetStyle(..), column, defaultStyle, row) main :: IO (Ptr AppContext) main = do