├── tests ├── __init__.py ├── watchdog.py ├── test_signal_testing.py ├── test_filteredtree.py ├── signals_testing.py └── tree_testing.py ├── debian ├── compat ├── docs ├── source │ └── format ├── rules ├── control ├── copyright └── changelog ├── .gitignore ├── requirements.txt ├── MANIFEST.in ├── examples ├── artefact ├── cycle ├── cycle2 ├── test_suite ├── torture ├── delete-randomly └── contact_list │ └── contact_list.py ├── AUTHORS ├── .travis.yml ├── README.md ├── run-tests ├── CHANGELOG ├── Makefile ├── setup.py ├── liblarch ├── viewcount.py ├── processqueue.py ├── filters_bank.py ├── __init__.py ├── treenode.py ├── viewtree.py ├── tree.py └── filteredtree.py ├── LICENSE ├── liblarch_gtk ├── treemodel.py └── __init__.py └── main.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /debian/docs: -------------------------------------------------------------------------------- 1 | AUTHORS 2 | README.md 3 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .*.sw* 3 | *.prof 4 | MANIFEST 5 | dist/ 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | spec 3 | pyflakes 4 | pycodestyle 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README 2 | include LICENSE 3 | include AUTHORS 4 | -------------------------------------------------------------------------------- /examples/artefact: -------------------------------------------------------------------------------- 1 | Tree before operation 2 | 3 | ==================== Tree ==================== 4 | root 5 | C 6 | D 7 | X 8 | X 9 | ============================================== 10 | -------------------------------------------------------------------------------- /examples/cycle: -------------------------------------------------------------------------------- 1 | Tree before operation 2 | 3 | ==================== Tree ==================== 4 | root 5 | R 6 | Parent 7 | Child 8 | Parent 9 | ============================================== 10 | -------------------------------------------------------------------------------- /examples/cycle2: -------------------------------------------------------------------------------- 1 | Tree before operation 2 | 3 | ==================== Tree ==================== 4 | root 5 | R 6 | A_node 7 | B_node 8 | B_node 9 | A_node 10 | ============================================== 11 | -------------------------------------------------------------------------------- /examples/test_suite: -------------------------------------------------------------------------------- 1 | Tree before operation 2 | 3 | ==================== Tree ==================== 4 | root 5 | A 6 | X 7 | B 8 | X 9 | C 10 | D 11 | X 12 | X 13 | E 14 | F 15 | X 16 | X 17 | ============================================== 18 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | AUTHORS 2 | ======== 3 | 4 | Liblarch team: 5 | -------------- 6 | 7 | * Lionel Dricot 8 | * Izidor Matušov 9 | 10 | Contributors: 11 | ------------- 12 | 13 | * Antonio Roquentin 14 | -------------------------------------------------------------------------------- /examples/torture: -------------------------------------------------------------------------------- 1 | Tree before operation 2 | 3 | ==================== Tree ==================== 4 | root 5 | 0 6 | 1 7 | 2 8 | 3 9 | 4 10 | 5 11 | parent 12 | 10 13 | 11 14 | 12 15 | 13 16 | 14 17 | 11 18 | 12 19 | 13 20 | 14 21 | ============================================== 22 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export PYBUILD_NAME = liblarch 4 | 5 | %: 6 | dh $@ --with python3 --buildsystem=pybuild 7 | 8 | # Use Xvfb to run tests without requiring an actual X server 9 | # (We need to check $DEB_BUILD_OPTIONS ourselves until Debhelper 13+) 10 | override_dh_auto_test: 11 | ifeq (,$(filter nocheck, $(DEB_BUILD_OPTIONS))) 12 | xvfb-run dh_auto_test 13 | endif 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - '3.2' 5 | 6 | before_install: 7 | - sudo apt-get update 8 | - sudo apt-get install -qq python3-gi gir1.2-gtk-3.0 9 | 10 | before_script: 11 | - "export DISPLAY=:99.0" 12 | - "sh -e /etc/init.d/xvfb start" 13 | 14 | virtualenv: 15 | system_site_packages: true 16 | 17 | install: 18 | - pip install -r requirements.txt 19 | 20 | script: 21 | - make check 22 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: liblarch 2 | Priority: optional 3 | Maintainer: Izidor Matušov 4 | Build-Depends: 5 | debhelper (>= 10~), 6 | dh-python, 7 | python3-all, 8 | python3-setuptools, 9 | # Needed for running the test suite: 10 | python3-nose, 11 | python3-gi, 12 | gir1.2-gtk-3.0, 13 | xvfb, 14 | xauth, 15 | Standards-Version: 4.5.0 16 | Section: python 17 | Homepage: https://github.com/getting-things-gnome/liblarch 18 | 19 | Package: python3-liblarch 20 | Architecture: all 21 | Depends: ${misc:Depends}, ${python3:Depends}, python3-gi, gir1.2-gtk-3.0 22 | Description: Python library to easily handle complex data structures 23 | Liblarch is a Python library built to easily handle data structures 24 | such as lists, trees and directed acyclic graphs and represent them 25 | as a GTK TreeWidget or in other forms. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Liblarch 2 | 3 | [![Build Status](https://travis-ci.org/getting-things-gnome/liblarch.svg)](https://travis-ci.org/getting-things-gnome/liblarch) 4 | 5 | If you find Gtk.Treeview and Gtk.Treemodel hard to use, then liblarch is probably for you. 6 | 7 | Liblarch is a python library built to easily handle data structure such are lists, trees and acyclic graphs 8 | (tree where nodes can have multiple parents). There's also a liblarch-gtk binding that will allow you to use 9 | your data structure into a Gtk.Treeview. 10 | 11 | Liblarch support multiple views of one data structure and complex filtering. That way, you have a clear 12 | separation between your data themselves (Model) and how they are displayed (View). 13 | 14 | ## Links 15 | 16 | - [Documentation](https://wiki.gnome.org/Projects/liblarch) 17 | 18 | ## Credits 19 | 20 | Liblarch is published under the LGPLv3 license, or (at your option) any later version. 21 | 22 | Authors: 23 | - [Lionel Dricot](https://github.com/ploum) 24 | - [Izidor Matušov](https://github.com/izidormatusov) 25 | -------------------------------------------------------------------------------- /examples/delete-randomly: -------------------------------------------------------------------------------- 1 | Tree before operation 2 | 3 | ==================== Tree ==================== 4 | root 5 | 1 6 | 2 7 | 3 8 | 4 9 | 5 10 | 6 11 | 7 12 | 8 13 | 9 14 | 10 15 | 11 16 | 12 17 | 13 18 | 14 19 | 15 20 | 16 21 | 17 22 | 18 23 | 19 24 | 20 25 | ============================================== 26 | 27 | Tasks should be removed in this order: ['22', '42', '37', '35', '32', '16', '8', '20', '27', '25', '3', '17', '19', '38', '12', '15', '9', '10', '30', '33', '13', '24', '28', '2', '23', '7', '4', '39', '1', '11', '26', '29', '6', '31', '14', '40', '34', '41', '21', '18', '36', '5'] 28 | 29 | ==================== Tree ==================== 30 | root 31 | ============================================== 32 | 33 | Tree after operation 34 | 35 | ==================== Tree ==================== 36 | root 37 | ============================================== 38 | 39 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://dep.debian.net/deps/dep5 2 | Upstream-Name: liblarch 3 | Source: https://github.com/getting-things-gnome/liblarch 4 | 5 | Files: * 6 | Copyright: 2011-2012 Lionel Dricot 7 | 2011-2012 Izidor Matušov 8 | License: LGPL-3 9 | This program is free software: you can redistribute it and/or modify it under 10 | the terms of the GNU Lesser General Public License as published by the Free 11 | Software Foundation, either version 3 of the License, or (at your option) any 12 | later version. 13 | . 14 | This program is distributed in the hope that it will be useful, but WITHOUT 15 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 16 | FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 17 | details. 18 | . 19 | You should have received a copy of the GNU Lesser General Public License 20 | along with this program. If not, see . 21 | . 22 | On Debian systems, the complete text of the GNU Lesser General 23 | Public License version 3 can be found in "/usr/share/common-licenses/LGPL-3". 24 | -------------------------------------------------------------------------------- /run-tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # ----------------------------------------------------------------------------- 4 | # Liblarch - a library to handle directed acyclic graphs 5 | # Copyright (c) 2011-2014 - Lionel Dricot & Izidor Matušov 6 | # 7 | # This program is free software: you can redistribute it and/or modify it under 8 | # the terms of the GNU Lesser General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) any 10 | # later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but WITHOUT 13 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 14 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 15 | # details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with this program. If not, see . 19 | # ----------------------------------------------------------------------------- 20 | 21 | import sys 22 | import pytest 23 | 24 | if __name__ == "__main__": 25 | # By default, run tests in tests folder 26 | if len(sys.argv) == 1: 27 | sys.argv.append('tests') 28 | 29 | pytest.main() 30 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2022-02-19 liblarch 3.2.0 2 | 3 | * Provide a fix for collapsing/expanding future (not-yet-created) nodes 4 | This prevents a TypeError under various circumstances, 5 | which made GTG's TreeView display inconsistent search/filtering results. 6 | * Add PyPI metadata to setup.py 7 | * Clarify, in the README file, that liblarch is LGPL v3 "or later" 8 | * Fix Pytest deprecation warnings 9 | 10 | 2021-03-31 liblarch 3.1.0 11 | 12 | * Provide an optimized way to refresh filtered items 13 | This provides better performance for GTG in particular 14 | * Replace calls to the pep8 executable by pycodestyle 15 | * Disable building with Python 2 16 | 17 | 2020-06-04 liblarch 3.0.1 18 | 19 | * Fix drag & drop from one GTK TreeView widget to another 20 | * Handle cases where the tree should and shouldn't be re-filtered 21 | * Improved PyGI (GObject introspection) compatibility 22 | * Improved code quality and PEP 8 compliance 23 | 24 | 2014-04-19 liblarch 3.0.0 25 | 26 | * Port to Python 3, GObject introspection and GTK 3 27 | 28 | 2013-01-22 liblarch 2.2.0 29 | 30 | * Fix an incorrect node count in a callback (LP #1078368) 31 | 32 | 2012-11-08 liblarch 2.1.0 33 | 34 | * Introducing the changelog 35 | * Removed completely the "transparency" property 36 | * Added a new object : viewcount 37 | * The version number of liblarch will now be the API number + a number. Meaning that: 38 | - 0.0.1 releases are pure bugfix/performance releases without impact on your application 39 | - 0.1.0 releases introduce new API but are backward compatible. You don't need to port your application. 40 | - 1.0.0 releases break the API. 41 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Liblarch - a library to handle directed acyclic graphs 3 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 4 | # 5 | # This program is free software: you can redistribute it and/or modify it under 6 | # the terms of the GNU Lesser General Public License as published by the Free 7 | # Software Foundation, either version 3 of the License, or (at your option) any 8 | # later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, but WITHOUT 11 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 12 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 13 | # details. 14 | # 15 | # You should have received a copy of the GNU Lesser General Public License 16 | # along with this program. If not, see . 17 | # ----------------------------------------------------------------------------- 18 | 19 | # Simple makefile for common tasks 20 | check: tests lint 21 | 22 | tests: 23 | ./run-tests 24 | 25 | sdist: 26 | python setup.py sdist 27 | 28 | # Remove .pyc files 29 | clean: 30 | find -type d -name '__pycache__' -print | xargs rm -rf 31 | find -type f -iname '*.~*~' -exec rm {} \; 32 | rm -f *.bak 33 | rm -rf dist/ 34 | 35 | # Check for common & easily catchable Python mistakes. 36 | pyflakes: 37 | pyflakes examples liblarch liblarch_gtk tests \ 38 | main.py run-tests setup.py 39 | 40 | # Check for coding standard violations. 41 | pep8: 42 | pycodestyle --statistics --count --repeat --max-line-length=110 --ignore=E128,W504 \ 43 | examples liblarch liblarch_gtk tests main.py run-tests setup.py 44 | 45 | # Check for coding standard violations & flakes. 46 | lint: pyflakes pycodestyle 47 | 48 | .PHONY: check tests sdist clean pyflakes pycodestyle lint 49 | -------------------------------------------------------------------------------- /tests/watchdog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | import threading 21 | 22 | 23 | class Watchdog(object): 24 | ''' 25 | a simple thread-safe watchdog. 26 | usage: 27 | with Watchdod(timeout, error_function): 28 | #do something 29 | ''' 30 | 31 | def __init__(self, timeout, error_function): 32 | ''' 33 | Just sets the timeout and the function to execute when an error occurs 34 | 35 | @param timeout: timeout in seconds 36 | @param error_function: what to execute in case the watchdog timer 37 | triggers 38 | ''' 39 | self.timeout = timeout 40 | self.error_function = error_function 41 | 42 | def __enter__(self): 43 | '''Starts the countdown''' 44 | self.timer = threading.Timer(self.timeout, self.error_function) 45 | self.timer.start() 46 | 47 | def __exit__(self, type, value, traceback): 48 | '''Aborts the countdown''' 49 | try: 50 | self.timer.cancel() 51 | except: 52 | pass 53 | if value is None: 54 | return True 55 | return False 56 | -------------------------------------------------------------------------------- /tests/test_signal_testing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | import unittest 21 | from gi.repository import GObject 22 | import uuid 23 | from gi.repository import GLib 24 | 25 | from tests.signals_testing import SignalCatcher, GobjectSignalsManager 26 | 27 | 28 | class TestSignalTesting(unittest.TestCase): 29 | def setUp(self): 30 | self.gobject_signal_manager = GobjectSignalsManager() 31 | self.gobject_signal_manager.init_signals() 32 | 33 | def tearDown(self): 34 | self.gobject_signal_manager.terminate_signals() 35 | 36 | def test_signal_catching(self): 37 | generator = FakeGobject() 38 | arg = str(uuid.uuid4()) 39 | with SignalCatcher(self, generator, 40 | 'one') as [signal_catched_event, signal_arguments]: 41 | generator.emit_signal('one', arg) 42 | signal_catched_event.wait() 43 | self.assertEqual(len(signal_arguments), 1) 44 | self.assertEqual(len(signal_arguments[0]), 1) 45 | one_signal_arguments = signal_arguments[0] 46 | self.assertEqual(arg, one_signal_arguments[0]) 47 | 48 | 49 | class FakeGobject(GObject.GObject): 50 | __gsignals__ = { 51 | 'one': (GObject.SignalFlags.RUN_FIRST, None, (str, )), 52 | 'two': (GObject.SignalFlags.RUN_FIRST, None, (str, )), 53 | } 54 | 55 | def emit_signal(self, signal_name, argument): 56 | GLib.idle_add(self.emit, signal_name, argument) 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # ----------------------------------------------------------------------------- 4 | # Liblarch - a library to handle directed acyclic graphs 5 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 6 | # 7 | # This program is free software: you can redistribute it and/or modify it under 8 | # the terms of the GNU Lesser General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) any 10 | # later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but WITHOUT 13 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 14 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 15 | # details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with this program. If not, see . 19 | # ----------------------------------------------------------------------------- 20 | 21 | from distutils.core import setup 22 | import codecs 23 | import os 24 | 25 | def read(*parts): 26 | """ 27 | Build an absolute path from *parts* and and return the contents of the 28 | resulting file. Assume UTF-8 encoding. 29 | """ 30 | HERE = os.path.abspath(os.path.dirname(__file__)) 31 | 32 | with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: 33 | return f.read() 34 | 35 | setup( 36 | version='3.2.0', 37 | url='https://wiki.gnome.org/Projects/liblarch', 38 | author='Lionel Dricot & Izidor Matušov', 39 | author_email='gtg-contributors@lists.launchpad.net', 40 | license='LGPLv3', 41 | long_description=read("README.md"), 42 | long_description_content_type="text/markdown", 43 | name='liblarch', 44 | packages=['liblarch', 'liblarch_gtk'], 45 | python_requires=">=3.5", 46 | keywords = ["gtk", "treeview", "treemodel"], 47 | classifiers = [ 48 | "Development Status :: 5 - Production/Stable", 49 | "Environment :: X11 Applications :: GTK", 50 | "Intended Audience :: Developers", 51 | "Natural Language :: English", 52 | "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", 53 | "Operating System :: POSIX :: Linux", 54 | "Programming Language :: Python", 55 | "Programming Language :: Python :: 3.5", 56 | "Topic :: Desktop Environment :: Gnome", 57 | "Topic :: Software Development :: Libraries :: Python Modules", 58 | "Topic :: Software Development :: User Interfaces", 59 | ], 60 | description=( 61 | 'LibLarch is a python library built to easily handle ' 62 | 'data structures such as lists, trees and directed acyclic graphs ' 63 | 'and represent them as a GTK TreeWidget or in other forms.' 64 | ), 65 | ) 66 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | liblarch (3.2.0-1) unstable; urgency=medium 2 | 3 | * Provide a fix for collapsing/expanding future (not-yet-created) nodes 4 | This prevents a TypeError under various circumstances, 5 | which made GTG's TreeView display inconsistent search/filtering results. 6 | * Add PyPI metadata to setup.py 7 | * Clarify, in the README file, that liblarch is LGPL v3 "or later" 8 | * Fix Pytest deprecation warnings 9 | 10 | -- Jeff F. Sat, 19 Feb 2022 13:37:00 -0400 11 | 12 | liblarch (3.1.0-1) unstable; urgency=medium 13 | 14 | * Provide an optimized way to refresh filtered items 15 | This provides better performance for GTG in particular 16 | * Replace calls to the pep8 executable by pycodestyle 17 | * Disable building with Python 2 18 | 19 | -- Jeff F. Wed, 31 Mar 2021 13:37:00 -0400 20 | 21 | liblarch (3.0.1-1) unstable; urgency=low 22 | 23 | * Release accumulated improvements: 24 | - Fix drag & drop from one GTK TreeView widget to another 25 | - Handle cases where the tree should and shouldn't be re-filtered 26 | - Improved PyGI (GObject introspection) compatibility 27 | - Improved code quality and PEP 8 compliance 28 | 29 | -- Jeff F. Fri, 4 Jun 2020 13:37:00 -0400 30 | 31 | liblarch (v3.0-26-g2b3366b-1) unstable; urgency=medium 32 | 33 | * New upstream snapshot 34 | * Debian packaging changes: 35 | - Switch dh build system to pybuild 36 | - Run test suite when building, using Xvfb 37 | - Bump debhelper compatibility level to 10 38 | - Reworded package synopsis line 39 | - Fixed package description indentation 40 | - Fixed typo in package description 41 | - Removed unnecessary X-Python3-Version header 42 | - Bumped Standards-Version to 4.5.0 43 | 44 | -- Frédéric Brière Wed, 06 May 2020 20:01:04 -0400 45 | 46 | liblarch (3.0.0-1) unstable; urgency=low 47 | 48 | * Port to Python 3, GObject introspection and GTK 3 49 | 50 | -- Izidor Matušov Sat, 19 Apr 2014 09:44:12 +0100 51 | 52 | liblarch (2.2.0-1) unstable; urgency=low 53 | 54 | * Fix for LP #1078368: incorrect node count in a callback 55 | 56 | -- Izidor Matušov Tue, 22 Jan 2013 15:56:12 +0100 57 | 58 | liblarch (2.1.0-1) unstable; urgency=low 59 | 60 | * Removed completely the "transparency" property 61 | * Added a new object: viewcount 62 | * The version number of liblarch will now be the API number + a number. Meaning that: 63 | 0.0.1 releases are pure bugfix/performance releases without impact on your application 64 | 0.1.0 releases introduce new API but are backward compatible. You don't need to port your application. 65 | 1.0.0 releases break the API. 66 | 67 | -- Lionel Dricot Thu, 08 Nov 2012 12:00:00 +0200 68 | 69 | liblarch (0.2.5-1) unstable; urgency=low 70 | 71 | * Repackage liblarch and liblarch_gtk into a single package 72 | 73 | -- Izidor Matušov Sat, 11 Aug 2012 16:50:51 +0200 74 | -------------------------------------------------------------------------------- /tests/test_filteredtree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | import unittest 21 | from liblarch.treenode import _Node 22 | from liblarch.tree import MainTree 23 | from liblarch.filters_bank import FiltersBank 24 | from liblarch.filteredtree import FilteredTree 25 | 26 | 27 | class TestFilteredTree(unittest.TestCase): 28 | def setUp(self): 29 | self.added_nodes = 0 30 | self.deleted_nodes = 0 31 | self.tree = MainTree() 32 | self.filtersbank = FiltersBank(self.tree) 33 | self.filtered_tree = FilteredTree(self.tree, self.filtersbank) 34 | self.tree.add_node(_Node(node_id="apple")) 35 | self.tree.add_node(_Node(node_id="google")) 36 | 37 | self.filtered_tree.set_callback('deleted', self.deleted) 38 | self.filtered_tree.set_callback('added', self.added) 39 | 40 | def search_filter(self, node, parameters): 41 | return node.get_id() == parameters['node_id'] 42 | 43 | def true_filter(self, node): 44 | return True 45 | 46 | def test_refresh_every_time_with_parameters(self): 47 | self.filtersbank.add_filter("search_filter", self.search_filter) 48 | self.assertTrue(self.filtered_tree.is_displayed(node_id="apple")) 49 | self.assertTrue(self.filtered_tree.is_displayed(node_id="apple")) 50 | 51 | self.filtered_tree.apply_filter("search_filter", 52 | parameters={'node_id': 'apple'}) 53 | self.assertTrue(self.filtered_tree.is_displayed(node_id="apple")) 54 | self.assertFalse(self.filtered_tree.is_displayed(node_id="google")) 55 | 56 | # Due to self.refilter() implementation, all nodes are deleted 57 | # at first and then only those satisfying the filter are added back. 58 | self.assertEqual(2, self.deleted_nodes) 59 | self.assertEqual(1, self.added_nodes) 60 | 61 | self.reset_counters() 62 | 63 | self.filtered_tree.apply_filter("search_filter", 64 | parameters={'node_id': 'google'}) 65 | self.assertFalse(self.filtered_tree.is_displayed(node_id="apple")) 66 | self.assertTrue(self.filtered_tree.is_displayed(node_id="google")) 67 | 68 | self.assertEqual(1, self.deleted_nodes) 69 | self.assertEqual(1, self.added_nodes) 70 | 71 | def test_refresh_only_with_new_filter(self): 72 | self.filtersbank.add_filter("true_filter", self.true_filter) 73 | 74 | self.reset_counters() 75 | 76 | self.filtered_tree.apply_filter("true_filter") 77 | 78 | self.assertEqual(2, self.deleted_nodes) 79 | self.assertEqual(2, self.added_nodes) 80 | 81 | self.reset_counters() 82 | 83 | self.filtered_tree.apply_filter("true_filter") 84 | 85 | self.assertEqual(0, self.deleted_nodes) 86 | self.assertEqual(0, self.added_nodes) 87 | 88 | def added(self, node_id, path): 89 | self.added_nodes += 1 90 | 91 | def deleted(self, node_id, path): 92 | self.deleted_nodes += 1 93 | 94 | def reset_counters(self): 95 | self.added_nodes, self.deleted_nodes = 0, 0 96 | -------------------------------------------------------------------------------- /liblarch/viewcount.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # --------------------------------------------------------------------------- 19 | 20 | 21 | class ViewCount(object): 22 | def __init__(self, tree, fbank, name=None, refresh=True): 23 | self.initialized = False 24 | self.ncount = {} 25 | self.tree = tree 26 | self.tree.register_callback("node-added", self.__modify) 27 | self.tree.register_callback("node-modified", self.__modify) 28 | self.tree.register_callback("node-deleted", self.__delete) 29 | 30 | self.fbank = fbank 31 | self.name = name 32 | 33 | self.applied_filters = [] 34 | self.nodes = [] 35 | self.cllbcks = [] 36 | 37 | if refresh: 38 | self.__refresh() 39 | 40 | def __refresh(self): 41 | for node in self.tree.get_all_nodes(): 42 | self.__modify(node) 43 | self.initialized = True 44 | 45 | def apply_filter(self, filter_name, refresh=True): 46 | if self.fbank.has_filter(filter_name): 47 | if filter_name not in self.applied_filters: 48 | self.applied_filters.append(filter_name) 49 | if refresh: 50 | # If we are not initialized, we need to refresh with 51 | # all existing nodes 52 | if self.initialized: 53 | for n in list(self.nodes): 54 | self.__modify(n) 55 | else: 56 | self.__refresh() 57 | else: 58 | print("There's no filter called %s" % filter_name) 59 | 60 | def unapply_filter(self, filter_name): 61 | if filter_name in self.applied_filters: 62 | self.applied_filters.remove(filter_name) 63 | for node in self.tree.get_all_nodes(): 64 | self.__modify(node) 65 | 66 | # there's only one callback: "modified" 67 | def register_cllbck(self, func): 68 | if func not in self.cllbcks: 69 | self.cllbcks.append(func) 70 | 71 | def unregister_cllbck(self, func): 72 | if func in self.cllbacks: 73 | self.cllbacks.remove(func) 74 | 75 | def get_n_nodes(self): 76 | return len(self.nodes) 77 | 78 | def modify(self, nid): 79 | """ Allow external update of a given node """ 80 | self.__modify(nid) 81 | 82 | def __modify(self, nid): 83 | displayed = True 84 | for filtname in self.applied_filters: 85 | filt = self.fbank.get_filter(filtname) 86 | displayed &= filt.is_displayed(nid) 87 | if displayed: 88 | self.__add(nid) 89 | else: 90 | self.__delete(nid) 91 | 92 | def __delete(self, nid): 93 | if nid in self.nodes: 94 | self.nodes.remove(nid) 95 | self.__callback() 96 | 97 | def __add(self, nid): 98 | if nid not in self.nodes: 99 | self.nodes.append(nid) 100 | self.__callback() 101 | 102 | def __callback(self): 103 | for c in self.cllbcks: 104 | c() 105 | -------------------------------------------------------------------------------- /liblarch/processqueue.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | import threading 21 | 22 | from gi.repository import GObject 23 | 24 | 25 | class SyncQueue(object): 26 | """ Synchronized queue for processing requests""" 27 | 28 | def __init__(self): 29 | """ Initialize synchronized queue. 30 | 31 | @param callback - function for processing requests""" 32 | self._low_queue = [] 33 | self._queue = [] 34 | self._vip_queue = [] 35 | self._handler = None 36 | self._lock = threading.Lock() 37 | self._origin_thread = threading.current_thread() 38 | 39 | self.count = 0 40 | 41 | def process_queue(self): 42 | """ Process requests from queue """ 43 | for action in self.process(): 44 | func = action[0] 45 | func(*action[1:]) 46 | # return True to process other requests as well 47 | return True 48 | 49 | def push(self, *element, **kwargs): 50 | """ Add a new element to the queue. 51 | 52 | Process actions from the same thread as the thread which created 53 | this queue immediately. What does it mean? When I use liblarch 54 | without threads, all actions are processed immediately. In GTG, 55 | this queue is created by the main thread which process GUI. When 56 | GUI callback is triggered, process those actions immediately because 57 | no protection is needed. However, requests from synchronization 58 | services are put in the queue. 59 | 60 | Application can choose which kind of priority should have an update. 61 | If the request is not in the queue of selected priority, add it and 62 | setup callback. 63 | """ 64 | 65 | if self._origin_thread == threading.current_thread(): 66 | func = element[0] 67 | func(*element[1:]) 68 | return 69 | 70 | priority = kwargs.get('priority') 71 | if priority == 'low': 72 | queue = self._low_queue 73 | elif priority == 'high': 74 | queue = self._vip_queue 75 | else: 76 | queue = self._queue 77 | 78 | self._lock.acquire() 79 | if element not in queue: 80 | queue.append(element) 81 | if self._handler is None: 82 | self._handler = GObject.idle_add(self.process_queue) 83 | 84 | self._lock.release() 85 | 86 | def process(self): 87 | """ Return elements to process 88 | 89 | At the moment, it returns just one element. In the future more 90 | elements may be better to return (to speed it up). 91 | 92 | If there is no request left, disable processing. """ 93 | 94 | self._lock.acquire() 95 | if len(self._vip_queue) > 0: 96 | toreturn = [self._vip_queue.pop(0)] 97 | elif len(self._queue) > 0: 98 | toreturn = [self._queue.pop(0)] 99 | elif len(self._low_queue) > 0: 100 | toreturn = [self._low_queue.pop(0)] 101 | else: 102 | toreturn = [] 103 | 104 | if (len(self._queue) == 0 and 105 | len(self._vip_queue) == 0 and 106 | len(self._low_queue) == 0 and 107 | self._handler is not None): 108 | GObject.source_remove(self._handler) 109 | self._handler = None 110 | self._lock.release() 111 | return toreturn 112 | -------------------------------------------------------------------------------- /liblarch/filters_bank.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | """ 21 | filters_bank stores all of GTG's filters in centralized place 22 | """ 23 | 24 | 25 | class Filter(object): 26 | def __init__(self, func, req): 27 | self.func = func 28 | self.dic = {} 29 | self.tree = req 30 | 31 | def set_parameters(self, dic): 32 | if dic: 33 | self.dic = dic 34 | 35 | def is_displayed(self, node_id): 36 | if self.tree.has_node(node_id): 37 | task = self.tree.get_node(node_id) 38 | else: 39 | return False 40 | 41 | if self.dic: 42 | value = self.func(task, parameters=self.dic) 43 | else: 44 | value = self.func(task) 45 | 46 | if 'negate' in self.dic and self.dic['negate']: 47 | value = not value 48 | 49 | return value 50 | 51 | def get_parameters(self, param): 52 | return self.dic.get(param, None) 53 | 54 | def is_flat(self): 55 | """ Should be the final list flat """ 56 | return self.get_parameters('flat') 57 | 58 | 59 | class FiltersBank(object): 60 | """ 61 | Stores filter objects in a centralized place. 62 | """ 63 | 64 | def __init__(self, tree): 65 | """ 66 | Create several stock filters: 67 | 68 | workview - Tasks that are active, workable, and started 69 | active - Tasks of status Active 70 | closed - Tasks of status closed or dismissed 71 | notag - Tasks with no tags 72 | """ 73 | self.tree = tree 74 | self.available_filters = {} 75 | self.custom_filters = {} 76 | 77 | ########################################## 78 | 79 | def get_filter(self, filter_name): 80 | """ Get the filter object for a given name """ 81 | if filter_name in self.available_filters: 82 | return self.available_filters[filter_name] 83 | elif filter_name in self.custom_filters: 84 | return self.custom_filters[filter_name] 85 | else: 86 | return None 87 | 88 | def has_filter(self, filter_name): 89 | return filter_name in self.available_filters \ 90 | or filter_name in self.custom_filters 91 | 92 | def list_filters(self): 93 | """ List, by name, all available filters """ 94 | liste = list(self.available_filters.keys()) 95 | liste += list(self.custom_filters.keys()) 96 | return liste 97 | 98 | def add_filter(self, filter_name, filter_func, parameters=None): 99 | """ 100 | Adds a filter to the filter bank 101 | Return True if the filter was added 102 | Return False if the filter_name was already in the bank 103 | """ 104 | if filter_name not in self.list_filters(): 105 | if filter_name.startswith('!'): 106 | filter_name = filter_name[1:] 107 | else: 108 | filter_obj = Filter(filter_func, self.tree) 109 | filter_obj.set_parameters(parameters) 110 | self.custom_filters[filter_name] = filter_obj 111 | return True 112 | else: 113 | return False 114 | 115 | def remove_filter(self, filter_name): 116 | """ 117 | Remove a filter from the bank. 118 | Only custom filters that were added here can be removed 119 | Return False if the filter was not removed 120 | """ 121 | if filter_name not in self.available_filters: 122 | if filter_name in self.custom_filters: 123 | self.custom_filters.pop(filter_name) 124 | return True 125 | else: 126 | return False 127 | else: 128 | return False 129 | -------------------------------------------------------------------------------- /tests/signals_testing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | import threading 21 | import time 22 | 23 | from tests.watchdog import Watchdog 24 | 25 | from gi.repository import GLib 26 | 27 | 28 | class SignalCatcher(object): 29 | ''' 30 | A class to test signals 31 | ''' 32 | 33 | def __init__(self, unittest, generator, signal_name, 34 | should_be_caught=True, how_many_signals=1, 35 | error_code="No error code set"): 36 | self.signal_catched_event = threading.Event() 37 | self.generator = generator 38 | self.signal_name = signal_name 39 | self.signal_arguments = [] 40 | self.unittest = unittest 41 | self.how_many_signals = how_many_signals 42 | self.should_be_caught = should_be_caught 43 | self.error_code = error_code 44 | 45 | def _on_failure(): 46 | # we need to release the waiting thread 47 | self.signal_catched_event.set() 48 | self.missed = True 49 | # then we notify the error 50 | # if the error_code is set to None, we're expecting it to fail. 51 | if error_code is not None: 52 | print("An expected signal wasn't received %s" % error_code) 53 | self.unittest.assertFalse(should_be_caught) 54 | 55 | self.watchdog = Watchdog(3, _on_failure) 56 | 57 | def __enter__(self): 58 | 59 | def __signal_callback(*args): 60 | self.signal_arguments.append(args[1:]) 61 | if len(self.signal_arguments) >= self.how_many_signals: 62 | self.signal_catched_event.set() 63 | 64 | self.handler = self.generator.connect( 65 | self.signal_name, __signal_callback) 66 | self.watchdog.__enter__() 67 | return [self.signal_catched_event, self.signal_arguments] 68 | 69 | def __exit__(self, err_type, value, traceback): 70 | self.generator.disconnect(self.handler) 71 | if not self.should_be_caught and not hasattr(self, 'missed'): 72 | self.assertFalse(True) 73 | return (not isinstance(value, Exception) and 74 | self.watchdog.__exit__(err_type, value, traceback)) 75 | 76 | 77 | class CallbackCatcher(object): 78 | ''' 79 | A class to test callbacks 80 | ''' 81 | 82 | def __init__(self, unittest, generator, signal_name, 83 | should_be_caught=True, how_many_signals=1, 84 | error_code="No error code set"): 85 | self.signal_catched_event = threading.Event() 86 | self.generator = generator 87 | self.signal_name = signal_name 88 | self.signal_arguments = [] 89 | self.unittest = unittest 90 | self.how_many_signals = how_many_signals 91 | self.should_be_caught = should_be_caught 92 | self.error_code = error_code 93 | 94 | def _on_failure(): 95 | # we need to release the waiting thread 96 | self.signal_catched_event.set() 97 | self.missed = True 98 | # then we notify the error 99 | # if the error_code is set to None, we're expecting it to fail. 100 | if error_code is not None: 101 | print("An expected signal wasn't received %s" % error_code) 102 | self.unittest.assertFalse(should_be_caught) 103 | 104 | self.watchdog = Watchdog(3, _on_failure) 105 | 106 | def __enter__(self): 107 | 108 | def __signal_callback(*args): 109 | """ Difference to SignalCatcher is that we do not skip 110 | the first argument. The first argument by signals is widget 111 | which sends the signal -- we omit this feature when using callbacks 112 | """ 113 | self.signal_arguments.append(args) 114 | if len(self.signal_arguments) >= self.how_many_signals: 115 | self.signal_catched_event.set() 116 | 117 | self.handler = self.generator.register_cllbck( 118 | self.signal_name, __signal_callback) 119 | self.watchdog.__enter__() 120 | return [self.signal_catched_event, self.signal_arguments] 121 | 122 | def __exit__(self, err_type, value, traceback): 123 | self.generator.deregister_cllbck(self.signal_name, self.handler) 124 | if not self.should_be_caught and not hasattr(self, 'missed'): 125 | self.assertFalse(True) 126 | return (not isinstance(value, Exception) and 127 | self.watchdog.__exit__(err_type, value, traceback)) 128 | 129 | 130 | class GobjectSignalsManager(object): 131 | 132 | def init_signals(self): 133 | ''' 134 | Initializes the gobject main loop so that signals can be used. 135 | This function returns only when the gobject main loop is running 136 | ''' 137 | def gobject_main_loop(): 138 | self.main_loop = GLib.MainLoop() 139 | self.main_loop.run() 140 | threading.Thread(target=gobject_main_loop).start() 141 | while (not hasattr(self, 'main_loop') or 142 | not self.main_loop.is_running()): 143 | # since running the gobject main loop is a blocking call, 144 | # we have to check that it has been started in a polling fashion 145 | time.sleep(0.1) 146 | 147 | def terminate_signals(self): 148 | self.main_loop.quit() 149 | -------------------------------------------------------------------------------- /liblarch/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | from .filters_bank import FiltersBank 21 | from .tree import MainTree 22 | from .treenode import _Node 23 | from .viewcount import ViewCount 24 | from .viewtree import ViewTree 25 | 26 | # API version of liblarch. 27 | # Your application is compatible if the major version number match liblarch's 28 | # one and if your minor version number is inferior to liblarch's one. 29 | # 30 | # The major number is incremented if an existing method is removed or modified 31 | # The minor number is incremented when a method is added to the API 32 | API = "3.0" 33 | 34 | 35 | def is_compatible(request): 36 | major, minor = [int(i) for i in request.split(".")] 37 | current_ma, current_mi = [int(i) for i in API.split(".")] 38 | return major == current_ma and minor <= current_mi 39 | 40 | 41 | class TreeNode(_Node): 42 | """ The public interface for TreeNode 43 | """ 44 | def __init__(self, node_id, parent=None): 45 | _Node.__init__(self, node_id, parent) 46 | 47 | def _set_tree(tree): 48 | print("_set_tree is not part of the API") 49 | 50 | 51 | class Tree(object): 52 | """ A thin wrapper to MainTree that adds filtering capabilities. 53 | It also provides a few methods to operate complex operation on the 54 | MainTree (e.g, move_node) """ 55 | 56 | def __init__(self): 57 | """ Creates MainTree which wraps and a main view without filters """ 58 | self.__tree = MainTree() 59 | self.__fbank = FiltersBank(self.__tree) 60 | self.__views = {} 61 | self.__viewscount = {} 62 | self.__views['main'] = ViewTree( 63 | self, self.__tree, self.__fbank, static=True) 64 | 65 | # HANDLE NODES ############################################################ 66 | def get_node(self, node_id): 67 | """ Returns the object of node. 68 | If the node does not exists, a ValueError is raised. """ 69 | return self.__tree.get_node(node_id) 70 | 71 | def has_node(self, node_id): 72 | """ Does the node exists in this tree? """ 73 | return self.__tree.has_node(node_id) 74 | 75 | def add_node(self, node, parent_id=None, priority="low"): 76 | """ Add a node to tree. If parent_id is set, put the node as a child of 77 | this node, otherwise put it as a child of the root node.""" 78 | self.__tree.add_node(node, parent_id, priority) 79 | 80 | def del_node(self, node_id, recursive=False): 81 | """ Remove node from tree and return whether it was successful or not 82 | """ 83 | return self.__tree.remove_node(node_id, recursive) 84 | 85 | def refresh_node(self, node_id, priority="low"): 86 | """ Send a request for updating the node """ 87 | self.__tree.modify_node(node_id, priority) 88 | 89 | def refresh_all(self): 90 | """ Refresh all nodes """ 91 | self.__tree.refresh_all() 92 | 93 | def move_node(self, node_id, new_parent_id=None): 94 | """ Move the node to a new parent (dismissing all other parents) 95 | use pid None to move it to the root """ 96 | if self.has_node(node_id): 97 | node = self.get_node(node_id) 98 | node.set_parent(new_parent_id) 99 | toreturn = True 100 | else: 101 | toreturn = False 102 | 103 | return toreturn 104 | 105 | def add_parent(self, node_id, new_parent_id=None): 106 | """ Add the node to a new parent. Return whether operation was 107 | successful or not. If the node does not exists, return False """ 108 | 109 | if self.has_node(node_id): 110 | node = self.get_node(node_id) 111 | return node.add_parent(new_parent_id) 112 | else: 113 | return False 114 | 115 | # VIEWS ################################################################### 116 | def get_main_view(self): 117 | """ Return the special view "main" which is without any filters on it. 118 | """ 119 | return self.__views['main'] 120 | 121 | def get_viewtree(self, name=None, refresh=True): 122 | """ Returns a viewtree by the name: 123 | * a viewtree with that name exists => return it 124 | * a viewtree with that name does not exist => create a new one and 125 | return it 126 | * name is None => create an anonymous tree (do not remember it) 127 | 128 | If refresh is False, the view is not initialized. This is useful as 129 | an optimization if you plan to apply a filter. 130 | """ 131 | 132 | if name is not None and name in self.__views: 133 | view_tree = self.__views[name] 134 | else: 135 | view_tree = ViewTree( 136 | self, self.__tree, self.__fbank, name=name, refresh=refresh) 137 | if name is not None: 138 | self.__views[name] = view_tree 139 | return view_tree 140 | 141 | def get_viewcount(self, name=None, refresh=True): 142 | if name is not None and name in self.__viewscount: 143 | view_count = self.__viewscount[name] 144 | else: 145 | view_count = ViewCount( 146 | self.__tree, self.__fbank, name=name, refresh=refresh) 147 | if name is not None: 148 | self.__viewscount[name] = view_count 149 | return view_count 150 | 151 | # FILTERS ################################################################# 152 | def list_filters(self): 153 | """ Return a list of all available filters by name """ 154 | return self.__fbank.list_filters() 155 | 156 | def add_filter(self, filter_name, filter_func, parameters=None): 157 | """ Adds a filter to the filter bank. 158 | 159 | @filter_name : name to give to the filter 160 | @filter_func : the function that will filter the nodes 161 | @parameters : some default parameters for that filter 162 | Return True if the filter was added 163 | Return False if the filter_name was already in the bank 164 | """ 165 | return self.__fbank.add_filter(filter_name, filter_func, parameters) 166 | 167 | def remove_filter(self, filter_name): 168 | """ Remove a filter from the bank. Only custom filters that were 169 | added here can be removed. Return False if the filter was not removed. 170 | """ 171 | return self.__fbank.remove_filter(filter_name) 172 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /liblarch_gtk/treemodel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | from gi.repository import Gtk 21 | 22 | 23 | class TreeModel(Gtk.TreeStore): 24 | """ Local copy of showed tree """ 25 | 26 | def __init__(self, tree, types): 27 | """ Initializes parent and create list of columns. The first column 28 | is node_id of node """ 29 | 30 | self.count = 0 31 | self.count2 = 0 32 | 33 | self.types = [[str, lambda node: node.get_id()]] + types 34 | only_types = [python_type for python_type, access_method in self.types] 35 | 36 | super(TreeModel, self).__init__(*only_types) 37 | self.cache_paths = {} 38 | self.cache_position = {} 39 | self.tree = tree 40 | 41 | def set_column_function(self, column_num, column_func): 42 | """ Replace function for generating certain column. 43 | 44 | Original use case was changing method of generating background 45 | color during runtime - background by tags or due date """ 46 | 47 | if column_num < len(self.types): 48 | self.types[column_num][1] = column_func 49 | return True 50 | else: 51 | return False 52 | 53 | def connect_model(self): 54 | """ Register "signals", callbacks from liblarch. 55 | 56 | Also asks for the current status by providing add_task callback. 57 | We are able to connect to liblarch tree on the fly. """ 58 | 59 | self.tree.register_cllbck('node-added-inview', self.add_task) 60 | self.tree.register_cllbck('node-deleted-inview', self.remove_task) 61 | self.tree.register_cllbck('node-modified-inview', self.update_task) 62 | self.tree.register_cllbck('node-children-reordered', self.reorder_nodes) 63 | 64 | # Request the current state 65 | self.tree.get_current_state() 66 | 67 | def my_get_iter(self, path): 68 | """ Because we sort the TreeStore, paths in the treestore are 69 | not the same as paths in the FilteredTree. We do the conversion here. 70 | We receive a Liblarch path as argument and return a Gtk.TreeIter""" 71 | # The function is recursive. We take iter for path (A, B, C) in cache. 72 | # If there is not, we take iter for path (A, B) and try to find C. 73 | if path == (): 74 | return None 75 | nid = str(path[-1]) 76 | self.count += 1 77 | # We try to use the cache 78 | iter = self.cache_paths.get(path, None) 79 | toreturn = None 80 | if (iter and self.iter_is_valid(iter) and nid == self.get_value(iter, 0)): 81 | self.count2 += 1 82 | toreturn = iter 83 | else: 84 | root = self.my_get_iter(path[:-1]) 85 | # This is a small ad-hoc optimisation. 86 | # Instead of going through all the children nodes 87 | # We go directly at the last known position. 88 | pos = self.cache_position.get(path, None) 89 | if pos: 90 | iter = self.iter_nth_child(root, pos) 91 | if iter and self.get_value(iter, 0) == nid: 92 | toreturn = iter 93 | if not toreturn: 94 | if root: 95 | iter = self.iter_children(root) 96 | else: 97 | iter = self.get_iter_first() 98 | while iter and self.get_value(iter, 0) != nid: 99 | iter = self.iter_next(iter) 100 | self.cache_paths[path] = iter 101 | toreturn = iter 102 | return toreturn 103 | 104 | def print_tree(self): 105 | """ Print TreeStore as Tree into console """ 106 | 107 | def push_to_stack(stack, level, iterator): 108 | """ Macro which adds a new element into stack if it is possible """ 109 | if iterator is not None: 110 | stack.append((level, iterator)) 111 | 112 | stack = [] 113 | push_to_stack(stack, 0, self.get_iter_first()) 114 | 115 | print("+" * 50) 116 | print("Treemodel print_tree: ") 117 | while stack != []: 118 | level, iterator = stack.pop() 119 | 120 | print("=>" * level, self.get_value(iterator, 0)) 121 | 122 | push_to_stack(stack, level, self.iter_next(iterator)) 123 | push_to_stack(stack, level + 1, self.iter_children(iterator)) 124 | print("+" * 50) 125 | 126 | # INTERFACE TO LIBLARCH ################################################### 127 | def add_task(self, node_id, path): 128 | """ Add new instance of node_id to position described at path. 129 | 130 | @param node_id: identification of task 131 | @param path: identification of position 132 | """ 133 | node = self.tree.get_node(node_id) 134 | 135 | # Build a new row 136 | row = [] 137 | for python_type, access_method in self.types: 138 | value = access_method(node) 139 | row.append(value) 140 | 141 | # Find position to add task 142 | iter_path = path[:-1] 143 | 144 | iterator = self.my_get_iter(iter_path) 145 | self.cache_position[path] = self.iter_n_children(iterator) 146 | self.insert(iterator, -1, row) 147 | 148 | def remove_task(self, node_id, path): 149 | """ Remove instance of node. 150 | 151 | @param node_id: identification of task 152 | @param path: identification of position 153 | """ 154 | it = self.my_get_iter(path) 155 | if not it: 156 | raise Exception( 157 | "Trying to remove node %s with no iterator" % node_id) 158 | actual_node_id = self.get_value(it, 0) 159 | assert actual_node_id == node_id 160 | self.remove(it) 161 | self.cache_position.pop(path) 162 | 163 | def update_task(self, node_id, path): 164 | """ Update instance of node by rebuilding the row. 165 | 166 | @param node_id: identification of task 167 | @param path: identification of position 168 | """ 169 | # We cannot assume that the node is in the tree because 170 | # update is asynchronus 171 | # Also, we should consider that missing an update is not critical 172 | # and ignoring the case where there is no iterator 173 | if self.tree.is_displayed(node_id): 174 | node = self.tree.get_node(node_id) 175 | # That call to my_get_iter is really slow! 176 | iterator = self.my_get_iter(path) 177 | 178 | if iterator: 179 | for column_num, (__, access_method) in enumerate(self.types): 180 | value = access_method(node) 181 | if value is not None: 182 | self.set_value(iterator, column_num, value) 183 | 184 | def reorder_nodes(self, node_id, path, neworder): 185 | """ Reorder nodes. 186 | 187 | This is deprecated signal. In the past it was useful for reordering 188 | showed nodes of tree. It was possible to delete just the last 189 | element and therefore every element must be moved to the last position 190 | and then deleted. 191 | 192 | @param node_id: identification of root node 193 | @param path: identification of position of root node 194 | @param neworder: new order of children of root node 195 | """ 196 | 197 | if path is not None: 198 | it = self.my_get_iter(path) 199 | else: 200 | it = None 201 | self.reorder(it, neworder) 202 | self.print_tree() 203 | -------------------------------------------------------------------------------- /liblarch/treenode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | 21 | class _Node(object): 22 | """ Object just for a single node in Tree """ 23 | def __init__(self, node_id, parent=None): 24 | """ Initializes node 25 | 26 | @param node_id - unique identifier of node (str) 27 | @param parent - node_id of parent 28 | """ 29 | self.node_id = node_id 30 | 31 | self.parents_enabled = True 32 | self.children_enabled = True 33 | self.parents = [] 34 | self.children = [] 35 | 36 | self.tree = None 37 | self.pending_relationships = [] 38 | 39 | if parent: 40 | self.add_parent(parent) 41 | 42 | def __str__(self): 43 | return "" % (self.node_id) 44 | 45 | def get_id(self): 46 | """ Return node_id """ 47 | return self.node_id 48 | 49 | def modified(self, priority="low"): 50 | """ Force to update node (because it has changed) """ 51 | if self.tree: 52 | self.tree.modify_node(self.node_id, priority=priority) 53 | 54 | def _set_tree(self, tree): 55 | """ Set tree which is should contain this node. 56 | 57 | This method should be called only from MainTree. It is not 58 | part of public interface. """ 59 | self.tree = tree 60 | 61 | def get_tree(self): 62 | """ Return associated tree with this node """ 63 | return self.tree 64 | 65 | # Parents ################################################################# 66 | def set_parents_enabled(self, bol): 67 | if not bol: 68 | for p in self.get_parents(): 69 | self.remove_parent(p) 70 | self.parents_enabled = bol 71 | 72 | def has_parents_enabled(self): 73 | return self.parents_enabled 74 | 75 | def add_parent(self, parent_id): 76 | """ Add a new parent """ 77 | if (parent_id != self.get_id() and 78 | self.parents_enabled and 79 | parent_id not in self.parents): 80 | if not self.tree: 81 | self.pending_relationships.append((parent_id, self.get_id())) 82 | elif not self.tree.has_node(parent_id): 83 | self.tree.pending_relationships.append( 84 | (parent_id, self.get_id())) 85 | else: 86 | par = self.tree.get_node(parent_id) 87 | if par.has_children_enabled(): 88 | self.tree.new_relationship(parent_id, self.node_id) 89 | 90 | def set_parent(self, parent_id): 91 | """ Remove other parents and set this parent as only parent """ 92 | if parent_id != self.get_id() and self.parents_enabled: 93 | is_already_parent_flag = False 94 | if not self.tree: 95 | self.pending_relationships.append((parent_id, self.get_id())) 96 | elif not self.tree.has_node(parent_id): 97 | for p in self.get_parents(): 98 | self.tree.break_relationship(p, self.get_id()) 99 | self.tree.pending_relationships.append( 100 | (parent_id, self.get_id())) 101 | else: 102 | par = self.tree.get_node(parent_id) 103 | if par.has_children_enabled(): 104 | # First we remove all the other parents 105 | for node_id in self.parents: 106 | if node_id != parent_id: 107 | self.remove_parent(node_id) 108 | else: 109 | is_already_parent_flag = True 110 | if parent_id and not is_already_parent_flag: 111 | self.add_parent(parent_id) 112 | 113 | def remove_parent(self, parent_id): 114 | """ Remove parent """ 115 | if self.parents_enabled and parent_id in self.parents: 116 | self.parents.remove(parent_id) 117 | self.tree.break_relationship(parent_id, self.node_id) 118 | 119 | def has_parent(self, parent_id=None): 120 | """ Has parent/parents? 121 | 122 | @param parent_id - None => has any parent? 123 | not None => has this parent? 124 | """ 125 | if self.parents_enabled: 126 | if parent_id: 127 | parent_in_tree = self.tree.has_node(parent_id) 128 | own_parent = parent_id in self.parents 129 | return parent_in_tree and own_parent 130 | else: 131 | return len(self.parents) > 0 132 | else: 133 | return False 134 | 135 | def get_parents(self): 136 | """ Return parents of node """ 137 | parents = [] 138 | if self.parents_enabled and self.tree: 139 | for parent_id in self.parents: 140 | if self.tree.has_node(parent_id): 141 | parents.append(parent_id) 142 | 143 | return parents 144 | 145 | # Children ################################################################ 146 | def set_children_enabled(self, bol): 147 | if not bol: 148 | for c in self.get_children(): 149 | self.tree.break_relationship(self.get_id(), c) 150 | self.children_enabled = bol 151 | 152 | def has_children_enabled(self): 153 | return self.children_enabled 154 | 155 | def add_child(self, child_id): 156 | """ Add a children to node """ 157 | if self.children_enabled and child_id != self.get_id(): 158 | if child_id not in self.children: 159 | if not self.tree: 160 | self.pending_relationships.append( 161 | (self.get_id(), child_id)) 162 | elif not self.tree.has_node(child_id): 163 | self.tree.pending_relationships.append( 164 | (self.get_id(), child_id)) 165 | else: 166 | child = self.tree.get_node(child_id) 167 | if child.has_parents_enabled(): 168 | self.children.append(child_id) 169 | self.tree.new_relationship(self.node_id, child_id) 170 | else: 171 | print("{} was already in children of {}".format( 172 | child_id, self.node_id)) 173 | 174 | def has_child(self, child_id=None): 175 | """ Has child/children? 176 | 177 | @param child_id - None => has any child? 178 | not None => has this child? 179 | """ 180 | if self.children_enabled: 181 | if child_id: 182 | return child_id in self.children 183 | else: 184 | return bool(self.children) 185 | else: 186 | return False 187 | 188 | def get_children(self): 189 | """ Return children of nodes """ 190 | children = [] 191 | if self.children_enabled and self.tree: 192 | for child_id in self.children: 193 | if self.tree.has_node(child_id): 194 | children.append(child_id) 195 | 196 | return children 197 | 198 | def get_n_children(self): 199 | """ Return count of children """ 200 | if self.children_enabled: 201 | return len(self.get_children()) 202 | else: 203 | return 0 204 | 205 | def get_nth_child(self, index): 206 | """ Return nth child """ 207 | try: 208 | return self.children[index] 209 | except(IndexError): 210 | raise ValueError("Requested non-existing child") 211 | 212 | def get_child_index(self, node_id): 213 | if self.children_enabled and node_id in self.children: 214 | return self.children.index(node_id) 215 | else: 216 | return None 217 | -------------------------------------------------------------------------------- /tests/tree_testing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | # If True, the TreeTester will automatically reorder node on the same level 21 | # as a deleted node. If False, it means that Liblarch has the responsibility 22 | # to handle that itself. 23 | REORDER_ON_DELETE = False 24 | 25 | 26 | class TreeTester(object): 27 | '''A class that will check if a tree implementation is consistent 28 | by connecting to emitted signals and crashing on any problem''' 29 | def __init__(self, viewtree): 30 | self.tree = viewtree 31 | # both dict should always be synchronized 32 | # They are the internal representation of the tree, 33 | # based only on received signals 34 | self.nodes = {} 35 | self.paths = {} 36 | self.tree.register_cllbck('node-added-inview', self.add) 37 | self.tree.register_cllbck('node-deleted-inview', self.delete) 38 | self.tree.register_cllbck('node-modified-inview', self.update) 39 | self.tree.register_cllbck('node-children-reordered', self.reordered) 40 | self.trace = "* * * * * * * *\n" 41 | 42 | def add(self, nid, path): 43 | self.trace += "adding %s to path %s\n" % (nid, str(path)) 44 | currentnode = self.paths.get(path, None) 45 | if currentnode and currentnode != nid: 46 | raise Exception( 47 | 'path %s is already occupied by %s' % (str(path), nid)) 48 | if nid in self.nodes: 49 | node = self.nodes[nid] 50 | else: 51 | node = [] 52 | self.nodes[nid] = node 53 | if path not in node: 54 | node.append(path) 55 | self.paths[path] = nid 56 | 57 | def delete(self, nid, path): 58 | self.trace += "removing %s from path %s\n" % (nid, str(path)) 59 | if nid != self.paths.get(path, None): 60 | error = '%s is not assigned to path %s\n' % (nid, str(path)) 61 | error += self.print_tree() 62 | raise Exception(error) 63 | if path not in self.nodes.get(nid, []): 64 | raise Exception('%s is not a path of node %s' % (str(path), nid)) 65 | if REORDER_ON_DELETE: 66 | index = path[-1:] 67 | print("reorder on delete not yet implemented") 68 | self.nodes[nid].remove(path) 69 | if len(self.nodes[nid]) == 0: 70 | self.nodes.pop(nid) 71 | 72 | self.paths.pop(path) 73 | 74 | # Move other paths lower like in real TreeModel 75 | path_prefix = path[:-1] 76 | index = path[-1] 77 | 78 | assert path_prefix + (index, ) == path, ( 79 | "%s vs %s" % (path_prefix + (index, ), path)) 80 | 81 | def check_prefix(path): 82 | """ Is this path affected by the change? 83 | Conditions: 84 | * the same prefix 85 | (3, 1, 2, 3) vs (3, 1, 2, 4) OK 86 | (3, 1, 2, 3) vs (3, 1, 2, 4, 0) OK 87 | (3, 1, 2, 3) vs (3, 2, 2, 4) FALSE 88 | * higher index 89 | (3, 1, 2, 3) vs (3, 1, 2, 2) FALSE 90 | """ 91 | if len(path) <= len(path_prefix): 92 | return False 93 | 94 | for i, pos in enumerate(path_prefix): 95 | if path[i] != pos: 96 | return False 97 | 98 | return path[len(path_prefix)] > index 99 | 100 | paths = list(self.paths.keys()) 101 | paths.sort() 102 | 103 | for path in paths: 104 | old_path = path 105 | if check_prefix(path) and len(path_prefix) > 1: 106 | new_path = list(path) 107 | print("new_path: %s" % str(new_path)) 108 | new_path[len(path_prefix)] = str(int( 109 | new_path[len(path_prefix)]) - 1) 110 | new_path = tuple(new_path) 111 | 112 | print("new_path: %s" % str(new_path)) 113 | print("self.paths: %s" % str(self.paths)) 114 | 115 | assert new_path not in self.paths 116 | 117 | nid = self.paths[old_path] 118 | self.nodes[nid].remove(old_path) 119 | del self.paths[old_path] 120 | self.nodes[nid].append(new_path) 121 | self.paths[new_path] = nid 122 | 123 | def update(self, nid, path): 124 | # Because of the asynchronousness of update, this test 125 | # doesn't work anymore 126 | pass 127 | # self.tree.flush() 128 | # self.trace += "updating %s in path %s\n" % (nid, str(path)) 129 | # error = "updating node %s for path %s\n" % (nid, str(path)) 130 | # if not self.nodes.has_key(nid): 131 | # error += "%s is not in nodes !\n" %nid 132 | # error += self.print_tree() 133 | # raise Exception(error) 134 | # # Nothing to do, we just update. 135 | # for p in self.nodes[nid]: 136 | # if self.paths[p] != nid: 137 | # raise Exception('Mismatching path for %s'%nid) 138 | # if not self.paths.has_key(path): 139 | # error += '%s is not in stored paths (node %s)\n'% ( 140 | # str(path), nid) 141 | # error += self.print_tree() 142 | # raise Exception(error) 143 | # n = self.paths[path] 144 | # if path not in self.nodes[n] or n != nid: 145 | # raise Exception('Mismatching node for path %s'%str(p)) 146 | 147 | def reordered(self, nid, path, neworder): 148 | self.trace += "reordering children of %s (%s) : %s\n" % ( 149 | nid, str(path), neworder) 150 | self.trace += "VR is %s\n" % self.tree.node_all_children() 151 | if not path: 152 | path = () 153 | i = 0 154 | newpaths = {} 155 | toremove = [] 156 | # we first update self.nodes with the new paths 157 | while i < len(neworder): 158 | if i != neworder[i]: 159 | old = neworder[i] 160 | oldp = path + (old, ) 161 | newp = path + (i, ) 162 | le = len(newp) 163 | for pp in list(self.paths.keys()): 164 | if pp[0:le] == oldp: 165 | n = self.paths[pp] 166 | self.nodes[n].remove(pp) 167 | newpp = newp + pp[le:] 168 | self.nodes[n].append(newpp) 169 | self.trace += " change %s path from %s to %s\n" % ( 170 | n, pp, newpp) 171 | newpaths[newpp] = n 172 | toremove.append(pp) 173 | i += 1 174 | # now we can update self.paths 175 | for p in toremove: 176 | self.paths.pop(p) 177 | for p in newpaths: 178 | self.trace += " adding %s to paths %s\n" % (newpaths[p], str(p)) 179 | self.paths[p] = newpaths[p] 180 | 181 | def test_validity(self): 182 | for n in list(self.nodes.keys()): 183 | paths = self.tree.get_paths_for_node(n) 184 | if len(self.nodes[n]) == 0: 185 | raise Exception('Node %s is stored without any path' % n) 186 | for p in self.nodes[n]: 187 | if self.paths[p] != n: 188 | raise Exception('Mismatching path for %s' % n) 189 | if p not in paths: 190 | error = 'we have a unknown stored path for %s\n' % n 191 | nn = self.tree.get_node_for_path(p) 192 | error += ' path %s is the path of %s\n' % ( 193 | str(p), str(nn)) 194 | error += ' parent is %s' % self.tree.get_node_for_path( 195 | p[:-1]) 196 | raise Exception(error) 197 | paths.remove(p) 198 | if len(paths) > 0: 199 | raise Exception('why is this path existing for %s' % n) 200 | for p in list(self.paths.keys()): 201 | node = self.tree.get_node_for_path(p) 202 | n = self.paths[p] 203 | if n != node: 204 | error = 'Node for path is %s but should be %s' % (node, n) 205 | raise Exception(error) 206 | if p not in self.nodes[n]: 207 | error = 'Mismatching node for path %s\n' % str(p) 208 | error += self.print_tree() 209 | raise Exception(error) 210 | if len(p) == 1 and len(self.nodes[n]) > 1: 211 | error = 'Node %s has multiple paths and is in the VR\n' % n 212 | error += self.print_tree() 213 | raise Exception(error) 214 | return True 215 | 216 | def print_tree(self): 217 | st = self.trace 218 | st += "nodes are %s\n" % self.nodes 219 | st += "paths are %s\n" % self.paths 220 | return st 221 | 222 | def quit(self): 223 | self.tree.deregister_cllbck('node-added-inview', self.add) 224 | self.tree.deregister_cllbck('node-deleted-inview', self.delete) 225 | self.tree.deregister_cllbck('node-modified-inview', self.update) 226 | self.tree.deregister_cllbck('node-children-reordered', self.reordered) 227 | -------------------------------------------------------------------------------- /liblarch/viewtree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | import functools 21 | 22 | from .filteredtree import FilteredTree 23 | 24 | 25 | # There should be two classes: for static and for dynamic mode 26 | # There are many conditions, and also we would prevent unallowed modes 27 | class ViewTree(object): 28 | def __init__(self, maininterface, maintree, filters_bank, 29 | name=None, refresh=True, static=False): 30 | """A ViewTree is the interface that should be used to display Tree(s). 31 | 32 | In static mode, FilteredTree layer is not created. 33 | (There is no need) 34 | 35 | We connect to MainTree or FilteredTree to get informed about 36 | changes. If FilteredTree is used, it is connected to MainTree 37 | to handle changes and then send id to ViewTree if it applies. 38 | 39 | @param maintree: a Tree object, containing all the nodes 40 | @param filters_bank: a FiltersBank object. Filters can be added 41 | dynamically to that. 42 | @param refresh: if True, this ViewTree is automatically refreshed 43 | after applying a filter. 44 | @param static: if True, this is the view of the complete maintree. 45 | Filters cannot be added to such a view. 46 | """ 47 | self.maininterface = maininterface 48 | self.__maintree = maintree 49 | self.__cllbcks = {} 50 | self.__fbank = filters_bank 51 | self.static = static 52 | 53 | if self.static: 54 | self._tree = self.__maintree 55 | self.__ft = None 56 | self.__maintree.register_callback( 57 | 'node-added', 58 | functools.partial(self.__emit, 'node-added')) 59 | self.__maintree.register_callback( 60 | 'node-deleted', 61 | functools.partial(self.__emit, 'node-deleted')) 62 | self.__maintree.register_callback( 63 | 'node-modified', 64 | functools.partial(self.__emit, 'node-modified')) 65 | else: 66 | self.__ft = FilteredTree( 67 | maintree, filters_bank, name=name, refresh=refresh) 68 | self._tree = self.__ft 69 | self.__ft.set_callback( 70 | 'added', 71 | functools.partial(self.__emit, 'node-added-inview')) 72 | self.__ft.set_callback( 73 | 'deleted', 74 | functools.partial(self.__emit, 'node-deleted-inview')) 75 | self.__ft.set_callback( 76 | 'modified', 77 | functools.partial(self.__emit, 'node-modified-inview')) 78 | self.__ft.set_callback( 79 | 'reordered', 80 | functools.partial(self.__emit, 'node-children-reordered')) 81 | 82 | def queue_action(self, node_id, func, param=None): 83 | self.__ft.set_callback('runonce', func, node_id=node_id, param=param) 84 | 85 | def get_basetree(self): 86 | """ Return Tree object """ 87 | return self.maininterface 88 | 89 | def register_cllbck(self, event, func): 90 | """ Store function and return unique key which can be used to 91 | unregister the callback later """ 92 | 93 | if event not in self.__cllbcks: 94 | self.__cllbcks[event] = {} 95 | 96 | callbacks = self.__cllbcks[event] 97 | key = 0 98 | while key in callbacks: 99 | key += 1 100 | 101 | callbacks[key] = func 102 | return key 103 | 104 | def deregister_cllbck(self, event, key): 105 | """ Remove the callback identifed by key (from register_cllbck) """ 106 | try: 107 | del self.__cllbcks[event][key] 108 | except KeyError: 109 | pass 110 | 111 | def __emit(self, event, node_id, path=None, neworder=None): 112 | """ Handle a new event from MainTree or FilteredTree 113 | by passing it to other objects, e.g. TreeWidget """ 114 | callbacks = dict(self.__cllbcks.get(event, {})) 115 | for func in callbacks.values(): 116 | if neworder: 117 | func(node_id, path, neworder) 118 | else: 119 | func(node_id, path) 120 | 121 | def get_node(self, node_id): 122 | """ Get a node from MainTree """ 123 | return self.__maintree.get_node(node_id) 124 | 125 | # FIXME Remove this method from public interface 126 | def get_root(self): 127 | return self.__maintree.get_root() 128 | 129 | # FIXME Remove this method from public interface 130 | def refresh_all(self): 131 | self.__maintree.refresh_all() 132 | 133 | def get_current_state(self): 134 | """ Request current state to be send by signals/callbacks. 135 | 136 | This allow LibLarch widget to connect on fly (e.g. after FilteredTree 137 | is up and has some nodes). """ 138 | 139 | if self.static: 140 | self.__maintree.refresh_all() 141 | else: 142 | self.__ft.get_current_state() 143 | 144 | def print_tree(self, string=None): 145 | """ Print the shown tree, i.e. MainTree or FilteredTree """ 146 | return self._tree.print_tree(string) 147 | 148 | def get_all_nodes(self): 149 | """ Return list of node_id of displayed nodes """ 150 | return self._tree.get_all_nodes() 151 | 152 | def get_n_nodes(self, withfilters=[]): 153 | """ Returns quantity of displayed nodes in this tree 154 | 155 | @withfilters => Additional filters are applied before counting, 156 | i.e. the currently applied filters are also taken into account 157 | """ 158 | 159 | if not self.__ft: 160 | self.__ft = FilteredTree( 161 | self.__maintree, self.__fbank, refresh=True) 162 | return self.__ft.get_n_nodes(withfilters=withfilters) 163 | 164 | def get_nodes(self, withfilters=[]): 165 | """ Returns displayed nodes in this tree 166 | 167 | @withfilters => Additional filters are applied before counting, 168 | i.e. the currently applied filters are also taken into account 169 | """ 170 | 171 | if not self.__ft: 172 | self.__ft = FilteredTree( 173 | self.__maintree, self.__fbank, refresh=True) 174 | return self.__ft.get_nodes(withfilters=withfilters) 175 | 176 | def get_node_for_path(self, path): 177 | """ Convert path to node_id. 178 | 179 | I am not sure what this is for... """ 180 | return self._tree.get_node_for_path(path) 181 | 182 | def get_paths_for_node(self, node_id=None): 183 | """ If node_id is none, return root path 184 | 185 | *Almost* reverse function to get_node_for_path 186 | (1 node can have many paths, 1:M) 187 | """ 188 | return self._tree.get_paths_for_node(node_id) 189 | 190 | # FIXME change pid => parent_id 191 | def next_node(self, node_id, pid=None): 192 | """ Return the next node to node_id. 193 | 194 | @parent_id => identify which instance of node_id to work. 195 | If None, random instance is used """ 196 | 197 | return self._tree.next_node(node_id, pid) 198 | 199 | def node_has_child(self, node_id): 200 | """ Has the node at least one child? """ 201 | if self.static: 202 | return self.__maintree.get_node(node_id).has_child() 203 | else: 204 | return self.__ft.node_has_child(node_id) 205 | 206 | def node_all_children(self, node_id=None): 207 | """ Return children of a node """ 208 | if self.static: 209 | if not node_id or self.__maintree.has_node(node_id): 210 | return self.__maintree.get_node(node_id).get_children() 211 | else: 212 | return [] 213 | else: 214 | return self._tree.node_all_children(node_id) 215 | 216 | def node_n_children(self, node_id=None, recursive=False): 217 | """ Return quantity of children of node_id. 218 | If node_id is None, use the root node. 219 | Every instance of node has the same children""" 220 | if not self.__ft: 221 | self.__ft = FilteredTree( 222 | self.__maintree, self.__fbank, refresh=True) 223 | return self.__ft.node_n_children(node_id, recursive) 224 | 225 | def node_nth_child(self, node_id, n): 226 | """ Return nth child of the node. """ 227 | if self.static: 228 | if not node_id or node_id == 'root': 229 | node = self.__maintree.get_root() 230 | else: 231 | node = self.__maintree.get_node(node_id) 232 | 233 | if node and node.get_n_children() > n: 234 | return node.get_nth_child(n) 235 | else: 236 | raise ValueError( 237 | "node {} has less than {} nodes".format(node_id, n)) 238 | else: 239 | realn = self.__ft.node_n_children(node_id) 240 | if realn <= n: 241 | raise ValueError( 242 | "viewtree has {} nodes, no node {}".format(realn, n)) 243 | return self.__ft.node_nth_child(node_id, n) 244 | 245 | def node_has_parent(self, node_id): 246 | """ Has node parents? Is it child of root? """ 247 | return len(self.node_parents(node_id)) > 0 248 | 249 | def node_parents(self, node_id): 250 | """ Returns displayed parents of the given node, or [] if there is no 251 | parent (such as if the node is a child of the virtual root), 252 | or if the parent is not displayable. 253 | Doesn't check whether node node_id is displayed or not. 254 | (we only care about parents) 255 | """ 256 | if self.static: 257 | return self.__maintree.get_node(node_id).get_parents() 258 | else: 259 | return self.__ft.node_parents(node_id) 260 | 261 | def is_displayed(self, node_id): 262 | """ Is the node displayed? """ 263 | if self.static: 264 | return self.__maintree.has_node(node_id) 265 | else: 266 | return self.__ft.is_displayed(node_id) 267 | 268 | # FILTERS ################################################################# 269 | def list_applied_filters(self): 270 | return self.__ft.list_applied_filters() 271 | 272 | def apply_filter(self, filter_name, parameters=None, 273 | reset=False, refresh=True): 274 | """ Applies a new filter to the tree. 275 | 276 | @param filter_name: The name of an already registered filter to apply 277 | @param parameters: Optional parameters to pass to the filter 278 | @param reset: optional boolean. Should we remove other filters? 279 | @param refresh : should we refresh after applying this filter ? 280 | """ 281 | if self.static: 282 | raise Exception("WARNING: filters cannot be applied" 283 | "to a static tree\n") 284 | 285 | self.__ft.apply_filter(filter_name, parameters, reset, refresh) 286 | 287 | def unapply_filter(self, filter_name, refresh=True): 288 | """ Removes a filter from the tree. 289 | 290 | @param filter_name: The name of filter to remove 291 | """ 292 | if self.static: 293 | raise Exception("WARNING: filters cannot be unapplied" 294 | "from a static tree\n") 295 | 296 | self.__ft.unapply_filter(filter_name, refresh) 297 | 298 | def reset_filters(self, refresh=True): 299 | """ Remove all filters currently set on the tree. """ 300 | if self.static: 301 | raise Exception("WARNING: filters cannot be reset" 302 | "on a static tree\n") 303 | else: 304 | self.__ft.reset_filters(refresh) 305 | -------------------------------------------------------------------------------- /examples/contact_list/contact_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # ----------------------------------------------------------------------------- 4 | # Liblarch - a library to handle directed acyclic graphs 5 | # Copyright (c) 2011-2015 - Lionel Dricot & Izidor Matušov 6 | # 7 | # This program is free software: you can redistribute it and/or modify it under 8 | # the terms of the GNU Lesser General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) any 10 | # later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but WITHOUT 13 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 14 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 15 | # details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with this program. If not, see . 19 | # ----------------------------------------------------------------------------- 20 | 21 | # The following code is an example that build a GTK contact-list with liblarch 22 | # If you have some basic PyGTK experience, the code should be straightforward. 23 | 24 | from gi.repository import Gtk, GObject 25 | import cairo 26 | import sys 27 | 28 | # First, we import this liblarch 29 | try: 30 | sys.path.append("../../../liblarch") 31 | from liblarch import Tree, TreeNode 32 | from liblarch_gtk import TreeView 33 | except ImportError: 34 | raise 35 | 36 | # The contacts (this is a static list for the purpose of the example) 37 | # We have the following: 38 | # 1: XMPP address 39 | # 2: status: online, busy or offline 40 | # 3: Name or nickname 41 | # 4: The teams in which the contact is 42 | CONTACTS = [ 43 | { 44 | 'xmpp': 'me@myself.com', 'name': 'Myself', 45 | 'status': 'online', 'teams': [], 46 | }, 47 | { 48 | 'xmpp': 'ploum@gtg.net', 'name': 'Lionel', 49 | 'status': 'online', 'teams': ['gtg', 'gnome'], 50 | }, 51 | { 52 | 'xmpp': 'izidor@gtg.net', 'name': 'Izidor', 53 | 'status': 'busy', 'teams': ['gtg', 'gnome'], 54 | }, 55 | { 56 | 'xmpp': 'bertrand@gtg.net', 'name': 'Bertrand', 57 | 'status': 'offline', 'teams': ['gtg', 'on holidays'], 58 | }, 59 | { 60 | 'xmpp': 'joe@dalton.com', 'name': 'Joe Dalton', 61 | 'status': 'busy', 'teams': ['daltons'], 62 | }, 63 | { 64 | 'xmpp': 'jack@dalton.com', 'name': 'Jack Dalton', 65 | 'status': 'offline', 'teams': ['daltons'], 66 | }, 67 | { 68 | 'xmpp': 'william@dalton.com', 'name': 'William Dalton', 69 | 'status': 'offline', 'teams': ['daltons', 'on holidays'], 70 | }, 71 | { 72 | 'xmpp': 'averell@dalton.com', 'name': 'Averell Dalton', 73 | 'status': 'online', 'teams': ['daltons'], 74 | }, 75 | { 76 | 'xmpp': 'guillaume@gnome.org', 'name': 'Guillaume (Ploum)', 77 | 'status': 'busy', 'teams': ['gnome'], 78 | }, 79 | { 80 | 'xmpp': 'xavier@gnome.org', 'name': 'Navier', 81 | 'status': 'busy', 'teams': ['gnome'], 82 | }, 83 | { 84 | 'xmpp': 'vincent@gnome.org', 'name': 'Nice Hat', 85 | 'status': 'busy', 'teams': ['gnome'], 86 | }, 87 | ] 88 | 89 | 90 | class NodeContact(TreeNode): 91 | """ This is the "Contact" object. """ 92 | def __init__(self, node_id): 93 | self.status = "online" 94 | self.nick = "" 95 | TreeNode.__init__(self, node_id) 96 | # A contact cannot have children node. We disable that 97 | self.set_children_enabled(False) 98 | 99 | def get_type(self): 100 | return "contact" 101 | 102 | def set_status(self, status): 103 | self.status = status 104 | self.modified() 105 | 106 | def get_status(self): 107 | return self.status 108 | 109 | def set_nick(self, nick): 110 | self.nick = nick 111 | self.modified() 112 | 113 | def get_nick(self): 114 | return self.nick 115 | 116 | def get_label(self): 117 | """ The label is: 118 | - the nickname in bold 119 | - XMPP address (small) 120 | """ 121 | if self.status == "offline": 122 | label = "%s" % self.nick 123 | else: 124 | label = "%s" % self.nick 125 | label += " (%s)" % ( 126 | self.get_id()) 127 | return label 128 | 129 | 130 | class NodeTeam(TreeNode): 131 | """ Each team is also a node """ 132 | def __init__(self, node_id): 133 | TreeNode.__init__(self, node_id) 134 | # A team cannot have parents. This is arbitrarily done for the purpose 135 | # of this example. 136 | self.set_parents_enabled(False) 137 | 138 | def get_type(self): 139 | return "team" 140 | 141 | def get_label(self): 142 | return self.get_id() 143 | 144 | def get_status(self): 145 | return None 146 | 147 | 148 | class ContactListWindow(object): 149 | 150 | def __init__(self): 151 | # First we do all the GTK stuff 152 | # This is not interesting from a liblarch perspective 153 | self.window = Gtk.Window() 154 | self.window.set_size_request(300, 600) 155 | self.window.set_border_width(12) 156 | self.window.set_title('Liblarch contact-list') 157 | self.window.connect('destroy', self.quit) 158 | vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 159 | vbox.set_spacing(6) 160 | # A check button to show/hide offline contacts 161 | show_offline = Gtk.CheckButton("Show offline contacts") 162 | show_offline.connect("toggled", self.show_offline_contacts) 163 | vbox.pack_start(show_offline, expand=False, fill=True, padding=0) 164 | # The search through contacts 165 | search = Gtk.Entry() 166 | search.set_icon_from_icon_name(0, "search") 167 | search.get_buffer().connect("inserted-text", self.search) 168 | search.get_buffer().connect("deleted-text", self.search) 169 | vbox.pack_start(search, expand=False, fill=True, padding=0) 170 | # The contact list, build with liblarch 171 | scrolled_window = Gtk.ScrolledWindow() 172 | scrolled_window.add_with_viewport(self.make_contact_list()) 173 | scrolled_window.set_policy( 174 | Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 175 | vbox.pack_start(scrolled_window, True, True, 0) 176 | # Status 177 | box = Gtk.ComboBoxText() 178 | box.append_text("Online") 179 | box.append_text("Busy") 180 | box.append_text("Offline") 181 | box.set_active(0) 182 | box.connect('changed', self.status_changed) 183 | vbox.pack_start(box, expand=False, fill=True, padding=0) 184 | self.window.add(vbox) 185 | self.window.show_all() 186 | 187 | # This is the interesting part on how we use liblarch 188 | def make_contact_list(self): 189 | 190 | # LIBLARCH TREE CONSTRUCTION 191 | # First thing, we create a liblarch tree 192 | self.tree = Tree() 193 | # Now, we add each contact *and* each team as nodes of that tree. 194 | # The team will be the parents of the contact nodes. 195 | for contact in CONTACTS: 196 | # We create the node and use the XMPP address as the node_id 197 | node = NodeContact(contact['xmpp']) 198 | # We add the status and the nickname 199 | node.set_status(contact['status']) 200 | node.set_nick(contact['name']) 201 | # The contact node is added to the tree 202 | self.tree.add_node(node) 203 | # Now, we create the team if it was not done before 204 | for team_name in contact['teams']: 205 | if not self.tree.has_node(team_name): 206 | team_node = NodeTeam(team_name) 207 | self.tree.add_node(team_node) 208 | # now we put the contact under the team 209 | node.add_parent(team_name) 210 | # we could also have done 211 | # team_node.add_child(contact[0]) 212 | 213 | # LIBLARCH VIEW and FILTER 214 | # Ok, now we have our liblarch tree. What we need is a view. 215 | self.view = self.tree.get_viewtree() 216 | # We also create a filter that will allow us to hide offline people 217 | self.tree.add_filter("online", self.is_node_online) 218 | self.offline = False 219 | self.tree.add_filter("search", self.search_filter) 220 | # And we apply this filter by default 221 | self.view.apply_filter("online") 222 | 223 | # LIBLARCH GTK.TreeView 224 | # And, now, we build our Gtk.TreeView 225 | # We will build each column of our TreeView 226 | columns = {} 227 | # The first column contain the XMPP address but will be hidden 228 | # But it is still useful for searching 229 | col = {} 230 | col['value'] = [str, lambda node: node.get_id()] 231 | col['visible'] = False 232 | col['order'] = 0 233 | columns['XMPP'] = col 234 | # The second column is the status 235 | col = {} 236 | render_tags = CellRendererTags() 237 | render_tags.set_property('xalign', 0.0) 238 | col['renderer'] = ['status', render_tags] 239 | col['value'] = [GObject.TYPE_PYOBJECT, lambda node: node.get_status()] 240 | col['expandable'] = False 241 | col['resizable'] = False 242 | col['order'] = 1 243 | columns['status'] = col 244 | # the third column is the nickname 245 | col = {} 246 | col['value'] = [str, lambda node: node.get_label()] 247 | col['visible'] = True 248 | col['order'] = 2 249 | columns['nick'] = col 250 | 251 | return TreeView(self.view, columns) 252 | 253 | # This is the "online" filter. 254 | # It returns the contacts that are busy or online 255 | # and teams that have at least one contact displayed 256 | def is_node_online(self, node): 257 | if node.get_type() == "contact": 258 | # Always show myself 259 | if node.get_id() == 'me@myself.com': 260 | return True 261 | status = node.get_status() 262 | if status == "online" or status == "busy": 263 | return True 264 | else: 265 | return False 266 | # For the team, we test each contact of that team 267 | elif node.get_type() == "team": 268 | tree = node.get_tree() 269 | for child_id in node.get_children(): 270 | child = tree.get_node(child_id) 271 | status = child.get_status() 272 | if status == "online" or status == "busy": 273 | return True 274 | return False 275 | return True 276 | 277 | def show_offline_contacts(self, widget): 278 | # We should remove the filter to show offline contacts 279 | if widget.get_active(): 280 | self.view.unapply_filter('online') 281 | self.offline = True 282 | # else we apply the "online" filter, showing only online/busy people 283 | else: 284 | self.view.apply_filter('online') 285 | self.offline = False 286 | 287 | def status_changed(self, widget): 288 | new = widget.get_active_text() 289 | node = self.tree.get_node('me@myself.com') 290 | if new == 'Busy': 291 | node.set_status('busy') 292 | elif new == 'Offline': 293 | node.set_status('offline') 294 | else: 295 | node.set_status('online') 296 | 297 | def search(self, widget, position, char, nchar=None): 298 | search_string = widget.get_text() 299 | if len(search_string) > 0: 300 | # First, we remove the old filter 301 | # Note the "refresh=False", because we know we will apply another 302 | # filter just afterwards 303 | # We also remove the online filter to search through offline 304 | # contacts 305 | if not self.offline: 306 | self.view.unapply_filter('online', refresh=False) 307 | self.view.unapply_filter('search', refresh=False) 308 | self.view.apply_filter( 309 | 'search', parameters={'search': search_string}) 310 | else: 311 | if not self.offline: 312 | self.view.apply_filter('online') 313 | self.view.unapply_filter('search') 314 | 315 | def search_filter(self, node, parameters=None): 316 | string = parameters['search'] 317 | if node.get_type() == "contact": 318 | if string in node.get_id() or string in node.get_nick(): 319 | return True 320 | else: 321 | return False 322 | else: 323 | return False 324 | 325 | def quit(self, widget): 326 | Gtk.main_quit() 327 | 328 | 329 | class CellRendererTags(Gtk.CellRenderer): 330 | """ Custom CellRenderer that will make a coloured circle 331 | 332 | This is absolutely not needed for liblarch. The purpose of using it is 333 | to show that liblarch works with complex cellrenderer too. 334 | """ 335 | __gproperties__ = { 336 | 'status': ( 337 | GObject.TYPE_PYOBJECT, "Status", 338 | "Status", GObject.PARAM_READWRITE, 339 | ), 340 | } 341 | 342 | def __init__(self): 343 | super(CellRendererTags, self).__init__() 344 | self.status = None 345 | self.xpad = 1 346 | self.ypad = 1 347 | self.PADDING = 1 348 | 349 | def do_set_property(self, pspec, value): 350 | if pspec.name == "status": 351 | self.status = value 352 | else: 353 | setattr(self, pspec.name, value) 354 | 355 | def do_get_property(self, pspec): 356 | if pspec.name == "status": 357 | return self.status 358 | else: 359 | return getattr(self, pspec.name) 360 | 361 | def do_render(self, cr, widget, background_area, cell_area, flags): 362 | cr.set_antialias(cairo.ANTIALIAS_NONE) 363 | # Coordinates of the origin point 364 | y_align = self.get_property("yalign") 365 | rect_x = cell_area.x 366 | rect_y = cell_area.y + int((cell_area.height - 16) * y_align) 367 | colours = { 368 | "online": (0.059, 0.867, 0.157), 369 | "busy": (0.910, 0.067, 0.063), 370 | "offline": (0.467, 0.467, 0.467), 371 | } 372 | if self.status: 373 | color = colours[self.status] 374 | # Draw circle 375 | radius = 7 376 | cr.set_source_rgb(*color) 377 | cr.arc(rect_x, rect_y + 8, radius, 90, 180) 378 | cr.fill() 379 | 380 | # Outer line 381 | cr.set_source_rgba(0, 0, 0, 0.20) 382 | cr.set_line_width(1.0) 383 | cr.arc(rect_x, rect_y + 8, radius, 90, 180) 384 | cr.stroke() 385 | 386 | def do_get_size(self, widget, cell_area=None): 387 | if self.status: 388 | return ( 389 | self.xpad, 390 | self.ypad, 391 | self.xpad * 2 + 16 + 2 * self.PADDING, 392 | self.ypad * 2 + 16, 393 | ) 394 | else: 395 | return (0, 0, 0, 0) 396 | 397 | 398 | GObject.type_register(CellRendererTags) 399 | 400 | 401 | if __name__ == "__main__": 402 | # We launch the GTK main_loop 403 | ContactListWindow() 404 | Gtk.main() 405 | -------------------------------------------------------------------------------- /liblarch/tree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | from . import processqueue 21 | from .treenode import _Node 22 | 23 | 24 | class MainTree(object): 25 | """ Tree which stores and handle all requests """ 26 | 27 | def __init__(self): 28 | """ Initialize MainTree. 29 | 30 | @param root - the "root" node which contains all nodes 31 | """ 32 | 33 | self.nodes = {} 34 | self.pending_relationships = [] 35 | 36 | self.__cllbcks = {} 37 | 38 | self.root_id = 'root' 39 | self.root = _Node(self.root_id) 40 | _Node._set_tree(self.root, self) 41 | 42 | self._queue = processqueue.SyncQueue() 43 | 44 | def __str__(self): 45 | return "" % self.root 46 | 47 | def get_root(self): 48 | """ Return root node """ 49 | return self.root 50 | 51 | # INTERFACE FOR CALLBACKS ################################################# 52 | def register_callback(self, event, func): 53 | """ Store function and return unique key which can be used to 54 | unregister the callback later """ 55 | 56 | if event not in self.__cllbcks: 57 | self.__cllbcks[event] = {} 58 | 59 | callbacks = self.__cllbcks[event] 60 | key = 0 61 | while key in callbacks: 62 | key += 1 63 | 64 | callbacks[key] = func 65 | return key 66 | 67 | def deregister_callback(self, event, key): 68 | """ Remove the callback identifed by key (from register_cllbck) """ 69 | try: 70 | del self.__cllbcks[event][key] 71 | except KeyError: 72 | pass 73 | 74 | def _callback(self, event, node_id): 75 | """ Inform others about the event """ 76 | # We copy the dict to not loop on it while it could be modified 77 | dic = dict(self.__cllbcks.get(event, {})) 78 | for func in dic.values(): 79 | func(node_id) 80 | 81 | # INTERFACE FOR HANDLING REQUESTS ######################################### 82 | def add_node(self, node, parent_id=None, priority="low"): 83 | self._queue.push(self._add_node, node, parent_id, priority=priority) 84 | 85 | def remove_node(self, node_id, recursive=False): 86 | self._queue.push(self._remove_node, node_id, recursive) 87 | 88 | def modify_node(self, node_id, priority="low"): 89 | self._queue.push(self._modify_node, node_id, priority=priority) 90 | 91 | def new_relationship(self, parent_id, child_id): 92 | self._queue.push(self._new_relationship, parent_id, child_id) 93 | 94 | def break_relationship(self, parent_id, child_id): 95 | self._queue.push(self._break_relationship, parent_id, child_id) 96 | 97 | def refresh_all(self): 98 | """ Refresh all nodes """ 99 | for node_id in list(self.nodes.keys()): 100 | self.modify_node(node_id) 101 | 102 | # IMPLEMENTATION OF HANDLING REQUESTS ##################################### 103 | def _create_relationship(self, parent_id, child_id): 104 | """ Create relationship without any checks """ 105 | parent = self.nodes[parent_id] 106 | child = self.nodes[child_id] 107 | 108 | if child_id not in parent.children: 109 | parent.children.append(child_id) 110 | 111 | if parent_id not in child.parents: 112 | child.parents.append(parent_id) 113 | 114 | if child_id in self.root.children: 115 | self.root.children.remove(child_id) 116 | 117 | def _destroy_relationship(self, parent_id, child_id): 118 | """ Destroy relationship without any checks """ 119 | parent = self.nodes[parent_id] 120 | child = self.nodes[child_id] 121 | 122 | if child_id in parent.children: 123 | parent.children.remove(child_id) 124 | 125 | if parent_id in child.parents: 126 | child.parents.remove(parent_id) 127 | 128 | def _is_circular_relation(self, parent_id, child_id): 129 | """ Would the new relation be circular? 130 | 131 | Go over every possible ancestors. If one of them is child_id, 132 | this would be circular relation. 133 | """ 134 | 135 | visited = [] 136 | ancestors = [parent_id] 137 | while ancestors != []: 138 | node_id = ancestors.pop(0) 139 | if node_id == child_id: 140 | return True 141 | 142 | if node_id not in self.nodes: 143 | continue 144 | 145 | for ancestor_id in self.nodes[node_id].parents: 146 | if ancestor_id not in visited: 147 | ancestors.append(ancestor_id) 148 | 149 | return False 150 | 151 | def _add_node(self, node, parent_id): 152 | """ Add a node to the tree 153 | 154 | @param node - node to be added 155 | @param parent_id - parent to add or it will be add to root 156 | """ 157 | node_id = node.get_id() 158 | if node_id in self.nodes: 159 | print("Error: Node '%s' already exists" % node_id) 160 | return False 161 | 162 | _Node._set_tree(node, self) 163 | for relationship in node.pending_relationships: 164 | if relationship not in self.pending_relationships: 165 | self.pending_relationships.append(relationship) 166 | node.pending_relationships = [] 167 | 168 | self.nodes[node_id] = node 169 | 170 | add_to_root = True 171 | parents_to_refresh = [] 172 | children_to_refresh = [] 173 | 174 | # Build pending relationships 175 | for rel_parent_id, rel_child_id in list(self.pending_relationships): 176 | # Adding as a child 177 | if rel_child_id == node_id and rel_parent_id in self.nodes: 178 | if not self._is_circular_relation(rel_parent_id, node_id): 179 | self._create_relationship(rel_parent_id, node_id) 180 | add_to_root = False 181 | parents_to_refresh.append(rel_parent_id) 182 | else: 183 | print("Error: Detected pending circular relationship", 184 | rel_parent_id, rel_child_id) 185 | self.pending_relationships.remove( 186 | (rel_parent_id, rel_child_id)) 187 | 188 | # Adding as a parent 189 | if rel_parent_id == node_id and rel_child_id in self.nodes: 190 | if not self._is_circular_relation(node_id, rel_child_id): 191 | self._create_relationship(node_id, rel_child_id) 192 | children_to_refresh.append(rel_child_id) 193 | else: 194 | print("Error: Detected pending circular relationship", 195 | rel_parent_id, rel_child_id) 196 | self.pending_relationships.remove((rel_parent_id, rel_child_id)) 197 | 198 | # Build relationship with given parent 199 | if parent_id is not None: 200 | if self._is_circular_relation(parent_id, node_id): 201 | raise Exception( 202 | 'Creating circular relationship between {} and {}'.format( 203 | parent_id, node_id)) 204 | if parent_id in self.nodes: 205 | self._create_relationship(parent_id, node_id) 206 | add_to_root = False 207 | parents_to_refresh.append(parent_id) 208 | else: 209 | self.pending_relationships.append((parent_id, node_id)) 210 | 211 | # Add at least to root 212 | if add_to_root: 213 | self.root.children.append(node_id) 214 | 215 | # Send callbacks 216 | # updating the parent and the children is handled by the FT 217 | self._callback("node-added", node_id) 218 | 219 | def _remove_node(self, node_id, recursive=False): 220 | """ Remove node from tree """ 221 | 222 | if node_id not in self.nodes: 223 | print("*** Warning *** Trying to remove a non-existing node") 224 | return 225 | 226 | # Do not remove root node 227 | if node_id is None: 228 | return 229 | 230 | # Remove pending relationships with this node 231 | for relation in list(self.pending_relationships): 232 | if node_id in relation: 233 | self.pending_relationships.remove(relation) 234 | 235 | node = self.nodes[node_id] 236 | 237 | # Handle parents 238 | for parent_id in node.parents: 239 | self._destroy_relationship(parent_id, node_id) 240 | self._callback('node-modified', parent_id) 241 | 242 | # Handle children 243 | for child_id in list(node.children): 244 | if recursive: 245 | self._remove_node(child_id, True) 246 | else: 247 | self._destroy_relationship(node_id, child_id) 248 | self._callback('node-modified', child_id) 249 | if self.nodes[child_id].parents == []: 250 | self.root.children.append(child_id) 251 | 252 | if node_id in self.root.children: 253 | self.root.children.remove(node_id) 254 | 255 | self.nodes.pop(node_id) 256 | self._callback('node-deleted', node_id) 257 | 258 | def _modify_node(self, node_id): 259 | """ Force update of a node """ 260 | if node_id != self.root_id and node_id in self.nodes: 261 | self._callback('node-modified', node_id) 262 | 263 | def _new_relationship(self, parent_id, child_id): 264 | """ Creates a new relationship 265 | 266 | This method is used mainly from TreeNode""" 267 | 268 | if (parent_id, child_id) in self.pending_relationships: 269 | self.pending_relationships.remove((parent_id, child_id)) 270 | 271 | if not parent_id or not child_id or parent_id == child_id: 272 | return False 273 | 274 | if parent_id not in self.nodes or child_id not in self.nodes: 275 | self.pending_relationships.append((parent_id, child_id)) 276 | return True 277 | 278 | if self._is_circular_relation(parent_id, child_id): 279 | self._destroy_relationship(parent_id, child_id) 280 | raise Exception( 281 | 'Cannot build circular relationship between {} and {}'.format( 282 | parent_id, child_id)) 283 | 284 | self._create_relationship(parent_id, child_id) 285 | 286 | # Remove from root when having a new relationship 287 | if child_id in self.root.children: 288 | self.root.children.remove(child_id) 289 | 290 | self._callback('node-modified', parent_id) 291 | self._callback('node-modified', child_id) 292 | 293 | def _break_relationship(self, parent_id, child_id): 294 | """ Remove a relationship 295 | 296 | This method is used mainly from TreeNode """ 297 | for rel_parent, rel_child in list(self.pending_relationships): 298 | if rel_parent == parent_id and rel_child == child_id: 299 | self.pending_relationships.remove((rel_parent, rel_child)) 300 | 301 | if not parent_id or not child_id or parent_id == child_id: 302 | return False 303 | 304 | if parent_id not in self.nodes or child_id not in self.nodes: 305 | return False 306 | 307 | self._destroy_relationship(parent_id, child_id) 308 | 309 | # Move to root if beak the last parent 310 | if self.nodes[child_id].get_parents() == []: 311 | self.root.add_child(child_id) 312 | 313 | self._callback('node-modified', parent_id) 314 | self._callback('node-modified', child_id) 315 | 316 | # INTERFACE FOR READING STATE OF TREE ##################################### 317 | def has_node(self, node_id): 318 | """ Is this node_id in this tree? """ 319 | return node_id in self.nodes 320 | 321 | def get_node(self, node_id=None): 322 | """ Return node of tree or root node of this tree """ 323 | if node_id in self.nodes: 324 | return self.nodes[node_id] 325 | elif node_id == self.root_id or node_id is None: 326 | return self.root 327 | else: 328 | raise ValueError("Node %s is not in the tree" % node_id) 329 | 330 | def get_node_for_path(self, path): 331 | """ Convert path into node_id 332 | 333 | @return node_id if path is valid, None otherwise 334 | """ 335 | if not path or path == (): 336 | return None 337 | node_id = path[-1] 338 | if path in self.get_paths_for_node(node_id): 339 | return node_id 340 | else: 341 | return None 342 | return node_id 343 | 344 | def get_paths_for_node(self, node_id): 345 | """ Get all paths for node_id """ 346 | if not node_id or node_id == self.root_id: 347 | return [()] 348 | elif node_id in self.nodes: 349 | node = self.nodes[node_id] 350 | if node.has_parent(): 351 | paths = [] 352 | for parent_id in node.get_parents(): 353 | if parent_id not in self.nodes: 354 | continue 355 | for path in self.get_paths_for_node(parent_id): 356 | paths.append(path + (node_id, )) 357 | return paths 358 | else: 359 | return [(node_id, )] 360 | else: 361 | raise ValueError("Cannot get path for non existing node {}".format( 362 | node_id)) 363 | 364 | def get_all_nodes(self): 365 | """ Return list of all nodes in this tree """ 366 | return list(self.nodes.keys()) 367 | 368 | def next_node(self, node_id, parent_id=None): 369 | """ Return the next sibling node or None if there is none 370 | 371 | @param node_id - we look for siblings of this node 372 | @param parent_id - specify which siblings should be used, 373 | if task has more parents. If None, random parent will be used 374 | """ 375 | if node_id is None: 376 | raise ValueError('node_id should be different than None') 377 | 378 | node = self.get_node(node_id) 379 | parents_id = node.get_parents() 380 | if len(parents_id) == 0: 381 | parid = self.root_id 382 | elif parent_id in parents_id: 383 | parid = parent_id 384 | else: 385 | parid = parents_id[0] 386 | 387 | parent = self.get_node(parid) 388 | if not parent: 389 | raise ValueError('Parent does not exist') 390 | 391 | index = parent.get_child_index(node_id) 392 | if index is None: 393 | error = 'children are : {}\n'.format(parent.get_children()) 394 | error += 'node {} is not a child of {}'.format(node_id, parid) 395 | raise IndexError(error) 396 | 397 | if parent.get_n_children() > index + 1: 398 | return parent.get_nth_child(index + 1) 399 | else: 400 | return None 401 | 402 | def print_tree(self, string=False): 403 | output = self.root_id + "\n" 404 | stack = [(" ", child_id) for child_id in reversed(self.root.children)] 405 | 406 | while stack != []: 407 | prefix, node_id = stack.pop() 408 | output += prefix + node_id + "\n" 409 | prefix += " " 410 | for child_id in reversed(self.nodes[node_id].get_children()): 411 | stack.append((prefix, child_id)) 412 | 413 | if string: 414 | return output 415 | else: 416 | print(output, end=' ') 417 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # ----------------------------------------------------------------------------- 4 | # Liblarch - a library to handle directed acyclic graphs 5 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 6 | # 7 | # This program is free software: you can redistribute it and/or modify it under 8 | # the terms of the GNU Lesser General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) any 10 | # later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but WITHOUT 13 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 14 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 15 | # details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with this program. If not, see . 19 | # ----------------------------------------------------------------------------- 20 | from random import randint, choice, shuffle 21 | from time import sleep, time 22 | import logging 23 | import os 24 | import re 25 | import sys 26 | import threading 27 | 28 | from liblarch import Tree 29 | from liblarch import TreeNode 30 | from liblarch_gtk import TreeView 31 | 32 | from gi.repository import Gtk 33 | from gi.repository import GObject 34 | 35 | # Set up logging 36 | logging.basicConfig(level=logging.INFO) 37 | 38 | # Constants 39 | LOAD_MANY_TASKS_COUNT = 1000 40 | ADD_MANY_TASKS_TO_EXISTING_TASKS = True 41 | SLEEP_BETWEEN_TASKS = 0 42 | 43 | # Useful for experimenting with the tree 44 | BACKUP_OPERATIONS = False 45 | 46 | 47 | def random_task_title_on_id(t_id): 48 | try: 49 | return 'Task %5d' % int(t_id) 50 | except ValueError: 51 | return 'Task %5s' % t_id 52 | 53 | 54 | # Generate title in different ways 55 | random_task_title = random_task_title_on_id 56 | 57 | MAX_FILE_ID = 0 58 | 59 | 60 | def save_backup(function): 61 | def _save_backup(*args, **kwargs): 62 | global MAX_FILE_ID 63 | 64 | self = args[0] 65 | 66 | file_name = "operation_%03d.bak" % MAX_FILE_ID 67 | while os.path.exists(file_name): 68 | MAX_FILE_ID += 1 69 | file_name = "operation_%03d.bak" % MAX_FILE_ID 70 | 71 | stdout = sys.stdout 72 | stderr = sys.stderr 73 | output = open(file_name, "w", 0) 74 | 75 | sys.stdout = output 76 | sys.stderr = output 77 | 78 | print("Tree before operation") 79 | self.print_tree() 80 | print("\nOperation '%s':" % (function.__name__)) 81 | 82 | res = function(*args, **kwargs) 83 | 84 | print("Tree after operation") 85 | self.print_tree() 86 | 87 | sys.stdout = stdout 88 | sys.stderr = stderr 89 | 90 | output.close() 91 | 92 | # Print the log 93 | output = open(file_name, "r") 94 | print(output.read()) 95 | output.close() 96 | 97 | return res 98 | 99 | if BACKUP_OPERATIONS: 100 | return _save_backup 101 | else: 102 | return function 103 | 104 | 105 | MAX_ID = 0 106 | 107 | 108 | def random_id(): 109 | global MAX_ID 110 | 111 | MAX_ID += 1 112 | return str(MAX_ID) 113 | 114 | 115 | class TaskNode(TreeNode): 116 | def __init__(self, tid, label, viewtree): 117 | TreeNode.__init__(self, tid) 118 | self.label = label 119 | self.tid = tid 120 | self.vt = viewtree 121 | 122 | def get_label(self): 123 | return "%s (%s children)" % ( 124 | self.label, self.vt.node_n_children(self.tid, recursive=True)) 125 | 126 | 127 | class Backend(threading.Thread): 128 | def __init__(self, backend_id, finish_event, delay, tree, viewtree): 129 | super().__init__() 130 | 131 | self.backend_id = backend_id 132 | self.delay = delay 133 | self.tree = tree 134 | self.viewtree = viewtree 135 | self.finish_event = finish_event 136 | 137 | def run(self): 138 | counter = 0 139 | parent_id = None 140 | while not self.finish_event.wait(self.delay): 141 | task_id = self.backend_id + "_" + str(counter) 142 | title = task_id 143 | node = TaskNode(task_id, title, self.viewtree) 144 | self.tree.add_node(node, parent_id, self.tree) 145 | parent_id = task_id 146 | 147 | # Delete some tasks 148 | for i in range(randint(3, 10)): 149 | delete_id = "{}sec_{}".format( 150 | choice([1, 3, 5]), randint(0, 2 * counter)) 151 | logging.info("%s deleting %s", self.backend_id, delete_id) 152 | self.tree.del_node(delete_id) 153 | counter += 1 154 | 155 | logging.info("%s --- finish", self.backend_id) 156 | 157 | 158 | class LiblarchDemo(object): 159 | """ Shows a simple GUI demo of liblarch usage 160 | with several functions for adding tasks """ 161 | 162 | def _build_tree_view(self): 163 | self.tree = Tree() 164 | self.tree.add_filter("even", self.even_filter) 165 | self.tree.add_filter("odd", self.odd_filter) 166 | self.tree.add_filter("flat", self.flat_filter, {"flat": True}) 167 | self.tree.add_filter("leaf", self.leaf_filter) 168 | self.view_tree = self.tree.get_viewtree() 169 | self.mod_counter = 0 170 | 171 | self.view_tree.register_cllbck( 172 | 'node-added-inview', self._update_title) 173 | self.view_tree.register_cllbck( 174 | 'node-modified-inview', self._modified_count) 175 | self.view_tree.register_cllbck( 176 | 'node-deleted-inview', self._update_title) 177 | 178 | desc = {} 179 | 180 | col_name = 'label' 181 | col = {} 182 | col['title'] = "Title" 183 | col['value'] = [str, self.task_label_column] 184 | col['expandable'] = True 185 | col['resizable'] = True 186 | col['sorting'] = 'label' 187 | col['order'] = 0 188 | desc[col_name] = col 189 | 190 | tree_view = TreeView(self.view_tree, desc) 191 | 192 | # Polish TreeView 193 | def on_row_activate(sender, a, b): 194 | logging.info( 195 | "Selected nodes are: %s", str(tree_view.get_selected_nodes())) 196 | 197 | tree_view.set_dnd_name('liblarch-demo/liblarch_widget') 198 | tree_view.set_multiple_selection(True) 199 | 200 | tree_view.set_property("enable-tree-lines", True) 201 | tree_view.connect('row-activated', on_row_activate) 202 | 203 | return tree_view 204 | 205 | def even_filter(self, node): 206 | if node.get_id().isdigit(): 207 | return int(node.get_id()) % 2 == 0 208 | else: 209 | return False 210 | 211 | def odd_filter(self, node): 212 | return not self.even_filter(node) 213 | 214 | def flat_filter(self, node, parameters=None): 215 | return True 216 | 217 | def leaf_filter(self, node): 218 | return not node.has_child() 219 | 220 | def _modified_count(self, nid, path): 221 | logging.debug("Node %s has been modified", nid) 222 | self.mod_counter += 1 223 | 224 | def _update_title(self, sender, nid): 225 | count = self.view_tree.get_n_nodes() 226 | if count == LOAD_MANY_TASKS_COUNT and self.start_time > 0: 227 | stop_time = time() - self.start_time 228 | logging.info( 229 | "Time to load %s tasks: %s", LOAD_MANY_TASKS_COUNT, stop_time) 230 | mean = self.mod_counter * 1.0 / count 231 | logging.info( 232 | "%s modified signals were received (%s per task)", 233 | self.mod_counter, mean) 234 | self.window.set_title('Liblarch demo: %s nodes' % count) 235 | 236 | def __init__(self): 237 | self.window = Gtk.Window() 238 | self.window.set_size_request(640, 480) 239 | self.window.set_position(Gtk.WindowPosition.CENTER) 240 | self.window.set_border_width(10) 241 | self.window.set_title('Liblarch demo') 242 | self.window.connect('destroy', self.finish) 243 | 244 | self.liblarch_widget = self._build_tree_view() 245 | scrolled_window = Gtk.ScrolledWindow() 246 | scrolled_window.add_with_viewport(self.liblarch_widget) 247 | 248 | self.start_time = 0 249 | 250 | # Buttons 251 | action_panel = Gtk.Box() 252 | action_panel.set_spacing(5) 253 | 254 | button_desc = [ 255 | ('_Add a Task', self.add_task), 256 | ('_Delete a Task', self.delete_task), 257 | ('_Print Tree', self.print_tree), 258 | ('_Print FT', self.print_ft), 259 | ('_Load many Tasks', self.many_tasks), 260 | ('_Quit', self.finish), 261 | ] 262 | 263 | for name, callback in button_desc: 264 | button = Gtk.Button.new_with_mnemonic(name) 265 | button.connect('clicked', callback) 266 | action_panel.pack_start(button, True, True, 0) 267 | 268 | filter_panel = Gtk.Box() 269 | filter_panel.set_spacing(5) 270 | 271 | for name in self.tree.list_filters(): 272 | button = Gtk.ToggleButton("%s filter" % name) 273 | button.connect('toggled', self.apply_filter, name) 274 | filter_panel.pack_start(button, True, True, 0) 275 | 276 | # Use cases 277 | usecases_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 278 | usecase_box = None 279 | usecase_order = 0 280 | usecase_order_max = 3 281 | 282 | button_desc = [ 283 | ('_Tree high 3', self.tree_high_3), 284 | ('Tree high 3 backwards', self.tree_high_3_backwards), 285 | ('Load from file', self.load_from_file), 286 | ('Delete DFXBCAE', self.delete_magic), 287 | ('Delete backwards', self.delete_backwards), 288 | ('Delete randomly', self.delete_random), 289 | ('Change task', self.change_task), 290 | ('_Backend use case', self.backends), 291 | ] 292 | 293 | for name, callback in button_desc: 294 | if usecase_order <= 0: 295 | if usecase_box is not None: 296 | usecases_vbox.pack_start( 297 | usecase_box, expand=False, fill=True, padding=0) 298 | usecase_box = Gtk.Box() 299 | usecase_box.set_spacing(5) 300 | 301 | button = Gtk.Button.new_with_mnemonic(name) 302 | button.connect('clicked', callback) 303 | usecase_box.pack_start(button, True, True, 0) 304 | 305 | usecase_order = (usecase_order + 1) % usecase_order_max 306 | 307 | usecases_vbox.pack_start( 308 | usecase_box, expand=False, fill=True, padding=0) 309 | usecase_panel = Gtk.Expander() 310 | usecase_panel.set_label('Use cases') 311 | usecase_panel.set_expanded(True) 312 | usecase_panel.add(usecases_vbox) 313 | 314 | # Show it 315 | vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 316 | vbox.pack_start(action_panel, False, True, 10) 317 | vbox.pack_start(filter_panel, False, True, 10) 318 | vbox.pack_start(scrolled_window, True, True, 0) 319 | vbox.pack_start(usecase_panel, False, True, 10) 320 | 321 | self.window.add(vbox) 322 | self.window.show_all() 323 | 324 | self.should_finish = threading.Event() 325 | 326 | def task_label_column(self, node): 327 | newlabel = node.get_label() 328 | return newlabel 329 | 330 | def print_tree(self, widget=None): 331 | print() 332 | print("=" * 20, "Tree", "=" * 20) 333 | self.tree.get_main_view().print_tree() 334 | print("=" * 46) 335 | print() 336 | 337 | def print_ft(self, widget=None): 338 | print() 339 | self.view_tree.print_tree() 340 | print() 341 | 342 | @save_backup 343 | def add_task(self, widget): 344 | """ Add a new task. If a task is selected, 345 | the new task is added as its child """ 346 | selected = self.liblarch_widget.get_selected_nodes() 347 | 348 | t_id = random_id() 349 | t_title = random_task_title(t_id) 350 | task = TaskNode(t_id, t_title, self.view_tree) 351 | 352 | if len(selected) == 1: 353 | # Adding a subchild 354 | parent = selected[0] 355 | self.tree.add_node(task, parent_id=parent) 356 | logging.info( 357 | 'Added sub-task "%s" (%s) for %s', t_title, t_id, parent) 358 | else: 359 | # Adding as a new child 360 | self.tree.add_node(task) 361 | for parent_id in selected: 362 | task.add_parent(parent_id) 363 | logging.info('Added task "%s" (%s)', t_title, t_id) 364 | 365 | def apply_filter(self, widget, param): 366 | logging.info("applying filter: %s", param) 367 | if param in self.view_tree.list_applied_filters(): 368 | self.view_tree.unapply_filter(param) 369 | else: 370 | self.view_tree.apply_filter(param) 371 | 372 | @save_backup 373 | def tree_high_3(self, widget): 374 | ''' We add the leaf nodes before the root, in order to test 375 | if it works fine even in this configuration''' 376 | logging.info('Adding a tree of height 3') 377 | 378 | selected = self.liblarch_widget.get_selected_nodes() 379 | 380 | if len(selected) == 1: 381 | parent = selected[0] 382 | else: 383 | parent = None 384 | 385 | t_id = random_id() 386 | t_title = random_task_title(t_id) 387 | roottask = TaskNode(t_id, t_title, self.view_tree) 388 | local_parent = t_id 389 | 390 | for i in range(2): 391 | t_id = random_id() 392 | t_title = random_task_title(t_id) 393 | task = TaskNode(t_id, t_title, self.view_tree) 394 | 395 | self.tree.add_node(task, parent_id=local_parent) 396 | 397 | # Task becomes a parent for new task 398 | local_parent = t_id 399 | 400 | self.tree.add_node(roottask, parent_id=parent) 401 | 402 | @save_backup 403 | def tree_high_3_backwards(self, widget): 404 | logging.info('Adding a tree of height 3 backwards') 405 | 406 | selected = self.liblarch_widget.get_selected_nodes() 407 | 408 | if len(selected) == 1: 409 | parent = selected[0] 410 | else: 411 | parent = None 412 | 413 | tasks = [] 414 | relationships = [] 415 | for i in range(3): 416 | t_id = random_id() 417 | t_title = random_task_title(t_id) 418 | task = TaskNode(t_id, t_title, self.view_tree) 419 | 420 | tasks.append((t_id, task)) 421 | 422 | if parent is not None: 423 | relationships.append((parent, t_id)) 424 | 425 | parent = t_id 426 | 427 | # Relationships can come in any order, e.g. reversed 428 | relationships = reversed(relationships) 429 | 430 | for t_id, task in tasks: 431 | logging.info("Adding task to tree: %s %s", t_id, task) 432 | self.tree.add_node(task) 433 | logging.info("=" * 50) 434 | 435 | for parent, child in relationships: 436 | logging.info("New relationship: %s with %s", parent, child) 437 | parent_node = self.tree.get_node(parent) 438 | parent_node.add_child(child) 439 | logging.info("=" * 50) 440 | 441 | @save_backup 442 | def delete_task(self, widget, order='normal'): 443 | logging.info('Deleting a task, order: %s', order) 444 | selected = self.liblarch_widget.get_selected_nodes() 445 | 446 | if order == 'normal': 447 | ordered_nodes = selected 448 | elif order == 'backward': 449 | ordered_nodes = reversed(selected) 450 | elif order == 'random': 451 | ordered_nodes = selected 452 | shuffle(ordered_nodes) 453 | # Replace iterator for a list => we want to see the order in logs 454 | # and the performance is not important 455 | ordered_nodes = [node for node in ordered_nodes] 456 | elif order == 'magic-combination': 457 | # testing a special case from examples/test_suite 458 | ordered_nodes = ['D', 'F', 'X', 'B', 'C', 'A', 'E'] 459 | else: 460 | logging.error('Unknown order, skipping...') 461 | return 462 | 463 | logging.info( 464 | "Tasks should be removed in this order: %s", ordered_nodes) 465 | 466 | for node_id in ordered_nodes: 467 | self.tree.del_node(node_id) 468 | logging.info('Removed node %s', node_id) 469 | 470 | self.print_tree(None) 471 | 472 | def delete_backwards(self, widget): 473 | """ Delete task backward """ 474 | self.delete_task(widget, order='backward') 475 | 476 | def delete_random(self, widget): 477 | """ Delete tasks in random order """ 478 | self.delete_task(widget, order='random') 479 | 480 | def delete_magic(self, widget): 481 | self.delete_task(widget, order='magic-combination') 482 | 483 | def change_task(self, widget): 484 | for node_id in self.liblarch_widget.get_selected_nodes(): 485 | node = self.tree.get_node(node_id) 486 | node.label = "Hello" 487 | node.modified() 488 | 489 | def backends(self, widget): 490 | logging.info("Backends....") 491 | Backend( 492 | '1sec', self.should_finish, 1, self.tree, self.view_tree).start() 493 | Backend( 494 | '3sec', self.should_finish, 3, self.tree, self.view_tree).start() 495 | Backend( 496 | '5sec', self.should_finish, 5, self.tree, self.view_tree).start() 497 | widget.set_sensitive(False) 498 | 499 | def many_tasks(self, widget): 500 | self.start_time = time() 501 | 502 | def _many_tasks(): 503 | tasks_ids = [] 504 | prefix = randint(1, 1000) * 100000 505 | for i in range(LOAD_MANY_TASKS_COUNT): 506 | t_id = str(prefix + i) 507 | t_title = t_id 508 | task = TaskNode(t_id, t_title, self.view_tree) 509 | 510 | # There is 25 % chance to adding as a sub_task 511 | if tasks_ids != [] and randint(0, 100) < 90: 512 | parent = choice(tasks_ids) 513 | self.tree.add_node(task, parent_id=parent) 514 | else: 515 | self.tree.add_node(task) 516 | 517 | tasks_ids.append(t_id) 518 | 519 | # Sleep 0.01 second to create illusion of real tasks 520 | sleep(SLEEP_BETWEEN_TASKS) 521 | 522 | logging.info("end of _many_tasks thread") 523 | t = threading.Thread(target=_many_tasks) 524 | t.start() 525 | 526 | def load_from_file(self, widget): 527 | dialog = Gtk.FileChooserDialog( 528 | "Open..", 529 | self.window, 530 | Gtk.FileChooserAction.OPEN, 531 | (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, 532 | Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) 533 | dialog.set_default_response(Gtk.ResponseType.OK) 534 | 535 | response = dialog.run() 536 | if response == Gtk.ResponseType.OK: 537 | file_name = dialog.get_filename() 538 | else: 539 | file_name = None 540 | dialog.destroy() 541 | 542 | if file_name is None: 543 | return 544 | 545 | log = open(file_name, 'r').read() 546 | 547 | m = re.match( 548 | r'\s*Tree before operation\s+=+\s+Tree\s+=+\s+(.*?)=+', 549 | log, re.UNICODE | re.DOTALL) 550 | if m: 551 | treelines = m.group(1) 552 | items = [(len(line) - len(line.lstrip()), line.strip()) 553 | for line in treelines.splitlines()] 554 | # Filter "root" item and decrease level 555 | items = [(level, name) for level, name in items[1:]] 556 | 557 | # The "root" items should be at level 0, adjust level to that 558 | min_level = min(level for level, name in items) 559 | items = [(level - min_level, name) for level, name in items] 560 | 561 | nodes = list(set([name for level, name in items])) 562 | 563 | relationships = [] 564 | parent_level = {-1: None} 565 | 566 | for level, name in items: 567 | parent = parent_level[level - 1] 568 | relationships.append((parent, name)) 569 | 570 | for key in list(parent_level.keys()): 571 | if key > level: 572 | del parent_level[key] 573 | 574 | parent_level[level] = name 575 | 576 | logging.info("Nodes to add:", nodes) 577 | logging.info("Relationships:", 578 | "\n".join(str(r) for r in relationships)) 579 | 580 | for node_id in nodes: 581 | task = TaskNode( 582 | node_id, random_task_title(node_id), self.view_tree) 583 | self.tree.add_node(task) 584 | 585 | for parent, child in relationships: 586 | parent_node = self.tree.get_node(parent) 587 | parent_node.add_child(child) 588 | else: 589 | logging.info("Not matched") 590 | logging.info("Log: %s", log) 591 | 592 | def finish(self, widget): 593 | self.should_finish.set() 594 | Gtk.main_quit() 595 | 596 | def run(self): 597 | Gtk.main() 598 | 599 | 600 | if __name__ == "__main__": 601 | GObject.threads_init() 602 | app = LiblarchDemo() 603 | app.run() 604 | 605 | # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 606 | -------------------------------------------------------------------------------- /liblarch_gtk/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | import gi # noqa 21 | gi.require_version("Gtk", "3.0") # noqa 22 | from gi.repository import Gtk, Gdk 23 | from gi.repository import GObject 24 | 25 | from .treemodel import TreeModel 26 | 27 | 28 | # Useful for debugging purpose. 29 | # Disabling that will disable the TreeModelSort on top of our TreeModel 30 | ENABLE_SORTING = True 31 | USE_TREEMODELFILTER = False 32 | 33 | 34 | BRITGHTNESS_CACHE = {} 35 | 36 | 37 | def brightness(color_str): 38 | """ Compute brightness of a color on scale 0-1 39 | 40 | Courtesy to 41 | http://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color 42 | """ 43 | global BRITGHTNESS_CACHE 44 | 45 | if color_str not in BRITGHTNESS_CACHE: 46 | c = Gdk.color_parse(color_str) 47 | brightness = (0.2126 * c.red + 0.7152 * c.green + 0.0722 * c.blue) / 65535.0 48 | BRITGHTNESS_CACHE[color_str] = brightness 49 | return BRITGHTNESS_CACHE[color_str] 50 | 51 | 52 | class TreeView(Gtk.TreeView): 53 | """ Widget which display LibLarch FilteredTree. 54 | 55 | This widget extends Gtk.TreeView by several features: 56 | * Drag'n'Drop support 57 | * Sorting support 58 | * separator rows 59 | * background color of a row 60 | * selection of multiple rows 61 | """ 62 | 63 | __string_signal__ = (GObject.SignalFlags.RUN_FIRST, None, (str, )) 64 | __gsignals__ = { 65 | 'node-expanded': __string_signal__, 66 | 'node-collapsed': __string_signal__, 67 | } 68 | 69 | def __init__(self, tree, description): 70 | """ Build the widget 71 | 72 | @param tree - LibLarch FilteredTree 73 | @param description - definition of columns. 74 | 75 | Parameters of description dictionary for a column: 76 | * value => (type of values, function for generating value from 77 | a node) 78 | * renderer => (renderer_attribute, renderer object) 79 | 80 | Optional: 81 | * order => specify order of column otherwise use natural order 82 | * expandable => is the column expandable? 83 | * resizable => is the column resizable? 84 | * visible => is the column visible? 85 | * title => title of column 86 | * new_colum => do not create a separate column, just continue with 87 | the previous one (this can be used to create 88 | columns without borders) 89 | * sorting => allow default sorting on this column 90 | * sorting_func => use special function for sorting on this func 91 | 92 | Example of columns descriptions: 93 | description = { 'title': { 94 | 'value': [str, self.task_title_column], 95 | 'renderer': ['markup', Gtk.CellRendererText()], 96 | 'order': 0 97 | }} 98 | """ 99 | GObject.GObject.__init__(self) 100 | self.columns = {} 101 | self.sort_col = None 102 | self.sort_order = Gtk.SortType.ASCENDING 103 | self.bg_color_column = None 104 | self.separator_func = None 105 | 106 | self.dnd_internal_target = '' 107 | self.dnd_external_targets = {} 108 | 109 | # Sort columns 110 | self.order_of_column = [] 111 | last = 9999 112 | for col_name in description: 113 | desc = description[col_name] 114 | order = desc.get('order', last) 115 | last += 1 116 | self.order_of_column.append((order, col_name)) 117 | 118 | types = [] 119 | sorting_func = [] 120 | # Build the first column if user starts with new_colum=False 121 | col = Gtk.TreeViewColumn() 122 | 123 | # Build columns according to the order 124 | for col_num, (order_num, col_name) in enumerate( 125 | sorted(self.order_of_column), 1): 126 | desc = description[col_name] 127 | types.append(desc['value']) 128 | 129 | expand = desc.get('expandable', False) 130 | resizable = desc.get('resizable', True) 131 | visible = desc.get('visible', True) 132 | 133 | if 'renderer' in desc: 134 | rend_attribute, renderer = desc['renderer'] 135 | else: 136 | rend_attribute = 'markup' 137 | renderer = Gtk.CellRendererText() 138 | 139 | # If new_colum=False, do not create new column, use the previous 140 | # one. It will create columns without borders. 141 | if desc.get('new_column', True): 142 | col = Gtk.TreeViewColumn() 143 | newcol = True 144 | else: 145 | newcol = False 146 | col.set_visible(visible) 147 | 148 | if 'title' in desc: 149 | col.set_title(desc['title']) 150 | 151 | col.pack_start(renderer, expand=expand) 152 | col.add_attribute(renderer, rend_attribute, col_num) 153 | col.set_resizable(resizable) 154 | col.set_expand(expand) 155 | 156 | # Allow to set background color 157 | col.set_cell_data_func(renderer, self._celldatafunction) 158 | 159 | if newcol: 160 | self.append_column(col) 161 | self.columns[col_name] = (col_num, col) 162 | 163 | if ENABLE_SORTING: 164 | if 'sorting' in desc: 165 | # Just allow sorting and use default comparing 166 | self.sort_col = desc['sorting'] 167 | sort_num, sort_col = self.columns[self.sort_col] 168 | col.set_sort_column_id(sort_num) 169 | 170 | if 'sorting_func' in desc: 171 | # Use special function for comparing, e.g. dates 172 | sorting_func.append((col_num, col, desc['sorting_func'])) 173 | 174 | self.basetree = tree 175 | # Build the model around LibLarch tree 176 | self.basetreemodel = TreeModel(tree, types) 177 | # Applying an intermediate treemodelfilter, for debugging purpose 178 | if USE_TREEMODELFILTER: 179 | treemodelfilter = self.basetreemodel.filter_new() 180 | else: 181 | treemodelfilter = self.basetreemodel 182 | 183 | # Apply TreeModelSort to be able to sort 184 | if ENABLE_SORTING: 185 | self.treemodel = self.basetreemodel 186 | for col_num, col, sort_func in sorting_func: 187 | self.treemodel.set_sort_func( 188 | col_num, self._sort_func, sort_func) 189 | col.set_sort_column_id(col_num) 190 | else: 191 | self.treemodel = treemodelfilter 192 | 193 | self.set_model(self.treemodel) 194 | 195 | self.expand_all() 196 | self.show() 197 | 198 | self.collapsed_paths = [] 199 | self.connect('row-expanded', self.__emit, 'expanded') 200 | self.connect('row-collapsed', self.__emit, 'collapsed') 201 | self.treemodel.connect('row-has-child-toggled', self.on_child_toggled) 202 | 203 | def __emit(self, sender, iter, path, data): 204 | """ Emit expanded/collapsed signal """ 205 | # recreating the path of the collapsed node 206 | ll_path = () 207 | i = 1 208 | path = path.get_indices() 209 | while i <= len(path): 210 | temp_path = Gtk.TreePath(":".join(str(n) for n in path[:i])) 211 | temp_iter = self.treemodel.get_iter(temp_path) 212 | ll_path += (self.treemodel.get_value(temp_iter, 0), ) 213 | i += 1 214 | if data == 'expanded': 215 | self.emit('node-expanded', ll_path) 216 | elif data == 'collapsed': 217 | self.emit('node-collapsed', ll_path) 218 | 219 | def on_child_toggled(self, treemodel, path, iter, param=None): 220 | """ Expand row """ 221 | # is the toggled node in the collapsed paths? 222 | collapsed = False 223 | nid = treemodel.get_value(iter, 0) 224 | while iter and not collapsed: 225 | for c in self.collapsed_paths: 226 | if c[-1] == nid: 227 | collapsed = True 228 | iter = treemodel.iter_parent(iter) 229 | if not self.row_expanded(path) and not collapsed: 230 | self.expand_row(path, True) 231 | 232 | def expand_node(self, llpath): 233 | """ Expand the children of a node. This is not recursive """ 234 | self.collapse_node(llpath, collapsing_method=self.expand_one_row) 235 | 236 | def expand_one_row(self, p): 237 | # We have to set the "open all" parameters 238 | self.expand_row(p, False) 239 | 240 | def collapse_node(self, llpath, collapsing_method=None): 241 | """ Hide children of a node 242 | 243 | This method is needed for "remember collapsed nodes" feature of GTG. 244 | Transform node_id into paths and those paths collapse. By default all 245 | children are expanded (see self.expand_all()) 246 | 247 | @parameter llpath - LibLarch path to the node. Node_id is extracted 248 | as the last parameter and then all instances of that node are 249 | collapsed. For retro-compatibility, we take llpath instead of 250 | node_id directly""" 251 | if not collapsing_method: 252 | collapsing_method = self.collapse_row 253 | node_id = llpath[-1].strip("'") 254 | if not node_id: 255 | raise Exception('Missing node_id in path %s' % str(llpath)) 256 | 257 | schedule_next = True 258 | for path in self.basetree.get_paths_for_node(node_id): 259 | iter = self.basetreemodel.my_get_iter(path) 260 | if iter is None: 261 | continue 262 | 263 | target_path = self.basetreemodel.get_path(iter) 264 | if self.basetreemodel.get_value(iter, 0) == node_id: 265 | collapsing_method(target_path) 266 | self.collapsed_paths.append(path) 267 | schedule_next = False 268 | 269 | if schedule_next: 270 | self.basetree.queue_action( 271 | node_id, self._collapse_node_retry, 272 | param=(llpath, collapsing_method)) 273 | 274 | def _collapse_node_retry(self, param): 275 | """ 276 | Node to be collapsed/expand found, so re-try now with correct 277 | and preserved parameters. 278 | """ 279 | self.collapse_node(param[0], collapsing_method=param[1]) 280 | 281 | def show(self): 282 | """ Shows the TreeView and connect basetreemodel to LibLarch """ 283 | Gtk.TreeView.show(self) 284 | self.basetreemodel.connect_model() 285 | 286 | def get_columns(self): 287 | """ Return the list of columns name """ 288 | return list(self.columns.keys()) 289 | 290 | def set_main_search_column(self, col_name): 291 | """ Set search column for GTK integrate search 292 | This is just wrapper to use internal representation of columns""" 293 | col_num, col = self.columns[col_name] 294 | self.set_search_column(col_num) 295 | 296 | def set_expander_column(self, col_name): 297 | """ Set expander column (that which expands through free space) 298 | This is just wrapper to use internal representation of columns""" 299 | col_num, col = self.columns[col_name] 300 | self.set_property("expander-column", col) 301 | 302 | def set_sort_column(self, col_name, order=Gtk.SortType.ASCENDING): 303 | """ Select column to sort by it by default """ 304 | if ENABLE_SORTING: 305 | self.sort_col = col_name 306 | self.sort_order = order 307 | col_num, col = self.columns[col_name] 308 | self.treemodel.set_sort_column_id(col_num, order) 309 | 310 | def get_sort_column(self): 311 | """ Get sort column """ 312 | if ENABLE_SORTING: 313 | return self.sort_col, self.sort_order 314 | 315 | def set_col_visible(self, col_name, visible): 316 | """ Set visibility of column. 317 | Allow to hide/show certain column """ 318 | col_num, col = self.columns[col_name] 319 | col.set_visible(visible) 320 | 321 | def set_col_resizable(self, col_name, resizable): 322 | """ Allow/forbid column to be resizable """ 323 | col_num, col = self.columns[col_name] 324 | col.set_resizable(resizable) 325 | 326 | def set_bg_color(self, color_func, color_column): 327 | """ Set which column and function for generating background color 328 | 329 | Function should be in format func(node, default_color) 330 | """ 331 | 332 | def closure_default_color(func, column): 333 | """ Set default color to the function. 334 | 335 | Transform function from func(node, default_color) into func(node). 336 | Default color is computed based on some GTK style magic. """ 337 | style = column.get_tree_view().get_style_context() 338 | color = style.get_background_color(Gtk.StateFlags.NORMAL) 339 | default = color.to_color() 340 | return lambda node: func(node, default) 341 | 342 | if color_column in self.columns: 343 | self.bg_color_column, column = self.columns[color_column] 344 | func = closure_default_color(color_func, column) 345 | self.treemodel.set_column_function(self.bg_color_column, func) 346 | else: 347 | raise ValueError( 348 | "There is no column %s to use to set color" % color_column) 349 | 350 | def _sort_func(self, model, iter1, iter2, func=None): 351 | """ Sort two iterators by function which gets node objects. 352 | This is a simple wrapper which prepares node objects and then 353 | call comparing function. In other case return default value -1 354 | """ 355 | node_id_a = model.get_value(iter1, 0) 356 | node_id_b = model.get_value(iter2, 0) 357 | if node_id_a and node_id_b and func: 358 | id, order = self.treemodel.get_sort_column_id() 359 | node_a = self.basetree.get_node(node_id_a) 360 | node_b = self.basetree.get_node(node_id_b) 361 | sort = func(node_a, node_b, order) 362 | else: 363 | sort = -1 364 | return sort 365 | 366 | def _celldatafunction(self, column, cell, model, myiter, user_data): 367 | """ Determine background color for cell 368 | 369 | Requirements: self.bg_color_column must be set 370 | (see self.set_bg_color()) 371 | 372 | Set background color based on a certain column value. 373 | """ 374 | if self.bg_color_column is None: 375 | return 376 | 377 | if myiter and model.iter_is_valid(myiter): 378 | color = model.get_value(myiter, self.bg_color_column) 379 | else: 380 | color = None 381 | 382 | if isinstance(cell, Gtk.CellRendererText): 383 | if color is not None and brightness(color) < 0.5: 384 | cell.set_property("foreground", '#FFFFFF') 385 | else: 386 | # Otherwise unset foreground color 387 | cell.set_property("foreground-set", False) 388 | 389 | cell.set_property("cell-background", color) 390 | 391 | # DRAG-N-DROP functions ##################################### 392 | 393 | def set_dnd_name(self, dndname): 394 | """ Sets Drag'n'Drop name and initialize Drag'n'Drop support 395 | 396 | If ENABLE_SORTING, drag_drop signal must be handled by this widget.""" 397 | self.dnd_internal_target = dndname 398 | self.__init_dnd() 399 | self.connect('drag_data_get', self.on_drag_data_get) 400 | self.connect('drag_data_received', self.on_drag_data_received) 401 | 402 | def set_dnd_external(self, sourcename, func): 403 | """ Add a new external target and initialize Drag'n'Drop support""" 404 | i = 1 405 | while i in self.dnd_external_targets: 406 | i += 1 407 | self.dnd_external_targets[i] = [sourcename, func] 408 | self.__init_dnd() 409 | 410 | def __init_dnd(self): 411 | """ Initialize Drag'n'Drop support 412 | 413 | Firstly build list of DND targets: 414 | * name 415 | * scope - just the same widget / same application 416 | * id 417 | 418 | Enable DND by calling enable_model_drag_dest(), 419 | enable_model-drag_source() 420 | 421 | It didn't use support from Gtk.Widget(drag_source_set(), 422 | drag_dest_set()). To know difference, look in PyGTK FAQ: 423 | http://faq.pyGtk.org/index.py?file=faq13.033.htp&req=show 424 | """ 425 | self.defer_select = False 426 | 427 | if self.dnd_internal_target == '': 428 | error = 'Cannot initialize DND without a valid name\n' 429 | error += 'Use set_dnd_name() first' 430 | raise Exception(error) 431 | 432 | dnd_targets = [( 433 | self.dnd_internal_target, Gtk.TargetFlags.SAME_WIDGET, 0)] 434 | for target in self.dnd_external_targets: 435 | name = self.dnd_external_targets[target][0] 436 | dnd_targets.append((name, Gtk.TargetFlags.SAME_APP, target)) 437 | 438 | self.enable_model_drag_source( 439 | Gdk.ModifierType.BUTTON1_MASK, 440 | dnd_targets, 441 | Gdk.DragAction.DEFAULT | Gdk.DragAction.MOVE) 442 | 443 | self.enable_model_drag_dest( 444 | dnd_targets, Gdk.DragAction.DEFAULT | Gdk.DragAction.MOVE) 445 | 446 | def on_drag_data_get(self, treeview, context, selection, info, timestamp): 447 | """ Extract data from the source of the DnD operation. 448 | 449 | Serialize iterators of selected tasks in format 450 | , , ..., and set it as parameter of DND """ 451 | 452 | treeselection = treeview.get_selection() 453 | model, paths = treeselection.get_selected_rows() 454 | iters = [model.get_iter(path) for path in paths] 455 | iter_str = ','.join(model.get_string_from_iter(iter) for iter in iters) 456 | selection.set(selection.get_target(), 0, iter_str.encode('ascii')) 457 | 458 | def on_drag_data_received(self, treeview, context, x, y, selection, info, 459 | timestamp): 460 | """ Handle a drop situation. 461 | 462 | First of all, we need to get id of node which should accept 463 | all dragged nodes as their new children. If there is no node, 464 | drop to root node. 465 | 466 | Deserialize iterators of dragged nodes (see self.on_drag_data_get()) 467 | Info parameter determines which target was used: 468 | * info == 0 => internal DND within this TreeView 469 | * info > 0 => external DND 470 | 471 | In case of internal DND we just use Tree.move_node(). 472 | In case of external DND we call function associated with that DND 473 | set by self.set_dnd_external() 474 | """ 475 | # TODO: it should be configurable for each TreeView if you want: 476 | # 0 : no drag-n-drop at all 477 | # 1 : drag-n-drop move the node 478 | # 2 : drag-n-drop copy the node 479 | 480 | model = treeview.get_model() 481 | drop_info = treeview.get_dest_row_at_pos(x, y) 482 | if drop_info: 483 | path, position = drop_info 484 | iter = model.get_iter(path) 485 | # Must add the task to the parent of the task situated 486 | # before/after 487 | if position == Gtk.TreeViewDropPosition.BEFORE or\ 488 | position == Gtk.TreeViewDropPosition.AFTER: 489 | # Get sibling parent 490 | destination_iter = model.iter_parent(iter) 491 | else: 492 | # Must add task as a child of the dropped-on iter 493 | # Get parent 494 | destination_iter = iter 495 | 496 | if destination_iter: 497 | destination_tid = model.get_value(destination_iter, 0) 498 | else: 499 | # it means we have drag-n-dropped above the first task 500 | # we should consider the destination as a root then. 501 | destination_tid = None 502 | else: 503 | # Must add the task to the root 504 | # Parent = root => iter=None 505 | destination_tid = None 506 | 507 | tree = self.basetree.get_basetree() 508 | 509 | # Get dragged iter as a TaskTreeModel iter 510 | # If there is no selected task (empty selection.data), 511 | # explicitly skip handling it (set to empty list) 512 | data = selection.get_data() 513 | if data == '': 514 | iters = [] 515 | else: 516 | iters = data.decode().split(',') 517 | 518 | dragged_iters = [] 519 | for iter in iters: 520 | if info == 0: 521 | try: 522 | dragged_iters.append(model.get_iter_from_string(iter)) 523 | except ValueError: 524 | # I hate to silently fail but we have no choice. 525 | # It means that the iter is not good. 526 | # Thanks shitty gtk API for not allowing us to test 527 | # the string 528 | dragged_iter = None 529 | 530 | # Handle drag from one widget to another (ex: treeview to treeview) 531 | elif info in self.dnd_external_targets and destination_tid: 532 | f = self.dnd_external_targets[info][1] 533 | src_model = Gtk.drag_get_source_widget(context).get_model() 534 | dragged_iters.append(src_model.get_iter_from_string(iter)) 535 | 536 | for dragged_iter in dragged_iters: 537 | if info == 0: 538 | if dragged_iter and model.iter_is_valid(dragged_iter): 539 | dragged_tid = model.get_value(dragged_iter, 0) 540 | try: 541 | tree.move_node( 542 | dragged_tid, new_parent_id=destination_tid) 543 | except Exception as e: 544 | print('Problem with dragging: %s' % e) 545 | 546 | # Handle inter-widget Drag'n'Drop again (like in the previous loop) 547 | elif info in self.dnd_external_targets and destination_tid: 548 | source = src_model.get_value(dragged_iter, 0) 549 | f(source, destination_tid) 550 | 551 | # Separators support ############################################## 552 | def _separator_func(self, model, itera, user_data=None): 553 | """ Call user function to determine if this node is separator """ 554 | if itera and model.iter_is_valid(itera): 555 | node_id = model.get_value(itera, 0) 556 | node = self.basetree.get_node(node_id) 557 | if self.separator_func: 558 | return self.separator_func(node) 559 | else: 560 | return False 561 | else: 562 | return False 563 | 564 | def set_row_separator_func(self, func, data=None): 565 | """ Enable support for row separators. 566 | 567 | @param func - function which determines if a node is separator, 568 | None will disable support for row separators. 569 | """ 570 | self.separator_func = func 571 | Gtk.TreeView.set_row_separator_func(self, self._separator_func, data) 572 | 573 | # Multiple selection #################################################### 574 | def get_selected_nodes(self): 575 | """ Return list of node_id from liblarch for selected nodes """ 576 | # Get the selection in the Gtk.TreeView 577 | selection = self.get_selection() 578 | # Get the selection iter 579 | if selection.count_selected_rows() <= 0: 580 | ids = [] 581 | else: 582 | model, paths = selection.get_selected_rows() 583 | iters = [model.get_iter(path) for path in paths] 584 | ts = self.get_model() 585 | # 0 is the column of the tid 586 | ids = [ts.get_value(iter, 0) for iter in iters] 587 | 588 | return ids 589 | 590 | def set_multiple_selection(self, multiple_selection): 591 | """ Allow/forbid multiple selection in TreeView """ 592 | # TODO support for dragging multiple rows at the same time 593 | # See LP #817433 594 | 595 | if multiple_selection: 596 | selection_type = Gtk.SelectionMode.MULTIPLE 597 | else: 598 | selection_type = Gtk.SelectionMode.SINGLE 599 | 600 | self.get_selection().set_mode(selection_type) 601 | -------------------------------------------------------------------------------- /liblarch/filteredtree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ----------------------------------------------------------------------------- 3 | # Liblarch - a library to handle directed acyclic graphs 4 | # Copyright (c) 2011-2012 - Lionel Dricot & Izidor Matušov 5 | # 6 | # This program is free software: you can redistribute it and/or modify it under 7 | # the terms of the GNU Lesser General Public License as published by the Free 8 | # Software Foundation, either version 3 of the License, or (at your option) any 9 | # later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, but WITHOUT 12 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 13 | # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 14 | # details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with this program. If not, see . 18 | # ----------------------------------------------------------------------------- 19 | 20 | from gi.repository import GObject 21 | 22 | 23 | class FilteredTree(object): 24 | """ FilteredTree is the most important and also the most buggy part of 25 | LibLarch. 26 | 27 | FilteredTree transforms general changes in tree like creating/removing 28 | relationships between nodes and adding/updating/removing nodes into a series 29 | of simple steps which can be for instance by GTK Widget. 30 | 31 | FilteredTree allows filtering - hiding certain nodes defined by 32 | a predicate. 33 | 34 | The reason of most bugs is that FilteredTree is request to update a node. 35 | FilteredTree must update its ancestors and also decestors. You can't do 36 | that by a simple recursion. 37 | """ 38 | 39 | def __init__(self, tree, filtersbank, name=None, refresh=True): 40 | """ Construct a layer where filters could by applied 41 | 42 | @param tree: Original tree to filter. 43 | @param filtersbank: Filter bank which stores filters 44 | @param refresh: Requests all nodes in the beginning? Additional 45 | filters can be added and refresh can be done later 46 | 47 | _flat defines whether only nodes without children can be shown. 48 | For example WorkView filter. 49 | """ 50 | 51 | self.cllbcks = {} 52 | 53 | # Cache 54 | self.nodes = {} 55 | # Set root_id by the name of FilteredTree 56 | if name is None: 57 | self.root_id = "anonymous_root" 58 | else: 59 | self.root_id = "root_%s" % name 60 | 61 | self.nodes[self.root_id] = {'parents': [], 'children': []} 62 | self.cache_paths = {} 63 | self.filter_cache = {} 64 | 65 | # Connect to signals from MainTree 66 | self.tree = tree 67 | self.tree.register_callback("node-added", self.__external_modify) 68 | self.tree.register_callback("node-modified", self.__external_modify) 69 | self.tree.register_callback("node-deleted", self.__external_modify) 70 | 71 | # Filters 72 | self.__flat = False 73 | self.applied_filters = [] 74 | self.fbank = filtersbank 75 | 76 | if refresh: 77 | self.refilter() 78 | 79 | def set_callback(self, event, func, node_id=None, param=None): 80 | """ Register a callback for an event. 81 | 82 | It is possible to have just one callback for event. 83 | @param event: one of added, modified, deleted, reordered 84 | @param func: callback function 85 | """ 86 | if event == 'runonce': 87 | if not node_id: 88 | raise Exception('runonce callback should come with a node_id') 89 | if self.is_displayed(node_id): 90 | # it is essential to idle_add to avoid hard recursion 91 | GObject.idle_add(func, param) 92 | else: 93 | if node_id not in self.cllbcks: 94 | self.cllbcks[node_id] = [] 95 | self.cllbcks[node_id].append([func, node_id, param]) 96 | else: 97 | self.cllbcks[event] = [func, node_id, param] 98 | 99 | def callback(self, event, node_id, path, neworder=None): 100 | """ Run a callback. 101 | 102 | To call callback, the object must be initialized and function exists. 103 | 104 | @param event: one of added, modified, deleted, reordered, runonce 105 | @param node_id: node_id parameter for callback function 106 | @param path: path parameter for callback function 107 | @param neworder: neworder parameter for reorder callback function 108 | 109 | The runonce event is actually only run once, when a given task appears. 110 | """ 111 | if event == 'added': 112 | for func, nid, param in self.cllbcks.get(node_id, []): 113 | if nid and self.is_displayed(nid): 114 | func(param) 115 | if node_id in self.cllbcks: 116 | self.cllbcks.pop(node_id) 117 | else: 118 | raise Exception( 119 | '{} is not displayed but {} was added'.format( 120 | nid, node_id)) 121 | func, nid, param = self.cllbcks.get(event, (None, None, None)) 122 | if func: 123 | if neworder: 124 | func(node_id, path, neworder) 125 | else: 126 | func(node_id, path) 127 | 128 | # EXTERNAL MODIFICATION ################################################### 129 | def __external_modify(self, node_id): 130 | return self.__update_node(node_id, direction="both") 131 | 132 | def __update_node(self, node_id, direction): 133 | '''update the node node_id and propagate the 134 | change in direction (up|down|both) ''' 135 | 136 | if node_id == self.root_id: 137 | return None 138 | 139 | current_display = self.is_displayed(node_id) 140 | new_display = self.__is_displayed(node_id) 141 | 142 | for fcname in self.filter_cache: 143 | if node_id in self.filter_cache[fcname]['nodes']: 144 | self.filter_cache[fcname]['nodes'].remove(node_id) 145 | self.filter_cache[fcname]['count'] = len( 146 | self.filter_cache[fcname]['nodes']) 147 | 148 | completely_updated = True 149 | 150 | if not current_display and not new_display: 151 | # If a task is not displayed and should not be displayed, we 152 | # should still check its parent because he might not be aware 153 | # that he has a child 154 | if self.tree.has_node(node_id): 155 | node = self.tree.get_node(node_id) 156 | for parent in node.get_parents(): 157 | self.__update_node(parent, "up") 158 | return completely_updated 159 | elif not current_display and new_display: 160 | action = 'added' 161 | elif current_display and not new_display: 162 | action = 'deleted' 163 | else: 164 | action = 'modified' 165 | 166 | # Create node info for new node 167 | if action == 'added': 168 | self.nodes[node_id] = {'parents': [], 'children': []} 169 | 170 | # Make sure parents are okay if we adding or updating 171 | if action == 'added' or action == 'modified': 172 | current_parents = self.nodes[node_id]['parents'] 173 | new_parents = self.__node_parents(node_id) 174 | 175 | # When using flat filter or a recursive filter, FilteredTree 176 | # might not recognize a parent correctly, make sure to check them 177 | if action == 'added': 178 | node = self.tree.get_node(node_id) 179 | for parent_id in node.get_parents(): 180 | if (parent_id not in new_parents and parent_id not in current_parents): 181 | self.__update_node(parent_id, direction="up") 182 | 183 | # Refresh list of parents after doing checkup once again 184 | current_parents = self.nodes[node_id]['parents'] 185 | new_parents = self.__node_parents(node_id) 186 | 187 | self.nodes[node_id]['parents'] = [ 188 | parent_id for parent_id in new_parents 189 | if parent_id in self.nodes] 190 | 191 | remove_from = set(current_parents) - set(new_parents) 192 | add_to = set(new_parents) - set(current_parents) 193 | stay = set(new_parents) - set(add_to) 194 | 195 | # If we are updating a node at the root, we should take care 196 | # of the root too 197 | if direction == "down" and self.root_id in add_to: 198 | direction = "both" 199 | 200 | for parent_id in remove_from: 201 | self.send_remove_tree(node_id, parent_id) 202 | self.nodes[parent_id]['children'].remove(node_id) 203 | if direction == "both" or direction == "up": 204 | self.__update_node(parent_id, direction="up") 205 | # there might be some optimization here 206 | for parent_id in add_to: 207 | if parent_id in self.nodes: 208 | self.nodes[parent_id]['children'].append(node_id) 209 | self.send_add_tree(node_id, parent_id) 210 | if direction == "both" or direction == "up": 211 | self.__update_node(parent_id, direction="up") 212 | else: 213 | completely_updated = False 214 | raise Exception("We have a parent not in the ViewTree") 215 | # We update all the other parents 216 | if direction == "both" or direction == "up": 217 | for parent_id in stay: 218 | self.__update_node(parent_id, direction="up") 219 | # We update the node itself 220 | # Why should we call the callback only for modify? 221 | if action == 'modified': 222 | for path in self.get_paths_for_node(node_id): 223 | self.callback(action, node_id, path) 224 | 225 | # We update the children 226 | current_children = self.nodes[node_id]['children'] 227 | new_children = self.__node_children(node_id) 228 | if direction == "both" or direction == "down": 229 | for cid in new_children: 230 | if cid not in current_children: 231 | self.__update_node(cid, direction="down") 232 | 233 | elif action == 'deleted': 234 | paths = self.get_paths_for_node(node_id) 235 | children = list(reversed(self.nodes[node_id]['children'])) 236 | for child_id in children: 237 | self.send_remove_tree(child_id, node_id) 238 | self.nodes[child_id]['parents'].remove(node_id) 239 | self.__update_node(child_id, direction="down") 240 | 241 | node = self.nodes.pop(node_id) 242 | for path in paths: 243 | self.callback(action, node_id, path) 244 | 245 | # Remove node from cache 246 | for parent_id in node['parents']: 247 | self.nodes[parent_id]['children'].remove(node_id) 248 | self.__update_node(parent_id, direction="up") 249 | 250 | # We update parents who are not displayed 251 | # If the node is only hidden and still exists in the tree 252 | if self.tree.has_node(node_id): 253 | node = self.tree.get_node(node_id) 254 | for parent in node.get_parents(): 255 | if parent not in self.nodes: 256 | self.__update_node(parent, direction="up") 257 | 258 | return completely_updated 259 | 260 | def send_add_tree(self, node_id, parent_id): 261 | paths = self.get_paths_for_node(parent_id) 262 | queue = [(node_id, (node_id, ))] 263 | 264 | while queue != []: 265 | node_id, relative_path = queue.pop(0) 266 | 267 | for start_path in paths: 268 | path = start_path + relative_path 269 | self.callback('added', node_id, path) 270 | 271 | for child_id in self.nodes[node_id]['children']: 272 | queue.append((child_id, relative_path + (child_id, ))) 273 | 274 | def send_remove_tree(self, node_id, parent_id): 275 | paths = self.get_paths_for_node(parent_id) 276 | stack = [(node_id, (node_id, ), True)] 277 | 278 | while stack != []: 279 | node_id, relative_path, first_time = stack.pop() 280 | 281 | if first_time: 282 | stack.append((node_id, relative_path, False)) 283 | for child_id in self.nodes[node_id]['children']: 284 | stack.append( 285 | (child_id, relative_path + (child_id, ), True)) 286 | 287 | else: 288 | for start_path in paths: 289 | path = start_path + relative_path 290 | self.callback('deleted', node_id, path) 291 | 292 | def test_validity(self): 293 | for node_id in self.nodes: 294 | for parent_id in self.nodes[node_id]['parents']: 295 | assert node_id in self.nodes[parent_id]['children'], ( 296 | "Node '{}' is not in children of '{}'".format( 297 | node_id, parent_id)) 298 | 299 | if self.nodes[node_id]['parents'] == []: 300 | assert node_id == self.root_id, ( 301 | "Node '{}' does not have parents".format(node_id)) 302 | 303 | for parent_id in self.nodes[node_id]['children']: 304 | assert node_id in self.nodes[parent_id]['parents'], ( 305 | "Node '{}' is not in parents of '{}'".format( 306 | node_id, parent_id)) 307 | 308 | # OTHER ################################################################### 309 | def refilter(self): 310 | # Find out it there is at least one flat filter 311 | self.filter_cache = {} 312 | self.__flat = False 313 | for filter_name in self.applied_filters: 314 | filt = self.fbank.get_filter(filter_name) 315 | if filt and filt.is_flat(): 316 | self.__flat = True 317 | break 318 | 319 | # Clean the tree 320 | for node_id in reversed(self.nodes[self.root_id]['children']): 321 | self.send_remove_tree(node_id, self.root_id) 322 | 323 | self.nodes = {self.root_id: {'parents': [], 'children': []}} 324 | 325 | # Build tree again 326 | root_node = self.tree.get_root() 327 | queue = root_node.get_children() 328 | 329 | while queue: 330 | node_id = queue.pop(0) 331 | # FIXME: decide which is the best direction 332 | self.__update_node(node_id, direction="both") 333 | queue.extend(self.tree.get_node(node_id).get_children()) 334 | 335 | def __is_displayed(self, node_id): 336 | """ Should be node displayed regardless of its current status? """ 337 | if node_id and self.tree.has_node(node_id): 338 | for filter_name in self.applied_filters: 339 | filt = self.fbank.get_filter(filter_name) 340 | if filt: 341 | can_be_displayed = filt.is_displayed(node_id) 342 | if not can_be_displayed: 343 | return False 344 | else: 345 | return False 346 | return True 347 | else: 348 | return False 349 | 350 | def is_displayed(self, node_id): 351 | """ Is the node displayed at the moment? """ 352 | 353 | return node_id in self.nodes 354 | 355 | def __node_children(self, node_id): 356 | if node_id == self.root_id: 357 | raise Exception("Requesting children for root node") 358 | 359 | if not self.__flat: 360 | if self.tree.has_node(node_id): 361 | node = self.tree.get_node(node_id) 362 | else: 363 | node = None 364 | else: 365 | node = None 366 | 367 | if not node: 368 | return [] 369 | 370 | toreturn = [] 371 | for child_id in node.get_children(): 372 | if self.__is_displayed(child_id): 373 | toreturn.append(child_id) 374 | 375 | return toreturn 376 | 377 | def __node_parents(self, node_id): 378 | """ Returns parents of the given node. If node has no parent or 379 | no displayed parent, return the virtual root. 380 | """ 381 | if node_id == self.root_id: 382 | raise ValueError("Requested a parent of the root node") 383 | 384 | parents_nodes = [] 385 | # we return only parents that are not root and displayed 386 | if not self.__flat and self.tree.has_node(node_id): 387 | node = self.tree.get_node(node_id) 388 | for parent_id in node.get_parents(): 389 | if parent_id in self.nodes and self.__is_displayed(parent_id): 390 | parents_nodes.append(parent_id) 391 | 392 | # Add to root if it is an orphan 393 | if parents_nodes == []: 394 | parents_nodes = [self.root_id] 395 | 396 | return parents_nodes 397 | 398 | # This is a crude hack which is more performant that other methods 399 | def is_path_valid(self, p): 400 | valid = True 401 | i = 0 402 | if len(p) == 1: 403 | valid = False 404 | else: 405 | while valid and i < len(p) - 1: 406 | child = p[i + 1] 407 | par = p[i] 408 | if par in self.nodes: 409 | valid = (child in self.nodes[par]['children']) 410 | else: 411 | valid = False 412 | i += 1 413 | return valid 414 | 415 | def get_paths_for_node(self, node_id): 416 | if node_id == self.root_id or not self.is_displayed(node_id): 417 | return [()] 418 | else: 419 | toreturn = [] 420 | for parent_id in self.nodes[node_id]['parents']: 421 | if parent_id not in self.nodes: 422 | raise Exception("Parent %s does not exists" % parent_id) 423 | if node_id not in self.nodes[parent_id]['children']: 424 | # Dump also state of FilteredTree => useful for debugging 425 | s = "\nCurrent tree:\n" 426 | for key in self.nodes: 427 | s += "{}\n\t parents: {}\n\t children: {}\n".format( 428 | key, 429 | str(self.nodes[key]['parents']), 430 | str(self.nodes[key]['children'])) 431 | raise Exception( 432 | "{} is not children of {}\n{}".format( 433 | node_id, parent_id, s)) 434 | 435 | for parent_path in self.get_paths_for_node(parent_id): 436 | mypath = parent_path + (node_id, ) 437 | toreturn.append(mypath) 438 | self.cache_paths[node_id] = toreturn 439 | return toreturn 440 | 441 | def print_tree(self, string=False): 442 | """ Representation of tree in FilteredTree 443 | 444 | @param string: if set, instead of printing, return string for printing. 445 | """ 446 | 447 | stack = [(self.root_id, "")] 448 | 449 | output = "_" * 30 + "\n" + "FilteredTree cache\n" + "_" * 30 + "\n" 450 | 451 | while stack != []: 452 | node_id, prefix = stack.pop() 453 | 454 | output += prefix + str(node_id) + '\n' 455 | 456 | for child_id in reversed(self.nodes[node_id]['children']): 457 | stack.append((child_id, prefix + " ")) 458 | 459 | output += "_" * 30 + "\n" 460 | 461 | if string: 462 | return output 463 | else: 464 | print(output) 465 | 466 | def get_all_nodes(self): 467 | nodes = list(self.nodes.keys()) 468 | nodes.remove(self.root_id) 469 | return nodes 470 | 471 | def get_n_nodes(self, withfilters=[]): 472 | """ 473 | returns quantity of displayed nodes in this tree 474 | if the withfilters is set, returns the quantity of nodes 475 | that will be displayed if we apply those filters to the current 476 | tree. It means that the currently applied filters are also taken into 477 | account. 478 | """ 479 | return len(self.get_nodes(withfilters=withfilters)) 480 | 481 | def get_nodes(self, withfilters=[]): 482 | """ 483 | returns quantity of displayed nodes in this tree 484 | if the withfilters is set, returns the quantity of nodes 485 | that will be displayed if we apply those filters to the current 486 | tree. It means that the currently applied filters are also taken into 487 | account. 488 | """ 489 | if withfilters == []: 490 | # Use current cache 491 | return self.get_all_nodes() 492 | elif len(withfilters) == 1 and withfilters[0] in self.filter_cache: 493 | return self.filter_cache[withfilters[0]]['nodes'] 494 | else: 495 | # Filter on the current nodes 496 | filters = [] 497 | for filter_name in withfilters: 498 | filt = self.fbank.get_filter(filter_name) 499 | if filt: 500 | filters.append(filt) 501 | 502 | nodes = [] 503 | for node_id in self.nodes: 504 | if node_id == self.root_id: 505 | continue 506 | 507 | displayed = True 508 | for filt in filters: 509 | displayed = filt.is_displayed(node_id) 510 | if not displayed: 511 | break 512 | 513 | if displayed: 514 | nodes.append(node_id) 515 | 516 | return nodes 517 | 518 | def get_node_for_path(self, path): 519 | if not path or path == (): 520 | return None 521 | node_id = path[-1] 522 | # Both "if" should be benchmarked 523 | if path in self.get_paths_for_node(node_id): 524 | return node_id 525 | else: 526 | return None 527 | 528 | def next_node(self, node_id, parent_id): 529 | if node_id == self.root_id: 530 | raise Exception("Calling next_node on the root node") 531 | 532 | if node_id not in self.nodes: 533 | raise Exception("Node %s is not displayed" % node_id) 534 | 535 | parents = self.nodes[node_id]['parents'] 536 | if not parent_id: 537 | parent_id = parents[0] 538 | elif parent_id not in parents: 539 | raise Exception( 540 | "Node {} does not have parent {}".format(node_id, parent_id)) 541 | 542 | index = self.nodes[parent_id]['children'].index(node_id) 543 | if index + 1 < len(self.nodes[parent_id]['children']): 544 | return self.nodes[parent_id]['children'][index + 1] 545 | else: 546 | return None 547 | 548 | def node_all_children(self, node_id=None): 549 | if node_id is None: 550 | node_id = self.root_id 551 | return list(self.nodes[node_id]['children']) 552 | 553 | def node_has_child(self, node_id): 554 | return len(self.nodes[node_id]['children']) > 0 555 | 556 | def node_n_children(self, node_id, recursive=False): 557 | if node_id is None: 558 | node_id = self.root_id 559 | if node_id not in self.nodes: 560 | return 0 561 | if recursive: 562 | total = 0 563 | # We avoid recursion in a loop 564 | # because the dict might be updated in the meantime 565 | cids = list(self.nodes[node_id]['children']) 566 | for cid in cids: 567 | total += self.node_n_children(cid, recursive=True) 568 | total += 1 # we count the node itself ofcourse 569 | return total 570 | else: 571 | return len(self.nodes[node_id]['children']) 572 | 573 | def node_nth_child(self, node_id, n): 574 | return self.nodes[node_id]['children'][n] 575 | 576 | def node_parents(self, node_id): 577 | if node_id not in self.nodes: 578 | raise IndexError('Node %s is not displayed' % node_id) 579 | parents = list(self.nodes[node_id]['parents']) 580 | if self.root_id in parents: 581 | parents.remove(self.root_id) 582 | return parents 583 | 584 | def get_current_state(self): 585 | """ Allows to connect LibLarch widget on fly to FilteredTree 586 | 587 | Sends 'added' signal/callback for every nodes that is currently 588 | in FilteredTree. After that, FilteredTree and TreeModel are 589 | in the same state 590 | """ 591 | for node_id in self.nodes[self.root_id]['children']: 592 | self.send_add_tree(node_id, self.root_id) 593 | 594 | # FILTERS ################################################################# 595 | def list_applied_filters(self): 596 | return list(self.applied_filters) 597 | 598 | def apply_filter(self, filter_name, parameters=None, 599 | reset=None, refresh=True): 600 | """ Apply a new filter to the tree. 601 | 602 | @param filter_name: The name of an registrered filter from filters_bank 603 | @param parameters: Optional parameters to pass to the filter 604 | @param reset: Should other filters be removed? 605 | @param refresh: Should be refereshed the whole tree? 606 | (performance optimization) 607 | """ 608 | should_refilter = False 609 | if reset: 610 | self.applied_filters = [] 611 | 612 | if parameters: 613 | filt = self.fbank.get_filter(filter_name) 614 | if filt: 615 | filt.set_parameters(parameters) 616 | should_refilter = True 617 | else: 618 | raise ValueError( 619 | "No filter of name {} in the bank".format(filter_name)) 620 | 621 | if filter_name not in self.applied_filters: 622 | self.applied_filters.append(filter_name) 623 | should_refilter = True 624 | toreturn = True 625 | else: 626 | toreturn = False 627 | 628 | if refresh and should_refilter: 629 | self.refilter() 630 | return toreturn 631 | 632 | def unapply_filter(self, filter_name, refresh=True): 633 | """ Removes a filter from the tree. 634 | 635 | @param filter_name: The name of an already added filter to remove 636 | @param refresh: Should be refereshed the whole tree? 637 | (performance optimization) 638 | """ 639 | if filter_name in self.applied_filters: 640 | self.applied_filters.remove(filter_name) 641 | if refresh: 642 | self.refilter() 643 | return True 644 | else: 645 | return False 646 | 647 | def reset_filters(self, refresh=True): 648 | """ 649 | Clears all filters currently set on the tree. Can't be called on 650 | the main tree. 651 | """ 652 | self.applied_filters = [] 653 | if refresh: 654 | self.refilter() 655 | --------------------------------------------------------------------------------