Tab cycling on a GtkColumnView with widgets

I am using a GtkColumnView to render a table with interactive cells. Some columns render a CheckButton, other columns render an Entry. For simplicity, let’s assume that it’s all entries.

After learning about the widget API in the docs, the way I’ve setup my ColumnView is to use the model property to set a SelectionModel bound to a ListModel. (So, roughly speaking, something similar to the following pseudocode)

column_view.set_model(new Gtk.NoSelection(list_model));

This allows me to map every item of my ListModel with a row if I use multiple ColumnViewColumn instances, with a SignalListItemFactory to provide callbacks to setup and bind each column so that it renders properly, backed by the item itself from the ListModel.

And this works perfectly fine when interacting with a mouse. Click a cell, change the value or enable the checkbox, and things work. Even more, with some property bindings, updates made to the widget can update the different items of the ListModel.

However, I am having a hard time trying to understand the default tab cycling model of GtkColumnView. My ideal goal is to leverage as much as possible from the default GTK behaviours. I don’t want to do clever things with widget focus if it’s not needed, because the risk of introducing accessibility issues while just trying to provide a good keyboard UX is real (if webdev has taught me one thing over the years…).

With the default behaviour, if the focus is on a widget that is NOT the last one in the column, pressing Tab works as expected, moving focus to the next cell in the row.

However, when the focus is in the last widget of a row, pressing Tab seems to move the focus one cell below, but keeping the same column, instead of going back to the first column of the next row.

I’ve made the following test case in Vala. (Not a language that I know a lot, but I wanted to make a minimum working example where this issue can be replicated.)

int main(string[] args) {
    var app = new Gtk.Application("es.danirod.ColumnDemo", GLib.ApplicationFlags.DEFAULT_FLAGS);

    app.activate.connect(() => {
        var model = new Gtk.StringList({ "monday", "tuesday", "wednesday" });
        var column_view = new Gtk.ColumnView(new Gtk.NoSelection(model));
        column_view.tab_behavior = Gtk.ListTabBehavior.ITEM;
        column_view.append_column(new Gtk.ColumnViewColumn("raw", raw_factory()));
        column_view.append_column(new Gtk.ColumnViewColumn("up()", up_factory()));
        column_view.append_column(new Gtk.ColumnViewColumn("length", len_factory()));

        var win = new Gtk.ApplicationWindow(app);
        win.set_child(column_view);
        win.present();
    });
    return app.run(args);
}


// Creates a SignalListItemFactory that simply copies the string in the
// ListItem item as text in a GtkEntry.
Gtk.ListItemFactory raw_factory() {
    var raw_factory = new Gtk.SignalListItemFactory();
    raw_factory.setup.connect((obj) => {
        var item = obj as Gtk.ListItem;
        var entry = new Gtk.Entry();
        item.set_child(entry);
    });
    raw_factory.bind.connect((obj) => {
        var item = obj as Gtk.ListItem;
        var entry = item.child as Gtk.Entry;
        var value = item.item as Gtk.StringObject;
        entry.set_text(value.get_string());
    });
    return raw_factory;
}

// Converts the text in the ListItem item to uppercase and puts it in an Entry
Gtk.ListItemFactory up_factory() {
    var up_factory = new Gtk.SignalListItemFactory();
    up_factory.setup.connect((obj) => {
        var item = obj as Gtk.ListItem;
        var entry = new Gtk.Entry();
        item.set_child(entry);
    });
    up_factory.bind.connect((obj) => {
        var item = obj as Gtk.ListItem;
        var entry = item.child as Gtk.Entry;
        var value = item.item as Gtk.StringObject;
        entry.set_text(value.get_string().up());
    });
    return up_factory;
}

// A more complex case where I place something not obvious into the Entry
// that also depends on whatever is in the StringObject part of the ListItem.
Gtk.ListItemFactory len_factory() {
    var len_factory = new Gtk.SignalListItemFactory();
    len_factory.setup.connect((obj) => {
        var item = obj as Gtk.ListItem;
        var entry = new Gtk.Entry();
        item.set_child(entry);
    });
    len_factory.bind.connect((obj) => {
        var item = obj as Gtk.ListItem;
        var entry = item.child as Gtk.Entry;
        var value = item.item as Gtk.StringObject;
        entry.set_text(@"$(value.get_string().length)");
    });
    return len_factory;
}

Pressing tab a few times when the focus is on the first widget of the first row seems to trigger this behaviour. I can cycle through the widgets of the first row, but after the last widget of the first row, the focus is moved to the last widget of the second row, not the first widget of the first row.

Is this the correct behaviour for this widget? If it is working as expected, what options do I have if I wanted to tweak how the focus works.

1 Like

I have also experienced someting similar sometime back. A user reported it while using one of the apps I maintain. I investigated but never really got to fix it. However, I did notice something.

Take the JavaScript code below. I don’t know Vala. Therefore, I haven’t translated it. When I run it using gjs -m demo.js, I can tab through the interactive widgets in the Gtk.ColumnView normally.

However, when I run it using the version of GJS bundled with org.gnome.Sdk//48 or org.gnome.Platform//48 using the command below, I notice the same odd tabbing behavior.

flatpak run --command=gjs --filesystem=host --socket=wayland   org.gnome.Sdk//48 -m demo
.js

I suspect it has something to do with Gnome rather than Gtk because I’m using Gtk 4.0 in both cases.

#! usr/bin/env/ -m gjs

import Gtk from "gi://Gtk?version=4.0";
import GLib from "gi://GLib";

Gtk.init();

const loop = GLib.MainLoop.new(null, false);

const win = new Gtk.Window({
  title: "ListView demo",
  defaultWidth: 600,
  defaultHeight: 800,
});

const model = Gtk.NoSelection.new(Gtk.StringList.new(["1", "2", "3"]));

const columnView = Gtk.ColumnView.new(model);
columnView.set_show_column_separators(true);
columnView.set_show_row_separators(true);

const factoryEntryCol = new Gtk.SignalListItemFactory();
const factoryButtonsCol = new Gtk.SignalListItemFactory();

const entryCol = Gtk.ColumnViewColumn.new("Entry Column", factoryEntryCol);
const buttonsCol = Gtk.ColumnViewColumn.new(
  "Buttons Column",
  factoryButtonsCol
);

columnView.append_column(entryCol);
columnView.append_column(buttonsCol);

factoryEntryCol.connect("setup", (factory, listItem) => {
  listItem.child = new Gtk.Entry();
});
factoryButtonsCol.connect("setup", (factory, listItem) => {
  listItem.child = new Gtk.Box({
    spacing: 10,
  });

  listItem.child.append(
    new Gtk.Button({
      icon_name: "edit-copy-symbolic",
      css_classes: ["suggested-action"],
    })
  );
  listItem.child.append(
    new Gtk.Button({
      icon_name: "edit-delete-symbolic",
      css_classes: ["destructive-action"],
    })
  );
});

factoryEntryCol.connect("bind", (factory, listItem) => {
  listItem.child.text = listItem.item.string;
});

factoryButtonsCol.connect("bind", (factory, listItem) => {
  const copyButton = listItem.child.get_first_child();
  const deleteButton = listItem.child.get_last_child();

  copyButton.connect("clicked", () => {
    console.log(listItem.item.string);
  });
  deleteButton.connect("clicked", () => {
    console.log(listItem.item.string);
  });
});

const scrolledWindow = new Gtk.ScrolledWindow({
  child: columnView,
  propagate_natural_width: true,
  propagate_natural_height: true,
});

const vBox = new Gtk.Box({
  orientation: Gtk.Orientation.VERTICAL,
  valign: Gtk.Align.CENTER,
  halign: Gtk.Align.CENTER,
});

vBox.append(scrolledWindow);

win.child = vBox;

win.connect("close-request", () => {
  loop.quit();
});

win.present();
loop.run();

In my case, I’m experiencing this behaviour outside of Flatpak as well. I don’t even run GNOME as my primary, only when I’m about to upload to Flathub I test that things work fine there. I ran this demo with vala --pkg gtk4 demo.vala.

However, I added a meson.build and some foundation to test within Flatpak and I found the same results when running against org.gnome.Sdk//48, and against org.gnome.Sdk//47. Even when running in a separate environment powered by GNOME 46 (Ubuntu 24.04).

I’ve searched in the GitLab too and I’ve found GNOME/gtk#5827. It’s the same behaviour, but rather than a discussion it’s a bug report. Unfortunately zero comments and zero inbound links.

I’ve also reviewed the code and I’ve found out two things:

1. There is a line that shouldn’t be there in my snippet:

column_view.tab_behavior = Gtk.ListTabBehavior.ITEM;

I probably forgot to save or undo before copying the code to paste it here. I tried to see how setting tab behaviours changed the… well, the tab behaviour. ITEM works as expected as described in the docs: after the first row is tabbed, gets the focus out of the columnview. That line shouldn’t be there, to use the default ALL behaviour.

It seems to me that using the ITEM tab behaviour would be ideal. Nautilus is using a GtkColumnView to list the files and according to the GTK inspector they are using ITEM. Of course it is easier because there is only one interactive widget per row. For a row full of GtkEntries, there are a lot of widgets trying to control the up and down keys.

2. Setting a row-factory allows to customize how each one of the rows is added. The interesting thing is that if the ColumnViewRow is made not activatable and not focusable, then Shift+Tab starts triggering the same behaviour on the opposite direction.

column_view.row_factory = row_factory();

// [...]

Gtk.ListItemFactory row_factory() {
    var row_factory = new Gtk.SignalListItemFactory();
    row_factory.bind.connect((obj) => {
        var item = obj as Gtk.ColumnViewRow;
        item.focusable = false;
        item.activatable = false;
    });
    return row_factory;
}

With this row factory, pressing Shift+Tab on the first element moves the focus to the first element of the previous row. It’s the same behaviour as tabbing after the last element of a row, but in the opposite direction. It’s consistent even when the text direction is set to RTL.

It’s a very weird behaviour, confusing to anyone used to how focus tabbing works on other platforms and even in a web browser, but at least it is consistent. I haven’t been able to discover any explanation about it. I’ve also found an old thread here that talks about focus order, and I am quoting this part:

The focus order is determined by the order in which the children appear to the user, as it’s part of the accessibility layer.

I wish there was a way in the inspector to review the children order as perceived by the accessibility layer, because clearly the order is right according to the Objects tree (ironically, another GtkColumnView).

recording-ezgif.com-video-to-gif-converter

1 Like

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.