Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Re-design Python controls and their Flutter counterparts #4300

Open
FeodorFitsner opened this issue Nov 3, 2024 · 7 comments
Open

WIP: Re-design Python controls and their Flutter counterparts #4300

FeodorFitsner opened this issue Nov 3, 2024 · 7 comments
Assignees

Comments

@FeodorFitsner
Copy link
Contributor

FeodorFitsner commented Nov 3, 2024

The problem

  • Python classes for controls are bulky and hard to add/update/support:
    • Unnecessary use of properties and "attrs" dictionary with redundant str-to-value-to-str conversions - coming from an old web-only Flet design.
    • String values of properties must be "camelCase" to use on a Dart side.
    • Unnecessary constructors and passing args to inherited constructors.
    • Events implementation is different for simple events and "typed" events, scattered across a control class.
    • Different approaches for serializing literal, Control-like, embedded object and enum properties.
    • Unnecessary use of .update() method coming from an old web-only Flet design.
  • Dart side uses Redux library which adds unnecessary complexity to the solution:
    • Internal page state is a linear list of control objects, not a graph - when building a widget child controls must be retrieved by IDs.
    • Redux is very ineffective as "Store" widget re-build is triggered by comparing string representation of "previous" and "new" states of the control.

The solution

  • Use @dataclass for UI controls on Python side.
  • Eliminate .update() method, switch to a single-threaded UI model.
  • Rename Page to App.
  • Use ChangeNotifier for controls on Dart side and InheritedNotifier or ListenableBuilder widgets to re-build UI when control props change.
  • Use jsonpath to compute the difference between page states on Python side.
  • Patch controls tree on Dart side and notify controls that changed.

Additional details

Python control classes could be @dataclasses and look like this:

@dataclass
class Button(Control):
    """This is button"""

    text: str
    """Button display text"""

    child: Optional[Control | str] = None
    """Content - Control or str"""

    border_radius: Optional[BorderRadius] = None
    """Border radius"""

    direction: Optional[Direction] = Direction.ABC
    padding: Optional[int] = 5
    on_click: Optional[Callable] = event(convertor=lambda x: x + 2)

    def __post_init__(self):
        self._control = "Button"

Example usage:

b1 = Button(
    "a",
    Button("b", padding=3),
    x=["test"],
    border_radius=BorderRadius(1, 2, 3, 4),
    direction=Direction.XYZ,
)
b2 = Button(text="a", on_click=lambda: print("Hello!"))

print(json.dumps(b1, indent=4, cls=EmbedJsonEncoder, separators=(",", ":")))

# field metadata can be read like this
event_field = next(filter(lambda f: f.name == "on_click", fields(b1)))
print(event_field.metadata)
Base classes
def event(convertor: Optional[Callable]):
    return field(default=None, metadata={"convertor": convertor})

class Direction(Enum):
    ABC = "abc"
    DEF = "def"
    XYZ = "xyz"

@dataclass(kw_only=True)
class Control:
    x: Optional[List[str]] = None
    _control: Optional[str] = None

    def is_isolated(self) -> bool:
        return False

    def build(self):
        pass

    def before_update(self):
        pass

    def __post_init__(self):
        print("post init!")

    def _get_event(self):
        return 1

    def _set_event(self, e):
        pass
EmbedJsonEncoder
class EmbedJsonEncoder(json.JSONEncoder):
    def default(self, obj):
        return self._convert_enums(obj.__dict__)

    def encode(self, o):
        return super().encode(self._convert_enums(o))

    def _convert_enums(self, obj):
        if isinstance(obj, Dict):
            return dict(
                map(
                    lambda item: (
                        self._convert_enums(
                            item[0]
                            if not isinstance(item[0], enum.Enum)
                            else item[0].value
                        ),
                        self._convert_enums(
                            item[1]
                            if not isinstance(item[1], enum.Enum)
                            else item[1].value
                        ),
                    ),
                    filter(
                        lambda item: not item[0].startswith("on_")
                        and item[1] is not None,
                        obj.items(),
                    ),
                )
            )
        else:
            return obj
@FeodorFitsner FeodorFitsner self-assigned this Nov 3, 2024
@syleishere
Copy link
Contributor

Would be nice if all def, could be converted to async def. Even a lot of controls won't work properly without their corresponding async method. The issue is cannot call async methods within an init in a class. Since controls are built in init method causes issues.
This introduces added complexity everywhere, mix of def and async def scattered everywhere.

