toga icon indicating copy to clipboard operation
toga copied to clipboard

Add widget as table element

Open saroad2 opened this issue 5 years ago • 17 comments

Expected Behavior

I want to add a table with Toga that one of its columns is a column of switches (checkboxes). When I run the application, instead of putting the checkboxes in the desired column, it writes its representation string. Is there any way you can fix that?

Example code:

data = [
   ["a", "b", c"],
   ["d", "e", f"]
   ["g", "h", i"]
]

table = toga.Table(
    headings=["choose", "1", "2", "3"]
    style=Pack(flex=1),
)

for record in data:
   checkbox = toga.Switch(label="label")
   table.data.append(checkbox, *record)
main_box.add(table)
main_window.content = main_box

Your Environment

  • Python Version 3.7

  • Operating System and Version

    • [ ] macOS - version:
    • [ ] Linux - distro: - version:
    • [X] Windows - version:
    • [ ] Other - name: - version:
  • Toga Version 0.3.0.dev19

  • Toga Target (the type of app you are trying to generate)

    • [ ] android
    • [ ] cocoa
    • [ ] django
    • [ ] gtk
    • [ ] iOS
    • [ ] tvOS
    • [ ] watchOS
    • [ ] winforms
    • [X] win32
    • [ ] Other (please specify)

saroad2 avatar Apr 02 '20 11:04 saroad2

Is it fixable? Maybe. Is it an easy fix? I don't know. I believe this already works on macOS; I have no idea what would be involved in making it also work on Linux or Windows.

freakboy3742 avatar Apr 03 '20 01:04 freakboy3742

So I did a little research and found out this.

What I actually needed is a checkbox for every row. I need that in order to select rows from the table in my project.

The question is: how would we use it in Toga? is it relevant for all platforms? I think it is.

My suggestion is to add a boolean checkboxes property to toga.Table. If it's true, add checkboxes for the table rows. Otherwise, do not. Also, we should add the following methods:

  • is_chcked(index: int) - Returns True if the row of the given index is checked.
  • set_check(index: int, check: bool) - Check/uncheck the checkbox in the row of the given index based on the check boolean variable

saroad2 avatar Apr 21 '20 19:04 saroad2

If I'm reading that correctly, the Winforms widget is restricted to only allow a checkbox on a per-row basis? i.e, you can't drop a checkbox anywhere in the layout - it can only be at the start of the row?

If so, that's an interesting restriction. I can definitely see the use case they're aiming for, though - the most likely use case for a checkbox in a table is exactly what you're using it for - "select this item".

However, it poses a bigger question for Toga - do we modify the Toga API to add "selectable rows" as an API, or do we keep looking for a way to include arbitrary widgets in a Winforms table?

freakboy3742 avatar Apr 23 '20 10:04 freakboy3742

I think you are right, but I am not familiar with Winforms enough in order to give you an educated answer.

I don't know whether it's right or not to constraint the usage of only checkboxes in tables. Right now this is the only thing that separates me from using the Table widget instead of simply using Box elements order as a table (which is what I actually using at the moment).

I guess in the end it's a question of design if it's appropriate for Toga to handle those things that way.

saroad2 avatar Apr 23 '20 10:04 saroad2

I believe this already works on macOS;

It works for a Tree widget on macOS which was recently rewritten to be view-based. The Table widget is currently still cell-based but rewriting it should not be too much work with the experience from NSOutlineView. On Windows, this seems to be more difficult. From their documentation:

A ListView control allows you to display a list of items with item text and, optionally, an icon to identify the type of item. For example, the Windows Explorer list of files is similar in appearance to a ListView control.

I think the general use case of arbitrary widgets in a table is nice to have if supported by the platform. Otherwise, putting together an equivalent by combining other widgets may not be worth the effort and can easily look out-of-place. The use case of checkable rows could possibly have its own cross-platform API without sacrificing the flexibility afforded by some platforms.

samschott avatar Apr 23 '20 14:04 samschott

@SamSchott , So you are talking about a new widget named CheckboxedTable that is exactly like Table (and maybe even inherits from that), but also has a checkbox in every row?

This can work, I think.

saroad2 avatar Apr 24 '20 06:04 saroad2

I was thinking of an API to make rows or items in a Table widget checkable without introducing a new widget altogether. This could be similar to the API of QTableWidgetItem in Qt with properties checkable and checked for every row or item.

But this of course depends on finding out what is available on different platforms, I might well have overlooked something in Winforms.

samschott avatar Apr 24 '20 14:04 samschott

In terms of a Toga API, I'm not wild about the idea of CheckboxedTable - that sounds like a workaround to compensate for the fact that the Windows API is proving difficult.

I agree with @SamSchott that allowing any table cell to be an interactive checkbox (or, I guess, any other simple control) is the real goal. In that context a CheckboxedTable is a table where the first column is always a checkbox; but it would also allow for rendering views like a file permissions table - where you need read,write and execute checkboxes, probably presented after the label for the file name.

freakboy3742 avatar Apr 25 '20 01:04 freakboy3742

Following up after the discussion on #1022:

I maintain that I'd prefer to see "widget anywhere in the table" be the supported API for Toga's Table, and CheckboxedTable is a workaround I'd rather avoid.

However - I would be willing to entertain a short-term fix, in the sprit of "do the best we can until we can work out the long-term fix".

If Winforms Table has a straightforward API to allow the first column to have a checkbox, but nothing else - then let's provide that. If the user specifies the first column as having a checkbox widget, use the Winforms API that enables that feature. If they specify a widget anywhere else, or a first-column widget other than a checkbox, raise a warning indicating it's not implemented.

This allows at least some uses (in particular, the one that you've described in #1022), while leaving the door open for a "full" fix later. I'd be deeply surprised if "widget anywhere" wasn't possible - it's only a question of how much effort is involved. A partial implementation gives at least some of the desired behavior. It also exploits the key benefit of the Toga API abstraction - that we can define a public API that exposes an interface that is almost completely independent of the private implementation; and, if necessary, the private implementation can change over time without affecting user code.

freakboy3742 avatar Sep 17 '20 23:09 freakboy3742

As mentioned here, currently ListView (which toga.Table rely on) supports only text as an input to the table. It supports some additional stuff such as a checkboxes column, but not much more. The only ways I can see to bypass that is one of 2 things:

  1. Implement our own sort of "ListView" that supports addition of any widget to the view.
  2. Use a third party library that already does that. A quick search I did revealed this and this, and some others.

In both of those cases, we would need to rewrite toga.Table from scratch in Windows.

What do you think is the right way to go?

saroad2 avatar Sep 18 '20 08:09 saroad2

Using a third party library isn't an option IMHO, because it introduces a binary dependency; so we're going to need to build a pure-Python ListView of our own.

We've done this with DetailedList on both GTK and Cocoa; on those platforms, there isn't a "simple" drop in DetailedList widget - we've had to construct one from primitives.

You may be able to use existing implementations as a source of inspiration, and do a "code translation". If you can find a good example written in C# that has the features we need, you may be able to do an idiomatic translation to Python and get the features we need.

However, if you do use someone else's code for inspiration, be very careful about licensing. The second example from your search results looks great - and looks like it could be the start of an implementation of icons on TreeViews and DetailedListView (both of which have been long standing wishlist items for the winforms backend). However, it's also GPL3, which means it is license toxic from the perspective of Toga. We cannot use any GPL code - even as a source of inspiration - without putting Toga's codebase into legally difficult waters.

So - if you find a project that is a good candidate for a code translation, make sure it's got a license that allows liberal use, before you look at the source code. MIT or BSD would be ideal; Apache is OK; LGPL might be ok (although I'd generally prefer to avoid it - even LGPL has some explosive potential in rewrite situations); but GPL and AGPL are complete non-starters. If you look at the code and then discover it's GPL, you could find that we're in a "fruit of the poisoned tree" situation.

freakboy3742 avatar Sep 19 '20 01:09 freakboy3742

Hey y'all, I have a working example for putting a button on a listview. I do have some copyright concerns though, I've used a list of potential problems described in the introduction of one of the articles mentioned above to come up with my own solutions of these problems, which may be a problem.

Intial Version of the Code
using System;
using System.Drawing;
using System.Windows.Forms;

class ScrollableListView : ListView
{
    // Custom scroll event
    public event ScrollEventHandler Scroll;

    protected override void WndProc(ref Message m)
    {
        const int WM_HSCROLL = 0x114;
        const int WM_VSCROLL = 0x115;

        base.WndProc(ref m);

        if (m.Msg == WM_HSCROLL || m.Msg == WM_VSCROLL)
        {
            // Trigger the custom scroll event
            Scroll.Invoke(this, new ScrollEventArgs(ScrollEventType.EndScroll, 0));
        }
    }
}

class MyForm : Form
{
    private ScrollableListView listView1;
    private Button cellButton;

private void ListView1_Scroll(object sender, ScrollEventArgs e)
{
    UpdateButtonPosition();
}

    private void ListView1_SelectedIndexChanged(object sender, EventArgs e)
    {
        cellButton.Invalidate();
    }


    public MyForm()
    {
        this.Text = "ListView Example";
        this.Size = new Size(400, 300);

        CreateMyListView();
        CreateCellButton();
        UpdateButtonPosition();
    }

    private void CreateMyListView()
    {
        listView1 = new ScrollableListView();
listView1.Scroll += ListView1_Scroll;
        listView1.SelectedIndexChanged += ListView1_SelectedIndexChanged;
        listView1.Bounds = new Rectangle(new Point(10, 10), new Size(360, 200));
        listView1.HeaderStyle = ColumnHeaderStyle.Nonclickable;
        listView1.FullRowSelect = true;
        listView1.MultiSelect = true;
        listView1.ColumnWidthChanging += ListView1_ColumnWidthChanging;
        listView1.HideSelection = false;
        listView1.View = View.Details;

        // Add columns
        listView1.Columns.Add("Item Column", 100, HorizontalAlignment.Left);
        listView1.Columns.Add("Column 2", 80, HorizontalAlignment.Left);
        listView1.Columns.Add("Column 3", 80, HorizontalAlignment.Left);
        listView1.Columns.Add("Column 4", 80, HorizontalAlignment.Center);

        // Add items
        ListViewItem item1 = new ListViewItem("item1");
        item1.SubItems.Add("1");
        item1.SubItems.Add("2");
        item1.SubItems.Add("3");

        ListViewItem item2 = new ListViewItem("item2");
        item2.SubItems.Add("4");
        item2.SubItems.Add("5");
        item2.SubItems.Add("6");

        ListViewItem item3 = new ListViewItem("item3");
        item3.SubItems.Add("7");
        item3.SubItems.Add("8");
        item3.SubItems.Add("9");

        listView1.Items.AddRange(new ListViewItem[] { item1, item2, item3 });

        this.Controls.Add(listView1);
    }

    private void CreateCellButton()
    {
        cellButton = new Button();
        cellButton.Text = "Click Me";
        cellButton.Parent = listView1;
cellButton.Click += (s, e) =>
{
    listView1.Width = (int)(listView1.Width * 0.8);
    listView1.Height = (int)(listView1.Height * 0.8);
};
    }

    private void ListView1_ColumnWidthChanging(object sender, ColumnWidthChangingEventArgs e)
    {
        // Update button bounds whenever columns are resized
        UpdateButtonPosition();
    }

private void UpdateButtonPosition()
{
    int rowIndex = 1;   // row 2
    int colIndex = 1;   // column 2

    if (listView1.Items.Count <= rowIndex || listView1.Columns.Count <= colIndex)
        return;

    // Get the row rectangle (accounts for scrolling)
    Rectangle rowRect = listView1.GetItemRect(rowIndex);

    // Compute the left of the target column
    int x = rowRect.Left;
    for (int i = 0; i < colIndex; i++)
    {
        x += listView1.Columns[i].Width;
    }

    // Use row height from GetItemRect
    int y = rowRect.Top;
    int width = listView1.Columns[colIndex].Width;
    int height = rowRect.Height;

    cellButton.Bounds = new Rectangle(x, y, width, height);
}



    [STAThread]
    static void Main()
    {
        Application.EnableVisualStyles();
        Application.Run(new MyForm());
    }
}

So I've not went into the specific code for the resources provided above by @saroad2 the only portion I really looked at and took from is this list of difficulties in the Approach section of the first article (https://www.codeproject.com/articles/Embedding-Controls-in-a-ListView)

- The position and size of a ListViewSubItem can be modified in various ways (for example, resizing the ListView, scrolling, resizing ColumnHeaders, and so on).
- The default implementation of ListView doesn't have any way to tell you the size and location of ListViewSubItems.
- Columns can be reordered.
- ListViewItems can be sorted.

The 3rd and 4th items her are not of concerned to use since Toga disables the functionality. For item 1 the only things that actually happen from my testing is scrolling and resizing the column headers. For item 2 I have not read the below portions of the article to see how the author did it but doing some research I on https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.listview.getitemrect?view=windowsdesktop-9.0 but then it seems to be only useful for the height and the origin point of the item, and it also accounts for the scrolling which is great, but then it doesn't account for subitems so we add in the widths manually.

Width change can be monitored by ColumnWidthChanging property -- ColumnWidthChanged is when the change finishes so it doesn't include all the small draggin-steps done by the user. If there exists a minimum size for the control inside that cell this shall also be used to enforce that minimum size

Now I'm stuck on how to do scrolling so I asked ChatGPT and it spit out the WndProc override I have above, which seems to work perfectly

Now my concern about this approach I've hacked together this monring is again licensing. I've only been using a small portion of information in the article, and I've been able to get a solution from that. However the license of the article sample code explicitly disallows redistributing the article.

So my question is -- does using a list of problems described somewhere else to come up with my own solution like I've described above create a derivative work?

If not someone else can translate the above C-sharp example I've hacked together into Python for use in Toga. If so let me know and I will destroy this comment.


EDIT -- sorry for messy code formatting, I've done all the coding in Notepad, starting with Microsoft's official example, stripping away a bunch of parts, applying a subset of options in Toga's table, and then started hacking.


EDIT 2 -- I've modified the code to force a repaint when I select items such that the background color of the button won't be correct, i.e. it won't show a white square.

johnzhou721 avatar Sep 20 '25 16:09 johnzhou721

Oh, and here's a video demo of this (the button resizes the table):

https://github.com/user-attachments/assets/a81a5f1f-55b7-4298-abd3-1e056c8ec530

If there's no copyright concerns I'll post the code in another repo with an appropriate and edit the above comment to link to it so there's no license concerns for integrating my demo into Toga.

johnzhou721 avatar Sep 20 '25 16:09 johnzhou721

OK... there's some bugs in my previous version; like when you press up and down key it doesn't move the button, when you double-click header to fit to text it doesn't resize the button (these 2 solved by listening to WM_PAINT in lieu of listening to column width changing and up and down buttoning) and when you scroll up such that the button is 1 row above the first visible row it overlaps the header of the table. The last issue gets fixed by asking ChatGPT and getting an answer about using interop and verifying that these APIs are indeed correct.

Code
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Runtime.InteropServices;

class ListViewHeaderHelper
{
    private const int LVM_FIRST = 0x1000;
    private const int LVM_GETHEADER = LVM_FIRST + 31;

    [DllImport("user32.dll", CharSet = CharSet.Auto)]
    private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);

    public static int GetHeaderBottom(ListView listView)
    {
        IntPtr headerHandle = SendMessage(listView.Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero);

        if (headerHandle != IntPtr.Zero)
        {
            NativeMethods.RECT rect;
            NativeMethods.GetClientRect(headerHandle, out rect);
            return rect.Bottom;
        }

        return 0;
    }

    private static class NativeMethods
    {
        [StructLayout(LayoutKind.Sequential)]
        public struct RECT
        {
            public int Left, Top, Right, Bottom;
        }

        [DllImport("user32.dll")]
        public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
    }
}


public delegate void RepaintEventHandler(object sender);

class ScrollableListView : ListView
{
    // Custom scroll event
    public event RepaintEventHandler Repaint;

    protected override void WndProc(ref Message m)
    {
        base.WndProc(ref m);
        if (m.Msg == 0x000F)
        {
            // Trigger the custom scroll event
            Repaint.Invoke(this);
        }
    }
}

class MyForm : Form
{
    private ScrollableListView listView1;
    private Button cellButton;

private void ListView1_Repaint(object sender)
{
    UpdateButtonPosition();
}

    private void ListView1_SelectedIndexChanged(object sender, EventArgs e)
    {
        cellButton.Invalidate();
    }


    public MyForm()
    {
        this.Text = "ListView Example";
        this.Size = new Size(400, 300);

        CreateMyListView();
        CreateCellButton();
        UpdateButtonPosition();
    }

    private void CreateMyListView()
    {
        listView1 = new ScrollableListView();
listView1.Repaint += ListView1_Repaint;
        listView1.SelectedIndexChanged += ListView1_SelectedIndexChanged;
        listView1.Bounds = new Rectangle(new Point(10, 10), new Size(360, 200));
        listView1.HeaderStyle = ColumnHeaderStyle.Nonclickable;
        listView1.FullRowSelect = true;
        listView1.MultiSelect = true;
        listView1.HideSelection = false;
        listView1.View = View.Details;

        // Add columns
        listView1.Columns.Add("Item Column", 100, HorizontalAlignment.Left);
        listView1.Columns.Add("Column 2", 80, HorizontalAlignment.Left);
        listView1.Columns.Add("Column 3", 80, HorizontalAlignment.Left);
        listView1.Columns.Add("Column 4", 80, HorizontalAlignment.Center);

        // Add items
        ListViewItem item1 = new ListViewItem("item1");
        item1.SubItems.Add("1");
        item1.SubItems.Add("2");
        item1.SubItems.Add("3");

        ListViewItem item2 = new ListViewItem("item2");
        item2.SubItems.Add("4");
        item2.SubItems.Add("5");
        item2.SubItems.Add("6");

        ListViewItem item3 = new ListViewItem("item3");
        item3.SubItems.Add("7");
        item3.SubItems.Add("8");
        item3.SubItems.Add("9");

        listView1.Items.AddRange(new ListViewItem[] { item1, item2, item3 });

        this.Controls.Add(listView1);
    }

    private void CreateCellButton()
    {
        cellButton = new Button();
        cellButton.Text = "Click Me";
        cellButton.Parent = listView1;
cellButton.Click += (s, e) =>
{
    listView1.Width = (int)(listView1.Width * 0.8);
    listView1.Height = (int)(listView1.Height * 0.8);
};
    }


private void UpdateButtonPosition()
{
    int rowIndex = 1;   // row 2
    int colIndex = 1;   // column 2

    if (listView1.Items.Count <= rowIndex || listView1.Columns.Count <= colIndex)
        return;

    // Get the row rectangle (accounts for scrolling)
    Rectangle rowRect = listView1.GetItemRect(rowIndex);

    // Compute the left of the target column
    int x = rowRect.Left;
    for (int i = 0; i < colIndex; i++)
    {
        x += listView1.Columns[i].Width;
    }

    // Use row height from GetItemRect
    int y = rowRect.Top;
    int width = listView1.Columns[colIndex].Width;
    int height = rowRect.Height;

    if (rowRect.Top < ListViewHeaderHelper.GetHeaderBottom(listView1)) {
        cellButton.Visible = false;
    } else {
        cellButton.Visible = true;
    } 

    cellButton.Bounds = new Rectangle(x, y, width, height);
}



    [STAThread]
    static void Main()
    {
        Application.EnableVisualStyles();
        Application.Run(new MyForm());
    }
}

Demo below. Note I hooked up the button to reduce the size of the table by 20%.

https://github.com/user-attachments/assets/ca8dc1e9-4bcc-4c74-b9fe-637475d23e23

johnzhou721 avatar Sep 20 '25 18:09 johnzhou721

Hey y'all, I have a working example for putting a button on a listview. I do have some copyright concerns though, ...

That sentence by itself is enough to make me stop reading - because if I read any further, then I see becomes fruit of the poisonous tree.

Can you please elaborate on your "copyright concerns"? If the code you're presenting here isn't available for reuse under an open source (and BSD-compatible) license, then it's a non-starter (and that includes GPL code).

freakboy3742 avatar Sep 22 '25 06:09 freakboy3742

My copyright concerns is that I used the introduction of one of the non-freely-licensed articles as a checklist -- the code here is completely originally authored but the author of the nonfree article listed some problems that he encountered while he implemented his solution. I used the list to start finding solutions to every problem he lists by myself, but no code or other methods were referenced from that article (although I don't know if there might be any similarities coincidentally since there's usually only a few ways to solve a problem).

johnzhou721 avatar Sep 22 '25 18:09 johnzhou721

If all you've used is content that is acting as a checklist, then that's fine. I don't think a list that says "here's three things you should consider when doing X" hits the threshold for a copyrightable code contribution, unless they are very specific instructions.

freakboy3742 avatar Sep 23 '25 09:09 freakboy3742