How to create a GtkBox with a fixed height?

Hello,

I want to create a timetable. I have a lesson Object that is a vertical GtkBox containing three Labels. This box should have a fixed height representing the duration of the lesson (box.set_size_request(-1, duration)). However, this does only work if this requested height is greater than the height required by the labels. If the requested height is too small (less than ~60 px), the box does expand. How can I achieve that the box does not grow in this case? I tried using GtkScrolledWindow, AdwClamp and GtkConstraint, but nothing worked.

Here is a screenshot:

The left and the right lesson have exactly the height they should (90px), but the two in the center don’t. They both should have a height of 45px.

Thanks for your help.

There are two relevant concerns here:

  1. What should happen to the labels if their text does not fit? You sould enable some form of ellipsization, or alternatively, clipping. Consider also Gtk.Inscription. Without either ellipsization or clipping (or some other way to handle overflow), there is no way that the box can be made smaller than the size required for the labels.
  2. Generally, while widgets are tasked with determining their own size requirements, assigning (allocating) specific sizes is up to their parents. So in your case, it’s more of a question of what sizes the parent widget (some sort of a timetable?) decides to allocate to the boxes.

Hi,

Gtk only allows to specify a minimal widget size. To prevent it form growing, place it into a ScrolledWindow.

To fully control the “lessons” allocation, you can use a custom timetable widget implementing its do_size_allocate virtual method to place the lessons at their expected offset and height.

Quick and (very) dirty example, assuming a full 24h timespan:

import sys
import gi
gi.require_version('Gdk', '4.0')
gi.require_version('Gtk', '4.0')
from gi.repository import Gdk, Gtk


class Lesson(Gtk.ScrolledWindow):
    __gtype_name__ = __qualname__

    def __init__(self, start, duration, *labels, **kwargs):
        Gtk.ScrolledWindow.__init__(self, **kwargs)
        self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.EXTERNAL)
        self.add_css_class('lesson')
        self.start = start
        self.duration = duration
        self.box = Gtk.Box(orientation='vertical')
        for label in labels:
            self.box.append(Gtk.Inscription(text=label))
        self.set_child(self.box)


class TimeTable(Gtk.Widget):
    __gtype_name__ = __qualname__

    def __init__(self, **kwargs):
        Gtk.Widget.__init__(self, **kwargs)
        self.add_css_class('timetable')

    def add_lesson(self, lesson):
        lesson.set_parent(self)

    def do_dispose(self):
        for child in list(self):
            child.unparent()

    def do_measure(self, orientation, for_size):
        w_min, w_nat, b_min, b_nat = 1, 1, -1, -1
        for child in self:
            c_min, c_nat, _, _ = child.measure(orientation, -1)
            if orientation == Gtk.Orientation.VERTICAL:
                w_min = max(w_min, 24 * c_min)   # hacky, assumes all lessons duration >= 1h
            else:
                w_min = max(w_min, c_min)
                w_nat = max(w_nat, c_nat)
        w_nat = max(w_min, w_nat)
        return w_min, w_nat, b_min, b_nat

    def do_size_allocate(self, width, height, baseline):
        rect = Gdk.Rectangle()
        rect.x, rect.width = 0, width
        for child in self:
            rect.y = child.start * height / 24
            rect.height = child.duration * height / 24
            child.size_allocate(rect, baseline)


class MyAppWindow(Gtk.ApplicationWindow):
    __gtype_name__ = __qualname__

    def __init__(self):
        Gtk.ApplicationWindow.__init__(self, title="Timetable", default_width=100, default_height=600)
        timetable = TimeTable()
        timetable.add_lesson(Lesson(8.0, 1.0, "DE", "OS", "O1"))
        timetable.add_lesson(Lesson(10.0, 2.0, "MA", "DP", "O1"))
        timetable.add_lesson(Lesson(14.0, 1.0, "GE", "HIP", "O1"))
        # Optional: put the timetable in a scrolledwindow
        self.set_child(timetable)
        self.present()


class MyApp(Gtk.Application):
    __gtype_name__ = __qualname__

    def __init__(self):
        Gtk.Application.__init__(self)
        self.connect('startup', self.on_startup)
        self.connect('activate', self.on_activate)

    def on_startup(self, app):
        css = Gtk.CssProvider()
        css.load_from_string(".lesson {border: 1px solid @borders; border-radius: 6px; margin: 0 3px;}")
        disp = Gdk.Display.get_default()
        Gtk.StyleContext.add_provider_for_display(disp, css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def on_activate(self, app):
        self.add_window(MyAppWindow())


if __name__ == '__main__':
    sys.exit(MyApp().run(sys.argv))

Thank you very much. There is only one remaining issue:

The overflow does not scroll. I think this is an issue with my adaption of the code as it works in your example. My code is:

from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Adw

class ConstrainedScrolledWindow(Gtk.ScrolledWindow):
    __gtype_name__ = "ConstrainedScrolledWindow"
    def __init__(self, start, duration, labels, **kwargs):
        super().__init__(**kwargs)
        self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.EXTERNAL)
        self.start = start
        self.duration = duration
        self.box = Gtk.Box(orientation='vertical')
        self.box.set_hexpand(True)
        for label in labels:
            label.set_halign(Gtk.Align.CENTER)
            self.box.append(label)
        self.set_child(self.box)

class LessonContent(Gtk.Widget):
    __gtype_name__ = "LessonContent"

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.set_hexpand(True)

    def add_label(self, lesson):
        lesson.set_parent(self)

    def do_dispose(self):
        for child in list(self):
            child.unparent()

    def do_measure(self, orientation, for_size):
        w_min, w_nat, b_min, b_nat = 1, 1, -1, -1
        for child in self:
            c_min, c_nat, _, _ = child.measure(orientation, -1)
            if orientation == Gtk.Orientation.VERTICAL:
                w_min = max(w_min, c_min)
            else:
                w_min = max(w_min, c_min)
                w_nat = max(w_nat, c_nat)
        w_nat = max(w_min, w_nat)
        return w_min, w_nat, b_min, b_nat

    def do_size_allocate(self, width, height, baseline):
        rect = Gdk.Rectangle()
        rect.x, rect.width = 0, width
        for child in self:
            rect.y = child.start * height / 24
            rect.height = child.duration * height / 24
            child.size_allocate(rect, baseline)

@Gtk.Template(resource_path='/page/codeberg/ostfriese4/Untis/lesson.ui')
class Lesson(Gtk.Overlay):
    __gtype_name__ = 'Lesson'

    homework_indicator = Gtk.Template.Child()
    content_box = Gtk.Template.Child()

    def __init__(self, lesson, window, **kwargs):
        super().__init__(**kwargs)

        click = Gtk.GestureClick.new()
        click.connect("released", self.on_click)
        self.add_controller(click)

        self.lesson = lesson
        self.window = window

        self.subject_label = Gtk.Label(label = self.lesson["subject-short"])
        self.teacher_label = Gtk.Label(label = self.lesson["teacher-short"])
        self.room_label = Gtk.Label(label = self.lesson["room"])

        self.content = LessonContent()
        self.content.add_label(ConstrainedScrolledWindow(0, self.lesson["duration"], [self.subject_label, self.teacher_label, self.room_label]))
        self.content_box.append(self.content)

        self.set_size_request(-1, self.lesson["duration"])
        self.set_size_request(-1, self.lesson["duration"])
        self.add_css_class("lesson")
        self.add_css_class(self.lesson["color"])
        if self.lesson["code"] == "cancelled":
            self.add_css_class("cancelled")
        if "original" in self.lesson:
            self.add_css_class("changed")
            if "teacher-short" in self.lesson["original"]:
                self.teacher_label.add_css_class("label")
                self.teacher_label.add_css_class("changed")
            if "subject-short" in self.lesson["original"]:
                self.subject_label.add_css_class("label")
                self.subject_label.add_css_class("changed")
            if "room" in self.lesson["original"]:
                self.room_label.add_css_class("label")
                self.room_label.add_css_class("changed")

        self.markedAsHidden = False

    def markAsHidden(self):
        self.markedAsHidden = True
        if self.window.information_window.lesson == self.lesson:
            self.window.information_window.close()

    def on_click(self, gesture, data, x, y):
        if not self.markedAsHidden:
            self.window.information_window.setLesson(self.lesson)

    def addHomework(self, homework):
        if "homeworks" in self.lesson:
            homeworks = self.lesson["homeworks"]
        else:
            homeworks = []
            self.lesson["homeworks"] = homeworks
        homeworks.append(homework)
        self.homework_indicator.set_visible(True)

I had to make this changes to integrate it into the existing logic. Do you see where I introduced this bug?

Hard to say without seeing the “lesson.ui”…

I see you use an overlay, it may catch pointer events first, so you could have to set the can-target property of the overlaying widget to False to allow the mouse scrolls to pass through.

Note that if the content_box where you pack the labels is in the overlay, then you may not need a scrolledwindow at all. You can even play with Gtk.Overlay.set_clip_overlay to show/hide the overflow on click, for example.

Also, all hexpand(True) and halign(CENTER) should not be necessary.

Just use set_xalign(0.5) to center the labels.

Another issue: it’s the LessonContent that shall be placed in a scrolledwindow, not the labels you add with add_label().

The content_box is not in the overlay. I did not mean that scrolling is not possible, but that the scrolled window does not hide the ocerflow (see screenshot)

add_label() is only bad named, but it adds a ConstrainedScrolledWindow.