Sure you could hack around this in init method by doing something like:
self.config.task = self.config.page.loop.create_task(self.starting())
then do some long running animation or whatever but it is just a hack.

Maybe building the controls for the page could be placed in an async def builder() function in the class instead.

Another note is python 3.13 removing the gil as experimental currently, threading may come into picture very shortly if someone wants to run a long running CPU intensive task utilizing separate threads/cores in their app. This may become a common place in future when everyone running pytorch in background threads either training models or running inference, without having to fork a subprocess to force it to use more cores.

@FeodorFitsner
Copy link
Contributor Author

@syleishere they are mostly async now. Could you give some specific examples?

@syleishere
Copy link
Contributor

syleishere commented Nov 5, 2024

I can give you a live example for playing with controls in an advanced fun way(tested in chrome)
https://crystaltunes.sunsaturn.com:9000 (go to nav bar on left and click on "Cool Page")

Code: Here route is changed to Page1App

import asyncio
import flet as ft


class IntroText(ft.Text):
    def __init__(self, text):
        super().__init__()
        self.value = text
        self.size = 50
        self.weight = ft.FontWeight.W_600


# Usage: from pages.page1 import Page1App
#        Page1App(config)
class Page1App(ft.Container):
    def __init__(self, config):
        super().__init__()
        # config & current page setup
        self.config = config
        # route setup
        self.config.route = config.route1
        self.config.route_info = config.route1_info
        # app bar title change
        # self.config.page.appbar.title.value = self.config.route_info
        self.config.page.appbar.title.controls[0].value = self.config.route_info
        # ft.super init
        self.bgcolor = ft.Colors.PRIMARY_CONTAINER
        self.alignment = ft.alignment.center
        self.padding = 0
        self.spacing = 0
        self.gradient = ft.LinearGradient(
            begin=ft.alignment.top_center,
            end=ft.alignment.bottom_center,
            colors=[ft.Colors.BLUE, ft.Colors.YELLOW],
        )

        self.text1 = IntroText("In the Beginning")
        self.text2 = IntroText("There was Adam and Eve")
        self.text3 = IntroText("There was the earth")
        self.text4 = IntroText("There was the big bang")

        self.myrows = ft.Column(
            controls=[
                self.text1,
                self.text2,
                self.text3,
                self.text4,
            ]
        )

        self.content = self.myrows

        asyncio.create_task(
            self.starting()
        )  # because we can't await inside init, just add task instead

    async def starting(self):
        # print(f"Made it inside page 1 controls are {self.myrows.controls}")
        # Let's start by removing each text field slowly
        # NEED TO CHECK IF THEY LEFT PAGE AND KILL THE SCRIPT, just do a return
        await asyncio.sleep(2)
        self.myrows.controls.remove(self.text4)
        self.update()
        await asyncio.sleep(1)
        self.myrows.controls.remove(self.text3)
        self.update()
        await asyncio.sleep(1)
        self.myrows.controls.remove(self.text2)
        self.update()
        await asyncio.sleep(1)
        self.myrows.controls.remove(self.text1)
        self.update()
        # Now all Text fields are gone, lets slowly add 4 more sentences back to page :)
        await asyncio.sleep(1)
        self.text1 = ft.Text(
            value="In the END...", theme_style=ft.TextThemeStyle.DISPLAY_MEDIUM
        )
        self.myrows.controls.append(self.text1)
        self.update()
        await asyncio.sleep(1)
        self.text2 = ft.Text(
            value="Was chaos and madness", theme_style=ft.TextThemeStyle.DISPLAY_MEDIUM
        )
        self.myrows.controls.append(self.text2)
        self.update()
        await asyncio.sleep(1)
        self.text3 = ft.Text(
            value="Hunger and bitterness", theme_style=ft.TextThemeStyle.DISPLAY_MEDIUM
        )
        self.myrows.controls.append(self.text3)
        self.update()
        await asyncio.sleep(1)
        self.text4 = ft.Text(
            value="Then a Hero came along...",
            theme_style=ft.TextThemeStyle.DISPLAY_MEDIUM,
        )
        self.progress = ft.ProgressBar(width=400, color="amber", bgcolor="#eeeeee")
        self.myrows.controls.append(self.text4)
        self.myrows.controls.append(self.progress)
        self.update()
        await asyncio.sleep(1)
        # Let's put a line through all the above
        self.myrows.controls.clear()
        self.text1 = ft.Text(
            value="In the END...",
            size=50,
            weight=ft.FontWeight.W_600,
            style=ft.TextStyle(decoration=ft.TextDecoration.LINE_THROUGH),
        )
        self.text2 = ft.Text(
            value="Was Chaos and madness",
            size=50,
            weight=ft.FontWeight.W_600,
            style=ft.TextStyle(decoration=ft.TextDecoration.LINE_THROUGH),
        )
        self.text3 = ft.Text(
            value="Hunger and bitterness",
            size=50,
            weight=ft.FontWeight.W_600,
            style=ft.TextStyle(decoration=ft.TextDecoration.LINE_THROUGH),
        )
        self.text4 = ft.Text(
            value="Then a Hero came along",
            size=50,
            weight=ft.FontWeight.W_600,
            style=ft.TextStyle(decoration=ft.TextDecoration.LINE_THROUGH),
        )
        self.myrows.controls.append(self.text1)
        self.myrows.controls.append(self.text2)
        self.myrows.controls.append(self.text3)
        self.myrows.controls.append(self.text4)
        self.myrows.controls.append(self.progress)
        self.update()

        # Let's display Crystal Tunes right in middle, should be array index 2
        await asyncio.sleep(1)
        # Underlined text: https://github.com/flet-dev/examples/blob/main/python/controls/text/richtext.py
        #                  https://flet-controls-gallery.fly.dev/displays/text
        self.text1 = ft.Text(
            value="CRYSTAL TUNES!!!",
            size=50,
            weight=ft.FontWeight.W_600,
            style=ft.TextStyle(
                foreground=ft.Paint(
                    gradient=ft.PaintLinearGradient(
                        (0, 20), (150, 20), [ft.Colors.RED, ft.Colors.YELLOW]
                    )
                )
            ),
        )
        self.myrows.controls.insert(2, self.text1)
        self.myrows.controls.pop()  # remove the progress bar, last element
        self.update()

