Last active
December 15, 2021 17:26
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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