Eto - Custom Scrollable (Drawable) - Help Adding Layout

Hello everyone,

I’m about to start working on a custom Scrollable to control the visual styling of it.

Something visually similar to the discourse (these forums) scrollable like this:

image

In drawable OnPaint I will create a pill shape for the “handle” of the scrollable and use a single line for the background “overall length” of the scrollable.

My question specifically is which parent do I inherit from to expose the “OnMouseEnter” “OnMouseLeave” “OnMouseWheel” etc. events? Since Drawable inherits from Eto.Forms.Control is this all I need? Or do I need to inherit from Eto.Forms.Scrollable somehow?

I’m guessing the scrollable handle is dynamically set based on the amount of content that the scrollable contains?

Can I visually change the “handle” “up/down arrows” and “scroll bar/background line” of a scrollable while still keeping the functionality of using it as a layout “container” to house other controls?

I’m just a little confused on where to start and what overrides what…
I could not find any examples of this online or on the forums here though I’ve seen a couple users here and there showcasing custom scrollable graphics.

Here’s my testing mockup code I’m working on:

import Rhino
import Eto

import Rhino
import Eto


class CustomScrollable(Eto.Forms.Drawable):
    def __init__(self):
        super(CustomScrollable, self).__init__()
        self.Size = Eto.Drawing.Size(300, 200)
        self.content_height = 0
        self.scroll_offset = 0
        self.mouse_down = False
        self.start_mouse_y = 0
        self.start_scroll_offset = 0
        self.content = []
        self.scroll_speed = 8  # Scroll speed factor

    def set_content(self, content):
        self.content = content
        self.content_height = len(content) * 25  # Adjust as needed for item height
        self.Invalidate()

    def OnPaint(self, e):
        try:
            e.Graphics.Clear(Eto.Drawing.Colors.White)
            y_offset = -self.scroll_offset

            y = y_offset
            for item in self.content:
                e.Graphics.DrawText(Eto.Drawing.Font("Arial", 12), Eto.Drawing.Colors.Black, 10, y, item)
                y += 25  # Adjust as needed for line spacing

        except Exception as ex:
            print(ex)

    def OnMouseDown(self, e):
        self.mouse_down = True
        self.start_mouse_y = e.Location.Y
        self.start_scroll_offset = self.scroll_offset

    def OnMouseUp(self, e):
        self.mouse_down = False

    def OnMouseMove(self, e):
        if self.mouse_down:
            delta = e.Location.Y - self.start_mouse_y
            self.scroll_offset = self.start_scroll_offset - delta
            self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
            self.Invalidate()

    def OnMouseWheel(self, e):
        self.scroll_offset -= e.Delta.Height * self.scroll_speed
        self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
        self.Invalidate()

class MainForm(Eto.Forms.Form):
    def __init__(self):
        super().__init__()

        # Set Form General Settings
        self.Title = "Main Form"
        self.Size = Eto.Drawing.Size(300, 300)
        self.Resizable = False
        self.MovableByWindowBackground = False

        self.CreateUI()  # Call Initially To Create UI

    def CreateUI(self):
        # Create Pixel Layout For Background Graphics
        self.layout = Eto.Forms.PixelLayout()

        # Setup Bitmap For Background Graphics To Be Drawn On
        pixelformat = Eto.Drawing.PixelFormat.Format32bppRgba
        bitmap = Eto.Drawing.Bitmap(self.Size, pixelformat)
        self.graphics = Eto.Drawing.Graphics(bitmap)

        # Create a stack layout to hold the search bar and search results
        self.stack_layout = Eto.Forms.StackLayout()
        self.stack_layout.Orientation = Eto.Forms.Orientation.Vertical

        test_header = Eto.Forms.Label()
        test_header.Text = "Test Scrollable Header"

        example_scrollable_content = [
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small"
        ]

        # Create the custom scrollable area
        self.custom_scrollable = CustomScrollable()
        self.custom_scrollable.Width = self.Width - 40
        self.custom_scrollable.Height = self.Height - 100
        self.custom_scrollable.BackgroundColor = Eto.Drawing.Colors.Transparent
        self.custom_scrollable.set_content(example_scrollable_content)

        # Add the title header bar to the stack layout
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(test_header))
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.custom_scrollable))

        # Add the stack layout to the pixel layout and set its bounds
        self.layout.Add(self.stack_layout, Eto.Drawing.Point(5, 5))

        # Set the content of the form to be the pixel layout
        self.Content = self.layout


def EstablishForm():
    main_form = MainForm()
    main_form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
    main_form.Show()


if __name__ == "__main__":
    EstablishForm()


Thank you all for any leads!

EDIT:

Okay I’m getting closer and have something mostly working, however, sometimes it crashes when using the mouse to “drag scroll” and sometimes it crashes using the mouse wheel scroll, other times it works fine so I’m having a hard time debugging the issue.

Here’s one of the errors I get in the Rhino Crash Dump:

[ERROR] FATAL UNHANDLED EXCEPTION: System.NullReferenceException: Object reference not set to an instance of an object.
   at Eto.Wpf.Forms.WpfFrameworkElement`3.HandlePreviewMouseWheel(Object sender, MouseWheelEventArgs e) in D:\BuildAgent\work\dujour\src4\DotNetSDK\Eto\src\Eto.Wpf\Forms\WpfFrameworkElement.cs:line 545

Here’s my updated code:

#! python3

import Rhino
import Eto


class CustomScrollable(Eto.Forms.Drawable):
    def __init__(self):
        super(CustomScrollable, self).__init__()
        self.Size = Eto.Drawing.Size(300, 200)
        self.content_height = 0
        self.scroll_offset = 0
        self.mouse_down = False
        self.start_mouse_y = 0
        self.start_scroll_offset = 0
        self.content = []
        self.scroll_speed = 8  # Scroll speed factor

    def set_content(self, content):
        self.content = content
        self.content_height = len(content) * 25  # Adjust as needed for item height
        self.Invalidate()

        self.scroll_pen = Eto.Drawing.Pen(Eto.Drawing.Colors.BlueViolet, 2)

    def OnPaint(self, e):
        try:
            y_offset = -self.scroll_offset

            y = y_offset
            for item in self.content:
                e.Graphics.DrawText(Eto.Drawing.Font("Arial", 12), Eto.Drawing.Colors.Black, 10, y, item)
                y += 25  # Adjust as needed for line spacing

            # Draw the scroll handle
            handle_height_ratio = self.Height / self.content_height
            handle_height = self.Height * handle_height_ratio
            handle_width = 10
            handle_position = (self.scroll_offset / self.content_height) * self.Height
            handle_rect = Eto.Drawing.Rectangle(self.Width - handle_width, int(handle_position), handle_width, int(handle_height))  # Adjust for handle size and position
            handle_round_rect = Eto.Drawing.GraphicsPath.GetRoundRect(handle_rect, (handle_width / 2))
            e.Graphics.FillPath(Eto.Drawing.Colors.BlueViolet, handle_round_rect)
            e.Graphics.DrawLine(self.scroll_pen, Eto.Drawing.PointF(self.Width - (handle_width / 2), 5), Eto.Drawing.PointF(self.Width - (handle_width / 2), self.Height - 5))

        except Exception as ex:
            print(ex)

    def OnMouseDown(self, e):
        try:
            self.mouse_down = True
            self.start_mouse_y = e.Location.Y
            self.start_scroll_offset = self.scroll_offset

        except Exception as ex:
            print(ex)

    def OnMouseUp(self, e):
        try:
            self.mouse_down = False

        except Exception as ex:
            print(ex)

    def OnMouseMove(self, e):
        try:
            if e.Buttons == Eto.Forms.MouseButtons.Primary and self.mouse_down:
                delta = e.Location.Y - self.start_mouse_y
                # self.scroll_offset = self.start_scroll_offset - delta  # Use this for inverted grab scrolling
                self.scroll_offset = self.start_scroll_offset + delta
                self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
                self.Invalidate()

        except Exception as ex:
            print(ex)

    def OnMouseWheel(self, e):
        try:
            self.scroll_offset -= e.Delta.Height * self.scroll_speed
            self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
            self.Invalidate()

        except Exception as ex:
            print(ex)


class MainForm(Eto.Forms.Form):
    def __init__(self):
        super().__init__()

        # Set Form General Settings
        self.Title = "Main Form"
        self.Size = Eto.Drawing.Size(300, 300)
        self.Resizable = False
        self.MovableByWindowBackground = False

        self.CreateUI()  # Call Initially To Create UI

    def CreateUI(self):
        # Create Pixel Layout For Background Graphics
        self.layout = Eto.Forms.PixelLayout()

        # Setup Bitmap For Background Graphics To Be Drawn On
        pixelformat = Eto.Drawing.PixelFormat.Format32bppRgba
        bitmap = Eto.Drawing.Bitmap(self.Size, pixelformat)
        self.graphics = Eto.Drawing.Graphics(bitmap)

        # Create a stack layout to hold the search bar and search results
        self.stack_layout = Eto.Forms.StackLayout()
        self.stack_layout.Orientation = Eto.Forms.Orientation.Vertical

        test_header = Eto.Forms.Label()
        test_header.Text = "Test Scrollable Header"

        example_scrollable_content = [
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small"
        ]

        # Create the custom scrollable area
        self.custom_scrollable = CustomScrollable()
        self.custom_scrollable.Width = self.Width - 40
        self.custom_scrollable.Height = self.Height - 100
        self.custom_scrollable.BackgroundColor = Eto.Drawing.Colors.Transparent
        self.custom_scrollable.set_content(example_scrollable_content)

        # Add the title header bar to the stack layout
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(test_header))
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.custom_scrollable))

        # Add the stack layout to the pixel layout and set its bounds
        self.layout.Add(self.stack_layout, Eto.Drawing.Point(5, 5))

        # Set the content of the form to be the pixel layout
        self.Content = self.layout


def EstablishForm():
    main_form = MainForm()
    main_form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
    main_form.Show()


if __name__ == "__main__":
    EstablishForm()

And what it looks like visually:
image

Does anyone have any ideas why the MouseEventArgs are crashing?

Thank you all for your help!

2 Likes

Here’s the the script using just graphics.DrawText which visually “operates” how I would like it to but doesn’t have the layout logic integrated:

import Rhino
import Eto


class CustomScrollable(Eto.Forms.Drawable):
    def __init__(self, control):
        super(CustomScrollable, self).__init__()
        self._control = control
        self.AutoScroll = True  # Enable automatic scrolling
        self.scrollFactor = 25

    def OnMouseWheel(self, e):
        moveDelta = e.Delta.Height * self.scrollFactor
        # Rhino.RhinoApp.WriteLine(moveDelta)
        self._currentLocation += moveDelta
        self.CheckScrollBarExceedsBounds(self._currentLocation)
        self._layout.Move(self._control, Eto.Drawing.Point(0, self._currentLocation))


class MainForm(Eto.Forms.Form):
    def __init__(self):
        super().__init__()

        global main_form_instance
        main_form_instance = self

        # Set Form General Settings
        self.Title = "Main Form"
        self.Size = Eto.Drawing.Size(300, 300)
        self.Resizable = False
        self.MovableByWindowBackground = False

        self.CreateUI()  # Call Initially To Create UI

    def CreateUI(self):
        # Create Pixel Layout For Background Graphics
        self.layout = Eto.Forms.PixelLayout()

        # Setup Bitmap For Background Graphics To Be Drawn On
        pixelformat = Eto.Drawing.PixelFormat.Format32bppRgba
        bitmap = Eto.Drawing.Bitmap(self.Size, pixelformat)
        self.graphics = Eto.Drawing.Graphics(bitmap)

        # Create a stack layout to hold the search bar and search results
        self.stack_layout = Eto.Forms.StackLayout()
        self.stack_layout.Orientation = Eto.Forms.Orientation.Vertical

        test_header = Eto.Forms.Label()
        test_header.Text = "Test Scrollable Header"

        example_scrollable_content = [
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small"
        ]

        self.dynamic_layout = Eto.Forms.DynamicLayout()

        for content in example_scrollable_content:
            button = Eto.Forms.Button()
            button.Text = content
            self.dynamic_layout.AddRow(button)

        # Create the custom scrollable area
        self.custom_scrollable = CustomScrollable(self.dynamic_layout)
        # self.custom_scrollable = Eto.Forms.Scrollable()
        self.custom_scrollable.Width = self.Width - 40
        self.custom_scrollable.Height = self.Height - 100
        self.custom_scrollable.BackgroundColor = Eto.Drawing.Colors.Transparent
        self.custom_scrollable.Content = self.dynamic_layout

        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.custom_scrollable))

        # Add the stack layout to the pixel layout and set its bounds
        self.layout.Add(self.stack_layout, Eto.Drawing.Point(5, 5))

        # Set the content of the form to be the pixel layout
        self.Content = self.layout


def EstablishForm():
    main_form = MainForm()
    main_form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
    main_form.Show()


if __name__ == "__main__":
    EstablishForm()

And here’s what it looks like, you can see the buttons show up when passed as an argument for the CustomScrollable class but I can no longer see the Custom Scrollbar:

image

I can’t for the life of me figure out how to get the layout of controls to show up in the custom drawable. It seems to be one or the other right now. I either can see the scrollbar but not the buttons, or I see the buttons but now the scroll bar:

Here’s that effort:

import Rhino
import Eto

main_form_instance = None


class CustomScrollable(Eto.Forms.Drawable):
    def __init__(self):
        super(CustomScrollable, self).__init__()
        self.Size = Eto.Drawing.Size(300, 200)
        self.content_height = 0
        self.scroll_offset = 0
        self.mouse_down = False
        self.start_mouse_y = 0
        self.start_scroll_offset = 0
        self.content = []
        self.scroll_speed = 25  # Scroll speed factor (same as item height)

        self.scroll_pen = Eto.Drawing.Pen(Eto.Drawing.Colors.BlueViolet, 2)

    def set_content(self, content):
        self.content = content
        self.content_height = 25  # Adjust as needed for item height
        self.Invalidate()

    def OnPaint(self, e):
        try:
            y_offset = -self.scroll_offset

            y = y_offset
            for control in self.content:
                y += 25  # Adjust as needed for content snapping

            # Draw the scroll handle
            handle_height_ratio = self.Height / self.content_height
            handle_height = self.Height * handle_height_ratio
            handle_width = 10
            handle_position = (self.scroll_offset / self.content_height) * self.Height
            handle_rect = Eto.Drawing.Rectangle(self.Width - handle_width, int(handle_position), handle_width, int(handle_height))  # Adjust for handle size and position
            handle_round_rect = Eto.Drawing.GraphicsPath.GetRoundRect(handle_rect, (handle_width / 2))
            e.Graphics.FillPath(Eto.Drawing.Colors.BlueViolet, handle_round_rect)
            e.Graphics.DrawLine(self.scroll_pen, Eto.Drawing.PointF(self.Width - (handle_width / 2), 5), Eto.Drawing.PointF(self.Width - (handle_width / 2), self.Height - 5))

        except Exception as ex:
            print(f"OnPaint Exception: {ex}")

    def OnMouseDown(self, e):
        try:
            if main_form_instance.HasFocus:
                # self.mouse_down = True
                pass
                # self.start_mouse_y = e.Location.Y
                # self.start_scroll_offset = self.scroll_offset

        except Exception as ex:
            print(f"OnMouseDown Exception: {ex}")

    def OnMouseUp(self, e):
        try:
            self.mouse_down = False
            if main_form_instance.HasFocus:
                self.start_mouse_y = e.Location.Y
                self.start_scroll_offset = self.scroll_offset
                # Calculate the click position as a percentage of the scrollable height
                click_position_ratio = e.Location.Y / self.Height
                self.scroll_offset = click_position_ratio * self.content_height
                self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
                # Snap to the nearest item
                self.scroll_offset = round(self.scroll_offset / 25) * 25
                self.Invalidate()

        except Exception as ex:
            print(f"OnMouseUp Exception: {ex}")

    # def OnMouseMove(self, e):
    #     try:
    #         if main_form_instance.HasFocus and e.Buttons == Eto.Forms.MouseButtons.Primary and self.mouse_down:
    #             delta = e.Location.Y - self.start_mouse_y
    #             self.scroll_offset = self.start_scroll_offset + delta
    #             self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
    #             self.Invalidate()

    #     except Exception as ex:
    #         print(f"OnMouseMove Exception: {ex}")

    def OnMouseWheel(self, e):
        try:
            if e.Buttons is not None:
                self.scroll_offset -= e.Delta.Height * self.scroll_speed
                self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
                # Snap to the nearest item
                self.scroll_offset = round(self.scroll_offset / 25) * 25
                self.Invalidate()

        except Exception as ex:
            print(f"OnMouseWheel Exception: {ex}")


class MainForm(Eto.Forms.Form):
    def __init__(self):
        super().__init__()

        global main_form_instance
        main_form_instance = self

        # Set Form General Settings
        self.Title = "Main Form"
        self.Size = Eto.Drawing.Size(300, 300)
        self.Resizable = False
        self.MovableByWindowBackground = False

        self.CreateUI()  # Call Initially To Create UI

    def CreateUI(self):
        # Create Pixel Layout For Background Graphics
        self.layout = Eto.Forms.PixelLayout()

        # Setup Bitmap For Background Graphics To Be Drawn On
        pixelformat = Eto.Drawing.PixelFormat.Format32bppRgba
        bitmap = Eto.Drawing.Bitmap(self.Size, pixelformat)
        self.graphics = Eto.Drawing.Graphics(bitmap)

        # Create a stack layout to hold the search bar and search results
        self.stack_layout = Eto.Forms.StackLayout()
        self.stack_layout.Orientation = Eto.Forms.Orientation.Vertical

        test_header = Eto.Forms.Label()
        test_header.Text = "Test Scrollable Header"

        example_scrollable_content = [
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small"
        ]

        self.dynamic_layout = Eto.Forms.DynamicLayout()

        for content in example_scrollable_content:
            button = Eto.Forms.Button()
            button.Text = content
            self.dynamic_layout.AddRow(button)

        self.custom_scrollable = CustomScrollable()
        self.custom_scrollable.Width = self.Width - 40
        self.custom_scrollable.Height = self.Height - 100
        self.custom_scrollable.BackgroundColor = Eto.Drawing.Colors.Transparent
        self.custom_scrollable.set_content( self.dynamic_layout)

        # Add the title header bar to the stack layout
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(test_header))
        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.custom_scrollable))

        # Add the stack layout to the pixel layout and set its bounds
        self.layout.Add(self.stack_layout, Eto.Drawing.Point(5, 5))

        # Set the content of the form to be the pixel layout
        self.Content = self.layout


def EstablishForm():
    main_form = MainForm()
    main_form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
    main_form.Show()


if __name__ == "__main__":
    EstablishForm()

I appreciate any leads! I tried to “pythonize” this C# implementation of a similar problem but could not get it working… Here’s that effort in code:

import Rhino
import Eto


class CustomScrollable(Eto.Forms.Drawable):
    def __init__(self, control):
        super(CustomScrollable, self).__init__()
        self._control = control
        self.AutoScroll = True  # Enable automatic scrolling
        self.scrollFactor = 25

    def OnMouseWheel(self, e):
        moveDelta = e.Delta.Height * self.scrollFactor
        # Rhino.RhinoApp.WriteLine(moveDelta)
        self._currentLocation += moveDelta
        self.CheckScrollBarExceedsBounds(self._currentLocation)
        self._layout.Move(self._control, Eto.Drawing.Point(0, self._currentLocation))


class MainForm(Eto.Forms.Form):
    def __init__(self):
        super().__init__()

        global main_form_instance
        main_form_instance = self

        # Set Form General Settings
        self.Title = "Main Form"
        self.Size = Eto.Drawing.Size(300, 300)
        self.Resizable = False
        self.MovableByWindowBackground = False

        self.CreateUI()  # Call Initially To Create UI

    def CreateUI(self):
        # Create Pixel Layout For Background Graphics
        self.layout = Eto.Forms.PixelLayout()

        # Setup Bitmap For Background Graphics To Be Drawn On
        pixelformat = Eto.Drawing.PixelFormat.Format32bppRgba
        bitmap = Eto.Drawing.Bitmap(self.Size, pixelformat)
        self.graphics = Eto.Drawing.Graphics(bitmap)

        # Create a stack layout to hold the search bar and search results
        self.stack_layout = Eto.Forms.StackLayout()
        self.stack_layout.Orientation = Eto.Forms.Orientation.Vertical

        test_header = Eto.Forms.Label()
        test_header.Text = "Test Scrollable Header"

        example_scrollable_content = [
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small",
            "Something Here", "Something There", "Something Else",
            "Something Over", "Something Under", "Something Near",
            "Something Far", "Something Large", "Something Small"
        ]

        self.dynamic_layout = Eto.Forms.DynamicLayout()

        for content in example_scrollable_content:
            button = Eto.Forms.Button()
            button.Text = content
            self.dynamic_layout.AddRow(button)

        # Create the custom scrollable area
        self.custom_scrollable = CustomScrollable(self.dynamic_layout)
        # self.custom_scrollable = Eto.Forms.Scrollable()
        self.custom_scrollable.Width = self.Width - 40
        self.custom_scrollable.Height = self.Height - 100
        self.custom_scrollable.BackgroundColor = Eto.Drawing.Colors.Transparent
        self.custom_scrollable.Content = self.dynamic_layout

        self.stack_layout.Items.Add(Eto.Forms.StackLayoutItem(self.custom_scrollable))

        # Add the stack layout to the pixel layout and set its bounds
        self.layout.Add(self.stack_layout, Eto.Drawing.Point(5, 5))

        # Set the content of the form to be the pixel layout
        self.Content = self.layout


def EstablishForm():
    main_form = MainForm()
    main_form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
    main_form.Show()


if __name__ == "__main__":
    EstablishForm()

Thank you all, I’m stuck on how to achieve a drawable scrollbar in python that accepts a layout as its content. I appreciate the help!

1 Like

Bumping this topic as I still haven’t figured it out.

The use case is to have a scrollable layout that can accept controls and drawable controls but allow full customization/graphics control over the scroll handle/bar itself.

@curtisw is there any content on controlling the styling/graphics of a scrollable control

If not, could you reference the code I posted above and let me know where I’m going wrong?

Thank you for your help!

EDIT:

Here’s some updated code where I can scroll the OnPaint drawables as expected and I got the button controls to show up but they do not scroll. I’m guessing this is because the buttons are wanting a layout to be moved for the scroll to happen but I’m only scrolling the “drawable”

Is this something you would be willing to enlighten me on @clement ?

Video of buttons not scrolling:

Code:

import Rhino
import Eto.Forms
import Eto.Drawing

wl = Rhino.RhinoApp.WriteLine

show_search = True
show_tray = True

srb_height = 24  # Button Height
srb_button_padding = 5  # Button Padding

test_button_count = 35


class CustomScrollable(Eto.Forms.Drawable):
    def __init__(self):
        super(CustomScrollable, self).__init__()
        self.Size = Eto.Drawing.Size(300, 200)
        self.content_height = 0
        self.scroll_offset = 0
        self.mouse_down = False
        self.start_mouse_y = 0
        self.start_scroll_offset = 0
        self.content = None
        self.scroll_speed = 10  # Scroll speed factor

    def SetContent(self, content):
        self.content = content
        if self.content:
            # Ensure content is valid before accessing properties
            self.content_height = len(self.content.Rows) * 25 if hasattr(self.content, 'Rows') else 0
        else:
            self.content_height = 0

        # self.scroll_offset = 0  # Reset scroll offset
        self.Invalidate()
        self.scroll_pen = Eto.Drawing.Pen(Eto.Drawing.Colors.BlueViolet, 2)
        return self.content

    def OnPaint(self, e):
        wl(f"SO: {self.scroll_offset}")
        # wl(f"C: {self.content}")
        try:

            if self.content is None:
                return

            y_offset = -self.scroll_offset
            y = y_offset

            for item in range(test_button_count):
                e.Graphics.DrawText(Eto.Drawing.Font("Arial", 12), Eto.Drawing.Colors.Black, 10, y, str(item))
                y += 25  # Adjust as needed for line spacing

            if self.content_height > self.Height:  # Prevent division by zero
                handle_height_ratio = self.Height / self.content_height

                # wl(f"CH: {self.content_height}")
                # wl(f"HHR: {handle_height_ratio}")
                handle_height = self.Height * handle_height_ratio
                handle_width = 10
                handle_position = (self.scroll_offset / self.content_height) * self.Height
                handle_rect = Eto.Drawing.Rectangle(self.Width - handle_width, int(handle_position), handle_width, int(handle_height))  # Adjust for handle size and position
                handle_round_rect = Eto.Drawing.GraphicsPath.GetRoundRect(handle_rect, (handle_width / 2))
                e.Graphics.FillPath(Eto.Drawing.Colors.BlueViolet, handle_round_rect)
                e.Graphics.DrawLine(self.scroll_pen, Eto.Drawing.PointF(self.Width - (handle_width / 2), 5), Eto.Drawing.PointF(self.Width - (handle_width / 2), self.Height - 5))

        except Exception as ex:
            wl(f"OnPaint Exception: {ex}")

    def OnMouseDown(self, e):
        try:
            self.mouse_down = True
            self.start_mouse_y = e.Location.Y
            self.start_scroll_offset = self.scroll_offset
            self.Invalidate()

        except Exception as ex:
            wl(f"OnMouseDown Exception: {ex}")

    def OnMouseUp(self, e):
        try:
            self.mouse_down = False
            self.Invalidate()

        except Exception as ex:
            wl(f"OnMouseUp Exception: {ex}")

    def OnMouseMove(self, e):
        try:
            if e.Buttons == Eto.Forms.MouseButtons.Primary and self.mouse_down:
                delta = e.Location.Y - self.start_mouse_y
                self.scroll_offset = self.start_scroll_offset + delta
                self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
                self.Invalidate()

        except Exception as ex:
            wl(f"OnMouseMove Exception: {ex}")

    def OnMouseWheel(self, e):
        try:
            if self.content_height > self.Height:  # Prevent scrolling if content_height is not greater than scrollable container height
                self.scroll_offset -= e.Delta.Height * self.scroll_speed
                self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
                self.Invalidate()
            else:
                pass

        except Exception as ex:
            wl(f"OnMouseWheel Exception: {ex}")


class MainForm(Eto.Forms.Form):
    def __init__(self):
        super().__init__()

        # Set Form General Settings
        self.Title = "Main Form"
        self.Size = Eto.Drawing.Size(300, 300)
        self.Resizable = True
        self.MovableByWindowBackground = False

        self.CreateUI()  # Call Initially To Create UI

    def CreateUI(self):
        # Create Dynamic Layout For Controls
        self.layout = Eto.Forms.DynamicLayout()

        # Create a stack layout to hold the search bar and search results
        self.search_layout = Eto.Forms.StackLayout()
        self.search_layout.Orientation = Eto.Forms.Orientation.Vertical
        self.search_layout.Spacing = 0
        self.search_layout.Padding = Eto.Drawing.Padding(10)

        test_header = Eto.Forms.Label()
        test_header.Text = "Test Scrollable Header"

        # Initialize the DynamicLayout
        self.search_result_layout = Eto.Forms.DynamicLayout()
        self.search_result_layout.Spacing = Eto.Drawing.Size(0, 0)

        # Create buttons for testing
        srb_buttons = []
        for i in range(test_button_count):
            button = Eto.Forms.Button()
            button.Text = f"Button {i}"
            button.Height = srb_height + (srb_button_padding * 2)
            button.Width = 100  # Set a fixed width for the buttons
            srb_buttons.append(button)

        # Update the DynamicLayout with new buttons
        # self.search_result_layout.Clear()  # Ensure layout is cleared
        for button in srb_buttons:
            self.search_result_layout.AddRow(None, button, None)  # Add None to center the button

        # Set the updated layout to the custom scrollable
        # Create a custom scrollable for search results
        self.search_result_scrollable = CustomScrollable()
        self.search_result_scrollable.Width = self.Width - 40
        self.search_result_scrollable.Height = self.Height - 100
        self.search_result_scrollable.BackgroundColor = Eto.Drawing.Colors.Salmon
        # self.search_result_scrollable.set_content(self.search_result_layout)
        self.search_result_scrollable.Content = self.search_result_scrollable.SetContent(self.search_result_layout)
        # self.search_result_scrollable.Content(self.content)
        wl(str(self.search_result_scrollable.Content))

        # Add the title header bar to the stack layout
        self.search_layout.Items.Add(Eto.Forms.StackLayoutItem(test_header))
        self.search_layout.Items.Add(Eto.Forms.StackLayoutItem(self.search_result_scrollable))

        # Add the stack layout to the pixel layout and set its bounds
        self.layout.Add(self.search_layout)

        # Set the content of the form to be the pixel layout
        self.Content = self.layout


def EstablishForm():
    main_form = MainForm()
    main_form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
    main_form.Show()


if __name__ == "__main__":
    EstablishForm()

1 Like

When coding a scrollable from scratch, it’s best to put your controls in a PixelLayout and move it’s pixel position up and down.

1 Like

Awesome! Thank you for the tip @mrhe , makes total sense now that I hear it.

Got it working with the following code, now I’ll clean up my code but here’s the stop and plot:

import Rhino
import Eto.Forms
import Eto.Drawing

wl = Rhino.RhinoApp.WriteLine

show_search = True
show_tray = True

srb_height = 24  # Button Height
srb_button_padding = 5  # Button Padding

test_button_count = 35


class CustomScrollable(Eto.Forms.Drawable):
    def __init__(self):
        super(CustomScrollable, self).__init__()
        self.Size = Eto.Drawing.Size(300, 200)
        self.content_height = 0
        self.scroll_offset = 0
        self.mouse_down = False
        self.start_mouse_y = 0
        self.start_scroll_offset = 0
        self.content = None
        self.scroll_speed = 24  # Scroll speed factor
        self.scroll_pen = Eto.Drawing.Pen(Eto.Drawing.Colors.BlueViolet, 2)

    def SetContent(self, content):
        self.content = content
        wl(f"C: {self.content}")
        
        if self.content and isinstance(self.content, Eto.Forms.PixelLayout):
            # Iterate through children to find DynamicLayout
            self.buttons = []
            dynamic_layout = self.content.Children
            for control in dynamic_layout:
                self.buttons.append(control)

            if dynamic_layout:
                self.content_height = len(self.buttons) * 25
                wl(f"CH: {self.content_height}")
            else:
                self.content_height = 0
                wl("No DynamicLayout found inside PixelLayout")
        else:
            self.content_height = 0
            wl(f"CH: {self.content_height}")

        self.UpdateContentPosition()
        self.Invalidate()
        return self.content

    def UpdateContentPosition(self):
        if self.content:
            # self.content.Location = Eto.Drawing.Point(0, -self.scroll_offset)
            for button in self.buttons:
                self.content.Move(button, Eto.Drawing.Point(50, -self.scroll_offset))
            self.Invalidate()

    def OnPaint(self, e):
        wl(f"SO: {self.scroll_offset}")
        try:
            if self.content is None:
                return

            y_offset = -self.scroll_offset
            y = y_offset

            for item in range(test_button_count):
                e.Graphics.DrawText(Eto.Drawing.Font("Arial", 12), Eto.Drawing.Colors.Black, 10, y, str(item))
                y += 25  # Adjust as needed for line spacing

            if self.content_height > self.Height:  # Prevent division by zero
                handle_height_ratio = self.Height / self.content_height

                handle_height = self.Height * handle_height_ratio
                handle_width = 10
                handle_position = (self.scroll_offset / self.content_height) * self.Height
                handle_rect = Eto.Drawing.Rectangle(self.Width - handle_width, int(handle_position), handle_width, int(handle_height))  # Adjust for handle size and position
                handle_round_rect = Eto.Drawing.GraphicsPath.GetRoundRect(handle_rect, (handle_width / 2))
                e.Graphics.FillPath(Eto.Drawing.Colors.BlueViolet, handle_round_rect)
                e.Graphics.DrawLine(self.scroll_pen, Eto.Drawing.PointF(self.Width - (handle_width / 2), 5), Eto.Drawing.PointF(self.Width - (handle_width / 2), self.Height - 5))

        except Exception as ex:
            wl(f"OnPaint Exception: {ex}")

    def OnMouseDown(self, e):
        try:
            self.mouse_down = True
            self.start_mouse_y = e.Location.Y
            self.start_scroll_offset = self.scroll_offset
            self.Invalidate()

        except Exception as ex:
            wl(f"OnMouseDown Exception: {ex}")

    def OnMouseUp(self, e):
        try:
            self.mouse_down = False
            self.Invalidate()

        except Exception as ex:
            wl(f"OnMouseUp Exception: {ex}")

    def OnMouseMove(self, e):
        try:
            if e.Buttons == Eto.Forms.MouseButtons.Primary and self.mouse_down:
                delta = e.Location.Y - self.start_mouse_y
                self.scroll_offset = self.start_scroll_offset + delta
                self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
                self.UpdateContentPosition()

        except Exception as ex:
            wl(f"OnMouseMove Exception: {ex}")

    def OnMouseWheel(self, e):
        try:
            if self.content_height > self.Height:  # Prevent scrolling if content_height is not greater than scrollable container height
                self.scroll_offset -= e.Delta.Height * self.scroll_speed
                self.scroll_offset = max(0, min(self.scroll_offset, self.content_height - self.Height))
                self.UpdateContentPosition()
            else:
                pass

        except Exception as ex:
            wl(f"OnMouseWheel Exception: {ex}")


class MainForm(Eto.Forms.Form):
    def __init__(self):
        super().__init__()

        # Set Form General Settings
        self.Title = "Main Form"
        self.Size = Eto.Drawing.Size(300, 300)
        self.Resizable = True
        self.MovableByWindowBackground = False

        self.CreateUI()  # Call Initially To Create UI

    def CreateUI(self):
        # Create Dynamic Layout For Controls
        self.layout = Eto.Forms.DynamicLayout()

        # Create a stack layout to hold the search bar and search results
        self.search_layout = Eto.Forms.StackLayout()
        self.search_layout.Orientation = Eto.Forms.Orientation.Vertical
        self.search_layout.Spacing = 0
        self.search_layout.Padding = Eto.Drawing.Padding(10)

        test_header = Eto.Forms.Label()
        test_header.Text = "Test Scrollable Header"

        # Initialize the DynamicLayout
        self.search_result_layout_wrapper = Eto.Forms.PixelLayout()

        self.search_result_layout = Eto.Forms.DynamicLayout()
        self.search_result_layout.Spacing = Eto.Drawing.Size(0, 0)

        # Create buttons for testing
        srb_buttons = []
        for i in range(test_button_count):
            button = Eto.Forms.Button()
            button.Text = f"Button {i}"
            button.Height = srb_height + (srb_button_padding * 2)
            button.Width = 100  # Set a fixed width for the buttons
            srb_buttons.append(button)

        # Update the DynamicLayout with new buttons
        for button in srb_buttons:
            self.search_result_layout.AddRow(None, button, None)  # Add None to center the button

        self.search_result_layout_wrapper.Add(self.search_result_layout, 50, 0)

        # Set the updated layout to the custom scrollable
        # Create a custom scrollable for search results
        self.search_result_scrollable = CustomScrollable()
        self.search_result_scrollable.Width = self.Width - 40
        self.search_result_scrollable.Height = self.Height - 40
        self.search_result_scrollable.BackgroundColor = Eto.Drawing.Colors.Salmon
        self.search_result_scrollable.Content = self.search_result_scrollable.SetContent(self.search_result_layout_wrapper)

        wl(str(self.search_result_scrollable.Content))

        # Add the title header bar to the stack layout
        self.search_layout.Items.Add(Eto.Forms.StackLayoutItem(test_header))
        self.search_layout.Items.Add(Eto.Forms.StackLayoutItem(self.search_result_scrollable))

        # Add the stack layout to the pixel layout and set its bounds
        self.layout.Add(self.search_layout)

        # Set the content of the form to be the pixel layout
        self.Content = self.layout


def EstablishForm():
    main_form = MainForm()
    main_form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
    main_form.Show()


if __name__ == "__main__":
    EstablishForm()

2 Likes

Looking good!

Always appreciate you sharing your eto code with us, some other folks will find that mighty handy in the future :slight_smile:

2 Likes

I’ve benefitted immensely from users sharing things here so I try to return the favor when I have something I can’t find anywhere else :slight_smile:

4 Likes