"""Platform-specific native view creation and update logic. This module replaces the old per-widget files. All platform-branching lives here, guarded behind lazy imports so the module can be imported on desktop for testing. """ from typing import Any, Callable, Dict, Optional, Union from .utils import IS_ANDROID # ====================================================================== # Abstract handler protocol # ====================================================================== class ViewHandler: """Protocol for creating, updating, and managing children of a native view type.""" def create(self, props: Dict[str, Any]) -> Any: raise NotImplementedError def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: raise NotImplementedError def add_child(self, parent: Any, child: Any) -> None: pass def remove_child(self, parent: Any, child: Any) -> None: pass def insert_child(self, parent: Any, child: Any, index: int) -> None: self.add_child(parent, child) # ====================================================================== # Registry # ====================================================================== class NativeViewRegistry: """Maps element type names to platform-specific :class:`ViewHandler` instances.""" def __init__(self) -> None: self._handlers: Dict[str, ViewHandler] = {} def register(self, type_name: str, handler: ViewHandler) -> None: self._handlers[type_name] = handler def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: handler = self._handlers.get(type_name) if handler is None: raise ValueError(f"Unknown element type: {type_name!r}") return handler.create(props) def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: handler = self._handlers.get(type_name) if handler is not None: handler.update(native_view, changed_props) def add_child(self, parent: Any, child: Any, parent_type: str) -> None: handler = self._handlers.get(parent_type) if handler is not None: handler.add_child(parent, child) def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: handler = self._handlers.get(parent_type) if handler is not None: handler.remove_child(parent, child) def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: handler = self._handlers.get(parent_type) if handler is not None: handler.insert_child(parent, child, index) # ====================================================================== # Shared helpers # ====================================================================== def parse_color_int(color: Union[str, int]) -> int: """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. """ if isinstance(color, int): val = color else: c = color.strip().lstrip("#") if len(c) == 6: c = "FF" + c val = int(c, 16) if val > 0x7FFFFFFF: val -= 0x100000000 return val def _resolve_padding( padding: Any, ) -> tuple: """Normalise various padding representations to ``(left, top, right, bottom)``.""" if padding is None: return (0, 0, 0, 0) if isinstance(padding, (int, float)): v = int(padding) return (v, v, v, v) if isinstance(padding, dict): h = int(padding.get("horizontal", 0)) v = int(padding.get("vertical", 0)) left = int(padding.get("left", h)) right = int(padding.get("right", h)) top = int(padding.get("top", v)) bottom = int(padding.get("bottom", v)) a = int(padding.get("all", 0)) if a: left = left or a right = right or a top = top or a bottom = bottom or a return (left, top, right, bottom) return (0, 0, 0, 0) _LAYOUT_KEYS = frozenset( { "width", "height", "flex", "margin", "min_width", "max_width", "min_height", "max_height", "align_self", } ) # ====================================================================== # Platform handler registration (lazy imports inside functions) # ====================================================================== def _register_android_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 from java import dynamic_proxy, jclass from .utils import get_android_context def _ctx() -> Any: return get_android_context() def _density() -> float: return float(_ctx().getResources().getDisplayMetrics().density) def _dp(value: float) -> int: return int(value * _density()) def _apply_layout(view: Any, props: Dict[str, Any]) -> None: """Apply common layout properties to an Android view.""" lp = view.getLayoutParams() LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") needs_set = False if lp is None: lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT) needs_set = True if "width" in props and props["width"] is not None: lp.width = _dp(float(props["width"])) needs_set = True if "height" in props and props["height"] is not None: lp.height = _dp(float(props["height"])) needs_set = True if "flex" in props and props["flex"] is not None: try: lp.weight = float(props["flex"]) needs_set = True except Exception: pass if "margin" in props and props["margin"] is not None: left, top, right, bottom = _resolve_padding(props["margin"]) try: lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom)) needs_set = True except Exception: pass if needs_set: view.setLayoutParams(lp) # ---- Text ----------------------------------------------------------- class AndroidTextHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: tv = jclass("android.widget.TextView")(_ctx()) self._apply(tv, props) _apply_layout(tv, props) return tv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def _apply(self, tv: Any, props: Dict[str, Any]) -> None: if "text" in props: tv.setText(str(props["text"])) if "font_size" in props and props["font_size"] is not None: tv.setTextSize(float(props["font_size"])) if "color" in props and props["color"] is not None: tv.setTextColor(parse_color_int(props["color"])) if "background_color" in props and props["background_color"] is not None: tv.setBackgroundColor(parse_color_int(props["background_color"])) if "bold" in props and props["bold"]: tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 if "max_lines" in props and props["max_lines"] is not None: tv.setMaxLines(int(props["max_lines"])) if "text_align" in props: Gravity = jclass("android.view.Gravity") mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} tv.setGravity(mapping.get(props["text_align"], Gravity.START)) # ---- Button --------------------------------------------------------- class AndroidButtonHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: btn = jclass("android.widget.Button")(_ctx()) self._apply(btn, props) _apply_layout(btn, props) return btn def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def _apply(self, btn: Any, props: Dict[str, Any]) -> None: if "title" in props: btn.setText(str(props["title"])) if "font_size" in props and props["font_size"] is not None: btn.setTextSize(float(props["font_size"])) if "color" in props and props["color"] is not None: btn.setTextColor(parse_color_int(props["color"])) if "background_color" in props and props["background_color"] is not None: btn.setBackgroundColor(parse_color_int(props["background_color"])) if "enabled" in props: btn.setEnabled(bool(props["enabled"])) if "on_click" in props: cb = props["on_click"] if cb is not None: class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): def __init__(self, callback: Callable[[], None]) -> None: super().__init__() self.callback = callback def onClick(self, view: Any) -> None: self.callback() btn.setOnClickListener(ClickProxy(cb)) else: btn.setOnClickListener(None) # ---- Column (vertical LinearLayout) --------------------------------- class AndroidColumnHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: ll = jclass("android.widget.LinearLayout")(_ctx()) ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) self._apply(ll, props) _apply_layout(ll, props) return ll def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def _apply(self, ll: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: px = _dp(float(props["spacing"])) GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") d = GradientDrawable() d.setColor(0x00000000) d.setSize(1, px) ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) ll.setDividerDrawable(d) if "padding" in props: left, top, right, bottom = _resolve_padding(props["padding"]) ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) if "alignment" in props and props["alignment"]: Gravity = jclass("android.view.Gravity") mapping = { "fill": Gravity.FILL_HORIZONTAL, "center": Gravity.CENTER_HORIZONTAL, "leading": Gravity.START, "start": Gravity.START, "trailing": Gravity.END, "end": Gravity.END, } ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_HORIZONTAL)) if "background_color" in props and props["background_color"] is not None: ll.setBackgroundColor(parse_color_int(props["background_color"])) def add_child(self, parent: Any, child: Any) -> None: parent.addView(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) def insert_child(self, parent: Any, child: Any, index: int) -> None: parent.addView(child, index) # ---- Row (horizontal LinearLayout) ---------------------------------- class AndroidRowHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: ll = jclass("android.widget.LinearLayout")(_ctx()) ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) self._apply(ll, props) _apply_layout(ll, props) return ll def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def _apply(self, ll: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: px = _dp(float(props["spacing"])) GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") d = GradientDrawable() d.setColor(0x00000000) d.setSize(px, 1) ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) ll.setDividerDrawable(d) if "padding" in props: left, top, right, bottom = _resolve_padding(props["padding"]) ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) if "alignment" in props and props["alignment"]: Gravity = jclass("android.view.Gravity") mapping = { "fill": Gravity.FILL_VERTICAL, "center": Gravity.CENTER_VERTICAL, "top": Gravity.TOP, "bottom": Gravity.BOTTOM, } ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_VERTICAL)) if "background_color" in props and props["background_color"] is not None: ll.setBackgroundColor(parse_color_int(props["background_color"])) def add_child(self, parent: Any, child: Any) -> None: parent.addView(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) def insert_child(self, parent: Any, child: Any, index: int) -> None: parent.addView(child, index) # ---- ScrollView ----------------------------------------------------- class AndroidScrollViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sv = jclass("android.widget.ScrollView")(_ctx()) if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor(parse_color_int(props["background_color"])) _apply_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "background_color" in changed and changed["background_color"] is not None: native_view.setBackgroundColor(parse_color_int(changed["background_color"])) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def add_child(self, parent: Any, child: Any) -> None: parent.addView(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) # ---- TextInput (EditText) with on_change ---------------------------- class AndroidTextInputHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: et = jclass("android.widget.EditText")(_ctx()) self._apply(et, props) _apply_layout(et, props) return et def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def _apply(self, et: Any, props: Dict[str, Any]) -> None: if "value" in props: et.setText(str(props["value"])) if "placeholder" in props: et.setHint(str(props["placeholder"])) if "font_size" in props and props["font_size"] is not None: et.setTextSize(float(props["font_size"])) if "color" in props and props["color"] is not None: et.setTextColor(parse_color_int(props["color"])) if "background_color" in props and props["background_color"] is not None: et.setBackgroundColor(parse_color_int(props["background_color"])) if "secure" in props and props["secure"]: InputType = jclass("android.text.InputType") et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) if "on_change" in props: cb = props["on_change"] if cb is not None: TextWatcher = jclass("android.text.TextWatcher") class ChangeProxy(dynamic_proxy(TextWatcher)): def __init__(self, callback: Callable[[str], None]) -> None: super().__init__() self.callback = callback def afterTextChanged(self, s: Any) -> None: self.callback(str(s)) def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None: pass def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None: pass et.addTextChangedListener(ChangeProxy(cb)) # ---- Image (with URL loading) --------------------------------------- class AndroidImageHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: iv = jclass("android.widget.ImageView")(_ctx()) self._apply(iv, props) _apply_layout(iv, props) return iv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def _apply(self, iv: Any, props: Dict[str, Any]) -> None: if "background_color" in props and props["background_color"] is not None: iv.setBackgroundColor(parse_color_int(props["background_color"])) if "source" in props and props["source"]: self._load_source(iv, props["source"]) if "scale_type" in props and props["scale_type"]: ScaleType = jclass("android.widget.ImageView$ScaleType") mapping = { "cover": ScaleType.CENTER_CROP, "contain": ScaleType.FIT_CENTER, "stretch": ScaleType.FIT_XY, "center": ScaleType.CENTER, } st = mapping.get(props["scale_type"]) if st: iv.setScaleType(st) def _load_source(self, iv: Any, source: str) -> None: try: if source.startswith(("http://", "https://")): Thread = jclass("java.lang.Thread") Runnable = jclass("java.lang.Runnable") URL = jclass("java.net.URL") BitmapFactory = jclass("android.graphics.BitmapFactory") Handler = jclass("android.os.Handler") Looper = jclass("android.os.Looper") handler = Handler(Looper.getMainLooper()) class LoadTask(dynamic_proxy(Runnable)): def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None: super().__init__() self.image_view = image_view self.url_str = url_str self.main_handler = main_handler def run(self) -> None: try: url = URL(self.url_str) stream = url.openStream() bitmap = BitmapFactory.decodeStream(stream) stream.close() class SetImage(dynamic_proxy(Runnable)): def __init__(self, view: Any, bmp: Any) -> None: super().__init__() self.view = view self.bmp = bmp def run(self) -> None: self.view.setImageBitmap(self.bmp) self.main_handler.post(SetImage(self.image_view, bitmap)) except Exception: pass Thread(LoadTask(iv, source, handler)).start() else: ctx = _ctx() res = ctx.getResources() pkg = ctx.getPackageName() res_name = source.rsplit(".", 1)[0] if "." in source else source res_id = res.getIdentifier(res_name, "drawable", pkg) if res_id != 0: iv.setImageResource(res_id) except Exception: pass # ---- Switch (with on_change) ---------------------------------------- class AndroidSwitchHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sw = jclass("android.widget.Switch")(_ctx()) self._apply(sw, props) _apply_layout(sw, props) return sw def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) def _apply(self, sw: Any, props: Dict[str, Any]) -> None: if "value" in props: sw.setChecked(bool(props["value"])) if "on_change" in props and props["on_change"] is not None: cb = props["on_change"] class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): def __init__(self, callback: Callable[[bool], None]) -> None: super().__init__() self.callback = callback def onCheckedChanged(self, button: Any, checked: bool) -> None: self.callback(checked) sw.setOnCheckedChangeListener(CheckedProxy(cb)) # ---- ProgressBar ---------------------------------------------------- class AndroidProgressBarHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: style = jclass("android.R$attr").progressBarStyleHorizontal pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) pb.setMax(1000) self._apply(pb, props) _apply_layout(pb, props) return pb def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) def _apply(self, pb: Any, props: Dict[str, Any]) -> None: if "value" in props: pb.setProgress(int(float(props["value"]) * 1000)) # ---- ActivityIndicator (circular ProgressBar) ----------------------- class AndroidActivityIndicatorHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: pb = jclass("android.widget.ProgressBar")(_ctx()) if not props.get("animating", True): pb.setVisibility(jclass("android.view.View").GONE) _apply_layout(pb, props) return pb def update(self, native_view: Any, changed: Dict[str, Any]) -> None: View = jclass("android.view.View") if "animating" in changed: native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) # ---- WebView -------------------------------------------------------- class AndroidWebViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: wv = jclass("android.webkit.WebView")(_ctx()) if "url" in props and props["url"]: wv.loadUrl(str(props["url"])) _apply_layout(wv, props) return wv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "url" in changed and changed["url"]: native_view.loadUrl(str(changed["url"])) # ---- Spacer --------------------------------------------------------- class AndroidSpacerHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = jclass("android.view.View")(_ctx()) if "size" in props and props["size"] is not None: px = _dp(float(props["size"])) lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) v.setLayoutParams(lp) if "flex" in props and props["flex"] is not None: lp = v.getLayoutParams() if lp is None: lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0) lp.weight = float(props["flex"]) v.setLayoutParams(lp) return v def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "size" in changed and changed["size"] is not None: px = _dp(float(changed["size"])) lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) native_view.setLayoutParams(lp) # ---- View (generic container FrameLayout) --------------------------- class AndroidViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: fl = jclass("android.widget.FrameLayout")(_ctx()) if "background_color" in props and props["background_color"] is not None: fl.setBackgroundColor(parse_color_int(props["background_color"])) if "padding" in props: left, top, right, bottom = _resolve_padding(props["padding"]) fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) _apply_layout(fl, props) return fl def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "background_color" in changed and changed["background_color"] is not None: native_view.setBackgroundColor(parse_color_int(changed["background_color"])) if "padding" in changed: left, top, right, bottom = _resolve_padding(changed["padding"]) native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) if changed.keys() & _LAYOUT_KEYS: _apply_layout(native_view, changed) def add_child(self, parent: Any, child: Any) -> None: parent.addView(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) def insert_child(self, parent: Any, child: Any, index: int) -> None: parent.addView(child, index) # ---- SafeAreaView (FrameLayout with fitsSystemWindows) --------------- class AndroidSafeAreaViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: fl = jclass("android.widget.FrameLayout")(_ctx()) fl.setFitsSystemWindows(True) if "background_color" in props and props["background_color"] is not None: fl.setBackgroundColor(parse_color_int(props["background_color"])) if "padding" in props: left, top, right, bottom = _resolve_padding(props["padding"]) fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) return fl def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "background_color" in changed and changed["background_color"] is not None: native_view.setBackgroundColor(parse_color_int(changed["background_color"])) def add_child(self, parent: Any, child: Any) -> None: parent.addView(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) # ---- Modal (AlertDialog) ------------------------------------------- class AndroidModalHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: placeholder = jclass("android.view.View")(_ctx()) placeholder.setVisibility(jclass("android.view.View").GONE) return placeholder def update(self, native_view: Any, changed: Dict[str, Any]) -> None: pass def add_child(self, parent: Any, child: Any) -> None: pass # ---- Slider (SeekBar) ----------------------------------------------- class AndroidSliderHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sb = jclass("android.widget.SeekBar")(_ctx()) sb.setMax(1000) self._apply(sb, props) _apply_layout(sb, props) return sb def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) def _apply(self, sb: Any, props: Dict[str, Any]) -> None: min_val = float(props.get("min_value", 0)) max_val = float(props.get("max_value", 1)) rng = max_val - min_val if max_val != min_val else 1 if "value" in props: normalized = (float(props["value"]) - min_val) / rng sb.setProgress(int(normalized * 1000)) if "on_change" in props and props["on_change"] is not None: cb = props["on_change"] class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)): def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None: super().__init__() self.callback = callback self.mn = mn self.rn = rn def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None: if fromUser: self.callback(self.mn + (progress / 1000.0) * self.rn) def onStartTrackingTouch(self, seekBar: Any) -> None: pass def onStopTrackingTouch(self, seekBar: Any) -> None: pass sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) # ---- Pressable (FrameLayout with click listener) -------------------- class AndroidPressableHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: fl = jclass("android.widget.FrameLayout")(_ctx()) fl.setClickable(True) self._apply(fl, props) return fl def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) def _apply(self, fl: Any, props: Dict[str, Any]) -> None: if "on_press" in props and props["on_press"] is not None: cb = props["on_press"] class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): def __init__(self, callback: Callable[[], None]) -> None: super().__init__() self.callback = callback def onClick(self, view: Any) -> None: self.callback() fl.setOnClickListener(PressProxy(cb)) if "on_long_press" in props and props["on_long_press"] is not None: cb = props["on_long_press"] class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)): def __init__(self, callback: Callable[[], None]) -> None: super().__init__() self.callback = callback def onLongClick(self, view: Any) -> bool: self.callback() return True fl.setOnLongClickListener(LongPressProxy(cb)) def add_child(self, parent: Any, child: Any) -> None: parent.addView(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) registry.register("Text", AndroidTextHandler()) registry.register("Button", AndroidButtonHandler()) registry.register("Column", AndroidColumnHandler()) registry.register("Row", AndroidRowHandler()) registry.register("ScrollView", AndroidScrollViewHandler()) registry.register("TextInput", AndroidTextInputHandler()) registry.register("Image", AndroidImageHandler()) registry.register("Switch", AndroidSwitchHandler()) registry.register("ProgressBar", AndroidProgressBarHandler()) registry.register("ActivityIndicator", AndroidActivityIndicatorHandler()) registry.register("WebView", AndroidWebViewHandler()) registry.register("Spacer", AndroidSpacerHandler()) registry.register("View", AndroidViewHandler()) registry.register("SafeAreaView", AndroidSafeAreaViewHandler()) registry.register("Modal", AndroidModalHandler()) registry.register("Slider", AndroidSliderHandler()) registry.register("Pressable", AndroidPressableHandler()) def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 from rubicon.objc import SEL, ObjCClass, objc_method NSObject = ObjCClass("NSObject") UIColor = ObjCClass("UIColor") UIFont = ObjCClass("UIFont") def _uicolor(color: Any) -> Any: argb = parse_color_int(color) if argb < 0: argb += 0x100000000 a = ((argb >> 24) & 0xFF) / 255.0 r = ((argb >> 16) & 0xFF) / 255.0 g = ((argb >> 8) & 0xFF) / 255.0 b = (argb & 0xFF) / 255.0 return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: """Apply common layout constraints to an iOS view.""" if "width" in props and props["width"] is not None: try: for c in list(view.constraints or []): if c.firstAttribute == 7: # NSLayoutAttributeWidth c.setActive_(False) view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True) except Exception: pass if "height" in props and props["height"] is not None: try: for c in list(view.constraints or []): if c.firstAttribute == 8: # NSLayoutAttributeHeight c.setActive_(False) view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) except Exception: pass # ---- Text ----------------------------------------------------------- class IOSTextHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: label = ObjCClass("UILabel").alloc().init() self._apply(label, props) _apply_ios_layout(label, props) return label def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_ios_layout(native_view, changed) def _apply(self, label: Any, props: Dict[str, Any]) -> None: if "text" in props: label.setText_(str(props["text"])) if "font_size" in props and props["font_size"] is not None: if props.get("bold"): label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) else: label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) elif "bold" in props and props["bold"]: size = label.font().pointSize() if label.font() else 17.0 label.setFont_(UIFont.boldSystemFontOfSize_(size)) if "color" in props and props["color"] is not None: label.setTextColor_(_uicolor(props["color"])) if "background_color" in props and props["background_color"] is not None: label.setBackgroundColor_(_uicolor(props["background_color"])) if "max_lines" in props and props["max_lines"] is not None: label.setNumberOfLines_(int(props["max_lines"])) if "text_align" in props: mapping = {"left": 0, "center": 1, "right": 2} label.setTextAlignment_(mapping.get(props["text_align"], 0)) # ---- Button --------------------------------------------------------- _pn_btn_handler_map: dict = {} class _PNButtonTarget(NSObject): # type: ignore[valid-type] _callback: Optional[Callable[[], None]] = None @objc_method def onTap_(self, sender: object) -> None: if self._callback is not None: self._callback() _pn_retained_views: list = [] class IOSButtonHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: btn = ObjCClass("UIButton").alloc().init() btn.retain() _pn_retained_views.append(btn) _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) btn.setTitleColor_forState_(_ios_blue, 0) self._apply(btn, props) _apply_ios_layout(btn, props) return btn def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_ios_layout(native_view, changed) def _apply(self, btn: Any, props: Dict[str, Any]) -> None: if "title" in props: btn.setTitle_forState_(str(props["title"]), 0) if "font_size" in props and props["font_size"] is not None: btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) if "background_color" in props and props["background_color"] is not None: btn.setBackgroundColor_(_uicolor(props["background_color"])) if "color" not in props: _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) btn.setTitleColor_forState_(_white, 0) if "color" in props and props["color"] is not None: btn.setTitleColor_forState_(_uicolor(props["color"]), 0) if "enabled" in props: btn.setEnabled_(bool(props["enabled"])) if "on_click" in props: existing = _pn_btn_handler_map.get(id(btn)) if existing is not None: existing._callback = props["on_click"] else: handler = _PNButtonTarget.new() handler._callback = props["on_click"] _pn_btn_handler_map[id(btn)] = handler btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) # ---- Column (vertical UIStackView) ---------------------------------- class IOSColumnHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) sv.setAxis_(1) # vertical self._apply(sv, props) _apply_ios_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_ios_layout(native_view, changed) def _apply(self, sv: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: sv.setSpacing_(float(props["spacing"])) if "alignment" in props and props["alignment"]: mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4} sv.setAlignment_(mapping.get(props["alignment"], 0)) if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor_(_uicolor(props["background_color"])) if "padding" in props: left, top, right, bottom = _resolve_padding(props["padding"]) sv.setLayoutMarginsRelativeArrangement_(True) try: sv.setDirectionalLayoutMargins_((top, left, bottom, right)) except Exception: sv.setLayoutMargins_((top, left, bottom, right)) def add_child(self, parent: Any, child: Any) -> None: parent.addArrangedSubview_(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeArrangedSubview_(child) child.removeFromSuperview() def insert_child(self, parent: Any, child: Any, index: int) -> None: parent.insertArrangedSubview_atIndex_(child, index) # ---- Row (horizontal UIStackView) ----------------------------------- class IOSRowHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) sv.setAxis_(0) # horizontal self._apply(sv, props) _apply_ios_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_ios_layout(native_view, changed) def _apply(self, sv: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: sv.setSpacing_(float(props["spacing"])) if "alignment" in props and props["alignment"]: mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4} sv.setAlignment_(mapping.get(props["alignment"], 0)) if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor_(_uicolor(props["background_color"])) def add_child(self, parent: Any, child: Any) -> None: parent.addArrangedSubview_(child) def remove_child(self, parent: Any, child: Any) -> None: parent.removeArrangedSubview_(child) child.removeFromSuperview() def insert_child(self, parent: Any, child: Any, index: int) -> None: parent.insertArrangedSubview_atIndex_(child, index) # ---- ScrollView ----------------------------------------------------- class IOSScrollViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIScrollView").alloc().init() if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor_(_uicolor(props["background_color"])) _apply_ios_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "background_color" in changed and changed["background_color"] is not None: native_view.setBackgroundColor_(_uicolor(changed["background_color"])) def add_child(self, parent: Any, child: Any) -> None: child.setTranslatesAutoresizingMaskIntoConstraints_(False) parent.addSubview_(child) content_guide = parent.contentLayoutGuide frame_guide = parent.frameLayoutGuide child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) def remove_child(self, parent: Any, child: Any) -> None: child.removeFromSuperview() # ---- TextInput (UITextField with on_change) ------------------------- _pn_tf_handler_map: dict = {} class _PNTextFieldTarget(NSObject): # type: ignore[valid-type] _callback: Optional[Callable[[str], None]] = None @objc_method def onEdit_(self, sender: object) -> None: if self._callback is not None: try: text = str(sender.text) if sender and hasattr(sender, "text") else "" self._callback(text) except Exception: pass class IOSTextInputHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: tf = ObjCClass("UITextField").alloc().init() tf.setBorderStyle_(2) # RoundedRect self._apply(tf, props) _apply_ios_layout(tf, props) return tf def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_ios_layout(native_view, changed) def _apply(self, tf: Any, props: Dict[str, Any]) -> None: if "value" in props: tf.setText_(str(props["value"])) if "placeholder" in props: tf.setPlaceholder_(str(props["placeholder"])) if "font_size" in props and props["font_size"] is not None: tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) if "color" in props and props["color"] is not None: tf.setTextColor_(_uicolor(props["color"])) if "background_color" in props and props["background_color"] is not None: tf.setBackgroundColor_(_uicolor(props["background_color"])) if "secure" in props and props["secure"]: tf.setSecureTextEntry_(True) if "on_change" in props: existing = _pn_tf_handler_map.get(id(tf)) if existing is not None: existing._callback = props["on_change"] else: handler = _PNTextFieldTarget.new() handler._callback = props["on_change"] _pn_tf_handler_map[id(tf)] = handler tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17) # ---- Image (with URL loading) --------------------------------------- class IOSImageHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: iv = ObjCClass("UIImageView").alloc().init() self._apply(iv, props) _apply_ios_layout(iv, props) return iv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) if changed.keys() & _LAYOUT_KEYS: _apply_ios_layout(native_view, changed) def _apply(self, iv: Any, props: Dict[str, Any]) -> None: if "background_color" in props and props["background_color"] is not None: iv.setBackgroundColor_(_uicolor(props["background_color"])) if "source" in props and props["source"]: self._load_source(iv, props["source"]) if "scale_type" in props and props["scale_type"]: mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4} iv.setContentMode_(mapping.get(props["scale_type"], 1)) def _load_source(self, iv: Any, source: str) -> None: try: if source.startswith(("http://", "https://")): NSURL = ObjCClass("NSURL") NSData = ObjCClass("NSData") UIImage = ObjCClass("UIImage") url = NSURL.URLWithString_(source) data = NSData.dataWithContentsOfURL_(url) if data: image = UIImage.imageWithData_(data) if image: iv.setImage_(image) else: UIImage = ObjCClass("UIImage") image = UIImage.imageNamed_(source) if image: iv.setImage_(image) except Exception: pass # ---- Switch (with on_change) ---------------------------------------- _pn_switch_handler_map: dict = {} class _PNSwitchTarget(NSObject): # type: ignore[valid-type] _callback: Optional[Callable[[bool], None]] = None @objc_method def onToggle_(self, sender: object) -> None: if self._callback is not None: try: self._callback(bool(sender.isOn())) except Exception: pass class IOSSwitchHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sw = ObjCClass("UISwitch").alloc().init() self._apply(sw, props) return sw def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) def _apply(self, sw: Any, props: Dict[str, Any]) -> None: if "value" in props: sw.setOn_animated_(bool(props["value"]), False) if "on_change" in props: existing = _pn_switch_handler_map.get(id(sw)) if existing is not None: existing._callback = props["on_change"] else: handler = _PNSwitchTarget.new() handler._callback = props["on_change"] _pn_switch_handler_map[id(sw)] = handler sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12) # ---- ProgressBar (UIProgressView) ----------------------------------- class IOSProgressBarHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: pv = ObjCClass("UIProgressView").alloc().init() if "value" in props: pv.setProgress_(float(props["value"])) _apply_ios_layout(pv, props) return pv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "value" in changed: native_view.setProgress_(float(changed["value"])) # ---- ActivityIndicator ---------------------------------------------- class IOSActivityIndicatorHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: ai = ObjCClass("UIActivityIndicatorView").alloc().init() if props.get("animating", True): ai.startAnimating() return ai def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "animating" in changed: if changed["animating"]: native_view.startAnimating() else: native_view.stopAnimating() # ---- WebView (WKWebView) -------------------------------------------- class IOSWebViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: wv = ObjCClass("WKWebView").alloc().init() if "url" in props and props["url"]: NSURL = ObjCClass("NSURL") NSURLRequest = ObjCClass("NSURLRequest") url_obj = NSURL.URLWithString_(str(props["url"])) wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) _apply_ios_layout(wv, props) return wv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "url" in changed and changed["url"]: NSURL = ObjCClass("NSURL") NSURLRequest = ObjCClass("NSURLRequest") url_obj = NSURL.URLWithString_(str(changed["url"])) native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) # ---- Spacer --------------------------------------------------------- class IOSSpacerHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = ObjCClass("UIView").alloc().init() if "size" in props and props["size"] is not None: size = float(props["size"]) v.setFrame_(((0, 0), (size, size))) return v def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "size" in changed and changed["size"] is not None: size = float(changed["size"]) native_view.setFrame_(((0, 0), (size, size))) # ---- View (generic UIView) ----------------------------------------- class IOSViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = ObjCClass("UIView").alloc().init() if "background_color" in props and props["background_color"] is not None: v.setBackgroundColor_(_uicolor(props["background_color"])) _apply_ios_layout(v, props) return v def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "background_color" in changed and changed["background_color"] is not None: native_view.setBackgroundColor_(_uicolor(changed["background_color"])) if changed.keys() & _LAYOUT_KEYS: _apply_ios_layout(native_view, changed) def add_child(self, parent: Any, child: Any) -> None: parent.addSubview_(child) def remove_child(self, parent: Any, child: Any) -> None: child.removeFromSuperview() # ---- SafeAreaView --------------------------------------------------- class IOSSafeAreaViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = ObjCClass("UIView").alloc().init() if "background_color" in props and props["background_color"] is not None: v.setBackgroundColor_(_uicolor(props["background_color"])) return v def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "background_color" in changed and changed["background_color"] is not None: native_view.setBackgroundColor_(_uicolor(changed["background_color"])) def add_child(self, parent: Any, child: Any) -> None: parent.addSubview_(child) def remove_child(self, parent: Any, child: Any) -> None: child.removeFromSuperview() # ---- Modal ---------------------------------------------------------- class IOSModalHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = ObjCClass("UIView").alloc().init() v.setHidden_(True) return v def update(self, native_view: Any, changed: Dict[str, Any]) -> None: pass # ---- Slider (UISlider) ---------------------------------------------- _pn_slider_handler_map: dict = {} class _PNSliderTarget(NSObject): # type: ignore[valid-type] _callback: Optional[Callable[[float], None]] = None @objc_method def onSlide_(self, sender: object) -> None: if self._callback is not None: try: self._callback(float(sender.value)) except Exception: pass class IOSSliderHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sl = ObjCClass("UISlider").alloc().init() self._apply(sl, props) _apply_ios_layout(sl, props) return sl def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) def _apply(self, sl: Any, props: Dict[str, Any]) -> None: if "min_value" in props: sl.setMinimumValue_(float(props["min_value"])) if "max_value" in props: sl.setMaximumValue_(float(props["max_value"])) if "value" in props: sl.setValue_(float(props["value"])) if "on_change" in props: existing = _pn_slider_handler_map.get(id(sl)) if existing is not None: existing._callback = props["on_change"] else: handler = _PNSliderTarget.new() handler._callback = props["on_change"] _pn_slider_handler_map[id(sl)] = handler sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) # ---- Pressable (UIView with tap gesture) ---------------------------- class IOSPressableHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = ObjCClass("UIView").alloc().init() v.setUserInteractionEnabled_(True) return v def update(self, native_view: Any, changed: Dict[str, Any]) -> None: pass def add_child(self, parent: Any, child: Any) -> None: parent.addSubview_(child) def remove_child(self, parent: Any, child: Any) -> None: child.removeFromSuperview() registry.register("Text", IOSTextHandler()) registry.register("Button", IOSButtonHandler()) registry.register("Column", IOSColumnHandler()) registry.register("Row", IOSRowHandler()) registry.register("ScrollView", IOSScrollViewHandler()) registry.register("TextInput", IOSTextInputHandler()) registry.register("Image", IOSImageHandler()) registry.register("Switch", IOSSwitchHandler()) registry.register("ProgressBar", IOSProgressBarHandler()) registry.register("ActivityIndicator", IOSActivityIndicatorHandler()) registry.register("WebView", IOSWebViewHandler()) registry.register("Spacer", IOSSpacerHandler()) registry.register("View", IOSViewHandler()) registry.register("SafeAreaView", IOSSafeAreaViewHandler()) registry.register("Modal", IOSModalHandler()) registry.register("Slider", IOSSliderHandler()) registry.register("Pressable", IOSPressableHandler()) # ====================================================================== # Factory # ====================================================================== _registry: Optional[NativeViewRegistry] = None def get_registry() -> NativeViewRegistry: """Return the singleton registry, lazily creating platform handlers.""" global _registry if _registry is not None: return _registry _registry = NativeViewRegistry() if IS_ANDROID: _register_android_handlers(_registry) else: _register_ios_handlers(_registry) return _registry def set_registry(registry: NativeViewRegistry) -> None: """Inject a custom or mock registry (primarily for testing).""" global _registry _registry = registry