Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions cbits/ui_bridge_android.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions include/UIBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions ios/Hatter/UIBridgeIOS.m
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSLayoutConstraint *> *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]]) {
Expand Down
3 changes: 3 additions & 0 deletions src/Hatter/Animation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ defaultStyleEmpty = WidgetStyle
, wsBackgroundColor = Nothing
, wsTranslateX = Nothing
, wsTranslateY = Nothing
, wsWidth = Nothing
, wsTouchPassthrough = Nothing
}

Expand Down Expand Up @@ -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 ->
Expand Down
3 changes: 3 additions & 0 deletions src/Hatter/Render.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/Hatter/UIBridge.hs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ data PropId
| PropTranslateY
| PropAutoFocus
| PropTouchPassthrough
| PropWidth
deriving (Show, Eq, Enum, Bounded)

-- | Map a 'PropId' to its C integer code.
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/Hatter/Widget.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -217,6 +221,7 @@ defaultStyle = WidgetStyle
, wsBackgroundColor = Nothing
, wsTranslateX = Nothing
, wsTranslateY = Nothing
, wsWidth = Nothing
, wsTouchPassthrough = Nothing
}

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions test/CounterDemoMain.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions test/Test/ActionTests.hs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import Hatter
, runActionM
)
import Hatter.Widget
( ButtonConfig(..)
( defaultStyle
, ButtonConfig(..)
, InputType(..)
, LayoutSettings(..)
, TextConfig(..)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions test/Test/AnimationTests.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 19 additions & 11 deletions test/Test/WidgetTests.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 })
]

Expand All @@ -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) })
Expand All @@ -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" $
Expand Down
3 changes: 3 additions & 0 deletions watchos/Hatter/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions watchos/Hatter/WatchUIBridgeState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions watchos/Hatter/WatchUINode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading