Skip to content

Instantly share code, notes, and snippets.

@diegogangl
Last active December 15, 2021 17:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save diegogangl/eba75d467854068ea75cc9a03ce61495 to your computer and use it in GitHub Desktop.
Save diegogangl/eba75d467854068ea75cc9a03ce61495 to your computer and use it in GitHub Desktop.
Attempt to figure out Gtk Tree models (this is practically a working GTG sidebar now, not quite generic anymore)
#!/usr/bin/env python3
# Load Gtk
import gi
gi.require_version('Gtk', '4.0')
from gi.repository import Gtk, Gio, GObject, GLib, Gdk
from uuid import UUID, uuid4
# TODO: Add saved searches
# --------------------------------------------------------------------------------
# GTG NOTES
# --------------------------------------------------------------------------------
# Sorting
#
# We have to use a Gtk.SortListModel
# which takes a Gtk.Sorter object
# https://docs.gtk.org/gtk4/class.Sorter.html
# subclass that and override the compare() function
#
# Looks like we'll need a sorter class for each
# type of sorting we want to do. We should probably
# add a prop in that class to handle asc/desc order
# Filtering
#
# https://docs.gtk.org/gtk4/class.FilterListModel.html
#
# We need to use a Gtk.FilterListModel which takes a
# Gtk.Filter object, much like the sorter
# We should sublcass and override the match() method
#
# One filter for each type of filtering, and then set
# different filters on the filter list model
# We should expose sorters and filters to the plugin
# API in some way. Maybe have them in their own modules
# Search needs to be re-implemented using a filter
# list model too
# Models could be added as props to the Store classes,
# no need to subclass them and possibly run into conflicts
# and weird stuff
# Data classes need to use GObject properties
# instead of regular python props
# Need to set a source as a controller for the rows widgets
# Need to set a drop as a controller for the listview itself
# Then Connect the signals
# https://docs.gtk.org/gtk4/drag-and-drop.html
# https://gitlab.gnome.org/GNOME/gtk/-/blob/master/demos/gtk-demo/dnd.c
# --------------------------------------------------------------------------------
# GTG
# --------------------------------------------------------------------------------
class Tag2(GObject.Object):
"""A tag that can be applied to a Task."""
__gtype_name__ = 'gtg_Tag'
# __slots__ = ['id', 'name', 'icon', 'color', 'actionable', 'children']
def __init__(self, id: UUID, name: str) -> None:
self.id = id
self._name = name
self._icon = None
self._color = None
self.actionable = True
self.children = []
self.parent = None
super(Tag2, self).__init__()
@GObject.Property(type=str)
def name(self) -> str:
"""Read only property."""
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
@GObject.Property(type=str)
def icon(self) -> str:
"""Read only property."""
return self._icon
@GObject.Property(type=str)
def color(self) -> str:
"""Read only property."""
return self._color
def __str__(self) -> str:
"""String representation."""
return f'Tag: {self.name} ({self.id})'
def __repr__(self) -> str:
"""String representation."""
return (f'Tag "{self.name}" with id "{self.id}"')
def __eq__(self, other) -> bool:
"""Equivalence."""
return self.id == other.id
# --------------------------------------------------------------------------------
# SORTING AND FILTERING
# --------------------------------------------------------------------------------
class MySorter(Gtk.Sorter):
__gtype_name__ = 'MySorter'
def __init__(self):
print('Sorter started')
super(MySorter, self).__init__()
# To override virtual methods we have to name
# them do_XXX.
def do_compare(self, a, b) -> Gtk.Ordering:
while type(a) is not Tag2:
a = a.get_item()
while type(b) is not Tag2:
b = b.get_item()
# Compare the last letter of name for testing
first = a.props.name[-1]
second = b.props.name[-1]
if first > second:
return Gtk.Ordering.LARGER
elif first < second:
return Gtk.Ordering.SMALLER
else:
return Gtk.Ordering.EQUAL
class MyFilter(Gtk.Filter):
__gtype_name__ = 'MyFilter'
def __init__(self):
print('Filter started')
super(MyFilter, self).__init__()
def do_match(self, item) -> bool:
while type(item) is not Tag2:
item = item.get_item()
# Yeah really basic, but should do
return 'nofilter' in item.props.name
# --------------------------------------------------------------------------------
# DnD
# --------------------------------------------------------------------------------
def prepare(source, x, y):
"""Callback to prepare for the DnD operation"""
print('Prearing DnD')
# Get item somehow
# Get content from source
data = source.get_widget().props.obj
# Set it as content provider
content = Gdk.ContentProvider.new_for_value(data)
return content
def drag_begin(source, drag):
"""Callback when DnD beings"""
print('Begining DnD')
source.get_widget().set_opacity(0.3)
def drag_end(source, drag, unused):
"""Callback when DnD ends"""
print('Ending DnD')
source.get_widget().set_opacity(1)
def drag_drop(self, value, x, y):
"""Callback when dropping onto a target"""
print('Dropped', value, 'on', self.get_widget().props.obj)
def drop_enter(self, x, y, user_data=None):
"""Callback when the mouse hovers over the drop target."""
expander = self.get_widget().get_first_child()
expander.activate_action('listitem.expand')
# There's a funny bug in here. If the expansion of the row
# makes the window larger, Gtk won't recognize the new drop areas
# and will think you're dragging outside the window.
return Gdk.DragAction.COPY
# --------------------------------------------------------------------------------
# BASIC STUFF
# --------------------------------------------------------------------------------
selection = None
sel_handle = None
listbox = None
revealer = None
btn_icon = None
class SomeType(GObject.Object):
"""Some basic type to test stuff"""
__gtype_name__ = 'SomeType'
int_prop = GObject.Property(default='OOO', type=str)
def __init__(self, val):
super(SomeType, self).__init__()
self.set_property('int_prop', val)
self.int_prop = val
def __str__(self) -> str:
return f'Sometype [{self.int_prop}]'
class MyBox(Gtk.Box):
"""Box subclass to keep a pointer to the SomeType prop"""
obj = GObject.Property(type=Tag2)
def model_func(item):
"""Callback when the tree expander is clicked or shown
Should return none or an empty list if there are no
children, otherwise return a Gio.ListStore or
a TreeListModel
"""
# print('Called model_func (', item, ')')
model = Gio.ListStore.new(Tag2)
if type(item) == Gtk.TreeListRow:
item = item.get_item()
# Shows we can use tag2 children list in here to
# populate the child model
# print('children', item.children)
# open the first one
if item.children:
for child in item.children:
model.append(child)
return Gtk.TreeListModel.new(model, False, False, model_func)
else:
return None
# if item.props.name == 'test':
# print('Returning children')
# model.append(Tag2(uuid4(), 'test5'))
# model.append(Tag2(uuid4(), 'test6'))
# model.append(Tag2(uuid4(), 'test7'))
# return Gtk.TreeListModel.new(model, False, False, model_func)
# # A nested one
# if item.props.name == 'test7':
# model.append(Tag2(uuid4(), 'test8'))
# model.append(Tag2(uuid4(), 'test9'))
# return Gtk.TreeListModel.new(model, False, False, model_func)
def setup_cb(factory, listitem, user_data=None):
"""Setup widgets for rows"""
box = MyBox()
label = Gtk.Label()
expander = Gtk.TreeExpander()
icon = Gtk.Label()
color = Gtk.Button()
expander.set_margin_end(6)
icon.set_margin_end(6)
color.set_sensitive(False)
color.set_margin_end(6)
color.set_valign(Gtk.Align.CENTER)
color.set_halign(Gtk.Align.CENTER)
color.set_vexpand(False)
# DnD stuff
source = Gtk.DragSource()
source.connect('prepare', prepare)
source.connect('drag-begin', drag_begin)
source.connect('drag-end', drag_end)
box.add_controller(source)
# Set drop for DnD
drop = Gtk.DropTarget.new(Tag2, Gdk.DragAction.COPY)
drop.connect('drop', drag_drop)
drop.connect('enter', drop_enter)
box.add_controller(drop)
box.append(expander)
box.append(color)
box.append(icon)
box.append(label)
listitem.set_child(box)
def bind(self, listitem, user_data=None):
"""Bind values to the widgets in setup_cb"""
# Kind of ugly
expander = listitem.get_child().get_first_child()
color = expander.get_next_sibling()
icon = color.get_next_sibling()
label = listitem.get_child().get_last_child()
box = listitem.get_child()
# HACK: Ugly! But apparently necessary
item = listitem.get_item()
while type(item) is not Tag2:
item = item.get_item()
box.props.obj = item
expander.set_list_row(listitem.get_item())
label.set_text(item.props.name)
if item.props.icon:
icon.set_text(item.props.icon)
color.set_visible(False)
elif item.props.color:
background = str.encode('* { background: #' + item.props.color + ' ; padding: 0; min-height: 16px; min-width: 16px; border: none;}')
cssProvider = Gtk.CssProvider()
cssProvider.load_from_data(background)
color.get_style_context().add_provider(cssProvider,
Gtk.STYLE_PROVIDER_PRIORITY_USER)
else:
icon.set_visible(False)
color.set_visible(False)
def reveal(self):
print('Revealing (or not)')
revealed = revealer.get_reveal_child()
if revealed:
btn_icon.set_from_icon_name('go-next-symbolic')
else:
btn_icon.set_from_icon_name('go-down-symbolic')
revealer.set_reveal_child(not revealed)
def row_selected(self, row, user_data=None):
"""Callback when selecting a row."""
if row:
with GObject.signal_handler_block(selection, sel_handle):
selection.set_selected(4294967295)
print('Selected row', row)
def tag_selected(self, position, n_items, user_data=None):
"""Callback when selection changes in the tag listview."""
listbox.unselect_all()
print('Selection changed', position, n_items,
'picked tag', self.get_selected_item().get_item())
# When the application is launched…
def on_activate(app):
global listbox, selection, sel_handle, revealer, btn_icon
# … create a new window…
win = Gtk.ApplicationWindow(application=app)
main_box = Gtk.Box()
main_box.set_orientation(Gtk.Orientation.VERTICAL)
listbox = Gtk.ListBox()
# Add all tags button
all_box = Gtk.Box()
all_icon = Gtk.Image.new_from_icon_name(
'emblem-documents-symbolic',
)
all_icon.set_margin_end(6)
all_box.append(all_icon)
all_name = Gtk.Label()
all_name.set_halign(Gtk.Align.START)
all_name.set_text('All Tasks')
all_box.append(all_name)
listbox.append(all_box)
# No tags
no_box = Gtk.Box()
no_icon = Gtk.Image.new_from_icon_name(
'task-past-due-symbolic',
)
no_icon.set_margin_end(6)
no_box.append(no_icon)
no_name = Gtk.Label()
no_name.set_halign(Gtk.Align.START)
no_name.set_text('Tasks with no tags')
no_box.append(no_name)
listbox.append(no_box)
# Prepare separator
separator = Gtk.Separator()
separator.set_sensitive(False)
# Create the tags
tag1 = Tag2(uuid4(), 'test')
tag1._icon = '😎️'
tag2 = Tag2(uuid4(), 'test2-nofilterbro')
tag2._icon = '👹️'
tag3 = Tag2(uuid4(), 'test5')
tag3._color = 'b6d7a8'
tag4 = Tag2(uuid4(), 'test3-nofilterbro')
tag5 = Tag2(uuid4(), 'test5')
tag6 = Tag2(uuid4(), 'test6')
tag7 = Tag2(uuid4(), 'test7')
tag8 = Tag2(uuid4(), 'test8')
tag9 = Tag2(uuid4(), 'test9')
tag1.children = [ tag5, tag6, tag7 ]
tag7.children = [ tag8, tag9 ]
# Root Model with some items
model = Gio.ListStore.new(Tag2)
model.append(tag1)
model.append(tag2)
model.append(tag3)
model.append(tag4)
# Init Tree model
treeModel = Gtk.TreeListModel.new(model, False, False, model_func)
# Filter model
filtered = Gtk.FilterListModel()
filtered.set_model(treeModel)
filtered.set_filter(MyFilter())
# Sort model
# But first wrap it in a TreeListRowSorter
# so it doesn't break the hierarchy in the view
# when sorting
tree_sort = Gtk.TreeListRowSorter()
tree_sort.set_sorter(MySorter())
sort = Gtk.SortListModel()
sort.set_sorter(tree_sort)
# Change commented line to try filter
# sort.set_model(filtered)
sort.set_model(treeModel)
# Wrap it in a selection model
selection = Gtk.SingleSelection.new(sort)
fac = Gtk.SignalListItemFactory()
fac.connect('setup', setup_cb)
fac.connect('bind', bind)
# Init the listview
view = Gtk.ListView.new(selection, fac)
view.set_vexpand(True)
view.set_hexpand(True)
selection.set_selected(4294967295)
sel_handle = selection.connect('selection-changed', tag_selected)
listbox.connect('row-selected', row_selected)
# Man it feels good to just add a class
# and have it look nice
view.get_style_context().add_class('navigation-sidebar')
listbox.get_style_context().add_class('navigation-sidebar')
# Put the view in a revealer
revealer = Gtk.Revealer()
revealer.set_child(view)
# Add a button
button = Gtk.Button()
btn_box = Gtk.Box()
button_label = Gtk.Label()
button_label.set_markup('<span font-variant="smallcaps">Tags</span>')
button_label.set_xalign(0)
button.get_style_context().add_class('flat')
label_style = str.encode('* { font-weight: bold; }')
cssProvider = Gtk.CssProvider()
cssProvider.load_from_data(label_style)
button_label.get_style_context().add_provider(cssProvider,
Gtk.STYLE_PROVIDER_PRIORITY_USER)
btn_icon = Gtk.Image.new_from_icon_name(
'go-down-symbolic',
)
btn_icon.set_margin_end(6)
btn_box.append(btn_icon)
btn_box.append(button_label)
button.set_child(btn_box)
button.connect('clicked', reveal)
# Seal the deal
main_box.append(listbox)
main_box.append(separator)
main_box.append(button)
main_box.append(revealer)
main_box.set_vexpand(True)
main_box.set_hexpand(True)
win.set_child(main_box)
win.set_default_size(400, 600)
win.present()
# Create a new application
app = Gtk.Application(application_id='com.example.GtkApplication')
app.connect('activate', on_activate)
# Run the application
app.run(None)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment