Scrollable and manually resizable Gtk.TextView

In Web forms, <textarea> elements usually have a little handle in the bottom right corner that allows users to manually resize the text area. It also automatically adds scroll bars if the text height exceeds the available space.

Is it possible to achieve this exact behavior with Gtk.TextView as well, or is there any other multi-line text entry widget in GTK4 or Libadwaita that allows the user to manually modify its height?

Currently, my window contains a GtkTextView as follows:

<object class="GtkFrame" id="my_text_container">
  <property name="child">
    <object class="GtkScrolledWindow" id="my_text_scroll">
      <property name="height-request">200</property>
      <property name="child">
        <object class="GtkTextView" id="my_text_view">
          <property name="monospace">true</property>
          <property name="wrap-mode">word</property>
          <style>
            <class name="text-view-framed"/>
          </style>
        </object>
      </property>
    </object>
  </property>
</object>

The size of this text view is fixed to the size of its containing scrolled window. However, my current code is still missing this resize handle.

Hi,

A simple possibility could be to put the scrolled window in a Gtk.Overlay, and a Gtk.Image as resize handle in the bottom-right corner.
Then attach a Gtk.GestureDrag on that image and from there update the GtkScrolledWindow height-request and width-request.

A better-engineered way would be to implement a custom widget instead of that GtkFrame, also use a GtkGestureDrag but only claim it when the start point is in the corner, then implement Gtk.Widget.measure to update the requested size and Gtk.Widget.snapshot to draw the handle.

Side note: GtkFrame are kind of useless these days, if you need to draw an outline then better apply a simple CSS style on the scrolled window (or any widget).

1 Like

Thanks very much for the suggestion. This sounds very complicated, but there is currently no simpler, more streamlined way to do this, right? In that case, I try to write a custom widget according to your suggestion.

Thank you very much again for your help. I implemented a widget according to your description and want to share my solution here for review and future reference. Since this is my first attempt, I guess there’s still something to be improved upon.

The UI simply consists of a hierarchy of a GtkWidget with a GtkScrolledWindow that in turn contains a GtkTextView:

<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <requires lib="gtk" version="4.0"/>

  <template class="ResizableTextView" parent="GtkWidget">
    <property name="hexpand">false</property>
    <property name="vexpand">false</property>
    
    <child>
      <object class="GtkScrolledWindow" id="scrolled_window">
        <property name="hexpand">true</property>
        <property name="vexpand">true</property>
        
        <child>
          <object class="GtkTextView" id="text_view">
            <property name="monospace">true</property>
            <property name="wrap-mode">word</property>
            <style>
              <class name="text-view-framed"/>
            </style>
          </object>
        </child>
      </object>
    </child>
  </template>
</interface>

Next, the widget logic looks as follows:

import enum
import os

import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Gdk', '4.0')
gi.require_version('Gsk', '4.0')
gi.require_version('GObject', '2.0')
gi.require_version('Graphene', '1.0')
from gi.repository import Gtk, Gdk, GObject, Graphene, Gsk


class ResizeDirection(enum.Enum):

    NONE = 0
    HORIZONTAL = 1
    VERTICAL = 2


@Gtk.Template(filename=os.path.join(os.path.dirname(__file__), 'resizable_text_view.ui'))
class ResizableTextView(Gtk.Widget):

    __gtype_name__ = 'ResizableTextView'
    
    min_width = GObject.Property(type=int, default=200)
    min_height = GObject.Property(type=int, default=100)
    max_width = GObject.Property(type=int, default=800)
    max_height = GObject.Property(type=int, default=600)
    requested_width = GObject.Property(type=int, default=400)
    requested_height = GObject.Property(type=int, default=200)
    handle_size = GObject.Property(type=int, default=64)
    handle_thickness = GObject.Property(type=int, default=8)
    hresize = GObject.Property(type=bool, default=True)
    vresize = GObject.Property(type=bool, default=True)

    scrolled_window = Gtk.Template.Child()
    text_view = Gtk.Template.Child()

    _SENSITIVE_RADIUS = 8  # Extra sensitive area around handles
    
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.init_template()
        
        self._setup_drag_gesture()

        # Track resize mode and direction
        self.is_resizing = False
        self.resize_direction = ResizeDirection.NONE
        self.start_width = 0
        self.start_height = 0
        
        self._setup_cursor_change_on_hover()
    
    def _setup_drag_gesture(self):
        """Set up drag gesture for resizing"""
        drag_gesture = Gtk.GestureDrag()
        drag_gesture.set_button(Gdk.BUTTON_PRIMARY)
        self.add_controller(drag_gesture)

        drag_gesture.connect("drag-begin", self.on_drag_begin)
        drag_gesture.connect("drag-update", self.on_drag_update)
        drag_gesture.connect("drag-end", self.on_drag_end)

        return drag_gesture

    def _setup_cursor_change_on_hover(self):
        motion_controller = Gtk.EventControllerMotion()
        self.add_controller(motion_controller)
        motion_controller.connect("motion", self.on_motion)
        motion_controller.connect("leave", self.on_leave)
        return motion_controller

    def do_measure(self, orientation, for_size):
        """Implement custom sizing"""
        child_min, child_nat, child_min_baseline, child_nat_baseline = \
            self.scrolled_window.measure(orientation, for_size)
        
        if orientation == Gtk.Orientation.HORIZONTAL:
            minimum = max(self.min_width, child_min)
            natural = max(self.requested_width, child_nat)
        else:
            minimum = max(self.min_height, child_min)
            natural = max(self.requested_height, child_nat)

        return minimum, natural, child_min_baseline, child_nat_baseline

    def do_size_allocate(self, width, height, baseline):
        """Allocate size to child widgets"""
        allocation_rect = Gdk.Rectangle()
        allocation_rect.x = 0
        allocation_rect.y = 0
        allocation_rect.width = width
        allocation_rect.height = height
        self.scrolled_window.size_allocate(allocation_rect, baseline)

    def do_snapshot(self, snapshot):
        """Custom drawing - draw children and resize handles"""
        allocation_is_valid = (self.scrolled_window.get_allocated_width() > 0
                               and self.scrolled_window.get_allocated_height() > 0)
        if not allocation_is_valid:
            return
        
        self.snapshot_child(self.scrolled_window, snapshot)
        
        if not self.hresize and not self.vresize:
            return
        
        self._draw_handles(snapshot)
    
    def _draw_handles(self, snapshot):
        """Draw resize handles on the right and bottom sides"""
        width = self.get_width()
        height = self.get_height()
        handle_color = Gdk.RGBA(0.4, 0.4, 0.4, 0.7)
        radius = self.handle_thickness / 2
        
        # Right side handle (width resize)
        if self.hresize:
            right_handle_x = width - (self.handle_thickness // 2)
            right_handle_y = (height - self.handle_size) // 2
            self.right_handle = self._draw_handle(snapshot, right_handle_x, right_handle_y, self.handle_thickness, self.handle_size)

        # Bottom side handle (height resize)
        if self.vresize:
            bottom_handle_x = (width - self.handle_size) // 2
            bottom_handle_y = height - (self.handle_thickness // 2)
            self.bottom_handle = self._draw_handle(snapshot, bottom_handle_x, bottom_handle_y, self.handle_size, self.handle_thickness)

    def _draw_handle(self, snapshot, x, y, width, height):
        """Draw a single handle"""
        rect = Graphene.Rect()
        rect.init(x, y, width, height)
        
        radius = self.handle_thickness / 2
        rounded = Gsk.RoundedRect()
        rounded.init_from_rect(rect, radius)
        
        handle_color = Gdk.RGBA(0.4, 0.4, 0.4, 0.7)
        
        snapshot.push_rounded_clip(rounded)
        snapshot.append_color(handle_color, rect)
        snapshot.pop()

        return rect

    def get_handle_at_position(self, x, y):
        """Return which handle is at the given position, or None"""
        sensitive_area = Graphene.Rect()
        sensitive_area.init(x - self._SENSITIVE_RADIUS,
                            y - self._SENSITIVE_RADIUS,
                            2 * self._SENSITIVE_RADIUS,
                            2 * self._SENSITIVE_RADIUS)
        
        right_intersect, _ = self.right_handle.intersection(sensitive_area) if self.hresize else (False, None)
        if right_intersect:
            return ResizeDirection.HORIZONTAL

        bottom_intersect, _ = self.bottom_handle.intersection(sensitive_area) if self.vresize else (False, None)
        if bottom_intersect:
            return ResizeDirection.VERTICAL
        
        return None
    
    def on_motion(self, controller, x, y):
        """Handle mouse motion for cursor changes"""
        handle = self.get_handle_at_position(x, y)
        if handle == ResizeDirection.HORIZONTAL:
            cursor = Gdk.Cursor.new_from_name("e-resize", None)
            if cursor:
                self.set_cursor(cursor)
        elif handle == ResizeDirection.VERTICAL:
            cursor = Gdk.Cursor.new_from_name("s-resize", None)
            if cursor:
                self.set_cursor(cursor)
        else:
            self.set_cursor(None)
    
    def on_leave(self, controller):
        """Reset cursor when leaving widget"""
        self.set_cursor(None)
    
    def on_drag_begin(self, gesture, start_x, start_y):
        """Start drag operation if on a handle"""
        handle = self.get_handle_at_position(start_x, start_y)
        if handle:
            self.is_resizing = True
            self.resize_direction = handle
            self.start_width = self.requested_width
            self.start_height = self.requested_height
            gesture.set_state(Gtk.EventSequenceState.CLAIMED)
        else:
            gesture.set_state(Gtk.EventSequenceState.DENIED)
    
    def on_drag_update(self, gesture, offset_x, offset_y):
        """Update size during drag"""
        if not self.is_resizing:
            return

        if self.resize_direction == ResizeDirection.HORIZONTAL and self.hresize:
            new_width = max(self.min_width,
                            min(self.max_width, self.start_width + offset_x))
            self.requested_width = new_width
        elif self.resize_direction == ResizeDirection.VERTICAL and self.vresize:
            new_height = max(self.min_height,
                            min(self.max_height, self.start_height + offset_y))
            self.requested_height = new_height
        else:
            return
    
        self.queue_resize()
    
    def on_drag_end(self, gesture, offset_x, offset_y):
        """End drag operation"""
        self.is_resizing = False
        self.resize_direction = None
    
    def get_text_view(self):
        """Get the internal text view widget"""
        return self.text_view
    
    def get_buffer(self):
        """Get the text buffer"""
        return self.text_view.get_buffer()
    
    def set_text(self, text):
        """Set the text content"""
        self.text_view.get_buffer().set_text(text)
    
    def get_text(self):
        """Get the text content"""
        buffer = self.text_view.get_buffer()
        start = buffer.get_start_iter()
        end = buffer.get_end_iter()
        return buffer.get_text(start, end, False)
    
    def do_get_request_mode(self):
        """Return the request mode for this widget"""
        return self.scrolled_window.get_request_mode()

1 Like