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()