@syleishere
Copy link
Contributor

syleishere commented Nov 5, 2024

That's just an example, but there are lots more, like calling an async function to update a slider, calling an async function to get IP address, or even client storage routines only seem to work for me with their _async counter parts.

Or here is a small example snippet from a nav bar trying to hack around calling an async call to change theme for app:

import flet as ft

# Usage: from pages.navigationdrawer import NavigationDrawerApp
#        add to a control: NavigationDrawerApp(config)


class Popup1(ft.PopupMenuButton):
    def __init__(self, config):
        super().__init__()
        self.config = config
        self.content = ft.Row(
            controls=[
                ft.Icon(ft.Icons.PALETTE, color=ft.Colors.ON_PRIMARY),
                ft.Text(
                    value="Light/Dark Theme",
                    color=ft.Colors.ON_PRIMARY,
                    weight=ft.FontWeight.W_600,
                ),
            ]
        )
        self.items = [
            ft.PopupMenuItem(
                on_click=lambda _: self.config.page.loop.create_task(
                    self.config.change_theme("LIGHT")
                ),
                content=ft.Row(
                    expand=True,
                    controls=[
                        ft.Icon(ft.Icons.LIGHT_MODE, color=ft.Colors.PRIMARY),
                        ft.Text(
                            value="Light Theme", expand=True, color=ft.Colors.PRIMARY
                        ),
                    ],
                ),
            ),

@syleishere
Copy link
Contributor

syleishere commented Nov 5, 2024

I'm curious why you decided to go with redux though. Honestly I remember building my first flutter app and I absolutely hated the state management system until I found: https://pub.dev/packages/get

Was a lifesaver, I remember sticking everything I wanted to keep state of in a controller.dart file and every other dart file it was as simple as: import 'package:get/get.dart'; import './controller.dart'; final Controller c = Get.find();

For example snippet of my old code from controller.dart from 5 years ago:
I will just put whole file here since it's kinda long: https://sunsaturn.com/controller.dart.txt

//in main.dart   Controller c = Get.put(Controller());
//is used to initialize this class for GetX
//in every other *.dart file      final Controller c = Get.find();
//is called to get the audio instance, this way our function Controller() does not get hit twice as well.
//So for every new *.dart file: import 'package:get/get.dart'; import './controller.dart';  final Controller c = Get.find();
class Controller extends GetxController {
  // https://pub.dev/packages/get
  //A nice way for globals and state management, toss everything in here for the app
  //var count = 0.obs;   //example for INT
  //NOTE EVERYTHING IS GLOBAL, so only tag stuff with .obs if your making it observable in a widget
  var song0 =
      'Welcome to Music Player\nSelect a Folder in Drawer menu to start playing!'
          .obs; //example of String or RXString in this case
  var song1 = 'A random song will play'
      .obs; //example of String or RXString in this case
  var song2 = 'from folder you select'.obs;
  var song3 = 'First 4 songs in folder'.obs;
  var song4 = 'will show up here'.obs;
  var dir = 'Z:/mp3/2008hits'; //global variables
  var fuck = 'love me tender';
  var playstatus = true.obs; //Play or Pause, icon to display
  Player? desktopPlayer;                  // linux, windows ( https://pub.dev/packages/dart_vlc )
  myaudio.AssetsAudioPlayer? audioPlayer; // android, IOS, MacOS, Web ( https://pub.dev/packages/
  var nextDone = true;                    // https://github.com/florent37/Flutter-AssetsAudioPlayer/issues/538
                                          // With AssetsAudio player they have cancellable future, so I need to check next/previous is done
  var songList = [].obs;                  // Place to store our songs, I will use a listbuilder wrapped with obx for this array
  var songTemp = [];                      // I need a place to store original list as the one above gets modified constantly on song changes
  String playlist = 'default';            //which playlist to default to
  //Login variables
  var device;       //device we are using
  var config_file = '.config.json'; // desktop/mobile users
  var config = {}; //json parsed map of above file, I also stuff cookies below into it if it is web user, ALSO login page will stuff config['email'] for us
  var cookie_email = ""; //Need these 2 for web users to grab email and token since I can't use json file for them
  var cookie_token = "";
  //incrementing test
  var increment = 1;
  //var play2 as Player;
  //var desktopPlayer = new AudioPlayer(id: 0).obs;
  //var desktopPlayer = (Player.create(id: 0).obs) as Future<Player>;
  //var desktopPlayer = Player.create(id: 0);
  //var desktopPlayer = Player.create(id: 0).obs;
  //var desktopPlayer = Player as Future<Player>;
  //var audioPlayer = myaudio.AudioPlayer().obs;

  //var desktopPlayer = GetPlatform.isWindows ? new AudioPlayer(id: 0).obs: myaudio.AudioPlayer().obs;
  //var myService = AudioService.init(builder: () => MyAudioHandler()).obs;

  Controller() {
    Timer(Duration(milliseconds: 1), () {
      init();
    }); //trick to call async function from non-async function
  }

  init() async {
    //HOLY CHRIST I had these 2 lines in readConfig() and it just kept concatenating config_file longer and longer
    //Just make sure this only gets set once, don't have time to figure this out.
    if(!GetPlatform.isWeb) { //need this before login() or won't have config_file
      Directory config_dir = await getApplicationDocumentsDirectory();
      config_file = p.join(config_dir.path, config_file); //safer than below
      //config_file = "${config_dir.path}/${config_file}";
    }
    device = await getDevice();
    await readConfig(); //put this in init of sunsaturnLogin class?
    await login(); //don't worry rest of this function does get executed even if we change routes in login() function, I tested
    if ((GetPlatform.isWindows || GetPlatform.isLinux) && !GetPlatform.isWeb) {
      print("Initializing Music Player");
      // desktopPlayer = await Player.create(id: 0);
      desktopPlayer = new Player(id: 0);
    }
    else {
      if (GetPlatform.isAndroid) {
        dir = '/sdcard/Music';
      }
      if (GetPlatform.isWeb) {
        dir = '/assets/Music';
      }
      audioPlayer = myaudio.AssetsAudioPlayer();
    }
    await audioInit();
  }

Example snippet of using it from a dart file accessing c.song0 from controller.dart:

import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import './controller.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';

class FirstPage extends StatelessWidget {
  final Controller c = Get.find();

  @override
  Widget build(BuildContext context) {
    return Column(
        //crossAxisAlignment: CrossAxisAlignment.stretch,
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          Expanded(
            flex: 10,
            child: Container(
              alignment: Alignment.centerLeft,
              //width: double.infinity,
              color: Colors.black,
              padding: EdgeInsets.only(left: 11),
              child: Obx(
                () => AutoSizeText('${c.song0}',
                    style: TextStyle(
                        fontSize: 50,
                        backgroundColor: Colors.black,
                        color: Colors.white)),
              ),
            ),
          ),

Just my 2 cents anyways, cut boiler plate code down dramatically making it a somewhat enjoyable experience lol.

@FeodorFitsner
Copy link
Contributor Author

I'm curious why you decided to go with redux though.

Because I was coming from React and Redux for Dart was like "oh, nice!" :)

@syleishere
Copy link
Contributor

Fair enough, snack bar is more beautiful in GetX though lol :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants