diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
index f418a595fa..8259fbedc9 100644
--- a/.github/workflows/unit_tests.yml
+++ b/.github/workflows/unit_tests.yml
@@ -9,7 +9,7 @@ on:
branches: [ master ]
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.10
@@ -20,10 +20,9 @@ jobs:
run: |
python -m pip install --upgrade pip
sudo apt-get update
- sudo apt install -y libgirepository1.0-dev gir1.2-gtk-3.0 libgtksourceview-4-dev
+ sudo apt install -y libgirepository1.0-dev gir1.2-gtk-4.0 libgtksourceview-5-dev
pip install --user -e git+https://github.com/getting-things-gnome/liblarch.git#egg=liblarch
pip install --user pytest pycairo PyGObject caldav mock lxml
- name: Run unit tests with Pytest
run: |
./run-tests
-
diff --git a/GTG/backends/__init__.py b/GTG/backends/__init__.py
index d7b740e78f..fe84cb3adb 100644
--- a/GTG/backends/__init__.py
+++ b/GTG/backends/__init__.py
@@ -170,13 +170,4 @@ def get_saved_backends_list(self):
backend_data['backend'] = module.Backend(backend_data)
backends.append(backend_data)
- # If no backend available, we create a new using localfile. Dic
- # will be filled in by the backend
- if not backends:
- dic = BackendFactory().get_new_backend_dict(
- "backend_localfile")
-
- dic["first_run"] = True
- backends.append(dic)
-
return backends
diff --git a/GTG/backends/backend_caldav.py b/GTG/backends/backend_caldav.py
index b199eeb833..d3edecf4ae 100644
--- a/GTG/backends/backend_caldav.py
+++ b/GTG/backends/backend_caldav.py
@@ -34,7 +34,7 @@
from GTG.backends.periodic_import_backend import PeriodicImportBackend
from GTG.core.dates import LOCAL_TIMEZONE, Accuracy, Date
from GTG.core.interruptible import interruptible
-from GTG.core.task import DisabledSyncCtx, Task
+from GTG.core.tasks import Task, Status
from vobject import iCalendar
logger = logging.getLogger(__name__)
@@ -107,7 +107,7 @@ def initialize(self) -> None:
@interruptible
def do_periodic_import(self) -> None:
- with self.datastore.get_backend_mutex():
+ with self.datastore.mutex:
self._do_periodic_import()
@interruptible
@@ -115,8 +115,8 @@ def set_task(self, task: Task) -> None:
if self._parameters["is-first-run"] or not self._cache.initialized:
logger.warning("not loaded yet, ignoring set_task")
return
- with self.datastore.get_backend_mutex():
- return self._set_task(task)
+ with self.datastore.mutex:
+ self.datastore.tasks.add(task)
@interruptible
def remove_task(self, tid: str) -> None:
@@ -126,8 +126,8 @@ def remove_task(self, tid: str) -> None:
if not tid:
logger.warning("no task id passed to remove_task call, ignoring")
return
- with self.datastore.get_backend_mutex():
- return self._remove_task(tid)
+ with self.datastore.mutex:
+ return self.datastore.tasks.remove(tid)
#
# real main methods
@@ -151,10 +151,9 @@ def _do_periodic_import(self) -> None:
self._cache.initialized = True
def _set_task(self, task: Task) -> None:
- logger.debug('set_task todo for %r', task.get_uuid())
- with DisabledSyncCtx(task, sync_on_exit=False):
- seq_value = SEQUENCE.get_gtg(task, self.namespace)
- SEQUENCE.write_gtg(task, seq_value + 1, self.namespace)
+ logger.debug('set_task todo for %r', task.id)
+ seq_value = SEQUENCE.get_gtg(task, self.namespace)
+ SEQUENCE.write_gtg(task, seq_value + 1, self.namespace)
todo, calendar = self._get_todo_and_calendar(task)
if not calendar:
logger.info("%r has no calendar to be synced with", task)
@@ -259,7 +258,7 @@ def _clean_task_missing_from_backend(self, uid: str,
if do_delete: # the task was missing for a good reason
counts['deleted'] += 1
self._cache.del_todo(uid)
- self.datastore.request_task_deletion(uid)
+ self.datastore.tasks.remove(uid)
@staticmethod
def _denorm_children_on_vtodos(todos: list):
@@ -298,12 +297,12 @@ def _import_calendar_todos(self, calendar: iCalendar,
uid = UID_FIELD.get_dav(todo)
self._cache.set_todo(todo, uid)
# Updating and creating task according to todos
- task = self.datastore.get_task(uid)
+ task = self.datastore.tasks.lookup[uid]
if not task: # not found, creating it
- task = self.datastore.task_factory(uid)
- with DisabledSyncCtx(task):
- Translator.fill_task(todo, task, self.namespace)
- self.datastore.push_task(task)
+ task = Task()
+ task.id = uid
+ Translator.fill_task(todo, task, self.namespace, self.datastore)
+ self.datastore.tasks.add(task)
counts['created'] += 1
else:
result = self._update_task(task, todo)
@@ -313,14 +312,13 @@ def _import_calendar_todos(self, calendar: iCalendar,
logger.warning("Shouldn't be diff for %r", uid)
def _update_task(self, task: Task, todo: iCalendar, force: bool = False):
- with DisabledSyncCtx(task):
- if not force:
- task_seq = SEQUENCE.get_gtg(task, self.namespace)
- todo_seq = SEQUENCE.get_dav(todo)
- if task_seq >= todo_seq:
- return 'unchanged'
- Translator.fill_task(todo, task, self.namespace)
- return 'updated'
+ if not force:
+ task_seq = SEQUENCE.get_gtg(task, self.namespace)
+ todo_seq = SEQUENCE.get_dav(todo)
+ if task_seq >= todo_seq:
+ return 'unchanged'
+ Translator.fill_task(todo, task, self.namespace, self.datastore)
+ return 'updated'
def __sort_todos(self, todos: list, max_depth: int = 500):
"""For a given list of todos, will return first the one without parent
@@ -336,7 +334,7 @@ def __sort_todos(self, todos: list, max_depth: int = 500):
parents = PARENT_FIELD.get_dav(todo)
if (not parents # no parent mean no relationship on build
or parents[0] in known_todos # already known parent
- or self.datastore.get_task(uid)): # already known uid
+ or self.datastore.tasks.lookup[uid]): # already known uid
yield todo
known_todos.add(uid)
if loop >= MAX_CALENDAR_DEPTH:
@@ -345,8 +343,7 @@ def __sort_todos(self, todos: list, max_depth: int = 500):
def _get_calendar_tasks(self, calendar: iCalendar):
"""Getting all tasks that has the calendar tag"""
- for uid in self.datastore.get_all_tasks():
- task = self.datastore.get_task(uid)
+ for task in self.datastore.tasks.data:
if CATEGORIES.has_calendar_tag(task, calendar):
yield uid, task
@@ -555,11 +552,11 @@ def _get_dt_for_dav_writing(value):
class Status(Field):
- DEFAULT_STATUS = (Task.STA_ACTIVE, 'NEEDS-ACTION')
- _status_mapping = ((Task.STA_ACTIVE, 'NEEDS-ACTION'),
- (Task.STA_ACTIVE, 'IN-PROCESS'),
- (Task.STA_DISMISSED, 'CANCELLED'),
- (Task.STA_DONE, 'COMPLETED'))
+ DEFAULT_STATUS = (Status.ACTIVE, 'NEEDS-ACTION')
+ _status_mapping = ((Status.ACTIVE, 'NEEDS-ACTION'),
+ (Status.ACTIVE, 'IN-PROCESS'),
+ (Status.DISMISSED, 'CANCELLED'),
+ (Status.DONE, 'COMPLETED'))
def _translate(self, gtg_value=None, dav_value=None):
for gtg_status, dav_status in self._status_mapping:
@@ -574,9 +571,9 @@ def write_dav(self, vtodo: iCalendar, value):
def get_gtg(self, task: Task, namespace: str = None) -> str:
active, done = 0, 0
for subtask in self._browse_subtasks(task):
- if subtask.get_status() == Task.STA_ACTIVE:
+ if subtask.is_active:
active += 1
- elif subtask.get_status() == Task.STA_DONE:
+ elif subtask.status == Status.DONE:
done += 1
if active and done:
return 'IN-PROCESS'
@@ -599,9 +596,9 @@ class PercentComplete(Field):
def get_gtg(self, task: Task, namespace: str = None) -> str:
total_cnt, done_cnt = 0, 0
for subtask in self._browse_subtasks(task):
- if subtask.get_status() != Task.STA_DISMISSED:
+ if subtask.status != Status.DISMISSED:
total_cnt += 1
- if subtask.get_status() == Task.STA_DONE:
+ if subtask.status == Status.DONE:
done_cnt += 1
if total_cnt:
return str(int(100 * done_cnt / total_cnt))
@@ -645,7 +642,7 @@ def get_calendar_tag(self, calendar: iCalendar) -> str:
return self.to_tag(calendar.name, DAV_TAG_PREFIX)
def has_calendar_tag(self, task: Task, calendar: iCalendar) -> bool:
- return self.get_calendar_tag(calendar) in task.get_tags_name()
+ return self.get_calendar_tag(calendar) in [t.name for t in task.tags]
class AttributeField(Field):
@@ -758,10 +755,10 @@ def _extract_plain_text(self, task: Task) -> str:
subtask = task.req.get_task(line[2:-2].strip())
if not subtask:
continue
- if subtask.get_status() == Task.STA_DONE:
- result += f"[x] {subtask.get_title()}\n"
+ if subtask.status == Status.DONE:
+ result += f"[x] {subtask.title}\n"
else:
- result += f"[ ] {subtask.get_title()}\n"
+ result += f"[ ] {subtask.title}\n"
else:
result += line.strip() + '\n'
return result.strip()
@@ -849,10 +846,9 @@ def __repr__(self):
class OrderField(Field):
def get_gtg(self, task: Task, namespace: str = None):
- parents = task.get_parents()
- if not parents or not parents[0]:
+ parent = task.parent
+ if not parent:
return
- parent = task.req.get_task(parents[0])
uid = UID_FIELD.get_gtg(task, namespace)
return parent.get_child_index(uid)
@@ -866,7 +862,7 @@ class Recurrence(Field):
DAV_DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
def get_gtg(self, task: Task, namespace: str = None) -> tuple:
- return task.get_recurring(), task.get_recurring_term()
+ return task._is_recurring, task.recurring_term
def get_dav(self, todo=None, vtodo=None) -> tuple:
if todo:
@@ -975,16 +971,16 @@ def fill_vtodo(cls, task: Task, calendar_name: str, namespace: str,
return vcal
@classmethod
- def fill_task(cls, todo: iCalendar, task: Task, namespace: str):
+ def fill_task(cls, todo: iCalendar, task: Task, namespace: str, datastore):
nmspc = {'namespace': namespace}
- with DisabledSyncCtx(task):
- for field in cls.fields:
- field.set_gtg(todo, task, **nmspc)
- task.set_attribute("url", str(todo.url), **nmspc)
- task.set_attribute("calendar_url", str(todo.parent.url), **nmspc)
- task.set_attribute("calendar_name", todo.parent.name, **nmspc)
- if not CATEGORIES.has_calendar_tag(task, todo.parent):
- task.add_tag(CATEGORIES.get_calendar_tag(todo.parent))
+ for field in cls.fields:
+ field.set_gtg(todo, task, **nmspc)
+ task.set_attribute("url", str(todo.url), **nmspc)
+ task.set_attribute("calendar_url", str(todo.parent.url), **nmspc)
+ task.set_attribute("calendar_name", todo.parent.name, **nmspc)
+ if not CATEGORIES.has_calendar_tag(task, todo.parent):
+ tag = datastore.tags.new(CATEGORIES.get_calendar_tag(todo.parent))
+ task.add_tag(tag)
return task
@classmethod
diff --git a/GTG/backends/backend_localfile.py b/GTG/backends/backend_localfile.py
deleted file mode 100644
index 6ff41b1eca..0000000000
--- a/GTG/backends/backend_localfile.py
+++ /dev/null
@@ -1,350 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-"""
-Localfile is a read/write backend that will store your tasks in an XML file
-This file will be in your $XDG_DATA_DIR/gtg folder.
-
-This backend contains comments that are meant as a reference, in case someone
-wants to write a backend.
-"""
-
-import os
-import logging
-
-from GTG.backends.backend_signals import BackendSignals
-from GTG.backends.generic_backend import GenericBackend
-from GTG.core.dirs import DATA_DIR
-from gettext import gettext as _
-from GTG.core import xml
-from GTG.core import firstrun_tasks
-from GTG.core import versioning
-from GTG.core.tag import SEARCH_TAG_PREFIX
-
-from typing import Dict
-from lxml import etree as et
-
-log = logging.getLogger(__name__)
-
-
-class Backend(GenericBackend):
- """
- Localfile backend, which stores your tasks in a XML file in the standard
- XDG_DATA_DIR/gtg folder (the path is configurable).
- An instance of this class is used as the default backend for GTG.
- This backend loads all the tasks stored in the localfile after it's enabled
- and from that point on just writes the changes to the file: it does not
- listen for eventual file changes
- """
-
- # General description of the backend: these are used to show a description
- # of the backend to the user when s/he is considering adding it.
- # BACKEND_NAME is the name of the backend used internally (it must be
- # unique).
- # Please note that BACKEND_NAME and BACKEND_ICON_NAME should *not* be
- # translated.
- _general_description = {
- GenericBackend.BACKEND_NAME: 'backend_localfile',
- GenericBackend.BACKEND_ICON: 'folder',
- GenericBackend.BACKEND_HUMAN_NAME: _('Local File'),
- GenericBackend.BACKEND_AUTHORS: ['Lionel Dricot',
- 'Luca Invernizzi'],
- GenericBackend.BACKEND_TYPE: GenericBackend.TYPE_READWRITE,
- GenericBackend.BACKEND_DESCRIPTION:
- _(('Your tasks are saved in a text file (XML format). '
- ' This is the most basic and the default way '
- 'for GTG to save your tasks.')),
- }
-
- # These are the parameters to configure a new backend of this type. A
- # parameter has a name, a type and a default value.
- # Here, we define a parameter "path", which is a string, and has a default
- # value as a random file in the default path
- _static_parameters = {
- "path": {
- GenericBackend.PARAM_TYPE: GenericBackend.TYPE_STRING,
- GenericBackend.PARAM_DEFAULT_VALUE:
- 'gtg_data.xml'}}
-
- def __init__(self, parameters: Dict):
- """
- Instantiates a new backend.
-
- @param parameters: A dictionary of parameters, generated from
- _static_parameters. A few parameters are added to those, the list of
- these is in the "DefaultBackend" class, look for the KEY_* constants.
-
- The backend should take care if one expected value is None or
- does not exist in the dictionary.
- """
- super().__init__(parameters)
-
- if self.KEY_DEFAULT_BACKEND not in parameters:
- parameters[self.KEY_DEFAULT_BACKEND] = True
-
- def get_path(self) -> str:
- """Return the current path to XML
-
- Path can be relative to projects.xml
- """
- path = self._parameters['path']
-
- # This is local path, convert it to absolute path
- if os.sep not in path:
- path = os.path.join(DATA_DIR, path)
-
- return os.path.abspath(path)
-
-
- def initialize(self):
- """ This is called when a backend is enabled """
-
- super(Backend, self).initialize()
- filepath = self.get_path()
-
- if not os.path.isfile(filepath):
- root = firstrun_tasks.generate()
- xml.create_dirs(self.get_path())
- xml.save_file(self.get_path(), root)
-
- self.data_tree = xml.open_file(filepath, 'gtgData')
- self.task_tree = self.data_tree.find('tasklist')
- self.tag_tree = self.data_tree.find('taglist')
- self.search_tree = self.data_tree.find('searchlist')
-
- self.datastore.load_tag_tree(self.tag_tree)
- self.datastore.load_search_tree(self.search_tree)
-
- # Make safety daily backup after loading
- xml.save_file(self.get_path(), self.data_tree)
- xml.write_backups(self.get_path())
-
-
- def this_is_the_first_run(self, _) -> None:
- """ Called upon the very first GTG startup.
-
- This function is needed only in this backend, because it can be used
- as default one. The xml parameter is an object containing GTG default
- tasks. It will be saved to a file, and the backend will be set as
- default.
-
- @param xml: an xml object containing the default tasks.
- """
-
- filepath = self.get_path()
- self.do_first_run_versioning(filepath)
-
-
- self._parameters[self.KEY_DEFAULT_BACKEND] = True
-
- # Load the newly created file
- self.data_tree = xml.open_file(self.get_path(), 'gtgData')
- self.task_tree = self.data_tree.find('tasklist')
- self.tag_tree = self.data_tree.find('taglist')
- xml.backup_used = None
-
-
- def do_first_run_versioning(self, filepath: str) -> None:
- """If there is an old file around needing versioning, convert it, then rename the old file."""
- old_path = self.find_old_path(DATA_DIR)
- if old_path is not None:
- log.warning('Found old file: %r. Running versioning code.', old_path)
- tree = versioning.convert(old_path, self.datastore)
- xml.save_file(filepath, tree)
- os.rename(old_path, old_path + '.imported')
- else:
- root = firstrun_tasks.generate()
- xml.create_dirs(self.get_path())
- xml.save_file(self.get_path(), root)
-
-
- def find_old_path(self, datadir: str) -> str:
- """Reliably find the old data files."""
- # used by which version?
- path = os.path.join(datadir, 'gtg_tasks.xml')
- if os.path.isfile(path):
- return path
- # used by (at least) 0.3.1-4
- path = os.path.join(datadir, 'projects.xml')
- if os.path.isfile(path):
- return self.find_old_uuid_path(path)
- return None
-
-
- def find_old_uuid_path(self, path: str) -> str:
- """Find the first backend entry with module='backend_localfile' and return its path."""
- old_tree = xml.open_file(path, 'config')
- for backend in old_tree.findall('backend'):
- module = backend.get('module')
- if module == 'backend_localfile':
- uuid_path = backend.get('path')
- if os.path.isfile(uuid_path):
- return uuid_path
- return None
-
-
- def start_get_tasks(self) -> None:
- """ This function starts submitting the tasks from the XML file into
- GTG core. It's run as a separate thread.
-
- @return: start_get_tasks() might not return or finish
- """
-
- for element in self.task_tree.iter('task'):
- tid = element.get('id')
- task = self.datastore.task_factory(tid)
-
- if task:
- task = xml.task_from_element(task, element)
- self.datastore.push_task(task)
-
-
- def set_task(self, task) -> None:
- """
- This function is called from GTG core whenever a task should be
- saved, either because it's a new one or it has been modified.
- This function will look into the loaded XML object if the task is
- present, and if it's not, it will create it. Then, it will save the
- task data in the XML object.
-
- @param task: the task object to save
- """
-
- tid = task.get_id()
- element = xml.task_to_element(task)
- existing = self.task_tree.findall(f"task[@id='{tid}']")
-
- if existing and element != existing[0]:
- existing[0].getparent().replace(existing[0], element)
-
- else:
- self.task_tree.append(element)
-
- # Write the xml
- xml.save_file(self.get_path(), self.data_tree)
-
- def remove_task(self, tid: str) -> None:
- """ This function is called from GTG core whenever a task must be
- removed from the backend. Note that the task could be not present here.
-
- @param tid: the id of the task to delete
- """
-
- element = self.task_tree.findall(f'task[@id="{tid}"]')
-
- if element:
- element[0].getparent().remove(element[0])
- xml.save_file(self.get_path(), self.data_tree)
-
- def save_tags(self, tagnames, tagstore) -> None:
- """Save changes to tags and saved searches."""
-
- already_saved = []
- self.search_tree.clear()
- self.tag_tree.clear()
-
- for tagname in tagnames:
- if tagname in already_saved:
- continue
-
- tag = tagstore.get_node(tagname)
-
- attributes = tag.get_all_attributes(butname=True, withparent=True)
- if "special" in attributes:
- continue
-
- if tag.is_search_tag():
- root = self.search_tree
- tag_type = 'savedSearch'
- else:
- root = self.tag_tree
- tag_type = 'tag'
-
- tid = str(tag.tid)
- element = root.findall(f'{tag_type}[@id="{tid}"]')
-
- if len(element) == 0:
- element = et.SubElement(self.task_tree, tag_type)
- root.append(element)
- else:
- element = element[0]
-
- # Don't save the @ in the name
- element.set('id', tid)
- element.set('name', tag.get_friendly_name())
-
- # Remove these and don't re-add them if not needed
- element.attrib.pop('icon', None)
- element.attrib.pop('color', None)
- element.attrib.pop('parent', None)
-
- for attr in attributes:
- # skip labels for search tags
- if tag.is_search_tag() and attr == 'label':
- continue
-
- value = tag.get_attribute(attr)
-
- if value:
- if attr == 'color':
- value = value[1:]
- element.set(attr, value)
-
- already_saved.append(tagname)
-
- xml.save_file(self.get_path(), self.data_tree)
-
- def used_backup(self):
- """ This functions return a boolean value telling if backup files
- were used when instantiating Backend class.
- """
- return xml.backup_used is not None
-
- def backup_file_info(self):
- """This functions returns status of the attempt to recover
- gtg_tasks.xml
- """
-
- back = xml.backup_used
-
- if not back:
- return
-
- elif back['filepath']:
- return f"Recovered from backup made on: {back['time']}"
-
- else:
- return 'No backups found. Created a new file'
-
-
- def notify_user_about_backup(self) -> None:
- """ This function causes the inforbar to show up with the message
- about file recovery.
- """
- message = _(
- 'Oops, something unexpected happened! '
- 'GTG tried to recover your tasks from backups. \n'
- ) + self.backup_file_info()
-
- BackendSignals().interaction_requested(
- self.get_id(), message,
- BackendSignals().INTERACTION_INFORM, 'on_continue_clicked')
-
- def on_continue_clicked(self, *args) -> None:
- """ Callback when the user clicks continue in the infobar."""
- pass
diff --git a/GTG/backends/generic_backend.py b/GTG/backends/generic_backend.py
index c48b125d54..f074c60799 100644
--- a/GTG/backends/generic_backend.py
+++ b/GTG/backends/generic_backend.py
@@ -30,7 +30,6 @@
import logging
from GTG.backends.backend_signals import BackendSignals
-from GTG.core.tag import ALLTASKS_TAG
from GTG.core.dirs import SYNC_DATA_DIR
from GTG.core.interruptible import _cancellation_point
from GTG.core.keyring import Keyring
@@ -38,6 +37,8 @@
log = logging.getLogger(__name__)
PICKLE_BACKUP_NBR = 2
+ALLTASKS_TAG = 'gtg-tags-all'
+
class GenericBackend():
"""
diff --git a/GTG/backends/meson.build b/GTG/backends/meson.build
index 4ede3fc71e..02908f4e24 100644
--- a/GTG/backends/meson.build
+++ b/GTG/backends/meson.build
@@ -1,6 +1,5 @@
gtg_backend_sources = [
'__init__.py',
- 'backend_localfile.py',
'backend_caldav.py',
'backend_signals.py',
'generic_backend.py',
diff --git a/GTG/core/base_store.py b/GTG/core/base_store.py
index ed13638b10..0d4b23a105 100644
--- a/GTG/core/base_store.py
+++ b/GTG/core/base_store.py
@@ -105,10 +105,16 @@ def remove(self, item_id: UUID) -> None:
"""Remove an existing item from the store."""
item = self.lookup[item_id]
- parent = item.parent
+
+ try:
+ parent = item.parent
+
+ for child in item.children:
+ del self.lookup[child.id]
+
+ except AttributeError:
+ parent = None
- for child in item.children:
- del self.lookup[child.id]
if parent:
parent.children.remove(item)
diff --git a/GTG/core/clipboard.py b/GTG/core/clipboard.py
index 01b0c81ba7..2eefb09f9d 100644
--- a/GTG/core/clipboard.py
+++ b/GTG/core/clipboard.py
@@ -24,10 +24,10 @@
class TaskClipboard():
- def __init__(self, req):
+ def __init__(self, ds):
self.description = None
self.content = []
- self.req = req
+ self.ds = ds
""""take two Gtk.TextIter as parameter and copy the
"""
@@ -56,9 +56,7 @@ def copy(self, start, stop, bullet=None):
if hasattr(ta, 'is_subtask'):
is_subtask = True
tid = ta.child
- tas = self.req.get_task(tid)
- tas.set_to_keep()
- tas.sync()
+ tas = self.ds.tasks.lookup[tid]
self.content.append(['subtask', tid])
if not is_subtask:
if end_line.get_line() < stop.get_line():
diff --git a/GTG/core/config.py b/GTG/core/config.py
index 9d3138e2c6..075834086b 100644
--- a/GTG/core/config.py
+++ b/GTG/core/config.py
@@ -43,6 +43,7 @@
'tasklist_sort_column': 5,
'tasklist_sort_order': 1,
"font_name": "",
+ "font_size": 0,
'hour': "00",
'min': "00",
'autoclean': True,
diff --git a/GTG/core/datastore.py b/GTG/core/datastore.py
index eb4a7d3643..df88580e41 100644
--- a/GTG/core/datastore.py
+++ b/GTG/core/datastore.py
@@ -1,6 +1,6 @@
# -----------------------------------------------------------------------------
# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
+# Copyright (c) The GTG Team
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
@@ -15,380 +15,461 @@
# You should have received a copy of the GNU General Public License along with
# this program. If not, see .
# -----------------------------------------------------------------------------
-"""
-Contains the Datastore object, which is the manager of all the active backends
-(both enabled and disabled ones)
-"""
-from collections import deque
+"""The datastore ties together all the basic type stores and backends."""
+
+import os
import threading
import logging
-import uuid
-
+import shutil
+from datetime import datetime, timedelta
+from time import time
+import random
+import string
+
+from GTG.core.tasks import TaskStore, Filter
+from GTG.core.tags import TagStore, Tag
+from GTG.core.saved_searches import SavedSearchStore
+from GTG.core import firstrun_tasks
+from GTG.core.dates import Date
from GTG.backends.backend_signals import BackendSignals
from GTG.backends.generic_backend import GenericBackend
-from GTG.core.config import CoreConfig
-from GTG.core import requester
-from GTG.core.search import parse_search_query, search_filter, InvalidQuery
-from GTG.core.tag import Tag, SEARCH_TAG, SEARCH_TAG_PREFIX
-from GTG.core.task import Task
-from GTG.core.treefactory import TreeFactory
-from GTG.core.borg import Borg
+import GTG.core.info as info
+
+from lxml import etree as et
+
+from typing import Optional
log = logging.getLogger(__name__)
-TAG_XMLROOT = "tagstore"
-class DataStore():
- """
- A wrapper around all backends that is responsible for keeping the backend
- instances. It can enable, disable, register and destroy backends, and acts
- as interface between the backends and GTG core.
- You should not interface yourself directly with the DataStore: use the
- Requester instead (which also sends signals as you issue commands).
- """
+class Datastore:
- def __init__(self, global_conf=CoreConfig()):
- """
- Initializes a DataStore object
- """
- # dictionary {backend_name_string: Backend instance}
+ #: Amount of backups to keep
+ BACKUPS_NUMBER = 7
+
+
+ def __init__(self) -> None:
+ self.tasks = TaskStore()
+ self.tags = TagStore()
+ self.saved_searches = SavedSearchStore()
+ self.xml_tree = None
+
+ self._mutex = threading.Lock()
self.backends = {}
- self.treefactory = TreeFactory()
- self._tasks = self.treefactory.get_tasks_tree()
- self.requester = requester.Requester(self, global_conf)
- self.tagfile_loaded = False
- self._tagstore = self.treefactory.get_tags_tree(self.requester)
self._backend_signals = BackendSignals()
- self.conf = global_conf
- self.tag_idmap = {}
+
+ # When a backup has to be used, this will be filled with
+ # info on the backup used
+ self.backup_info = {}
# Flag when turned to true, all pending operation should be
# completed and then GTG should quit
self.please_quit = False
- # The default backend must be loaded first. This flag turns to True
- # when the default backend loading has finished.
- self.is_default_backend_loaded = False
- self._backend_signals.connect('default-backend-loaded',
- self._activate_non_default_backends)
- self._backend_mutex = threading.Lock()
+ # Count of tasks for each pane and each tag
+ self.task_count = {
+ 'open': {'all': 0, 'untagged': 0},
+ 'actionable': {'all': 0, 'untagged': 0},
+ 'closed': {'all': 0, 'untagged': 0},
+ }
- # Accessor to embedded objects in DataStore ##############################
- def get_tagstore(self):
- """
- Return the Tagstore associated with this DataStore
+ self.data_path = None
+ self._activate_non_default_backends()
- @return GTG.core.tagstore.TagStore: the tagstore object
- """
- return self._tagstore
- def get_requester(self):
- """
- Return the Requester associate with this DataStore
+ @property
+ def mutex(self) -> threading.Lock:
+ return self._mutex
- @returns GTG.core.requester.Requester: the requester associated with
- this datastore
- """
- return self.requester
- def get_tasks_tree(self):
- """
- Return the Tree with all the tasks contained in this Datastore
+ def load_data(self, data: et.Element) -> None:
+ """Load data from an lxml element object."""
- @returns GTG.core.tree.Tree: a task tree (the main one)
- """
- return self._tasks
+ self.saved_searches.from_xml(data.find('searchlist'))
+ self.tags.from_xml(data.find('taglist'))
+ self.tasks.from_xml(data.find('tasklist'), self.tags)
- # Tags functions ##########################################################
- def _add_new_tag(self, name, tag, filter_func, parameters, parent_id=None):
- """ Add tag into a tree """
- if self._tagstore.has_node(name):
- raise IndexError(f'tag {name} was already in the datastore')
+ self.refresh_task_count()
- self._tasks.add_filter(name, filter_func, parameters=parameters)
- self._tagstore.add_node(tag, parent_id=parent_id)
- tag.set_save_callback(self.save)
- def new_tag(self, name, attributes={}, tid=None):
- """
- Create a new tag
+ def load_file(self, path: str) -> None:
+ """Load data from a file."""
- @returns GTG.core.tag.Tag: the new tag
- """
- parameters = {'tag': name}
- tag = Tag(name, req=self.requester, attributes=attributes, tid=tid)
- self._add_new_tag(name, tag, self.treefactory.tag_filter, parameters)
- return tag
+ bench_start = 0
- def new_search_tag(self, name, query, attributes={}, tid=None, save=True):
- """
- Create a new search tag
+ if log.isEnabledFor(logging.DEBUG):
+ bench_start = time()
+
+ parser = et.XMLParser(remove_blank_text=True, strip_cdata=False)
+
+ with open(path, 'rb') as stream:
+ self.xml_tree = et.parse(stream, parser=parser)
+ self.load_data(self.xml_tree)
+
+ if log.isEnabledFor(logging.DEBUG):
+ log.debug('Processed file %s in %.2fms',
+ path, (time() - bench_start) * 1000)
+
+ # Store path, so we can call save() on it
+ self.data_path = path
+
+
+ def generate_xml(self) -> et.ElementTree:
+ """Generate lxml element object with all data."""
+
+ root = et.Element('gtgData')
+ root.set('appVersion', info.VERSION)
+ root.set('xmlVersion', '2')
+
+ root.append(self.tags.to_xml())
+ root.append(self.saved_searches.to_xml())
+ root.append(self.tasks.to_xml())
+
+ return et.ElementTree(root)
+
+
+ def write_file(self, path: str) -> None:
+ """Generate and write xml file."""
+
+ tree = self.generate_xml()
+
+ with open(path, 'wb') as stream:
+ tree.write(stream, xml_declaration=True, pretty_print=True,
+ encoding='UTF-8')
+
+
+ def save(self, path: Optional[str] = None) -> None:
+ """Write GTG data file."""
+
+ path = path or self.data_path
+ temp_file = path + '__'
+ bench_start = 0
- @returns GTG.core.tag.Tag: the new search tag/None for a invalid query
- """
try:
- parameters = parse_search_query(query)
- except InvalidQuery as error:
- log.warning("Problem with parsing query %r (skipping): %s", query, error.message)
- return None
+ os.rename(path, temp_file)
+ except IOError:
+ log.error('Error renaming temp file %r', temp_file)
- # Create own copy of attributes and add special attributes label, query
- init_attr = dict(attributes)
- init_attr["label"] = name
- init_attr["query"] = query
+ if log.isEnabledFor(logging.DEBUG):
+ bench_start = time()
- name = SEARCH_TAG_PREFIX + name
- tag = Tag(name, req=self.requester, attributes=init_attr, tid=tid)
- self._add_new_tag(name, tag, search_filter, parameters,
- parent_id=SEARCH_TAG)
+ base_dir = os.path.dirname(path)
- if save:
- self.save_tagtree()
+ try:
+ os.makedirs(base_dir, exist_ok=True)
+ except IOError as error:
+ log.error("Error while creating directories: %r", error)
- return tag
+ try:
+ self.write_file(path)
+ except (IOError, FileNotFoundError):
+ log.error('Could not write XML file at %r', path)
+ return
- def remove_tag(self, name):
- """ Removes a tag from the tagtree """
- if self._tagstore.has_node(name):
- self._tagstore.del_node(name)
- self.save_tagtree()
- else:
- raise IndexError(f"There is no tag {name}")
- def rename_tag(self, oldname, newname):
- """ Give a tag a new name
+ if log.isEnabledFor(logging.DEBUG):
+ log.debug('Saved file %s in %.2fms',
+ path, (time() - bench_start) * 1000)
+
+ try:
+ os.remove(temp_file)
+ except FileNotFoundError:
+ pass
- This function is quite high-level method. Right now,
- only renaming search bookmarks are implemented by removing
- the old one and creating almost identical one with the new name.
+ self.write_backups(path)
- NOTE: Implementation for regular tasks must be much more robust.
- You have to replace all occurences of tag name in tasks descriptions,
- their parameters and backend settings (synchronize only certain tags).
- Have a fun with implementing it!
- """
+ def print_info(self) -> None:
+ """Print statistics and information on this datastore."""
- tag = self.get_tag(oldname)
+ tasks = self.tasks.count()
+ initialized = 'Initialized' if tasks > 0 else 'Empty'
- if not tag.is_search_tag():
- for task_id in tag.get_related_tasks():
+ print(f'Datastore [{initialized}]')
+ print(f'- Tags: {self.tags.count()}')
+ print(f'- Saved Searches: {self.saved_searches.count()}')
+ print(f'- Tasks: {self.tasks.count()}')
- # Store old tag attributes
- color = tag.get_attribute("color")
- icon = tag.get_attribute("icon")
- tid = tag.tid
- my_task = self.get_task(task_id)
- my_task.rename_tag(oldname, newname)
+ def refresh_task_for_tag(self, tag: Tag) -> None:
+ """Refresh task counts for a tag."""
- # Restore attributes on tag
- new_tag = self.get_tag(newname)
- new_tag.tid = tid
+ try:
+ tag.task_count_open = self.task_count['open'][tag.name]
+ except KeyError:
+ tag.task_count_open = 0
- if color:
- new_tag.set_attribute("color", color)
+ try:
+ tag.task_count_closed = self.task_count['closed'][tag.name]
+ except KeyError:
+ tag.task_count_closed = 0
- if icon:
- new_tag.set_attribute("icon", icon)
+ try:
+ tag.task_count_actionable = self.task_count['actionable'][tag.name]
+ except KeyError:
+ tag.task_count_actionable = 0
- self.remove_tag(oldname)
- self.save_tagtree()
- return None
+ def refresh_task_count(self) -> None:
+ """Refresh task count dictionary."""
- query = tag.get_attribute("query")
- self.remove_tag(oldname)
+ def count_tasks(count: dict, tasklist: list):
+ for task in tasklist:
+ count['all'] += 1
- # Make sure the name is unique
- if newname.startswith('!'):
- newname = '_' + newname
+ if not task.tags:
+ count['untagged'] += 1
- label, num = newname, 1
- while self._tagstore.has_node(SEARCH_TAG_PREFIX + label):
- num += 1
- label = newname + " " + str(num)
+ for tag in task.tags:
+ val = count.get(tag.name, 0)
+ count[tag.name] = val + 1
- self.new_search_tag(label, query, {}, tag.tid)
+ if task.children:
+ count_tasks(count, task.children)
- def get_tag(self, tagname):
- """
- Returns tag object
+ # Reset task counts
+ self.task_count = {
+ 'open': {'all': 0, 'untagged': 0},
+ 'actionable': {'all': 0, 'untagged': 0},
+ 'closed': {'all': 0, 'untagged': 0},
+ }
- @return GTG.core.tag.Tag
- """
- if self._tagstore.has_node(tagname):
- return self._tagstore.get_node(tagname)
- else:
- return None
+ count_tasks(self.task_count['open'],
+ self.tasks.filter(Filter.ACTIVE))
+
+ count_tasks(self.task_count['closed'],
+ self.tasks.filter(Filter.CLOSED))
+
+ count_tasks(self.task_count['actionable'],
+ self.tasks.filter(Filter.ACTIONABLE))
- def load_tag_tree(self, tag_tree):
- """
- Loads the tag tree from a xml file
- """
- for element in tag_tree.iter('tag'):
- tid = element.get('id')
- name = element.get('name')
- color = element.get('color')
- icon = element.get('icon')
- parent = element.get('parent')
- nonactionable = element.get('nonactionable')
+ def notify_tag_change(self, tag) -> None:
+ """Notify tasks that this tag has changed."""
+
+ for task in self.tasks.lookup.values():
+ if tag in task.tags:
+ task.notify('icons')
+ task.notify('row_css')
+ task.notify('tag_colors')
+ task.notify('show_tag_colors')
- tag_attrs = {}
- if color:
- tag_attrs['color'] = '#' + color
+ def first_run(self, path: str) -> et.Element:
+ """Write initial data file."""
+
+ self.xml_tree = firstrun_tasks.generate()
+ self.load_data(self.xml_tree)
+ self.save(path)
+
+
+ def do_first_run_versioning(self, filepath: str) -> None:
+ """If there is an old file around needing versioning, convert it, then rename the old file."""
+
+ old_path = self.find_old_path(DATA_DIR)
+
+ if old_path is not None:
+ log.warning('Found old file: %r. Running versioning code.', old_path)
+ tree = versioning.convert(old_path, self)
+ self.load_data(tree)
+ self.save(filepath)
+ os.rename(old_path, old_path + '.imported')
+
+ else:
+ self.first_run(self.data_path)
- if icon:
- tag_attrs['icon'] = icon
- if nonactionable:
- tag_attrs['nonactionable'] = nonactionable
+ def find_old_path(self, datadir: str) -> None | str:
+ """Reliably find the old data files."""
- tag = self.new_tag(name, tag_attrs, tid)
+ # used by which version?
+ path = os.path.join(datadir, 'gtg_tasks.xml')
+
+ if os.path.isfile(path):
+ return path
+
+ # used by (at least) 0.3.1-4
+ path = os.path.join(datadir, 'projects.xml')
+
+ if os.path.isfile(path):
+ return self.find_old_uuid_path(path)
- if parent:
- tag.set_parent(parent)
+ return None
- # Add to idmap for quick lookup based on ID
- self.tag_idmap[tid] = tag
- self.tagfile_loaded = True
+ def find_old_uuid_path(self, path: str) -> None | str:
+ """Find the first backend entry with module='backend_localfile' and return its path."""
+ with open(path, 'r') as stream:
+ xml_tree = et.parse(stream)
+
+ for backend in xml_tree.findall('backend'):
+ module = backend.get('module')
+ if module == 'backend_localfile':
+ uuid_path = backend.get('path')
+ if os.path.isfile(uuid_path):
- def load_search_tree(self, search_tree):
- """Load saved searches tree."""
+ return uuid_path
- for element in search_tree.iter('savedSearch'):
- tid = element.get('id')
- name = element.get('name')
- color = element.get('color')
- icon = element.get('icon')
- query = element.get('query')
+ return None
- tag_attrs = {}
- if color:
- tag_attrs['color'] = color
+ @staticmethod
+ def get_backup_path(path: str, i: int = None) -> str:
+ """Get path of backups which are backup/ directory."""
- if icon:
- tag_attrs['icon'] = icon
+ dirname, filename = os.path.split(path)
+ backup_file = f"{filename}.bak.{i}" if i else filename
- self.new_search_tag(name, query, tag_attrs, tid, False)
+ return os.path.join(dirname, 'backup', backup_file)
- def get_tag_by_id(self, tid):
- """Get a tag by its ID"""
+ def write_backups(self, path: str) -> None:
+ backup_name = self.get_backup_path(path)
+ backup_dir = os.path.dirname(backup_name)
+ # Make sure backup dir exists
try:
- return self.tag_idmap[tid]
- except KeyError:
+ os.makedirs(backup_dir, exist_ok=True)
+
+ except IOError:
+ log.error('Backup dir %r cannot be created!', backup_dir)
return
- def save_tagtree(self):
- """ Saves the tag tree to an XML file """
+ # Cycle backups
+ for current_backup in range(self.BACKUPS_NUMBER, 0, -1):
+ older = f"{backup_name}.bak.{current_backup}"
+ newer = f"{backup_name}.bak.{current_backup - 1}"
- if not self.tagfile_loaded:
- return
+ try:
+ shutil.move(newer, older)
+ except FileNotFoundError:
+ pass
- tags = self._tagstore.get_main_view().get_all_nodes()
+ # bak.0 is always a fresh copy of the closed file
+ # so that it's not touched in case of not opening next time
+ bak_0 = f"{backup_name}.bak.0"
+ shutil.copy(path, bak_0)
- for backend in self.backends.values():
- if backend.get_name() == 'backend_localfile':
- backend.save_tags(tags, self._tagstore)
+ # Add daily backup
+ today = datetime.today().strftime('%Y-%m-%d')
+ daily_backup = f'{backup_name}.{today}.bak'
+ if not os.path.exists(daily_backup):
+ shutil.copy(path, daily_backup)
- # Tasks functions #########################################################
- def get_all_tasks(self):
- """
- Returns list of all keys of active tasks
+ self.purge_backups(path)
- @return a list of strings: a list of task ids
- """
- return self._tasks.get_main_view().get_all_nodes()
- def has_task(self, tid):
- """
- Returns true if the tid is among the active or closed tasks for
- this DataStore, False otherwise.
+ @staticmethod
+ def purge_backups(path: str, days: int = 30) -> None:
+ """Remove backups older than X days."""
- @param tid: Task ID to search for
- @return bool: True if the task is present
- """
- return self._tasks.has_node(tid)
+ now = time()
+ day_in_secs = 86_400
+ basedir = os.path.dirname(path)
- def get_task(self, tid):
- """
- Returns the internal task object for the given tid, or None if the
- tid is not present in this DataStore.
+ for filename in os.listdir(basedir):
+ filename = os.path.join(basedir, filename)
+ filestamp = os.stat(filename).st_mtime
+ filecompare = now - (days * day_in_secs)
- @param tid: Task ID to retrieve
- @returns GTG.core.task.Task or None: whether the Task is present
- or not
- """
- if self.has_task(tid):
- return self._tasks.get_node(tid)
- else:
- # log.error("requested non-existent task %s", tid)
- # This is not an error: it is normal to request a task which
- # might not exist yet.
- return None
+ if filestamp < filecompare:
+ os.remove(filename)
- def task_factory(self, tid, newtask=False):
- """
- Instantiates the given task id as a Task object.
- @param tid: a task id. Must be unique
- @param newtask: True if the task has never been seen before
- @return Task: a Task instance
- """
- return Task(tid, self.requester, newtask)
+ def find_and_load_file(self, path: str) -> None:
+ """Find an XML file to open
- def new_task(self):
- """
- Creates a blank new task in this DataStore.
- New task is created in all the backends that collect all tasks (among
- them, the default backend). The default backend uses the same task id
- in its own internal representation.
+ If file could not be opened, try:
+ - file__
+ - file.bak.0
+ - file.bak.1
+ - .... until BACKUP_NUMBER
- @return: The task object that was created.
- """
- task = self.task_factory(str(uuid.uuid4()), True)
- self._tasks.add_node(task)
- return task
+ If file doesn't exist, create a new file."""
- def push_task(self, task):
- """
- Adds the given task object to the task tree. In other words, registers
- the given task in the GTG task set.
- This function is used in mutual exclusion: only a backend at a time is
- allowed to push tasks.
+ files = [
+ path, # Main file
+ path + '__', # Temp file
+ ]
- @param task: A valid task object (a GTG.core.task.Task)
- @return bool: True if the task has been accepted
- """
+ # Add backup files
+ files += [self.get_backup_path(path, i)
+ for i in range(self.BACKUPS_NUMBER)]
- def adding(task):
- self._tasks.add_node(task)
- task.set_loaded()
- if self.is_default_backend_loaded:
- task.sync()
- if self.has_task(task.get_id()):
- return False
- else:
- # Thread protection
- adding(task)
- return True
- ##########################################################################
- # Backends functions
- ##########################################################################
+ for index, filepath in enumerate(files):
+ try:
+ log.debug('Opening file %s', filepath)
+ self.load_file(filepath)
+
+ timestamp = os.path.getmtime(filepath)
+ mtime = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
+
+ # This was a backup. We should inform the user
+ if index > 0:
+ self.backup_info = {
+ 'name': filepath,
+ 'time': mtime
+ }
+
+ # We could open a file, let's stop this loop
+ break
+
+ except FileNotFoundError:
+ log.debug('File not found: %r. Trying next.', filepath)
+ continue
+
+ except PermissionError:
+ log.debug('Not allowed to open: %r. Trying next.', filepath)
+ continue
+
+ except et.XMLSyntaxError as error:
+ log.debug('Syntax error in %r. %r. Trying next.',
+ filepath, error)
+ continue
+
+ # We couldn't open any file :(
+ if not self.xml_tree:
+ try:
+ # Try making a new empty file and open it
+ self.first_run(path)
+ self.data_path = path
+
+ except IOError:
+ raise SystemError(f'Could not write a file at {path}')
+
+
+ def purge(self, max_days: int) -> None:
+ """Remove closed tasks and unused tags."""
+
+ log.debug("Deleting old tasks")
+
+ today = Date.today()
+ for task in self.tasks.data:
+ if (today - task.date_closed).days > max_days:
+ self.tasks.remove(task.id)
+
+ log.debug("Deleting unused tags")
+
+ for tag in self.tags.data:
+ count_open = self.task_count['open'].get(tag.name, 0)
+ count_closed = self.task_count['closed'].get(tag.name, 0)
+ customized = tag.color or tag.icon
+
+ if (count_open + count_closed) == 0 and not customized:
+ self.tags.remove(tag.id)
+
+
+ # --------------------------------------------------------------------------
+ # BACKENDS
+ # --------------------------------------------------------------------------
+
def get_all_backends(self, disabled=False):
- """
- returns list of all registered backends for this DataStore.
+ """returns list of all registered backends for this DataStore.
@param disabled: If disabled is True, attaches also the list of
disabled backends
@@ -400,6 +481,7 @@ def get_all_backends(self, disabled=False):
result.append(backend)
return result
+
def get_backend(self, backend_id):
"""
Returns a backend given its id.
@@ -413,6 +495,7 @@ def get_backend(self, backend_id):
else:
return None
+
def register_backend(self, backend_dic):
"""
Registers a TaskSource as a backend for this DataStore
@@ -434,38 +517,38 @@ def register_backend(self, backend_dic):
if backend.get_id() in self.backends:
log.error("registering already registered backend")
return None
- # creating the TaskSource which will wrap the backend,
- # filtering the tasks that should hit the backend.
- source = TaskSource(requester=self.requester,
- backend=backend,
- datastore=self)
+
+
+ backend.register_datastore(self)
if first_run:
backend.this_is_the_first_run(None)
- self.backends[backend.get_id()] = source
+ self.backends[backend.get_id()] = backend
# we notify that a new backend is present
self._backend_signals.backend_added(backend.get_id())
# saving the backend in the correct dictionary (backends for
# enabled backends, disabled_backends for the disabled ones)
# this is useful for retro-compatibility
if GenericBackend.KEY_ENABLED not in backend_dic:
- source.set_parameter(GenericBackend.KEY_ENABLED, True)
+ backend.set_parameter(GenericBackend.KEY_ENABLED, True)
if GenericBackend.KEY_DEFAULT_BACKEND not in backend_dic:
- source.set_parameter(GenericBackend.KEY_DEFAULT_BACKEND, True)
+ backend.set_parameter(GenericBackend.KEY_DEFAULT_BACKEND, True)
# if it's enabled, we initialize it
- if source.is_enabled() and \
- (self.is_default_backend_loaded or source.is_default()):
- source.initialize(connect_signals=False)
+ if backend.is_enabled():
+ self._backend_startup(backend)
+ # backend.initialize()
# Filling the backend
# Doing this at start is more efficient than
# after the GUI is launched
- source.start_get_tasks()
- return source
+ # backend.start_get_tasks()
+
+ return backend
else:
log.error("Tried to register a backend without a pid")
- def _activate_non_default_backends(self, sender=None):
+
+ def _activate_non_default_backends(self):
"""
Non-default backends have to wait until the default loads before
being activated. This function is called after the first default
@@ -473,15 +556,12 @@ def _activate_non_default_backends(self, sender=None):
@param sender: not used, just here for signal compatibility
"""
- if self.is_default_backend_loaded:
- log.debug("spurious call")
- return
- self.is_default_backend_loaded = True
for backend in self.backends.values():
- if backend.is_enabled() and not backend.is_default():
+ if backend.is_enabled():
self._backend_startup(backend)
+
def _backend_startup(self, backend):
"""
Helper function to launch a thread that starts a backend.
@@ -504,6 +584,7 @@ def __backend_startup(self, backend):
thread.setDaemon(True)
thread.start()
+
def set_backend_enabled(self, backend_id, state):
"""
The backend corresponding to backend_id is enabled or disabled
@@ -527,12 +608,8 @@ def set_backend_enabled(self, backend_id, state):
threading.Thread(target=backend.quit,
kwargs={'disable': True}).start()
elif current_state is False and state is True:
- if self.is_default_backend_loaded is True:
- self._backend_startup(backend)
- else:
- # will be activated afterwards
- backend.set_parameter(GenericBackend.KEY_ENABLED,
- True)
+ self._backend_startup(backend)
+
def remove_backend(self, backend_id):
"""
@@ -554,6 +631,7 @@ def remove_backend(self, backend_id):
self._backend_signals.backend_removed(backend.get_id())
del self.backends[backend_id]
+
def backend_change_attached_tags(self, backend_id, tag_names):
"""
Changes the tags for which a backend should store a task
@@ -565,6 +643,7 @@ def backend_change_attached_tags(self, backend_id, tag_names):
backend = self.backends[backend_id]
backend.set_attached_tags(tag_names)
+
def flush_all_tasks(self, backend_id):
"""
This function will cause all tasks to be checked against the backend
@@ -579,312 +658,122 @@ def flush_all_tasks(self, backend_id):
def _internal_flush_all_tasks():
backend = self.backends[backend_id]
- for task_id in self.get_all_tasks():
+ for task in self.tasks.data:
if self.please_quit:
break
- backend.queue_set_task(task_id)
+ backend.queue_set_task(task.id)
t = threading.Thread(target=_internal_flush_all_tasks)
t.start()
self.backends[backend_id].start_get_tasks()
- def save(self, quit=False):
- """
- Saves the backends parameters.
- @param quit: If quit is true, backends are shut down
- """
+ # --------------------------------------------------------------------------
+ # TESTING AND UTILS
+ # --------------------------------------------------------------------------
- try:
- self.start_get_tasks_thread.join()
- except Exception:
- pass
+ def fill_with_samples(self, tasks_count: int) -> None:
+ """Fill the Datastore with sample data."""
- # we ask all the backends to quit first.
- if quit:
- # we quit backends in parallel
- threads_dic = {}
+ def random_date(start: datetime = None):
+ start = start or datetime.now()
+ end = start + timedelta(days=random.randint(1, 365 * 5))
- for b in self.get_all_backends():
- thread = threading.Thread(target=b.quit)
- threads_dic[b.get_id()] = thread
- thread.start()
+ return start + (end - start)
- for backend_id, thread in threads_dic.items():
- # after 20 seconds, we give up
- thread.join(20)
- alive = thread.is_alive()
+ def random_boolean() -> bool:
+ return bool(random.getrandbits(1))
- if alive:
- log.error("The %s backend stalled while quitting",
- backend_id)
- # we save the parameters
- for b in self.get_all_backends(disabled=True):
- config = self.conf.get_backend_config(b.get_name())
+ def random_word(length: int) -> str:
+ letters = string.ascii_lowercase
+ return ''.join(random.choice(letters) for _ in range(length))
- for key, value in b.get_parameters().items():
- if key in ["backend", "xmlobject"]:
- # We don't want parameters, backend, xmlobject:
- # we'll create them at next startup
- continue
+ if tasks_count == 0:
+ return
- param_type = b.get_parameter_type(key)
- value = b.cast_param_type_to_string(param_type, value)
- config.set(str(key), value)
- config.save()
+ tags_count = random.randint(tasks_count // 10, tasks_count)
+ search_count = random.randint(0, tasks_count // 10)
+ task_sizes = [random.randint(0, 200) for _ in range(10)]
+ randint = random.randint
+ tag_words = [random_word(randint(2, 14)) for _ in range(tags_count)]
- # Saving the tagstore
- self.save_tagtree()
+ # Generate saved searches
+ for _ in range(search_count):
+ name = random_word(randint(2, 10))
+ query = random_word(randint(2, 10))
+ self.saved_searches.new(name, query)
- def request_task_deletion(self, tid):
- """
- This is a proxy function to request a task deletion from a backend
+ # Generate tags
+ for tag_name in tag_words:
+ tag = self.tags.new(tag_name)
+ tag.actionable = random_boolean()
+ tag.color = self.tags.generate_color()
- @param tid: the tid of the task to remove
- """
- self.requester.delete_task(tid)
- def get_backend_mutex(self):
- """
- Returns the mutex object used by backends to avoid modifying a task
- at the same time.
+ # Parent the tags
+ for tag in self.tags.data:
+ if bool(random.getrandbits(1)):
+ parent = random.choice(self.tags.data)
- @returns: threading.Lock
- """
- return self._backend_mutex
+ if tag.id == parent.id:
+ continue
+ self.tags.parent(tag.id, parent.id)
-class TaskSource():
- """
- Transparent interface between the real backend and the DataStore.
- Is in charge of connecting and disconnecting to signals
- """
- def __init__(self, requester, backend, datastore):
- """
- Instantiates a TaskSource object.
+ # Generate tasks
+ for _ in range(tasks_count):
+ title = ''
+ content = ''
+ content_size = random.choice(task_sizes)
- @param requester: a Requester
- @param backend: the backend being wrapped
- @param datastore: a Datastore
- """
- self.backend = backend
- self.req = requester
- self.backend.register_datastore(datastore)
- self.tasktree = datastore.get_tasks_tree().get_main_view()
- self.to_set = deque()
- self.to_remove = deque()
- self.please_quit = False
- self.task_filter = self.get_task_filter_for_backend()
- if log.isEnabledFor(logging.DEBUG):
- self.timer_timestep = 5
- else:
- self.timer_timestep = 1
- self.add_task_handle = None
- self.set_task_handle = None
- self.remove_task_handle = None
- self.to_set_timer = None
-
- def start_get_tasks(self):
- """ Loads all task from the backend and connects its signals
- afterwards. """
- self.backend.start_get_tasks()
- self._connect_signals()
- if self.backend.is_default():
- BackendSignals().default_backend_loaded()
-
- def get_task_filter_for_backend(self):
- """
- Filter that checks if the task should be stored in this backend.
+ for _ in range(random.randint(1, 15)):
+ word = random_word(randint(4, 20))
- @returns function: a function that accepts a task and returns
- True/False whether the task should be stored or not
- """
+ if word in tag_words:
+ word = '@' + word
- def backend_filter(req, task, parameters):
- """
- Filter that checks if two tags sets intersect. It is used to check
- if a task should be stored inside a backend
- @param task: a task object
- @param tags_to_match_set: a *set* of tag names
- """
- try:
- tags_to_match_set = parameters['tags']
- except KeyError:
- return []
- all_tasks_tag = req.get_alltag_tag().get_name()
- if all_tasks_tag in tags_to_match_set:
- return True
- task_tags = set(task.get_tags_name())
- return task_tags.intersection(tags_to_match_set)
-
- attached_tags = self.backend.get_attached_tags()
- return lambda task: backend_filter(self.requester, task,
- {"tags": set(attached_tags)})
-
- def should_task_id_be_stored(self, task_id):
- """
- Helper function: Checks if a task should be stored in this backend
+ title += word + ' '
- @param task_id: a task id
- @returns bool: True if the task should be stored
- """
- # task = self.req.get_task(task_id)
- # FIXME: it will be a lot easier to add, instead,
- # a filter to a tree and check that this task is well in the tree
-# return self.task_filter(task)
- return True
+ task = self.tasks.new(title)
- def queue_set_task(self, tid, path=None):
- """
- Updates the task in the DataStore. Actually, it adds the task to a
- queue to be updated asynchronously.
+ for _ in range(random.randint(0, 10)):
+ tag = self.tags.find(random.choice(tag_words))
+ task.add_tag(tag)
- @param task: The Task object to be updated.
- @param path: its path in TreeView widget => not used there
- """
- if self.should_task_id_be_stored(tid):
- if tid not in self.to_set and tid not in self.to_remove:
- self.to_set.appendleft(tid)
- self.__try_launch_setting_thread()
- else:
- self.queue_remove_task(tid, path)
+ if random_boolean():
+ task.toggle_active()
- def launch_setting_thread(self, bypass_please_quit=False):
- """
- Operates the threads to set and remove tasks.
- Releases the lock when it is done.
-
- @param bypass_please_quit: if True, the self.please_quit
- "quit condition" is ignored. Currently,
- it's turned to true after the quit
- condition has been issued, to execute
- eventual pending operations.
- """
- while not self.please_quit or bypass_please_quit:
- try:
- tid = self.to_set.pop()
- except IndexError:
- break
- # we check that the task is not already marked for deletion
- # and that it's still to be stored in this backend
- # NOTE: no need to lock, we're reading
- if tid not in self.to_remove and \
- self.should_task_id_be_stored(tid) and \
- self.req.has_task(tid):
- task = self.req.get_task(tid)
- self.backend.queue_set_task(task)
- while not self.please_quit or bypass_please_quit:
- try:
- tid = self.to_remove.pop()
- except IndexError:
- break
- self.backend.queue_remove_task(tid)
- # we release the weak lock
- self.to_set_timer = None
+ if random_boolean():
+ task.toggle_dismiss()
- def queue_remove_task(self, tid, path=None):
- """
- Queues task to be removed.
+ for _ in range(random.randint(0, content_size)):
+ word = random_word(randint(4, 20))
- @param sender: not used, any value will do
- @param tid: The Task ID of the task to be removed
- """
- if tid not in self.to_remove:
- self.to_remove.appendleft(tid)
- self.__try_launch_setting_thread()
+ if word in tag_words:
+ word = '@' + word
- def __try_launch_setting_thread(self):
- """
- Helper function to launch the setting thread, if it's not running
- """
- if self.to_set_timer is None and not self.please_quit:
- self.to_set_timer = threading.Timer(self.timer_timestep,
- self.launch_setting_thread)
- self.to_set_timer.setDaemon(True)
- self.to_set_timer.start()
+ content += word + ' '
+ content += '\n' if random_boolean() else ''
- def initialize(self, connect_signals=True):
- """
- Initializes the backend and starts looking for signals.
+ task.content = content
- @param connect_signals: if True, it starts listening for signals
- """
- self.backend.initialize()
- if connect_signals:
- self._connect_signals()
+ if random_boolean():
+ task.date_start = random_date()
- def _connect_signals(self):
- """
- Helper function to connect signals
- """
- if not self.add_task_handle:
- self.add_task_handle = self.tasktree.register_cllbck(
- 'node-added', self.queue_set_task)
- if not self.set_task_handle:
- self.set_task_handle = self.tasktree.register_cllbck(
- 'node-modified', self.queue_set_task)
- if not self.remove_task_handle:
- self.remove_task_handle = self.tasktree.register_cllbck(
- 'node-deleted', self.queue_remove_task)
-
- def _disconnect_signals(self):
- """
- Helper function to disconnect signals
- """
- if self.add_task_handle:
- self.tasktree.deregister_cllbck('node-added',
- self.set_task_handle)
- self.add_task_handle = None
- if self.set_task_handle:
- self.tasktree.deregister_cllbck('node-modified',
- self.set_task_handle)
- self.set_task_handle = None
- if self.remove_task_handle:
- self.tasktree.deregister_cllbck('node-deleted',
- self.remove_task_handle)
- self.remove_task_handle = None
-
- def sync(self):
- """
- Forces the TaskSource to sync all the pending tasks
- """
- try:
- self.to_set_timer.cancel()
- except Exception:
- pass
- try:
- self.to_set_timer.join(3)
- except Exception:
- pass
- try:
- self.start_get_tasks_thread.join(3)
- except Exception:
- pass
- self.launch_setting_thread(bypass_please_quit=True)
+ if random_boolean():
+ task.date_due = Date(random_date())
- def quit(self, disable=False):
- """
- Quits the backend and disconnect the signals
- @param disable: if True, the backend is disabled.
- """
- self._disconnect_signals()
- self.please_quit = True
- self.sync()
- self.backend.quit(disable)
+ # Parent the tasks
+ for task in self.tasks.data:
+ if bool(random.getrandbits(1)):
+ parent = random.choice(self.tasks.data)
- def __getattr__(self, attr):
- """
- Delegates all the functions not defined here to the real backend
- (standard python function)
+ if task.id == parent.id:
+ continue
- @param attr: attribute to get
- """
- if attr in self.__dict__:
- return self.__dict__[attr]
- else:
- return getattr(self.backend, attr)
+ self.tasks.parent(task.id, parent.id)
diff --git a/GTG/core/datastore2.py b/GTG/core/datastore2.py
deleted file mode 100644
index b608f199ab..0000000000
--- a/GTG/core/datastore2.py
+++ /dev/null
@@ -1,700 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) The GTG Team
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-"""The datastore ties together all the basic type stores and backends."""
-
-import os
-import threading
-import logging
-import shutil
-from datetime import datetime, timedelta
-from time import time
-import random
-import string
-
-from GTG.core.tasks2 import TaskStore, Filter
-from GTG.core.tags2 import TagStore
-from GTG.core.saved_searches import SavedSearchStore
-from GTG.core import firstrun_tasks
-from GTG.core.dates import Date
-from GTG.backends.backend_signals import BackendSignals
-from GTG.backends.generic_backend import GenericBackend
-import GTG.core.info as info
-
-from lxml import etree as et
-
-from typing import Optional
-
-
-log = logging.getLogger(__name__)
-
-
-class Datastore2:
-
- #: Amount of backups to keep
- BACKUPS_NUMBER = 7
-
-
- def __init__(self) -> None:
- self.tasks = TaskStore()
- self.tags = TagStore()
- self.saved_searches = SavedSearchStore()
- self.xml_tree = None
-
- self._mutex = threading.Lock()
- self.backends = {}
- self._backend_signals = BackendSignals()
-
- # When a backup has to be used, this will be filled with
- # info on the backup used
- self.backup_info = {}
-
- # Flag when turned to true, all pending operation should be
- # completed and then GTG should quit
- self.please_quit = False
-
- # Count of tasks for each pane and each tag
- self.task_count = {
- 'open': {'all': 0, 'untagged': 0},
- 'actionable': {'all': 0, 'untagged': 0},
- 'closed': {'all': 0, 'untagged': 0},
- }
-
- self.data_path = None
-
-
- @property
- def mutex(self) -> threading.Lock:
- return self._mutex
-
-
- def load_data(self, data: et.Element) -> None:
- """Load data from an lxml element object."""
-
- self.saved_searches.from_xml(data.find('searchlist'))
- self.tags.from_xml(data.find('taglist'))
- self.tasks.from_xml(data.find('tasklist'), self.tags)
-
- self.refresh_task_count()
-
-
- def load_file(self, path: str) -> None:
- """Load data from a file."""
-
- bench_start = 0
-
- if log.isEnabledFor(logging.DEBUG):
- bench_start = time()
-
- parser = et.XMLParser(remove_blank_text=True, strip_cdata=False)
-
- with open(path, 'rb') as stream:
- self.xml_tree = et.parse(stream, parser=parser)
- self.load_data(self.xml_tree)
-
- if log.isEnabledFor(logging.DEBUG):
- log.debug('Processed file %s in %.2fms',
- path, (time() - bench_start) * 1000)
-
- # Store path, so we can call save() on it
- self.data_path = path
-
-
- def generate_xml(self) -> et.ElementTree:
- """Generate lxml element object with all data."""
-
- root = et.Element('gtgData')
- root.set('appVersion', info.VERSION)
- root.set('xmlVersion', '2')
-
- root.append(self.tags.to_xml())
- root.append(self.saved_searches.to_xml())
- root.append(self.tasks.to_xml())
-
- return et.ElementTree(root)
-
-
- def write_file(self, path: str) -> None:
- """Generate and write xml file."""
-
- tree = self.generate_xml()
-
- with open(path, 'wb') as stream:
- tree.write(stream, xml_declaration=True, pretty_print=True,
- encoding='UTF-8')
-
-
- def save(self, path: Optional[str] = None) -> None:
- """Write GTG data file."""
-
- path = path or self.data_path
- temp_file = path + '__'
- bench_start = 0
-
- try:
- os.rename(path, temp_file)
- except IOError:
- log.error('Error renaming temp file %r', temp_file)
-
- if log.isEnabledFor(logging.DEBUG):
- bench_start = time()
-
- base_dir = os.path.dirname(path)
-
- try:
- os.makedirs(base_dir, exist_ok=True)
- except IOError as error:
- log.error("Error while creating directories: %r", error)
-
- try:
- self.write_file(path)
- except (IOError, FileNotFoundError):
- log.error('Could not write XML file at %r', path)
- return
-
-
- if log.isEnabledFor(logging.DEBUG):
- log.debug('Saved file %s in %.2fms',
- path, (time() - bench_start) * 1000)
-
- try:
- os.remove(temp_file)
- except FileNotFoundError:
- pass
-
- self.write_backups(path)
-
-
- def print_info(self) -> None:
- """Print statistics and information on this datastore."""
-
- tasks = self.tasks.count()
- initialized = 'Initialized' if tasks > 0 else 'Empty'
-
- print(f'Datastore [{initialized}]')
- print(f'- Tags: {self.tags.count()}')
- print(f'- Saved Searches: {self.saved_searches.count()}')
- print(f'- Tasks: {self.tasks.count()}')
-
-
- def refresh_task_count(self) -> None:
- """Refresh task count dictionary."""
-
- def count_tasks(count: dict, tasklist: list):
- for task in tasklist:
- count['all'] += 1
-
- if not task.tags:
- count['untagged'] += 1
-
- for tag in task.tags:
- val = count.get(tag.name, 0)
- count[tag.name] = val + 1
-
- if task.children:
- count_tasks(count, task.children)
-
- # Reset task counts
- self.task_count = {
- 'open': {'all': 0, 'untagged': 0},
- 'actionable': {'all': 0, 'untagged': 0},
- 'closed': {'all': 0, 'untagged': 0},
- }
-
- count_tasks(self.task_count['open'],
- self.tasks.filter(Filter.ACTIVE))
-
- count_tasks(self.task_count['closed'],
- self.tasks.filter(Filter.CLOSED))
-
- count_tasks(self.task_count['actionable'],
- self.tasks.filter(Filter.ACTIONABLE))
-
-
- def first_run(self, path: str) -> et.Element:
- """Write initial data file."""
-
- self.xml_tree = firstrun_tasks.generate()
- self.load_data(self.xml_tree)
- self.save(path)
-
-
- @staticmethod
- def get_backup_path(path: str, i: int = None) -> str:
- """Get path of backups which are backup/ directory."""
-
- dirname, filename = os.path.split(path)
- backup_file = f"{filename}.bak.{i}" if i else filename
-
- return os.path.join(dirname, 'backup', backup_file)
-
-
- def write_backups(self, path: str) -> None:
- backup_name = self.get_backup_path(path)
- backup_dir = os.path.dirname(backup_name)
-
- # Make sure backup dir exists
- try:
- os.makedirs(backup_dir, exist_ok=True)
-
- except IOError:
- log.error('Backup dir %r cannot be created!', backup_dir)
- return
-
- # Cycle backups
- for current_backup in range(self.BACKUPS_NUMBER, 0, -1):
- older = f"{backup_name}.bak.{current_backup}"
- newer = f"{backup_name}.bak.{current_backup - 1}"
-
- try:
- shutil.move(newer, older)
- except FileNotFoundError:
- pass
-
- # bak.0 is always a fresh copy of the closed file
- # so that it's not touched in case of not opening next time
- bak_0 = f"{backup_name}.bak.0"
- shutil.copy(path, bak_0)
-
- # Add daily backup
- today = datetime.today().strftime('%Y-%m-%d')
- daily_backup = f'{backup_name}.{today}.bak'
-
- if not os.path.exists(daily_backup):
- shutil.copy(path, daily_backup)
-
- self.purge_backups(path)
-
-
- @staticmethod
- def purge_backups(path: str, days: int = 30) -> None:
- """Remove backups older than X days."""
-
- now = time()
- day_in_secs = 86_400
- basedir = os.path.dirname(path)
-
- for filename in os.listdir(basedir):
- filename = os.path.join(basedir, filename)
- filestamp = os.stat(filename).st_mtime
- filecompare = now - (days * day_in_secs)
-
- if filestamp < filecompare:
- os.remove(filename)
-
-
- def find_and_load_file(self, path: str) -> None:
- """Find an XML file to open
-
- If file could not be opened, try:
- - file__
- - file.bak.0
- - file.bak.1
- - .... until BACKUP_NUMBER
-
- If file doesn't exist, create a new file."""
-
- files = [
- path, # Main file
- path + '__', # Temp file
- ]
-
- # Add backup files
- files += [self.get_backup_path(path, i)
- for i in range(self.BACKUPS_NUMBER)]
-
-
- for index, filepath in enumerate(files):
- try:
- log.debug('Opening file %s', filepath)
- self.load_file(filepath)
-
- timestamp = os.path.getmtime(filepath)
- mtime = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
-
- # This was a backup. We should inform the user
- if index > 0:
- self.backup_info = {
- 'name': filepath,
- 'time': mtime
- }
-
- # We could open a file, let's stop this loop
- break
-
- except FileNotFoundError:
- log.debug('File not found: %r. Trying next.', filepath)
- continue
-
- except PermissionError:
- log.debug('Not allowed to open: %r. Trying next.', filepath)
- continue
-
- except et.XMLSyntaxError as error:
- log.debug('Syntax error in %r. %r. Trying next.',
- filepath, error)
- continue
-
- # We couldn't open any file :(
- if not self.xml_tree:
- try:
- # Try making a new empty file and open it
- self.first_run(path)
-
- except IOError:
- raise SystemError(f'Could not write a file at {path}')
-
-
- def purge(self, max_days: int) -> None:
- """Remove closed tasks and unused tags."""
-
- log.debug("Deleting old tasks")
-
- today = Date.today()
- for task in self.tasks.data:
- if (today - task.date_closed).days > max_days:
- self.tasks.remove(task.id)
-
- log.debug("Deleting unused tags")
-
- for tag in self.tags.data:
- count_open = self.task_count['open'].get(tag.name, 0)
- count_closed = self.task_count['closed'].get(tag.name, 0)
- customized = tag.color or tag.icon
-
- if (count_open + count_closed) == 0 and not customized:
- self.tags.remove(tag.id)
-
-
- # --------------------------------------------------------------------------
- # BACKENDS
- # --------------------------------------------------------------------------
-
- def get_all_backends(self, disabled=False):
- """returns list of all registered backends for this DataStore.
-
- @param disabled: If disabled is True, attaches also the list of
- disabled backends
- @return list: a list of TaskSource objects
- """
- result = []
- for backend in self.backends.values():
- if backend.is_enabled() or disabled:
- result.append(backend)
- return result
-
-
- def get_backend(self, backend_id):
- """
- Returns a backend given its id.
-
- @param backend_id: a backend id
- @returns GTG.core.datastore.TaskSource or None: the requested backend,
- or None
- """
- if backend_id in self.backends:
- return self.backends[backend_id]
- else:
- return None
-
-
- def register_backend(self, backend_dic):
- """
- Registers a TaskSource as a backend for this DataStore
-
- @param backend_dic: Dictionary object containing all the
- parameters to initialize the backend
- (filename...). It should also contain the
- backend class (under "backend"), and its
- unique id (under "pid")
- """
- if "backend" in backend_dic:
- if "pid" not in backend_dic:
- log.error("registering a backend without pid.")
- return None
- backend = backend_dic["backend"]
- first_run = backend_dic["first_run"]
-
- # Checking that is a new backend
- if backend.get_id() in self.backends:
- log.error("registering already registered backend")
- return None
-
- if first_run:
- backend.this_is_the_first_run(None)
-
- self.backends[backend.get_id()] = backend
- # we notify that a new backend is present
- self._backend_signals.backend_added(backend.get_id())
- # saving the backend in the correct dictionary (backends for
- # enabled backends, disabled_backends for the disabled ones)
- # this is useful for retro-compatibility
- if GenericBackend.KEY_ENABLED not in backend_dic:
- backend.set_parameter(GenericBackend.KEY_ENABLED, True)
- if GenericBackend.KEY_DEFAULT_BACKEND not in backend_dic:
- backend.set_parameter(GenericBackend.KEY_DEFAULT_BACKEND, True)
- # if it's enabled, we initialize it
- if backend.is_enabled() and \
- (self.is_default_backend_loaded or backend.is_default()):
-
- backend.initialize(connect_signals=False)
- # Filling the backend
- # Doing this at start is more efficient than
- # after the GUI is launched
- backend.start_get_tasks()
-
- return backend
- else:
- log.error("Tried to register a backend without a pid")
-
-
- def _activate_non_default_backends(self, sender=None):
- """
- Non-default backends have to wait until the default loads before
- being activated. This function is called after the first default
- backend has loaded all its tasks.
-
- @param sender: not used, just here for signal compatibility
- """
-
- self.is_default_backend_loaded = True
- for backend in self.backends.values():
- if backend.is_enabled() and not backend.is_default():
- self._backend_startup(backend)
-
-
- def _backend_startup(self, backend):
- """
- Helper function to launch a thread that starts a backend.
-
- @param backend: the backend object
- """
-
- def __backend_startup(self, backend):
- """
- Helper function to start a backend
-
- @param backend: the backend object
- """
- backend.initialize()
- backend.start_get_tasks()
- self.flush_all_tasks(backend.get_id())
-
- thread = threading.Thread(target=__backend_startup,
- args=(self, backend))
- thread.setDaemon(True)
- thread.start()
-
-
- def set_backend_enabled(self, backend_id, state):
- """
- The backend corresponding to backend_id is enabled or disabled
- according to "state".
- Disable:
- Quits a backend and disables it (which means it won't be
- automatically loaded next time GTG is started)
- Enable:
- Reloads a disabled backend. Backend must be already known by the
- Datastore
-
- @param backend_id: a backend id
- @param state: True to enable, False to disable
- """
- if backend_id in self.backends:
- backend = self.backends[backend_id]
- current_state = backend.is_enabled()
- if current_state is True and state is False:
- # we disable the backend
- # FIXME!!!
- threading.Thread(target=backend.quit,
- kwargs={'disable': True}).start()
- elif current_state is False and state is True:
- if self.is_default_backend_loaded is True:
- self._backend_startup(backend)
- else:
- # will be activated afterwards
- backend.set_parameter(GenericBackend.KEY_ENABLED,
- True)
-
-
- def remove_backend(self, backend_id):
- """
- Removes a backend, and forgets it ever existed.
-
- @param backend_id: a backend id
- """
- if backend_id in self.backends:
- backend = self.backends[backend_id]
- if backend.is_enabled():
- self.set_backend_enabled(backend_id, False)
- # FIXME: to keep things simple, backends are not notified that they
- # are completely removed (they think they're just
- # deactivated). We should add a "purge" call to backend to
- # let them know that they're removed, so that they can
- # remove all the various files they've created. (invernizzi)
-
- # we notify that the backend has been deleted
- self._backend_signals.backend_removed(backend.get_id())
- del self.backends[backend_id]
-
-
- def backend_change_attached_tags(self, backend_id, tag_names):
- """
- Changes the tags for which a backend should store a task
-
- @param backend_id: a backend_id
- @param tag_names: the new set of tags. This should not be a tag object,
- just the tag name.
- """
- backend = self.backends[backend_id]
- backend.set_attached_tags(tag_names)
-
-
- def flush_all_tasks(self, backend_id):
- """
- This function will cause all tasks to be checked against the backend
- identified with backend_id. If tasks need to be added or removed, it
- will be done here.
- It has to be run after the creation of a new backend (or an alteration
- of its "attached tags"), so that the tasks which are already loaded in
- the Tree will be saved in the proper backends
-
- @param backend_id: a backend id
- """
-
- def _internal_flush_all_tasks():
- backend = self.backends[backend_id]
- for task in self.tasks.data:
- if self.please_quit:
- break
- backend.queue_set_task(task.id)
- t = threading.Thread(target=_internal_flush_all_tasks)
- t.start()
- self.backends[backend_id].start_get_tasks()
-
-
- # --------------------------------------------------------------------------
- # TESTING AND UTILS
- # --------------------------------------------------------------------------
-
- def fill_with_samples(self, tasks_count: int) -> None:
- """Fill the Datastore with sample data."""
-
- def random_date(start: datetime = None):
- start = start or datetime.now()
- end = start + timedelta(days=random.randint(1, 365 * 5))
-
- return start + (end - start)
-
-
- def random_boolean() -> bool:
- return bool(random.getrandbits(1))
-
-
- def random_word(length: int) -> str:
- letters = string.ascii_lowercase
- return ''.join(random.choice(letters) for _ in range(length))
-
-
- if tasks_count == 0:
- return
-
-
- tags_count = random.randint(tasks_count // 10, tasks_count)
- search_count = random.randint(0, tasks_count // 10)
- task_sizes = [random.randint(0, 200) for _ in range(10)]
- randint = random.randint
- tag_words = [random_word(randint(2, 14)) for _ in range(tags_count)]
-
- # Generate saved searches
- for _ in range(search_count):
- name = random_word(randint(2, 10))
- query = random_word(randint(2, 10))
- self.saved_searches.new(name, query)
-
- # Generate tags
- for tag_name in tag_words:
- tag = self.tags.new(tag_name)
- tag.actionable = random_boolean()
- tag.color = self.tags.generate_color()
-
-
- # Parent the tags
- for tag in self.tags.data:
- if bool(random.getrandbits(1)):
- parent = random.choice(self.tags.data)
-
- if tag.id == parent.id:
- continue
-
- self.tags.parent(tag.id, parent.id)
-
-
- # Generate tasks
- for _ in range(tasks_count):
- title = ''
- content = ''
- content_size = random.choice(task_sizes)
-
- for _ in range(random.randint(1, 15)):
- word = random_word(randint(4, 20))
-
- if word in tag_words:
- word = '@' + word
-
- title += word + ' '
-
- task = self.tasks.new(title)
-
- for _ in range(random.randint(0, 10)):
- tag = self.tags.find(random.choice(tag_words))
- task.add_tag(tag)
-
- if random_boolean():
- task.toggle_active()
-
- if random_boolean():
- task.toggle_dismiss()
-
- for _ in range(random.randint(0, content_size)):
- word = random_word(randint(4, 20))
-
- if word in tag_words:
- word = '@' + word
-
- content += word + ' '
- content += '\n' if random_boolean() else ''
-
- task.content = content
-
- if random_boolean():
- task.date_start = random_date()
-
- if random_boolean():
- task.date_due = Date(random_date())
-
-
- # Parent the tasks
- for task in self.tasks.data:
- if bool(random.getrandbits(1)):
- parent = random.choice(self.tasks.data)
-
- if task.id == parent.id:
- continue
-
- self.tasks.parent(task.id, parent.id)
diff --git a/GTG/core/dates.py b/GTG/core/dates.py
index 80e8789bd2..68b81cfcd2 100644
--- a/GTG/core/dates.py
+++ b/GTG/core/dates.py
@@ -199,7 +199,7 @@ def dt_by_accuracy(self, wanted_accuracy: Accuracy):
return self.dt_value
if self.accuracy is Accuracy.fuzzy:
now = datetime.now()
- delta_days = {SOON: 15, SOMEDAY: 365, NODATE: 9999}
+ delta_days = {SOON: 15, SOMEDAY: 9999, NODATE: 9999}
gtg_date = Date(now + timedelta(delta_days[self.dt_value]))
if gtg_date.accuracy is wanted_accuracy:
return gtg_date.dt_value
diff --git a/GTG/core/filters.py b/GTG/core/filters.py
new file mode 100644
index 0000000000..4207f4a635
--- /dev/null
+++ b/GTG/core/filters.py
@@ -0,0 +1,182 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) - The GTG Team
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""Filters for tags and tasks"""
+
+from gi.repository import Gtk, GObject, Gdk
+from GTG.core.tags import Tag
+from GTG.core.tasks import Task, Status
+from GTG.core import search
+
+
+def unwrap(row, expected_type):
+ """Find an item in TreeRow widget (sometimes nested)."""
+
+ item = row.get_item()
+
+ while type(item) is not expected_type:
+ item = item.get_item()
+
+ return item
+
+
+class TagEmptyFilter(Gtk.Filter):
+ __gtype_name__ = 'TagEmptyFilter'
+
+ def __init__(self, ds, pane):
+ super(TagEmptyFilter, self).__init__()
+ self.ds = ds
+ self.pane = pane
+
+
+ def do_match(self, item) -> bool:
+ tag = unwrap(item, Tag)
+
+ if self.pane == 'open':
+ return tag.task_count_open > 0
+
+ elif self.pane == 'closed':
+ return tag.task_count_closed > 0
+
+ elif self.pane == 'workview':
+ return tag.task_count_actionable > 0 and tag.actionable
+
+ else:
+ return True
+
+
+class TaskPaneFilter(Gtk.Filter):
+ __gtype_name__ = 'TaskPaneFilter'
+
+ def __init__(self, ds, pane, tags = [], no_tags=False):
+ super(TaskPaneFilter, self).__init__()
+ self.ds = ds
+ self.pane = pane
+ self.tags = set()
+ self.no_tags = no_tags
+ self.expand = False
+
+
+ def expand_tags(self) -> set:
+ """Expand tags to include their children."""
+
+ def get_children(children: set) -> None:
+ for child in children:
+ result.add(child)
+
+ if child.children:
+ return get_children(child.children)
+
+
+ result = set()
+
+ for tag in self.tags:
+ result.add(tag)
+
+ if tag.children:
+ get_children(tag.children)
+
+ return result
+
+
+ def match_tags(self, task: Task) -> bool:
+ """Match selected tags to task tags."""
+
+ all_tags = self.expand_tags()
+ return len(all_tags.intersection(set(task.tags))) >= len(self.tags)
+
+
+ def do_match(self, item) -> bool:
+ task = unwrap(item, Task)
+
+ if self.pane == 'active':
+ show = task.status is Status.ACTIVE
+ elif self.pane == 'workview':
+ show = task.is_actionable
+
+ if self.expand:
+ item.set_expanded(True)
+ self.expand = False
+
+ elif self.pane == 'closed':
+ show = task.status is not Status.ACTIVE
+
+ if show:
+ if self.no_tags:
+ current = not task.tags
+ return current or any(bool(c.tags) for c in task.children)
+ elif self.tags:
+ current = self.match_tags(task)
+ return current or any(self.match_tags(c) for c in task.children)
+ else:
+ return True
+ else:
+ return False
+
+
+class SearchTaskFilter(Gtk.Filter):
+ __gtype_name__ = 'SearchTaskFilter'
+
+ def __init__(self, ds, pane):
+ super(SearchTaskFilter, self).__init__()
+ self.ds = ds
+ self.query = ''
+ self.checks = None
+ self.pane = pane
+ self.expand = False
+ self.tags = set()
+
+
+ def set_query(self, query: str) -> None:
+ self.query = query
+
+ try:
+ self.checks = search.parse_search_query(query)
+ except search.InvalidQuery:
+ self.checks = None
+
+
+ def match_tags(self, task: Task) -> bool:
+ """Match selected tags to task tags."""
+
+ return len(self.tags.intersection(set(task.tags))) >= len(self.tags)
+
+
+ def do_match(self, item) -> bool:
+ task = unwrap(item, Task)
+
+ if self.pane == 'active':
+ show = task.status is Status.ACTIVE
+ elif self.pane == 'workview':
+ show = task.is_actionable
+ if self.expand:
+ item.set_expanded(True)
+ self.expand = False
+ elif self.pane == 'closed':
+ show = task.status is not Status.ACTIVE
+
+ if show:
+ if self.tags:
+ current = self.match_tags(task)
+ tag_show = current or any(self.match_tags(c) for c in task.children)
+
+ return tag_show and search.search_filter(task, self.checks)
+
+ return search.search_filter(task, self.checks)
+ else:
+ return False
\ No newline at end of file
diff --git a/GTG/core/firstrun_tasks.py b/GTG/core/firstrun_tasks.py
index 75538d13b3..42bbaba83e 100644
--- a/GTG/core/firstrun_tasks.py
+++ b/GTG/core/firstrun_tasks.py
@@ -21,8 +21,7 @@
Tasks should serve as a quick tutorial how GTG works """
from gettext import gettext as _
-from GTG.core.tag import extract_tags_from_text
-from GTG.core import xml
+from GTG.core.tags import extract_tags_from_text
from GTG.core.dates import Date
from uuid import uuid4
@@ -68,13 +67,13 @@
"# Creating and editing tasks\n"
"\n"
"Using GTG is easy: you organize what you have to do by creating new "
- "tasks. To do this, simply press the "New Task" (+) button, "
+ "tasks. To do this, simply press the \"New Task\" (+) button, "
"edit the task by describing it, set some parameters, and that's it! "
- "Once a task is done, you can close it by pressing the "Mark As "
- "Done" button.\n"
+ "Once a task is done, you can close it by pressing the \"Mark As "
+ "Done\" button.\n"
"\n"
"In GTG, a task is automatically saved while you are editing it. No "
- "need to press any "Save" button! Try it: add some text to "
+ "need to press any \"Save\" button! Try it: add some text to "
"this task, close the window, and reopen it: your changes are still "
"there!\n"
"\n"
@@ -82,21 +81,21 @@
"\n"
"In life, you often get more things done by refining them in "
"smaller, more operational tasks. GTG helps to do just this by "
- "defining "subtasks". In GTG, those subtasks are "
+ "defining \"subtasks\". In GTG, those subtasks are "
"considered as prerequisites that must be completed before being able "
"to close their parent task.\n"
"\n"
"Therefore, in GTG, a task might host one or several subtasks. Those "
"appear as links in the task description, just like the link below. "
"To open and edit a subtask, simply click on its link! "
- "You can always come back using the "Open Parent" button. "
+ "You can always come back using the \"Open Parent\" button. "
"Try opening the following subtask for example:\n"
"{! 262d1410-71aa-4e35-abec-90ef1bab44d3 !}\n"
"\n"
"# Closing a task\n"
"\n"
"In GTG, once you are done with a task, you can close it by pushing "
- "either the "Mark as Done" or the "Dismiss" "
+ "either the \"Mark as Done\" or the \"Dismiss\" "
"button. Use the first one if the task is done, and the latter if you "
"want to close it because it is not relevant anymore.\n"
"\n"
@@ -106,7 +105,7 @@
"anymore (they were prerequisites, after all).\n"
"\n"
"Note that the tasks that you have marked as done or dismissed are "
- "listed in the "Closed" tasks view mode.\n"
+ "listed in the \"Closed\" tasks view mode.\n"
"\n"
"# Learn more about GTG\n"
"\n"
@@ -122,7 +121,7 @@
"{! 586dd1a7-0772-4d0a-85db-34edfc8ee30c !}\n"
"\n"
"We also recommend you read our user manual, by pressing F1 "
- "or using the "Help" entry in the main window's menu button.\n"
+ "or using the \"Help\" entry in the main window's menu button.\n"
"\n"
"We hope you will enjoy using GTG, and thank you for trying it out! "
"To learn more about the GTG project and how you can contribute, "
@@ -137,16 +136,16 @@
'added': today,
'modified': today,
'content': _(
- "A "Subtask" is something that you need to do first before "
+ "A \"Subtask\" is something that you need to do first before "
"being able to accomplish your task. In GTG, the purpose of subtasks "
"is to cut down a task (or project) in smaller, action-oriented subtasks "
"that are easier to achieve and to track down.\n\n"
"To insert a subtask in the task description (this window, for "
- "instance), begin a line with "-", then write the subtask "
+ "instance), begin a line with \"-\", then write the subtask "
"title and press Enter.\n"
"\n"
- "Try inserting one subtask below. Type "- This is my first "
- "subtask!", for instance, and press Enter:\n"
+ "Try inserting one subtask below. Type \"- This is my first "
+ "subtask!\", for instance, and press Enter:\n"
"\n"
"\n"
"\n"
@@ -169,9 +168,9 @@
'modified': today,
'content': _(
"In GTG, you use tags to sort your tasks. A tag is a simple word that "
- "begins with "@".\n"
+ "begins with \"@\".\n"
"\n"
- "Try to type a word beginning with "@" here:\n"
+ "Try to type a word beginning with \"@\" here:\n"
"\n"
"Once it becomes highlighted, it means it is recognized as a tag, "
"and this tag is immediately linked to the task.\n"
@@ -202,19 +201,19 @@
'added': today,
'modified': today,
'content': _(
- "If you press the "Actionable" tab, only actionable tasks "
+ "If you press the \"Actionable\" tab, only actionable tasks "
"will be displayed in your list.\n"
"\n"
"What is an actionable task? It's a task you can do directly, right "
"now.\n"
"\n"
- "It's a task that is already "start-able", i.e. the start "
+ "It's a task that is already \"start-able\", i.e. the start "
"date is already over.\n"
"\n"
"It's a task that doesn't have open subtasks, i.e. you can do the "
"task itself directly, it does not depend on something else.\n"
"\n"
- "It's a task that has a due date different than "Someday", "
+ "It's a task that has a due date different than \"Someday\", "
"since this kind of date is reserved for things that needs more "
"thoughts before being actionable.\n"
"\n"
@@ -233,7 +232,7 @@
"Actionable view, it might disappear from the view due to the change "
"you just made (e.g. adding a tag hidden in the Actionable view, etc.). "
"To avoid this, you may prefer to edit your task while "
- "in the "Open" tasks view instead.")
+ "in the \"Open\" tasks view instead.")
},
{
@@ -245,7 +244,7 @@
'content': _(
"GTG has the ability to add plugins to extend its core functionality."
"\n\n"
- "You can find the Plugins Manager by clicking on "Plugins" "
+ "You can find the Plugins Manager by clicking on \"Plugins\" "
"in the main window's menu button. We would like to encourage you "
"to write your own plugins and contribute them to the GTG project "
"so that we can consider them for wider inclusion.")
@@ -304,11 +303,11 @@
"if you want to edit your tasks using an online service.\n"
"\n"
"To use synchronization services, click the menu button in the main window, "
- "and select "Synchronization". You will then have the "
+ "and select \"Synchronization\". You will then have the "
"possibility to select among several online or local services "
"from/to where you can import or export your tasks.\n"
"As of v0.6, only CalDAV is available. You need to have the "
- ""caldav" Python package installed if it doesn't appear.")
+ "\"caldav\" Python package installed if it doesn't appear.")
},
{
@@ -324,18 +323,18 @@
"Searching for tasks is really easy: in the main window, hit Ctrl+F and "
"just type the words you are looking for in the search bar.\n"
"\n"
- "GTG can store your searches in the sidebar, in the "Saved Searches"
- "" section. You can thus always go back to a previous search if you "
+ "GTG can store your searches in the sidebar, in the \"Saved Searches"
+ "\" section. You can thus always go back to a previous search if you "
"need it. Search results are updated automatically, so you always get "
"all the tasks matching your search request.\n"
"\n"
"GTG's search feature is really powerful and accepts many parameters "
"that allow you to search for very specific tasks. For instance, "
- "using the search query "@errands !today", you can search "
+ "using the search query \"@errands !today\", you can search "
"for tasks with the @errands tag that must be done today. To learn "
"more about the various search parameters you can use, refer to the "
"Search Syntax documentation in GTG's user manual, found through the "
- ""Help" menu item in the main window's menu button.")
+ "\"Help\" menu item in the main window's menu button.")
},
]
diff --git a/GTG/core/meson.build b/GTG/core/meson.build
index f7a1f96a8d..f54a6544c4 100644
--- a/GTG/core/meson.build
+++ b/GTG/core/meson.build
@@ -26,29 +26,25 @@ gtg_core_sources = [
'borg.py',
'clipboard.py',
'config.py',
- 'datastore.py',
'dates.py',
'dirs.py',
'firstrun_tasks.py',
'interruptible.py',
'keyring.py',
'networkmanager.py',
- 'requester.py',
'search.py',
- 'tag.py',
- 'task.py',
- 'xml.py',
'timer.py',
- 'treefactory.py',
'twokeydict.py',
'urlregex.py',
'watchdog.py',
'versioning.py',
'base_store.py',
- 'tasks2.py',
- 'tags2.py',
+ 'tasks.py',
+ 'tags.py',
'saved_searches.py',
- 'datastore2.py',
+ 'datastore.py',
+ 'filters.py',
+ 'sorters.py',
]
gtg_core_plugin_sources = [
diff --git a/GTG/core/plugins/api.py b/GTG/core/plugins/api.py
index 3af6dac8da..7fe7f3d095 100644
--- a/GTG/core/plugins/api.py
+++ b/GTG/core/plugins/api.py
@@ -37,8 +37,7 @@ class PluginAPI():
"""
def __init__(self,
- requester,
- view_manager,
+ app,
taskeditor=None):
"""
Construct a PluginAPI object.
@@ -48,25 +47,29 @@ def __init__(self,
@param task_id: The Editor, if we are in one
otherwise.
"""
- self.__requester = requester
- self.__view_manager = view_manager
+
+ self.app = app
+ self.ds = app.ds
+ self.browser = app.browser
+
self.selection_changed_callback_listeners = []
+
if taskeditor:
self.__ui = taskeditor
- self.__builder = self.__ui.get_builder()
self.__task_id = taskeditor.get_task()
else:
- self.__ui = self.__view_manager.browser
- self.__builder = self.__ui.get_builder()
+ self.__ui = self.browser
self.__task_id = None
- self.__view_manager.browser.selection.connect(
- "changed", self.__selection_changed)
+
+ for pane in self.browser.panes.values():
+ pane.task_selection.connect('selection-changed', self.__selection_changed, pane)
+
self.taskwidget_id = 0
self.taskwidget_widg = []
- def __selection_changed(self, selection):
+ def __selection_changed(self, model, position, n_items, user_data=None):
for func in self.selection_changed_callback_listeners:
- func(selection)
+ func(user_data.get_selection())
# Accessor methods ============================================================
def is_editor(self):
@@ -85,19 +88,7 @@ def get_view_manager(self):
"""
returns a GTG.gtk.manager.Manager
"""
- return self.__view_manager
-
- def get_requester(self):
- """
- returns a GTG.core.requester.Requester
- """
- return self.__requester
-
- def get_gtk_builder(self):
- """
- Returns the gtk builder for the parent window
- """
- return self.__builder
+ return self.app
def get_ui(self):
"""
@@ -109,21 +100,21 @@ def get_browser(self):
"""
Returns a Browser
"""
- return self.__view_manager.browser
+ return self.browser
def get_menu(self):
"""
Return the menu entry to the menu of the Task Browser or Task Editor.
"""
- return self.__builder.get_object('main_menu')
+ return self.__ui.get_menu()
def get_header(self):
"""Return the headerbar of the mainwindow"""
- return self.__builder.get_object('browser_headerbar')
+ return self.__ui.get_headerbar()
def get_quickadd_pane(self):
"""Return the quickadd pane"""
- return self.__builder.get_object('quickadd_pane')
+ return self.__ui.get_quickadd_pane()
def get_selected(self):
"""
@@ -132,7 +123,7 @@ def get_selected(self):
if self.is_editor():
return self.__task_id
else:
- return self.__view_manager.browser.get_selected_tasks()
+ return self.browser.get_selected_tasks()
def set_active_selection_changed_callback(self, func):
if func not in self.selection_changed_callback_listeners:
@@ -149,9 +140,8 @@ def add_menu_item(self, item):
@param item: The Gio.MenuItem that is going to be added.
"""
- _, _, menu = self.__builder.get_object(
- 'editor_menu' if self.is_editor() else 'main_menu'
- ).iterate_item_links(0).get_next()
+ # All menu items are added to the first section
+ _, _, menu = self.__ui.get_menu().iterate_item_links(0).get_next()
menu.append_item(item)
def remove_menu_item(self, item):
@@ -162,10 +152,8 @@ def remove_menu_item(self, item):
# you cannot remove items by identity since there values are simply copied
# when adding a new one. A reliable solution is to instead find the first one
# with the same label as the given one.
- _, _, menu = self.__builder.get_object(
- 'editor_menu' if self.is_editor() else 'main_menu'
- # all menu items are added to the first section
- ).iterate_item_links(0).get_next()
+ # All menu items are added to the first section
+ _, _, menu = self.get_menu().iterate_item_links(0).get_next()
length = menu.get_n_items()
i = 0
@@ -189,11 +177,9 @@ def add_widget_to_taskeditor(self, widget):
@param widget: The Gtk.Widget that is going to be added.
"""
- vbox = self.__builder.get_object('pluginbox')
+ vbox = self.__ui.get_plugin_box()
if vbox:
- vbox.add(widget)
- vbox.reorder_child(widget, -2)
- widget.show_all()
+ vbox.append(widget)
self.taskwidget_id += 1
self.taskwidget_widg.append(widget)
return self.taskwidget_id
@@ -207,7 +193,7 @@ def remove_widget_from_taskeditor(self, widg_id):
"""
if self.is_editor() and widg_id:
try:
- wi = self.__builder.get_object('vbox4')
+ wi = self.__ui.get_plugin_box()
if wi and widg_id in self.taskwidget_widg:
wi.remove(self.taskwidget_widg.pop(widg_id))
except Exception:
@@ -223,12 +209,12 @@ def set_bgcolor_func(self, func=None):
browser = self.get_browser()
# set default bgcolor?
- if func is None:
- func = browser.tv_factory.get_task_bg_color
+ # if func is None:
+ # func = browser.tv_factory.get_task_bg_color
- for pane in browser.vtree_panes.values():
- pane.set_bg_color(func, 'bg_color')
- pane.basetree.get_basetree().refresh_all()
+ # for pane in browser.vtree_panes.values():
+ # pane.set_bg_color(func, 'bg_color')
+ # pane.basetree.get_basetree().refresh_all()
# file saving/loading =======================================================
def load_configuration_object(self, plugin_name, filename,
diff --git a/GTG/core/plugins/engine.py b/GTG/core/plugins/engine.py
index 15794ade06..500462e26d 100644
--- a/GTG/core/plugins/engine.py
+++ b/GTG/core/plugins/engine.py
@@ -15,7 +15,9 @@
# You should have received a copy of the GNU General Public License along with
# this program. If not, see .
# -----------------------------------------------------------------------------
-import imp
+
+import importlib
+import inspect
import os
import logging
from gi.repository import GLib
@@ -101,15 +103,17 @@ def _load_module(self, module_paths):
"""Load the module containing this plugin."""
try:
# import the module containing the plugin
- f, pathname, desc = imp.find_module(self.module_name, module_paths)
- module = imp.load_module(self.module_name, f, pathname, desc)
- # find the class object for the actual plugin
- for key, item in module.__dict__.items():
- if isinstance(item, type):
- self.plugin_class = item
- self.class_name = item.__dict__['__module__'].split('.')[1]
- break
+ spec = importlib.machinery.PathFinder().find_spec(self.module_name, module_paths)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+
+ classes = inspect.getmembers(mod, inspect.isclass)
+
+ self.class_name = classes[0][0]
+ self.plugin_class = classes[0][1]
+
except ImportError as e:
+ print(e)
# load_module() failed, probably because of a module dependency
if len(self.module_depends) > 0:
self._check_module_depends()
diff --git a/GTG/core/requester.py b/GTG/core/requester.py
deleted file mode 100644
index 4efbc70a2b..0000000000
--- a/GTG/core/requester.py
+++ /dev/null
@@ -1,278 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-"""
-A nice general purpose interface for the datastore and tagstore
-"""
-import logging
-from gi.repository import GObject
-
-from GTG.core.tag import Tag, SEARCH_TAG_PREFIX
-
-log = logging.getLogger(__name__)
-
-
-class Requester(GObject.GObject):
- """ A view on a GTG datastore.
-
- L{Requester} is a stateless object that simply provides a nice API for
- user interfaces to use for datastore operations.
-
- Multiple L{Requester}s can exist on the same datastore, so they should
- never have state of their own.
- """
- __gsignals__ = {'status-changed': (GObject.SignalFlags.RUN_FIRST, None, (str, str, str,))}
-
- def __init__(self, datastore, global_conf):
- """Construct a L{Requester}."""
- super().__init__()
- self.ds = datastore
- self._config = global_conf
- self.__basetree = self.ds.get_tasks_tree()
-
- # Tasks Tree ######################
- # By default, we return the task tree of the main window
- def get_tasks_tree(self, name='active', refresh=True):
- return self.__basetree.get_viewtree(name=name, refresh=refresh)
-
- def get_main_view(self):
- return self.__basetree.get_main_view()
-
- def is_displayed(self, task):
- return self.__basetree.get_viewtree(name='active').is_displayed(task)
-
- def get_basetree(self):
- return self.__basetree
-
- def apply_global_filter(self, tree, filtername):
- """
- This method also update the viewcount of tags
- TODO(jakubbrindza): Evaluate if this is used somewhere before release
- """
- tree.apply_filter(filtername)
- for t in self.get_all_tags():
- ta = self.get_tag(t)
- ta.apply_filter(filtername)
-
- def unapply_global_filter(self, tree, filtername):
- """
- TODO(jakubbrindza): Evaluate if this is used somewhere before release
- """
- tree.unapply_filter(filtername)
- for t in self.get_all_tags():
- ta = self.get_tag(t)
- ta.unapply_filter(filtername)
-
- # Filters bank #######################
- # List, by name, all available filters
- def list_filters(self):
- return self.__basetree.list_filters()
-
- # Add a filter to the filter bank
- # Return True if the filter was added
- # Return False if the filter_name was already in the bank
- def add_filter(self, filter_name, filter_func):
- return self.__basetree.add_filter(filter_name, filter_func)
-
- # Remove a filter from the bank.
- # Only custom filters that were added here can be removed
- # Return False if the filter was not removed
- def remove_filter(self, filter_name):
- return self.__basetree.remove_filter(filter_name)
-
- # Tasks ##########################
- def has_task(self, tid):
- """Does the task 'tid' exist?"""
- return self.ds.has_task(tid)
-
- def get_task(self, tid):
- """Get the task with the given C{tid}.
-
- If no such task exists, create it and force the tid to be C{tid}.
-
- @param tid: The task id.
- @return: A task.
- """
- task = self.ds.get_task(tid)
- return task
-
- # FIXME unused parameter newtask (maybe for compatibility?)
- def new_task(self, tags=None, newtask=True):
- """Create a new task.
-
- Note: this modifies the datastore.
-
- @param pid: The project where the new task will be created.
- @param tags: The tags for the new task. If not provided, then the
- task will have no tags. Tags must be an iterator type containing
- the tags tids
- @param newtask: C{True} if this is creating a new task that never
- existed, C{False} if importing an existing task from a backend.
- @return: A task from the data store
- """
- task = self.ds.new_task()
- if tags:
- for t in tags:
- assert(not isinstance(t, Tag))
- task.tag_added(t)
- return task
-
- def delete_task(self, tid, recursive=True):
- """Delete the task 'tid' and, by default, delete recursively
- all the childrens.
-
- Note: this modifies the datastore.
-
- @param tid: The id of the task to be deleted.
- """
- # send the signal before actually deleting the task !
- log.debug("deleting task %s", tid)
- return self.__basetree.del_node(tid, recursive=recursive)
-
- def get_task_id(self, task_title):
- """ Heuristic which convert task_title to a task_id
-
- Return a first task which has similar title """
-
- task_title = task_title.lower()
- tasks = self.get_tasks_tree('active', False).get_all_nodes()
- tasktree = self.get_main_view()
- for task_id in tasks:
- task = tasktree.get_node(task_id)
- if task_title == task.get_title().lower():
- return task_id
-
- return None
-
- # Tags ##########################
- def get_tag_tree(self):
- return self.ds.get_tagstore().get_viewtree(name='activetags')
-
- def new_tag(self, tagname):
- """Create a new tag called 'tagname'.
-
- Note: this modifies the datastore.
-
- @param tagname: The name of the new tag.
- @return: The newly-created tag.
- """
- return self.ds.new_tag(tagname)
-
- def new_search_tag(self, query):
- """
- Create a new search tag from search query
-
- Note: this modifies the datastore.
-
- @param query: Query will be parsed using search parser
- @return: tag_id
- """
- # ! at the beginning is reserved keyword for liblarch
- if query.startswith('!'):
- label = '_' + query
- else:
- label = query
-
- # find possible name collisions
- name, number = label, 1
- already_search = False
- while True:
- tag = self.get_tag(SEARCH_TAG_PREFIX + name)
- if tag is None:
- break
-
- if tag.is_search_tag() and tag.get_attribute("query") == query:
- already_search = True
- break
-
- # this name is used, adding number
- number += 1
- name = label + ' ' + str(number)
-
- if not already_search:
- tag = self.ds.new_search_tag(name, query)
-
- return SEARCH_TAG_PREFIX + name
-
- def remove_tag(self, name):
- """ calls datastore to remove a given tag """
- self.ds.remove_tag(name)
-
- def rename_tag(self, oldname, newname):
- self.ds.rename_tag(oldname, newname)
-
- def get_tag(self, tagname):
- return self.ds.get_tag(tagname)
-
- def get_used_tags(self):
- """Return tags currently used by a task.
-
- @return: A list of tag names used by a task.
- """
- tagstore = self.ds.get_tagstore()
- view = tagstore.get_viewtree(name='tag_completion', refresh=False)
- tags = view.get_all_nodes()
- tags.sort(key=str.lower)
- return tags
-
- def get_all_tags(self):
- """
- Gets all tags from all tasks
- """
- return self.ds.get_tagstore().get_main_view().get_all_nodes()
-
- def delete_tag(self, tagname):
- my_tag = self.get_tag(tagname)
- for task_id in my_tag.get_related_tasks():
- my_task = self.get_task(task_id)
- my_task.remove_tag(tagname)
- my_task.sync()
-
- # Backends #######################
- def get_all_backends(self, disabled=False):
- return self.ds.get_all_backends(disabled)
-
- def register_backend(self, dic):
- return self.ds.register_backend(dic)
-
- def flush_all_tasks(self, backend_id):
- return self.ds.flush_all_tasks(backend_id)
-
- def get_backend(self, backend_id):
- return self.ds.get_backend(backend_id)
-
- def set_backend_enabled(self, backend_id, state):
- return self.ds.set_backend_enabled(backend_id, state)
-
- def remove_backend(self, backend_id):
- return self.ds.remove_backend(backend_id)
-
- def backend_change_attached_tags(self, backend_id, tags):
- return self.ds.backend_change_attached_tags(backend_id, tags)
-
- def save_datastore(self, quit=False):
- return self.ds.save(quit)
-
- # Config ############################
- def get_config(self, system):
- """ Returns configuration object for subsytem, e.g. browser """
- return self._config.get_subconfig(system)
-
- def get_task_config(self, task_id):
- """ Returns configuration object for task """
- return self._config.get_task_config(task_id)
diff --git a/GTG/core/saved_searches.py b/GTG/core/saved_searches.py
index d7e6f5f025..fa361eabb0 100644
--- a/GTG/core/saved_searches.py
+++ b/GTG/core/saved_searches.py
@@ -19,7 +19,7 @@
"""Everything related to saved searches."""
-from gi.repository import GObject
+from gi.repository import GObject, Gio
from uuid import uuid4, UUID
from typing import Optional
@@ -36,17 +36,49 @@ class SavedSearch(GObject.Object):
"""A saved search."""
__gtype_name__ = 'gtg_SavedSearch'
- __slots__ = ['id', 'name', 'query', 'icon', 'children']
def __init__(self, id: UUID, name: str, query: str) -> None:
self.id = id
- self.name = name
- self.query = query
+ self._name = name
+ self._query = query
+ self._icon = None
- self.icon = None
- self.children = []
- self.parent = None
+ super(SavedSearch, self).__init__()
+
+ @GObject.Property(type=str)
+ def name(self) -> str:
+ """Read only property."""
+
+ return self._name
+
+ @name.setter
+ def set_name(self, value: str) -> None:
+ self._name = value
+
+
+ @GObject.Property(type=str)
+ def icon(self) -> str:
+ """Read only property."""
+
+ return self._icon
+
+
+ @icon.setter
+ def set_icon(self, value: str) -> None:
+ self._icon = value
+
+
+ @GObject.Property(type=str)
+ def query(self) -> str:
+ """Read only property."""
+
+ return self._query
+
+
+ @query.setter
+ def set_query(self, value: str) -> None:
+ self._query = value
def __str__(self) -> str:
@@ -76,6 +108,11 @@ class SavedSearchStore(BaseStore):
#: Tag to look for in XML
XML_TAG = 'savedSearch'
+ def __init__(self) -> None:
+ super().__init__()
+
+ self.model = Gio.ListStore.new(SavedSearch)
+
def __str__(self) -> str:
"""String representation."""
@@ -109,42 +146,17 @@ def from_xml(self, xml: Element) -> None:
log.debug('Added %s', search)
- for element in elements:
- parent_name = element.get('parent')
-
- if parent_name and parent_name != 'search':
- tid = element.get('id')
-
- parent = self.find(parent_name)
-
- if parent:
- self.parent(tid, parent.id)
- log.debug('Added %s as child of %s', element, parent)
-
-
def to_xml(self) -> Element:
"""Save searches to an LXML element."""
root = Element('searchlist')
- parent_map = {}
-
- for search in self.data:
- for child in search.children:
- parent_map[child.id] = search.name
-
for search in self.lookup.values():
element = SubElement(root, self.XML_TAG)
element.set('id', str(search.id))
element.set('name', search.name)
element.set('query', search.query)
- try:
- element.set('parent', str(parent_map[search.id]))
- except KeyError:
- # Toplevel search
- pass
-
return root
@@ -154,10 +166,17 @@ def new(self, name: str, query: str, parent: UUID = None) -> SavedSearch:
search_id = uuid4()
search = SavedSearch(id=search_id, name=name, query=query)
- if parent:
- self.add(search, parent)
- else:
- self.data.append(search)
- self.lookup[search_id] = search
+ self.data.append(search)
+ self.lookup[search_id] = search
+ self.model.append(search)
return search
+
+
+ def add(self, item, parent_id: UUID = None) -> None:
+ """Add a tag to the tagstore."""
+
+ super().add(item, parent_id)
+ self.model.append(item)
+
+ self.emit('added', item)
diff --git a/GTG/core/search.py b/GTG/core/search.py
index a8876e8809..38c1b1308f 100644
--- a/GTG/core/search.py
+++ b/GTG/core/search.py
@@ -247,7 +247,7 @@ def search_filter(task, parameters=None):
""" Check if task satisfies all search parameters """
if parameters is None or 'q' not in parameters:
- return False
+ return True
def check_commands(commands_list):
""" Execute search commands
@@ -256,24 +256,25 @@ def check_commands(commands_list):
def fulltext_search(task, word):
""" check if task contains the word """
+
word = word.lower()
- text = task.get_excerpt(strip_tags=False).lower()
- title = task.get_title().lower()
+ text = task.excerpt.lower()
+ title = task.title.lower()
return word in text or word in title
value_checks = {
- 'after': lambda t, v: task.get_due_date() > v,
- 'before': lambda t, v: task.get_due_date() < v,
- 'tag': lambda t, v: v in task.get_tags_name(),
+ 'after': lambda t, v: task.date_due > v,
+ 'before': lambda t, v: task.date_due < v,
+ 'tag': lambda t, v: v in [tag.name for tag in t.tags],
'word': fulltext_search,
- 'today': lambda task, v: task.get_due_date() == Date.today(),
- 'tomorrow': lambda task, v: task.get_due_date() == Date.tomorrow(),
- 'nodate': lambda task, v: task.get_due_date() == Date.no_date(),
- 'now': lambda task, v: task.get_due_date() == Date.now(),
- 'soon': lambda task, v: task.get_due_date() == Date.soon(),
- 'someday': lambda task, v: task.get_due_date() == Date.someday(),
- 'notag': lambda task, v: task.get_tags() == [],
+ 'today': lambda task, v: task.date_due == Date.today(),
+ 'tomorrow': lambda task, v: task.date_due == Date.tomorrow(),
+ 'nodate': lambda task, v: task.date_due == Date.no_date(),
+ 'now': lambda task, v: task.date_due == Date.now(),
+ 'soon': lambda task, v: task.date_due == Date.soon(),
+ 'someday': lambda task, v: task.date_due == Date.someday(),
+ 'notag': lambda task, v: task.tags == [],
}
for command in commands_list:
diff --git a/GTG/core/sorters.py b/GTG/core/sorters.py
new file mode 100644
index 0000000000..f2e8fb1178
--- /dev/null
+++ b/GTG/core/sorters.py
@@ -0,0 +1,186 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) - The GTG Team
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""Sorters for tags and tasks."""
+
+from gi.repository import Gtk, GObject, Gdk
+from GTG.core.tasks import Task
+
+def unwrap(row, expected_type):
+ """Find an item in TreeRow widget (sometimes nested)."""
+
+ item = row
+
+ while type(item) is not expected_type:
+ item = item.get_item()
+
+ return item
+
+
+class TaskTitleSorter(Gtk.Sorter):
+ __gtype_name__ = 'TaskTitleSorter'
+
+ def __init__(self):
+ super(TaskTitleSorter, self).__init__()
+
+
+ def do_compare(self, a, b) -> Gtk.Ordering:
+
+ a = unwrap(a, Task)
+ b = unwrap(b, Task)
+
+ first = a.title[0]
+ second = b.title[0]
+
+ if first > second:
+ return Gtk.Ordering.LARGER
+ elif first < second:
+ return Gtk.Ordering.SMALLER
+ else:
+ return Gtk.Ordering.EQUAL
+
+
+class TaskDueSorter(Gtk.Sorter):
+ __gtype_name__ = 'DueSorter'
+
+ def __init__(self):
+ super(TaskDueSorter, self).__init__()
+
+
+ def do_compare(self, a, b) -> Gtk.Ordering:
+
+ a = unwrap(a, Task)
+ b = unwrap(b, Task)
+
+ first = a.date_due
+ second = b.date_due
+
+ if first > second:
+ return Gtk.Ordering.LARGER
+ elif first < second:
+ return Gtk.Ordering.SMALLER
+ else:
+ return Gtk.Ordering.EQUAL
+
+
+class TaskStartSorter(Gtk.Sorter):
+ __gtype_name__ = 'StartSorter'
+
+ def __init__(self):
+ super(TaskStartSorter, self).__init__()
+
+
+ def do_compare(self, a, b) -> Gtk.Ordering:
+
+ a = unwrap(a, Task)
+ b = unwrap(b, Task)
+
+ first = a.date_start
+ second = b.date_start
+
+ if first > second:
+ return Gtk.Ordering.LARGER
+ elif first < second:
+ return Gtk.Ordering.SMALLER
+ else:
+ return Gtk.Ordering.EQUAL
+
+
+class TaskModifiedSorter(Gtk.Sorter):
+ __gtype_name__ = 'ModifiedSorter'
+
+ def __init__(self):
+ super(TaskModifiedSorter, self).__init__()
+
+
+ def do_compare(self, a, b) -> Gtk.Ordering:
+
+ a = unwrap(a, Task)
+ b = unwrap(b, Task)
+
+ first = a.date_modified
+ second = b.date_modified
+
+ if first > second:
+ return Gtk.Ordering.LARGER
+ elif first < second:
+ return Gtk.Ordering.SMALLER
+ else:
+ return Gtk.Ordering.EQUAL
+
+
+class TaskTagSorter(Gtk.Sorter):
+ __gtype_name__ = 'TagSorter'
+
+ def __init__(self):
+ super(TaskTagSorter, self).__init__()
+
+
+ def get_first_letter(self, tags) -> str:
+ """Get first letter of the first tag in a set of tags."""
+
+ # Fastest way to get the first item
+ # on a set in Python
+ for t in tags:
+ return t.name[0]
+
+ def do_compare(self, a, b) -> Gtk.Ordering:
+
+ a = unwrap(a, Task)
+ b = unwrap(b, Task)
+
+ if a.tags:
+ first = self.get_first_letter(a.tags)
+ else:
+ first = 'zzzzzzz'
+
+ if b.tags:
+ second = self.get_first_letter(b.tags)
+ else:
+ second = 'zzzzzzz'
+
+ if first > second:
+ return Gtk.Ordering.LARGER
+ elif first < second:
+ return Gtk.Ordering.SMALLER
+ else:
+ return Gtk.Ordering.EQUAL
+
+
+class TaskAddedSorter(Gtk.Sorter):
+ __gtype_name__ = 'AddedSorter'
+
+ def __init__(self):
+ super(TaskAddedSorter, self).__init__()
+
+
+ def do_compare(self, a, b) -> Gtk.Ordering:
+
+ a = unwrap(a, Task)
+ b = unwrap(b, Task)
+
+ first = a.date_added
+ second = b.date_added
+
+ if first > second:
+ return Gtk.Ordering.LARGER
+ elif first < second:
+ return Gtk.Ordering.SMALLER
+ else:
+ return Gtk.Ordering.EQUAL
+
diff --git a/GTG/core/tag.py b/GTG/core/tag.py
deleted file mode 100644
index 068e5a27e6..0000000000
--- a/GTG/core/tag.py
+++ /dev/null
@@ -1,287 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-"""
-tagstore is where the tag objects are handled. Also defines the Tag object.
-
-Tagstore is to tag as datastore is to task. Of course, the tagstore is
-easier. See the end of this file for the Tag object implementation.
-"""
-
-import uuid
-import xml.sax.saxutils as saxutils
-import re
-
-from liblarch import TreeNode
-from functools import reduce
-
-# Tags with special meaning
-ALLTASKS_TAG = "gtg-tags-all"
-NOTAG_TAG = "gtg-tags-none"
-SEP_TAG = "gtg-tags-sep"
-SEARCH_TAG = "search"
-
-SEARCH_TAG_PREFIX = "__SAVED_SEARCH_" # For name inside the tagtree
-
-def extract_tags_from_text(text):
- """ Given a string, returns a list of the @tags contained in that """
-
- return re.findall(r'(?:^|[\s])(@[\w\/\.\-\:\&]*\w)', text)
-
-
-def parse_tag_list(text):
- """ Parse a line of a list of tasks. User can specify if the tag is
- positive or not by prepending '!'.
-
- @param text: string entry from user
- @return: list of tupples (tag, is_positive)
- """
-
- result = []
- for tag in text.split():
- if tag.startswith('!'):
- tag = tag[1:]
- is_positive = False
- else:
- is_positive = True
-
- result.append((tag, is_positive))
- return result
-
-
-class Tag(TreeNode):
- """A short name that can be applied to L{Task}s.
-
- I mean, surely you must know what a tag is by now. Think Gmail,
- del.icio.us, Flickr et al.
-
- A tag is defined by its name, which in most cases is C{@something}. A tag
- can also have multiple arbitrary attributes. The only attribute enforced
- for tags is C{name}, which always matches L{Tag.get_name()}.
- """
-
- def __init__(self, name, req, attributes={}, tid=None):
- """Construct a tag.
-
- @param name: The name of the tag. Should be a string, generally
- a short one.
- @param attributes: Allow having initial set of attributes without
- calling _save callback
- """
- super().__init__(name)
- self._name = saxutils.unescape(str(name))
- self.req = req
- self._save = None
- self._attributes = {'name': self._name}
- for key, value in attributes.items():
- self.set_attribute(key, value)
-
- self.viewcount = None
-
- if tid:
- self.tid = tid
- else:
- self.tid = uuid.uuid4()
-
- def __get_viewcount(self):
- if not self.viewcount and self.get_name() != "gtg-tags-sep":
- basetree = self.req.get_basetree()
- self.viewcount = basetree.get_viewcount(self.get_name(), False)
-
- sp_id = self.get_attribute("special")
- if sp_id == "all":
- pass
- if sp_id == "notag":
- self.viewcount.apply_filter('notag', refresh=False)
- # No special means a normal tag
- else:
- self.viewcount.apply_filter(self.get_name(), refresh=False)
- self.viewcount.apply_filter('active')
- self.viewcount.register_cllbck(self.modified)
- return self.viewcount
-
- def apply_filter(self, filtername):
- if self.viewcount:
- self.viewcount.apply_filter(filtername)
-
- def unapply_filter(self, filtername):
- if self.viewcount:
- self.viewcount.unapply_filter(filtername)
-
- # When a task change a tag, we may want to manually update
- # To ensure that the task is well counted/uncounted for that tag
- def update_task(self, task_id):
- vc = self.__get_viewcount()
- vc.modify(task_id)
-
- # overiding some functions to not allow dnd of special tags
- def add_parent(self, parent_id):
- p = self.req.get_tag(parent_id)
- if p and not self.is_special() and not p.is_special():
- TreeNode.add_parent(self, parent_id)
-
- def add_child(self, child_id):
- special_child = self.req.get_tag(child_id).is_special()
- if not self.is_special() and not special_child:
- TreeNode.add_child(self, child_id)
-
- def get_name(self):
- """Return the internal name of the tag, as saved in the tree."""
- return self.get_attribute("name")
-
- def get_friendly_name(self):
- """Return the name of the tag, but without the internal search tag prefix."""
- if self.is_search_tag():
- return self.get_attribute("name")[len(SEARCH_TAG_PREFIX):]
- else:
- return self.get_attribute("name")
-
- def set_save_callback(self, save):
- self._save = save
-
- def set_attribute(self, att_name, att_value):
- """Set an arbitrary attribute.
-
- This will call the C{save_cllbk} callback passed to the constructor.
-
- @param att_name: The name of the attribute.
- @param att_value: The value of the attribute. Will be converted to a
- string.
- """
- modified = False
- if att_name == "name":
- raise KeyError(
- "The name of tag cannot be set manually")
- elif att_name == "parent":
- self.add_parent(att_value)
- modified = True
- else:
- # Attributes should all be strings.
- val = str(att_value)
- self._attributes[att_name] = val
- if self._save:
- self._save()
- modified = True
- if modified:
- self.modified()
- self.notify_related_tasks()
-
- def get_attribute(self, att_name):
- """Get the attribute C{att_name}.
-
- Returns C{None} if there is no attribute matching C{att_name}.
- """
- to_return = None
- if att_name == 'parent':
- if self.has_parent():
- parents_id = self.get_parents()
- if len(parents_id) > 0:
- to_return = reduce(lambda a, b: f"{a},{b}",
- parents_id)
- elif att_name == 'label':
- to_return = self._attributes.get(att_name, self.get_id())
- else:
- to_return = self._attributes.get(att_name, None)
- return to_return
-
- def del_attribute(self, att_name):
- """Deletes the attribute C{att_name}.
- """
- if att_name not in self._attributes:
- return
- elif att_name in ['name', 'parent']:
- return
- else:
- del self._attributes[att_name]
- if self._save:
- self._save()
- self.modified()
- self.notify_related_tasks()
-
- def get_all_attributes(self, butname=False, withparent=False):
- """Return a list of all attribute names.
-
- @param butname: If True, exclude C{name} from the list of attribute
- names.
- @param withparent: If True, the "parent" attribute is attached
- """
- attributes = list(self._attributes.keys())
- if butname:
- attributes.remove('name')
- if withparent:
- parent_id = self.get_attribute("parent")
- if parent_id:
- attributes.append("parent")
- return attributes
-
- # TASK relation ####
- def get_active_tasks_count(self):
- count = self.__get_count()
- return count
-
- def get_total_tasks_count(self):
- return self.__get_count()
-
- def __get_count(self, tasktree=None):
- """Returns the number of all related tasks"""
- # this method purposefully doesn't rely on get_related_tasks()
- # which does a similar job, in order to benefit from liblarch
- # optimizations
- vc = self.__get_viewcount()
- if vc:
- return vc.get_n_nodes()
- else:
- return 0
-
- def get_related_tasks(self, tasktree=None):
- """Returns all related tasks node ids"""
- if not tasktree:
- tasktree = self.req.get_tasks_tree()
- sp_id = self.get_attribute("special")
- if sp_id == "all":
- toreturn = tasktree.get_nodes(withfilters=['active'])
- elif sp_id == "notag":
- toreturn = tasktree.get_nodes(withfilters=['notag'])
- elif sp_id == "sep":
- toreturn = []
- else:
- tname = self.get_name()
- toreturn = tasktree.get_nodes(withfilters=[tname])
- return toreturn
-
- def notify_related_tasks(self):
- """Notify changes to all related tasks"""
- for task_id in self.get_related_tasks():
- my_task = self.req.get_task(task_id)
- my_task.modified()
-
- def is_special(self):
- return bool(self.get_attribute('special'))
-
- def is_search_tag(self):
- return SEARCH_TAG in self.get_parents()
-
- def is_used(self):
- return self.get_total_tasks_count() > 0
-
- def is_actively_used(self):
- return self.is_search_tag() or self.is_special() or\
- self.get_active_tasks_count() > 0
-
- def __str__(self):
- return "Tag: %s" % self.get_name()
diff --git a/GTG/core/tags2.py b/GTG/core/tags.py
similarity index 52%
rename from GTG/core/tags2.py
rename to GTG/core/tags.py
index 35334187e3..4b7b64571c 100644
--- a/GTG/core/tags2.py
+++ b/GTG/core/tags.py
@@ -19,11 +19,12 @@
"""Everything related to tags."""
-from gi.repository import GObject
+from gi.repository import GObject, Gtk, Gio, Gdk
from uuid import uuid4, UUID
import logging
import random
+import re
from lxml.etree import Element, SubElement
from typing import Any, Dict, Set
@@ -33,23 +34,33 @@
log = logging.getLogger(__name__)
-class Tag2(GObject.Object):
+def extract_tags_from_text(text):
+ """ Given a string, returns a list of the @tags contained in that """
+
+ return re.findall(r'(?:^|[\s])(@[\w\/\.\-\:\&]*\w)', text)
+
+
+class Tag(GObject.Object):
"""A tag that can be applied to a Task."""
__gtype_name__ = 'gtg_Tag'
- __slots__ = ['id', 'name', 'icon', 'color', 'actionable', 'children']
-
def __init__(self, id: UUID, name: str) -> None:
self.id = id
- self.name = name
+ self._name = name
- self.icon = None
- self.color = None
+ self._icon = None
+ self._color = None
self.actionable = True
self.children = []
self.parent = None
+ self._task_count_open = 0
+ self._task_count_actionable = 0
+ self._task_count_closed = 0
+
+ super(Tag, self).__init__()
+
def __str__(self) -> str:
"""String representation."""
@@ -69,6 +80,93 @@ def __eq__(self, other) -> bool:
return self.id == other.id
+ @GObject.Property(type=str)
+ def name(self) -> str:
+ """Read only property."""
+
+ return self._name
+
+
+ @name.setter
+ def set_name(self, value: str) -> None:
+ self._name = value
+
+
+ @GObject.Property(type=str)
+ def icon(self) -> str:
+ """Read only property."""
+
+ return self._icon
+
+
+ @icon.setter
+ def set_icon(self, value: str) -> None:
+ self._icon = value
+ self.notify('has-icon')
+
+
+ @GObject.Property(type=str)
+ def color(self) -> str:
+ """Read only property."""
+
+ return self._color
+
+
+ @color.setter
+ def set_color(self, value: str) -> None:
+ self._color = value
+ self.notify('has-color')
+
+
+ @GObject.Property(type=bool, default=False)
+ def has_color(self) -> bool:
+
+ return self._color and not self._icon
+
+
+ @GObject.Property(type=bool, default=False)
+ def has_icon(self) -> bool:
+
+ return self._icon
+
+
+ @GObject.Property(type=int, default=0)
+ def task_count_open(self) -> int:
+
+ return self._task_count_open
+
+
+ @task_count_open.setter
+ def set_task_count_open(self, value: int) -> None:
+ self._task_count_open = value
+
+
+ @GObject.Property(type=int, default=0)
+ def task_count_actionable(self) -> int:
+
+ return self._task_count_actionable
+
+
+ @task_count_actionable.setter
+ def set_task_count_actionable(self, value: int) -> None:
+ self._task_count_actionable = value
+
+
+ @GObject.Property(type=int, default=0)
+ def task_count_closed(self) -> int:
+
+ return self._task_count_closed
+
+
+ @task_count_closed.setter
+ def set_task_count_closed(self, value: int) -> None:
+ self._task_count_closed = value
+
+
+ def __hash__(self):
+ return id(self)
+
+
class TagStore(BaseStore):
"""A tree of tags."""
@@ -81,24 +179,42 @@ class TagStore(BaseStore):
def __init__(self) -> None:
self.used_colors: Set[Color] = set()
- self.lookup_names: Dict[str, Tag2] = {}
+ self.lookup_names: Dict[str, Tag] = {}
super().__init__()
+ self.model = Gio.ListStore.new(Tag)
+ self.tree_model = Gtk.TreeListModel.new(self.model, False, False, self.model_expand)
+
+
+ def model_expand(self, item):
+ model = Gio.ListStore.new(Tag)
+
+ if type(item) == Gtk.TreeListRow:
+ item = item.get_item()
+
+ # open the first one
+ if item.children:
+ for child in item.children:
+ model.append(child)
+
+ return Gtk.TreeListModel.new(model, False, False, self.model_expand)
+
+
def __str__(self) -> str:
"""String representation."""
return f'Tag Store. Holds {len(self.lookup)} tag(s)'
- def find(self, name: str) -> Tag2:
+ def find(self, name: str) -> Tag:
"""Get a tag by name."""
return self.lookup_names[name]
- def new(self, name: str, parent: UUID = None) -> Tag2:
+ def new(self, name: str, parent: UUID = None) -> Tag:
"""Create a new tag and add it to the store."""
name = name if not name.startswith('@') else name[1:]
@@ -107,14 +223,12 @@ def new(self, name: str, parent: UUID = None) -> Tag2:
return self.lookup_names[name]
except KeyError:
tid = uuid4()
- tag = Tag2(id=tid, name=name)
+ tag = Tag(id=tid, name=name)
if parent:
self.add(tag, parent)
else:
- self.data.append(tag)
- self.lookup[tid] = tag
- self.lookup_names[name] = tag
+ self.add(tag)
self.emit('added', tag)
return tag
@@ -132,10 +246,23 @@ def from_xml(self, xml: Element) -> None:
name = element.get('name')
color = element.get('color')
icon = element.get('icon')
+ nonactionable = element.get('nonactionable') or 'False'
- tag = Tag2(id=tid, name=name)
+ if color:
+ if not color.startswith('#'):
+ color = '#' + color
+
+ rgb = Gdk.RGBA()
+ rgb.parse(color)
+ red = int(rgb.red * 255)
+ blue = int(rgb.blue * 255)
+ green = int(rgb.green * 255)
+ color = '#{:02x}{:02x}{:02x}'.format(red, green, blue)
+
+ tag = Tag(id=tid, name=name)
tag.color = color
tag.icon = icon
+ tag.actionable = (nonactionable == 'False')
self.add(tag)
@@ -149,9 +276,13 @@ def from_xml(self, xml: Element) -> None:
tid = element.get('id')
try:
- parent = self.find(parent_name)
- self.parent(tid, parent.id)
- log.debug('Added %s as child of %s', tag, parent)
+ parent_id = self.find(parent_name).id
+ except KeyError:
+ parent_id = parent_name
+
+ try:
+ self.parent(tid, parent_id)
+ log.debug('Added %s as child of %s', tag, parent_name)
except KeyError:
pass
@@ -178,6 +309,9 @@ def to_xml(self) -> Element:
if tag.icon:
element.set('icon', tag.icon)
+
+ element.set('nonactionable', str(not tag.actionable))
+
try:
element.set('parent', str(parent_map[tag.id]))
except KeyError:
@@ -210,4 +344,24 @@ def add(self, item: Any, parent_id: UUID = None) -> None:
super().add(item, parent_id)
self.lookup_names[item.name] = item
+
+ if not parent_id:
+ self.model.append(item)
+
self.emit('added', item)
+
+
+ def parent(self, item_id: UUID, parent_id: UUID) -> None:
+
+ super().parent(item_id, parent_id)
+ item = self.lookup[item_id]
+ pos = self.model.find(item)
+ self.model.remove(pos[1])
+
+
+
+ def unparent(self, item_id: UUID, parent_id: UUID) -> None:
+
+ super().unparent(item_id, parent_id)
+ item = self.lookup[item_id]
+ self.model.append(item)
diff --git a/GTG/core/task.py b/GTG/core/task.py
deleted file mode 100644
index 88be9af6a8..0000000000
--- a/GTG/core/task.py
+++ /dev/null
@@ -1,933 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-"""
-task.py contains the Task class which represents (guess what) a task
-"""
-from datetime import datetime, date
-import html
-import re
-import uuid
-import logging
-import xml.sax.saxutils as saxutils
-
-from gi.repository import GLib
-
-from gettext import gettext as _
-from GTG.core.dates import Date
-from liblarch import TreeNode
-
-log = logging.getLogger(__name__)
-
-
-class Task(TreeNode):
- """ This class represent a task in GTG.
- You should never create a Task directly. Use the datastore.new_task()
- function."""
-
- STA_ACTIVE = "Active"
- STA_DISMISSED = "Dismiss"
- STA_DONE = "Done"
- DEFAULT_TASK_NAME = None
-
- def __init__(self, task_id, requester, newtask=False):
- super().__init__(task_id)
- # the id of this task in the project should be set
- # tid is a string ! (we have to choose a type and stick to it)
- if not isinstance(task_id, str):
- raise ValueError("Wrong type for task_id %r", type(task_id))
- self.tid = task_id
- self.set_uuid(task_id)
- self.remote_ids = {}
- # set to True to disable self.sync() and avoid flooding on task edit
- self.sync_disabled = False
- self.content = ""
- if Task.DEFAULT_TASK_NAME is None:
- Task.DEFAULT_TASK_NAME = _("My new task")
- self.title = Task.DEFAULT_TASK_NAME
- # available status are: Active - Done - Dismiss - Note
- self.status = self.STA_ACTIVE
-
- self.added_date = Date.no_date()
- if newtask:
- self.added_date = Date(datetime.now())
-
- self.closed_date = Date.no_date()
- self.due_date = Date.no_date()
- self.start_date = Date.no_date()
- self.can_be_deleted = newtask
- # tags
- self.tags = []
- self.req = requester
- self.__main_treeview = requester.get_main_view()
- # If we don't have a newtask, we will have to load it.
- self.loaded = newtask
- # Should not be necessary with the new backends
-# if self.loaded:
-# self.req._task_loaded(self.tid)
- self.attributes = {}
- self._modified_update()
-
- # Setting the attributes related to repeating tasks.
- self.recurring = False # type: bool
- self.recurring_term = None # type: str
- self.recurring_updated_date = Date.no_date()
- self.inherit_recursion()
-
- def get_added_date(self):
- return self.added_date
-
- def set_added_date(self, value):
- self.added_date = Date(value)
-
- def is_loaded(self):
- return self.loaded
-
- def set_loaded(self, signal=True):
- # avoid doing it multiple times
- if not self.loaded:
- self.loaded = True
-
- def set_to_keep(self):
- self.can_be_deleted = False
-
- def is_new(self):
- return self.can_be_deleted
-
- def get_id(self):
- return str(self.tid)
-
- def set_uuid(self, value):
- self.uuid = str(value)
-
- def get_uuid(self):
- # NOTE: Transitional if switch, needed to add
- # the uuid field to tasks created before
- # adding this field to the task description.
- if self.uuid == "":
- self.set_uuid(uuid.uuid4())
- self.sync()
- return str(self.uuid)
-
- def get_title(self):
- return self.title
-
- def duplicate(self):
- """ Duplicates a task with a new ID """
- copy = self.req.ds.new_task()
- # Inherit the recurrency
- copy.set_recurring(True, self.recurring_term)
- nextdate = self.get_next_occurrence()
- copy.set_due_date(nextdate)
-
- copy.set_title(self.title)
- copy.content = self.content
- copy.tags = self.tags
- log.debug("Duppicating task %s as task %s",
- self.get_id(), copy.get_id())
- return copy
-
- def duplicate_recursively(self):
- """
- Duplicates recursively all the task itself and its children while keeping the relationship
- """
- newtask = self.duplicate()
- if self.has_child():
- for c_tid in self.get_children():
- child = self.req.get_task(c_tid)
- if child.is_loaded():
- newtask.add_child(child.duplicate_recursively())
-
- newtask.sync()
- return newtask.tid
-
-
- def set_title(self, title):
- """Set the tasks title. Returns True if title was changed."""
-
- if title:
- title = title.strip('\t\n')
- else:
- title = '(no title task)'
-
- # Avoid unnecessary syncing
- if title == self.title:
- return False
- else:
- self.title = title
- self.sync()
- return True
-
- def toggle_status(self):
-
- if self.status in [self.STA_DONE, self.STA_DISMISSED]:
- self.set_status(self.STA_ACTIVE)
- else:
- self.set_status(self.STA_DONE)
-
- def set_status(self, status, donedate=None, propagation=False, init=False):
- old_status = self.status
- self.can_be_deleted = False
- # No need to update children or whatever if the task is not loaded
- if status and self.is_loaded():
- # we first modify the status of the children
- # If Done, we set the done date
- if status in [self.STA_DONE, self.STA_DISMISSED]:
- for c in self.get_subtasks():
- if c.get_status() in [self.STA_ACTIVE]:
- c.set_status(status, donedate=donedate, propagation=True)
-
- # If the task is recurring, it must be duplicate with
- # another task id and the next occurence of the task
- # while preserving child/parent relations.
- # For a task to be duplicated, it must satisfy 3 rules.
- # 1- It is recurring.
- # 2- It has no parent or no recurring parent.
- # 3- It was directly marked as done (not by propagation from its parent).
- rules = [self.recurring, not propagation]
- if all(rules) and not self.is_parent_recurring():
- # duplicate all the children
- nexttask_tid = self.duplicate_recursively()
- if self.has_parent():
- for p_tid in self.get_parents():
- par = self.req.get_task(p_tid)
- if par.is_loaded() and par.get_status() in (self.STA_ACTIVE):
- par.add_child(nexttask_tid)
- par.sync()
-
- # If we mark a task as Active and that some parent are not
- # Active, we break the parent/child relation
- # It has no sense to have an active subtask of a done parent.
- # (old_status check is necessary to avoid false positive a start)
- elif status in [self.STA_ACTIVE] and\
- old_status in [self.STA_DONE, self.STA_DISMISSED]:
- if self.has_parent():
- for p_tid in self.get_parents():
- par = self.req.get_task(p_tid)
- if par.is_loaded() and par.get_status() in\
- [self.STA_DONE, self.STA_DISMISSED]:
- # we can either break the parent/child relationship
- # self.remove_parent(p_tid)
- # or restore the parent too
- par.set_status(self.STA_ACTIVE)
- # We dont mark the children as Active because
- # They might be already completed after all
-
- # then the task itself
- if status:
- if not init:
- GLib.idle_add(self.req.emit, "status-changed", self.tid, self.status, status)
- self.status = status
-
- # Set closing date
- if status and status in [self.STA_DONE, self.STA_DISMISSED]:
- # to the specified date (if any)
- if donedate:
- self.closed_date = donedate
- # or to today
- else:
- self.closed_date = Date.today()
- self.sync()
-
- def get_status(self):
- return self.status
-
- def get_modified(self):
- return self.last_modified
-
- def set_modified(self, value):
- self.last_modified = Date(value)
-
- def recursive_sync(self):
- """Recursively sync the task and all task children. Defined"""
- self.sync()
- for sub_id in self.children:
- sub = self.req.get_task(sub_id)
- sub.recursive_sync()
-
- # ABOUT RECURRING TASKS
- # Like anything related to dates, repeating tasks are subtle and complex
- # when creating a new task, the due date is calculated from either the current date or
- # the start date, while we get the next occurrence of a task not from the current date but
- # from the due date itself.
- #
- # However when we are retrieving the task from the XML files, we should only set the
- # the recurring_term.
-
- def set_recurring(self, recurring: bool, recurring_term: str = None, newtask=False):
- """Sets a task as recurring or not, and its recurring term.
-
- There are 4 cases to acknowledge when setting a task to recurring:
- - if repeating but the term is invalid: it will be set to False.
- - if repeating and the term is valid: we set it to True.
- - if not repeating and the term is valid: we set the bool attr to True and set the term.
- - if not repeating and the term is invalid: we set it to False and keep the previous term.
-
- Setting a task as recurrent implies that the
- children of a recurrent task will be also
- set to recurrent and will inherit
- their parent's recurring term
-
- Args:
- recurring (bool): True if the task is recurring and False if not.
- recurring_term (str, optional): the recurring period of a task (every Monday, day..).
- Defaults to None.
- newtask (bool, optional): if this is a new task, we must set the due_date.
- Defaults to False.
- """
- def is_valid_term():
- """ Verify if the term is valid and returns the appropriate Due date.
-
- Return a tuple of (bool, Date)
- """
- if recurring_term is None:
- return False, None
-
- try:
- # If a start date is already set,
- # we should calculate the next date from that day.
- if self.start_date == Date.no_date():
- start_from = Date(datetime.now())
- else:
- start_from = self.start_date
-
- newdate = start_from.parse_from_date(recurring_term, newtask)
- return True, newdate
- except ValueError:
- return False, None
-
- self.recurring = recurring
- # We verifiy if the term passed is valid
- valid, newdate = is_valid_term()
-
- recurring_term = recurring_term if valid else None
-
- if self.recurring:
- if not valid:
- self.recurring_term = None
- self.recurring = False
- else:
- self.recurring_term = recurring_term
- self.recurring_updated_date = datetime.now()
- if newtask:
- self.set_due_date(newdate)
- else:
- if valid:
- self.recurring_term = recurring_term
- self.recurring_updated_date = datetime.now()
-
- self.sync()
- # setting its children to recurrent
- if self.has_child():
- for c_tid in self.get_children():
- child = self.req.get_task(c_tid)
- if child.is_loaded() and child.get_status() in (self.STA_ACTIVE):
- child.set_recurring(self.recurring, self.recurring_term)
- if self.recurring:
- child.set_due_date(newdate)
-
- def toggle_recurring(self):
- """ Toggle a task's recurrency ON/OFF. Use this function to toggle, not set_recurring"""
- # If there is no recurring_term, We assume it to recur every day.
- newtask = False
- if self.recurring_term is None:
- self.recurring_term = 'day'
- newtask = True
-
- self.set_recurring(not self.recurring, self.recurring_term, newtask)
-
- def get_recurring(self):
- return self.recurring
-
- def get_recurring_term(self):
- return self.recurring_term
-
- def get_recurring_updated_date(self):
- return self.recurring_updated_date
-
- def set_recurring_updated_date(self, date):
- self.recurring_updated_date = Date(date)
-
- def inherit_recursion(self):
- """ Inherits the recurrent state of the parent.
- If the task has a recurrent parent, it must be set to recur, itself.
- """
- if self.has_parent():
- for p_tid in self.get_parents():
- par = self.req.get_task(p_tid)
- if par.get_recurring() and par.is_loaded():
- self.set_recurring(True, par.get_recurring_term())
- self.set_due_date(par.due_date)
- else:
- self.set_recurring(False)
-
-
- def get_next_occurrence(self):
- """Calcutate the next occurrence of a recurring task
-
- To know which is the correct next occurrence there are two rules:
- - if the task was marked as done before or during the open period (before the duedate);
- in this case, we need to deal with the issue of tasks that recur on the same date.
- example: due_date is 09/09 and done_date is 09/09
- - if the task was marked after the due date, we need to figure out the next occurrence
- after the current date(today).
-
- Raises:
- ValueError: if the recurring_term is invalid
-
- Returns:
- Date: the next due date of a task
- """
- today = date.today()
- if today <= self.due_date:
- try:
- nextdate = self.due_date.parse_from_date(self.recurring_term, newtask=False)
- while nextdate <= self.due_date:
- nextdate = nextdate.parse_from_date(self.recurring_term, newtask=False)
- return nextdate
- except Exception:
- raise ValueError(f'Invalid recurring term {self.recurring_term}')
- elif today > self.due_date:
- try:
- next_date = self.due_date.parse_from_date(self.recurring_term, newtask=False)
- while next_date < date.today():
- next_date = next_date.parse_from_date(self.recurring_term, newtask=False)
- return next_date
- except Exception:
- raise ValueError(f'Invalid recurring term {self.recurring_term}')
-
- def is_parent_recurring(self):
- if self.has_parent():
- for p_tid in self.get_parents():
- p = self.req.get_task(p_tid)
- if p.is_loaded() and p.get_status() in (self.STA_ACTIVE) and p.get_recurring():
- return True
- return False
-
-
- # ABOUT DUE DATES
- #
- # PLEASE READ THIS: although simple in appearance, handling task dates can
- # actually be subtle. Take the time to understand this if you plan to work
- # on the methods below.
- #
- # Due date is the date at which a task must be accomplished. Constraints
- # exist between a task's due date and its ancestor/children's due dates.
- #
- # Date constraints
- #
- # Those are the following:
- # - children of a task cannot have a task due date that happens later
- # than the task's due date
- # - ancestors of a task cannot have a due that happens before the
- # task's due date (this is the reverse constraint from the first one)
- # - a task's start date cannot happen later than this task's due date
- #
- # Tasks with undefined or fuzzy due dates
- #
- # Task with no due date (="undefined" tasks) or tasks with fuzzy start/due
- # dates are not subject to constraints. Furthermore, they are
- # "transparent". Meaning that they let the constraints coming from their
- # children/parents pass through them. So, for instance, a children of
- # a task with an undefined or fuzzy task would be constrained by this
- # latter task's ancestors. Equally, the an ancestor from the same
- # undefined/fuzzy task would be constrained by the children due dates.
- #
- # Updating a task due date
- #
- # Whenever a task due date is changed, all ancestor/chldren of this task
- # *must* be updated according to the constraining rules. As said above,
- # constraints must go through tasks with undefined/fuzzy due dates too!
- #
- # Undefined/fuzzy task dates are NEVER to be updated. They are not
- # sensitive to constraint. If you want to now what constraint there is
- # on this task's due date though, you can obtain it by using
- # get_due_date_constraint method.
- def set_due_date(self, new_duedate):
- """Defines the task's due date."""
-
- def __get_defined_parent_list(task):
- """Recursively fetch a list of parents that have a defined due date
- which is not fuzzy"""
- parent_list = []
- for par_id in task.parents:
- par = self.req.get_task(par_id)
- if par.get_due_date().is_fuzzy():
- parent_list += __get_defined_parent_list(par)
- else:
- parent_list.append(par)
- return parent_list
-
- def __get_defined_child_list(task):
- """Recursively fetch a list of children that have a defined
- due date which is not fuzzy"""
- child_list = []
- for child_id in task.children:
- child = self.req.get_task(child_id)
- if child.get_due_date().is_fuzzy():
- child_list += __get_defined_child_list(child)
- else:
- child_list.append(child)
- return child_list
-
- old_due_date = self.due_date
- new_duedate_obj = Date(new_duedate) # caching the conversion
- self.due_date = new_duedate_obj
- # If the new date is fuzzy or undefined, we don't update related tasks
- if not new_duedate_obj.is_fuzzy():
- # if some ancestors' due dates happen before the task's new
- # due date, we update them (except for fuzzy dates)
- for par in __get_defined_parent_list(self):
- if par.get_due_date() < new_duedate_obj:
- par.set_due_date(new_duedate)
- # we must apply the constraints to the defined & non-fuzzy children
- # as well
- for sub in __get_defined_child_list(self):
- sub_duedate = sub.get_due_date()
- # if the child's due date happens later than the task's: we
- # update it to the task's new due date
- if sub_duedate > new_duedate_obj:
- sub.set_due_date(new_duedate)
- # if the child's start date happens later than
- # the task's new due date, we update it
- # (except for fuzzy start dates)
- sub_startdate = sub.get_start_date()
- if not sub_startdate.is_fuzzy() and \
- sub_startdate > new_duedate_obj:
- sub.set_start_date(new_duedate)
- # If the date changed, we notify the change for the children since the
- # constraints might have changed
- if old_due_date != new_duedate_obj:
- self.recursive_sync()
-
- def get_due_date(self):
- """ Returns the due date, which always respects all constraints """
- return self.due_date
-
- def get_urgent_date(self):
- """
- Returns the most urgent due date among the task and its active subtasks
- """
- urgent_date = self.get_due_date()
- for subtask in self.get_subtasks():
- if subtask.get_status() == self.STA_ACTIVE:
- urgent_date = min(urgent_date, subtask.get_urgent_date())
- return urgent_date
-
- def get_due_date_constraint(self):
- """ Returns the most urgent due date constraint, following
- parents' due dates. Return Date.no_date() if no constraint
- is applied. """
- # Check out for constraints depending on date definition/fuzziness.
- strongest_const_date = self.due_date
- if strongest_const_date.is_fuzzy():
- for par_id in self.parents:
- par = self.req.get_task(par_id)
- par_duedate = par.get_due_date()
- # if parent date is undefined or fuzzy, look further up
- if par_duedate.is_fuzzy():
- par_duedate = par.get_due_date_constraint()
- # if par_duedate is still undefined/fuzzy, all parents' due
- # dates are undefined or fuzzy: strongest_const_date is then
- # the best choice so far, we don't update it.
- if par_duedate.is_fuzzy():
- continue
- # par_duedate is not undefined/fuzzy. If strongest_const_date
- # is still undefined or fuzzy, parent_duedate is the best
- # choice.
- if strongest_const_date.is_fuzzy():
- strongest_const_date = par_duedate
- continue
- # strongest_const_date and par_date are defined and not fuzzy:
- # we compare the dates
- if par_duedate < strongest_const_date:
- strongest_const_date = par_duedate
- return strongest_const_date
-
- # ABOUT START DATE
- #
- # Start date is the date at which the user has decided to work or consider
- # working on this task.
- def set_start_date(self, fulldate):
- self.start_date = Date(fulldate)
- self.sync()
-
- def get_start_date(self):
- return self.start_date
-
- # ABOUT CLOSED DATE
- #
- # Closed date is the date at which the task has been closed (done or
- # dismissed). Closed date is not constrained and doesn't constrain other
- # dates.
- def set_closed_date(self, fulldate):
- self.closed_date = Date(fulldate)
- self.sync()
-
- def get_closed_date(self):
- return self.closed_date
-
- def get_days_left(self):
- return self.get_due_date().days_left()
-
- def get_days_late(self):
- due_date = self.get_due_date()
- if due_date == Date.no_date():
- return None
- closed_date = self.get_closed_date()
- return (closed_date - due_date).days
-
- def get_text(self):
- """ Return the content or empty string in case of None """
- if self.content:
- return str(self.content)
- else:
- return ""
-
- def get_excerpt(self, lines=0, char=0, strip_tags=False,
- strip_subtasks=True):
- """
- get_excerpt return the beginning of the content of the task.
- If "lines" is provided and different than 0, it return the number X
- of line (or the whole content if it contains less lines)
- If "char" is provided, it returns the X first chars of content (or the
- whole contents if it contains less char)
- If both char and lines are provided, the shorter one is returned.
- If none of them are provided (or if they are 0), this function is
- equivalent to get_text with with all XML stripped down.
- Warning: all markup informations are stripped down. Empty lines are
- also removed
- """
- # defensive programmation to avoid returning None
- if self.content:
- txt = self.content
-
- # Prevent issues with & in content
- txt = saxutils.escape(txt)
- txt = txt.strip()
-
- if strip_tags:
- for tag in self.get_tags_name():
- txt = (txt.replace(f'@{tag}, ', '')
- .replace(f'@{tag},', '')
- .replace(f'@{tag}', ''))
-
- if strip_subtasks:
- txt = re.sub(r'\{\!.+\!\}', '', txt)
-
- # Strip blank lines and get desired amount of lines
- txt = [line for line in txt.splitlines() if line]
- txt = txt[:lines]
- txt = '\n'.join(txt)
-
- # We keep the desired number of char
- if char > 0:
- txt = txt[:char]
- return txt
- else:
- return ""
-
- def __strip_content(self, element, strip_subtasks=False):
- txt = ""
- if element:
- for n in element.childNodes:
- if n.nodeType == n.ELEMENT_NODE:
- if strip_subtasks and n.tagName == 'subtask':
- if txt[-2:] == '→ ':
- txt = txt[:-2]
- else:
- txt += self.__strip_content(n, strip_subtasks)
- elif n.nodeType == n.TEXT_NODE:
- txt += n.nodeValue
- return txt
-
- def set_text(self, texte):
- self.can_be_deleted = False
- self.content = html.unescape(str(texte))
-
- # SUBTASKS ###############################################################
- def new_subtask(self):
- """Add a newly created subtask to this task. Return the task added as
- a subtask
- """
- subt = self.req.new_task(newtask=True)
- # we use the inherited childrens
- self.add_child(subt.get_id())
- return subt
-
- def add_child(self, tid):
- """Add a subtask to this task
-
- @param child: the added task
- """
- log.debug("adding child %s to task %s", tid, self.get_id())
- self.can_be_deleted = False
- # the core of the method is in the TreeNode object
- TreeNode.add_child(self, tid)
- # now we set inherited attributes only if it's a new task
- child = self.req.get_task(tid)
- if self.is_loaded() and child and child.can_be_deleted:
- # If the the child is repeating no need to change the date
- if not child.get_recurring():
- child.set_start_date(self.get_start_date())
- child.set_due_date(self.get_due_date())
- for t in self.get_tags():
- child.add_tag(t.get_name())
-
- child.inherit_recursion()
-
- self.sync()
- return True
-
- def remove_child(self, tid):
- """Removed a subtask from the task.
-
- @param tid: the ID of the task to remove
- """
- c = self.req.get_task(tid)
- c.remove_parent(self.get_id())
- if c.can_be_deleted:
- self.req.delete_task(tid)
- self.sync()
- return True
- else:
- return False
-
- # FIXME: remove this function and use liblarch instead.
- def get_subtasks(self):
- tree = self.get_tree()
- return [tree.get_node(node_id) for node_id in self.get_children()]
-
- def set_parent(self, parent_id):
- """Update the task's parent. Refresh due date constraints."""
- TreeNode.set_parent(self, parent_id)
- if parent_id is not None:
- par = self.req.get_task(parent_id)
- par_duedate = par.get_due_date_constraint()
- if not par_duedate.is_fuzzy() and \
- not self.due_date.is_fuzzy() and \
- par_duedate < self.due_date:
- self.set_due_date(par_duedate)
- self.inherit_recursion()
- self.recursive_sync()
-
- def set_attribute(self, att_name, att_value, namespace=""):
- """Set an arbitrary attribute.
-
- @param att_name: The name of the attribute.
- @param att_value: The value of the attribute. Will be converted to a
- string.
- """
- val = str(att_value)
- self.attributes[(namespace, att_name)] = val
- self.sync()
-
- def get_attribute(self, att_name, namespace=""):
- """Get the attribute C{att_name}.
-
- Returns C{None} if there is no attribute matching C{att_name}.
- """
- return self.attributes.get((namespace, att_name), None)
-
- def sync(self):
- if self.sync_disabled:
- return self.is_loaded()
- self._modified_update()
- if self.is_loaded():
- # This is a liblarch call to the TreeNode ancestor
- self.modified()
- return True
- return False
-
- def _modified_update(self):
- """
- Updates the modified timestamp
- """
- self.last_modified = datetime.now()
-
-# TAG FUNCTIONS ##############################################################
- def get_tags_name(self):
- # Return a copy of the list of tags. Not the original object.
- return list(self.tags)
-
- # return a copy of the list of tag objects
- def get_tags(self):
- tags = []
- for tname in self.tags:
- tag = self.req.get_tag(tname)
- if not tag:
- tag = self.req.new_tag(tname)
- tags.append(tag)
- return tags
-
- def rename_tag(self, old, new):
- eold = saxutils.escape(saxutils.unescape(old))
- enew = saxutils.escape(saxutils.unescape(new))
- self.content = self.content.replace(eold, enew)
- oldt = self.req.get_tag(old)
- self.remove_tag(old)
- oldt.modified()
- self.tag_added(new)
- self.req.get_tag(new).modified()
- self.sync()
-
- def tag_added_by_id(self, tid):
- """Add a tag by its ID"""
-
- tag = self.req.ds.get_tag_by_id(tid)
-
- if tag:
- self.tag_added(tag.get_name())
-
- def tag_added(self, tagname):
- """
- Adds a tag. Does not add '@tag' to the contents. See add_tag
- """
- if tagname not in self.tags:
- self.tags.append(tagname)
- if self.is_loaded():
- for child in self.get_subtasks():
- if child.can_be_deleted:
- child.tag_added(tagname)
-
- tag = self.req.get_tag(tagname)
- if not tag:
- tag = self.req.new_tag(tagname)
- tag.modified()
- return True
-
- def add_tag(self, tagname):
- "Add a tag to the task and insert '@tag' into the task's content"
-
- if self.tag_added(tagname):
- c = self.content
- tagname = html.escape(tagname)
- tagname = '@' + tagname if not tagname.startswith('@') else tagname
-
- if not c:
- # don't need a separator if it's the only text
- sep = ''
- elif c.startswith('@'):
- # if content starts with a tag, make a comma-separated list
- sep = ', '
- else:
- # other text at the beginning, so put the tag on its own line
- sep = '\n\n'
-
- self.content = f'{tagname}{sep}{c}'
- # we modify the task internal state, thus we have to call for a
- # sync
-
- self.sync()
-
- # remove by tagname
- def remove_tag(self, tagname):
- modified = False
- if tagname in self.tags:
- self.tags.remove(tagname)
- modified = True
- for child in self.get_subtasks():
- if child.can_be_deleted:
- child.remove_tag(tagname)
- self.content = self._strip_tag(self.content, tagname)
- if modified:
- tag = self.req.get_tag(tagname)
- # The ViewCount of the tag still doesn't know that
- # the task was removed. We need to update manually
- if tag:
- tag.update_task(self.get_id())
- tag.modified()
-
- def _strip_tag(self, text, tagname, newtag=''):
- if tagname.startswith('@'):
- inline_tag = tagname[1:]
- else:
- inline_tag = tagname
-
- return (text
- .replace(f'@{tagname}\n\n', newtag)
- .replace(f'@{tagname}\n', newtag)
- .replace(f'@{tagname}, ', newtag)
- .replace(f'@{tagname}', newtag)
- .replace(f'{tagname}\n\n', newtag)
- .replace(f'{tagname}, ', newtag)
- .replace(f'{tagname},', inline_tag)
- # don't forget a space a the end
- .replace(f'{tagname}', inline_tag))
-
- # tag_list is a list of tags names
- # return true if at least one of the list is in the task
- def has_tags(self, tag_list=None, notag_only=False):
- # recursive function to explore the tags and its children
- def children_tag(tagname):
- toreturn = False
- if tagname in self.tags:
- toreturn = True
- else:
- tag = self.req.get_tag(tagname)
- if tag:
- for tagc_name in tag.get_children():
- if not toreturn:
- toreturn = children_tag(tagc_name)
- return toreturn
-
- # We want to see if the task has no tags
- toreturn = False
- if notag_only:
- toreturn = self.tags == []
- # Here, the user ask for the "empty" tag
- # And virtually every task has it.
- elif tag_list == [] or tag_list is None:
- toreturn = True
- elif tag_list:
- for tagname in tag_list:
- if not toreturn:
- toreturn = children_tag(tagname)
- else:
- # Well, if we don't filter on tags or notag, it's true, of course
- toreturn = True
- return toreturn
-
- def __str__(self):
- return '' % (
- self.title,
- self.tid,
- self.status,
- self.tags,
- self.added_date,
- self.recurring)
-
- __repr__ = __str__
-
-
-class DisabledSyncCtx:
- """Context manager disabling GTG.core.task.Task.sync()
- and firing one only sync on __exit__"""
-
- def __init__(self, task: Task, sync_on_exit: bool = True):
- self.task = task
- self.sync_on_exit = sync_on_exit
-
- def __enter__(self):
- self.task.sync_disabled = True
- return self.task
-
- def __exit__(self, *args, **kwargs):
- self.task.sync_disabled = False
- if self.sync_on_exit:
- self.task.sync()
diff --git a/GTG/core/tasks.py b/GTG/core/tasks.py
new file mode 100644
index 0000000000..9446b8140d
--- /dev/null
+++ b/GTG/core/tasks.py
@@ -0,0 +1,1033 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) The GTG Team
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""Everything related to tasks."""
+
+
+from gi.repository import GObject, Gio, Gtk, Gdk
+from gettext import gettext as _
+
+from uuid import uuid4, UUID
+import logging
+from typing import Callable, Any, Optional
+from enum import Enum
+import re
+import datetime
+from operator import attrgetter
+
+from lxml.etree import Element, SubElement, CDATA
+
+from GTG.core.base_store import BaseStore
+from GTG.core.tags import Tag, TagStore
+from GTG.core.dates import Date
+
+log = logging.getLogger(__name__)
+
+
+# ------------------------------------------------------------------------------
+# REGEXES
+# ------------------------------------------------------------------------------
+
+TAG_REGEX = re.compile(r'^\B\@\w+(\-\w+)*\,*')
+SUB_REGEX = re.compile(r'\{\!.+\!\}')
+
+
+# ------------------------------------------------------------------------------
+# TASK STATUS
+# ------------------------------------------------------------------------------
+
+class Status(Enum):
+ """Status for a task."""
+
+ ACTIVE = 'Active'
+ DONE = 'Done'
+ DISMISSED = 'Dismissed'
+
+
+class Filter(Enum):
+ """Types of filters."""
+
+ ACTIVE = 'Active'
+ ACTIONABLE = 'Actionable'
+ CLOSED = 'Closed'
+ STATUS = 'Status'
+ TAG = 'Tag'
+ PARENT = 'Parent'
+ CHILDREN = 'Children'
+
+
+# ------------------------------------------------------------------------------
+# TASK
+# ------------------------------------------------------------------------------
+
+DEFAULT_TITLE = _('New Task')
+
+
+class Task(GObject.Object):
+ """A single task."""
+
+ __gtype_name__ = 'gtg_Task'
+
+ def __init__(self, id: UUID, title: str) -> None:
+ self.id = id
+ self.raw_title = title.strip('\t\n')
+ self.content = ''
+ self.tags = set()
+ self.children = []
+ self.status = Status.ACTIVE
+ self.parent = None
+
+ self._date_added = Date.no_date()
+ self._date_due = Date.no_date()
+ self._date_start = Date.no_date()
+ self._date_closed = Date.no_date()
+ self._date_modified = Date(datetime.datetime.now())
+
+ self._has_date_due = False
+ self._has_date_start = False
+
+ self._date_due_str = ''
+ self._date_start_str = ''
+ self._is_active = True
+
+ self._is_recurring = False
+ self.recurring_term = None
+ self.recurring_updated_date = datetime.datetime.now()
+
+ self.attributes = {}
+
+ self.duplicate_cb = NotImplemented
+
+ super(Task, self).__init__()
+
+
+ @GObject.Property(type=bool, default=True)
+ def is_actionable(self) -> bool:
+ """Determine if this task is actionable."""
+
+ actionable_tags = all(t.actionable for t in self.tags)
+ active_children = all(t.status != Status.ACTIVE for t in self.children)
+ days_left = self._date_start.days_left()
+ can_start = True if not days_left else days_left <= 0
+
+ return (self.status == Status.ACTIVE
+ and self._date_due != Date.someday()
+ and actionable_tags
+ and active_children
+ and can_start)
+
+
+ def toggle_active(self, propagated: bool = False) -> None:
+ """Toggle between possible statuses."""
+
+ if self.status is Status.ACTIVE:
+ status = Status.DONE
+
+ else:
+ status = Status.ACTIVE
+
+ self.set_status(status, propagated)
+
+
+ def toggle_dismiss(self, propagated: bool = False) -> None:
+ """Set this task to be dismissed."""
+
+ if self.status is Status.ACTIVE:
+ status = Status.DISMISSED
+
+ else:
+ status = Status.ACTIVE
+
+ self.set_status(status, propagated)
+
+
+ def set_status(self, status: Status, propagated: bool = False) -> None:
+ """Set status for task."""
+
+ if self.status == Status.ACTIVE:
+ for t in self.tags:
+ t.task_count_open -= 1
+
+ if self.is_actionable:
+ for t in self.tags:
+ t.task_count_actionable -= 1
+
+ else:
+ for t in self.tags:
+ t.task_count_closed -= 1
+
+ self.status = status
+ self.is_active = (status == Status.ACTIVE)
+
+ if status != Status.ACTIVE:
+ self.date_closed = Date.today()
+
+ # If the task is recurring, it must be duplicate with
+ # another task id and the next occurence of the task
+ # while preserving child/parent relations.
+ # For a task to be duplicated, it must satisfy 3 rules.
+ # 1- It is recurring.
+ # 2- It has no parent or no recurring parent.
+ # 3- It was directly marked as done (not by propagation from its parent).
+ if (self._is_recurring and not propagated and
+ not self.is_parent_recurring()):
+ self.duplicate_cb(self)
+
+ else:
+ self.date_closed = Date.no_date()
+
+ if self.parent and self.parent.status is not Status.ACTIVE:
+ self.parent.set_status(status, propagated=True)
+
+ if status == Status.ACTIVE:
+ for t in self.tags:
+ t.task_count_open += 1
+
+ if self.is_actionable:
+ for t in self.tags:
+ t.task_count_actionable += 1
+
+ else:
+ for t in self.tags:
+ t.task_count_closed += 1
+
+
+ for child in self.children:
+ child.set_status(status, propagated=True)
+
+
+ @property
+ def date_due(self) -> Date:
+ return self._date_due
+
+
+ @date_due.setter
+ def date_due(self, value: Date) -> None:
+ self._date_due = value
+ self.has_date_due = bool(value)
+
+ if value:
+ self.date_due_str = self._date_due.to_readable_string()
+ else:
+ self.date_due_str = ''
+
+ for tag in self.tags:
+ if self.is_actionable:
+ tag.task_count_actionable += 1
+ else:
+ tag.task_count_actionable -= 1
+
+ if not value or value.is_fuzzy():
+ return
+
+ for child in self.children:
+ if (child.date_due
+ and not child.date_due.is_fuzzy()
+ and child.date_due > value):
+
+ child.date_due = value
+
+ if (self.parent
+ and self.parent.date_due
+ and self.parent.date_due.is_fuzzy()
+ and self.parent.date_due < value):
+ self.parent.date_due = value
+
+
+ @property
+ def date_added(self) -> Date:
+ return self._date_added
+
+
+ @date_added.setter
+ def date_added(self, value: Any) -> None:
+ self._date_added = Date(value)
+
+
+ @property
+ def date_start(self) -> Date:
+ return self._date_start
+
+
+ @date_start.setter
+ def date_start(self, value: Any) -> None:
+ if isinstance(value, str):
+ self._date_start = Date.parse(value)
+ else:
+ self._date_start = Date(value)
+
+ self.has_date_start = bool(value)
+ self.date_start_str = self._date_start.to_readable_string()
+
+
+ @property
+ def date_closed(self) -> Date:
+ return self._date_closed
+
+
+ @date_closed.setter
+ def date_closed(self, value: Any) -> None:
+ self._date_closed = Date(value)
+
+
+ @property
+ def date_modified(self) -> Date:
+ return self._date_modified
+
+
+ @date_modified.setter
+ def date_modified(self, value: Any) -> None:
+ self._date_modified = Date(value)
+
+
+ @GObject.Property(type=str)
+ def title(self) -> str:
+ return self.raw_title
+
+
+ @title.setter
+ def title(self, value) -> None:
+ self.raw_title = value.strip('\t\n') or _('(no title)')
+
+
+ @GObject.Property(type=str)
+ def excerpt(self) -> str:
+ if not self.content:
+ return ''
+
+ # Strip tags
+ txt = TAG_REGEX.sub('', self.content)
+
+ # Strip subtasks
+ txt = SUB_REGEX.sub('', txt)
+
+ # Strip blank lines and set within char limit
+ return f'{txt.strip()[:50]}…'
+
+
+ def add_tag(self, tag: Tag) -> None:
+ """Add a tag to this task."""
+
+ if isinstance(tag, Tag):
+ if tag not in self.tags:
+ self.tags.add(tag)
+
+ if self.status == Status.ACTIVE:
+ tag.task_count_open += 1
+ else:
+ tag.task_count_closed += 1
+
+ if self.is_actionable:
+ tag.task_count_actionable += 1
+ else:
+ raise ValueError
+
+
+ def remove_tag(self, tag_name: str) -> None:
+ """Remove a tag from this task."""
+
+ for t in self.tags.copy():
+ if t.name == tag_name:
+ self.tags.remove(t)
+
+ if self.status == Status.ACTIVE:
+ t.task_count_open -= 1
+ else:
+ t.task_count_closed -= 1
+
+ if self.is_actionable:
+ t.task_count_actionable -= 1
+
+ self.content = (self.content.replace(f'{tag_name}\n\n', '')
+ .replace(f'{tag_name},', '')
+ .replace(f'{tag_name}', ''))
+
+
+ def rename_tag(self, old_tag_name: str, new_tag_name: str) -> None:
+ """Replace a tag's name in the content."""
+
+ self.content = (self.content.replace(f'@{old_tag_name}',
+ f'@{new_tag_name}'))
+
+
+ @property
+ def days_left(self) -> Optional[Date]:
+ return self.date_due.days_left()
+
+
+ def update_modified(self) -> None:
+ """Update the modified property."""
+
+ self._date_modified = Date(datetime.datetime.now())
+
+
+ def set_recurring(self, recurring: bool, recurring_term: str = None, newtask=False):
+ """Sets a task as recurring or not, and its recurring term.
+
+ Like anything related to dates, repeating tasks are subtle and complex
+ when creating a new task, the due date is calculated from either the
+ current date or the start date, while we get the next occurrence of a
+ task not from the current date but from the due date itself.
+
+ However when we are retrieving the task from the XML files, we should
+ only set the the recurring_term.
+
+ There are 4 cases to acknowledge when setting a task to recurring:
+ - if repeating but the term is invalid: it will be set to False.
+ - if repeating and the term is valid: we set it to True.
+ - if not repeating and the term is valid: we set the bool attr to True and set the term.
+ - if not repeating and the term is invalid: we set it to False and keep the previous term.
+
+ Setting a task as recurrent implies that the children of a recurrent
+ task will be also set to recurrent and will inherit their parent's
+ recurring term
+
+ Args:
+ recurring (bool): True if the task is recurring and False if not.
+ recurring_term (str, optional): the recurring period of a task (every Monday, day..).
+ Defaults to None.
+ newtask (bool, optional): if this is a new task, we must set the due_date.
+ Defaults to False.
+ """
+ def is_valid_term():
+ """ Verify if the term is valid and returns the appropriate Due date.
+
+ Return a tuple of (bool, Date)
+ """
+ if recurring_term is None:
+ return False, None
+
+ try:
+ # If a start date is already set,
+ # we should calculate the next date from that day.
+ if self.date_start == Date.no_date():
+ start_from = Date(datetime.datetime.now())
+ else:
+ start_from = self.date_start
+
+ newdate = start_from.parse_from_date(recurring_term, newtask)
+
+ return True, newdate
+
+ except ValueError:
+ return False, None
+
+ self._is_recurring = recurring
+
+ # We verifiy if the term passed is valid
+ valid, newdate = is_valid_term()
+ recurring_term = recurring_term if valid else None
+
+ if self._is_recurring:
+ if not valid:
+ self.recurring_term = None
+ self._is_recurring = False
+ else:
+ self.recurring_term = recurring_term
+ self.recurring_updated_date = datetime.datetime.now()
+
+ if newtask:
+ self.date_due = newdate
+ else:
+ if valid:
+ self.recurring_term = recurring_term
+ self.recurring_updated_date = datetime.datetime.now()
+
+ # setting its children to recurrent
+ for child in self.children:
+ if child.status is Status.ACTIVE:
+ child.set_recurring(self._is_recurring, self.recurring_term)
+
+ if self._is_recurring:
+ child.date_due = newdate
+
+ self.notify('is_recurring')
+
+
+ def toggle_recurring(self):
+ """ Toggle a task's recurrency ON/OFF. Use this function to toggle, not set_recurring"""
+
+ # If there is no recurring_term, We assume it to recur every day.
+ newtask = False
+
+ if self.recurring_term is None:
+ self.recurring_term = 'day'
+ newtask = True
+
+ self.set_recurring(not self._is_recurring, self.recurring_term, newtask)
+
+
+ def inherit_recursion(self):
+ """ Inherits the recurrent state of the parent.
+ If the task has a recurrent parent, it must be set to recur, itself.
+ """
+ if self.parent and self.parent._is_recurring:
+ self.set_recurring(True, self.parent.recurring_term)
+ self.date_due = self.parent.date_due
+ else:
+ self._is_recurring = False
+
+
+ def is_parent_recurring(self):
+ """Determine if the parent task is recurring."""
+
+ return (self.parent and
+ self.parent.status == Status.ACTIVE
+ and self.parent._is_recurring)
+
+
+ def get_next_occurrence(self):
+ """Calcutate the next occurrence of a recurring task
+
+ To know which is the correct next occurrence there are two rules:
+ - if the task was marked as done before or during the open period (before the duedate);
+ in this case, we need to deal with the issue of tasks that recur on the same date.
+ example: due_date is 09/09 and done_date is 09/09
+ - if the task was marked after the due date, we need to figure out the next occurrence
+ after the current date(today).
+
+ Raises:
+ ValueError: if the recurring_term is invalid
+
+ Returns:
+ Date: the next due date of a task
+ """
+
+ today = datetime.date.today()
+
+ if today <= self.date_due:
+ try:
+ nextdate = self.date_due.parse_from_date(self.recurring_term, newtask=False)
+
+ while nextdate <= self.date_due:
+ nextdate = nextdate.parse_from_date(self.recurring_term, newtask=False)
+
+ return nextdate
+
+ except Exception:
+ raise ValueError(f'Invalid recurring term {self.recurring_term}')
+
+ elif today > self.date_due:
+ try:
+ next_date = self.date_due.parse_from_date(self.recurring_term, newtask=False)
+
+ while next_date < datetime.date.today():
+ next_date = next_date.parse_from_date(self.recurring_term, newtask=False)
+
+ return next_date
+
+ except Exception:
+ raise ValueError(f'Invalid recurring term {self.recurring_term}')
+
+ # -----------------------------------------------------------------------
+ # Bind Properties
+ #
+ # Since PyGobject doesn't support bind_property_full() yet
+ # we can't do complex binds. These props below serve as a
+ # workaround so that we can use them with the regular
+ # bind_property().
+ # -----------------------------------------------------------------------
+
+ @GObject.Property(type=bool, default=False)
+ def is_recurring(self) -> bool:
+ return self._is_recurring
+
+
+ @GObject.Property(type=bool, default=False)
+ def has_date_due(self) -> bool:
+ return self._has_date_due
+
+
+ @has_date_due.setter
+ def set_has_date_due(self, value) -> None:
+ self._has_date_due = value
+
+
+ @GObject.Property(type=bool, default=False)
+ def has_date_start(self) -> bool:
+ return self._has_date_start
+
+
+ @has_date_start.setter
+ def set_has_date_start(self, value) -> None:
+ self._has_date_start = value
+
+
+ @GObject.Property(type=str)
+ def date_start_str(self) -> str:
+ return self._date_start_str
+
+
+ @date_start_str.setter
+ def set_date_start_str(self, value) -> None:
+ self._date_start_str = value
+
+
+ @GObject.Property(type=str)
+ def date_due_str(self) -> str:
+ return self._date_due_str
+
+
+ @date_due_str.setter
+ def set_date_due_str(self, value) -> None:
+ self._date_due_str = value
+
+
+ @GObject.Property(type=bool, default=True)
+ def is_active(self) -> bool:
+ return self._is_active
+
+
+ @is_active.setter
+ def set_is_active(self, value) -> None:
+ self._is_active = value
+
+
+ @GObject.Property(type=bool, default=False)
+ def has_children(self) -> bool:
+ return bool(len(self.children))
+
+
+ @GObject.Property(type=str)
+ def icons(self) -> str:
+ icons_text = ''
+ for t in self.tags:
+ if t.icon:
+ icons_text += t.icon
+
+ return icons_text
+
+
+ @GObject.Property(type=str)
+ def row_css(self) -> str:
+ for tag in self.tags:
+ if tag.color:
+ color = Gdk.RGBA()
+ color.parse(tag.color)
+ color.alpha = 0.1
+ return '* { background:' + color.to_string() + '; }'
+
+
+ @GObject.Property(type=str)
+ def tag_colors(self) -> str:
+ return ','.join(t.color for t in self.tags
+ if t.color and not t.icon)
+
+
+ @GObject.Property(type=bool, default=False)
+ def show_tag_colors(self) -> str:
+ return any(t.color and not t.icon for t in self.tags)
+
+
+ @property
+ def tag_names(self) -> list[str]:
+ return [ t.name for t in self.tags ]
+
+
+ def set_attribute(self, att_name, att_value, namespace="") -> None:
+ """Set an arbitrary attribute."""
+
+ val = str(att_value)
+ self.attributes[(namespace, att_name)] = val
+
+
+ def get_attribute(self, att_name, namespace="") -> str | None:
+ """Get an attribute."""
+
+ return self.attributes.get((namespace, att_name), None)
+
+
+ def __str__(self) -> str:
+ """String representation."""
+
+ return f'Task: {self.title} ({self.id})'
+
+
+ def __repr__(self) -> str:
+ """String representation."""
+
+ tags = ', '.join([t.name for t in self.tags])
+ return (f'Task "{self.title}" with id "{self.id}".'
+ f'Status: {self.status}, tags: {tags}')
+
+
+ def __eq__(self, other) -> bool:
+ """Equivalence."""
+
+ return self.id == other.id
+
+
+ def __hash__(self) -> int:
+ """Hash (used for dicts and sets)."""
+
+ return hash(self.id)
+
+
+# ------------------------------------------------------------------------------
+# STORE
+# ------------------------------------------------------------------------------
+
+class TaskStore(BaseStore):
+ """A tree of tasks."""
+
+ __gtype_name__ = 'gtg_TaskStore'
+
+ #: Tag to look for in XML
+ XML_TAG = 'task'
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ self.model = Gio.ListStore.new(Task)
+ self.tree_model = Gtk.TreeListModel.new(self.model, False, False, self.model_expand)
+
+
+ def model_expand(self, item):
+ model = Gio.ListStore.new(Task)
+
+ if type(item) == Gtk.TreeListRow:
+ item = item.get_item()
+
+ # open the first one
+ if item.children:
+ for child in item.children:
+ model.append(child)
+
+ return Gtk.TreeListModel.new(model, False, False, self.model_expand)
+
+
+ def __str__(self) -> str:
+ """String representation."""
+
+ return f'Task Store. Holds {len(self.lookup)} task(s)'
+
+
+ def get(self, tid: UUID) -> Task:
+ """Get a task by name."""
+
+ return self.lookup[tid]
+
+
+ def duplicate_for_recurrent(self, task: Task) -> Task:
+ """Duplicate a task for the next ocurrence."""
+
+ new_task = self.new(task.title)
+ new_task.tags = task.tags
+ new_task.content = task.content
+ new_task.date_added = task.date_added
+ new_task.date_due = task.get_next_occurrence()
+
+ # Only goes through for the first task
+ if task.parent and task.parent.is_active:
+ self.parent(new_task.id, task.parent.id)
+
+ for child in task.children:
+ new_child = self.duplicate_for_recurrent(child)
+ self.parent(new_child.id, new_task.id)
+
+ log.debug("Duplicated task %s as task %s", task.id, new_task.id)
+ return new_task
+
+
+ def new(self, title: str = None, parent: UUID = None) -> Task:
+ """Create a new task and add it to the store."""
+
+ tid = uuid4()
+ title = title or DEFAULT_TITLE
+ task = Task(id=tid, title=title)
+ task.date_added = Date.now()
+
+ if parent:
+ self.add(task, parent)
+ else:
+ self.add(task)
+
+ return task
+
+
+ def from_xml(self, xml: Element, tag_store: TagStore) -> None:
+ """Load up tasks from a lxml object."""
+
+ elements = list(xml.iter(self.XML_TAG))
+
+ for element in elements:
+ tid = element.get('id')
+ title = element.find('title').text
+ status = element.get('status')
+
+ task = Task(id=tid, title=title)
+
+ dates = element.find('dates')
+
+ modified = dates.find('modified').text
+ task.date_modified = Date(datetime.datetime.fromisoformat(modified))
+
+ added = dates.find('added').text
+ task.date_added = Date(datetime.datetime.fromisoformat(added))
+
+ if status == 'Done':
+ task.status = Status.DONE
+ elif status == 'Dismissed':
+ task.status = Status.DISMISSED
+
+ # Dates
+ try:
+ closed = Date.parse(dates.find('done').text)
+ task.date_closed = closed
+ except AttributeError:
+ pass
+
+ fuzzy_due_date = Date.parse(dates.findtext('fuzzyDue'))
+ due_date = Date.parse(dates.findtext('due'))
+
+ if fuzzy_due_date:
+ task.date_due = fuzzy_due_date
+ elif due_date:
+ task.date_due = due_date
+
+ fuzzy_start = dates.findtext('fuzzyStart')
+ start = dates.findtext('start')
+
+ if fuzzy_start:
+ task.date_start = Date(fuzzy_start)
+ elif start:
+ task.date_start = Date(start)
+
+ taglist = element.find('tags')
+
+ if taglist is not None:
+ for t in taglist.iter('tag'):
+ try:
+ tag = tag_store.get(t.text)
+ task.add_tag(tag)
+ except KeyError:
+ pass
+
+ # Content
+ content = element.find('content').text or ''
+ content = content.replace(']]>', ']]>')
+ task.content = content
+
+ self.add(task)
+
+ log.debug('Added %s', task)
+
+
+ # All tasks have been added, now we parent them
+ for element in elements:
+ parent_tid = element.get('id')
+ subtasks = element.find('subtasks')
+
+ for sub in subtasks.findall('sub'):
+ self.parent(sub.text, parent_tid)
+
+
+ def to_xml(self) -> Element:
+ """Serialize the taskstore into a lxml element."""
+
+ root = Element('tasklist')
+
+ for task in self.lookup.values():
+ element = SubElement(root, self.XML_TAG)
+ element.set('id', str(task.id))
+ element.set('status', task.status.value)
+
+ title = SubElement(element, 'title')
+ title.text = task.title
+
+ tags = SubElement(element, 'tags')
+
+ for t in task.tags:
+ tag_tag = SubElement(tags, 'tag')
+ tag_tag.text = str(t.id)
+
+ dates = SubElement(element, 'dates')
+
+ added_date = SubElement(dates, 'added')
+ added_date.text = str(task.date_added)
+
+ modified_date = SubElement(dates, 'modified')
+ modified_date.text = str(task.date_modified)
+
+ if task.status == Status.DONE:
+ done_date = SubElement(dates, 'done')
+ done_date.text = str(task.date_closed)
+
+ if task.date_due:
+ due = SubElement(dates, 'due')
+ due.text = str(task.date_due)
+
+ if task.date_start:
+ start = SubElement(dates, 'start')
+ start.text = str(task.date_start)
+
+ subtasks = SubElement(element, 'subtasks')
+
+ for subtask in task.children:
+ sub = SubElement(subtasks, 'sub')
+ sub.text = str(subtask.id)
+
+ content = SubElement(element, 'content')
+ text = task.content
+
+ # Poor man's encoding.
+ # CDATA's only poison is this combination of characters.
+ text = text.replace(']]>', ']]>')
+ content.text = CDATA(text)
+
+ return root
+
+ def add(self, item: Any, parent_id: UUID = None) -> None:
+ """Add a task to the taskstore."""
+
+ super().add(item, parent_id)
+
+ if not parent_id:
+ self.model.append(item)
+
+ item.duplicate_cb = self.duplicate_for_recurrent
+ self.notify('task_count_all')
+ self.notify('task_count_no_tags')
+
+ self.emit('added', item)
+
+
+ def remove(self, item_id: UUID) -> None:
+ """Remove an existing task."""
+
+ # Remove from UI
+ item = self.lookup[item_id]
+ pos = self.model.find(item)
+ self.model.remove(pos[1])
+
+ super().remove(item_id)
+
+ self.notify('task_count_all')
+ self.notify('task_count_no_tags')
+
+
+ def parent(self, item_id: UUID, parent_id: UUID) -> None:
+
+ super().parent(item_id, parent_id)
+
+ # Remove from UI
+ item = self.lookup[item_id]
+ pos = self.model.find(item)
+ self.model.remove(pos[1])
+
+
+ def unparent(self, item_id: UUID, parent_id: UUID) -> None:
+
+ super().unparent(item_id, parent_id)
+ item = self.lookup[item_id]
+ parent = self.lookup[parent_id]
+
+ self.model.append(item)
+ parent.notify('has_children')
+
+
+ def filter(self, filter_type: Filter, arg = None) -> list:
+ """Filter tasks according to a filter type."""
+
+ def filter_tag(tag: str) -> list:
+ """Filter tasks that only have a specific tag."""
+
+ output = []
+
+ for t in self.data:
+ tags = [_tag for _tag in t.tags]
+
+ # Include the tag's children
+ for _tag in t.tags:
+ for child in _tag.children:
+ tags.append(child)
+
+ if tag in tags:
+ output.append(t)
+
+ return output
+
+
+ if filter_type == Filter.STATUS:
+ return [t for t in self.data if t.status == arg]
+
+ elif filter_type == Filter.ACTIVE:
+ return [t for t in self.data if t.status == Status.ACTIVE]
+
+ elif filter_type == Filter.CLOSED:
+ return [t for t in self.data if t.status != Status.ACTIVE]
+
+ elif filter_type == Filter.ACTIONABLE:
+ return [t for t in self.data if t.is_actionable]
+
+ elif filter_type == Filter.PARENT:
+ return [t for t in self.lookup.values() if not t.parent]
+
+ elif filter_type == Filter.CHILDREN:
+ return [t for t in self.lookup.values() if t.parent]
+
+ elif filter_type == Filter.TAG:
+ if type(arg) == list:
+ output = []
+
+ for t in arg:
+ if output:
+ output = list(set(output) & set(filter_tag(t)))
+ else:
+ output = filter_tag(t)
+
+
+ return output
+
+ else:
+ return filter_tag(arg)
+
+
+ def filter_custom(self, key: str, condition: Callable) -> list:
+ """Filter tasks according to a function."""
+
+ return [t for t in self.lookup.values() if condition(getattr(t, key))]
+
+
+ def sort(self, tasks: list = None,
+ key: str = None, reverse: bool = False) -> None:
+ """Sort a list of tasks in-place."""
+
+ tasks = tasks or self.data
+ key = key or 'date_added'
+
+ for t in tasks:
+ t.children.sort(key=attrgetter(key), reverse=reverse)
+
+ tasks.sort(key=attrgetter(key), reverse=reverse)
+
+
+ @GObject.Property(type=str)
+ def task_count_all(self) -> str:
+ return str(len(self.lookup.keys()))
+
+
+ @GObject.Property(type=str)
+ def task_count_no_tags(self) -> str:
+ i = 0
+
+ for task in self.lookup.values():
+ if not task.tags:
+ i += 1
+
+ return str(i)
diff --git a/GTG/core/tasks2.py b/GTG/core/tasks2.py
deleted file mode 100644
index 05e833b198..0000000000
--- a/GTG/core/tasks2.py
+++ /dev/null
@@ -1,567 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) The GTG Team
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-"""Everything related to tasks."""
-
-
-from gi.repository import GObject
-from gettext import gettext as _
-
-from uuid import uuid4, UUID
-import logging
-from typing import Callable, Any, Optional
-from enum import Enum
-import re
-import datetime
-from operator import attrgetter
-
-from lxml.etree import Element, SubElement, CDATA
-
-from GTG.core.base_store import BaseStore
-from GTG.core.tags2 import Tag2, TagStore
-from GTG.core.dates import Date
-
-log = logging.getLogger(__name__)
-
-
-# ------------------------------------------------------------------------------
-# REGEXES
-# ------------------------------------------------------------------------------
-
-TAG_REGEX = re.compile(r'^\B\@\w+(\-\w+)*\,+')
-SUB_REGEX = re.compile(r'\{\!.+\!\}')
-
-
-# ------------------------------------------------------------------------------
-# TASK STATUS
-# ------------------------------------------------------------------------------
-
-class Status(Enum):
- """Status for a task."""
-
- ACTIVE = 'Active'
- DONE = 'Done'
- DISMISSED = 'Dismissed'
-
-
-class Filter(Enum):
- """Types of filters."""
-
- ACTIVE = 'Active'
- ACTIONABLE = 'Actionable'
- CLOSED = 'Closed'
- STATUS = 'Status'
- TAG = 'Tag'
- PARENT = 'Parent'
- CHILDREN = 'Children'
-
-
-# ------------------------------------------------------------------------------
-# TASK
-# ------------------------------------------------------------------------------
-
-class Task2(GObject.Object):
- """A single task."""
-
- __gtype_name__ = 'gtg_Task'
- __slots__ = ['id', 'raw_title', 'content', 'tags',
- 'children', 'status', 'parent', '_date_added',
- '_date_due', '_date_start', '_date_closed',
- '_date_modified']
-
-
- def __init__(self, id: UUID, title: str) -> None:
- self.id = id
- self.raw_title = title.strip('\t\n')
- self.content = ''
- self.tags = []
- self.children = []
- self.status = Status.ACTIVE
- self.parent = None
-
- self._date_added = Date.no_date()
- self._date_due = Date.no_date()
- self._date_start = Date.no_date()
- self._date_closed = Date.no_date()
- self._date_modified = Date(datetime.datetime.now())
-
-
- def is_actionable(self) -> bool:
- """Determine if this task is actionable."""
-
- actionable_tags = all(t.actionable for t in self.tags)
- active_children = all(t.status != Status.ACTIVE for t in self.children)
- days_left = self._date_start.days_left()
- can_start = True if not days_left else days_left <= 0
-
- return (self.status == Status.ACTIVE
- and self._date_due != Date.someday()
- and actionable_tags
- and active_children
- and can_start)
-
-
- def toggle_active(self, propagate: bool = True) -> None:
- """Toggle between possible statuses."""
-
- if self.status is Status.ACTIVE:
- self.status = Status.DONE
- self.date_closed = Date.today()
-
- else:
- self.status = Status.ACTIVE
- self.date_closed = Date.no_date()
-
- if self.parent and self.parent.status is not Status.ACTIVE:
- self.parent.toggle_active(propagate=False)
-
- if propagate:
- for child in self.children:
- child.toggle_active()
-
-
- def toggle_dismiss(self, propagate: bool = True) -> None:
- """Set this task to be dismissed."""
-
- if self.status is Status.ACTIVE:
- self.status = Status.DISMISSED
- self.date_closed = Date.today()
-
- elif self.status is Status.DISMISSED:
- self.status = Status.ACTIVE
- self.date_closed = Date.no_date()
-
- if self.parent and self.parent.status is not Status.ACTIVE:
- self.parent.toggle_dismiss(propagate=False)
-
- if propagate:
- for child in self.children:
- child.toggle_dismiss()
-
-
- def set_status(self, status: Status) -> None:
- """Set status for task."""
-
- self.status = status
-
- for child in self.children:
- child.set_status(status)
-
-
- @property
- def date_due(self) -> Date:
- return self._date_due
-
-
- @date_due.setter
- def date_due(self, value: Date) -> None:
- self._date_due = value
-
- if not value or value.is_fuzzy():
- return
-
- for child in self.children:
- if (child.date_due
- and not child.date_due.is_fuzzy()
- and child.date_due > value):
-
- child.date_due = value
-
- if (self.parent
- and self.parent.date_due
- and self.parent.date_due.is_fuzzy()
- and self.parent.date_due < value):
- self.parent.date_due = value
-
-
- @property
- def date_added(self) -> Date:
- return self._date_added
-
-
- @date_added.setter
- def date_added(self, value: Any) -> None:
- self._date_added = Date(value)
-
-
- @property
- def date_start(self) -> Date:
- return self._date_start
-
-
- @date_start.setter
- def date_start(self, value: Any) -> None:
- self._date_start = Date(value)
-
-
- @property
- def date_closed(self) -> Date:
- return self._date_closed
-
-
- @date_closed.setter
- def date_closed(self, value: Any) -> None:
- self._date_closed = Date(value)
-
-
- @property
- def date_modified(self) -> Date:
- return self._date_modified
-
-
- @date_modified.setter
- def date_modified(self, value: Any) -> None:
- self._date_modified = Date(value)
-
-
- @property
- def title(self) -> str:
- return self.raw_title
-
-
- @title.setter
- def title(self, value) -> None:
- self.raw_title = value.strip('\t\n') or _('(no title)')
-
-
- @property
- def excerpt(self) -> str:
- if not self.content:
- return ''
-
- # Strip tags
- txt = TAG_REGEX.sub('', self.content)
-
- # Strip subtasks
- txt = SUB_REGEX.sub('', txt)
-
- # Strip blank lines and set within char limit
- return f'{txt.strip()[:80]}…'
-
-
- def add_tag(self, tag: Tag2) -> None:
- """Add a tag to this task."""
-
- if isinstance(tag, Tag2):
- if tag not in self.tags:
- self.tags.append(tag)
- else:
- raise ValueError
-
-
- def remove_tag(self, tag_name: str) -> None:
- """Remove a tag from this task."""
-
- for t in self.tags:
- if t.name == tag_name:
- self.tags.remove(t)
- (self.content.replace(f'{tag_name}\n\n', '')
- .replace(f'{tag_name},', '')
- .replace(f'{tag_name}', ''))
-
-
- @property
- def days_left(self) -> Optional[Date]:
- return self.date_due.days_left()
-
-
- def update_modified(self) -> None:
- """Update the modified property."""
-
- self._date_modified = Date(datetime.datetime.now())
-
-
- def __str__(self) -> str:
- """String representation."""
-
- return f'Task: {self.title} ({self.id})'
-
-
- def __repr__(self) -> str:
- """String representation."""
-
- tags = ', '.join([t.name for t in self.tags])
- return (f'Task "{self.title}" with id "{self.id}".'
- f'Status: {self.status}, tags: {tags}')
-
-
- def __eq__(self, other) -> bool:
- """Equivalence."""
-
- return self.id == other.id
-
-
- def __hash__(self) -> int:
- """Hash (used for dicts and sets)."""
-
- return hash(self.id)
-
-
-# ------------------------------------------------------------------------------
-# STORE
-# ------------------------------------------------------------------------------
-
-class TaskStore(BaseStore):
- """A tree of tasks."""
-
- __gtype_name__ = 'gtg_TaskStore'
-
- #: Tag to look for in XML
- XML_TAG = 'task'
-
- def __init__(self) -> None:
- super().__init__()
-
-
- def __str__(self) -> str:
- """String representation."""
-
- return f'Task Store. Holds {len(self.lookup)} task(s)'
-
-
- def get(self, tid: UUID) -> Task2:
- """Get a task by name."""
-
- return self.lookup[tid]
-
-
- def new(self, title: str = None, parent: UUID = None) -> Task2:
- """Create a new task and add it to the store."""
-
- tid = uuid4()
- title = title or _('New Task')
- task = Task2(id=tid, title=title)
- task.date_added = Date.now()
-
- if parent:
- self.add(task, parent)
- else:
- self.data.append(task)
- self.lookup[tid] = task
-
- self.emit('added', task)
- return task
-
-
- def from_xml(self, xml: Element, tag_store: TagStore) -> None:
- """Load up tasks from a lxml object."""
-
- elements = list(xml.iter(self.XML_TAG))
-
- for element in elements:
- tid = element.get('id')
- title = element.find('title').text
- status = element.get('status')
-
- task = Task2(id=tid, title=title)
-
- dates = element.find('dates')
-
- modified = dates.find('modified').text
- task.date_modified = Date(datetime.datetime.fromisoformat(modified))
-
- added = dates.find('added').text
- task.date_added = Date(datetime.datetime.fromisoformat(added))
-
- if status == 'Done':
- task.status = Status.DONE
- elif status == 'Dismissed':
- task.status = Status.DISMISSED
-
- # Dates
- try:
- closed = Date.parse(dates.find('done').text)
- task.date_closed = closed
- except AttributeError:
- pass
-
- fuzzy_due_date = Date.parse(dates.findtext('fuzzyDue'))
- due_date = Date.parse(dates.findtext('due'))
-
- if fuzzy_due_date:
- task._date_due = fuzzy_due_date
- elif due_date:
- task._date_due = due_date
-
- fuzzy_start = dates.findtext('fuzzyStart')
- start = dates.findtext('start')
-
- if fuzzy_start:
- task.date_start = Date(fuzzy_start)
- elif start:
- task.date_start = Date(start)
-
- taglist = element.find('tags')
-
- if taglist is not None:
- for t in taglist.iter('tag'):
- try:
- tag = tag_store.get(t.text)
- task.tags.append(tag)
- except KeyError:
- pass
-
- # Content
- content = element.find('content').text or ''
- content = content.replace(']]>', ']]>')
- task.content = content
-
- self.add(task)
-
- log.debug('Added %s', task)
-
-
- # All tasks have been added, now we parent them
- for element in elements:
- parent_tid = element.get('id')
- subtasks = element.find('subtasks')
-
- for sub in subtasks.findall('sub'):
- self.parent(sub.text, parent_tid)
-
-
- def to_xml(self) -> Element:
- """Serialize the taskstore into a lxml element."""
-
- root = Element('tasklist')
-
- for task in self.lookup.values():
- element = SubElement(root, self.XML_TAG)
- element.set('id', str(task.id))
- element.set('status', task.status.value)
-
- title = SubElement(element, 'title')
- title.text = task.title
-
- tags = SubElement(element, 'tags')
-
- for t in task.tags:
- tag_tag = SubElement(tags, 'tag')
- tag_tag.text = str(t.id)
-
- dates = SubElement(element, 'dates')
-
- added_date = SubElement(dates, 'added')
- added_date.text = str(task.date_added)
-
- modified_date = SubElement(dates, 'modified')
- modified_date.text = str(task.date_modified)
-
- if task.status == Status.DONE:
- done_date = SubElement(dates, 'done')
- done_date.text = str(task.date_closed)
-
- if task.date_due:
- due = SubElement(dates, 'due')
- due.text = str(task.date_due)
-
- if task.date_start:
- start = SubElement(dates, 'start')
- start.text = str(task.date_start)
-
- subtasks = SubElement(element, 'subtasks')
-
- for subtask in task.children:
- sub = SubElement(subtasks, 'sub')
- sub.text = str(subtask.id)
-
- content = SubElement(element, 'content')
- text = task.content
-
- # Poor man's encoding.
- # CDATA's only poison is this combination of characters.
- text = text.replace(']]>', ']]>')
- content.text = CDATA(text)
-
- return root
-
-
- def filter(self, filter_type: Filter, arg = None) -> list:
- """Filter tasks according to a filter type."""
-
- def filter_tag(tag: str) -> list:
- """Filter tasks that only have a specific tag."""
-
- output = []
-
- for t in self.data:
- tags = [_tag for _tag in t.tags]
-
- # Include the tag's children
- for _tag in t.tags:
- for child in _tag.children:
- tags.append(child)
-
- if tag in tags:
- output.append(t)
-
- return output
-
-
- if filter_type == Filter.STATUS:
- return [t for t in self.data if t.status == arg]
-
- elif filter_type == Filter.ACTIVE:
- return [t for t in self.data if t.status == Status.ACTIVE]
-
- elif filter_type == Filter.CLOSED:
- return [t for t in self.data if t.status != Status.ACTIVE]
-
- elif filter_type == Filter.ACTIONABLE:
- return [t for t in self.data if t.is_actionable()]
-
- elif filter_type == Filter.PARENT:
- return [t for t in self.lookup.values() if not t.parent]
-
- elif filter_type == Filter.CHILDREN:
- return [t for t in self.lookup.values() if t.parent]
-
- elif filter_type == Filter.TAG:
- if type(arg) == list:
- output = []
-
- for t in arg:
- if output:
- output = list(set(output) & set(filter_tag(t)))
- else:
- output = filter_tag(t)
-
-
- return output
-
- else:
- return filter_tag(arg)
-
-
- def filter_custom(self, key: str, condition: Callable) -> list:
- """Filter tasks according to a function."""
-
- return [t for t in self.lookup.values() if condition(getattr(t, key))]
-
-
- def sort(self, tasks: list = None,
- key: str = None, reverse: bool = False) -> None:
- """Sort a list of tasks in-place."""
-
- tasks = tasks or self.data
- key = key or 'date_added'
-
- for t in tasks:
- t.children.sort(key=attrgetter(key), reverse=reverse)
-
- tasks.sort(key=attrgetter(key), reverse=reverse)
diff --git a/GTG/core/treefactory.py b/GTG/core/treefactory.py
deleted file mode 100644
index 7b7f935a0b..0000000000
--- a/GTG/core/treefactory.py
+++ /dev/null
@@ -1,234 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-from datetime import datetime
-
-from GTG.core.search import search_filter
-from GTG.core import tag
-from GTG.core.task import Task
-from gettext import gettext as _
-from GTG.core.dates import Date
-from liblarch import Tree
-from GTG.core.config import CoreConfig
-
-class TreeFactory():
-
- def __init__(self):
- # Keep the tree in memory jus in case we have to use it for filters.
- self.tasktree = None
- self.tagtree = None
-
- def get_tasks_tree(self):
- """This create a liblarch tree suitable for tasks,
- including default filters
- For tags, filter are dynamically created at Tag insertion.
- """
- tasktree = Tree()
- f_dic = {
- 'workview': [self.workview],
- 'active': [self.active],
- 'closed': [self.closed, {'flat': True}],
- 'notag': [self.notag],
- 'workable': [self.is_workable],
- 'started': [self.is_started],
- 'workdue': [self.workdue],
- 'workstarted': [self.workstarted],
- 'worktostart': [self.worktostart],
- 'worklate': [self.worklate],
- 'no_disabled_tag': [self.no_disabled_tag],
- }
-
- for f in f_dic:
- filt = f_dic[f]
- if len(filt) > 1:
- param = filt[1]
- else:
- param = None
- tasktree.add_filter(f, filt[0], param)
- self.tasktree = tasktree
- return tasktree
-
- def get_tags_tree(self, req):
- """This create a liblarch tree suitable for tags,
- including the all_tags_tag and notag_tag.
- """
- tagtree = Tree()
-
- # Build the "all tasks tag"
- alltag = tag.Tag(tag.ALLTASKS_TAG, req=req)
- alltag.set_attribute("special", "all")
- alltag.set_attribute("label", "%s"
- % _("All tasks"))
- alltag.set_attribute("icon", "emblem-documents-symbolic")
- alltag.set_attribute("order", 0)
- tagtree.add_node(alltag)
- p = {}
- self.tasktree.add_filter(tag.ALLTASKS_TAG,
- self.alltag, parameters=p)
- # Build the "without tag tag"
- notag_tag = tag.Tag(tag.NOTAG_TAG, req=req)
- notag_tag.set_attribute("special", "notag")
- notag_tag.set_attribute("label", "%s"
- % _("Tasks with no tags"))
- notag_tag.set_attribute("icon", "task-past-due-symbolic")
- notag_tag.set_attribute("order", 2)
- tagtree.add_node(notag_tag)
- p = {}
- self.tasktree.add_filter(tag.NOTAG_TAG,
- self.notag, parameters=p)
-
- # Build the search tag
- search_tag = tag.Tag(tag.SEARCH_TAG, req=req)
- search_tag.set_attribute("special", "search")
- search_tag.set_attribute("label", _("Saved searches"))
- search_tag.set_attribute("icon", "system-search-symbolic")
- search_tag.set_attribute("order", 1)
- tagtree.add_node(search_tag)
- p = {}
- self.tasktree.add_filter(tag.SEARCH_TAG,
- search_filter, parameters=p)
-
- # Build the separator
- sep_tag = tag.Tag(tag.SEP_TAG, req=req)
- sep_tag.set_attribute("special", "sep")
- sep_tag.set_attribute("order", 3)
- tagtree.add_node(sep_tag)
-
- # Filters
- tagtree.add_filter('activetag', self.actively_used_tag)
- tagtree.add_filter('usedtag', self.used_tag)
-
- activeview = tagtree.get_viewtree(name='activetags', refresh=False)
- activeview.apply_filter('activetag')
-
- # This view doesn't seem to be used. So it's not useful to build it now
-# usedview = tagtree.get_viewtree(name='usedtags',refresh=False)
-# usedview.apply_filter('usedtag')
-
- self.tagtree = tagtree
- self.tagtree_loaded = True
- return tagtree
-
- # Tag Filters ##########################################
-
- # filter to display only tags with active tasks
- def actively_used_tag(self, node, parameters=None):
- toreturn = node.is_actively_used()
- return toreturn
-
- def used_tag(self, node, parameters=None):
- return node.is_used()
-
- # Task Filters #########################################
- # That one is used to filters tag. Is it built dynamically each times
- # a tag is added to the tagstore
- def tag_filter(self, node, parameters):
- tag = parameters['tag']
- return node.has_tags([tag])
-
- def alltag(self, task, parameters=None):
- return True
-
- def notag(self, task, parameters=None):
- """ Filter of tasks without tags """
- return task.has_tags(notag_only=True)
-
- def is_leaf(self, task, parameters=None):
- """ Filter of tasks which have no children """
- return not task.has_child()
-
- def is_workable(self, task, parameters=None):
- """ Filter of tasks that can be worked """
- tree = task.get_tree()
- for child_id in task.get_children():
- if not tree.has_node(child_id):
- continue
-
- child = tree.get_node(child_id)
- if child.get_status() == Task.STA_ACTIVE:
- return False
-
- return True
-
- def is_started(self, task, parameters=None):
- """ Filter for tasks that are already started """
- days_left = task.get_start_date().days_left()
-
- if days_left is None:
- # without startdate
- return True
- elif days_left == 0:
- coreconfig = CoreConfig()
- browser_subconfig = coreconfig.get_subconfig('browser')
- hour_shift = browser_subconfig.get("hour")
- if datetime.now().hour > int(hour_shift):
- return True
- if datetime.now().hour == int(hour_shift):
- minute_shift = browser_subconfig.get("min")
- return datetime.now().minute >= int(minute_shift)
- else:
- return False
- else:
- return days_left < 0
-
- def workview(self, task, parameters=None):
- wv = self.active(task) and \
- self.is_started(task) and \
- self.is_workable(task) and \
- self.no_disabled_tag(task) and \
- task.get_due_date() != Date.someday()
- return wv
-
- def workdue(self, task):
- """ Filter for tasks due within the next day """
- wv = self.workview(task) and task.get_due_date() and task.get_days_left() < 2
- return wv
-
- def worklate(self, task):
- """ Filter for tasks due within the next day """
- wv = self.workview(task) and task.get_due_date() and task.get_days_late() > 0
- return wv
-
- def workstarted(self, task):
- """ Filter for workable tasks with a start date specified """
- wv = self.workview(task) and \
- task.start_date
- return wv
-
- def worktostart(self, task):
- """ Filter for workable tasks without a start date specified """
- wv = self.workview(task) and \
- not task.start_date
- return wv
-
- def active(self, task, parameters=None):
- """ Filter of tasks which are active """
- return task.get_status() == Task.STA_ACTIVE
-
- def closed(self, task, parameters=None):
- """ Filter of tasks which are closed """
- ret = task.get_status() in [Task.STA_DISMISSED, Task.STA_DONE]
- return ret
-
- def no_disabled_tag(self, task, parameters=None):
- """Filter of task that don't have any disabled/nonactionable tag"""
- toreturn = True
- for t in task.get_tags():
- if t.get_attribute("nonactionable") == "True":
- toreturn = False
- return toreturn
diff --git a/GTG/core/versioning.py b/GTG/core/versioning.py
index a7fdd31f71..e57a7cef81 100644
--- a/GTG/core/versioning.py
+++ b/GTG/core/versioning.py
@@ -26,7 +26,7 @@
from GTG.core.dates import Date
from GTG.core.dirs import DATA_DIR
from GTG.core import xml
-from GTG.core import datastore
+from uuid import uuid4, UUID
from datetime import date
from typing import Optional, Tuple
@@ -39,7 +39,7 @@
tid_cache = {}
-def convert(path: str, ds: datastore) -> et.ElementTree:
+def convert(path: str) -> et.ElementTree:
"""Convert old XML into the new format."""
old_tree = xml.open_file(path, 'project')
@@ -69,7 +69,7 @@ def convert(path: str, ds: datastore) -> et.ElementTree:
tid_cache[tid] = new_tid
for task in old_tree.iter('task'):
- new_task = convert_task(task, ds)
+ new_task = convert_task(task)
if new_task is not None:
tasklist.append(new_task)
@@ -137,18 +137,17 @@ def convert_tags(old_tree: et.Element) -> Tuple[et.Element, et.Element]:
return taglist, searchlist
-def convert_task(task: et.Element, ds: datastore) -> Optional[et.Element]:
+def convert_task(task: et.Element) -> Optional[et.Element]:
"""Convert old task XML into the new format."""
- tid = task.attrib['id']
- real_task = ds.task_factory(tid)
-
if task is None:
return
+ tid = task.attrib['id']
+
# Get the old task properties
# TIDs were stored as UUID, but sometimes they were not present
- tid = task.get('uuid') or real_task.get_uuid() or tid_cache[tid]
+ tid = task.get('uuid') or uuid4() or tid_cache[tid]
status = task.get('status')
title = task.find('title').text
content = task.find('content')
diff --git a/GTG/core/xml.py b/GTG/core/xml.py
deleted file mode 100644
index 15731b3109..0000000000
--- a/GTG/core/xml.py
+++ /dev/null
@@ -1,353 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-import os
-import shutil
-import logging
-from datetime import datetime
-from GTG.core.dates import Date
-
-from lxml import etree
-
-log = logging.getLogger(__name__)
-# Total amount of backups
-BACKUPS = 7
-
-# Information on whether a backup was used
-backup_used = {}
-
-
-def task_from_element(task, element: etree.Element):
- """Populate task from XML element."""
-
- task.set_title(element.find('title').text)
- task.set_uuid(element.get('id'))
- task.set_status(element.attrib['status'], init=True) # Done date set later
-
- # Recurring tasks
- recurring = element.find('recurring')
- recurring_enabled = recurring.get('enabled')
-
- recurring_term = recurring.findtext('term')
-
- if recurring_term:
- task.set_recurring(recurring_enabled == 'true',
- None if recurring_term == 'None' else recurring_term)
-
- try:
- recurring_updated_date = recurring.find('updated_date').text
- if recurring_updated_date:
- task.set_recurring_updated_date(Date(recurring_updated_date))
- except AttributeError:
- pass
-
- taglist = element.find('tags')
-
-
- if taglist is not None:
- [task.tag_added_by_id(t.text) for t in taglist.iter('tag')]
-
- # Content
- content = element.find('content').text or ''
-
- content = content.replace(']]>', ']]>')
- task.set_text(content)
-
- # Subtasks
- subtasks = element.find('subtasks')
-
- for sub in subtasks.findall('sub'):
- task.add_child(sub.text)
-
- # Retrieving all dates
- dates = element.find('dates')
- done_date = None
-
- # supporting old ways of salvaging fuzzy dates
- for key, get_date, set_date in (
- ('fuzzyDue', task.get_due_date, task.set_due_date),
- ('fuzzyStart', task.get_start_date, task.set_start_date)):
- if not get_date() and dates.find(key) is not None \
- and dates.find(key).text:
- set_date(Date(dates.find(key).text))
-
- # modified date from file needs to be set as last action to survive
- for key, set_date in (('added', task.set_added_date),
- ('due', task.set_due_date),
- ('done', task.set_closed_date),
- ('start', task.set_start_date),
- ('modified', task.set_modified),):
- value = dates.find(key)
- if value is not None and value.text:
- set_date(Date(value.text))
-
- return task
-
-
-def task_to_element(task) -> etree.Element:
- """Serialize task into XML Element."""
-
- element = etree.Element('task')
-
- element.set('id', task.get_id())
- element.set('status', task.get_status())
- element.set('uuid', task.get_uuid())
- element.set('recurring', str(task.get_recurring()))
-
- tags = etree.SubElement(element, 'tags')
-
- for t in task.get_tags():
- tag_tag = etree.SubElement(tags, 'tag')
- tag_tag.text = str(t.tid)
-
- title = etree.SubElement(element, 'title')
- title.text = task.get_title()
-
- dates = etree.SubElement(element, 'dates')
-
- for key, get_date in (('added', task.get_added_date),
- ('modified', task.get_modified),
- ('done', task.get_closed_date),
- ('due', task.get_due_date),
- ('start', task.get_start_date)):
- value = get_date()
- if value:
- etree.SubElement(dates, key).text = str(value)
-
- recurring = etree.SubElement(element, 'recurring')
- recurring.set('enabled', str(task.recurring).lower())
-
- recurring_term = etree.SubElement(recurring, 'term')
- recurring_term.text = str(task.get_recurring_term())
-
- recurring_updated_date_elem = etree.SubElement(recurring, 'updated_date')
- recurring_updated_date = task.get_recurring_updated_date()
-
- if recurring_updated_date:
- recurring_updated_date_elem.text = str(recurring_updated_date)
- else:
- recurring_updated_date_elem.text = '9999-12-30'
-
- subtasks = etree.SubElement(element, 'subtasks')
-
- for subtask_id in task.get_children():
- sub = etree.SubElement(subtasks, 'sub')
- sub.text = subtask_id
-
- content = etree.SubElement(element, 'content')
- text = task.get_text()
-
- # Poor man's encoding.
- # CDATA's only poison is this combination of characters.
- text = text.replace(']]>', ']]>')
-
- content.text = etree.CDATA(text)
-
- return element
-
-
-def get_file_mtime(filepath: str) -> str:
- """Get date from file."""
-
- timestamp = os.path.getmtime(filepath)
- return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
-
-
-def get_backup_name(filepath: str, i: int) -> str:
- """Get name of backups which are backup/ directory."""
-
- dirname, filename = os.path.split(filepath)
- backup_file = f"{filename}.bak.{i}" if i else filename
-
- return os.path.join(dirname, 'backup', backup_file)
-
-
-def get_xml_tree(filepath: str) -> etree.ElementTree:
- """Parse XML file at filepath and get tree."""
-
- parser = etree.XMLParser(remove_blank_text=True, strip_cdata=False)
-
- with open(filepath, 'rb') as stream:
- tree = etree.parse(stream, parser=parser)
-
- return tree
-
-
-def open_file(xml_path: str, root_tag: str) -> etree.ElementTree:
- """Open an XML file in a robust way
-
- If file could not be opened, try:
- - file__
- - file.bak.0
- - file.bak.1
- - .... until BACKUP_NBR
-
- If file doesn't exist, create a new file."""
-
- global backup_used
-
- files = [
- xml_path, # Main file
- xml_path + '__', # Temp file
- ]
-
- # Add backup files
- files += [get_backup_name(xml_path, i) for i in range(BACKUPS)]
-
- root = None
- backup_used = None
-
- for index, filepath in enumerate(files):
- try:
- log.debug('Opening file %s', filepath)
- root = get_xml_tree(filepath)
-
- # This was a backup. We should inform the user
- if index > 0:
- backup_used = {
- 'name': filepath,
- 'time': get_file_mtime(filepath)
- }
-
- # We could open a file, let's stop this loop
- break
-
- except FileNotFoundError:
- log.debug('File not found: %r. Trying next.', filepath)
- continue
-
- except PermissionError:
- log.debug('Not allowed to open: %r. Trying next.', filepath)
- continue
-
- except etree.XMLSyntaxError as error:
- log.debug('Syntax error in %r. %r. Trying next.', filepath, error)
- continue
-
- if root:
- return root
-
- # We couldn't open any file :(
- else:
- # Try making a new empty file and open it
- try:
-
- write_empty_file(xml_path, root_tag)
- return open_file(xml_path, root_tag)
-
- except IOError:
- raise SystemError(f'Could not write a file at {xml_path}')
-
-
-def write_backups(filepath: str) -> None:
- """Make backups for the file at filepath."""
-
- current_back = BACKUPS
- backup_name = get_backup_name(filepath, None)
- backup_dir = os.path.dirname(backup_name)
-
- # Make sure backup dir exists
- try:
- os.makedirs(backup_dir, exist_ok=True)
-
- except IOError:
- log.error('Backup dir %r cannot be created!', backup_dir)
- return
-
- # Cycle backups
- while current_back > 0:
- older = f"{backup_name}.bak.{current_back}"
- newer = f"{backup_name}.bak.{current_back - 1}"
-
- if os.path.exists(newer):
- shutil.move(newer, older)
-
- current_back -= 1
-
- # bak.0 is always a fresh copy of the closed file
- # so that it's not touched in case of not opening next time
- bak_0 = f"{backup_name}.bak.0"
- shutil.copy(filepath, bak_0)
-
- # Add daily backup
- today = datetime.today().strftime('%Y-%m-%d')
- daily_backup = f'{backup_name}.{today}.bak'
-
- if not os.path.exists(daily_backup):
- shutil.copy(filepath, daily_backup)
-
-
-def write_xml(filepath: str, tree: etree.ElementTree) -> None:
- """Write an XML file."""
-
- with open(filepath, 'wb') as stream:
- tree.write(stream, xml_declaration=True,
- pretty_print=True,
- encoding='UTF-8')
-
-
-def create_dirs(filepath: str) -> None:
- """Create directory tree for filepath."""
-
- base_dir = os.path.dirname(filepath)
- try:
- os.makedirs(base_dir, exist_ok=True)
- except IOError as error:
- log.error("Error while creating directories: %r", error)
-
-
-def save_file(filepath: str, root: etree.ElementTree) -> None:
- """Save an XML file."""
-
- temp_file = filepath + '__'
-
- if os.path.exists(filepath):
- os.rename(filepath, temp_file)
-
- try:
- write_xml(filepath, root)
-
- if os.path.exists(temp_file):
- os.remove(temp_file)
-
- except (IOError, FileNotFoundError):
- log.error('Could not write XML file at %r', filepath)
- create_dirs(filepath)
-
-
-def write_empty_file(filepath: str, root_tag: str) -> None:
- """Write an empty tasks file."""
-
- root = etree.Element(root_tag)
- save_file(filepath, etree.ElementTree(root))
-
-
-def skeleton() -> etree.Element:
- """Generate root XML tag and basic subtags."""
-
- root = etree.Element('gtgData')
- # Bump this on each new GTG release, no matter what:
- root.set('appVersion', '0.6')
- # Bump this when there are known file format changes:
- root.set('xmlVersion', '2')
-
- etree.SubElement(root, 'taglist')
- etree.SubElement(root, 'searchlist')
- etree.SubElement(root, 'tasklist')
-
- return root
diff --git a/GTG/gtg.in b/GTG/gtg.in
index 78ef9739fd..5c23fa486b 100755
--- a/GTG/gtg.in
+++ b/GTG/gtg.in
@@ -71,9 +71,23 @@ if __name__ == "__main__":
pass
import gi
-gi.require_version('Gdk', '3.0')
-gi.require_version('Gtk', '3.0')
-gi.require_version('GtkSource', '4')
+gi.require_version('Gdk', '4.0')
+gi.require_version('Gtk', '4.0')
+gi.require_version('GtkSource', '5')
+
+
+# Monkey patch Gtk.CssProvider.load_from_data for backwards compatibility.
+# GTK 4.10 introduced an api change that requires a fix in PyGObject:
+# https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/231
+from gi.repository import Gtk
+if (Gtk.get_major_version(), Gtk.get_minor_version()) >= (4, 9):
+ def decorate(original_func):
+ def compat(self, data: bytes):
+ original_func(self, data.decode(), -1)
+ return compat
+
+ Gtk.CssProvider.load_from_data = decorate(Gtk.CssProvider.load_from_data)
+
from gi.repository import GLib
diff --git a/GTG/gtk/application.py b/GTG/gtk/application.py
index 13c42e936e..015d60572f 100644
--- a/GTG/gtk/application.py
+++ b/GTG/gtk/application.py
@@ -27,7 +27,8 @@
from GTG.core.dirs import DATA_DIR
-from GTG.core.datastore2 import Datastore2
+from GTG.core.datastore import Datastore
+from GTG.core.tasks import Filter
from GTG.gtk.browser.delete_task import DeletionUI
from GTG.gtk.browser.main_window import MainWindow
@@ -36,14 +37,15 @@
from GTG.gtk.preferences import Preferences
from GTG.gtk.plugins import PluginsDialog
from GTG.core import clipboard
+from GTG.core.config import CoreConfig
from GTG.core.plugins.engine import PluginEngine
from GTG.core.plugins.api import PluginAPI
from GTG.backends import BackendFactory
-from GTG.core.datastore import DataStore
from GTG.core.dirs import CSS_DIR
from GTG.core.dates import Date
from GTG.gtk.backends import BackendsDialog
from GTG.gtk.browser.tag_editor import TagEditor
+from GTG.gtk.browser.search_editor import SearchEditor
from GTG.core.timer import Timer
from GTG.gtk.errorhandler import do_error_dialog
@@ -52,12 +54,9 @@
class Application(Gtk.Application):
- ds: Datastore2 = Datastore2()
+ ds: Datastore = Datastore()
"""Datastore loaded with the default data file"""
- # Requester
- req = None
-
# List of opened tasks (task editor windows). Task IDs are keys,
# while the editors are their values.
open_tasks = {}
@@ -115,30 +114,20 @@ def do_startup(self):
data_file = os.path.join(DATA_DIR, 'gtg_data.xml')
self.ds.find_and_load_file(data_file)
- # TODO: Remove this once the new core is stable
- self.ds.data_path = os.path.join(DATA_DIR, 'gtg_data2.xml')
-
- # Register backends
- datastore = DataStore()
-
for backend_dic in BackendFactory().get_saved_backends_list():
- datastore.register_backend(backend_dic)
-
- # Save the backends directly to be sure projects.xml is written
- datastore.save(quit=False)
+ self.ds.register_backend(backend_dic)
- self.req = datastore.get_requester()
+ self.config_core = CoreConfig()
+ self.config = self.config_core.get_subconfig('browser')
+ self.config_plugins = self.config_core.get_subconfig('plugins')
- self.config = self.req.get_config("browser")
- self.config_plugins = self.req.get_config("plugins")
-
- self.clipboard = clipboard.TaskClipboard(self.req)
+ self.clipboard = clipboard.TaskClipboard(self.ds)
self.timer = Timer(self.config)
self.timer.connect('refresh', self.autoclean)
- self.preferences_dialog = Preferences(self.req, self)
- self.plugins_dialog = PluginsDialog(self.req)
+ self.preferences_dialog = Preferences(self)
+ self.plugins_dialog = PluginsDialog(self.config_plugins)
if self.config.get('dark_mode'):
self.toggle_darkmode()
@@ -161,6 +150,7 @@ def do_activate(self):
try:
self.init_shared()
self.browser.present()
+ self.browser.restore_editor_windows()
log.debug("Application activation finished")
except Exception as e:
@@ -246,16 +236,16 @@ def init_shared(self):
def init_browser(self):
# Browser (still hidden)
if not self.browser:
- self.browser = MainWindow(self.req, self)
+ self.browser = MainWindow(self)
if self.props.application_id == 'org.gnome.GTGDevel':
- self.browser.get_style_context().add_class('devel')
+ self.browser.add_css_class('devel')
def init_plugin_engine(self):
"""Setup the plugin engine."""
self.plugin_engine = PluginEngine()
- plugin_api = PluginAPI(self.req, self)
+ plugin_api = PluginAPI(self)
self.plugin_engine.register_api(plugin_api)
try:
@@ -271,27 +261,26 @@ def init_plugin_engine(self):
def init_style(self):
"""Load the application's CSS file."""
- screen = Gdk.Screen.get_default()
+ display = Gdk.Display.get_default()
provider = Gtk.CssProvider()
- add_provider = Gtk.StyleContext.add_provider_for_screen
+ add_provider = Gtk.StyleContext.add_provider_for_display
css_path = os.path.join(CSS_DIR, 'style.css')
provider.load_from_path(css_path)
- add_provider(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
+ add_provider(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
def toggle_darkmode(self, state=True):
"""Use dark mode theme."""
settings = Gtk.Settings.get_default()
- prefs_css = self.preferences_dialog.window.get_style_context()
settings.set_property("gtk-application-prefer-dark-theme", state)
# Toggle dark mode for preferences and editors
if state:
- prefs_css.add_class('dark')
+ self.preferences_dialog.add_css_class('dark')
text_tags.use_dark_mode()
else:
- prefs_css.remove_class('dark')
+ self.preferences_dialog.remove_css_class('dark')
text_tags.use_light_mode()
def init_actions(self):
@@ -314,8 +303,7 @@ def init_actions(self):
('close', self.close_context, ('app.close', ['Escape'])),
('editor.close', self.close_focused_task, ('app.editor.close', ['w'])),
('editor.show_parent', self.open_parent_task, None),
- ('editor.delete', self.delete_editor_task, None),
- ('editor.open_tags_popup', self.open_tags_popup_in_editor, None),
+ ('editor.delete', self.delete_editor_task, None)
]
for action, callback, accel in action_entries:
@@ -328,7 +316,7 @@ def init_actions(self):
if accel is not None:
self.set_accels_for_action(*accel)
- self.plugins_dialog.dialog.insert_action_group('app', self)
+ self.plugins_dialog.insert_action_group('app', self)
# --------------------------------------------------------------------------
# ACTIONS
@@ -350,13 +338,8 @@ def new_subtask(self, param, action):
def add_parent(self, param, action):
"""Callback to add a parent to a task"""
- try:
- if self.browser.have_same_parent():
- self.browser.on_add_parent()
+ self.browser.on_add_parent()
- # When no task has been selected
- except IndexError:
- return
def edit_task(self, param, action):
"""Callback to edit a task."""
@@ -365,10 +348,13 @@ def edit_task(self, param, action):
def mark_as_done(self, param, action):
"""Callback to mark a task as done."""
+
try:
self.get_active_editor().change_status()
except AttributeError:
self.browser.on_mark_as_done()
+ finally:
+ self.browser.get_pane().refresh()
def dismiss(self, param, action):
"""Callback to mark a task as done."""
@@ -377,6 +363,8 @@ def dismiss(self, param, action):
self.get_active_editor().toggle_dismiss()
except AttributeError:
self.browser.on_dismiss_task()
+ finally:
+ self.browser.get_pane().refresh()
def reopen(self, param, action):
"""Callback to mark task as open."""
@@ -385,6 +373,8 @@ def reopen(self, param, action):
self.get_active_editor().reopen()
except AttributeError:
self.browser.on_reopen_task()
+ finally:
+ self.browser.get_pane().refresh()
def open_help(self, action, param):
"""Open help callback."""
@@ -403,7 +393,7 @@ def open_preferences(self, action, param):
"""Callback to open the preferences dialog."""
self.preferences_dialog.activate()
- self.preferences_dialog.window.set_transient_for(self.browser)
+ self.preferences_dialog.set_transient_for(self.browser)
def open_about(self, action, param):
"""Callback to open the about dialog."""
@@ -414,7 +404,7 @@ def open_plugins_manager(self, action, params):
"""Callback to open the plugins manager dialog."""
self.plugins_dialog.activate()
- self.plugins_dialog.dialog.set_transient_for(self.browser)
+ self.plugins_dialog.set_transient_for(self.browser)
def close_context(self, action, params):
@@ -424,7 +414,7 @@ def close_context(self, action, params):
search = self.browser.search_entry.is_focus()
if editor:
- self.close_task(editor.task.get_id())
+ self.close_task(editor.task.id)
elif search:
self.browser.toggle_search(action, params)
@@ -434,7 +424,7 @@ def close_focused_task(self, action, params):
editor = self.get_active_editor()
if editor:
- self.close_task(editor.task.get_id())
+ self.close_task(editor.task.id)
def delete_editor_task(self, action, params):
"""Callback to delete the task currently open."""
@@ -442,16 +432,10 @@ def delete_editor_task(self, action, params):
editor = self.get_active_editor()
task = editor.task
- if task.is_new():
- self.close_task(task.get_id())
+ if editor.is_new():
+ self.close_task(task)
else:
- self.delete_tasks([task.get_id()], editor.window)
-
- def open_tags_popup_in_editor(self, action, params):
- """Callback to open the tags popup in the focused task editor."""
-
- editor = self.get_active_editor()
- editor.open_tags_popover()
+ self.delete_tasks([task], editor)
def open_parent_task(self, action, params):
"""Callback to open the parent of the currently open task."""
@@ -470,17 +454,14 @@ def purge_old_tasks(self, widget=None):
today = Date.today()
max_days = self.config.get('autoclean_days')
- closed_tree = self.req.get_tasks_tree(name='inactive')
-
- closed_tasks = [self.req.get_task(tid) for tid in
- closed_tree.get_all_nodes()]
-
+ closed_tasks = self.ds.tasks.filter(Filter.CLOSED)
+
to_remove = [t for t in closed_tasks
- if (today - t.get_closed_date()).days > max_days]
+ if (today - t.date_closed).days > max_days]
+
+ for t in to_remove:
+ self.ds.tasks.remove(t.id)
- [self.req.delete_task(task.get_id())
- for task in to_remove
- if self.req.has_task(task.get_id())]
def autoclean(self, timer):
"""Run Automatic cleanup of old tasks."""
@@ -495,7 +476,7 @@ def autoclean(self, timer):
def open_edit_backends(self, sender=None, backend_id=None):
"""Open the backends dialog."""
- self.backends_dialog = BackendsDialog(self.req)
+ self.backends_dialog = BackendsDialog(self.ds)
self.backends_dialog.dialog.insert_action_group('app', self)
self.backends_dialog.activate()
@@ -503,24 +484,38 @@ def open_edit_backends(self, sender=None, backend_id=None):
if backend_id:
self.backends_dialog.show_config_for_backend(backend_id)
- def delete_tasks(self, tids, window):
+ def delete_tasks(self, tasks, window):
"""Present the delete task confirmation dialog."""
if not self.delete_task_dialog:
- self.delete_task_dialog = DeletionUI(self.req, window)
+ self.delete_task_dialog = DeletionUI(window, self.ds)
- tasks_to_delete = self.delete_task_dialog.show(tids)
+ def on_show_async_callback(tasks_to_delete):
+ [self.close_task(task.id) for task in tasks_to_delete
+ if task.id in self.open_tasks]
- [self.close_task(task.get_id()) for task in tasks_to_delete
- if task.get_id() in self.open_tasks]
+ self.delete_task_dialog.show_async(tasks, on_show_async_callback)
def open_tag_editor(self, tag):
"""Open Tag editor dialog."""
- self.edit_tag_dialog = TagEditor(self.req, self, tag)
+ self.edit_tag_dialog = TagEditor(self, tag)
self.edit_tag_dialog.set_transient_for(self.browser)
self.edit_tag_dialog.insert_action_group('app', self)
+
+ def open_search_editor(self, search):
+ """Open Saved search editor dialog."""
+
+ self.edit_search_dialog = SearchEditor(self, search)
+ self.edit_search_dialog.set_transient_for(self.browser)
+ self.edit_search_dialog.insert_action_group('app', self)
+
+ def close_search_editor(self):
+ """Close search editor dialog."""
+
+ self.edit_search_dialog = None
+
def close_tag_editor(self):
"""Close tag editor dialog."""
@@ -548,46 +543,39 @@ def reload_opened_editors(self, task_uid_list=None):
else:
[task.reload_editor() for task in self.open_tasks]
- def open_task(self, uid, new=False):
+ def open_task(self, task, new=False):
"""Open the task identified by 'uid'.
If a Task editor is already opened for a given task, we present it.
Otherwise, we create a new one.
"""
-
- if uid in self.open_tasks:
- editor = self.open_tasks[uid]
+
+ if task.id in self.open_tasks:
+ editor = self.open_tasks[task.id]
editor.present()
else:
- task = self.req.get_task(uid)
- editor = None
-
- if task:
- editor = TaskEditor(requester=self.req, app=self, task=task,
- thisisnew=new, clipboard=self.clipboard)
-
- editor.present()
- self.open_tasks[uid] = editor
+ editor = TaskEditor(app=self, task=task)
+ editor.present()
- # Save open tasks to config
- open_tasks = self.config.get("opened_tasks")
+ self.open_tasks[task.id] = editor
- if uid not in open_tasks:
- open_tasks.append(uid)
+ # Save open tasks to config
+ config_open_tasks = self.config.get("opened_tasks")
- self.config.set("opened_tasks", open_tasks)
+ if task.id not in config_open_tasks:
+ config_open_tasks.append(task.id)
- else:
- log.error('Task %s could not be found!', uid)
+ self.config.set("opened_tasks", config_open_tasks)
return editor
+
def get_active_editor(self):
"""Get focused task editor window."""
for editor in self.open_tasks.values():
- if editor.window.is_active():
+ if editor.is_active():
return editor
def close_task(self, tid):
@@ -657,10 +645,6 @@ def do_shutdown(self):
self.save_plugin_settings()
self.ds.save()
- if self.req is not None:
- # Save data and shutdown datastore backends
- self.req.save_datastore(quit=True)
-
Gtk.Application.do_shutdown(self)
# --------------------------------------------------------------------------
diff --git a/GTG/gtk/backends/__init__.py b/GTG/gtk/backends/__init__.py
index 5b57090e1c..4f5832338f 100644
--- a/GTG/gtk/backends/__init__.py
+++ b/GTG/gtk/backends/__init__.py
@@ -49,12 +49,11 @@ class BackendsDialog():
- the backend adding view
"""
- def __init__(self, req):
+ def __init__(self, ds):
"""
Initializes the gtk objects and signals.
- @param req: a Requester object
"""
- self.req = req
+ self.ds = ds
# Declare subsequently loaded widget
self.dialog = None
self.treeview_window = None
@@ -71,7 +70,7 @@ def __init__(self, req):
self.dialog.set_title(dialog_title.format(name=info.NAME))
self._create_widgets_for_add_panel()
self._create_widgets_for_conf_panel()
- self._setup_signal_connections(builder)
+ self._setup_signal_connections()
self._create_widgets_for_treeview()
########################################
@@ -79,7 +78,6 @@ def __init__(self, req):
########################################
def activate(self):
"""Shows this window, refreshing the current view"""
- self.dialog.show_all()
self.backends_tv.refresh()
self.backends_tv.select_backend()
self.dialog.present()
@@ -92,33 +90,12 @@ def on_close(self, widget, data=None):
@param data: same as widget, disregard the content
"""
self.dialog.hide()
- self.req.save_datastore()
+ self.ds.save()
return True
########################################
# HELPER FUNCTIONS #####################
########################################
- def get_requester(self):
- """
- Helper function: returns the requester.
- It's used by the "views" displayed by this class (backend editing and
- adding views) to access the requester
- """
- return self.req
-
- def get_pixbuf_from_icon_name(self, name, height):
- """
- Helper function: returns a pixbuf of an icon given its name in the
- loaded icon theme
-
- @param name: the name of the icon
- @param height: the height of the returned pixbuf
- @param width: the width of the returned pixbuf
-
- @returns GdkPixbuf: a pixbuf containing the wanted icon, or None
- (if the icon is not present)
- """
- return Gtk.IconTheme.get_default().load_icon(name, height, 0)
def _show_panel(self, panel_name):
"""
@@ -139,12 +116,11 @@ def _show_panel(self, panel_name):
log.error("panel name unknown %r", panel_name)
return
# Central pane
- # NOTE: self.central_pane is the Gtk.Container in which we load panels
+ # NOTE: self.central_pane is the Gtk.Viewport in which we load panels
if panel_to_remove in self.central_pane:
- self.central_pane.remove(panel_to_remove)
+ self.central_pane.set_child(None)
if panel_to_add not in self.central_pane:
- self.central_pane.add(panel_to_add)
- self.central_pane.show_all()
+ self.central_pane.set_child(panel_to_add)
# Side treeview
# disabled if we're adding a new backend
try:
@@ -176,17 +152,12 @@ def _load_widgets_from_builder(self, builder):
for attr, widget in widgets.items():
setattr(self, attr, builder.get_object(widget))
- def _setup_signal_connections(self, builder):
+ def _setup_signal_connections(self):
"""
Creates some GTK signals connections
-
- @param builder: a Gtk.Builder
"""
- signals = {
- 'on_add_button_clicked': self.on_add_button,
- 'on_remove_button_clicked': self.on_remove_button,
- }
- builder.connect_signals(signals)
+ self.add_button.connect("clicked", self.on_add_button)
+ self.remove_button.connect("clicked", self.on_remove_button)
def _create_widgets_for_treeview(self):
"""
@@ -194,11 +165,11 @@ def _create_widgets_for_treeview(self):
backends the user has added
"""
self.backends_tv = BackendsTree(self)
- self.treeview_window.add(self.backends_tv)
+ self.treeview_window.set_child(self.backends_tv)
def _create_widgets_for_conf_panel(self):
"""simply creates the panel to configure backends"""
- self.config_panel = ConfigurePanel(self)
+ self.config_panel = ConfigurePanel(self, self.ds)
def _create_widgets_for_add_panel(self):
"""simply creates the panel to add backends"""
@@ -217,7 +188,7 @@ def on_backend_selected(self, backend_id):
if backend_id:
self._show_panel("configuration")
self.config_panel.set_backend(backend_id)
- backend = self.req.get_backend(backend_id)
+ backend = self.ds.get_backend(backend_id)
self.remove_button.set_sensitive(not backend.is_default())
def on_add_button(self, widget=None, data=None):
@@ -242,7 +213,7 @@ def on_backend_added(self, backend_name):
backend_dic = BackendFactory().get_new_backend_dict(backend_name)
if backend_dic:
backend_dic[GenericBackend.KEY_ENABLED] = False
- self.req.register_backend(backend_dic)
+ self.ds.register_backend(backend_dic)
# Restore UI
self._show_panel("configuration")
@@ -263,18 +234,22 @@ def on_remove_button(self, widget=None, data=None):
if backend_id is None:
# no backend selected
return
- backend = self.req.get_backend(backend_id)
+ backend = self.ds.get_backend(backend_id)
dialog = Gtk.MessageDialog(
- parent=self.dialog,
- flags=Gtk.DialogFlags.DESTROY_WITH_PARENT,
- type=Gtk.MessageType.QUESTION,
+ transient_for=self.dialog,
+ modal=True,
+ destroy_with_parent=True,
+ message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.YES_NO,
- message_format=_("Do you really want to remove the '%s' "
+ text=_("Do you really want to remove the '%s' "
"synchronization service?") %
backend.get_human_name())
- response = dialog.run()
- dialog.destroy()
+ dialog.connect("response", self.on_remove_response, backend_id)
+ dialog.present()
+
+ def on_remove_response(self, dialog, response, backend_id):
if response == Gtk.ResponseType.YES:
# delete the backend and remove it from the lateral treeview
- self.req.remove_backend(backend_id)
+ self.ds.remove_backend(backend_id)
self.backends_tv.remove_backend(backend_id)
+ dialog.destroy()
diff --git a/GTG/gtk/backends/addpanel.py b/GTG/gtk/backends/addpanel.py
index b6b27320ff..434ce679a8 100644
--- a/GTG/gtk/backends/addpanel.py
+++ b/GTG/gtk/backends/addpanel.py
@@ -39,6 +39,7 @@ def __init__(self, backends):
loaded
"""
super().__init__(orientation=Gtk.Orientation.VERTICAL)
+ self.set_spacing(24)
self.dialog = backends
self._create_widgets()
@@ -51,14 +52,17 @@ def _create_widgets(self):
top = Gtk.Box()
top.set_spacing(6)
middle = Gtk.Box()
+ middle.set_valign(Gtk.Align.CENTER)
+ middle.set_vexpand(True)
bottom = Gtk.Box()
+ bottom.set_valign(Gtk.Align.END)
+ bottom.set_vexpand(True)
self._fill_top_box(top)
self._fill_middle_box(middle)
self._fill_bottom_box(bottom)
- self.pack_start(top, False, True, 0)
- self.pack_start(middle, True, True, 0)
- self.pack_start(bottom, True, True, 0)
- self.set_border_width(12)
+ self.append(top)
+ self.append(middle)
+ self.append(bottom)
def _fill_top_box(self, box):
"""
@@ -68,14 +72,15 @@ def _fill_top_box(self, box):
@param box: the Gtk.Box to fill
"""
label = Gtk.Label(label=_("Select synchronization service:"))
- label.set_alignment(0, 0.5)
+ label.set_xalign(0)
+ label.set_yalign(0.5)
self.combo_types = BackendsCombo(self.dialog)
# FIXME
# self.combo_types.get_child().connect(
# 'changed', self.on_combo_changed)
self.combo_types.connect('changed', self.on_combo_changed)
- box.pack_start(label, False, True, 0)
- box.pack_start(self.combo_types, False, True, 0)
+ box.append(label)
+ box.append(self.combo_types)
def _fill_middle_box(self, box):
"""
@@ -84,34 +89,30 @@ def _fill_middle_box(self, box):
@param box: the Gtk.Box to fill
"""
- self.label_name = Gtk.Label(label="name")
- self.label_name.set_alignment(xalign=0.5, yalign=1)
+ self.label_name = Gtk.Label()
self.label_description = Gtk.Label()
- self.label_description.set_justify(Gtk.Justification.FILL)
- self.label_description.set_line_wrap(True)
- self.label_description.set_size_request(300, -1)
- self.label_description.set_alignment(xalign=0, yalign=0.5)
- self.label_author = Gtk.Label(label="")
- self.label_author.set_line_wrap(True)
- self.label_author.set_alignment(xalign=0, yalign=0)
- self.label_modules = Gtk.Label(label="")
- self.label_modules.set_line_wrap(True)
- self.label_modules.set_alignment(xalign=0, yalign=0)
+ self.label_description.set_xalign(0)
+ self.label_description.set_wrap(True)
+ self.label_author = Gtk.Label()
+ self.label_modules = Gtk.Label()
self.image_icon = Gtk.Image()
- self.image_icon.set_size_request(128, 128)
- align_image = Gtk.Alignment.new(1, 0, 0, 0)
- align_image.add(self.image_icon)
+ self.image_icon.set_hexpand(True)
+ self.image_icon.set_halign(Gtk.Align.END)
+ self.image_icon.set_pixel_size(128)
labels_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- labels_vbox.pack_start(self.label_description, True, True, 10)
- labels_vbox.pack_start(self.label_author, True, True, 0)
- labels_vbox.pack_start(self.label_modules, True, True, 0)
+ labels_vbox.set_spacing(6)
+ labels_vbox.append(self.label_description)
+ labels_vbox.append(self.label_author)
+ labels_vbox.append(self.label_modules)
low_box = Gtk.Box()
- low_box.pack_start(labels_vbox, True, True, 0)
- low_box.pack_start(align_image, True, True, 0)
+ low_box.set_vexpand(True)
+ low_box.append(labels_vbox)
+ low_box.append(self.image_icon)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- vbox.pack_start(self.label_name, True, True, 0)
- vbox.pack_start(low_box, True, True, 0)
- box.pack_start(vbox, True, True, 0)
+ vbox.set_spacing(12)
+ vbox.append(self.label_name)
+ vbox.append(low_box)
+ box.append(vbox)
def _fill_bottom_box(self, box):
"""
@@ -125,16 +126,11 @@ def _fill_bottom_box(self, box):
cancel_button.connect('clicked', self.on_cancel)
self.ok_button = Gtk.Button()
self.ok_button.set_label(_("OK"))
+ self.ok_button.set_hexpand(True)
+ self.ok_button.set_halign(Gtk.Align.END)
self.ok_button.connect('clicked', self.on_confirm)
- align = Gtk.Alignment.new(0.5, 1, 1, 0)
- align.set_padding(0, 10, 0, 0)
- buttonbox = Gtk.ButtonBox()
- buttonbox.set_layout(Gtk.ButtonBoxStyle.EDGE)
- buttonbox.add(cancel_button)
- buttonbox.set_child_secondary(cancel_button, False)
- buttonbox.add(self.ok_button)
- align.add(buttonbox)
- box.pack_start(align, True, True, 0)
+ box.append(cancel_button)
+ box.append(self.ok_button)
def refresh_backends(self):
"""Populates the combo box containing the available backends"""
@@ -182,6 +178,4 @@ def on_combo_changed(self, widget=None):
(ngettext("Author", "Authors", len(authors)),
reduce(lambda a, b: a + "\n" + " - " + b, authors))
self.label_author.set_markup(author_txt)
- pixbuf = self.dialog.get_pixbuf_from_icon_name(backend.Backend.get_icon(), 128)
- self.image_icon.set_from_pixbuf(pixbuf)
- self.show_all()
+ self.image_icon.set_from_icon_name(backend.Backend.get_icon())
diff --git a/GTG/gtk/backends/backendscombo.py b/GTG/gtk/backends/backendscombo.py
index 9f6360d0cc..2536d162a9 100644
--- a/GTG/gtk/backends/backendscombo.py
+++ b/GTG/gtk/backends/backendscombo.py
@@ -17,7 +17,6 @@
# -----------------------------------------------------------------------------
from gi.repository import Gtk
-from gi.repository import GdkPixbuf
from GTG.backends import BackendFactory
@@ -45,11 +44,10 @@ def __init__(self, backends):
self._liststore_init()
self._renderers_init()
self.set_size_request(-1, 30)
- self.show_all()
def _liststore_init(self):
"""Setup the Gtk.ListStore"""
- self.liststore = Gtk.ListStore(str, str, GdkPixbuf.Pixbuf)
+ self.liststore = Gtk.ListStore(str, str, str)
self.set_model(self.liststore)
def _renderers_init(self):
@@ -61,7 +59,7 @@ def _renderers_init(self):
# Icon renderer
pixbuf_cell = Gtk.CellRendererPixbuf()
self.pack_start(pixbuf_cell, False)
- self.add_attribute(pixbuf_cell, "pixbuf", self.COLUMN_ICON)
+ self.add_attribute(pixbuf_cell, "icon-name", self.COLUMN_ICON)
def refresh(self):
"""
@@ -78,10 +76,9 @@ def refresh(self):
# See LP bug #940917 (Izidor)
if name == "backend_localfile":
continue
- pixbuf = self.dialog.get_pixbuf_from_icon_name(module.Backend.get_icon(), 16)
self.liststore.append((name,
module.Backend.get_human_default_name(),
- pixbuf))
+ module.Backend.get_icon()))
if backend_types:
# triggers a "changed" signal, which is used in the AddPanel to
# refresh the backend description and icon
diff --git a/GTG/gtk/backends/backendstree.py b/GTG/gtk/backends/backendstree.py
index 1827cb38d6..6c98cc3825 100644
--- a/GTG/gtk/backends/backendstree.py
+++ b/GTG/gtk/backends/backendstree.py
@@ -17,12 +17,11 @@
# -----------------------------------------------------------------------------
from gi.repository import Gtk
-from gi.repository import GdkPixbuf
-from GTG.core.tag import ALLTASKS_TAG
from GTG.gtk.colors import get_colored_tags_markup, rgba_to_hex
from GTG.backends.backend_signals import BackendSignals
+ALLTASKS_TAG = 'gtg-tags-all'
class BackendsTree(Gtk.TreeView):
"""
@@ -33,6 +32,7 @@ class BackendsTree(Gtk.TreeView):
COLUMN_ICON = 1
COLUMN_TEXT = 2 # holds the backend "human-readable" name
COLUMN_TAGS = 3
+ COLUMN_ENABLED = 4
def __init__(self, backendsdialog):
"""
@@ -43,7 +43,7 @@ def __init__(self, backendsdialog):
"""
super().__init__()
self.dialog = backendsdialog
- self.req = backendsdialog.get_requester()
+ self.ds = backendsdialog.ds
self._init_liststore()
self._init_renderers()
self._init_signals()
@@ -57,7 +57,7 @@ def refresh(self):
# Sort backends
# 1, put default backend on top
# 2, sort backends by human name
- backends = list(self.req.get_all_backends(disabled=True))
+ backends = list(self.ds.get_all_backends(disabled=True))
backends = sorted(backends,
key=lambda backend: (not backend.is_default(),
backend.get_human_name()))
@@ -75,7 +75,7 @@ def on_backend_added(self, sender, backend_id):
@param backend_id: the id of the backend to add
"""
# Add
- backend = self.req.get_backend(backend_id)
+ backend = self.ds.get_backend(backend_id)
if not backend:
return
self.add_backend(backend)
@@ -94,10 +94,10 @@ def add_backend(self, backend):
if backend:
backend_iter = self.liststore.append([
backend.get_id(),
- self.dialog.get_pixbuf_from_icon_name(backend.get_icon(),
- 16),
+ backend.get_icon(),
backend.get_human_name(),
self._get_markup_for_tags(backend.get_attached_tags()),
+ backend.is_enabled()
])
self.backendid_to_iter[backend.get_id()] = backend_iter
@@ -112,19 +112,10 @@ def on_backend_state_changed(self, sender, backend_id):
if backend_id in self.backendid_to_iter:
b_iter = self.backendid_to_iter[backend_id]
b_path = self.liststore.get_path(b_iter)
- backend = self.req.get_backend(backend_id)
+ backend = self.ds.get_backend(backend_id)
backend_name = backend.get_human_name()
- if backend.is_enabled():
- text = backend_name
- else:
- # FIXME This snippet is on more than 2 places!!!
- # FIXME create a function which takes a widget and
- # flag and returns color as #RRGGBB
- style_context = self.get_style_context()
- color = style_context.get_color(Gtk.StateFlags.INSENSITIVE)
- color = rgba_to_hex(color)
- text = f"{backend_name}"
- self.liststore[b_path][self.COLUMN_TEXT] = text
+ self.liststore[b_path][self.COLUMN_TEXT] = backend_name
+ self.liststore[b_path][self.COLUMN_ENABLED] = backend.is_enabled()
# Also refresh the tags
new_tags = self._get_markup_for_tags(backend.get_attached_tags())
@@ -140,7 +131,7 @@ def _get_markup_for_tags(self, tag_names):
if ALLTASKS_TAG in tag_names:
tags_txt = ""
else:
- tags_txt = get_colored_tags_markup(self.req, tag_names)
+ tags_txt = get_colored_tags_markup(self.ds, tag_names)
return "" + tags_txt + ""
def remove_backend(self, backend_id):
@@ -156,7 +147,7 @@ def remove_backend(self, backend_id):
def _init_liststore(self):
"""Creates the liststore"""
- self.liststore = Gtk.ListStore(object, GdkPixbuf.Pixbuf, str, str)
+ self.liststore = Gtk.ListStore(object, str, str, str, bool)
self.set_model(self.liststore)
def _init_renderers(self):
@@ -166,12 +157,13 @@ def _init_renderers(self):
# For the backend icon
pixbuf_cell = Gtk.CellRendererPixbuf()
tvcolumn_pixbuf = Gtk.TreeViewColumn('Icon', pixbuf_cell)
- tvcolumn_pixbuf.add_attribute(pixbuf_cell, 'pixbuf', self.COLUMN_ICON)
+ tvcolumn_pixbuf.add_attribute(pixbuf_cell, 'icon-name', self.COLUMN_ICON)
self.append_column(tvcolumn_pixbuf)
# For the backend name
text_cell = Gtk.CellRendererText()
tvcolumn_text = Gtk.TreeViewColumn('Name', text_cell)
tvcolumn_text.add_attribute(text_cell, 'markup', self.COLUMN_TEXT)
+ tvcolumn_text.add_attribute(text_cell, 'sensitive', self.COLUMN_ENABLED)
self.append_column(tvcolumn_text)
text_cell.connect('edited', self.cell_edited_callback)
text_cell.set_property('editable', True)
diff --git a/GTG/gtk/backends/configurepanel.py b/GTG/gtk/backends/configurepanel.py
index e6a67b5939..0030fc0bb1 100644
--- a/GTG/gtk/backends/configurepanel.py
+++ b/GTG/gtk/backends/configurepanel.py
@@ -28,7 +28,7 @@ class ConfigurePanel(Gtk.Box):
A vertical Box that lets you configure a backend
"""
- def __init__(self, backends):
+ def __init__(self, backends, ds):
"""
Constructor, creating all the gtk widgets
@@ -40,7 +40,7 @@ def __init__(self, backends):
self.should_spinner_be_shown = False
self.task_deleted_handle = None
self.task_added_handle = None
- self.req = backends.get_requester()
+ self.ds = ds
self._create_widgets()
self._connect_signals()
@@ -64,13 +64,12 @@ def _create_widgets(self):
middle.set_spacing(6)
self._fill_top_box(top)
self._fill_middle_box(middle)
- self.pack_start(top, False, True, 0)
- self.pack_start(middle, False, True, 0)
- align = Gtk.Alignment.new(0, 0, 1, 0)
- align.set_padding(10, 0, 0, 0)
- self.parameters_ui = ParametersUI(self.req)
- align.add(self.parameters_ui)
- self.pack_start(align, False, True, 0)
+ self.append(top)
+ self.append(middle)
+ self.parameters_ui = ParametersUI()
+ self.parameters_ui.ds = self.ds
+ self.parameters_ui.set_margin_top(10)
+ self.append(self.parameters_ui)
def _fill_top_box(self, box):
""" Fill header with service's icon, name, and a spinner
@@ -81,7 +80,9 @@ def _fill_top_box(self, box):
self.image_icon.set_size_request(48, 48)
self.human_name_label = Gtk.Label()
- self.human_name_label.set_alignment(xalign=0, yalign=0.5)
+ self.human_name_label.set_hexpand(True)
+ self.human_name_label.set_xalign(0)
+ self.human_name_label.set_yalign(0.5)
# FIXME in the newer versions of GTK3 there always be Spinner!
try:
@@ -91,12 +92,12 @@ def _fill_top_box(self, box):
self.spinner = Gtk.Box()
self.spinner.connect("show", self.on_spinner_show)
self.spinner.set_size_request(32, 32)
- align_spin = Gtk.Alignment.new(1, 0, 0, 0)
- align_spin.add(self.spinner)
+ self.spinner.set_margin_top(1)
- box.pack_start(self.image_icon, False, True, 0)
- box.pack_start(self.human_name_label, True, True, 0)
- box.pack_start(align_spin, False, True, 0)
+ box.set_spacing(10)
+ box.append(self.image_icon)
+ box.append(self.human_name_label)
+ box.append(self.spinner)
def _fill_middle_box(self, box):
"""
@@ -105,23 +106,26 @@ def _fill_middle_box(self, box):
@param box: the Gtk.Box to fill
"""
self.sync_status_label = Gtk.Label()
- self.sync_status_label.set_alignment(xalign=0.8, yalign=0.5)
+ self.sync_status_label.set_hexpand(True)
+ self.sync_status_label.set_xalign(0.8)
+ self.sync_status_label.set_yalign(0.5)
self.sync_button = Gtk.Button()
+ self.sync_button.set_hexpand(True)
self.sync_button.connect("clicked", self.on_sync_button_clicked)
- box.pack_start(self.sync_status_label, True, True, 0)
- box.pack_start(self.sync_button, True, True, 0)
+ box.append(self.sync_status_label)
+ box.append(self.sync_button)
def set_backend(self, backend_id):
"""Changes the backend to configure, refreshing this view.
@param backend_id: the id of the backend to configure
"""
- self.backend = self.dialog.get_requester().get_backend(backend_id)
+ self.backend = self.dialog.ds.get_backend(backend_id)
self.refresh_title()
self.refresh_sync_status()
self.parameters_ui.refresh(self.backend)
- self.image_icon.set_from_pixbuf(self.dialog.get_pixbuf_from_icon_name(
- self.backend.get_icon(), 48))
+ self.image_icon.set_pixel_size(48)
+ self.image_icon.set_from_icon_name(self.backend.get_icon())
def refresh_title(self, sender=None, data=None):
"""
@@ -176,7 +180,7 @@ def on_sync_button_clicked(self, sender):
@param sender: not used, here only for signal callback compatibility
"""
self.parameters_ui.commit_changes()
- self.req.set_backend_enabled(self.backend.get_id(),
+ self.ds.set_backend_enabled(self.backend.get_id(),
not self.backend.is_enabled())
def on_sync_started(self, sender, backend_id):
@@ -224,7 +228,6 @@ def spinner_set_active(self, active):
if active:
if isinstance(self.spinner, Gtk.Spinner):
self.spinner.start()
- self.spinner.show()
else:
self.spinner.hide()
if isinstance(self.spinner, Gtk.Spinner):
diff --git a/GTG/gtk/backends/parameters_ui/__init__.py b/GTG/gtk/backends/parameters_ui/__init__.py
index 1c28094592..58339113a8 100644
--- a/GTG/gtk/backends/parameters_ui/__init__.py
+++ b/GTG/gtk/backends/parameters_ui/__init__.py
@@ -46,15 +46,19 @@ class ParametersUI(Gtk.Box):
COMMON_WIDTH = 170
- def __init__(self, requester):
+ def __init__(self):
"""Constructs the list of the possible widgets.
@param requester: a GTG.core.requester.Requester object
"""
super().__init__(orientation=Gtk.Orientation.VERTICAL)
- self.req = requester
self.set_spacing(10)
+ # Keep track of our children, for some reason iterating through
+ # them the regular way to remove them just doesn't always work.
+ # And this is the recommended way anyway.
+ self.displayed_params = []
+
# builds a list of widget generators. More precisely, it's a
# list of tuples: (backend_parameter_name, widget_generator)
self.parameter_widgets = (
@@ -116,7 +120,7 @@ def UI_generator(self, param_type, special_arguments={}):
@return function: return a widget generator, not a widget. the widget
can be obtained by calling widget_generator(backend)
"""
- return lambda backend: param_type(req=self.req,
+ return lambda backend: param_type(ds=self.ds,
backend=backend,
width=self.COMMON_WIDTH,
**special_arguments)
@@ -128,9 +132,9 @@ def refresh(self, backend):
@param backend: the backend that is being configured
"""
# remove the old parameters UIs
- def _remove_child(self, child, data=None):
- self.remove(child)
- self.foreach(functools.partial(_remove_child, self), None)
+ for p_widget in self.displayed_params:
+ self.remove(p_widget)
+ self.displayed_params.clear()
# add new widgets
backend_parameters = backend.get_parameters()
if backend_parameters[GenericBackend.KEY_DEFAULT_BACKEND]:
@@ -139,15 +143,14 @@ def _remove_child(self, child, data=None):
for parameter_name, widget in self.parameter_widgets:
if parameter_name in backend_parameters:
# FIXME I am not 100% about this change
- self.pack_start(widget(backend), True, True, 0)
- self.show_all()
+ p_widget = widget(backend)
+ self.append(p_widget)
+ self.displayed_params.append(p_widget)
def commit_changes(self):
"""
Saves all the parameters at their current state (the user may have
modified them)
"""
-
- def _commit_changes(child, data=None):
- child.commit_changes()
- self.foreach(_commit_changes, None)
+ for p_widget in self.displayed_params:
+ p_widget.commit_changes()
diff --git a/GTG/gtk/backends/parameters_ui/checkbox.py b/GTG/gtk/backends/parameters_ui/checkbox.py
index 08f8241c0b..16a315b6b1 100644
--- a/GTG/gtk/backends/parameters_ui/checkbox.py
+++ b/GTG/gtk/backends/parameters_ui/checkbox.py
@@ -25,11 +25,11 @@ class CheckBoxUI(Gtk.Box):
meaning
"""
- def __init__(self, req, backend, width, text, parameter):
+ def __init__(self, ds, backend, width, text, parameter):
"""
Creates the checkbox and the related label.
- @param req: a Requester
+ @param ds: Datastore
@param backend: a backend object
@param width: the width of the gtk.Label object
@param parameter: the backend parameter this checkbox should display
@@ -37,7 +37,7 @@ def __init__(self, req, backend, width, text, parameter):
"""
super().__init__()
self.backend = backend
- self.req = req
+ self.ds = ds
self.text = text
self.parameter = parameter
self._populate_gtk(width)
@@ -51,7 +51,7 @@ def _populate_gtk(self, width):
backend_parameters = self.backend.get_parameters()[self.parameter]
self.checkbutton.set_active(backend_parameters)
self.checkbutton.connect("toggled", self.on_modified)
- self.pack_start(self.checkbutton, False, True, 0)
+ self.append(self.checkbutton)
def commit_changes(self):
"""Saves the changes to the backend parameter"""
@@ -66,4 +66,4 @@ def on_modified(self, sender=None):
@param sender: not used, only here for signal compatibility
"""
if self.backend.is_enabled() and not self.backend.is_default():
- self.req.set_backend_enabled(self.backend.get_id(), False)
+ self.ds.set_backend_enabled(self.backend.get_id(), False)
diff --git a/GTG/gtk/backends/parameters_ui/import_tags.py b/GTG/gtk/backends/parameters_ui/import_tags.py
index 9459bcdc9a..5c317a4b16 100644
--- a/GTG/gtk/backends/parameters_ui/import_tags.py
+++ b/GTG/gtk/backends/parameters_ui/import_tags.py
@@ -19,7 +19,8 @@
from gi.repository import Gtk
-from GTG.core.tag import ALLTASKS_TAG
+
+ALLTASKS_TAG = 'gtg-tags-all'
class ImportTagsUI(Gtk.Box):
@@ -28,11 +29,11 @@ class ImportTagsUI(Gtk.Box):
to let the user change the attached tags (or imported)
"""
- def __init__(self, req, backend, width, title, anybox_text, somebox_text,
+ def __init__(self, ds, backend, width, title, anybox_text, somebox_text,
parameter_name):
"""Populates the widgets and refresh the tags to display
- @param req: a requester
+ @param ds: a requester
@param backend: the backend to configure
@param width: the length of the radio buttons
@param title: the text for the label describing what this collection
@@ -44,7 +45,7 @@ def __init__(self, req, backend, width, title, anybox_text, somebox_text,
"""
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.backend = backend
- self.req = req
+ self.ds = ds
self.title = title
self.anybox_text = anybox_text
self.somebox_text = somebox_text
@@ -60,25 +61,25 @@ def _populate_gtk(self, width):
@param width: the length of the radio buttons
"""
title_label = Gtk.Label()
- title_label.set_alignment(xalign=0, yalign=0)
+ title_label.set_xalign(0)
+ title_label.set_yalign(0)
title_label.set_markup(f"{self.title}")
- self.pack_start(title_label, True, True, 0)
- align = Gtk.Alignment.new(0, 0, 1, 0)
- align.set_padding(0, 0, 10, 0)
- self.pack_start(align, True, True, 0)
+ self.append(title_label)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- align.add(vbox)
- self.all_tags_radio = Gtk.RadioButton(group=None,
+ self.append(vbox)
+ self.all_tags_radio = Gtk.CheckButton(group=None,
label=self.anybox_text)
- vbox.pack_start(self.all_tags_radio, True, True, 0)
- self.some_tags_radio = Gtk.RadioButton(group=self.all_tags_radio,
+ vbox.append(self.all_tags_radio)
+ self.some_tags_radio = Gtk.CheckButton(group=self.all_tags_radio,
label=self.somebox_text)
self.some_tags_radio.set_size_request(width=width, height=-1)
box = Gtk.Box()
- vbox.pack_start(box, True, True, 0)
- box.pack_start(self.some_tags_radio, False, True, 0)
+ box.set_spacing(10)
+ vbox.append(box)
+ box.append(self.some_tags_radio)
self.tags_entry = Gtk.Entry()
- box.pack_start(self.tags_entry, True, True, 0)
+ self.tags_entry.set_hexpand(True)
+ box.append(self.tags_entry)
def on_changed(self, radio, data=None):
""" Signal callback, executed when the user modifies something.
@@ -89,7 +90,7 @@ def on_changed(self, radio, data=None):
@param data: not used, only here for signal compatibility
"""
# every change in the config disables the backend
- self.req.set_backend_enabled(self.backend.get_id(), False)
+ self.ds.set_backend_enabled(self.backend.get_id(), False)
self._refresh_textbox_state()
def commit_changes(self):
diff --git a/GTG/gtk/backends/parameters_ui/password.py b/GTG/gtk/backends/parameters_ui/password.py
index bf981ec23c..eef1fd2c08 100644
--- a/GTG/gtk/backends/parameters_ui/password.py
+++ b/GTG/gtk/backends/parameters_ui/password.py
@@ -24,17 +24,17 @@
class PasswordUI(Gtk.Box):
"""Widget displaying a gtk.Label and a textbox to input a password"""
- def __init__(self, req, backend, width):
+ def __init__(self, ds, backend, width):
"""Creates the gtk widgets and loads the current password in the text
field
- @param req: a Requester
+ @param ds: a Requester
@param backend: a backend object
@param width: the width of the Gtk.Label object
"""
super().__init__()
self.backend = backend
- self.req = req
+ self.ds = ds
self._populate_gtk(width)
self._load_password()
self._connect_signals()
@@ -44,15 +44,15 @@ def _populate_gtk(self, width):
@param width: the width of the Gtk.Label object
"""
+ self.set_spacing(10)
password_label = Gtk.Label(label=_("Password:"))
- password_label.set_alignment(xalign=0, yalign=0)
+ password_label.set_yalign(0)
+ password_label.set_xalign(0)
password_label.set_size_request(width=width, height=-1)
- self.pack_start(password_label, False, True, 0)
- align = Gtk.Alignment.new(0, 0.5, 1, 0)
- align.set_padding(0, 0, 10, 0)
- self.pack_start(align, True, True, 0)
+ self.append(password_label)
self.password_textbox = Gtk.Entry()
- align.add(self.password_textbox)
+ self.password_textbox.set_hexpand(True)
+ self.append(self.password_textbox)
def _load_password(self):
"""Loads the password from the backend"""
@@ -78,4 +78,4 @@ def on_password_modified(self, sender):
@param sender: not used, only here for signal compatibility
"""
if self.backend.is_enabled() and not self.backend.is_default():
- self.req.set_backend_enabled(self.backend.get_id(), False)
+ self.ds.set_backend_enabled(self.backend.get_id(), False)
diff --git a/GTG/gtk/backends/parameters_ui/path.py b/GTG/gtk/backends/parameters_ui/path.py
index 81c94aa01f..2db077b469 100644
--- a/GTG/gtk/backends/parameters_ui/path.py
+++ b/GTG/gtk/backends/parameters_ui/path.py
@@ -28,17 +28,17 @@ class PathUI(Gtk.Box):
filesystem explorer to modify that path (also, a label to describe those)
"""
- def __init__(self, req, backend, width):
+ def __init__(self, ds, backend, width):
"""
Creates the textbox, the button and loads the current path.
- @param req: a Requester
+ @param ds: Datastore
@param backend: a backend object
@param width: the width of the Gtk.Label object
"""
super().__init__()
self.backend = backend
- self.req = req
+ self.ds = ds
self._populate_gtk(width)
def _populate_gtk(self, width):
@@ -47,21 +47,20 @@ def _populate_gtk(self, width):
@param width: the width of the Gtk.Label object
"""
label = Gtk.Label(label=_("Filename:"))
- label.set_line_wrap(True)
- label.set_alignment(xalign=0, yalign=0.5)
+ label.set_wrap(True)
+ label.set_xalign(0)
+ label.set_yalign(0.5)
label.set_size_request(width=width, height=-1)
- self.pack_start(label, False, True, 0)
- align = Gtk.Alignment.new(0, 0.5, 1, 0)
- align.set_padding(0, 0, 10, 0)
- self.pack_start(align, True, True, 0)
+ self.append(label)
self.textbox = Gtk.Entry()
+ self.textbox.set_hexpand(True)
self.textbox.set_text(self.backend.get_parameters()['path'])
self.textbox.connect('changed', self.on_path_modified)
- align.add(self.textbox)
+ self.append(self.textbox)
self.button = Gtk.Button()
self.button.set_label("Edit")
self.button.connect('clicked', self.on_button_clicked)
- self.pack_start(self.button, False, True, 0)
+ self.append(self.button)
def commit_changes(self):
"""Saves the changes to the backend parameter"""
@@ -75,7 +74,7 @@ def on_path_modified(self, sender):
@param sender: not used, only here for signal compatibility
"""
if self.backend.is_enabled() and not self.backend.is_default():
- self.req.set_backend_enabled(self.backend.get_id(), False)
+ self.ds.set_backend_enabled(self.backend.get_id(), False)
def on_button_clicked(self, sender):
"""Shows the filesystem explorer to choose a new file
diff --git a/GTG/gtk/backends/parameters_ui/period.py b/GTG/gtk/backends/parameters_ui/period.py
index 4888830564..4f2dbf9d99 100644
--- a/GTG/gtk/backends/parameters_ui/period.py
+++ b/GTG/gtk/backends/parameters_ui/period.py
@@ -25,18 +25,18 @@ class PeriodUI(Gtk.Box):
"""A widget to change the frequency of a backend synchronization
"""
- def __init__(self, req, backend, width):
+ def __init__(self, ds, backend, width):
"""
Creates the Gtk.Adjustment and the related label. Loads the current
period.
- @param req: a Requester
+ @param ds: Datastore
@param backend: a backend object
@param width: the width of the Gtk.Label object
"""
super().__init__()
self.backend = backend
- self.req = req
+ self.ds = ds
self._populate_gtk(width)
self._connect_signals()
@@ -45,30 +45,29 @@ def _populate_gtk(self, width):
@param width: the width of the Gtk.Label object
"""
+ self.set_spacing(10)
period_label = Gtk.Label(label=_("Check for new tasks every"))
- period_label.set_alignment(xalign=0, yalign=0.5)
- period_label.set_line_wrap(True)
+ period_label.set_xalign(0)
+ period_label.set_yalign(0.5)
+ period_label.set_wrap(True)
period_label.set_size_request(width=width, height=-1)
- self.pack_start(period_label, False, True, 0)
- align = Gtk.Alignment.new(0, 0.5, 1, 0)
- align.set_padding(0, 0, 10, 0)
- self.pack_start(align, False, True, 0)
+ self.append(period_label)
period = self.backend.get_parameters()['period']
self.adjustment = Gtk.Adjustment(value=period,
lower=1,
upper=120,
- step_incr=1,
- page_incr=0,
+ step_increment=1,
+ page_increment=0,
page_size=0)
self.period_spin = Gtk.SpinButton(adjustment=self.adjustment,
climb_rate=0.3,
digits=0)
+ self.append(self.period_spin)
self.minutes_label = Gtk.Label()
self.update_minutes_label()
- self.minutes_label.set_alignment(xalign=0, yalign=0.5)
- self.pack_start(self.minutes_label, False, True, 0)
- align.add(self.period_spin)
- self.show_all()
+ self.minutes_label.set_xalign(0)
+ self.minutes_label.set_yalign(0.5)
+ self.append(self.minutes_label)
def _connect_signals(self):
"""Connects the gtk signals"""
@@ -87,7 +86,7 @@ def on_spin_changed(self, sender):
"""
self.update_minutes_label()
if self.backend.is_enabled() and not self.backend.is_default():
- self.req.set_backend_enabled(self.backend.get_id(), False)
+ self.ds.set_backend_enabled(self.backend.get_id(), False)
def update_minutes_label(self):
adjustment = int(self.adjustment.get_value())
diff --git a/GTG/gtk/backends/parameters_ui/text.py b/GTG/gtk/backends/parameters_ui/text.py
index 4b77b1f538..d275e4d715 100644
--- a/GTG/gtk/backends/parameters_ui/text.py
+++ b/GTG/gtk/backends/parameters_ui/text.py
@@ -23,18 +23,18 @@ class TextUI(Gtk.Box):
"""A widget to display a simple textbox and a label to describe its content
"""
- def __init__(self, req, backend, width, description, parameter_name):
+ def __init__(self, ds, backend, width, description, parameter_name):
"""
Creates the textbox and the related label. Loads the current
content.
- @param req: a Requester
+ @param ds: Datastore
@param backend: a backend object
@param width: the width of the Gtk.Label object
"""
super().__init__()
self.backend = backend
- self.req = req
+ self.ds = ds
self.parameter_name = parameter_name
self.description = description
self._populate_gtk(width)
@@ -44,19 +44,19 @@ def _populate_gtk(self, width):
@param width: the width of the Gtk.Label object
"""
+ self.set_spacing(10)
label = Gtk.Label(label=f"{self.description}:")
- label.set_line_wrap(True)
- label.set_alignment(xalign=0, yalign=0.5)
+ label.set_wrap(True)
+ label.set_xalign(0)
+ label.set_yalign(0.5)
label.set_size_request(width=width, height=-1)
- self.pack_start(label, False, True, 0)
- align = Gtk.Alignment.new(0, 0.5, 1, 0)
- align.set_padding(0, 0, 10, 0)
- self.pack_start(align, True, True, 0)
+ self.append(label)
self.textbox = Gtk.Entry()
+ self.textbox.set_hexpand(True)
backend_parameters = self.backend.get_parameters()[self.parameter_name]
self.textbox.set_text(backend_parameters)
self.textbox.connect('changed', self.on_text_modified)
- align.add(self.textbox)
+ self.append(self.textbox)
def commit_changes(self):
"""Saves the changes to the backend parameter"""
@@ -71,4 +71,4 @@ def on_text_modified(self, sender):
@param sender: not used, only here for signal compatibility
"""
if self.backend.is_enabled() and not self.backend.is_default():
- self.req.set_backend_enabled(self.backend.get_id(), False)
+ self.ds.set_backend_enabled(self.backend.get_id(), False)
diff --git a/GTG/gtk/browser/__init__.py b/GTG/gtk/browser/__init__.py
index 0a9c53c209..8c2b3ecc3f 100644
--- a/GTG/gtk/browser/__init__.py
+++ b/GTG/gtk/browser/__init__.py
@@ -32,3 +32,4 @@ class GnomeConfig():
MENUS_UI_FILE = os.path.join(data, "context_menus.ui")
MODIFYTAGS_UI_FILE = os.path.join(data, "modify_tags.ui")
TAG_EDITOR_UI_FILE = os.path.join(data, "tag_editor.ui")
+ SEARCH_EDITOR_UI_FILE = os.path.join(data, "search_editor.ui")
diff --git a/GTG/gtk/browser/adaptive_button.py b/GTG/gtk/browser/adaptive_button.py
index a39402833f..db6fbac53e 100644
--- a/GTG/gtk/browser/adaptive_button.py
+++ b/GTG/gtk/browser/adaptive_button.py
@@ -8,7 +8,7 @@
log = logging.getLogger(__name__)
-class AdaptiveFittingWidget(Gtk.Container):
+class AdaptiveFittingWidget(Gtk.BinLayout):
"""
This widget chooses the biggest but fitting children widgets and displays
that one. This is useful to switch out between text and an icon in an
diff --git a/GTG/gtk/browser/backend_infobar.py b/GTG/gtk/browser/backend_infobar.py
index 80ce0a6d0f..8d034a0664 100644
--- a/GTG/gtk/browser/backend_infobar.py
+++ b/GTG/gtk/browser/backend_infobar.py
@@ -71,10 +71,10 @@ def _populate(self):
content_box.set_homogeneous(False)
self.label = Gtk.Label()
self.label.set_hexpand(True)
- self.label.set_line_wrap(True)
+ self.label.set_wrap(True)
self.label.set_alignment(0.5, 0.5)
self.label.set_justify(Gtk.Justification.FILL)
- content_box.add(self.label)
+ content_box.append(self.label)
def _on_error_response(self, widget, event):
"""
@@ -174,32 +174,31 @@ def _prepare_textual_interaction(self):
self.dialog.set_title(title)
self.dialog.set_transient_for(self.browser.window)
self.dialog.set_destroy_with_parent(True)
- self.dialog.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.dialog.set_modal(True)
# self.dialog.set_size_request(300,170)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- self.dialog.add(vbox)
+ self.dialog.set_child(vbox)
description_label = Gtk.Label()
description_label.set_justify(Gtk.Justification.FILL)
- description_label.set_line_wrap(True)
+ description_label.set_wrap(True)
description_label.set_markup(description)
align = Gtk.Alignment.new(0.5, 0.5, 1, 1)
align.set_padding(10, 0, 20, 20)
align.set_vexpand(True)
align.add(description_label)
- vbox.add(align)
+ vbox.append(align)
self.text_box = Gtk.Entry()
self.text_box.set_size_request(-1, 40)
align = Gtk.Alignment.new(0.5, 0.5, 1, 1)
align.set_vexpand(True)
align.set_padding(20, 20, 20, 20)
align.add(self.text_box)
- vbox.add(align)
+ vbox.append(align)
button = Gtk.Button()
button.set_label(_("OK"))
button.connect("clicked", self._on_text_confirmed)
button.set_size_request(-1, 40)
- vbox.add(button)
+ vbox.append(button)
self.dialog.show_all()
self.hide()
diff --git a/GTG/gtk/browser/cell_renderer_tags.py b/GTG/gtk/browser/cell_renderer_tags.py
deleted file mode 100644
index 3808c10f1b..0000000000
--- a/GTG/gtk/browser/cell_renderer_tags.py
+++ /dev/null
@@ -1,234 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-from gi.repository import GObject, GLib, Gtk, Gdk
-from gi.repository import Pango
-import gi
-import cairo
-gi.require_version('PangoCairo', '1.0')
-# XXX: disable PEP8 checking on this line to prevent an E402 error due to
-# require_version needing to be called before the PangoCairo import
-from gi.repository import PangoCairo # noqa
-
-
-class CellRendererTags(Gtk.CellRenderer):
-
- SYMBOLIC_ICONS = (
- 'emblem-documents-symbolic',
- 'task-past-due-symbolic',
- 'system-search-symbolic',
- )
-
- __gproperties__ = {
- 'tag_list': (GObject.TYPE_PYOBJECT,
- "Tag list", "A list of tags", GObject.ParamFlags.READWRITE),
- 'tag': (GObject.TYPE_PYOBJECT, "Tag",
- "Tag", GObject.ParamFlags.READWRITE),
- }
-
- # Private methods
- def __roundedrec(self, context, x, y, w, h, r=10):
- "Draw a rounded rectangle"
- # A * BQ
- # H C
- # * *
- # G D
- # F * E
-
- context.move_to(x + r, y) # Move to A
- context.line_to(x + w - r, y) # Line to B
-
- context.curve_to(
- x + w, y,
- x + w, y,
- x + w, y + r
- ) # Curve to C
- context.line_to(x + w, y + h - r) # Line to D
-
- context.curve_to(
- x + w, y + h,
- x + w, y + h,
- x + w - r, y + h
- ) # Curve to E
- context.line_to(x + r, y + h) # Line to F
-
- context.curve_to(
- x, y + h,
- x, y + h,
- x, y + h - r
- ) # Curve to G
- context.line_to(x, y + r) # Line to H
-
- context.curve_to(
- x, y,
- x, y,
- x + r, y
- ) # Curve to A
- return
-
- def __count_viewable_tags(self):
-
- count = 0
- if self.tag_list is not None:
- for my_tag in self.tag_list:
- my_tag_color = my_tag.get_attribute("color")
- my_tag_icon = my_tag.get_attribute("icon")
- if my_tag_color or my_tag_icon:
- count = count + 1
- elif self.tag is not None:
- count = 1
- else:
- count = 0
-
- return count
-
- # Class methods
- def __init__(self, config):
- super().__init__()
- self.tag_list = None
- self.tag = None
- self.xpad = 1
- self.ypad = 1
- self.PADDING = 1
- self.config = config
- self._ignore_icon_error_for = set()
-
- def do_set_property(self, pspec, value):
- if pspec.name == "tag-list":
- self.tag_list = value
- else:
- setattr(self, pspec.name, value)
-
- def do_get_property(self, pspec):
- if pspec.name == "tag-list":
- return self.tag_list
- else:
- return getattr(self, pspec.name)
-
- def do_render(self, cr, widget, background_area, cell_area, flags):
-
- vw_tags = self.__count_viewable_tags()
- count = 0
-
- # Select source
- if self.tag_list is not None:
- tags = self.tag_list
- elif self.tag is not None:
- tags = [self.tag]
- else:
- return
-
- if self.config.get('dark_mode'):
- symbolic_color = Gdk.RGBA(0.9, 0.9, 0.9, 1)
- else:
- symbolic_color = Gdk.RGBA(0, 0, 0, 1)
-
- # Drawing context
- gdkcontext = cr
- scale_factor = widget.get_scale_factor()
- # Don't blur border on lodpi
- if scale_factor == 1:
- gdkcontext.set_antialias(cairo.ANTIALIAS_NONE)
-
- # Coordinates of the origin point
- x_align = self.get_property("xalign")
- y_align = self.get_property("yalign")
- padding = self.PADDING
- orig_x = cell_area.x + int(
- (cell_area.width - 16 * vw_tags - padding * 2 * (vw_tags - 1)) * x_align)
- orig_y = cell_area.y + int(
- (cell_area.height - 16) * y_align)
-
- # We draw the icons & squares
- for my_tag in tags:
-
- my_tag_icon = my_tag.get_attribute("icon")
- my_tag_color = my_tag.get_attribute("color")
-
- rect_x = orig_x + self.PADDING * 2 * count + 16 * count
- rect_y = orig_y
-
- if my_tag_icon:
- if my_tag_icon in self.SYMBOLIC_ICONS:
- icon_theme = Gtk.IconTheme.get_default()
- info = icon_theme.lookup_icon_for_scale(my_tag_icon, 16,
- scale_factor, 0)
- pixbuf, was_symbolic = info.load_symbolic(symbolic_color)
-
- surface = Gdk.cairo_surface_create_from_pixbuf(
- pixbuf, scale_factor, widget.get_window())
- Gtk.render_icon_surface(
- widget.get_style_context(), gdkcontext, surface,
- rect_x, rect_y)
-
- count += 1
-
- else:
- layout = PangoCairo.create_layout(cr)
- layout.set_markup(my_tag_icon, -1)
- cr.move_to(rect_x - 2, rect_y - 1)
- PangoCairo.show_layout(cr, layout)
- count += 1
-
- elif my_tag_color:
-
- # Draw rounded rectangle
- my_color = Gdk.RGBA()
- my_color.parse(my_tag_color)
- Gdk.cairo_set_source_rgba(gdkcontext, my_color)
-
- self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8)
- gdkcontext.fill()
- count += 1
-
- # Outer line
- Gdk.cairo_set_source_rgba(gdkcontext, Gdk.RGBA(0, 0, 0, 0.20))
- gdkcontext.set_line_width(1.0)
- self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8)
- gdkcontext.stroke()
-
- if self.tag and my_tag:
-
- my_tag_icon = my_tag.get_attribute("icon")
- my_tag_color = my_tag.get_attribute("color")
-
- if not my_tag_icon and not my_tag_color:
- # Draw rounded rectangle
- Gdk.cairo_set_source_rgba(gdkcontext,
- Gdk.RGBA(0.95, 0.95, 0.95, 1))
- self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8)
- gdkcontext.fill()
-
- # Outer line
- Gdk.cairo_set_source_rgba(gdkcontext, Gdk.RGBA(0, 0, 0, 0.20))
- gdkcontext.set_line_width(1.0)
- self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8)
- gdkcontext.stroke()
-
- def do_get_size(self, widget, cell_area=None):
- count = self.__count_viewable_tags()
-
- if count != 0:
- return (self.xpad, self.ypad,
- self.xpad * 2 + 16 * count + 2 * count * self.PADDING,
- 16 + 2 * self.ypad)
- else:
- return (self.xpad, self.ypad, self.xpad * 2, self.ypad * 2)
-
-
-GObject.type_register(CellRendererTags)
diff --git a/GTG/gtk/browser/delete_tag.py b/GTG/gtk/browser/delete_tag.py
index 913e1a9113..871b2f0738 100644
--- a/GTG/gtk/browser/delete_tag.py
+++ b/GTG/gtk/browser/delete_tag.py
@@ -18,17 +18,16 @@
from gi.repository import Gtk
-from GTG.core.tasks2 import Filter
+from GTG.core.tasks import Filter
from gettext import gettext as _, ngettext
-class DeleteTagsDialog():
+class DeleteTagsDialog:
MAXIMUM_TAGS_TO_SHOW = 5
- def __init__(self, req, browser):
- self.req = req
+ def __init__(self, browser):
self.browser = browser
self.tags_todelete = []
@@ -37,20 +36,28 @@ def on_delete_confirm(self):
otherwise, we will look which tid is selected"""
for tag in self.tags_todelete:
- self.req.delete_tag(tag)
-
- # TODO: New Core
- the_tag = self.browser.app.ds.tags.find(tag)
- tasks = self.browser.app.ds.tasks.filter(Filter.TAG, the_tag)
+ tasks = self.browser.app.ds.tasks.filter(Filter.TAG, tag)
for t in tasks:
- t.remove_tag(tag)
+ t.remove_tag(tag.name)
- self.browser.app.ds.tags.remove(the_tag.id)
+ self.browser.app.ds.tags.remove(tag.id)
+ self.browser.app.ds.tasks.notify('task_count_no_tags')
self.tags_todelete = []
- def delete_tags(self, tags=None):
+ def on_response(self, dialog, response, tagslist, callback):
+ dialog.destroy()
+
+ if response == Gtk.ResponseType.YES:
+ self.on_delete_confirm()
+ elif response == Gtk.ResponseType.REJECT:
+ tagslist = []
+
+ if callback:
+ callback(tagslist)
+
+ def show(self, tags=None, callback=None):
self.tags_todelete = tags or self.tags_todelete
if not self.tags_todelete:
@@ -86,28 +93,22 @@ def delete_tags(self, tags=None):
if len(tagslist) == 1:
# Don't show a bulleted list if there's only one item
- titles = "".join(tag for tag in tagslist)
+ titles = "".join(tag.name for tag in tagslist)
else:
- titles = "".join("\n• " + tag for tag in tagslist)
+ titles = "".join("\n• " + tag.name for tag in tagslist)
# Build and run dialog
dialog = Gtk.MessageDialog(transient_for=self.browser, modal=True)
dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL)
delete_btn = dialog.add_button(delete_text, Gtk.ResponseType.YES)
- delete_btn.get_style_context().add_class("destructive-action")
+ delete_btn.add_css_class("destructive-action")
dialog.props.use_markup = True
- dialog.props.text = "" + label_text + ""
+ # Decrease size of title to workaround not being able to put two texts in GTK4
+ dialog.props.text = "" + label_text + ""
dialog.props.secondary_text = titles + titles_suffix
- response = dialog.run()
- dialog.destroy()
-
- if response == Gtk.ResponseType.YES:
- self.on_delete_confirm()
- elif response == Gtk.ResponseType.REJECT:
- tagslist = []
-
- return tagslist
+ dialog.connect("response", self.on_response, tagslist, callback)
+ dialog.present()
diff --git a/GTG/gtk/browser/delete_task.py b/GTG/gtk/browser/delete_task.py
index 5749e77fc1..bd6337b012 100644
--- a/GTG/gtk/browser/delete_task.py
+++ b/GTG/gtk/browser/delete_task.py
@@ -23,66 +23,51 @@
from GTG.gtk import ViewConfig
-class DeletionUI():
+class DeletionUI:
MAXIMUM_TIDS_TO_SHOW = 5
- def __init__(self, req, window):
- self.req = req
- self.tids_todelete = []
-
- # Tags which must be updated
- self.update_tags = []
+ def __init__(self, window, ds):
+ self.tasks_to_delete = []
self.window = window
+ self.ds = ds
+
+
+ def on_response(self, dialog, response, tasklist, callback):
+ dialog.destroy()
+
+ if response == Gtk.ResponseType.YES:
+ self.on_delete_confirm(tasklist)
- def on_delete_confirm(self):
- """if we pass a tid as a parameter, we delete directly
- otherwise, we will look which tid is selected"""
+ if callback:
+ callback(tasklist)
- for tid in self.tids_todelete:
- if self.req.has_task(tid):
- self.req.delete_task(tid, recursive=True)
- self.tids_todelete = []
+ def on_delete_confirm(self, tasklist):
- # Update tags
- for tagname in self.update_tags:
- tag = self.req.get_tag(tagname)
- tag.modified()
+ for task in tasklist:
+ self.ds.tasks.remove(task.id)
- self.update_tags = []
def recursive_list_tasks(self, tasklist, root):
"""Populate a list of all the subtasks and
- their children, recursively.
+ their children, recursively."""
- Also collect the list of affected tags
- which should be refreshed"""
if root not in tasklist:
tasklist.append(root)
- [self.update_tags.append(tagname)
- for tagname in root.get_tags_name()
- if tagname not in self.update_tags]
+ [self.recursive_list_tasks(tasklist, t)
+ for t in root.children
+ if t not in tasklist]
- [self.recursive_list_tasks(tasklist, i)
- for i in root.get_subtasks() if i not in tasklist]
-
- def show(self, tids=None):
- self.tids_todelete = tids or self.tids_todelete
-
- if not self.tids_todelete:
- # We must at least have something to delete!
- return []
+ def show_async(self, tasks_to_delete, callback=None):
# Get full task list to delete
tasklist = []
- self.update_tags = []
- for tid in self.tids_todelete:
- task = self.req.get_task(tid)
+ for task in tasks_to_delete:
self.recursive_list_tasks(tasklist, task)
# Prepare Labels
@@ -103,7 +88,7 @@ def show(self, tids=None):
missing_titles_count = len(tasklist) - self.MAXIMUM_TIDS_TO_SHOW
if missing_titles_count >= 2:
- tasks = tasklist[: self.MAXIMUM_TIDS_TO_SHOW]
+ tasks = tasklist[:self.MAXIMUM_TIDS_TO_SHOW]
titles_suffix = _("\nAnd {missing_titles_count:d} more tasks")
titles_suffix = titles_suffix.format(missing_titles_count=missing_titles_count)
else:
@@ -112,28 +97,22 @@ def show(self, tids=None):
if len(tasklist) == 1:
# Don't show a bulleted list if there's only one item
- titles = "".join(task.get_title() for task in tasks)
+ titles = "".join(task.title for task in tasks)
else:
- titles = "".join("\n• " + task.get_title() for task in tasks)
+ titles = "".join("\n• " + task.title for task in tasks)
# Build and run dialog
dialog = Gtk.MessageDialog(transient_for=self.window, modal=True)
dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL)
delete_btn = dialog.add_button(delete_text, Gtk.ResponseType.YES)
- delete_btn.get_style_context().add_class("destructive-action")
+ delete_btn.add_css_class("destructive-action")
dialog.props.use_markup = True
- dialog.props.text = "" + label_text + ""
+ # Decrease size of title to workaround not being able to put two texts in GTK4
+ dialog.props.text = "" + label_text + ""
dialog.props.secondary_text = titles + titles_suffix
- response = dialog.run()
- dialog.destroy()
-
- if response == Gtk.ResponseType.YES:
- self.on_delete_confirm()
- elif response == Gtk.ResponseType.CANCEL:
- tasklist = []
-
- return tasklist
+ dialog.connect("response", self.on_response, tasklist, callback)
+ dialog.present()
diff --git a/GTG/gtk/browser/main_window.py b/GTG/gtk/browser/main_window.py
index 470cb2c496..7446255d1f 100644
--- a/GTG/gtk/browser/main_window.py
+++ b/GTG/gtk/browser/main_window.py
@@ -22,7 +22,7 @@
import datetime
import logging
import ast
-import liblarch_gtk # Just for types
+import re
from gi.repository import GObject, Gtk, Gdk, Gio, GLib
@@ -30,20 +30,18 @@
from GTG.backends.backend_signals import BackendSignals
from GTG.core.dirs import ICONS_DIR
from GTG.core.search import parse_search_query, InvalidQuery
-from GTG.core.tag import SEARCH_TAG
-from GTG.core.task import Task
from gettext import gettext as _
from GTG.gtk.browser import GnomeConfig
from GTG.gtk.browser import quick_add
from GTG.gtk.browser.backend_infobar import BackendInfoBar
from GTG.gtk.browser.modify_tags import ModifyTagsDialog
from GTG.gtk.browser.delete_tag import DeleteTagsDialog
-from GTG.gtk.browser.tag_context_menu import TagContextMenu
-from GTG.gtk.browser.treeview_factory import TreeviewFactory
+from GTG.gtk.browser.sidebar import Sidebar
+from GTG.gtk.browser.task_pane import TaskPane
from GTG.gtk.editor.calendar import GTGCalendar
from GTG.gtk.tag_completion import TagCompletion
from GTG.core.dates import Date
-from GTG.core.tasks2 import Filter
+from GTG.core.tasks import Filter, Status
from GTG.gtk.browser.adaptive_button import AdaptiveFittingWidget # Register type
log = logging.getLogger(__name__)
@@ -55,10 +53,13 @@
PANE_STACK_NAMES_MAP_INVERTED = {v: k for k, v in PANE_STACK_NAMES_MAP.items()}
+@Gtk.Template(filename=GnomeConfig.BROWSER_UI_FILE)
class MainWindow(Gtk.ApplicationWindow):
""" The UI for browsing open and closed tasks,
and listing tags in a tree """
+ __gtype_name__ = 'MainWindow'
+
__string_signal__ = (GObject.SignalFlags.RUN_FIRST, None, (str, ))
__none_signal__ = (GObject.SignalFlags.RUN_FIRST, None, tuple())
__gsignals__ = {'task-added-via-quick-add': __string_signal__,
@@ -67,38 +68,65 @@ class MainWindow(Gtk.ApplicationWindow):
'visibility-toggled': __none_signal__,
}
- def __init__(self, requester, app):
+ main_hpanes = Gtk.Template.Child()
+ open_pane = Gtk.Template.Child()
+ actionable_pane = Gtk.Template.Child()
+ closed_pane = Gtk.Template.Child()
+ tree_stack = Gtk.Template.Child('stack')
+
+ search_entry = Gtk.Template.Child()
+ searchbar = Gtk.Template.Child()
+ search_button = Gtk.Template.Child()
+
+ quickadd_entry = Gtk.Template.Child('quickadd_field')
+ quickadd_pane = Gtk.Template.Child()
+
+ sidebar_vbox = Gtk.Template.Child('sidebar_vbox')
+
+ vbox_toolbars = None
+ stack_switcher = Gtk.Template.Child()
+ main_box = Gtk.Template.Child('main_view_box')
+
+ defer_btn = Gtk.Template.Child('defer_task_button')
+ defer_menu_btn = Gtk.Template.Child()
+ defer_menu_days_section = Gtk.Template.Child()
+
+ headerbar = Gtk.Template.Child('browser_headerbar')
+ main_menu_btn = Gtk.Template.Child()
+ main_menu = Gtk.Template.Child()
+ help_overlay = Gtk.Template.Child('shortcuts')
+ about = Gtk.Template.Child('about_dialog')
+
+ def __init__(self, app):
super().__init__(application=app)
# Object prime variables
- self.req = requester
self.app = app
- self.config = self.req.get_config('browser')
+ self.config = app.config
self.tag_active = False
# Timeout handler for search
self.search_timeout = None
- # Treeviews handlers
- self.vtree_panes = {}
- self.tv_factory = TreeviewFactory(self.req, self.config)
+ self.sidebar = Sidebar(app, app.ds, self)
+ self.sidebar_vbox.append(self.sidebar)
+
+ self.panes = {
+ 'active': None,
+ 'workview': None,
+ 'closed': None,
+ }
+
+ self.panes['active'] = TaskPane(self, 'active')
+ self.open_pane.append(self.panes['active'])
- # Active Tasks
- self.activetree = self.req.get_tasks_tree(name='active', refresh=False)
- self.vtree_panes['active'] = \
- self.tv_factory.active_tasks_treeview(self.activetree)
+ self.panes['workview'] = TaskPane(self, 'workview')
+ self.actionable_pane.append(self.panes['workview'])
- # Workview Tasks
- self.workview_tree = \
- self.req.get_tasks_tree(name='workview', refresh=False)
- self.vtree_panes['workview'] = \
- self.tv_factory.active_tasks_treeview(self.workview_tree)
+ self.panes['closed'] = TaskPane(self, 'closed')
+ self.closed_pane.append(self.panes['closed'])
- # Closed Tasks
- self.closedtree = \
- self.req.get_tasks_tree(name='closed', refresh=False)
- self.vtree_panes['closed'] = \
- self.tv_factory.closed_tasks_treeview(self.closedtree)
+ self._init_context_menus()
# YOU CAN DEFINE YOUR INTERNAL MECHANICS VARIABLES BELOW
# Setup GTG icon theme
@@ -111,36 +139,23 @@ def __init__(self, requester, app):
self.tagtree = None
self.tagtreeview = None
- # Load window tree
- self.builder = Gtk.Builder()
- self.builder.add_from_file(GnomeConfig.BROWSER_UI_FILE)
- self.builder.add_from_file(GnomeConfig.HELP_OVERLAY_UI_FILE)
-
- # Define aliases for specific widgets to reuse them easily in the code
- self._init_widget_aliases()
- self.sidebar.connect('notify::visible', self._on_sidebar_visible)
- self.add_action(Gio.PropertyAction.new('sidebar', self.sidebar, 'visible'))
-
- self.set_titlebar(self.headerbar)
- self.set_title('Getting Things GNOME!')
- self.add(self.main_box)
+ self.sidebar_vbox.connect('notify::visible', self._on_sidebar_visible)
+ self.add_action(Gio.PropertyAction.new('sidebar', self.sidebar_vbox, 'visible'))
# Setup help overlay (shortcuts window)
self.set_help_overlay(self.help_overlay)
# Init non-GtkBuilder widgets
self._init_ui_widget()
- self._init_context_menus()
# Initialize "About" dialog
self._init_about_dialog()
- # Create our dictionary and connect it
+ # Connect manual signals
self._init_signal_connections()
self.restore_state_from_conf()
- self.reapply_filter()
self._set_defer_days()
self.browser_shown = False
@@ -159,12 +174,29 @@ def _init_context_menus(self):
builder.add_from_file(GnomeConfig.MENUS_UI_FILE)
closed_menu_model = builder.get_object('closed_task_menu')
- self.closed_menu = Gtk.Menu.new_from_model(closed_menu_model)
- self.closed_menu.attach_to_widget(self.main_box)
+ self.closed_menu = Gtk.PopoverMenu.new_from_model_full(
+ closed_menu_model, Gtk.PopoverMenuFlags.NESTED
+ )
+
+ self.closed_menu.set_has_arrow(False)
+ self.closed_menu.set_parent(self)
+ self.closed_menu.set_halign(Gtk.Align.START)
+ self.closed_menu.set_position(Gtk.PositionType.BOTTOM)
open_menu_model = builder.get_object('task_menu')
- self.open_menu = Gtk.Menu.new_from_model(open_menu_model)
- self.open_menu.attach_to_widget(self.main_box)
+ self.open_menu = Gtk.PopoverMenu.new_from_model_full(
+ open_menu_model, Gtk.PopoverMenuFlags.NESTED
+ )
+
+ self.open_menu.set_has_arrow(False)
+ self.open_menu.set_parent(self)
+ self.open_menu.set_halign(Gtk.Align.START)
+ self.open_menu.set_position(Gtk.PositionType.BOTTOM)
+
+ sort_menu_model = builder.get_object('sort_menu')
+ self.sort_menu = Gtk.PopoverMenu.new_from_model(sort_menu_model)
+ self.panes['active'].sort_btn.set_popover(self.sort_menu)
+
def _set_actions(self):
"""Setup actions."""
@@ -210,6 +242,13 @@ def _set_actions(self):
('recurring_month', self.on_set_recurring_every_month, None),
('recurring_year', self.on_set_recurring_every_year, None),
('recurring_toggle', self.on_toggle_recurring, None),
+ ('sort_by_start', self.on_sort_start, None),
+ ('sort_by_due', self.on_sort_due, None),
+ ('sort_by_added', self.on_sort_added, None),
+ ('sort_by_title', self.on_sort_title, None),
+ ('sort_by_modified', self.on_sort_modified, None),
+ ('sort_by_added', self.on_sort_added, None),
+ ('sort_by_tags', self.on_sort_tags, None),
]
for action, callback, accel in action_entries:
@@ -228,58 +267,29 @@ def _init_icon_theme(self):
sets the deafault theme for icon and its directory
"""
# TODO(izidor): Add icon dirs on app level
- Gtk.IconTheme.get_default().prepend_search_path(ICONS_DIR)
-
- def _init_widget_aliases(self):
- """
- Defines aliases for UI elements found in the GtkBuilder file
- """
-
- self.taskpopup = self.builder.get_object("task_context_menu")
- self.defertopopup = self.builder.get_object("defer_to_context_menu")
- self.ctaskpopup = self.builder.get_object("closed_task_context_menu")
- self.about = self.builder.get_object("about_dialog")
- self.open_pane = self.builder.get_object("open_pane")
- self.actionable_pane = self.builder.get_object("actionable_pane")
- self.closed_pane = self.builder.get_object("closed_pane")
- self.menu_view_workview = self.builder.get_object("view_workview")
- self.toggle_workview = self.builder.get_object("workview_toggle")
- self.search_entry = self.builder.get_object("search_entry")
- self.searchbar = self.builder.get_object("searchbar")
- self.search_button = self.builder.get_object("search_button")
- self.quickadd_entry = self.builder.get_object("quickadd_field")
- self.quickadd_pane = self.builder.get_object("quickadd_pane")
- self.sidebar = self.builder.get_object("sidebar_vbox")
- self.sidebar_container = self.builder.get_object("sidebar-scroll")
- self.sidebar_notebook = self.builder.get_object("sidebar_notebook")
- self.main_notebook = self.builder.get_object("main_notebook")
- self.accessory_notebook = self.builder.get_object("accessory_notebook")
- self.vbox_toolbars = self.builder.get_object("vbox_toolbars")
- self.stack_switcher = self.builder.get_object("stack_switcher")
- self.headerbar = self.builder.get_object("browser_headerbar")
- self.main_box = self.builder.get_object("main_view_box")
- self.defer_btn = self.builder.get_object("defer_task_button")
- self.defer_menu_btn = self.builder.get_object("defer_menu_btn")
- self.help_overlay = self.builder.get_object("shortcuts")
-
- self.tagpopup = TagContextMenu(self.req, self.app)
+ Gtk.IconTheme.get_for_display(self.get_display()).add_search_path(ICONS_DIR)
def _init_ui_widget(self):
""" Sets the main pane with three trees for active tasks,
actionable tasks (workview), closed tasks and creates
ModifyTagsDialog & Calendar """
# Tasks treeviews
- self.open_pane.add(self.vtree_panes['active'])
- self.actionable_pane.add(self.vtree_panes['workview'])
- self.closed_pane.add(self.vtree_panes['closed'])
-
- tag_completion = TagCompletion(self.req.get_tag_tree())
- self.modifytags_dialog = ModifyTagsDialog(tag_completion, self.req, self.app)
- self.modifytags_dialog.dialog.set_transient_for(self)
- self.deletetags_dialog = DeleteTagsDialog(self.req, self)
+ # self.open_pane.add(self.vtree_panes['active'])
+ # self.actionable_pane.add(self.vtree_panes['workview'])
+ # self.closed_pane.add(self.vtree_panes['closed'])
+
+ quickadd_focus_controller = Gtk.EventControllerFocus()
+ quickadd_focus_controller.connect('enter', self.on_quickadd_focus_in)
+ quickadd_focus_controller.connect('leave', self.on_quickadd_focus_out)
+ self.quickadd_entry.add_controller(quickadd_focus_controller)
+
+ tag_completion = TagCompletion(self.app.ds.tags)
+ self.modifytags_dialog = ModifyTagsDialog(tag_completion, self.app)
+ self.modifytags_dialog.set_transient_for(self)
+
+ self.deletetags_dialog = DeleteTagsDialog(self)
self.calendar = GTGCalendar()
self.calendar.set_transient_for(self)
- self.calendar.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.calendar.connect("date-changed", self.on_date_changed)
def _set_defer_days(self, timer=None):
@@ -287,7 +297,6 @@ def _set_defer_days(self, timer=None):
Set dynamic day labels for the toolbar's task deferring menubutton.
"""
today = datetime.datetime.today()
- dynamic_days_menu_section = self.builder.get_object("defer_menu_dydays_section")
# Day 0 is "Today", day 1 is "Tomorrow",
# so we don't need to calculate the weekday name for those.
@@ -296,44 +305,15 @@ def _set_defer_days(self, timer=None):
translated_weekday_combo = _("In {number_of_days} days — {weekday}").format(
weekday=weekday_name, number_of_days=i + 2)
- action = ''.join(dynamic_days_menu_section.get_item_attribute_value(
+ action = ''.join(self.defer_menu_days_section.get_item_attribute_value(
i,
'action',
GLib.VariantType.new('s')).get_string()
)
replacement_item = Gio.MenuItem.new(translated_weekday_combo, action)
- dynamic_days_menu_section.remove(i)
- dynamic_days_menu_section.insert_item(i, replacement_item)
-
- def init_tags_sidebar(self):
- """
- initializes the tagtree (left area with tags and searches)
- """
- self.tagtree = self.req.get_tag_tree()
- self.tagtreeview = self.tv_factory.tags_treeview(self.tagtree)
- self.tagtreeview.get_selection().connect('changed', self.on_select_tag)
+ self.defer_menu_days_section.remove(i)
+ self.defer_menu_days_section.insert_item(i, replacement_item)
- self.tagtree_gesture_single = Gtk.GestureSingle(widget=self.tagtreeview, button=Gdk.BUTTON_SECONDARY)
- self.tagtree_gesture_single.connect('begin', self.on_tag_treeview_click_begin)
- self.tagtree_key_controller = Gtk.EventControllerKey(widget=self.tagtreeview)
- self.tagtree_key_controller.connect('key-pressed', self.on_tag_treeview_key_press_event)
- self.tagtreeview.connect('node-expanded', self.on_tag_expanded)
- self.tagtreeview.connect('node-collapsed', self.on_tag_collapsed)
-
- self.sidebar_container.add(self.tagtreeview)
-
- for path_t in self.config.get("expanded_tags"):
- # the tuple was stored as a string. we have to reconstruct it
- path = ()
- for p in path_t[1:-1].split(","):
- p = p.strip(" '")
- path += (p, )
- if path[-1] == '':
- path = path[:-1]
- self.tagtreeview.expand_node(path)
-
- # expanding search tag does not work automatically, request it
- self.expand_search_tag()
def _init_about_dialog(self):
"""
@@ -388,74 +368,89 @@ def _init_signal_connections(self):
"""
connects signals on UI elements
"""
- SIGNAL_CONNECTIONS_DIC = {
- "on_edit_done_task": self.on_edit_done_task,
- "on_add_subtask": self.on_add_subtask,
- "on_tagcontext_deactivate": self.on_tagcontext_deactivate,
- "on_quickadd_field_activate": self.on_quickadd_activate,
- "on_quickadd_field_focus_in": self.on_quickadd_focus_in,
- "on_quickadd_field_focus_out": self.on_quickadd_focus_out,
- "on_about_delete": self.on_about_close,
- "on_about_close": self.on_about_close,
- "on_search": self.on_search,
- }
- self.builder.connect_signals(SIGNAL_CONNECTIONS_DIC)
-
# When destroying this window, quit GTG
- self.connect("destroy", self.quit)
+ self.connect("close-request", self.quit)
# Store window position
- self.connect('size-allocate', self.on_size_allocate)
-
- # Active tasks TreeView
- self.vtree_panes['active'].connect('row-activated', self.on_edit_active_task)
- self.vtree_panes['active'].connect('cursor-changed', self.on_cursor_changed)
-
- tsk_treeview_btn_press = self.on_task_treeview_click_begin
- self.active_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['active'], button=Gdk.BUTTON_SECONDARY,
- propagation_phase=Gtk.PropagationPhase.CAPTURE)
- self.active_pane_gesture_single.connect('begin', tsk_treeview_btn_press)
- task_treeview_key_press = self.on_task_treeview_key_press_event
- self.active_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['active'])
- self.active_pane_key_controller.connect('key-pressed', task_treeview_key_press)
- self.vtree_panes['active'].connect('node-expanded', self.on_task_expanded)
- self.vtree_panes['active'].connect('node-collapsed', self.on_task_collapsed)
-
- # Workview tasks TreeView
- self.vtree_panes['workview'].connect('row-activated', self.on_edit_active_task)
- self.vtree_panes['workview'].connect('cursor-changed', self.on_cursor_changed)
-
- tsk_treeview_btn_press = self.on_task_treeview_click_begin
- self.workview_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['workview'], button=Gdk.BUTTON_SECONDARY,
- propagation_phase=Gtk.PropagationPhase.CAPTURE)
- self.workview_pane_gesture_single.connect('begin', tsk_treeview_btn_press)
- task_treeview_key_press = self.on_task_treeview_key_press_event
- self.workview_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['workview'])
- self.workview_pane_key_controller.connect('key-pressed', task_treeview_key_press)
- self.vtree_panes['workview'].set_col_visible('startdate', False)
+ self.connect('notify::default-width', self.on_window_resize)
+ self.connect('notify::default-height', self.on_window_resize)
+
+ # # Active tasks TreeView
+ # tsk_treeview_btn_press = self.on_task_treeview_click_begin
+ # active_pane_gesture_single = Gtk.GestureSingle(
+ # button=Gdk.BUTTON_SECONDARY, propagation_phase=Gtk.PropagationPhase.CAPTURE
+ # )
+ # active_pane_gesture_single.connect('begin', tsk_treeview_btn_press)
+ # task_treeview_key_press = self.on_task_treeview_key_press_event
+ # active_pane_key_controller = Gtk.EventControllerKey()
+ # active_pane_key_controller.connect('key-pressed', task_treeview_key_press)
+ # self.vtree_panes['active'].add_controller(active_pane_gesture_single)
+ # self.vtree_panes['active'].add_controller(active_pane_key_controller)
+ # self.vtree_panes['active'].connect('node-expanded', self.on_task_expanded)
+ # self.vtree_panes['active'].connect('node-collapsed', self.on_task_collapsed)
+
+ # # Workview tasks TreeView
+ # tsk_treeview_btn_press = self.on_task_treeview_click_begin
+ # workview_pane_gesture_single = Gtk.GestureSingle(
+ # button=Gdk.BUTTON_SECONDARY, propagation_phase=Gtk.PropagationPhase.CAPTURE
+ # )
+ # workview_pane_gesture_single.connect('begin', tsk_treeview_btn_press)
+ # task_treeview_key_press = self.on_task_treeview_key_press_event
+ # workview_pane_key_controller = Gtk.EventControllerKey()
+ # workview_pane_key_controller.connect('key-pressed', task_treeview_key_press)
+ # self.vtree_panes['workview'].add_controller(workview_pane_gesture_single)
+ # self.vtree_panes['workview'].add_controller(workview_pane_key_controller)
+ # self.vtree_panes['workview'].set_col_visible('startdate', False)
# Closed tasks Treeview
- self.vtree_panes['closed'].connect('row-activated', self.on_edit_done_task)
+ # self.vtree_panes['closed'].connect('row-activated', self.on_edit_done_task)
# I did not want to break the variable and there was no other
# option except this name:(Nimit)
- clsd_tsk_btn_prs = self.on_closed_task_treeview_click_begin
- self.closed_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['closed'], button=Gdk.BUTTON_SECONDARY,
- propagation_phase=Gtk.PropagationPhase.CAPTURE)
- self.closed_pane_gesture_single.connect('begin', clsd_tsk_btn_prs)
- clsd_tsk_key_prs = self.on_closed_task_treeview_key_press_event
- self.closed_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['closed'])
- self.closed_pane_key_controller.connect('key-pressed', clsd_tsk_key_prs)
- self.vtree_panes['closed'].connect('cursor-changed', self.on_cursor_changed)
+ # clsd_tsk_btn_prs = self.on_closed_task_treeview_click_begin
+ # closed_pane_gesture_single = Gtk.GestureSingle(
+ # button=Gdk.BUTTON_SECONDARY, propagation_phase=Gtk.PropagationPhase.CAPTURE
+ # )
+ # closed_pane_gesture_single.connect('begin', clsd_tsk_btn_prs)
+ # clsd_tsk_key_prs = self.on_closed_task_treeview_key_press_event
+ # closed_pane_key_controller = Gtk.EventControllerKey()
+ # closed_pane_key_controller.connect('key-pressed', clsd_tsk_key_prs)
+ # self.vtree_panes['closed'].add_controller(closed_pane_gesture_single)
+ # self.vtree_panes['closed'].add_controller(closed_pane_key_controller)
+ # self.vtree_panes['closed'].connect('cursor-changed', self.on_cursor_changed)
+ # # Closed tasks Treeview
+ # self.vtree_panes['closed'].connect('row-activated', self.on_edit_done_task)
+ # # I did not want to break the variable and there was no other
+ # # option except this name:(Nimit)
+ # clsd_tsk_btn_prs = self.on_closed_task_treeview_click_begin
+ # self.closed_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['closed'], button=Gdk.BUTTON_SECONDARY,
+ # propagation_phase=Gtk.PropagationPhase.CAPTURE)
+ # self.closed_pane_gesture_single.connect('begin', clsd_tsk_btn_prs)
+ # clsd_tsk_key_prs = self.on_closed_task_treeview_key_press_event
+ # self.closed_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['closed'])
+ # self.closed_pane_key_controller.connect('key-pressed', clsd_tsk_key_prs)
+ # self.vtree_panes['closed'].connect('cursor-changed', self.on_cursor_changed)
b_signals = BackendSignals()
b_signals.connect(b_signals.BACKEND_FAILED, self.on_backend_failed)
b_signals.connect(b_signals.BACKEND_STATE_TOGGLED, self.remove_backend_infobar)
b_signals.connect(b_signals.INTERACTION_REQUESTED, self.on_backend_needing_interaction)
- self.selection = self.vtree_panes['active'].get_selection()
+ # self.selection = self.vtree_panes['active'].get_selection()
# HELPER FUNCTIONS ##########################################################
+ def show_popup_at(self, popup, x, y):
+ rect = Gdk.Rectangle()
+ rect.x = x
+ rect.y = y
+ popup.set_pointing_to(rect)
+ popup.popup()
+
+ def show_popup_at_tree_cursor(self, popup, treeview):
+ _, selected_paths = treeview.get_selection().get_selected_rows()
+ rect = treeview.get_cell_area(selected_paths[0], None)
+ self.show_popup_at(popup, 0, rect.y+2*rect.height)
+
def toggle_search(self, action, param):
"""Callback to toggle search bar."""
@@ -466,31 +461,22 @@ def on_search_toggled(self, widget=None):
self.search_button.set_active(False)
self.searchbar.set_search_mode(False)
self.search_entry.set_text('')
- self.get_selected_tree().unapply_filter(SEARCH_TAG)
else:
self.search_button.set_active(True)
self.searchbar.set_search_mode(True)
self.search_entry.grab_focus()
- def _try_filter_by_query(self, query, refresh: bool = True):
- log.debug("Searching for %r", query)
- vtree = self.get_selected_tree()
- try:
- vtree.apply_filter(SEARCH_TAG, parse_search_query(query),
- refresh=refresh)
- except InvalidQuery as error:
- log.debug("Invalid query %r: %r", query, error)
- vtree.unapply_filter(SEARCH_TAG)
def do_search(self):
"""Perform the actual search and cancel the timeout."""
- self._try_filter_by_query(self.search_entry.get_text())
+ self.get_pane().set_search_query(self.search_entry.get_text())
GLib.source_remove(self.search_timeout)
self.search_timeout = None
+ @Gtk.Template.Callback()
def on_search(self, data):
"""Callback everytime a character is inserted in the search field."""
@@ -505,34 +491,10 @@ def on_search(self, data):
def on_save_search(self, action, param):
query = self.search_entry.get_text()
+ name = re.sub(r'!(?=\w)+', '', query)
- # Try if this is a new search tag and save it correctly
- tag_id = self.req.new_search_tag(query)
-
- # Apply new search right now
- if self.tagtreeview is not None:
- self.select_search_tag(tag_id)
- else:
- self.get_selected_tree().apply_filter(tag_id)
-
- def select_search_tag(self, tag_id):
- tag = self.req.get_tag(tag_id)
- """Select new search in tagsidebar and apply it"""
-
- # Make sure search tag parent is expanded
- # (otherwise selection does not work)
- self.expand_search_tag()
-
- # Get iterator for new search tag
- model = self.tagtreeview.get_model()
- path = self.tagtree.get_paths_for_node(tag.get_id())[0]
- tag_iter = model.my_get_iter(path)
+ self.app.ds.saved_searches.new(name, query)
- # Select only it and apply filters on top of that
- selection = self.tagtreeview.get_selection()
- selection.unselect_all()
- selection.select_iter(tag_iter)
- self.on_select_tag()
def quit(self, widget=None, data=None):
self.app.quit()
@@ -561,67 +523,69 @@ def restore_collapsed_tasks(self, tasks=None):
print(f"Invalid liblarch path {path}")
def restore_state_from_conf(self):
- # Extract state from configuration dictionary
- # if "browser" not in self.config:
- # #necessary to have the minimum width of the tag pane
- # # inferior to the "first run" width
- # self.builder.get_object("hpaned1").set_position(250)
- # return
-
+ # NOTE: for the window state to be restored, this must
+ # be called **before** the window is realized. The size
+ # of the window cannot manually be changed later.
width = self.config.get('width')
height = self.config.get('height')
if width and height:
- self.resize(width, height)
+ self.set_default_size(width, height)
# checks for maximization window
- self.connect('notify::is-maximized', self.on_maximize)
+ self.connect('notify::maximized', self.on_maximize)
if self.config.get("maximized"):
self.maximize()
tag_pane = self.config.get("tag_pane")
- self.sidebar.props.visible = tag_pane
+ self.sidebar_vbox.props.visible = tag_pane
sidebar_width = self.config.get("sidebar_width")
- self.builder.get_object("main_hpanes").set_position(sidebar_width)
- self.builder.get_object("main_hpanes").connect('notify::position',
- self.on_sidebar_width)
+ self.main_hpanes.set_position(sidebar_width)
+ self.main_hpanes.connect('notify::position', self.on_sidebar_width)
# Callbacks for sorting and restoring previous state
- model = self.vtree_panes['active'].get_model()
- model.connect('sort-column-changed', self.on_sort_column_changed)
- sort_column = self.config.get('tasklist_sort_column')
- sort_order = self.config.get('tasklist_sort_order')
+ # model = self.vtree_panes['active'].get_model()
+ # model.connect('sort-column-changed', self.on_sort_column_changed)
+ # sort_column = self.config.get('tasklist_sort_column')
+ # sort_order = self.config.get('tasklist_sort_order')
- if sort_column and sort_order:
- sort_column, sort_order = int(sort_column), int(sort_order)
- model.set_sort_column_id(sort_column, sort_order)
+ # if sort_column and sort_order:
+ # sort_column, sort_order = int(sort_column), int(sort_order)
+ # model.set_sort_column_id(sort_column, sort_order)
- self.restore_collapsed_tasks()
+ # self.restore_collapsed_tasks()
- view_name = PANE_STACK_NAMES_MAP_INVERTED.get(self.config.get('view'),
- PANE_STACK_NAMES_MAP_INVERTED['active'])
- self.stack_switcher.get_stack().set_visible_child_name(view_name)
+ # view_name = PANE_STACK_NAMES_MAP_INVERTED.get(self.config.get('view'),
+ # PANE_STACK_NAMES_MAP_INVERTED['active'])
+ # self.stack_switcher.get_stack().set_visible_child_name(view_name)
- def open_task(req, t):
- """ Open the task if loaded. Otherwise ask for next iteration """
- if req.has_task(t):
- self.app.open_task(t)
- return False
- else:
- return True
+ # def open_task(ds, tid):
+ # """ Open the task if loaded. Otherwise ask for next iteration """
+ # try:
+ # task = ds.tasks.lookup[tid]
+ # self.app.open_task(task)
+ # return False
+ # except KeyError:
+ # return True
- for t in self.config.get("opened_tasks"):
- GLib.idle_add(open_task, self.req, t)
+ # for t in self.config.get("opened_tasks"):
+ # GLib.idle_add(open_task, self.app.ds, t)
- def refresh_all_views(self, timer):
- collapsed = self.config.get("collapsed_tasks")
- for pane in 'active', 'workview', 'closed':
- self.req.get_tasks_tree(pane, False).reset_filters(refresh=False)
- self.reapply_filter()
+ def restore_editor_windows(self):
+ """Restore editor window for tasks."""
+
+ for tid in self.config.get("opened_tasks"):
+ try:
+ task = self.app.ds.tasks.lookup[tid]
+ self.app.open_task(task)
+ except KeyError:
+ log.warning("Could not restore task with id %s", tid)
+
- self.restore_collapsed_tasks(collapsed)
+ def refresh_all_views(self, timer):
+ self.get_pane().refresh()
def find_value_in_treestore(self, store, treeiter, value):
"""Search for value in tree store recursively."""
@@ -653,8 +617,8 @@ def on_sort_column_changed(self, model):
self.config.set('tasklist_sort_column', sort_column)
self.config.set('tasklist_sort_order', sort_order)
- def on_size_allocate(self, widget=None, data=None):
- width, height = self.get_size()
+ def on_window_resize(self, widget=None, gparam=None):
+ width, height = self.get_default_size()
self.config.set('width', width)
self.config.set('height', height)
@@ -667,7 +631,8 @@ def on_about_clicked(self, widget):
"""
self.about.show()
- def on_about_close(self, widget, response):
+ @Gtk.Template.Callback()
+ def on_about_close(self, widget):
"""
close the about dialog
"""
@@ -677,12 +642,13 @@ def on_about_close(self, widget, response):
def on_cursor_changed(self, widget=None):
"""Callback when the treeview's cursor changes."""
- if self.has_any_selection():
- self.defer_btn.set_sensitive(True)
- self.defer_menu_btn.set_sensitive(True)
- else:
- self.defer_btn.set_sensitive(False)
- self.defer_menu_btn.set_sensitive(False)
+ ...
+ # if self.has_any_selection():
+ # self.defer_btn.set_sensitive(True)
+ # self.defer_menu_btn.set_sensitive(True)
+ # else:
+ # self.defer_btn.set_sensitive(False)
+ # self.defer_menu_btn.set_sensitive(False)
def on_tagcontext_deactivate(self, menushell):
self.reset_cursor()
@@ -691,13 +657,13 @@ def _show_main_menu(self, action, param):
"""
Action callback to show the main menu.
"""
- main_menu_btn = self.builder.get_object('main_menu_btn')
+ main_menu_btn = self.main_menu_btn
main_menu_btn.props.active = not main_menu_btn.props.active
def on_sidebar_toggled(self, action, param):
"""Toggle tags sidebar via the action."""
- self.sidebar.props.visible = not self.sidebar.props.visible
+ self.sidebar_vbox.props.visible = not self.sidebar_vbox.props.visible
def _on_sidebar_visible(self, obj, param):
"""Visibility of the sidebar changed."""
@@ -705,18 +671,18 @@ def _on_sidebar_visible(self, obj, param):
assert param.name == 'visible'
visible = obj.get_property(param.name)
self.config.set("tag_pane", visible)
- if visible and not self.tagtreeview:
- self.init_tags_sidebar()
def on_collapse_all_tasks(self, action, param):
"""Collapse all tasks."""
- self.vtree_panes['active'].collapse_all()
+
+ self.get_pane().emit('collapse-all')
def on_expand_all_tasks(self, action, param):
"""Expand all tasks."""
- self.vtree_panes['active'].expand_all()
- def on_task_expanded(self, sender: liblarch_gtk.TreeView, path: str):
+ self.get_pane().emit('expand-all')
+
+ def on_task_expanded(self, sender, path: str):
# For some reason, path is turned from a tuple into a string of a
# tuple
if type(path) is str:
@@ -781,13 +747,13 @@ def focus_quickentry(self, action, param):
def focus_sidebar(self, action, param):
"""Callback to focus the sidebar widget."""
- self.sidebar.props.visible = True
+ self.sidebar_vbox.props.visible = True
self.tagtreeview.grab_focus()
- def on_quickadd_focus_in(self, widget, event):
+ def on_quickadd_focus_in(self, controller):
self.toggle_delete_accel(False)
- def on_quickadd_focus_out(self, widget, event):
+ def on_quickadd_focus_out(self, controller):
self.toggle_delete_accel(True)
def toggle_delete_accel(self, enable_delete_accel):
@@ -797,100 +763,50 @@ def toggle_delete_accel(self, enable_delete_accel):
accels = ['Delete'] if enable_delete_accel else []
self.app.set_accels_for_action('win.delete_task', accels)
- def on_quickadd_activate(self, widget):
+ @Gtk.Template.Callback()
+ def on_quickadd_activate(self, widget) -> None:
""" Add a new task from quickadd toolbar """
- text = str(self.quickadd_entry.get_text())
- text = text.strip()
- if text:
- tags = self.get_selected_tags(nospecial=True)
-
- # We will select quick-added task in browser.
- # This has proven to be quite complex and deserves an explanation.
- # We register a callback on the sorted treemodel that we're
- # displaying, which is a TreeModelSort. When a row gets added,
- # we're notified of it.
- # We have to verify that that row belongs to the task we should
- # select. So, we have to wait for the task to be created, and then
- # wait for its tid to show up (invernizzi)
- def select_next_added_task_in_browser(treemodelsort, path, iter, self):
- # copy() is required because boxed structures are not copied
- # when passed in a callback without transfer
- # See https://bugzilla.gnome.org/show_bug.cgi?id=722899
- iter = iter.copy()
-
- def selecter(treemodelsort, path, iter, self):
- self.__last_quick_added_tid_event.wait()
- treeview = self.vtree_panes['active']
- treemodelsort.disconnect(self.__quick_add_select_handle)
- selection = treeview.get_selection()
- selection.unselect_all()
- # Since we use iter for selection,
- # the task selected is bound to be correct
- selection.select_iter(iter)
-
- # It cannot be another thread than the main gtk thread !
- GLib.idle_add(selecter, treemodelsort, path, iter, self)
-
- data = quick_add.parse(text)
- # event that is set when the new task is created
- self.__last_quick_added_tid_event = threading.Event()
- self.__quick_add_select_handle = \
- self.vtree_panes['active'].get_model().connect(
- "row-inserted", select_next_added_task_in_browser,
- self)
- task = self.req.new_task(newtask=True)
- self.__last_quick_added_tid = task.get_id()
- self.__last_quick_added_tid_event.set()
-
- # Combine tags from selection with tags from parsed text
- data['tags'].update(tags)
-
- if data['title'] != '':
- task.set_title(data['title'])
- task.set_to_keep()
-
- for tag in data['tags']:
- task.add_tag(tag)
-
- task.set_start_date(data['start'])
- task.set_due_date(data['due'])
-
- if data['recurring']:
- task.set_recurring(True, data['recurring'], newtask=True)
-
- self.quickadd_entry.set_text('')
-
- # TODO: New Core
- new_t = self.app.ds.tasks.new(data['title'])
- new_t.date_start = data['start']
- new_t.date_due = data['due']
- new_t.id = task.tid
- self.app.ds.tasks.refresh_lookup_cache()
-
-
- for tag in data['tags']:
- _tag = self.app.ds.tags.new(tag)
- new_t.add_tag(_tag)
-
- # signal the event for the plugins to catch
- GLib.idle_add(self.emit, "task-added-via-quick-add", task.get_id())
- else:
- # if no text is selected, we open the currently selected task
- nids = self.vtree_panes['active'].get_selected_nodes()
- for nid in nids:
- self.app.open_task(nid)
+
+ text = self.quickadd_entry.get_text().strip()
+
+ if not text:
+ tasks = self.get_pane().get_selection()
+ for t in tasks:
+ self.app.open_task(t)
+
+ return
+
+ tags = self.sidebar.selected_tags(names_only=True)
+ data = quick_add.parse(text)
+
+ # Combine tags from selection with tags from parsed text
+ data['tags'].update(tags)
+ self.quickadd_entry.set_text('')
+
+ task = self.app.ds.tasks.new(data['title'])
+ task.date_start = data['start']
+ task.date_due = data['due']
+ self.app.ds.tasks.refresh_lookup_cache()
+
+ #TODO: Add back recurring
+
+ for tag in data['tags']:
+ _tag = self.app.ds.tags.new(tag)
+ task.add_tag(_tag)
+
+ # signal the event for the plugins to catch
+ GLib.idle_add(self.emit, "task-added-via-quick-add", task.id)
+ self.get_pane().select_last()
+
def on_tag_treeview_click_begin(self, gesture, sequence):
"""
deals with mouse click event on the tag tree
"""
- event = Gtk.get_current_event()
+ _, x, y = gesture.get_point(sequence)
log.debug("Received button event #%d at %d, %d",
- event.button, event.x, event.y)
- if event.button.button == 3:
- x = int(event.x)
- y = int(event.y)
- time = event.time
+ gesture.get_current_button(), x, y)
+ if gesture.get_current_button() == 3:
pthinfo = self.tagtreeview.get_path_at_pos(x, y)
if pthinfo is not None:
path, col, cellx, celly = pthinfo
@@ -916,20 +832,19 @@ def on_tag_treeview_click_begin(self, gesture, sequence):
if selected_search is not None:
my_tag = self.req.get_tag(selected_search)
self.tagpopup.set_tag(my_tag)
- self.tagpopup.popup(None, None, None, None, event.button.button, time)
+ self.show_popup_at(self.tagpopup, x, y)
elif len(selected_tags) > 0:
# Then we are looking at single, normal tag rather than
# the special 'All tags' or 'Tasks without tags'. We only
# want to popup the menu for normal tags.
my_tag = self.req.get_tag(selected_tags[0])
self.tagpopup.set_tag(my_tag)
- self.tagpopup.popup(None, None, None, None, event.button.button, time)
+ self.show_popup_at(self.tagpopup, x, y)
else:
self.reset_cursor()
def on_tag_treeview_key_press_event(self, controller, keyval, keycode, state):
keyname = Gdk.keyval_name(keyval)
- event = Gtk.get_current_event()
is_shift_f10 = (keyname == "F10" and state & Gdk.ModifierType.SHIFT_MASK)
if is_shift_f10 or keyname == "Menu":
selected_tags = self.get_selected_tags(nospecial=True)
@@ -939,24 +854,26 @@ def on_tag_treeview_key_press_event(self, controller, keyval, keycode, state):
# popup menu for searches
if selected_search is not None:
self.tagpopup.set_tag(selected_search)
- self.tagpopup.popup(None, None, None, None, 0, event.time)
+ self.show_popup_at_tree_cursor(self.tagpopup, self.tagtreeview)
elif len(selected_tags) > 0:
# Then we are looking at single, normal tag rather than
# the special 'All tags' or 'Tasks without tags'. We only
# want to popup the menu for normal tags.
selected_tag = self.req.get_tag(selected_tags[0])
self.tagpopup.set_tag(selected_tag)
- self.tagpopup.popup(None, None, None, None, 0, event.time)
+ model, titer = self.tagtreeview.get_selection().get_selected()
+ rect = self.tagtreeview.get_cell_area(model.get_path(titer), None)
+ self.show_popup_at_tree_cursor(self.tagpopup, self.tagtreeview)
else:
self.reset_cursor()
return True
if keyname == "Delete":
- self.on_delete_tag_activate(event)
+ self.on_delete_tag_activate()
return True
- def on_delete_tag_activate(self, event):
- tags = self.get_selected_tags()
- self.deletetags_dialog.delete_tags(tags)
+ def on_delete_tag_activate(self, tags=[]):
+ tags = tags or self.get_selected_tags()
+ self.deletetags_dialog.show(tags)
def on_delete_tag(self, event):
tags = self.get_selected_tags()
@@ -977,14 +894,18 @@ def on_delete_tag(self, event):
def on_task_treeview_click_begin(self, gesture, sequence):
""" Pop up context menu on right mouse click in the main
task tree view """
- event = Gtk.get_current_event()
treeview = gesture.get_widget()
+ _, x, y = gesture.get_point(sequence)
log.debug("Received button event #%s at %d,%d",
- event.button, event.x, event.y)
- if event.button.button == 3:
- x = int(event.x)
- y = int(event.y)
- pthinfo = treeview.get_path_at_pos(x, y)
+ gesture.get_current_button(), x, y)
+ if gesture.get_current_button() == 3:
+ # Only when using a filtered treeview (you have selected a specific
+ # tag in tagtree), for some reason the standard coordinates become
+ # wrong and you must convert them.
+ # The original coordinates are still needed to put the popover
+ # in the correct place
+ tx, ty = treeview.convert_widget_to_bin_window_coords(x, y)
+ pthinfo = treeview.get_path_at_pos(tx, ty)
if pthinfo is not None:
path, col, cellx, celly = pthinfo
selection = treeview.get_selection()
@@ -997,24 +918,21 @@ def on_task_treeview_click_begin(self, gesture, sequence):
self.app.action_enabled_changed('add_parent', True)
if not self.have_same_parent():
self.app.action_enabled_changed('add_parent', False)
- self.open_menu.popup_at_pointer(event)
+ self.show_popup_at(self.open_menu, x, y)
def on_task_treeview_key_press_event(self, controller, keyval, keycode, state):
keyname = Gdk.keyval_name(keyval)
- event = Gtk.get_current_event()
is_shift_f10 = (keyname == "F10" and state & Gdk.ModifierType.SHIFT_MASK)
if is_shift_f10 or keyname == "Menu":
- self.open_menu.popup_at_pointer(event)
- return True
+ self.show_popup_at_tree_cursor(self.open_menu, controller.get_widget())
def on_closed_task_treeview_click_begin(self, gesture, sequence):
- event = Gtk.get_current_event()
treeview = gesture.get_widget()
- if event.button.button == 3:
- x = int(event.x)
- y = int(event.y)
- pthinfo = treeview.get_path_at_pos(x, y)
+ _, x, y = gesture.get_point(sequence)
+ if gesture.get_current_button() == 3:
+ tx, ty = treeview.convert_widget_to_bin_window_coords(x, y)
+ pthinfo = treeview.get_path_at_pos(tx, ty)
if pthinfo is not None:
path, col, cellx, celly = pthinfo
@@ -1026,150 +944,103 @@ def on_closed_task_treeview_click_begin(self, gesture, sequence):
treeview.set_cursor(path, col, 0)
treeview.grab_focus()
- self.closed_menu.popup_at_pointer(event)
+ self.show_popup_at(self.closed_menu, x, y)
def on_closed_task_treeview_key_press_event(self, controller, keyval, keycode, state):
keyname = Gdk.keyval_name(keyval)
- event = Gtk.get_current_event()
is_shift_f10 = (keyname == "F10" and state & Gdk.ModifierType.SHIFT_MASK)
if is_shift_f10 or keyname == "Menu":
- self.closed_menu.popup_at_pointer(event)
- return True
+ self.show_popup_at_tree_cursor(self.closed_menu, controller.get_widget())
def on_add_task(self, widget=None):
- tags = [tag for tag in self.get_selected_tags(nospecial=True)]
-
- task = self.req.new_task(tags=tags, newtask=True)
- uid = task.get_id()
-
- # TODO: New core
new_task = self.app.ds.tasks.new()
- new_task.id = uid
- for t in tags:
- new_task.add_tag(self.app.ds.tags.new(t))
+ for tag in self.sidebar.selected_tags():
+ new_task.add_tag(tag)
+
+ self.app.open_task(new_task)
- self.app.open_task(uid, new=True)
def on_add_subtask(self, widget=None):
- uid = self.get_selected_task()
- if uid:
- zetask = self.req.get_task(uid)
- tags = [t.get_name() for t in zetask.get_tags()]
- task = self.req.new_task(tags=tags, newtask=True)
- # task.add_parent(uid)
- zetask.add_child(task.get_id())
- # if the parent task is recurring, its child must be also.
- task.inherit_recursion()
+ pane = self.get_pane()
- # TODO: New core
- t = self.app.ds.tasks.new(parent=uid)
- t.tags = self.app.ds.tasks.get(uid).tags
+ for task in pane.get_selection():
+ new_task = self.app.ds.tasks.new(parent=task.id)
+ new_task.tags = task.tags
+ self.app.open_task(new_task)
+ pane.refresh()
- self.app.open_task(task.get_id(), new=True)
def on_add_parent(self, widget=None):
- selected_tasks = self.get_selected_tasks()
- first_task = self.req.get_task(selected_tasks[0])
- if len(selected_tasks):
- parents = first_task.get_parents()
- if parents:
- # Switch parents
- for p_tid in parents:
- par = self.req.get_task(p_tid)
-
- # TODO: New core
- new_p = self.app.ds.tasks.get(p_tid)
-
- if par.get_status() == Task.STA_ACTIVE:
- new_parent = par.new_subtask()
- nc_parent = self.app.ds.tasks.new(parent=p_tid)
- nc_parent.id = new_parent.tid
-
- for uid_task in selected_tasks:
- # Make sure the task doesn't get deleted
- # while switching parents
- self.req.get_task(uid_task).set_to_keep()
- par.remove_child(uid_task)
- new_parent.add_child(uid_task)
-
- # TODO: New Core
- self.app.ds.tasks.refresh_lookup_cache()
- self.app.ds.tasks.unparent(uid_task, p_tid)
- self.app.ds.tasks.parent(uid_task, nc_parent.id)
+ selection = self.get_pane().get_selection()
+
+ if not selection:
+ return
+
+ parent = selection[0].parent
- else:
- # If the tasks have no parent already, no need to switch parents
- new_parent = self.req.new_task(newtask=True)
- for uid_task in selected_tasks:
- new_parent.add_child(uid_task)
-
- # TODO: New Core
- t = self.app.ds.tasks.new()
- t.id = new_parent.tid
+ # Check all tasks have the same parent
+ if any(t.parent != parent for t in selection):
+ return
+
+ if parent:
+ if parent.status == Status.ACTIVE:
+ new_parent = self.app.ds.tasks.new(parent=parent.id)
+
+ for task in selection:
self.app.ds.tasks.refresh_lookup_cache()
- self.app.ds.tasks.parent(uid_task, new_parent.tid)
+ self.app.ds.tasks.unparent(task.id, parent.id)
+ self.app.ds.tasks.parent(task.id, new_parent.id)
+ else:
+ new_parent = self.app.ds.tasks.new()
+
+ for task in selection:
+ self.app.ds.tasks.refresh_lookup_cache()
+ self.app.ds.tasks.parent(task.id, new_parent.id)
+
+ self.app.open_task(new_parent)
+ self.get_pane().refresh()
- self.app.open_task(new_parent.get_id(), new=True)
def on_edit_active_task(self, widget=None, row=None, col=None):
- tid = self.get_selected_task()
- if tid:
- self.app.open_task(tid)
+ for task in self.get_pane().get_selection():
+ self.app.open_task(task)
def on_edit_done_task(self, widget, row=None, col=None):
tid = self.get_selected_task('closed')
if tid:
self.app.open_task(tid)
+
def on_delete_tasks(self, widget=None, tid=None):
# If we don't have a parameter, then take the selection in the
# treeview
if not tid:
- # tid_to_delete is a [project, task] tuple
- tids_todelete = self.get_selected_tasks()
- if not tids_todelete:
+ tasks_todelete = self.get_pane().get_selection()
+
+ if not tasks_todelete:
return
else:
- tids_todelete = [tid]
+ tasks_todelete = [self.ds.tasks.lookup[tid]]
- log.debug("going to delete %r", tids_todelete)
- self.app.delete_tasks(tids_todelete, self)
+ log.debug("going to delete %r", tasks_todelete)
+ self.app.delete_tasks(tasks_todelete, self)
- # TODO: New core core
- for tid in tids_todelete:
- self.app.ds.tasks.remove(tid)
def update_start_date(self, widget, new_start_date):
- tasks = [self.req.get_task(uid)
- for uid in self.get_selected_tasks()
- if uid is not None]
-
- start_date = Date.parse(new_start_date)
-
- # FIXME:If the task dialog is displayed, refresh its start_date widget
- for task in tasks:
- task.set_start_date(start_date)
-
- # TODO: New core
- for uid in self.get_selected_tasks():
- if uid:
- t = self.app.ds.tasks.get(uid)
- t.date_start = start_date
+ for task in self.get_pane().get_selection():
+ task.date_start = new_start_date
def update_start_to_next_day(self, day_number):
"""Update start date to N days from today."""
- tasks = [self.req.get_task(uid)
- for uid in self.get_selected_tasks()
- if uid is not None]
-
next_day = Date.today() + datetime.timedelta(days=day_number)
- for task in tasks:
- task.set_start_date(next_day)
+ for task in self.get_pane().get_selection():
+ task.date_start = next_day
+
def on_mark_as_started(self, action, param):
self.update_start_date(None, "today")
@@ -1222,21 +1093,10 @@ def on_start_clear(self, action, param):
self.update_start_date(None, None)
def update_due_date(self, widget, new_due_date):
- tasks = [self.req.get_task(uid)
- for uid in self.get_selected_tasks()
- if uid is not None]
-
due_date = Date.parse(new_due_date)
- # FIXME: If the task dialog is displayed, refresh its due_date widget
- for task in tasks:
- task.set_due_date(due_date)
-
- # TODO: New core
- for uid in self.get_selected_tasks():
- if uid:
- t = self.app.ds.tasks.get(uid)
- t.date_due = due_date
+ for task in self.get_pane().get_selection():
+ task.date_due = due_date
def on_set_due_today(self, action, param):
self.update_due_date(None, "today")
@@ -1293,18 +1153,11 @@ def on_set_due_for_specific_date(self, action, param):
self.calendar.show()
def update_recurring(self, recurring, recurring_term):
- tasks = [self.req.get_task(uid)
- for uid in self.get_selected_tasks()
- if uid is not None]
-
- for task in tasks:
+ for task in self.get_pane().get_selection():
task.set_recurring(recurring, recurring_term, True)
def update_toggle_recurring(self):
- tasks = [self.req.get_task(uid)
- for uid in self.get_selected_tasks()
- if uid is not None]
- for task in tasks:
+ for task in self.get_pane().get_selection():
task.toggle_recurring()
def on_set_recurring_every_day(self, action, param):
@@ -1339,9 +1192,52 @@ def on_date_changed(self, calendar):
def on_modify_tags(self, action, params):
"""Open modify tags dialog for selected tasks."""
- tasks = self.get_selected_tasks()
+ tasks = self.get_pane().get_selection()
self.modifytags_dialog.modify_tags(tasks)
+
+ def on_sort_start(self, action, params) -> None:
+ """Callback when changing task sorting."""
+
+ self.get_pane().set_sorter('Start')
+
+
+ def on_sort_due(self, action, params) -> None:
+ """Callback when changing task sorting."""
+
+ self.get_pane().set_sorter('Due')
+
+
+ def on_sort_added(self, action, params) -> None:
+ """Callback when changing task sorting."""
+
+ self.get_pane().set_sorter('Added')
+
+
+ def on_sort_title(self, action, params) -> None:
+ """Callback when changing task sorting."""
+
+ self.get_pane().set_sorter('Title')
+
+
+ def on_sort_modified(self, action, params) -> None:
+ """Callback when changing task sorting."""
+
+ self.get_pane().set_sorter('Modified')
+
+
+ def on_sort_added(self, action, params) -> None:
+ """Callback when changing task sorting."""
+
+ self.get_pane().set_sorter('Added')
+
+
+ def on_sort_tags(self, action, params) -> None:
+ """Callback when changing task sorting."""
+
+ self.get_pane().set_sorter('Tags')
+
+
def close_all_task_editors(self, task_id):
""" Including editors of subtasks """
all_subtasks = []
@@ -1355,103 +1251,20 @@ def trace_subtasks(root):
trace_subtasks(self.req.get_task(task_id))
for task in all_subtasks:
- self.app.close_task(task.get_id())
+ self.app.close_task(task.id)
- def on_mark_as_done(self, widget=None):
- tasks_uid = [uid for uid in self.get_selected_tasks()
- if uid is not None]
- if len(tasks_uid) == 0:
- return
- tasks = [self.req.get_task(uid) for uid in tasks_uid]
- tasks_status = [task.get_status() for task in tasks]
- for uid, task, status in zip(tasks_uid, tasks, tasks_status):
- if status == Task.STA_DONE:
- # Marking as undone
- task.set_status(Task.STA_ACTIVE)
- GObject.idle_add(self.emit, "task-marked-as-not-done", task.get_id())
- # Parents of that task must be updated - not to be shown
- # in workview, update children count, etc.
- for parent_id in task.get_parents():
- parent = self.req.get_task(parent_id)
- parent.modified()
- else:
- task.set_status(Task.STA_DONE)
- self.close_all_task_editors(uid)
- GObject.idle_add(self.emit, "task-marked-as-done", task.get_id())
- # TODO: New core
- for uid in tasks_uid:
- t = self.app.ds.tasks.get(uid)
- t.toggle_active()
+ def on_mark_as_done(self, widget=None):
+ for task in self.get_pane().get_selection():
+ task.toggle_active()
def on_dismiss_task(self, widget=None):
- tasks_uid = [uid for uid in self.get_selected_tasks()
- if uid is not None]
- if len(tasks_uid) == 0:
- return
- tasks = [self.req.get_task(uid) for uid in tasks_uid]
- tasks_status = [task.get_status() for task in tasks]
- for uid, task, status in zip(tasks_uid, tasks, tasks_status):
- if status == Task.STA_DISMISSED:
- task.set_status(Task.STA_ACTIVE)
- else:
- task.set_status(Task.STA_DISMISSED)
- self.close_all_task_editors(uid)
-
- # TODO: New core
- for uid in tasks_uid:
- t = self.app.ds.tasks.get(uid)
- t.toggle_dismiss()
-
+ for task in self.get_pane().get_selection():
+ task.toggle_dismiss()
def on_reopen_task(self, widget=None):
- tasks_uid = [uid for uid in self.get_selected_tasks()
- if uid is not None]
- tasks = [self.req.get_task(uid) for uid in tasks_uid]
- tasks_status = [task.get_status() for task in tasks]
- for uid, task, status in zip(tasks_uid, tasks, tasks_status):
- if status == Task.STA_DONE:
- task.set_status(Task.STA_ACTIVE)
- GObject.idle_add(self.emit, "task-marked-as-not-done", task.get_id())
- # Parents of that task must be updated - not to be shown
- # in workview, update children count, etc.
- for parent_id in task.get_parents():
- parent = self.req.get_task(parent_id)
- parent.modified()
- elif status == Task.STA_DISMISSED:
- task.set_status(Task.STA_ACTIVE)
-
- # TODO: New core
- for uid in tasks_uid:
- t = self.app.ds.tasks.get(uid)
- t.toggle_active()
-
-
- def reapply_filter(self, current_pane: str = None):
- if current_pane is None:
- current_pane = self.get_selected_pane()
- filters = self.get_selected_tags()
- filters.append(current_pane)
- vtree = self.req.get_tasks_tree(name=current_pane, refresh=False)
- # Re-applying search if some search is specified
- search = self.search_entry.get_text()
- if search:
- filters.append(SEARCH_TAG)
- # only resetting filters if the applied filters are different from
- # current ones, leaving a chance for liblarch to make the good call on
- # whether to refilter or not
- if sorted(filters) != sorted(vtree.list_applied_filters()):
- vtree.reset_filters(refresh=False)
- # Browsing and applying filters. For performance optimization, only
- # allowing liblarch to trigger a refresh on last item. This way the
- # refresh is never triggered more than once and we let the possibility
- # to liblarch not to trigger refresh is filters did not change.
- for filter_name in filters:
- is_last = filter_name == filters[-1]
- if filter_name == SEARCH_TAG:
- self._try_filter_by_query(search, refresh=is_last)
- else:
- vtree.apply_filter(filter_name, refresh=is_last)
+ for task in self.get_pane().get_selection():
+ task.toggle_active()
def on_select_tag(self, widget=None, row=None, col=None):
""" Callback for tag(s) selection from left sidebar.
@@ -1473,11 +1286,40 @@ def on_pane_switch(self, obj, pspec):
"""
current_pane = self.get_selected_pane()
self.config.set('view', current_pane)
- self.reapply_filter(current_pane)
+
+ # HACK: We expand all the tasks in the open tab
+ # so their subtasks "exist" when switching
+ # to actionable
+ self.stack_switcher.get_stack().get_first_child().get_first_child().emit('expand-all')
+
+ self.get_pane().set_filter_tags(set(self.sidebar.selected_tags()))
+ self.sidebar.change_pane(current_pane)
+ self.get_pane().sort_btn.set_popover(None)
+ self.get_pane().sort_btn.set_popover(self.sort_menu)
+
+ if search_query := self.search_entry.get_text():
+ self.get_pane().set_search_query(search_query)
+
+ self.notify('is_pane_open')
+ self.notify('is_pane_actionable')
+ self.notify('is_pane_closed')
# PUBLIC METHODS ###########################################################
+ def get_menu(self):
+ """Get the primary application menu"""
+ return self.main_menu
+
+ def get_headerbar(self):
+ """Get the headerbar for the window"""
+ return self.headerbar
+
+ def get_quickadd_pane(self):
+ """Get the quickadd pane"""
+ return self.quickadd_pane
+
def have_same_parent(self):
"""Determine whether the selected tasks have the same parent"""
+
selected_tasks = self.get_selected_tasks()
first_task = self.req.get_task(selected_tasks[0])
parents = first_task.get_parents()
@@ -1503,6 +1345,28 @@ def get_selected_pane(self):
return PANE_STACK_NAMES_MAP[current]
+
+ def get_pane(self):
+ """Get the selected pane."""
+
+ return self.stack_switcher.get_stack().get_visible_child().get_first_child()
+
+
+ @GObject.Property(type=bool, default=True)
+ def is_pane_open(self) -> bool:
+ return self.get_selected_pane() == 'active'
+
+
+ @GObject.Property(type=bool, default=False)
+ def is_pane_actionable(self) -> bool:
+ return self.get_selected_pane() == 'workview'
+
+
+ @GObject.Property(type=bool, default=False)
+ def is_pane_closed(self) -> bool:
+ return self.get_selected_pane() == 'closed'
+
+
def get_selected_tree(self, refresh: bool = False):
return self.req.get_tasks_tree(name=self.get_selected_pane(),
refresh=refresh)
@@ -1620,30 +1484,6 @@ def add_page_to_sidebar_notebook(self, icon, page):
"""
return self._add_page(self.sidebar_notebook, icon, page)
- def add_page_to_main_notebook(self, title, page):
- """Adds a new page tab to the top right main panel. The tab
- will be added as the last tab. Also causes the tabs to be
- shown.
- @param title: Short text to use for the tab label
- @param page: Gtk.Frame-based panel to be added
- """
- return self._add_page(self.main_notebook, Gtk.Label(label=title), page)
-
- def remove_page_from_sidebar_notebook(self, page):
- """Removes a new page tab from the left panel. If this leaves
- only one tab in the notebook, the tab selector will be hidden.
- @param page: Gtk.Frame-based panel to be removed
- """
- return self._remove_page(self.sidebar_notebook, page)
-
- def remove_page_from_main_notebook(self, page):
- """Removes a new page tab from the top right main panel. If
- this leaves only one tab in the notebook, the tab selector will
- be hidden.
- @param page: Gtk.Frame-based panel to be removed
- """
- return self._remove_page(self.main_notebook, page)
-
def hide(self):
""" Hides the task browser """
self.browser_shown = False
@@ -1673,9 +1513,6 @@ def is_active(self):
return self.get_property("is-active") or self.menu.is_visible()
- def get_builder(self):
- return self.builder
-
def get_window(self):
return self
@@ -1738,7 +1575,7 @@ def remove_backend_infobar(self, sender, backend_id):
@param backend_id: the id of the backend which Gtk.Infobar should be
removed.
"""
- backend = self.req.get_backend(backend_id)
+ backend = self.app.ds.get_backend(backend_id)
if not backend or (backend and backend.is_enabled()):
# remove old infobar related to backend_id, if any
if self.vbox_toolbars:
@@ -1771,12 +1608,3 @@ def get_selected_search(self):
if tag.is_search_tag():
return tags[0]
return None
-
- def expand_search_tag(self):
- """ For some unknown reason, search tag is not expanded correctly and
- it must be done manually """
- if self.tagtreeview is not None:
- model = self.tagtreeview.get_model()
- search_iter = model.my_get_iter((SEARCH_TAG, ))
- search_path = model.get_path(search_iter)
- self.tagtreeview.expand_row(search_path, False)
diff --git a/GTG/gtk/browser/modify_tags.py b/GTG/gtk/browser/modify_tags.py
index 8af823ce35..fbd6e8a1ba 100644
--- a/GTG/gtk/browser/modify_tags.py
+++ b/GTG/gtk/browser/modify_tags.py
@@ -21,97 +21,97 @@
from gettext import gettext as _
from GTG.gtk.browser import GnomeConfig
-from GTG.core.tag import parse_tag_list
+@Gtk.Template(filename=GnomeConfig.MODIFYTAGS_UI_FILE)
-class ModifyTagsDialog():
+class ModifyTagsDialog(Gtk.Dialog):
"""
Dialog for batch adding/removal of tags
"""
- def __init__(self, tag_completion, req, app):
- self.req = req
+ __gtype_name__ = "ModifyTagsDialog"
+
+ _tag_entry = Gtk.Template.Child()
+ _apply_to_subtasks_check = Gtk.Template.Child()
+
+ def __init__(self, tag_completion, app):
+ super().__init__()
+
self.app = app
self.tasks = []
- self._init_dialog()
- self.tag_entry.set_completion(tag_completion)
+ self._tag_entry.set_completion(tag_completion)
# Rember values from last time
self.last_tag_entry = _("NewTag")
self.last_apply_to_subtasks = False
- def _init_dialog(self):
- """ Init GtkBuilder .ui file """
- builder = Gtk.Builder()
- builder.add_from_file(GnomeConfig.MODIFYTAGS_UI_FILE)
- builder.connect_signals({
- "on_modifytags_confirm":
- self.on_confirm,
- "on_modifytags_cancel":
- lambda dialog: dialog.hide,
- })
-
- self.tag_entry = builder.get_object("tag_entry")
- self.apply_to_subtasks = builder.get_object("apply_to_subtasks")
- self.dialog = builder.get_object("modifytags_dialog")
+
+ def parse_tag_list(self, text):
+ """ Parse a line of a list of tasks. User can specify if the tag is
+ positive or not by prepending '!'.
+
+ @param text: string entry from user
+ @return: list of tupples (tag, is_positive)
+ """
+
+ result = []
+ for tag in text.split():
+ if tag.startswith('!'):
+ tag = tag[1:]
+ is_positive = False
+ else:
+ is_positive = True
+
+ result.append((tag, is_positive))
+ return result
+
def modify_tags(self, tasks):
""" Show and run dialog for selected tasks """
- if len(tasks) == 0:
+
+ if not tasks:
return
self.tasks = tasks
- self.tag_entry.set_text(self.last_tag_entry)
- self.tag_entry.grab_focus()
- self.apply_to_subtasks.set_active(self.last_apply_to_subtasks)
+ self._tag_entry.set_text(self.last_tag_entry)
+ self._tag_entry.grab_focus()
+ self._apply_to_subtasks_check.set_active(self.last_apply_to_subtasks)
- self.dialog.run()
- self.dialog.hide()
+ self.show()
- self.tasks = []
+ @Gtk.Template.Callback()
+ def on_response(self, widget, response):
+ if response == Gtk.ResponseType.APPLY:
+ self.apply_changes()
+
+ self.hide()
- def on_confirm(self, widget):
+ def apply_changes(self):
""" Apply changes """
- tags = parse_tag_list(self.tag_entry.get_text())
- # If the checkbox is checked, find all subtasks
- if self.apply_to_subtasks.get_active():
- for task_id in self.tasks:
- task = self.req.get_task(task_id)
- # FIXME: Python not reinitialize the default value of its
- # parameter therefore it must be done manually. This function
- # should be refractored # as far it is marked as depricated
- for subtask in task.get_subtasks():
- subtask_id = subtask.get_id()
- if subtask_id not in self.tasks:
- self.tasks.append(subtask_id)
-
- for task_id in self.tasks:
- task = self.req.get_task(task_id)
- for tag, is_positive in tags:
- if is_positive:
- task.add_tag(tag)
- else:
- task.remove_tag(tag)
- task.sync()
+ tags = self.parse_tag_list(self._tag_entry.get_text())
- # TODO: New Core
- for tid in self.tasks:
- t = self.app.ds.tasks.get(tid)
+ # If the checkbox is checked, find all subtasks
+ if self._apply_to_subtasks_check.get_active():
+ for task in self.tasks:
+ for subtask in task.children:
+ if subtask not in self.tasks:
+ self.tasks.append(subtask)
+ for task in self.tasks:
for tag, is_positive in tags:
_tag = self.app.ds.tags.new(tag)
if is_positive:
- t.add_tag(_tag)
+ task.add_tag(_tag)
else:
- t.remove_tag(_tag)
+ task.remove_tag(_tag.name)
self.app.ds.save()
+ self.app.ds.tasks.notify('task_count_no_tags')
# Rember the last actions
- self.last_tag_entry = self.tag_entry.get_text()
- self.last_apply_to_subtasks = self.apply_to_subtasks.get_active()
-# -----------------------------------------------------------------------------
+ self.last_tag_entry = self._tag_entry.get_text()
+ self.last_apply_to_subtasks = self._apply_to_subtasks_check.get_active()
diff --git a/GTG/gtk/browser/search_editor.py b/GTG/gtk/browser/search_editor.py
new file mode 100644
index 0000000000..9327ddc1e1
--- /dev/null
+++ b/GTG/gtk/browser/search_editor.py
@@ -0,0 +1,242 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""
+This module contains the SearchEditor class which is a window that allows the
+user to edit a saved search properties.
+"""
+from gi.repository import GObject, Gtk, Gdk, GdkPixbuf, GLib
+
+import logging
+import random
+from gettext import gettext as _
+from GTG.gtk.browser import GnomeConfig
+
+log = logging.getLogger(__name__)
+
+
+@Gtk.Template(filename=GnomeConfig.SEARCH_EDITOR_UI_FILE)
+class SearchEditor(Gtk.Dialog):
+ """
+ A window to edit certain properties of a saved search.
+ """
+
+ __gtype_name__ = 'GTG_SearchEditor'
+ _emoji_chooser = Gtk.Template.Child('emoji-chooser')
+ _icon_button = Gtk.Template.Child('icon-button')
+ _name_entry = Gtk.Template.Child('name-entry')
+
+ def __init__(self, app, search=None):
+ super().__init__(use_header_bar=1)
+
+ set_icon_shortcut = Gtk.Shortcut.new(
+ Gtk.ShortcutTrigger.parse_string("I"),
+ Gtk.CallbackAction.new(self._set_icon))
+
+ self.add_shortcut(set_icon_shortcut)
+
+ self.app = app
+ self.search = search
+
+ self.set_transient_for(app.browser)
+ self._title_format = self.get_title()
+ self._emoji_chooser.set_parent(self._icon_button)
+
+ self.is_valid = True
+ self._emoji = None
+ self.use_icon = False
+ self._search_name = ''
+
+ self.set_search(search)
+ self.show()
+
+
+ @GObject.Property(type=str, default='')
+ def search_name(self):
+ """The (new) name of the search."""
+
+ return self._search_name
+
+ @search_name.setter
+ def search_name(self, value: str):
+ self._search_name = value
+ self._validate()
+
+ @GObject.Property(type=str, default='')
+ def search_query(self):
+ """The (new) name of the search."""
+
+ return self._search_query
+
+ @search_query.setter
+ def search_query(self, value: str):
+ self._search_query = value
+ self._validate()
+
+ @GObject.Property(type=bool, default=True)
+ def is_valid(self):
+ """
+ Whenever it is valid to apply the changes (like malformed search name).
+ """
+
+ return self._is_valid
+
+ @is_valid.setter
+ def is_valid(self, value: bool):
+ self._is_valid = value
+
+ @GObject.Property(type=bool, default=False)
+ def has_icon(self):
+ """
+ Whenever the search will have an icon.
+ """
+
+ return bool(self._emoji)
+
+ def _validate(self):
+ """
+ Validates the current search preferences.
+ Returns true whenever it passes validation, False otherwise,
+ and modifies the is_valid property appropriately.
+ On failure, the widgets are modified accordingly to show the user
+ why it doesn't accept it.
+ """
+
+ valid = True
+ valid &= self._validate_search_name()
+ self.is_valid = valid
+ return valid
+
+ def _validate_search_name(self):
+ """
+ Validates the current search name.
+ Returns true whenever it passes validation, False otherwise.
+ On failure, the widgets are modified accordingly to show the user
+ why it doesn't accept it.
+ """
+
+ if self.search_name == '':
+ self._name_entry.add_css_class("error")
+ self._name_entry.props.tooltip_text = \
+ _("search name can not be empty")
+ return False
+ else:
+ self._name_entry.remove_css_class("error")
+ self._name_entry.props.tooltip_text = ""
+ return True
+
+ def set_search(self, search):
+ """
+ Set the search to edit.
+ Widgets are updated with the information of the search,
+ the previous information/state is lost.
+ """
+ self.search = search
+ if search is None:
+ return
+
+ icon = search.icon
+ self._set_emoji(self._emoji_chooser, text=icon if icon else '')
+
+ self.search_name = search.name
+ self.search_query = search.query
+ self.set_title(self._title_format % self.search_name)
+
+
+ def do_destroy(self):
+
+ self.app.close_search_editor()
+ super().destroy()
+
+ def _cancel(self):
+ """
+ Cancel button has been clicked, closing the editor window without
+ applying changes.
+ """
+
+ self.destroy()
+
+ def _apply(self):
+ """
+ Apply button has been clicked, applying the settings and closing the
+ editor window.
+ """
+ if self.search is None:
+ log.warning("Trying to apply but no search set, shouldn't happen")
+ self._cancel()
+ return
+
+ if self.has_icon and self._emoji:
+ self.search.icon = self._emoji
+ elif self.has_icon: # Should never happen, but just in case
+ log.warning("Tried to set icon for %r but no icon given",
+ self.search.name)
+ self.search.icon = None
+ else:
+ self.search.icon = None
+
+ if self.search_name != self.search.name:
+ log.debug("Renaming %r → %r", self.search.name, self.search_name)
+ self.search.name = self.search_name
+
+ self.app.ds.saved_searches.model.emit('items-changed', 0, 0, 0)
+ self.destroy()
+
+ # CALLBACKS #####
+ @Gtk.Template.Callback('response')
+ def _response(self, widget: GObject.Object, response: Gtk.ResponseType):
+ if response == Gtk.ResponseType.APPLY:
+ self._apply()
+ else:
+ self._cancel()
+
+
+ @Gtk.Template.Callback('set_icon')
+ def _set_icon(self, widget: GObject.Object, shargs: GLib.Variant = None):
+ """
+ Button to set the icon/emoji has been clicked.
+ """
+ self._emoji_chooser.popup()
+
+ @Gtk.Template.Callback('emoji_set')
+ def _set_emoji(self, widget: GObject.Object, text: str = None):
+ """
+ Callback when an emoji has been inserted.
+ """
+ self._emoji = text if text else None
+
+ if text:
+ self._emoji = text
+ self._icon_button.set_label(text)
+ if label := self._icon_button.get_child():
+ label.set_opacity(1)
+ else:
+ self._emoji = None
+ self._icon_button.set_label('🏷️')
+ if label := self._icon_button.get_child():
+ label.set_opacity(0.4)
+
+ self.notify('has-icon')
+
+ @Gtk.Template.Callback('remove_icon')
+ def _remove_icon(self, widget: GObject.Object):
+ """
+ Callback to remove the icon.
+ """
+
+ self._set_emoji(self._emoji_chooser, text='')
diff --git a/GTG/gtk/browser/sidebar.py b/GTG/gtk/browser/sidebar.py
new file mode 100644
index 0000000000..f23fe08c62
--- /dev/null
+++ b/GTG/gtk/browser/sidebar.py
@@ -0,0 +1,658 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) - The GTG Team
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""Sidebar widgets."""
+
+from gi.repository import Gtk, GObject, Gdk, Gio
+
+from GTG.core.tags import Tag
+from GTG.core.tasks import Task
+from GTG.core.filters import TagEmptyFilter
+from GTG.core.saved_searches import SavedSearch
+from GTG.core.datastore import Datastore
+from GTG.gtk.browser.sidebar_context_menu import TagContextMenu, SearchesContextMenu
+from GTG.gtk.browser.tag_pill import TagPill
+
+
+class TagBox(Gtk.Box):
+ """Box subclass to keep a pointer to the tag object"""
+
+ tag = GObject.Property(type=Tag)
+
+
+class SearchBox(Gtk.Box):
+ """Box subclass to keep a pointer to the search object"""
+
+ search = GObject.Property(type=SavedSearch)
+
+
+def unwrap(row, expected_type):
+ """Find an item in TreeRow widget (sometimes nested)."""
+
+ item = row.get_item()
+
+ while type(item) is not expected_type:
+ item = item.get_item()
+
+ return item
+
+
+# Shorthands
+BIND_FLAGS = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE
+signal_block = GObject.signal_handler_block
+
+
+class Sidebar(Gtk.ScrolledWindow):
+ """The sidebar widget"""
+
+ def __init__(self, app, ds: Datastore, browser):
+
+ super(Sidebar, self).__init__()
+ self.ds = ds
+ self.app = app
+ self.browser = browser
+
+ wrap_box = Gtk.Box()
+ wrap_box.set_orientation(Gtk.Orientation.VERTICAL)
+ wrap_box.set_vexpand(True)
+ wrap_box.set_hexpand(True)
+
+ # -------------------------------------------------------------------------------
+ # General Filters
+ # -------------------------------------------------------------------------------
+
+ self.general_box = Gtk.ListBox()
+ self.general_box.get_style_context().add_class('navigation-sidebar')
+
+ all_count = str(ds.task_count['open']['all'])
+ untag_count = str(ds.task_count['open']['untagged'])
+
+ self.all_btn = self.btn_item('emblem-documents-symbolic', 'All Tasks', 'task_count_all')
+ self.none_btn = self.btn_item('task-past-due-symbolic', 'Tasks with no tags', 'task_count_no_tags')
+
+ self.general_box.append(self.all_btn)
+ self.general_box.append(self.none_btn)
+ wrap_box.append(self.general_box)
+
+ separator = Gtk.Separator()
+ separator.set_sensitive(False)
+ wrap_box.append(separator)
+
+ self.general_box.select_row(self.general_box.get_row_at_index(0))
+ self.box_handle = self.general_box.connect('row-selected', self.on_general_box_selected)
+
+ # -------------------------------------------------------------------------------
+ # Saved Searches Section
+ # -------------------------------------------------------------------------------
+ searches_btn_box = Gtk.Box()
+ searches_button = Gtk.Button()
+ searches_button_label = Gtk.Label()
+ searches_button_label.set_markup('Saved Searches')
+ searches_button_label.set_xalign(0)
+ searches_button.get_style_context().add_class('flat')
+
+ searches_button.set_margin_top(6)
+ searches_button.set_margin_start(6)
+ searches_button.set_margin_end(6)
+
+ searches_icon = Gtk.Image.new_from_icon_name('folder-saved-search-symbolic')
+ searches_icon.set_margin_end(6)
+ searches_btn_box.append(searches_icon)
+ searches_btn_box.append(searches_button_label)
+ searches_button.set_child(searches_btn_box)
+ searches_button.connect('clicked', self.on_search_reveal)
+
+ self.searches_selection = Gtk.SingleSelection.new(ds.saved_searches.model)
+ self.searches_selection.set_can_unselect(True)
+ self.searches_selection.unselect_item(0)
+ self.search_handle = self.searches_selection.connect('selection-changed', self.on_search_selected)
+
+ searches_signals = Gtk.SignalListItemFactory()
+ searches_signals.connect('setup', self.searches_setup_cb)
+ searches_signals.connect('bind', self.searches_bind_cb)
+ searches_signals.connect('unbind', self.searches_unbind_cb)
+
+ searches_view = Gtk.ListView.new(self.searches_selection, searches_signals)
+ searches_view.get_style_context().add_class('navigation-sidebar')
+ searches_view.set_hexpand(True)
+
+ self.searches_revealer = Gtk.Revealer()
+ self.searches_revealer.set_child(searches_view)
+ self.searches_revealer.set_reveal_child(True)
+
+ # -------------------------------------------------------------------------------
+ # Tags Section
+ # -------------------------------------------------------------------------------
+ self.tags_filter = TagEmptyFilter(ds, 'open')
+ self.filtered_tags = Gtk.FilterListModel()
+ self.filtered_tags.set_model(ds.tags.tree_model)
+ self.filtered_tags.set_filter(self.tags_filter)
+
+ self.tag_selection = Gtk.MultiSelection.new(self.filtered_tags)
+ self.tag_handle = self.tag_selection.connect('selection-changed', self.on_tag_selected)
+
+ tags_signals = Gtk.SignalListItemFactory()
+ tags_signals.connect('setup', self.tags_setup_cb)
+ tags_signals.connect('bind', self.tags_bind_cb)
+ tags_signals.connect('unbind', self.tags_unbind_cb)
+
+ view = Gtk.ListView.new(self.tag_selection, tags_signals)
+ view.get_style_context().add_class('navigation-sidebar')
+ view.set_vexpand(True)
+ view.set_hexpand(True)
+
+ view_drop = Gtk.DropTarget.new(Tag, Gdk.DragAction.COPY)
+ view_drop.connect("drop", self.on_toplevel_tag_drop)
+ view.add_controller(view_drop)
+
+ self.revealer = Gtk.Revealer()
+ self.revealer.set_child(view)
+ self.revealer.set_reveal_child(True)
+
+ btn_box = Gtk.Box()
+ button = Gtk.Button()
+ button_label = Gtk.Label()
+ button_label.set_markup('Tags')
+ button_label.set_xalign(0)
+ button.get_style_context().add_class('flat')
+
+ button.set_margin_top(6)
+ button.set_margin_start(6)
+ button.set_margin_end(6)
+
+ tags_icon = Gtk.Image.new_from_icon_name('view-list-symbolic')
+ tags_icon.set_margin_end(6)
+ btn_box.append(tags_icon)
+ btn_box.append(button_label)
+ button.set_child(btn_box)
+ button.connect('clicked', self.on_tag_reveal)
+
+ self.expanders = set()
+
+ # -------------------------------------------------------------------------------
+ # Bring everything together
+ # -------------------------------------------------------------------------------
+
+ wrap_box.append(searches_button)
+ wrap_box.append(self.searches_revealer)
+ wrap_box.append(button)
+ wrap_box.append(self.revealer)
+ self.set_child(wrap_box)
+
+
+ def refresh_tags(self) -> None:
+ """Refresh tags list."""
+
+ self.filtered_tags.items_changed(0, 0, 0)
+ self.tags_filter.changed(Gtk.FilterChange.DIFFERENT)
+
+
+ def change_pane(self, pane: str) -> None:
+ """Change pane for the tag list."""
+
+ self.tags_filter.pane = pane
+ self.tags_filter.changed(Gtk.FilterChange.DIFFERENT)
+
+
+ def on_tag_RMB_click(self, gesture, sequence) -> None:
+ """Callback when right-clicking on a tag."""
+
+ menu = TagContextMenu(self.ds, self.app, gesture.get_widget().tag)
+ menu.set_parent(gesture.get_widget())
+ menu.set_halign(Gtk.Align.START)
+ menu.set_position(Gtk.PositionType.BOTTOM)
+
+ point = gesture.get_point(sequence)
+ rect = Gdk.Rectangle()
+ rect.x = point.x
+ rect.y = point.y
+ menu.set_pointing_to(rect)
+ menu.popup()
+
+
+ def on_searches_RMB_click(self, gesture, sequence) -> None:
+ """Callback when right-clicking on a search."""
+
+ menu = SearchesContextMenu(self.ds, self.app, gesture.get_widget().search)
+ menu.set_parent(gesture.get_widget())
+ menu.set_halign(Gtk.Align.START)
+ menu.set_position(Gtk.PositionType.BOTTOM)
+
+ point = gesture.get_point(sequence)
+ rect = Gdk.Rectangle()
+ rect.x = point.x
+ rect.y = point.y
+ menu.set_pointing_to(rect)
+ menu.popup()
+
+
+ def btn_item(self, icon_name:str, text: str, count_prop: str) -> Gtk.Box:
+ """Generate a button for the main listbox."""
+
+ box = Gtk.Box()
+
+ icon = Gtk.Image.new_from_icon_name(icon_name)
+ icon.set_margin_end(6)
+ box.append(icon)
+
+ label = Gtk.Label()
+ label.set_halign(Gtk.Align.START)
+ label.set_hexpand(True)
+ label.set_text(text)
+ box.append(label)
+
+ count_label = Gtk.Label()
+ count_label.set_halign(Gtk.Align.START)
+ count_label.add_css_class('dim-label')
+ box.append(count_label)
+
+ self.ds.tasks.bind_property(count_prop, count_label, 'label', BIND_FLAGS)
+
+ return box
+
+
+ def tags_setup_cb(self, factory, listitem, user_data=None) -> None:
+ """Setup for a row in the tags listview"""
+
+ box = TagBox()
+ label = Gtk.Label()
+ expander = Gtk.TreeExpander()
+ icon = Gtk.Label()
+ color = TagPill()
+ open_count_label = Gtk.Label()
+ actionable_count_label = Gtk.Label()
+ closed_count_label = Gtk.Label()
+
+ expander.set_margin_end(6)
+ expander.add_css_class('arrow-only-expander')
+ icon.set_margin_end(6)
+ color.set_margin_end(6)
+ color.set_size_request(16, 16)
+
+ color.set_valign(Gtk.Align.CENTER)
+ color.set_halign(Gtk.Align.CENTER)
+ color.set_vexpand(False)
+ label.set_halign(Gtk.Align.START)
+ label.set_hexpand(True)
+
+ open_count_label.set_halign(Gtk.Align.START)
+ open_count_label.add_css_class('dim-label')
+ open_count_label.set_text('0')
+
+ actionable_count_label.set_halign(Gtk.Align.START)
+ actionable_count_label.add_css_class('dim-label')
+ actionable_count_label.set_text('0')
+
+ closed_count_label.set_halign(Gtk.Align.START)
+ closed_count_label.add_css_class('dim-label')
+ closed_count_label.set_text('0')
+
+
+ # Drag ...
+ source = Gtk.DragSource()
+ source.connect('prepare', self.prepare)
+ source.connect('drag-begin', self.drag_begin)
+ source.connect('drag-end', self.drag_end)
+ box.add_controller(source)
+
+ # ... and drop
+ drop = Gtk.DropTarget.new(Tag, Gdk.DragAction.COPY)
+ drop.connect('drop', self.drag_drop)
+ drop.connect('enter', self.drop_enter)
+ box.add_controller(drop)
+
+ task_drop = Gtk.DropTarget.new(Task, Gdk.DragAction.COPY)
+ task_drop.connect('drop', self.task_drag_drop)
+ task_drop.connect('enter', self.drop_enter)
+ box.add_controller(task_drop)
+
+ multi_task_drop = Gtk.DropTarget.new(Gio.ListModel, Gdk.DragAction.COPY)
+ multi_task_drop.connect('drop', self.multi_task_drag_drop)
+ multi_task_drop.connect('enter', self.drop_enter)
+ box.add_controller(multi_task_drop)
+
+
+ box.append(expander)
+ box.append(color)
+ box.append(icon)
+ box.append(label)
+ box.append(open_count_label)
+ box.append(actionable_count_label)
+ box.append(closed_count_label)
+ listitem.set_child(box)
+
+ # Right click event controller
+ tags_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY)
+ tags_RMB_controller.connect('begin', self.on_tag_RMB_click)
+ box.add_controller(tags_RMB_controller)
+
+ self.expanders.add(expander)
+
+
+ def tags_bind_cb(self, signallistitem, listitem, user_data=None) -> None:
+ """Bind properties for a specific row in the tags listview"""
+
+ box = listitem.get_child()
+ expander = box.get_first_child()
+ color = expander.get_next_sibling()
+ icon = color.get_next_sibling()
+ label = icon.get_next_sibling()
+
+ open_count_label = label.get_next_sibling()
+ actionable_count_label = open_count_label.get_next_sibling()
+ closed_count_label = actionable_count_label.get_next_sibling()
+
+ item = unwrap(listitem, Tag)
+
+ box.props.tag = item
+ expander.set_list_row(listitem.get_item())
+
+ listitem.bindings = [
+ item.bind_property('name', label, 'label', BIND_FLAGS),
+ item.bind_property('icon', icon, 'label', BIND_FLAGS),
+ item.bind_property('color', color, 'color_list', BIND_FLAGS),
+
+ item.bind_property('has_icon', color, 'visible', BIND_FLAGS, lambda b, v: not v),
+ item.bind_property('has_icon', icon, 'visible', BIND_FLAGS),
+
+ item.bind_property('task_count_open', open_count_label, 'label', BIND_FLAGS),
+ item.bind_property('task_count_actionable', actionable_count_label, 'label', BIND_FLAGS),
+ item.bind_property('task_count_closed', closed_count_label, 'label', BIND_FLAGS),
+ ]
+
+ self.browser.bind_property('is_pane_open', open_count_label, 'visible', BIND_FLAGS)
+ self.browser.bind_property('is_pane_actionable', actionable_count_label, 'visible', BIND_FLAGS)
+ self.browser.bind_property('is_pane_closed', closed_count_label, 'visible', BIND_FLAGS)
+
+
+ if item.parent:
+ parent = item
+ depth = 0
+
+ while parent.parent:
+ depth += 1
+ parent = parent.parent
+
+ box.set_margin_start((18 * depth) + 16)
+ else:
+ box.set_margin_start(18)
+
+ if not item.children:
+ expander.set_visible(False)
+ else:
+ expander.set_visible(True)
+
+
+ def tags_unbind_cb(self, signallistitem, listitem, user_data=None) -> None:
+ """Clean up bindings made in tags_bind_cb"""
+ for binding in listitem.bindings:
+ binding.unbind()
+
+
+ def unselect_tags(self) -> None:
+ """Clear tags selection"""
+
+ with signal_block(self.tag_selection, self.tag_handle):
+ self.tag_selection.unselect_all()
+
+
+ def unselect_searches(self) -> None:
+ """Clear selection for saved searches"""
+
+ with signal_block(self.searches_selection, self.search_handle):
+ search_id = self.searches_selection.get_selected()
+ self.searches_selection.unselect_item(search_id)
+
+
+ def unselect_general_box(self) -> None:
+ """Clear selection for saved searches"""
+
+ with signal_block(self.general_box, self.box_handle):
+ self.general_box.unselect_all()
+
+
+ def on_general_box_selected(self, listbox, user_data=None):
+ """Callback when clicking on a row in the general listbox"""
+
+ self.unselect_tags()
+ self.unselect_searches()
+ index = listbox.get_selected_row().get_index()
+ self.app.browser.get_pane().emit('expand-all')
+
+ if index == 0:
+ self.app.browser.get_pane().set_filter_tags()
+ elif index == 1:
+ self.app.browser.get_pane().set_filter_notags()
+
+
+ def on_search_selected(self, model, position, user_data=None):
+ """Callback when selecting a saved search"""
+
+ self.unselect_tags()
+ self.unselect_general_box()
+
+ item = model.get_item(position)
+ self.app.browser.get_pane().emit('expand-all')
+ self.app.browser.get_pane().set_search_query(item.query)
+
+
+ def selected_tags(self, names_only: bool = False) -> list:
+ """Get a list of selected tags"""
+
+ selection = self.tag_selection.get_selection()
+ result, iterator, _ = Gtk.BitsetIter.init_first(selection)
+ selected = []
+
+ while iterator.is_valid():
+ val = iterator.get_value()
+ item = unwrap(self.tag_selection.get_item(val), Tag)
+ selected.append(item.name if names_only else item)
+ iterator.next()
+
+ return selected
+
+
+ def on_tag_selected(self, model, position, n_items, user_data=None):
+ """Callback when selecting one or more tags"""
+
+ self.unselect_general_box()
+ self.unselect_searches()
+
+ self.app.browser.get_pane().emit('expand-all')
+ self.app.browser.get_pane().set_filter_tags(set(self.selected_tags()))
+
+
+ def on_tag_reveal(self, event) -> None:
+ """Callback for clicking on the tags title button (revealer)."""
+
+ self.revealer.set_reveal_child(not self.revealer.get_reveal_child())
+
+
+ def on_search_reveal(self, event) -> None:
+ """Callback for clicking on the search title button (revealer)."""
+
+ self.searches_revealer.set_reveal_child(not self.searches_revealer.get_reveal_child())
+
+
+ def searches_setup_cb(self, factory, listitem, user_data=None) -> None:
+ """Setup for a row in the saved searches listview"""
+
+ box = SearchBox()
+ label = Gtk.Label()
+ icon = Gtk.Label()
+
+ icon.set_margin_end(6)
+ label.set_halign(Gtk.Align.START)
+ label.set_hexpand(True)
+
+ box.set_margin_start(18)
+
+ box.append(icon)
+ box.append(label)
+ listitem.set_child(box)
+
+ searches_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY)
+ searches_RMB_controller.connect('begin', self.on_searches_RMB_click)
+ box.add_controller(searches_RMB_controller)
+
+
+ def searches_bind_cb(self, signallistitem, listitem, user_data=None) -> None:
+ """Bind properties for a specific row in the searches listview"""
+
+ icon = listitem.get_child().get_first_child()
+ label = icon.get_next_sibling()
+ box = listitem.get_child()
+
+ item = unwrap(listitem, SavedSearch)
+ box.search = item
+
+ listitem.bindings = [
+ item.bind_property('name', label, 'label', BIND_FLAGS),
+ item.bind_property('icon', icon, 'label', BIND_FLAGS),
+ ]
+
+ def searches_unbind_cb(self, signallistitem, listitem, user_data=None) -> None:
+ """Clean up bindings made in searches_bind_cb"""
+ for binding in listitem.bindings:
+ binding.unbind()
+ listitem.bindings.clear()
+
+
+ # -------------------------------------------------------------------------------------------
+ # Drag and drop
+ # -------------------------------------------------------------------------------------------
+
+ def prepare(self, source, x, y):
+ """Callback to prepare for the DnD operation"""
+
+ # Get content from source
+ data = source.get_widget().props.tag
+
+ # Set it as content provider
+ content = Gdk.ContentProvider.new_for_value(data)
+
+ return content
+
+
+ def drag_begin(self, source, drag):
+ """Callback when DnD beings"""
+
+ source.get_widget().set_opacity(0.25)
+ icon = Gtk.DragIcon.get_for_drag(drag)
+ frame = Gtk.Frame()
+ picture = Gtk.Picture.new_for_paintable(
+ Gtk.WidgetPaintable.new(source.get_widget()))
+
+ frame.set_child(picture)
+ icon.set_child(frame)
+
+
+ def drag_end(self, source, drag, unused):
+ """Callback when DnD ends"""
+
+ if source.get_widget():
+ source.get_widget().set_opacity(1)
+
+
+ def check_parent(self, value, target) -> bool:
+ """Check to parenting a parent to its own children"""
+
+ item = target
+ while item.parent:
+ if item.parent == value:
+ return False
+
+ item = item.parent
+
+ return True
+
+
+ def drag_drop(self, target, value, x, y):
+ """Callback when dropping onto a target"""
+
+ dropped = target.get_widget().props.tag
+
+ if not self.check_parent(value, dropped):
+ return
+
+ if value.parent:
+ self.ds.tags.unparent(value.id, value.parent.id)
+
+ self.ds.tags.parent(value.id, dropped.id)
+ self.ds.tags.tree_model.emit('items-changed', 0, 0, 0)
+
+
+ def drop_enter(self, target, x, y, user_data=None):
+ """Callback when the mouse hovers over the drop target."""
+
+ expander = target.get_widget().get_first_child()
+
+ if target.get_widget().tag.children:
+ expander.activate_action('listitem.expand')
+
+ # There's a funny bug in here. If the expansion of the row
+ # makes the window larger, Gtk won't recognize the new drop areas
+ # and will think you're dragging outside the window.
+
+ return Gdk.DragAction.COPY
+
+
+ def task_drag_drop(self, target, task, x, y):
+ """Callback when dropping onto a target"""
+
+ tag = target.get_widget().props.tag
+ task.add_tag(tag)
+ self.notify_task(task)
+
+ self.ds.tasks.notify('task_count_no_tags')
+
+
+ def multi_task_drag_drop(self, target, tasklist, x, y):
+ """Callback when dropping onto a target"""
+
+ for task in list(tasklist):
+ tag = target.get_widget().props.tag
+ task.add_tag(tag)
+ self.notify_task(task)
+
+ self.ds.tasks.notify('task_count_no_tags')
+
+
+ def notify_task(self, task: Task) -> None:
+ """Notify that tasks props have changed."""
+
+ task.notify('row_css')
+ task.notify('icons')
+ task.notify('tag_colors')
+
+
+ def on_toplevel_tag_drop(self, drop_target, tag, x, y):
+ if tag.parent:
+ self.ds.tags.unparent(tag.id, tag.parent.id)
+
+ try:
+ for expander in self.expanders:
+ expander.activate_action('listitem.toggle-expand')
+ expander.activate_action('listitem.toggle-expand')
+ except RuntimeError:
+ pass
+
+ self.ds.tags.tree_model.emit('items-changed', 0, 0, 0)
+ return True
+ else:
+ return False
diff --git a/GTG/gtk/browser/sidebar_context_menu.py b/GTG/gtk/browser/sidebar_context_menu.py
new file mode 100644
index 0000000000..997e8e9dee
--- /dev/null
+++ b/GTG/gtk/browser/sidebar_context_menu.py
@@ -0,0 +1,119 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""
+tag_context_menu:
+Implements a context (pop-up) menu for the tag item in the sidebar.
+Right now it is just a void shell It is supposed to become a more generic
+sidebar context for all kind of item displayed there.
+Also, it is supposed to handle more complex menus (with non-std widgets,
+like a color picker)
+"""
+
+from gi.repository import Gtk, Gio
+
+from gettext import gettext as _
+from GTG.gtk.colors import generate_tag_color, color_add, color_remove
+from GTG.gtk.browser import GnomeConfig
+
+
+class TagContextMenu(Gtk.PopoverMenu):
+ """Context menu fo the tag i the sidebar"""
+
+ def __init__(self, ds, app, tag):
+ super().__init__(has_arrow=False)
+ self.tag = tag
+ self.app = app
+ self.ds = ds
+
+ actions = [
+ ("edit_tag", self.on_mi_cc_activate),
+ ("generate_tag_color", self.on_mi_ctag_activate),
+ ("delete_tag", lambda w, a, p : self.app.browser.on_delete_tag_activate([self.tag]))
+ ]
+
+ for action_disc in actions:
+ self.install_action(
+ ".".join(["tags_popup", action_disc[0]]), None, action_disc[1])
+
+ # Build up the menu
+ self.build_menu()
+
+
+ def build_menu(self):
+ """Build up the widget"""
+ if self.tag:
+ menu_builder = Gtk.Builder()
+ menu_builder.add_from_file(GnomeConfig.MENUS_UI_FILE)
+ menu_model = menu_builder.get_object("tag_context_menu")
+ self.mi_del_tag = Gio.MenuItem.new(_("Delete"), "tags_popup.delete_tag")
+ menu_model.append_item(self.mi_del_tag)
+
+ self.set_menu_model(menu_model)
+
+
+ # CALLBACKS ###############################################################
+ def on_mi_cc_activate(self, widget, action_name, param):
+ """Callback: show the tag editor upon request"""
+
+ self.app.open_tag_editor(self.tag)
+
+
+ def on_mi_ctag_activate(self, widget, action_name, param):
+
+ self.tag.color = self.ds.tags.generate_color()
+ self.ds.notify_tag_change(self.tag)
+
+
+class SearchesContextMenu(Gtk.PopoverMenu):
+ """Context menu fo the tag i the sidebar"""
+
+ def __init__(self, ds, app, search):
+ super().__init__(has_arrow=False)
+ self.search = search
+ self.app = app
+ self.ds = ds
+
+ actions = [
+ ("edit_search", lambda w, a, p: app.open_search_editor(search)),
+ ("delete_search", lambda w, a, p : ds.saved_searches.remove(self.search.id))
+ ]
+
+ for action_disc in actions:
+ self.install_action(
+ ".".join(["search_popup", action_disc[0]]), None, action_disc[1])
+
+ # Build up the menu
+ self.build_menu()
+
+
+ def build_menu(self):
+ """Build up the widget"""
+
+ menu_builder = Gtk.Builder()
+ menu_builder.add_from_file(GnomeConfig.MENUS_UI_FILE)
+ menu_model = menu_builder.get_object("search_context_menu")
+
+ self.set_menu_model(menu_model)
+
+
+ # CALLBACKS ###############################################################
+ def on_mi_cc_activate(self, widget, action_name, param):
+ """Callback: show the tag editor upon request"""
+
+ # self.app.open_tag_editor(self.tag)
\ No newline at end of file
diff --git a/GTG/gtk/browser/tag_context_menu.py b/GTG/gtk/browser/tag_context_menu.py
deleted file mode 100644
index 47d6084d4f..0000000000
--- a/GTG/gtk/browser/tag_context_menu.py
+++ /dev/null
@@ -1,99 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-"""
-tag_context_menu:
-Implements a context (pop-up) menu for the tag item in the sidebar.
-Right now it is just a void shell It is supposed to become a more generic
-sidebar context for all kind of item displayed there.
-Also, it is supposed to handle more complex menus (with non-std widgets,
-like a color picker)
-"""
-
-from gi.repository import Gtk
-
-from gettext import gettext as _
-from GTG.gtk.colors import generate_tag_color, color_add, color_remove
-
-
-class TagContextMenu(Gtk.Menu):
- """Context menu fo the tag i the sidebar"""
-
- def __init__(self, req, app, tag=None):
- super().__init__()
- self.req = req
- self.app = app
- self.tag = tag
- # Build up the menu
- self.set_tag(tag)
- self.__build_menu()
-
- def __build_menu(self):
- """Build up the widget"""
- # Reset the widget
- for i in self:
- self.remove(i)
- i.destroy()
- if self.tag is not None:
- # Color chooser FIXME: SHOULD BECOME A COLOR PICKER
- self.mi_cc = Gtk.MenuItem()
- self.mi_cc.set_label(_("Edit..."))
- self.append(self.mi_cc)
- self.mi_cc.connect('activate', self.on_mi_cc_activate)
-
- self.mi_ctag = Gtk.MenuItem()
- self.mi_ctag.set_label(_("Generate Color"))
- self.append(self.mi_ctag)
- self.mi_ctag.connect('activate', self.on_mi_ctag_activate)
-
- if self.tag.is_search_tag():
- self.mi_del = Gtk.MenuItem()
- self.mi_del.set_label(_("Delete"))
- self.append(self.mi_del)
- self.mi_del.connect('activate', self.on_mi_del_activate)
- else:
- self.mi_del_tag = Gtk.MenuItem()
- self.mi_del_tag.set_label(_("Delete"))
- self.append(self.mi_del_tag)
- self.mi_del_tag.connect('activate', self.app.browser.on_delete_tag_activate)
-
- # Make it visible
- self.show_all()
-
- # PUBLIC API ##############################################################
- def set_tag(self, tag):
- """Update the context menu items using the tag attributes."""
- self.tag = tag
- self.__build_menu()
-
- # CALLBACKS ###############################################################
- def on_mi_cc_activate(self, widget):
- """Callback: show the tag editor upon request"""
- self.app.open_tag_editor(self.tag)
-
- def on_mi_ctag_activate(self, widget):
- random_color = generate_tag_color()
- present_color = self.tag.get_attribute('color')
- if present_color is not None:
- color_remove(present_color)
- self.tag.set_attribute('color', random_color)
- color_add(random_color)
-
- def on_mi_del_activate(self, widget):
- """ delete a selected search """
- self.req.remove_tag(self.tag.get_name())
diff --git a/GTG/gtk/browser/tag_editor.py b/GTG/gtk/browser/tag_editor.py
index 30bf78d26f..03307416c7 100644
--- a/GTG/gtk/browser/tag_editor.py
+++ b/GTG/gtk/browser/tag_editor.py
@@ -20,7 +20,7 @@
This module contains the TagEditor class which is a window that allows the
user to edit a tag properties.
"""
-from gi.repository import GObject, Gtk, Gdk, GdkPixbuf
+from gi.repository import GObject, Gtk, Gdk, GdkPixbuf, GLib
import logging
import random
@@ -32,29 +32,35 @@
@Gtk.Template(filename=GnomeConfig.TAG_EDITOR_UI_FILE)
-class TagEditor(Gtk.Window):
+class TagEditor(Gtk.Dialog):
"""
A window to edit certain properties of an tag.
"""
__gtype_name__ = 'GTG_TagEditor'
- _emoji_entry = Gtk.Template.Child('emoji-entry')
+ _emoji_chooser = Gtk.Template.Child('emoji-chooser')
_icon_button = Gtk.Template.Child('icon-button')
_name_entry = Gtk.Template.Child('name-entry')
- def __init__(self, req, app, tag=None):
- super().__init__()
+ def __init__(self, app, tag=None):
+ super().__init__(use_header_bar=1)
+
+ set_icon_shortcut = Gtk.Shortcut.new(
+ Gtk.ShortcutTrigger.parse_string("I"),
+ Gtk.CallbackAction.new(self._set_icon))
+
+ self.add_shortcut(set_icon_shortcut)
- self.req = req
self.app = app
self.tag = tag
+ self.set_transient_for(app.browser)
self._title_format = self.get_title()
+ self._emoji_chooser.set_parent(self._icon_button)
- self._emoji_entry_changed_id = self._emoji_entry.connect(
- 'changed', self._set_emoji)
-
- self.tag_rgba = RGBA(1.0, 1.0, 1.0, 1.0)
+ self.tag_rgba = Gdk.RGBA()
+ (self.tag_rgba.red, self.tag_rgba.green,
+ self.tag_rgba.blue, self.tag_rgba.alpha) = 1.0, 1.0, 1.0, 1.0
self.tag_name = ''
self.tag_is_actionable = True
self.is_valid = True
@@ -62,29 +68,34 @@ def __init__(self, req, app, tag=None):
self.use_icon = False
self.set_tag(tag)
- self.show_all()
+ self.show()
@GObject.Property(type=bool, default=False)
def has_color(self):
"""Whenever the tag has a color."""
+
return self._has_color
@has_color.setter
def has_color(self, value: bool):
+
self._has_color = value
@GObject.Property(type=Gdk.RGBA)
def tag_rgba(self):
"""The color of the tag. Alpha is ignored."""
+
return self._tag_rgba
@tag_rgba.setter
def tag_rgba(self, value: Gdk.RGBA):
+
self._tag_rgba = value
@GObject.Property(type=str, default='')
def tag_name(self):
"""The (new) name of the tag."""
+
return self._tag_name
@tag_name.setter
@@ -97,6 +108,7 @@ def tag_is_actionable(self):
"""
Whenever the tag should show up in the actionable tab.
"""
+
return self._tag_is_actionable
@tag_is_actionable.setter
@@ -108,6 +120,7 @@ def is_valid(self):
"""
Whenever it is valid to apply the changes (like malformed tag name).
"""
+
return self._is_valid
@is_valid.setter
@@ -119,17 +132,8 @@ def has_icon(self):
"""
Whenever the tag will have an icon.
"""
- return bool(self._emoji)
- def _reset_emoji_entry(self):
- """
- The emoji entry should stay clear in order to function properly.
- When something is being inserted, then it should be cleared after
- either starting editing a new tag, selected one, or otherwise changed.
- """
- with GObject.signal_handler_block(self._emoji_entry,
- self._emoji_entry_changed_id):
- self._emoji_entry.set_text('')
+ return bool(self._emoji)
def _validate(self):
"""
@@ -139,6 +143,7 @@ def _validate(self):
On failure, the widgets are modified accordingly to show the user
why it doesn't accept it.
"""
+
valid = True
valid &= self._validate_tag_name()
self.is_valid = valid
@@ -151,19 +156,17 @@ def _validate_tag_name(self):
On failure, the widgets are modified accordingly to show the user
why it doesn't accept it.
"""
- # TODO: Possibly add more restrictions.
+
if self.tag_name == '':
- self._name_entry.set_icon_from_icon_name(
- Gtk.EntryIconPosition.SECONDARY, Gtk.STOCK_DIALOG_ERROR)
- self._name_entry.props.secondary_icon_tooltip_text = \
+ self._name_entry.add_css_class("error")
+ self._name_entry.props.tooltip_text = \
_("Tag name can not be empty")
return False
else:
- self._name_entry.set_icon_from_icon_name(
- Gtk.EntryIconPosition.SECONDARY, None)
+ self._name_entry.remove_css_class("error")
+ self._name_entry.props.tooltip_text = ""
return True
- # PUBLIC API #####
def set_tag(self, tag):
"""
Set the tag to edit.
@@ -174,73 +177,91 @@ def set_tag(self, tag):
if tag is None:
return
- icon = tag.get_attribute('icon')
- self._set_emoji(self._emoji_entry, text=icon if icon else '')
+ icon = tag.icon
+ self._set_emoji(self._emoji_chooser, text=icon if icon else '')
- self.tag_name = tag.get_friendly_name()
+ self.tag_name = tag.name
self.set_title(self._title_format % ('@' + self.tag_name,))
- rgba = RGBA(1.0, 1.0, 1.0, 1.0)
- if color := tag.get_attribute('color'):
+ rgba = Gdk.RGBA()
+ rgba.red, rgba.green, rgba.blue, rgba.alpha = 1.0, 1.0, 1.0, 1.0
+
+ if color := tag.color:
+ color = '#' + color if not color.startswith('#') else color
if not rgba.parse(color):
log.warning("Failed to parse tag color for %r: %r",
- tag.get_name(), color)
+ tag.name, color)
+
self.has_color = bool(color)
self.tag_rgba = rgba
- self.tag_is_actionable = \
- self.tag.get_attribute("nonactionable") != "True"
+ self.tag_is_actionable = self.tag.actionable
def do_destroy(self):
+
self.app.close_tag_editor()
super().destroy()
- # CALLBACKS #####
- @Gtk.Template.Callback('cancel')
- def _cancel(self, widget: GObject.Object):
+ def _cancel(self):
"""
Cancel button has been clicked, closing the editor window without
applying changes.
"""
+
self.destroy()
- @Gtk.Template.Callback('apply')
- def _apply(self, widget: GObject.Object):
+ def _apply(self):
"""
Apply button has been clicked, applying the settings and closing the
editor window.
"""
if self.tag is None:
log.warning("Trying to apply but no tag set, shouldn't happen")
- self._cancel(widget)
+ self._cancel()
return
if self.has_icon and self._emoji:
- self.tag.set_attribute('icon', self._emoji)
+ self.tag.icon = self._emoji
elif self.has_icon: # Should never happen, but just in case
log.warning("Tried to set icon for %r but no icon given",
- self.tag.get_name())
- self.tag.del_attribute('icon')
+ self.tag.name)
+ self.tag.icon = None
else:
- self.tag.del_attribute('icon')
+ self.tag.icon = None
if self.has_color:
color = rgb_to_hex(self.tag_rgba)
color_add(color)
- self.tag.set_attribute('color', color)
+ self.tag.color = color
else:
- if tag_color := self.tag.get_attribute('color'):
+ if tag_color := self.tag.color:
color_remove(tag_color)
- self.tag.del_attribute('color')
- self.tag.set_attribute('nonactionable', str(not self.tag_is_actionable))
+ self.tag.color = None
+
+ self.tag.actionable = self.tag_is_actionable
- if self.tag_name != self.tag.get_friendly_name():
- log.debug("Renaming %r → %r", self.tag.get_name(), self.tag_name)
- self.req.rename_tag(self.tag.get_name(), self.tag_name)
- self.tag = self.req.get_tag(self.tag_name)
+ if self.tag_name != self.tag.name:
+ log.debug("Renaming %r → %r", self.tag.name, self.tag_name)
+ for t in self.app.ds.tasks.lookup.values():
+ t.rename_tag(self.tag.name, self.tag_name)
+
+ self.tag.name = self.tag_name
+
+ self.app.ds.refresh_task_count()
+ self.app.ds.refresh_task_for_tag(self.tag)
+ self.app.ds.notify_tag_change(self.tag)
+ self.app.browser.sidebar.refresh_tags()
self.destroy()
+ # CALLBACKS #####
+ @Gtk.Template.Callback('response')
+ def _response(self, widget: GObject.Object, response: Gtk.ResponseType):
+ if response == Gtk.ResponseType.APPLY:
+ self._apply()
+ else:
+ self._cancel()
+
@Gtk.Template.Callback('random_color')
def _random_color(self, widget: GObject.Object):
"""
@@ -248,38 +269,34 @@ def _random_color(self, widget: GObject.Object):
with an random color.
"""
self.has_color = True
- self.tag_rgba = random_color()
-
+ color = self.app.ds.tags.generate_color()
+ c = Gdk.RGBA()
+ c.red, c.green, c.blue, c.alpha = 1.0, 1.0, 1.0, 1.0
+ c.parse('#' + color)
+ self.tag_rgba = c
+
@Gtk.Template.Callback('activate_color')
def _activate_color(self, widget: GObject.Object):
"""
Enable using the selected color because a color has been selected.
"""
+
self.has_color = True
@Gtk.Template.Callback('set_icon')
- def _set_icon(self, widget: GObject.Object):
+ def _set_icon(self, widget: GObject.Object, shargs: GLib.Variant = None):
"""
Button to set the icon/emoji has been clicked.
"""
- self._reset_emoji_entry()
- # Resize to make the emoji picker fit (can't go outside of the
- # window for some reason, at least in GTK3)
- w, h = self.get_size()
- self.resize(max(w, 550), max(h, 300))
- self._emoji_entry.do_insert_emoji(self._emoji_entry)
+ self._emoji_chooser.popup()
+ @Gtk.Template.Callback('emoji_set')
def _set_emoji(self, widget: GObject.Object, text: str = None):
"""
Callback when an emoji has been inserted.
- This is part of the emoji insertion hack.
- The text parameter can be used to override the emoji to use, used
- for initialization.
"""
- if text is None:
- text = self._emoji_entry.get_text()
-
self._emoji = text if text else None
+
if text:
self._emoji = text
self._icon_button.set_label(text)
@@ -290,21 +307,24 @@ def _set_emoji(self, widget: GObject.Object, text: str = None):
self._icon_button.set_label('🏷️')
if label := self._icon_button.get_child():
label.set_opacity(0.4)
- self.notify('has-icon')
- self._reset_emoji_entry()
+ self.notify('has-icon')
@Gtk.Template.Callback('remove_icon')
def _remove_icon(self, widget: GObject.Object):
"""
Callback to remove the icon.
"""
- self._set_emoji(self._emoji_entry, text='')
+
+ self._set_emoji(self._emoji_chooser, text='')
@Gtk.Template.Callback('remove_color')
def _remove_color(self, widget: GObject.Object):
"""
Callback to remove the color.
"""
- self.tag_rgba = RGBA(1.0, 1.0, 1.0, 1.0)
+
+ c = Gdk.RGBA()
+ c.red, c.green, c.blue, c.alpha = 1.0, 1.0, 1.0, 1.0
+ self.tag_rgba = c
self.has_color = False
diff --git a/GTG/gtk/browser/tag_pill.py b/GTG/gtk/browser/tag_pill.py
new file mode 100644
index 0000000000..d2bf752240
--- /dev/null
+++ b/GTG/gtk/browser/tag_pill.py
@@ -0,0 +1,124 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) - The GTG Team
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""Tag colors widget"""
+
+from gi.repository import Gtk, Gdk, GObject
+
+
+class TagPill(Gtk.DrawingArea):
+ """Color pill widget for tags."""
+
+ __gtype_name__ = 'TagPill'
+
+ default_color = '#666666'
+
+ def __init__(self, radius: int = 5):
+
+ super(TagPill, self).__init__()
+ self.colors = [Gdk.RGBA()]
+ self.colors[0].parse(self.default_color)
+
+ self.colors_str = ''
+ self.radius = radius
+ self.set_draw_func(self.do_draw_function)
+
+
+ @GObject.Property(type=str)
+ def color_list(self) -> str:
+ return self.colors_str
+
+
+ @color_list.setter
+ def set_colors(self, value) -> None:
+
+ try:
+ self.colors = []
+
+ for color in value.split(','):
+ rgba = Gdk.RGBA()
+ valid = rgba.parse(color)
+
+ if valid:
+ self.colors.append(rgba)
+ else:
+ self.colors = [Gdk.RGBA()]
+ self.colors[0].parse(self.default_color)
+
+ except AttributeError:
+ self.colors = [Gdk.RGBA()]
+ self.colors[0].parse(self.default_color)
+
+ self.set_size_request((16 + 6) * len(self.colors), 16)
+ self.queue_draw()
+
+
+ def draw_rect(self, context, x: int, w: int, h: int,
+ color: Gdk.RGBA = None) -> None:
+ """Draw a single color rectangle."""
+
+ y = 0 # No change in Y axis
+ r = self.radius
+
+ if color:
+ context.set_source_rgba(color.red, color.green, color.blue)
+
+ # A * BQ
+ # H C
+ # * *
+ # G D
+ # F * E
+
+ context.move_to(x + r, y) # Move to A
+ context.line_to(x + w - r, y) # Line to B
+
+ context.curve_to(
+ x + w, y,
+ x + w, y,
+ x + w, y + r
+ ) # Curve to C
+ context.line_to(x + w, y + h - r) # Line to D
+
+ context.curve_to(
+ x + w, y + h,
+ x + w, y + h,
+ x + w - r, y + h
+ ) # Curve to E
+ context.line_to(x + r, y + h) # Line to F
+
+ context.curve_to(
+ x, y + h,
+ x, y + h,
+ x, y + h - r
+ ) # Curve to G
+ context.line_to(x, y + r) # Line to H
+
+ context.curve_to(
+ x, y,
+ x, y,
+ x + r, y
+ ) # Curve to A
+
+
+ def do_draw_function(self, area, context, w, h, user_data=None):
+ """Drawing callback."""
+
+ for i, color in enumerate(self.colors):
+ x = i * (16 + 6)
+ self.draw_rect(context, x, 16, h, color)
+ context.fill()
diff --git a/GTG/gtk/browser/task_pane.py b/GTG/gtk/browser/task_pane.py
new file mode 100644
index 0000000000..25d436b09d
--- /dev/null
+++ b/GTG/gtk/browser/task_pane.py
@@ -0,0 +1,695 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) - The GTG Team
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program. If not, see .
+# -----------------------------------------------------------------------------
+
+"""Task pane and list."""
+
+from gi.repository import Gtk, GObject, Gdk, Gio, Pango
+from GTG.core.tasks import Task, Status
+from GTG.core.filters import TaskPaneFilter, SearchTaskFilter
+from GTG.core.sorters import *
+from GTG.gtk.browser.tag_pill import TagPill
+from gettext import gettext as _
+
+
+BIND_FLAGS = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE
+
+class TaskBox(Gtk.Box):
+ """Box subclass to keep a pointer to the tag object"""
+
+ task = GObject.Property(type=Task)
+
+ def __init__(self, config, is_actionable=False):
+ self.config = config
+ super(TaskBox, self).__init__()
+
+ self.expander = Gtk.TreeExpander()
+ self.expander.set_margin_end(6)
+ self.expander.add_css_class('arrow-only-expander')
+
+ self.check = Gtk.CheckButton()
+ self.check.set_margin_end(6)
+
+ self.append(self.expander)
+ self.append(self.check)
+
+ self.is_actionable = is_actionable
+
+
+ @GObject.Property(type=bool, default=True)
+ def has_children(self) -> None:
+ return
+
+
+ @has_children.setter
+ def set_has_children(self, value) -> bool:
+
+ if self.is_actionable:
+ value = False
+
+ self.expander.set_visible(value)
+
+ if value:
+ widget = self.expander
+ else:
+ widget = self.check
+
+ check_width = 21
+ margin = 6
+
+ indent = margin if value else (check_width + margin)
+
+ if self.task.parent and not self.is_actionable:
+ parent = self.task
+ depth = 0
+
+ while parent.parent:
+ depth += 1
+ parent = parent.parent
+
+ widget.set_margin_start(indent + (21 * depth))
+ else:
+ widget.set_margin_start(indent)
+
+
+ @GObject.Property(type=bool, default=True)
+ def is_active(self) -> None:
+ return
+
+
+ @is_active.setter
+ def set_is_active(self, value) -> bool:
+ if value:
+ self.remove_css_class('closed-task')
+ else:
+ self.add_css_class('closed-task')
+
+
+ @GObject.Property(type=str)
+ def row_css(self) -> None:
+ return
+
+
+ @row_css.setter
+ def set_row_css(self, value) -> None:
+ show = self.config.get('bg_color_enable')
+ context = self.get_style_context()
+
+ if not value or not show:
+ try:
+ context.remove_provider(self.provider)
+ return
+ except AttributeError:
+ return
+
+ val = str.encode(value)
+
+ self.provider = Gtk.CssProvider()
+ self.provider.load_from_data(val)
+ context.add_provider(self.provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
+
+
+def unwrap(row, expected_type):
+ """Find an item in TreeRow widget (sometimes nested)."""
+
+ item = row.get_item()
+
+ while type(item) is not expected_type:
+ item = item.get_item()
+
+ return item
+
+
+class TaskPane(Gtk.ScrolledWindow):
+ """The task pane widget"""
+
+ def __init__(self, browser, pane):
+
+ super(TaskPane, self).__init__()
+ self.ds = browser.app.ds
+ self.app = browser.app
+ self.browser = browser
+ self.pane = pane
+ self.searching = False
+
+ self.set_vexpand(True)
+ self.set_hexpand(True)
+
+ # -------------------------------------------------------------------------------
+ # Title
+ # -------------------------------------------------------------------------------
+ title_box = Gtk.Box()
+ title_box.set_valign(Gtk.Align.START)
+
+ title_box.set_margin_top(32)
+ title_box.set_margin_bottom(32)
+ title_box.set_margin_start(24)
+ title_box.set_margin_end(24)
+
+ self.title = Gtk.Label()
+ self.title.set_halign(Gtk.Align.START)
+ self.title.set_hexpand(True)
+ self.title.add_css_class('title-1')
+ title_box.append(self.title)
+
+ self.sort_btn = Gtk.MenuButton()
+ self.sort_btn.set_icon_name('view-more-symbolic')
+ self.sort_btn.add_css_class('flat')
+
+ title_box.append(self.sort_btn)
+
+
+ # -------------------------------------------------------------------------------
+ # Task List
+ # -------------------------------------------------------------------------------
+
+ self.search_filter = SearchTaskFilter(self.ds, pane)
+ self.task_filter = TaskPaneFilter(self.app.ds, pane)
+
+ self.filtered = Gtk.FilterListModel()
+ self.filtered.set_model(self.app.ds.tasks.tree_model)
+ self.filtered.set_filter(self.task_filter)
+
+ self.sort_model = Gtk.TreeListRowSorter()
+
+ self.main_sorter = Gtk.SortListModel()
+ self.main_sorter.set_model(self.filtered)
+ self.main_sorter.set_sorter(self.sort_model)
+
+ self.task_selection = Gtk.MultiSelection.new(self.main_sorter)
+
+ tasks_signals = Gtk.SignalListItemFactory()
+ tasks_signals.connect('setup', self.task_setup_cb)
+ tasks_signals.connect('bind', self.task_bind_cb)
+ tasks_signals.connect('unbind', self.task_unbind_cb)
+
+ view = Gtk.ListView.new(self.task_selection, tasks_signals)
+ view.set_show_separators(True)
+ view.add_css_class('task-list')
+
+ view_drop = Gtk.DropTarget.new(Task, Gdk.DragAction.COPY)
+ view_drop.connect("drop", self.on_toplevel_tag_drop)
+ view.add_controller(view_drop)
+
+ key_controller = Gtk.EventControllerKey()
+ key_controller.connect('key-released', self.on_key_released)
+ view.add_controller(key_controller)
+ view.connect('activate', self.on_listview_activated)
+
+ self.set_child(view)
+ self.set_title()
+
+
+ @GObject.Signal(name='expand-all')
+ def expand_all(self, *_):
+ """Emit this signal to expand all TreeRowExpanders"""
+
+
+ @GObject.Signal(name='collapse-all')
+ def collapse_all(self, *_):
+ """Emit this signal to collapse all TreeRowExpanders"""
+
+
+ def set_title(self) -> None:
+ """Change pane title."""
+
+ if not self.task_filter.tags:
+ if self.pane == 'active':
+ self.title.set_text(_('All Open Tasks'))
+ if self.pane == 'workview':
+ self.title.set_text(_('Actionable Tasks'))
+ if self.pane == 'closed':
+ self.title.set_text(_('All Closed Tasks'))
+
+ else:
+ tags = ', '.join('@' + t.name for t in self.task_filter.tags)
+
+ if self.pane == 'active':
+ self.title.set_text(_('{0} (Open)'.format(tags)))
+ if self.pane == 'workview':
+ self.title.set_text(_('{0} (Actionable)'.format(tags)))
+ if self.pane == 'closed':
+ self.title.set_text(_('{0} (Closed)'.format(tags)))
+
+
+ def set_search_query(self, query) -> None:
+ """Change tasks filter."""
+
+ self.filtered.set_filter(self.search_filter)
+ self.search_filter.set_query(query)
+ self.search_filter.pane = self.pane
+ self.search_filter.changed(Gtk.FilterChange.DIFFERENT)
+ self.searching = True
+
+
+ def set_filter_pane(self, pane) -> None:
+ """Change tasks filter."""
+
+ if self.searching:
+ self.searching = False
+ self.filtered.set_filter(self.task_filter)
+
+ self.pane = pane
+ self.task_filter.pane = pane
+ self.task_filter.expand = True
+ self.task_filter.changed(Gtk.FilterChange.DIFFERENT)
+ self.set_title()
+
+
+ def set_filter_tags(self, tags=[]) -> None:
+ """Change tasks filter."""
+
+ if self.searching:
+ self.search_filter.tags = tags
+ self.search_filter.changed(Gtk.FilterChange.DIFFERENT)
+ else:
+ self.task_filter.tags = tags
+ self.task_filter.no_tags = False
+ self.task_filter.changed(Gtk.FilterChange.DIFFERENT)
+
+ self.set_title()
+
+ def set_filter_notags(self, tags=[]) -> None:
+ """Change tasks filter."""
+
+ if self.searching:
+ self.search_filter.tags = []
+ self.search_filter.changed(Gtk.FilterChange.DIFFERENT)
+ else:
+ self.task_filter.tags = []
+ self.task_filter.no_tags = True
+ self.task_filter.changed(Gtk.FilterChange.DIFFERENT)
+
+ self.set_title()
+
+
+ def refresh(self):
+ """Refresh the task filter"""
+
+ self.task_filter.changed(Gtk.FilterChange.DIFFERENT)
+ self.main_sorter.items_changed(0,0,0)
+
+
+ def set_sorter(self, method=None) -> None:
+ """Change tasks filter."""
+
+ sorter = None
+
+ if method == 'Start':
+ sorter = TaskStartSorter()
+ if method == 'Due':
+ sorter = TaskDueSorter()
+ if method == 'Modified':
+ sorter = TaskModifiedSorter()
+ elif method == 'Added':
+ sorter = TaskAddedSorter()
+ elif method == 'Tags':
+ sorter = TaskTagSorter()
+ elif method == 'Title':
+ sorter = TaskTitleSorter()
+
+ self.sort_model.set_sorter(sorter)
+
+
+ def on_listview_activated(self, listview, position, user_data = None):
+ """Callback when double clicking on a row."""
+
+ self.app.browser.on_edit_active_task()
+
+
+ def on_key_released(self, controller, keyval, keycode, state):
+ """Callback when a key is released. """
+
+ is_enter = keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter)
+ is_left = keyval == Gdk.KEY_Left
+ is_right = keyval == Gdk.KEY_Right
+
+ if is_enter:
+ self.app.browser.on_edit_active_task()
+ elif is_left:
+ self.expand_selected(False)
+ elif is_right:
+ self.expand_selected(True)
+
+
+ def select_last(self) -> None:
+ """Select last position in the task list."""
+
+ position = self.app.ds.tasks.tree_model.get_n_items()
+ self.task_selection.select_item(position - 1, True)
+
+
+ def select_task(self, task: Task) -> None:
+ """Select a task in the list."""
+
+ position = None
+
+ for i in range(self.main_sorter.get_n_items()):
+ item = unwrap(self.main_sorter.get_item(i), Task)
+
+ if item == task:
+ position = i
+ break
+
+ if position:
+ self.task_selection.select_item(position, True)
+
+
+ def get_selection(self, indices: bool = False) -> list:
+ """Get the currently selected tasks."""
+
+ selection = self.task_selection.get_selection()
+ result, iterator, _ = Gtk.BitsetIter.init_first(selection)
+ selected = []
+
+ while iterator.is_valid():
+ val = iterator.get_value()
+
+ if indices:
+ selected.append(val)
+ else:
+ selected.append(unwrap(self.task_selection.get_item(val), Task))
+
+ iterator.next()
+
+ return selected
+
+
+ def expand_selected(self, expand) -> None:
+ """Get the box widgets of the selected tasks."""
+
+ selection = self.task_selection.get_selection()
+ result, iterator, _ = Gtk.BitsetIter.init_first(selection)
+
+ while iterator.is_valid():
+ val = iterator.get_value()
+ row = self.task_selection.get_item(val)
+ row.set_expanded(expand)
+
+ iterator.next()
+
+
+ def get_selected_number(self) -> int:
+ """Get number of items currently selected."""
+
+ selection = self.task_selection.get_selection()
+ return selection.get_size()
+
+
+ def on_checkbox_toggled(self, button, task=None):
+ """Callback when clicking a checkbox."""
+
+ if task.status == Status.DISMISSED:
+ task.toggle_dismiss()
+ else:
+ task.toggle_active()
+
+ task.notify('is_active')
+ self.task_filter.changed(Gtk.FilterChange.DIFFERENT)
+
+
+ def task_setup_cb(self, factory, listitem, user_data=None):
+ """Setup widgets for rows"""
+
+ box = TaskBox(self.app.config, self.pane == 'workview')
+ label = Gtk.Label()
+ separator = Gtk.Separator()
+ icons = Gtk.Label()
+ color = TagPill()
+ due = Gtk.Label()
+ due_icon = Gtk.Image.new_from_icon_name('alarm-symbolic')
+ start = Gtk.Label()
+ start_icon = Gtk.Image.new_from_icon_name('media-playback-start-symbolic')
+ recurring_icon = Gtk.Label()
+
+ color.set_size_request(16, 16)
+
+ color.set_vexpand(False)
+ color.set_valign(Gtk.Align.CENTER)
+
+ separator.set_margin_end(12)
+
+ def on_notify_visibility(obj, gparamstring):
+ val = ((recurring_icon.is_visible()
+ or due_icon.is_visible()
+ or start_icon.is_visible())
+ and
+ (color.is_visible() or icons.is_visible()))
+ separator.set_visible(val)
+
+ for widget in (recurring_icon, due_icon, start_icon, color, icons):
+ widget.connect("notify::visible", on_notify_visibility)
+
+ icons.set_margin_end(6)
+
+ label.set_hexpand(True)
+ label.set_ellipsize(Pango.EllipsizeMode.END)
+ label.set_margin_end(12)
+ label.set_xalign(0)
+
+ recurring_icon.set_margin_end(16)
+ recurring_icon.set_label('\u2B6E')
+
+ due_icon.set_margin_end(6)
+ due.set_margin_end(24)
+
+ start_icon.set_margin_end(6)
+ start.set_margin_end(12)
+
+ # DnD stuff
+ source = Gtk.DragSource()
+ source.connect('prepare', self.drag_prepare)
+ source.connect('drag-begin', self.drag_begin)
+ source.connect('drag-end', self.drag_end)
+ box.add_controller(source)
+
+ # Set drop for DnD
+ drop = Gtk.DropTarget.new(Task, Gdk.DragAction.COPY)
+ drop.connect('drop', self.drag_drop)
+ drop.connect('enter', self.drop_enter)
+
+ box.add_controller(drop)
+
+ task_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY)
+ task_RMB_controller.connect('end', self.on_task_RMB_click)
+ box.add_controller(task_RMB_controller)
+
+ self.connect('expand-all', lambda s: box.expander.activate_action('listitem.expand'))
+ self.connect('collapse-all', lambda s: box.expander.activate_action('listitem.collapse'))
+
+ box.append(label)
+ box.append(recurring_icon)
+ box.append(due_icon)
+ box.append(due)
+ box.append(start_icon)
+ box.append(start)
+ box.append(separator)
+ box.append(color)
+ box.append(icons)
+ listitem.set_child(box)
+
+
+ def task_bind_cb(self, factory, listitem, user_data=None):
+ """Bind values to the widgets in setup_cb"""
+
+ box = listitem.get_child()
+ expander = box.get_first_child()
+ check = expander.get_next_sibling()
+ label = check.get_next_sibling()
+ recurring_icon = label.get_next_sibling()
+ due_icon = recurring_icon.get_next_sibling()
+ due = due_icon.get_next_sibling()
+ start_icon = due.get_next_sibling()
+ start = start_icon.get_next_sibling()
+ separator = start.get_next_sibling()
+ color = separator.get_next_sibling()
+ icons = color.get_next_sibling()
+
+ item = unwrap(listitem, Task)
+
+ box.props.task = item
+ box.expander.set_list_row(listitem.get_item())
+
+ def not_empty(binding, value, user_data=None):
+ return len(value) > 0
+
+ def show_start(binding, value, user_data=None):
+ return value and self.pane == 'active'
+
+
+ listitem.bindings = [
+ item.bind_property('has_children', box, 'has_children', BIND_FLAGS),
+
+ item.bind_property('title', label, 'label', BIND_FLAGS),
+ item.bind_property('excerpt', box, 'tooltip-text', BIND_FLAGS),
+
+ item.bind_property('is_recurring', recurring_icon, 'visible', BIND_FLAGS),
+
+ item.bind_property('has_date_due', due_icon, 'visible', BIND_FLAGS),
+ item.bind_property('has_date_start', start_icon, 'visible', BIND_FLAGS, show_start),
+
+ item.bind_property('date_due_str', due, 'label', BIND_FLAGS),
+ item.bind_property('date_start_str', start, 'label', BIND_FLAGS),
+ item.bind_property('date_start_str', start, 'visible', BIND_FLAGS, show_start),
+
+ item.bind_property('is_active', box, 'is_active', BIND_FLAGS),
+ item.bind_property('icons', icons, 'label', BIND_FLAGS),
+ item.bind_property('icons', icons, 'visible', BIND_FLAGS, not_empty),
+ item.bind_property('row_css', box, 'row_css', BIND_FLAGS),
+
+ item.bind_property('tag_colors', color, 'color_list', BIND_FLAGS),
+ item.bind_property('show_tag_colors', color, 'visible', BIND_FLAGS),
+ ]
+
+ box.check.set_active(item.status == Status.DONE)
+ box.check.connect('toggled', self.on_checkbox_toggled, item)
+
+
+ def task_unbind_cb(self, factory, listitem, user_data=None):
+ """Clean up bindings made in task_bind_cb"""
+ for binding in listitem.bindings:
+ binding.unbind()
+ listitem.bindings.clear()
+ box = listitem.get_child()
+ box.check.disconnect_by_func(self.on_checkbox_toggled)
+
+
+ def drag_prepare(self, source, x, y):
+ """Callback to prepare for the DnD operation"""
+
+ selection = self.get_selection()
+
+ if len(selection) > 1:
+ data = Gio.ListStore()
+ data.splice(0, 0, selection)
+
+ content = Gdk.ContentProvider.new_for_value(GObject.Value(Gio.ListModel, data))
+ return content
+
+ else:
+ # Get content from source
+ data = source.get_widget().props.task
+
+ # Set it as content provider
+ content = Gdk.ContentProvider.new_for_value(data)
+ return content
+
+
+ def drag_begin(self, source, drag):
+ """Callback when DnD beings"""
+
+ source.get_widget().set_opacity(0.25)
+ icon = Gtk.DragIcon.get_for_drag(drag)
+ frame = Gtk.Frame()
+ picture = Gtk.Picture.new_for_paintable(
+ Gtk.WidgetPaintable.new(source.get_widget()))
+
+ frame.set_child(picture)
+ icon.set_child(frame)
+
+
+ def drag_end(self, source, drag, unused):
+ """Callback when DnD ends"""
+
+ if source.get_widget():
+ source.get_widget().set_opacity(1)
+
+
+ def drop_enter(self, target, x, y, user_data=None):
+ """Callback when the mouse hovers over the drop target."""
+
+ expander = target.get_widget().get_first_child()
+
+ if target.get_widget().task.children:
+ expander.activate_action('listitem.expand')
+
+ # There's a funny bug in here. If the expansion of the row
+ # makes the window larger, Gtk won't recognize the new drop areas
+ # and will think you're dragging outside the window.
+
+ return Gdk.DragAction.COPY
+
+
+ def check_parent(self, value, target) -> bool:
+ """Check to parenting a parent to its own children"""
+
+ item = target
+ while item.parent:
+ if item.parent == value:
+ return False
+
+ item = item.parent
+
+ return True
+
+
+ def drag_drop(self, target, task, x, y):
+ """Callback when dropping onto a target"""
+
+ dropped = target.get_widget().props.task
+
+ if not self.check_parent(task, dropped):
+ return
+
+ if task.parent:
+ self.ds.tasks.unparent(task.id, task.parent.id)
+
+ self.ds.tasks.parent(task.id, dropped.id)
+ self.refresh()
+ self.emit('collapse-all')
+ self.emit('expand-all')
+
+
+ def on_task_RMB_click(self, gesture, sequence) -> None:
+ """Callback when right-clicking on an open task."""
+
+ widget = gesture.get_widget()
+ task = widget.task
+
+ if self.get_selected_number() <= 1:
+ self.select_task(task)
+
+ if task.status == Status.ACTIVE:
+ menu = self.browser.open_menu
+ else:
+ menu = self.browser.closed_menu
+
+ point = gesture.get_point(sequence)
+ x, y = widget.translate_coordinates(self.browser, point.x, point.y)
+
+ rect = Gdk.Rectangle()
+ rect.x = x
+ rect.y = y
+
+ menu.set_pointing_to(rect)
+ menu.popup()
+
+
+ def on_toplevel_tag_drop(self, drop_target, task, x, y):
+ if task.parent:
+ self.ds.tasks.unparent(task.id, task.parent.id)
+ self.ds.tasks.tree_model.emit('items-changed', 0, 0, 0)
+ self.refresh()
+
+ # Not pretty, but needed to force the update of
+ # the parent task and it's remaining children
+ self.emit('collapse-all')
+ self.emit('expand-all')
+
+ return True
+ else:
+ return False
diff --git a/GTG/gtk/browser/treeview_factory.py b/GTG/gtk/browser/treeview_factory.py
deleted file mode 100644
index f22487c113..0000000000
--- a/GTG/gtk/browser/treeview_factory.py
+++ /dev/null
@@ -1,469 +0,0 @@
-# -----------------------------------------------------------------------------
-# Getting Things GNOME! - a personal organizer for the GNOME desktop
-# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or (at your option) any later
-# version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details.
-#
-# You should have received a copy of the GNU General Public License along with
-# this program. If not, see .
-# -----------------------------------------------------------------------------
-
-import locale
-from datetime import datetime
-import xml.sax.saxutils as saxutils
-
-from gi.repository import GObject, Gtk, Pango
-
-from GTG.core.search import parse_search_query, search_filter
-from GTG.core.tag import SEARCH_TAG
-from GTG.core.task import Task
-from gettext import gettext as _
-from GTG.gtk import colors
-from GTG.gtk.browser.cell_renderer_tags import CellRendererTags
-from GTG.core.dates import Date
-from liblarch_gtk import TreeView
-
-
-class TreeviewFactory():
-
- def __init__(self, requester, config):
- self.req = requester
- self.mainview = self.req.get_tasks_tree()
- self.config = config
-
- # Initial unactive color
- # This is a crude hack. As we don't have a reference to the
- # treeview to retrieve the style, we save that color when we
- # build the treeview.
- self.unactive_color = "#888a85"
-
- # List of keys for connecting/disconnecting Tag tree
- self.tag_cllbcks = []
-
- # Cache tags treeview for on_rename_tag callback
- self.tags_view = None
-
- #############################
- # Functions for tasks columns
- ################################
- def _has_hidden_subtask(self, task):
- # not recursive
- display_count = self.mainview.node_n_children(task.get_id())
- real_count = 0
- if task.has_child():
- for tid in task.get_children():
- sub_task = self.req.get_task(tid)
- if sub_task and sub_task.get_status() == Task.STA_ACTIVE:
- real_count = real_count + 1
- return display_count < real_count
-
- def get_task_bg_color(self, node, default_color):
- if self.config.get('bg_color_enable'):
- return colors.background_color(node.get_tags(), default_color, 0.5)
- else:
- return None
-
- def get_task_tags_column_contents(self, node):
- """Returns an ordered list of tags of a task"""
- tags = node.get_tags()
-
- search_parent = self.req.get_tag(SEARCH_TAG)
- for search_tag in search_parent.get_children():
- tag = self.req.get_tag(search_tag)
- match = search_filter(
- node,
- parse_search_query(tag.get_attribute('query')),
- )
- if match and search_tag not in tags:
- tags.append(tag)
-
- tags.sort(key=lambda x: x.get_name())
- return tags
-
- def get_task_title_column_string(self, node):
- return saxutils.escape(node.get_title())
-
- def get_task_label_column_string(self, node):
- str_format = "%s"
-
- # We add the indicator when task is repeating
- INDICATOR = "\u21BB "
- if node.get_recurring():
- str_format = INDICATOR + str_format
-
- if node.get_status() == Task.STA_ACTIVE:
- # we mark in bold tasks which are due today or as Now
- days_left = node.get_days_left()
- if days_left is not None and days_left <= 0:
- str_format = f"{str_format}"
- if self._has_hidden_subtask(node):
- str_format = f"{str_format}"
-
- title = str_format % saxutils.escape(node.get_title())
- if node.get_status() == Task.STA_ACTIVE:
- count = self.mainview.node_n_children(node.get_id(), recursive=True)
- if count != 0:
- title += f" ({count})"
- elif node.get_status() == Task.STA_DISMISSED:
- title = f"{title}"
-
- if self.config.get("contents_preview_enable"):
- excerpt = saxutils.escape(node.get_excerpt(lines=1,
- strip_tags=True,
- strip_subtasks=True))
- title += " %s" \
- % (self.unactive_color, excerpt)
- return title
-
- def get_task_startdate_column_string(self, node):
- start_date = node.get_start_date()
- if start_date:
- return _(start_date.to_readable_string())
- else:
- # Do not parse with gettext then, or you'll get undefined behavior.
- return ""
-
- def get_task_duedate_column_string(self, node):
- # For tasks with no due dates, we use the most constraining due date.
- if node.get_due_date() == Date.no_date():
- # This particular call must NOT use the gettext "_" function,
- # as you will get some very weird erratic behavior:
- # strings showing up and changing on the fly when the mouse hovers,
- # whereas no strings should even be shown at all.
- return node.get_due_date_constraint().to_readable_string()
- else:
- # Other tasks show their due date (which *can* be fuzzy)
- return _(node.get_due_date().to_readable_string())
-
- def get_task_closeddate_column_string(self, node):
- closed_date = node.get_closed_date()
- if closed_date:
- return _(closed_date.to_readable_string())
- else:
- # Do not parse with gettext then, or you'll get undefined behavior.
- return ""
-
- def sort_by_startdate(self, task1, task2, order):
- t1 = task1.get_start_date()
- t2 = task2.get_start_date()
- return self.__date_comp_continue(task1, task2, order, t1, t2)
-
- def sort_by_duedate(self, task1, task2, order):
- t1 = task1.get_urgent_date()
- t2 = task2.get_urgent_date()
- if t1 == Date.no_date():
- t1 = task1.get_due_date_constraint()
- if t2 == Date.no_date():
- t2 = task2.get_due_date_constraint()
- return self.__date_comp_continue(task1, task2, order, t1, t2)
-
- def sort_by_closeddate(self, task1, task2, order):
- t1 = task1.get_closed_date()
- t2 = task2.get_closed_date()
-
- # Convert both times to datetimes (accurate comparison)
- if isinstance(t1, Date):
- d = t1.date()
- t1 = datetime(year=d.year, month=d.month, day=d.day)
- if isinstance(t2, Date):
- d = t2.date()
- t2 = datetime(year=d.year, month=d.month, day=d.day)
- return self.__date_comp_continue(task1, task2, order, t1, t2)
-
- def sort_by_title(self, task1, task2, order):
- # Strip "@" and convert everything to lowercase to allow fair comparisons;
- # otherwise, Capitalized Tasks get sorted after their lowercase equivalents,
- # and tasks starting with a tag would get sorted before everything else.
- t1 = task1.get_title().replace("@", "").lower()
- t2 = task2.get_title().replace("@", "").lower()
- return (t1 > t2) - (t1 < t2)
-
- def __date_comp_continue(self, task1, task2, order, t1, t2):
- sort = (t2 > t1) - (t2 < t1)
-
- if sort != 0: # Ingore order, since this will be done automatically
-
- return sort
-
- # Dates are equal
- # Group tasks with the same tag together for visual cleanness
- t1_tags = task1.get_tags_name()
- t1_tags.sort()
- t2_tags = task2.get_tags_name()
- t2_tags.sort()
- sort = (t1_tags > t2_tags) - (t1_tags < t2_tags)
-
- if sort == 0: # Even tags are equal
- # Break ties by sorting by title
- sort = locale.strcoll(task1.get_title(), task2.get_title())
-
- if order != Gtk.SortType.ASCENDING:
- return -sort
- return sort
-
- #############################
- # Functions for tags columns
- #############################
- def get_tag_name(self, node):
- label = node.get_attribute("label")
- if label.startswith('@'):
- label = label[1:]
-
- if node.get_attribute("nonactionable") == "True":
- return f"{label}"
- elif node.get_id() == 'search' and not node.get_children():
- return f"{label}"
- else:
- return label
-
- def get_tag_count(self, node):
- if node.get_id() == 'search':
- return ""
- else:
- toreturn = node.get_active_tasks_count()
- return f"{toreturn}"
-
- def is_tag_separator_filter(self, tag):
- return tag.get_attribute('special') == 'sep'
-
- def tag_sorting(self, t1, t2, order):
- t1_sp = t1.get_attribute("special")
- t2_sp = t2.get_attribute("special")
- t1_name = locale.strxfrm(t1.get_name())
- t2_name = locale.strxfrm(t2.get_name())
- if not t1_sp and not t2_sp:
- return (t1_name > t2_name) - (t1_name < t2_name)
- elif not t1_sp and t2_sp:
- return 1
- elif t1_sp and not t2_sp:
- return -1
- else:
- t1_order = t1.get_attribute("order")
- t2_order = t2.get_attribute("order")
- return (t1_order > t2_order) - (t1_order < t2_order)
-
- def on_tag_task_dnd(self, source, target):
- task = self.req.get_task(source)
- if target.startswith('@'):
- task.add_tag(target)
- elif target == 'gtg-tags-none':
- for t in task.get_tags_name():
- task.remove_tag(t)
- task.modified()
-
- ############################################
- # The Factory ##############################
- ############################################
- def tags_treeview(self, tree):
- desc = {}
-
- # Tag id
- col_name = 'tag_id'
- col = {}
- col['renderer'] = ['markup', Gtk.CellRendererText()]
- col['value'] = [str, lambda node: node.get_id()]
- col['visible'] = False
- col['order'] = 0
- col['sorting_func'] = self.tag_sorting
- desc[col_name] = col
-
- # Tags color
- col_name = 'color'
- col = {}
- render_tags = CellRendererTags(self.config)
- render_tags.set_property('ypad', 5)
- col['title'] = _("Tags")
- col['renderer'] = ['tag', render_tags]
- col['value'] = [GObject.TYPE_PYOBJECT, lambda node: node]
- col['expandable'] = False
- col['resizable'] = False
- col['order'] = 1
- desc[col_name] = col
-
- # Tag names
- col_name = 'tagname'
- col = {}
- render_text = Gtk.CellRendererText()
- render_text.set_property('ypad', 5)
- col['renderer'] = ['markup', render_text]
- col['value'] = [str, self.get_tag_name]
- col['expandable'] = True
- col['new_column'] = False
- col['order'] = 2
- desc[col_name] = col
-
- # Tag count
- col_name = 'tagcount'
- col = {}
- render_text = Gtk.CellRendererText()
- render_text.set_property('xpad', 17)
- render_text.set_property('ypad', 5)
- render_text.set_property('xalign', 1)
- col['renderer'] = ['markup', render_text]
- col['value'] = [str, self.get_tag_count]
- col['expandable'] = False
- col['new_column'] = False
- col['order'] = 3
- desc[col_name] = col
-
- return self.build_tag_treeview(tree, desc)
-
- def active_tasks_treeview(self, tree):
- # Build the title/label/tags columns
- # Translators: Column name, containing the task titles
- desc = self.common_desc_for_tasks(tree, _("Tasks"))
-
- # "startdate" column
- col_name = 'startdate'
- col = {}
- # Translators: Column name, containing the start date
- col['title'] = _("Start Date")
- col['expandable'] = False
- col['resizable'] = False
- col['value'] = [str, self.get_task_startdate_column_string]
- col['order'] = 3
- col['sorting_func'] = self.sort_by_startdate
- desc[col_name] = col
-
- # 'duedate' column
- col_name = 'duedate'
- col = {}
- # Translators: Column name, containing the due date
- col['title'] = _("Due")
- col['expandable'] = False
- col['resizable'] = False
- col['value'] = [str, self.get_task_duedate_column_string]
- col['order'] = 4
- col['sorting_func'] = self.sort_by_duedate
- desc[col_name] = col
-
- # Returning the treeview
- treeview = self.build_task_treeview(tree, desc)
- treeview.set_sort_column('duedate')
- return treeview
-
- def closed_tasks_treeview(self, tree):
- # Build the title/label/tags columns
- # Translators: Column name, containing the task titles
- desc = self.common_desc_for_tasks(tree, _("Closed Tasks"))
-
- # "startdate" column
- col_name = 'closeddate'
- col = {}
- # Translators: Column name, containing the closed date
- col['title'] = _("Closed Date")
- col['expandable'] = False
- col['resizable'] = False
- col['value'] = [str, self.get_task_closeddate_column_string]
- col['order'] = 3
- col['sorting_func'] = self.sort_by_closeddate
- desc[col_name] = col
-
- # Returning the treeview
- treeview = self.build_task_treeview(tree, desc)
- treeview.set_sort_column('closeddate')
- return treeview
-
- # This build the first tag/title columns, common
- # to both active and closed tasks treeview
- def common_desc_for_tasks(self, tree, title_label):
- desc = {}
-
- # invisible 'task_id' column
- col_name = 'task_id'
- col = {}
- col['renderer'] = ['markup', Gtk.CellRendererText()]
- col['value'] = [str, lambda node: node.get_id()]
- col['visible'] = False
- col['order'] = 0
- desc[col_name] = col
-
- # invisible 'bg_color' column
- col_name = 'bg_color'
- col = {}
- col['value'] = [str, lambda node: None]
- col['visible'] = False
- desc[col_name] = col
-
- # invisible 'title' column
- col_name = 'title'
- col = {}
- render_text = Gtk.CellRendererText()
- render_text.set_property("ellipsize", Pango.EllipsizeMode.END)
- col['renderer'] = ['markup', render_text]
- col['value'] = [str, self.get_task_title_column_string]
- col['visible'] = False
- col['order'] = 0
- col['sorting_func'] = self.sort_by_title
- desc[col_name] = col
-
- # "tags" column (no title)
- col_name = 'tags'
- col = {}
- render_tags = CellRendererTags(self.config)
- render_tags.set_property('xalign', 0.0)
- col['renderer'] = ['tag_list', render_tags]
- col['value'] = [GObject.TYPE_PYOBJECT, self.get_task_tags_column_contents]
- col['expandable'] = False
- col['resizable'] = False
- col['order'] = 1
- desc[col_name] = col
-
- # "label" column
- col_name = 'label'
- col = {}
- col['title'] = title_label
- render_text = Gtk.CellRendererText()
- render_text.set_property("ellipsize", Pango.EllipsizeMode.END)
- col['renderer'] = ['markup', render_text]
- col['value'] = [str, self.get_task_label_column_string]
- col['expandable'] = True
- col['resizable'] = True
- col['sorting'] = 'title'
- col['order'] = 2
- desc[col_name] = col
- return desc
-
- def build_task_treeview(self, tree, desc):
- treeview = TreeView(tree, desc)
- # Now that the treeview is done, we can polish
- treeview.set_main_search_column('label')
- treeview.set_expander_column('label')
- treeview.set_dnd_name('gtg/task-iter-str')
- # Background colors
- treeview.set_bg_color(self.get_task_bg_color, 'bg_color')
- # Global treeview properties
- treeview.set_property("enable-tree-lines", False)
- treeview.set_rules_hint(False)
- treeview.set_multiple_selection(True)
- # Updating the unactive color (same for everyone)
- color = treeview.get_style_context().get_color(Gtk.StateFlags.INSENSITIVE)
- # Convert color into #RRRGGGBBB
- self.unactive_color = color.to_color().to_string()
- return treeview
-
- def build_tag_treeview(self, tree, desc):
- treeview = TreeView(tree, desc)
- # Global treeview properties
- treeview.set_property("enable-tree-lines", False)
- treeview.set_rules_hint(False)
- treeview.set_row_separator_func(self.is_tag_separator_filter)
- treeview.set_headers_visible(False)
- treeview.set_dnd_name('gtg/tag-iter-str')
- treeview.set_dnd_external('gtg/task-iter-str', self.on_tag_task_dnd)
- # Updating the unactive color (same for everyone)
- color = treeview.get_style_context().get_color(Gtk.StateFlags.INSENSITIVE)
- # Convert color into #RRRGGGBBB
- self.unactive_color = color.to_color().to_string()
-
- treeview.set_sort_column('tag_id')
- self.tags_view = treeview
- return treeview
diff --git a/GTG/gtk/colors.py b/GTG/gtk/colors.py
index f1462ac907..10eec558a7 100644
--- a/GTG/gtk/colors.py
+++ b/GTG/gtk/colors.py
@@ -109,8 +109,7 @@ def background_color(tags, bgcolor=None, galpha_scale=1, use_alpha=True):
gcolor = RGBA(red, green, blue)
gcolor.alpha = (1.0 - abs(brightness - target_brightness)) * galpha_scale
- # TODO: Remove GTK3 check on gtk4 port
- if not use_alpha or Gtk.get_major_version() == 3:
+ if not use_alpha:
gcolor.red = (1 - gcolor.alpha) * gcolor.red + gcolor.alpha * bgcolor.red
gcolor.green = (1 - gcolor.alpha) * gcolor.green + gcolor.alpha * bgcolor.green
gcolor.blue = (1 - gcolor.alpha) * gcolor.blue + gcolor.alpha * bgcolor.blue
@@ -120,13 +119,13 @@ def background_color(tags, bgcolor=None, galpha_scale=1, use_alpha=True):
return my_color
-def get_colored_tag_markup(req, tag_name, html=False):
+def get_colored_tag_markup(ds, tag_name, html=False):
"""
Given a tag name, returns a string containing the markup to color the
tag name
if html, returns a string insertable in html
"""
- tag = req.get_tag(tag_name)
+ tag = ds.tags.find(tag_name)
if tag is None:
# no task loaded with that tag, color cannot be taken
return tag_name
@@ -142,11 +141,11 @@ def get_colored_tag_markup(req, tag_name, html=False):
return tag_name
-def get_colored_tags_markup(req, tag_names):
+def get_colored_tags_markup(ds, tag_names):
"""
Calls get_colored_tag_markup for each tag_name in tag_names
"""
- tag_markups = [get_colored_tag_markup(req, t) for t in tag_names]
+ tag_markups = [get_colored_tag_markup(ds, t) for t in tag_names]
tags_txt = ""
if tag_markups:
# reduce crashes if applied to an empty list
@@ -167,6 +166,21 @@ def generate_tag_color():
return my_color
+def grgba_to_hex(rgba, ignore_alpha=True):
+ """
+ Simply convert a Gdk.RGBA to a #ffffff style color representation,
+ Gdk.Color used to have this built in, however Gdk.RGBA.to_string
+ gives it in rgb(255, 255, 255) which can't be used in certain
+ cases.
+ """
+ colors = [int(rgba.red * 255), int(rgba.green * 255), int(rgba.blue * 255)]
+ format_string = '#%02x%02x%02x'
+ if not ignore_alpha:
+ colors.append(int(rgba.alpha * 255))
+ format_string += '%02x'
+ return format_string % tuple(colors)
+
+
def color_add(present_color):
if present_color not in used_color:
diff --git a/GTG/gtk/data/backends.ui b/GTG/gtk/data/backends.ui
index 242624a447..35743ff5be 100644
--- a/GTG/gtk/data/backends.ui
+++ b/GTG/gtk/data/backends.ui
@@ -9,151 +9,76 @@
10
-
-
-
-
-
+
diff --git a/GTG/gtk/data/modify_tags.ui b/GTG/gtk/data/modify_tags.ui
index 6acaebc9b3..d861d71343 100644
--- a/GTG/gtk/data/modify_tags.ui
+++ b/GTG/gtk/data/modify_tags.ui
@@ -1,67 +1,26 @@
-
-
- False
- 12
+
+
+ modifytags_apply
Modify Tags
- center-on-parent
- dialog
-
+ True
+
+
- True
- False
vertical
- 6
-
-
- True
- False
- end
-
-
- Cancel
- False
- True
- True
- False
-
-
- False
- False
- 0
-
-
-
-
- Apply
- False
- True
- True
- True
- True
- False
-
-
-
-
-
-
-
+ 5
+ 5
+ 5
+ 5
+ 2
- True
- False
Enter the name of the tag(s) you wish to add or remove:
-
- True
- True
- True
+
●
True
TagName
@@ -69,8 +28,6 @@
- True
- False
Hint: you can add several tags by separating them with
space. Place '!' before tags you want to remove.
@@ -79,20 +36,36 @@ space. Place '!' before tags you want to remove.
-
+
Apply to subtasks
- True
- True
- False
- False
- True
+
+
+ True
+ end
+ 4
+ 4
+ 4
+ 4
+ 4
+
+
+
+
+ Apply
+
+
+
+
+ Cancel
+
+
- modifytags_cancel
- modifytags_ok
+ modifytags_apply
+ modifytags_cancel
-
+
diff --git a/GTG/gtk/data/plugins.ui b/GTG/gtk/data/plugins.ui
index 6ef8628fc3..9e76b09dff 100644
--- a/GTG/gtk/data/plugins.ui
+++ b/GTG/gtk/data/plugins.ui
@@ -1,120 +1,46 @@
-
-
-
- False
- 5
- mouse
- dialog
- system-run-symbolic
-
-
-
-
-
-
-
-
- True
- False
- vertical
- 2
-
-
- True
- False
- end
-
-
-
-
- True
- False
- 0
- none
-
-
- True
- False
- 12
- True
-
-
-
-
- True
- False
- <b>Dependencies</b>
- True
-
-
-
-
-
-
-
-
- False
- 10
- center-on-parent
- 750
+
+
+ 500
500
- dialog
-
+
-
+
- True
- False
+ 10
+ 10
+ 10
+ 10
vertical
-
-
- False
-
-
-
-
-
-
-
-
- True
- False
vertical
6
240
- True
- True
12
12
- in
-
-
- True
+ True
+
+
True
- True
False
False
horizontal
-
+
-
+
- True
- False
12
12
12
@@ -122,26 +48,21 @@
Help
- True
- True
True
app.open_help
- True
True
end
- False
-
+
_Configure Plugin
False
- True
True
True
-
+
@@ -150,11 +71,9 @@
_About Plugin
- True
- True
True
True
-
+
+
+
+
+ cancel
+ apply
+
+
+
+
+
+
+ 18
+ 18
+ 18
+ 18
+ horizontal
+ 12
+
+
+ start
+ 18
+ vertical
+ 6
+
+
+ center
+
+
+ 🏷️
+ 64
+ 64
+ Click here to set an icon for this saved search
+ center
+
+
+
+
+
+
+
+
+ Delete the currently selected icon
+ center
+ user-trash-symbolic
+
+
+
+
+
+
+
+
+ vertical
+ 18
+
+
+ 6
+ 12
+
+
+ end
+ Name
+ 1
+
+ 0
+ 0
+
+
+
+
+
+ center
+ True
+ True
+
+
+ 1
+ 0
+
+
+
+
+
+
+ end
+ Query
+ 1
+
+ 0
+ 1
+
+
+
+
+
+ center
+ True
+ True
+
+
+ 1
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GTG/gtk/data/style.css b/GTG/gtk/data/style.css
index ca9a681902..951c688ba3 100644
--- a/GTG/gtk/data/style.css
+++ b/GTG/gtk/data/style.css
@@ -2,10 +2,27 @@
* MAIN WINDOW
* --------------------------------------------------------------------------------
*/
-.menu-disclose {
+.menu-disclose button {
min-width: 16px;
}
+.task-list {
+ border-top: 1px solid rgb(213, 208, 204);
+}
+
+/*
+ We have to get move the padding into the boxes
+ so the background color of the rows reaches all
+ the way to the edges
+*/
+.task-list row {
+ padding: 0;
+}
+
+.task-list row > box {
+ padding: 15px 16px;
+}
+
/* --------------------------------------------------------------------------------
* ERROR HANDLER
* --------------------------------------------------------------------------------
@@ -36,10 +53,14 @@
border-bottom-color: alpha(rgb(0, 0, 0), 0.2);
}
-.recurring-active {
+.recurring-active image {
color: rgb(52, 130, 224);
}
+.backends-cont {
+ padding: 12px;
+}
+
/* --------------------------------------------------------------------------------
* TEXT EDITOR
* --------------------------------------------------------------------------------
@@ -62,3 +83,25 @@ textview check {
.icon {
font-size: 36px;
}
+
+.hide { opacity: 0.2; }
+
+.color-pill {
+ background: #999;
+ padding: 0;
+ min-height: 16px;
+ min-width: 16px;
+ border: none;
+}
+
+.arrow-only-expander {
+ border-spacing: 0;
+}
+
+.arrow-only-expander indent {
+ -gtk-icon-size: 0px;
+}
+
+.closed-task {
+ opacity: 0.5;
+}
\ No newline at end of file
diff --git a/GTG/gtk/data/tag_editor.ui b/GTG/gtk/data/tag_editor.ui
index 3076361973..b35063f71a 100644
--- a/GTG/gtk/data/tag_editor.ui
+++ b/GTG/gtk/data/tag_editor.ui
@@ -1,238 +1,160 @@
-
-
-
- False
- mouse
- dialog
-
-
- True
- False
10
10
- True
- False
Closed on
0
@@ -538,12 +226,29 @@
- True
- True
10
-
-
+
+
+ False
+
+
+ 6
+ 6
+ 6
+ 6
+ vertical
+
+
+
+
+
+
+
+
+
@@ -556,39 +261,28 @@
400
300
- True
- True
-
+
- True
- True
word
5
5
-
+
- True
- False
vertical
-
+
-
-
-
- False
- closeddate_entry
- False
-
-
- True
- False
- 12
- 12
- 12
- 12
- vertical
-
-
- True
- False
-
-
-
-
-
-
-
- False
- duedate_entry
- False
-
-
- True
- False
- 12
- 12
- 12
- 12
- vertical
-
-
- True
- False
- 2020
- 2
-
-
-
-
- True
- False
- 8
- True
-
-
- Now
- True
- True
- False
- True
-
-
-
-
-
- Soon
- True
- True
- False
- True
-
-
-
-
-
- Someday
- True
- True
- False
- True
-
-
-
-
-
-
-
-
- Clear
- True
- True
- False
- True
- 8
-
-
-
-
-
-
-
- False
- startdate_entry
- False
-
-
- True
- False
- 12
- 12
- 12
- 12
- vertical
-
-
- True
- False
- 2020
- 2
-
-
- Clear
- True
- True
- False
- True
-
-
-
-
-
-
+
+
+
- True
- False
- 12
- 12
- 8
- 8
+ 6
+ 6
+ 2
+ 2
vertical
- True
- True
10
- edit-find-symbolic
- False
- False
Search
- True
- True
- in
+ True
200
-
+
- True
- True
tag_store
False
1
@@ -826,10 +356,10 @@
-
+
-
+
diff --git a/GTG/gtk/editor/calendar.py b/GTG/gtk/editor/calendar.py
index 5496af1155..ff932abf82 100644
--- a/GTG/gtk/editor/calendar.py
+++ b/GTG/gtk/editor/calendar.py
@@ -49,6 +49,10 @@ def __init__(self):
def __init_gtk__(self):
self.__window = self.__builder.get_object("calendar")
+ self.__window.add_shortcut(Gtk.Shortcut.new(
+ Gtk.ShortcutTrigger.parse_string("Escape"),
+ Gtk.CallbackAction.new(self._esc_close)
+ ))
self.__calendar = self.__builder.get_object("calendar1")
self.__fuzzydate_btns = self.__builder.get_object("fuzzydate_btns")
self.__builder.get_object("button_clear").connect(
@@ -59,11 +63,6 @@ def __init_gtk__(self):
"clicked", lambda w: self.__day_selected(w, "soon"))
self.__builder.get_object("button_someday").connect(
"clicked", lambda w: self. __day_selected(w, "someday"))
- # allow fast closing by Escape key
- agr = Gtk.AccelGroup()
- self.add_accel_group(agr)
- key, modifier = Gtk.accelerator_parse('Escape')
- agr.connect(key, modifier, Gtk.AccelFlags.VISIBLE, self._esc_close)
def set_date(self, date, date_kind):
self.__date_kind = date_kind
@@ -76,70 +75,28 @@ def set_date(self, date, date_kind):
date = Date.today()
self.__date = date
if not date.is_fuzzy():
- date = date.date()
- self.__calendar.select_day(date.day)
- # Calendar use 0..11 for a month so we need -1
- # We can't use conversion through python's datetime
- # because it is often an invalid date
- self.__calendar.select_month(date.month - 1, date.year)
-
- def __mark_today_in_bold(self):
- """ Mark today in bold
-
- If the current showed month is the current month (has the same year
- and month), then make the current day bold. Otherwise no day should
- be bold.
- """
- today = datetime.date.today()
-
- # Get the current displayed month
- # (month must be corrected because calendar is 0-based)
- year, month, day = self.__calendar.get_date()
- month += 1
-
- if today.year == year and today.month == month:
- self.__calendar.mark_day(today.day)
- else:
- # If marked day is 31th, and the next month does not have 31th day,
- # unmark_day raises a warning. Clear_marks() is clever way how
- # to let GTK solve it's bussiness.
- self.__calendar.clear_marks()
-
- def move_calendar_inside(self, width, height, x, y):
- """ This method moves the calender inside the screen whenever part of
- it is displayed outside the screen """
- screen_width = Gdk.Screen.width()
- # To display calendar inside the screen when editor window is
- # outside leftside of the screen
- if x < width:
- self.__window.move(2, y - height)
- # To display calendar inside the screen when editor window is outside
- # rightside of the screen
- elif x > (screen_width - 2):
- self.__window.move(screen_width - width - 2, y - height)
- else:
- self.__window.move(x - width, y - height)
+ gtime = GLib.DateTime.new_local(
+ date.date().year, date.date().month, date.date().day, 0, 0, 0
+ )
+ self.__calendar.select_day(gtime)
def show(self):
self.__window.show()
if self.get_decorated():
- self.__window.connect("delete-event", self.close_calendar)
+ self.__window.connect("close-request", self.close_calendar)
else:
- self.__window_gesture_single = Gtk.GestureSingle(widget=self.__window)
- self.__window_gesture_single.connect('begin', self.__focus_out)
+ window_gesture_single = Gtk.GestureSingle()
+ window_gesture_single.connect('begin', self.__focus_out)
+ self.__window.add_controller(window_gesture_single)
self.__sigid = self.__calendar.connect("day-selected",
self.__day_selected,
"RealDate",)
-
- self.__sigid_month = self.__calendar.connect("month-changed",
- self.__month_changed)
# Problem: Gtk.Calendar does not tell you directly if the
# "day-selected" signal was caused by the user clicking on
# a date, or just browsing the calendar.
# Solution: we track that in a variable
self.__is_user_just_browsing_the_calendar = False
- self.__mark_today_in_bold()
def __focus_out(self, g=None, s=None):
w = g.get_widget()
@@ -155,9 +112,6 @@ def close_calendar(self, widget=None, e=None):
self.__calendar.disconnect(self.__sigid)
self.__sigid = None
- if self.__sigid_month is not None:
- self.__calendar.disconnect(self.__sigid_month)
- self.__sigid_month = None
return True
def __day_selected(self, widget, date_type):
@@ -178,17 +132,18 @@ def __day_selected(self, widget, date_type):
def __from_calendar_date_to_datetime(self, calendar_date):
"""
- Gtk.Calendar uses a 0-based convention for counting months.
- The rest of the world, including the datetime module, starts from 1.
+ Gtk.Calendar uses a GLib based convention for counting time.
+ The rest of the world, including the datetime module, doesn't use GLib.
This is a converter between the two. GTG follows the datetime
convention.
"""
- year, month, day = calendar_date
- return datetime.date(year, month + 1, day)
+ year, month, day = (calendar_date.get_year(),
+ calendar_date.get_month(),
+ calendar_date.get_day_of_month())
+ return datetime.date(year, month, day)
def __month_changed(self, widget):
self.__is_user_just_browsing_the_calendar = True
- self.__mark_today_in_bold()
def get_selected_date(self):
return self.__date, self.__date_kind
@@ -196,13 +151,9 @@ def get_selected_date(self):
def __getattr__(self, attr):
return getattr(self.__window, attr)
- def _esc_close(self, widget, event, arg1=None, arg2=None, arg3=None):
+ def _esc_close(self, widget=None, args=None):
"""
Callback: Close this window when pressing Escape.
-
- Arguments arg1-arg3 are needed to satisfy callback when closing
- by Escape
"""
-
self.close_calendar()
return True
diff --git a/GTG/gtk/editor/editor.py b/GTG/gtk/editor/editor.py
index 445bbea51b..c36f31fb01 100644
--- a/GTG/gtk/editor/editor.py
+++ b/GTG/gtk/editor/editor.py
@@ -34,129 +34,67 @@
from GTG.core.dirs import UI_DIR
from GTG.core.plugins.api import PluginAPI
from GTG.core.plugins.engine import PluginEngine
-from GTG.core.task import Task
from GTG.gtk.editor import GnomeConfig
from GTG.gtk.editor.calendar import GTGCalendar
from GTG.gtk.editor.recurring_menu import RecurringMenu
from GTG.gtk.editor.taskview import TaskView
from GTG.gtk.tag_completion import tag_filter
from GTG.gtk.colors import rgb_to_hex
-from GTG.core.tasks2 import Task2
+from GTG.core.tasks import Task, Status, DEFAULT_TITLE
-"""
-TODO (jakubbrindza): re-factor tag_filter into a separate module
-"""
log = logging.getLogger(__name__)
-class TaskEditor:
+@Gtk.Template(filename=os.path.join(UI_DIR, "task_editor.ui"))
+class TaskEditor(Gtk.Window):
+ __gtype_name__ = "TaskEditor"
- EDITOR_UI_FILE = os.path.join(UI_DIR, "task_editor.ui")
+ editormenu = Gtk.Template.Child("editor_menu")
+ donebutton = Gtk.Template.Child("mark_as_done")
+ undonebutton = Gtk.Template.Child("mark_as_undone")
+ add_subtask = Gtk.Template.Child()
+ tag_store = Gtk.Template.Child()
+ parent_button = Gtk.Template.Child("parent")
+ repeat_button = Gtk.Template.Child('set_repeat')
+ scrolled = Gtk.Template.Child("scrolledtask")
+ plugin_box = Gtk.Template.Child("pluginbox")
+
+ tags_entry = Gtk.Template.Child()
+ tags_tree = Gtk.Template.Child()
+
+ # Closed date
+ closed_box = Gtk.Template.Child()
+ closed_popover = Gtk.Template.Child()
+ closed_entry = Gtk.Template.Child("closeddate_entry")
+ closed_calendar = Gtk.Template.Child("calendar_closed")
+
+ # Start date
+ start_box = Gtk.Template.Child()
+ start_popover = Gtk.Template.Child()
+ start_entry = Gtk.Template.Child("startdate_entry")
+ start_calendar = Gtk.Template.Child("calendar_start")
+
+ # Due date
+ due_popover = Gtk.Template.Child()
+ due_entry = Gtk.Template.Child("duedate_entry")
+ due_calendar = Gtk.Template.Child("calendar_due")
+
+ def __init__(self, app, task):
+ super().__init__()
- def __init__(self,
- requester,
- app,
- task,
- thisisnew=False,
- clipboard=None):
- """
- req is the requester
- app is the view manager
- thisisnew is True when a new task is created and opened
- """
- self.req = requester
self.app = app
- self.browser_config = self.req.get_config('browser')
- self.config = self.req.get_task_config(task.get_id())
+ self.ds = app.ds
+ self.task = task
+ self.config = app.config_core
+ self.task_config = self.config.get_task_config(str(task.id))
self.time = None
- self.clipboard = clipboard
- self.builder = Gtk.Builder()
- self.builder.add_from_file(self.EDITOR_UI_FILE)
- self.editormenu = self.builder.get_object("editor_menu")
- self.donebutton = self.builder.get_object("mark_as_done")
- self.undonebutton = self.builder.get_object("mark_as_undone")
- self.add_subtask = self.builder.get_object("add_subtask")
- self.tag_store = self.builder.get_object("tag_store")
- self.parent_button = self.builder.get_object("parent")
-
- # Closed date
- self.closed_popover = self.builder.get_object("closed_popover")
- self.closed_entry = self.builder.get_object("closeddate_entry")
- self.closed_calendar = self.builder.get_object("calendar_closed")
-
- # Start date
- self.start_popover = self.builder.get_object("start_popover")
- self.start_entry = self.builder.get_object("startdate_entry")
- self.start_calendar = self.builder.get_object("calendar_start")
-
- # Due date
- self.due_popover = self.builder.get_object("due_popover")
- self.due_entry = self.builder.get_object("duedate_entry")
- self.due_calendar = self.builder.get_object("calendar_due")
+ self.clipboard = app.clipboard
+ use_dark = self.config.get_subconfig('browser')
- # Recurrence
- self.recurring_menu = RecurringMenu(self.req, task.tid, self.builder)
-
- # TODO: Remove old code when new core is stable
- # If new add to new DS
- if thisisnew and task.tid not in self.app.ds.tasks.lookup.keys():
- new_task = Task2(task.tid, task.get_title())
- self.app.ds.tasks.add(new_task)
-
-
- # Create our dictionary and connect it
- dic = {
- "on_tags_popover": self.open_tags_popover,
- "on_tag_toggled": self.on_tag_toggled,
-
- "on_move": self.on_move,
-
- "set_recurring_term_every_day": self.set_recurring_term_every_day,
- "set_recurring_term_every_otherday": self.set_recurring_term_every_otherday,
- "set_recurring_term_every_week": self.set_recurring_term_every_week,
- "set_recurring_term_every_month": self.set_recurring_term_every_month,
- "set_recurring_term_every_year": self.set_recurring_term_every_year,
- "set_recurring_term_week_day": self.set_recurring_term_week_day,
- "set_recurring_term_calender_month": self.set_recurring_term_month,
- "set_recurring_term_calender_year": self.set_recurring_term_year,
- "toggle_recurring_status": self.toggle_recurring_status,
- "on_repeat_icon_toggled": self.on_repeat_icon_toggled,
-
- "show_popover_start": self.show_popover_start,
- "startingdate_changed": lambda w: self.date_changed(
- w, GTGCalendar.DATE_KIND_START),
- "startdate_cleared": lambda w: self.on_date_cleared(
- w, GTGCalendar.DATE_KIND_START),
- "startdate_focus_out": lambda w, e: self.date_focus_out(
- w, e, GTGCalendar.DATE_KIND_START),
-
- "show_popover_due": self.show_popover_due,
- "duedate_changed": lambda w: self.date_changed(
- w, GTGCalendar.DATE_KIND_DUE),
- "duedate_now_selected": lambda w: self.on_duedate_fuzzy(
- w, Date.now()),
- "duedate_soon_selected": lambda w: self.on_duedate_fuzzy(
- w, Date.soon()),
- "duedate_someday_selected": lambda w: self.on_duedate_fuzzy(
- w, Date.someday()),
- "duedate_cleared": lambda w: self.on_date_cleared(
- w, GTGCalendar.DATE_KIND_DUE),
- "duedate_focus_out": lambda w, e: self.date_focus_out(
- w, e, GTGCalendar.DATE_KIND_DUE),
-
- "show_popover_closed": self.show_popover_closed,
- "closeddate_changed": lambda w: self.date_changed(
- w, GTGCalendar.DATE_KIND_CLOSED),
- "closeddate_focus_out": lambda w, e: self.date_focus_out(
- w, e, GTGCalendar.DATE_KIND_CLOSED),
- }
-
- self.window = self.builder.get_object("TaskEditor")
- self.builder.connect_signals(dic)
- self.window.set_application(app)
-
- if task.has_parent():
+ self.set_application(app)
+
+ if task.parent:
self.parent_button.set_label(_('Open Parent'))
else:
self.parent_button.set_label(_('Add Parent'))
@@ -170,24 +108,62 @@ def __init__(self,
self.closed_handle = self.closed_calendar.connect(
'day-selected', lambda c: self.on_date_selected(c, GTGCalendar.DATE_KIND_CLOSED))
+ start_entry_controller = Gtk.EventControllerFocus()
+ start_entry_controller.connect("enter", self.show_popover_start)
+ start_entry_controller.connect("leave", self.startdate_focus_out)
+ self.start_entry.add_controller(start_entry_controller)
+ due_entry_controller = Gtk.EventControllerFocus()
+ due_entry_controller.connect("enter", self.show_popover_due)
+ due_entry_controller.connect("leave", self.duedate_focus_out)
+ self.due_entry.add_controller(due_entry_controller)
+ closed_entry_controller = Gtk.EventControllerFocus()
+ closed_entry_controller.connect("enter", self.show_popover_closed)
+ closed_entry_controller.connect("leave", self.closeddate_focus_out)
+ self.closed_entry.add_controller(closed_entry_controller)
+
+ self.connect("notify::is-active", self.on_window_focus_change)
# Removing the Normal textview to replace it by our own
# So don't try to change anything with glade, this is a home-made
# widget
- textview = self.builder.get_object("textview")
- scrolled = self.builder.get_object("scrolledtask")
- scrolled.remove(textview)
- self.textview = TaskView(self.req, self.clipboard)
+ self.scrolled.set_child(None)
+ self.textview = TaskView(app.ds, task, self.clipboard, use_dark)
self.textview.set_vexpand(True)
- self.textview.show()
- scrolled.add(self.textview)
- conf_font_value = self.browser_config.get("font_name")
- if conf_font_value != "":
- self.textview.override_font(Pango.FontDescription(conf_font_value))
+ self.scrolled.set_child(self.textview)
+
+ browser_config = self.config.get_subconfig('browser')
+ conf_font_name = browser_config.get("font_name")
+ conf_font_size = browser_config.get("font_size")
+
+ if conf_font_name or conf_font_size:
+ provider = Gtk.CssProvider.new()
+ family_string = f'font-family:{conf_font_name};' if conf_font_name else ''
+ size_string = f'font-size:{conf_font_size}pt;' if conf_font_size else ''
+ provider.load_from_data(
+ f""".taskview {{
+ {family_string}
+ {size_string}
+ }}""".encode('utf-8')
+ )
+ self.textview.get_style_context().add_provider(
+ provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ )
+
+ # self.textview.browse_tag_cb = app.select_tag
+ # self.textview.new_subtask_cb = self.new_subtask
+ # self.textview.get_subtasks_cb = task.get_children
+ # self.textview.delete_subtask_cb = self.remove_subtask
+ # self.textview.rename_subtask_cb = self.rename_subtask
+ # self.textview.open_subtask_cb = self.open_subtask
+ # self.textview.save_cb = self.light_save
+ # self.textview.add_tasktag_cb = self.tag_added
+ # self.textview.remove_tasktag_cb = self.tag_removed
+ # self.textview.refresh_cb = self.refresh_editor
+ # self.textview.get_tagslist_cb = task.get_tags_name
+ # self.textview.tid = task.id
self.textview.browse_tag_cb = app.select_tag
self.textview.new_subtask_cb = self.new_subtask
- self.textview.get_subtasks_cb = task.get_children
self.textview.delete_subtask_cb = self.remove_subtask
self.textview.rename_subtask_cb = self.rename_subtask
self.textview.open_subtask_cb = self.open_subtask
@@ -195,28 +171,21 @@ def __init__(self,
self.textview.add_tasktag_cb = self.tag_added
self.textview.remove_tasktag_cb = self.tag_removed
self.textview.refresh_cb = self.refresh_editor
- self.textview.get_tagslist_cb = task.get_tags_name
- self.textview.tid = task.tid
+ self.textview.tid = task.id
# Voila! it's done
- self.textview.connect('focus-in-event', self.on_textview_focus_in)
- self.textview.connect('focus-out-event', self.on_textview_focus_out)
+ textview_focus_controller = Gtk.EventControllerFocus()
+ textview_focus_controller.connect("enter", self.on_textview_focus_in)
+ textview_focus_controller.connect("leave", self.on_textview_focus_out)
+ self.textview.add_controller(textview_focus_controller)
- """
- TODO(jakubbrindza): Once all the functionality in editor is back and
- working, bring back also the accelerators! Dayleft_label needs to be
- brought back, however its position is unsure.
- """
- # self.dayleft_label = self.builder.get_object("dayleft")
-
- self.task = task
- tags = task.get_tags()
- text = self.task.get_text()
- title = self.task.get_title()
+ tags = task.tags
+ text = self.task.content
+ title = self.task.title
# Insert text and tags as a non_undoable action, otherwise
# the user can CTRL+Z even this inserts.
- self.textview.buffer.begin_not_undoable_action()
+ self.textview.buffer.begin_irreversible_action()
self.textview.buffer.set_text(f"{title}\n")
if text:
@@ -224,40 +193,42 @@ def __init__(self,
# Insert any remaining tags
if tags:
- tag_names = [t.get_name() for t in tags]
+ tag_names = [t.name for t in tags]
self.textview.insert_tags(tag_names)
else:
# If not text, we insert tags
if tags:
- tag_names = [t.get_name() for t in tags]
+ tag_names = [t.name for t in tags]
self.textview.insert_tags(tag_names)
start = self.textview.buffer.get_end_iter()
self.textview.buffer.insert(start, '\n')
# Insert subtasks if they weren't inserted in the text
- subtasks = task.get_children()
+ subtasks = task.children
for sub in subtasks:
- if sub not in self.textview.subtasks['tags']:
+ if sub.id not in self.textview.subtasks['tags']:
self.textview.insert_existing_subtask(sub)
- if thisisnew:
- self.textview.select_title()
- else:
- self.task.set_to_keep()
+ # if thisisnew:
+ # self.textview.select_title()
+ # else:
+ # self.task.set_to_keep()
- self.textview.buffer.end_not_undoable_action()
- self.window.connect("destroy", self.destruction)
+ self.textview.buffer.end_irreversible_action()
# Connect search field to tags popup
- self.tags_entry = self.builder.get_object("tags_entry")
- self.tags_tree = self.builder.get_object("tags_tree")
-
self.tags_tree.set_search_entry(self.tags_entry)
self.tags_tree.set_search_equal_func(self.search_function, None)
- # plugins
+ # Recurrence
+ self.recurring_menu = RecurringMenu(self, task)
+ self.recurring_menu.connect('notify::is-task-recurring', self.sync_repeat_button)
+ self.repeat_button.set_popover(self.recurring_menu)
+ self.sync_repeat_button()
+
+ # Plugins
self.pengine = PluginEngine()
- self.plugin_api = PluginAPI(self.req, self.app, self)
+ self.plugin_api = PluginAPI(self.app, self)
self.pengine.register_api(self.plugin_api)
self.pengine.onTaskLoad(self.plugin_api)
@@ -267,44 +238,41 @@ def __init__(self,
self.init_dimensions()
- self.window.insert_action_group('app', app)
- self.window.insert_action_group('win', app.browser)
-
+ self.connect("close-request", self.destruction)
self.textview.set_editable(True)
- self.window.set_transient_for(self.app.browser)
- self.window.show()
+ self.set_transient_for(self.app.browser)
+ self.show()
def tag_added(self, name):
- self.task.tag_added(name)
- t = self.app.ds.tasks.get(self.task.tid)
- t.add_tag(self.app.ds.tags.new(name))
+ self.task.add_tag(self.ds.tags.new(name))
+ self.ds.tasks.notify('task_count_no_tags')
+ self.app.browser.sidebar.refresh_tags()
def tag_removed(self, name):
self.task.remove_tag(name)
- t = self.app.ds.tasks.get(self.task.tid)
- t.remove_tag(name)
+ self.ds.tasks.notify('task_count_no_tags')
+ self.app.browser.sidebar.refresh_tags()
- def show_popover_start(self, widget, event):
+ def show_popover_start(self, _=None):
"""Open the start date calendar popup."""
- start_date = (self.task.get_start_date() or Date.today()).date()
+ start_date = (self.task.date_start or Date.today()).date()
with signal_handler_block(self.start_calendar, self.start_handle):
- self.start_calendar.select_day(start_date.day)
- self.start_calendar.select_month(start_date.month - 1,
- start_date.year)
+ gtime = GLib.DateTime.new_local(start_date.year, start_date.month, start_date.day, 0, 0, 0)
+ self.start_calendar.select_day(gtime)
self.start_popover.popup()
- def show_popover_due(self, widget, popover):
+ def show_popover_due(self, _=None):
"""Open the due date calendar popup."""
- due_date = self.task.get_due_date()
+ due_date = self.task.date_due
if not due_date or due_date.is_fuzzy():
due_date = Date.today()
@@ -312,48 +280,43 @@ def show_popover_due(self, widget, popover):
due_date = due_date.date()
with signal_handler_block(self.due_calendar, self.due_handle):
- self.due_calendar.select_day(due_date.day)
- self.due_calendar.select_month(due_date.month - 1,
- due_date.year)
+ gtime = GLib.DateTime.new_local(due_date.year, due_date.month, due_date.day, 0, 0, 0)
+ self.due_calendar.select_day(gtime)
self.due_popover.popup()
- def show_popover_closed(self, widget, popover):
+ def show_popover_closed(self, _=None):
"""Open the closed date calendar popup."""
- closed_date = self.task.get_closed_date().date()
+ closed_date = self.task.date_closed
with signal_handler_block(self.closed_calendar, self.closed_handle):
- self.closed_calendar.select_day(closed_date.day)
- self.closed_calendar.select_month(closed_date.month - 1,
- closed_date.year)
+ gtime = GLib.DateTime.new_local(closed_date.year, closed_date.month, closed_date.day, 0, 0, 0)
+ self.closed_calendar.select_day(gtime)
self.closed_popover.popup()
- def open_tags_popover(self):
- self.tag_store.clear()
-
- tags = self.req.get_tag_tree().get_all_nodes()
+ @Gtk.Template.Callback()
+ def sync_tag_store(self, widget=None):
- used_tags = self.task.get_tags()
+ self.tag_store.clear()
+ used = set()
- special_tags = ["search",
- "gtg-tags-all",
- "gtg-tags-none",
- "gtg-tags-sep"]
-
- for tagname in tags:
- tag = self.req.get_tag(tagname)
- if tag_filter(tag) \
- and tagname not in special_tags \
- and "__SAVED_SEARCH_" not in tagname:
- is_used = tag in used_tags
- self.tag_store.append([is_used, tagname])
- """
- TODO(jakubbrindza): add sorting of the tags based on
- True | False and within each sub-group arrange them
- alphabetically
- """
+ for used_tag in self.task.tags:
+ # First parameter marks the tag as used
+ self.tag_store.append((True, used_tag.name))
+ used.add(used_tag.name)
+
+ for tag_name in self.ds.tags.lookup_names.keys():
+ if tag_name not in used:
+ self.tag_store.append((False, tag_name))
+
+
+ def sync_repeat_button(self, object=None, pspec=None):
+ if self.recurring_menu.is_task_recurring:
+ self.repeat_button.add_css_class('recurring-active')
+ else:
+ self.repeat_button.remove_css_class('recurring-active')
def set_dismissable_in_menu(self, dismissable):
"""
@@ -384,6 +347,7 @@ def set_dismissable_in_menu(self, dismissable):
return
i += 1
+ @Gtk.Template.Callback()
def on_tag_toggled(self, widget, path, column):
"""We toggle by tag_row variable. tag_row is
meant to be a tuple (is_used, tagname)"""
@@ -396,60 +360,54 @@ def on_tag_toggled(self, widget, path, column):
TODO(jakubbrindza): Add else case that will remove tag.
"""
- def on_repeat_icon_toggled(self, widget):
- """ Reset popup stack to the first page every time you open it """
- if widget.get_active():
- self.recurring_menu.reset_stack()
+ @Gtk.Template.Callback()
+ def startingdate_changed(self, w):
+ self.date_changed(w, GTGCalendar.DATE_KIND_START)
- def toggle_recurring_status(self, widget):
- self.recurring_menu.update_repeat_checkbox()
- self.refresh_editor()
+ @Gtk.Template.Callback()
+ def startdate_today(self, w):
+ # change the day to something other than the current day
+ # so that we could select it
+ current_day = self.start_calendar.get_property('day')
+ self.start_calendar.set_property('day', 1 if current_day > 1 else 2)
+ self.start_calendar.select_day(GLib.DateTime.new_now_local())
- def set_recurring_term_every_day(self, widget):
- self.recurring_menu.set_selected_term('day')
- self.recurring_menu.update_term()
- self.refresh_editor()
+ @Gtk.Template.Callback()
+ def startdate_cleared(self, w):
+ self.on_date_cleared(w, GTGCalendar.DATE_KIND_START)
- def set_recurring_term_every_otherday(self, widget):
- self.recurring_menu.set_selected_term('other-day')
- self.recurring_menu.update_term()
- self.refresh_editor()
+ def startdate_focus_out(self, c):
+ self.date_focus_out(c.get_widget(), GTGCalendar.DATE_KIND_START)
- def set_recurring_term_every_week(self, widget):
- self.recurring_menu.set_selected_term('week')
- self.recurring_menu.update_term()
- self.refresh_editor()
+ @Gtk.Template.Callback()
+ def duedate_changed(self, w):
+ self.date_changed(w, GTGCalendar.DATE_KIND_DUE)
- def set_recurring_term_every_month(self, widget):
- self.recurring_menu.set_selected_term('month')
- self.recurring_menu.update_term()
- self.refresh_editor()
+ @Gtk.Template.Callback()
+ def duedate_now_selected(self, w):
+ self.on_duedate_fuzzy(w, Date.now())
- def set_recurring_term_every_year(self, widget):
- self.recurring_menu.set_selected_term('year')
- self.recurring_menu.update_term()
- self.refresh_editor()
+ @Gtk.Template.Callback()
+ def duedate_soon_selected(self, w):
+ self.on_duedate_fuzzy(w, Date.soon())
- def set_recurring_term_week_day(self, widget):
- self.recurring_menu.set_selected_term(widget.get_property("name"))
- self.recurring_menu.update_term()
- self.refresh_editor()
+ @Gtk.Template.Callback()
+ def duedate_someday_selected(self, w):
+ self.on_duedate_fuzzy(w, Date.someday())
- def set_recurring_term_month(self, widget):
- self.recurring_menu.set_selected_term(str(widget.get_date()[2]))
- self.recurring_menu.update_term()
- self.refresh_editor()
+ @Gtk.Template.Callback()
+ def duedate_cleared(self, w):
+ self.on_date_cleared(w, GTGCalendar.DATE_KIND_DUE)
- def set_recurring_term_year(self, widget):
- month = str(widget.get_date()[1] + 1)
- day = str(widget.get_date()[2])
- if len(month) < 2:
- month = "0" + month
- if len(day) < 2:
- day = "0" + day
- self.recurring_menu.set_selected_term(month + day)
- self.recurring_menu.update_term()
- self.refresh_editor()
+ def duedate_focus_out(self, c):
+ self.date_focus_out(c.get_widget(), GTGCalendar.DATE_KIND_DUE)
+
+ @Gtk.Template.Callback()
+ def closeddate_changed(self, w):
+ self.date_changed(w, GTGCalendar.DATE_KIND_CLOSED)
+
+ def closeddate_focus_out(self, c):
+ self.date_focus_out(c.get_widget(), GTGCalendar.DATE_KIND_CLOSED)
def search_function(self, model, column, key, iter, *search_data):
"""Callback when searching in the tags popup."""
@@ -467,57 +425,16 @@ def get_monitor_dimensions() -> Gdk.Rectangle:
return Gdk.Display.get_default().get_monitor(0).get_geometry()
def init_dimensions(self):
- """ Restores position and size of task if possible """
+ """ Sets up size of task if possible """
- position = self.config.get('position')
- size = self.config.get('size')
- screen_size = self.get_monitor_dimensions()
+ size = self.task_config.get('size')
- if size and len(size) == 2:
+ if size:
try:
- self.window.resize(int(size[0]), int(size[1]))
- except ValueError:
+ self.set_default_size(int(size[0]), int(size[1]))
+ except ValueError as e:
log.warning('Invalid size configuration for task %s: %s',
- self.task.get_id(), size)
-
- can_move = True
- if position and len(position) == 2:
- try:
- x = max(0, int(position[0]))
- y = max(0, int(position[1]))
- can_move = True
- except ValueError:
- can_move = False
- log.warning('Invalid position configuration for task %s:%s',
- self.task.get_id(), position)
- else:
- gdk_window = self.window.get_window()
- if gdk_window is None:
- log.debug("Using default display to position editor window")
- display = Gdk.Display.get_default()
- else:
- # TODO: AFAIK never happens because window is not realized at
- # this point, but maybe we should just in case the display
- # is actually different.
- display = gdk_window.get_display()
- seat = display.get_default_seat()
- pointer = seat.get_pointer()
- if pointer is None:
- can_move = False
- log.debug("Didn't receiver pointer info, can't move editor window")
- else:
- screen, x, y = pointer.get_position()
- assert isinstance(x, int)
- assert isinstance(y, int)
-
- if can_move:
- width, height = self.window.get_size()
-
- # Clamp positions to current screen size
- x = min(x, screen_size.width - width)
- y = min(y, screen_size.height - height)
-
- self.window.move(x, y)
+ self.task.id, size)
# Can be called at any time to reflect the status of the Task
# Refresh should never interfere with the TaskView.
@@ -526,22 +443,24 @@ def init_dimensions(self):
# Refreshtext is whether or not we should refresh the TaskView
# (doing it all the time is dangerous if the task is empty)
def refresh_editor(self, title=None, refreshtext=False):
- if self.window is None:
+ if self is None:
return
+
to_save = False
+
# title of the window
if title:
- self.window.set_title(title)
+ self.set_title(title)
to_save = True
else:
- self.window.set_title(self.task.get_title())
+ self.set_title(self.task.title)
- status = self.task.get_status()
- if status == Task.STA_DISMISSED:
+ status = self.task.status
+ if status == Status.ACTIVE:
self.donebutton.show()
self.undonebutton.hide()
self.set_dismissable_in_menu(False)
- elif status == Task.STA_DONE:
+ elif status == Status.DONE:
self.donebutton.hide()
self.undonebutton.show()
self.set_dismissable_in_menu(True)
@@ -551,7 +470,7 @@ def refresh_editor(self, title=None, refreshtext=False):
self.set_dismissable_in_menu(True)
# Refreshing the parent button
- if self.task.has_parent():
+ if self.task.parent:
# Translators: Button label to open the parent task
self.parent_button.set_label(_('Open Parent'))
else:
@@ -559,15 +478,15 @@ def refresh_editor(self, title=None, refreshtext=False):
self.parent_button.set_label(_('Add Parent'))
# Refreshing the status bar labels and date boxes
- if status in [Task.STA_DISMISSED, Task.STA_DONE]:
- self.builder.get_object("start_box").hide()
- self.builder.get_object("closed_box").show()
+ if status in [Status.DISMISSED, Status.DONE]:
+ self.start_box.hide()
+ self.closed_box.show()
else:
- self.builder.get_object("closed_box").hide()
- self.builder.get_object("start_box").show()
+ self.closed_box.hide()
+ self.start_box.show()
# refreshing the start date field
- startdate = self.task.get_start_date()
+ startdate = self.task.date_start
try:
prevdate = Date.parse(self.start_entry.get_text())
update_date = startdate != prevdate
@@ -578,7 +497,7 @@ def refresh_editor(self, title=None, refreshtext=False):
self.start_entry.set_text(startdate.localized_str)
# refreshing the due date field
- duedate = self.task.get_due_date()
+ duedate = self.task.date_due
try:
prevdate = Date.parse(self.due_entry.get_text())
update_date = duedate != prevdate
@@ -589,18 +508,12 @@ def refresh_editor(self, title=None, refreshtext=False):
self.due_entry.set_text(duedate.localized_str)
# refreshing the closed date field
- closeddate = self.task.get_closed_date()
+ closeddate = self.task.date_closed
prevcldate = Date.parse(self.closed_entry.get_text())
+
if closeddate != prevcldate:
self.closed_entry.set_text(closeddate.localized_str)
- # refreshing the day left label
- """
- TODO(jakubbrindza): re-enable refreshing the day left.
- We need to come up how and where this information is viewed
- in the editor window.
- """
- # self.refresh_day_left()
if refreshtext:
self.textview.modified(refresheditor=False)
@@ -642,22 +555,26 @@ def refresh_day_left(self):
txt = ngettext("Due yesterday!", "Was %(days)d days ago",
abs_result) % {'days': abs_result}
- style_context = self.window.get_style_context()
- color = style_context.get_color(Gtk.StateFlags.INSENSITIVE).to_color()
+ style_context = self.get_style_context()
+ color = style_context.get_color()
self.dayleft_label.set_markup(
f"{txt}")
def reload_editor(self):
task = self.task
textview = self.textview
- task_text = task.get_text()
- task_title = task.get_title()
+ task_text = task.content
+ task_title = task.title
+
textview.set_text(f"{task_title}\n")
+
if task_text:
textview.insert(f"{task_text}")
- task.set_title(task_title)
+
+ task.title = task_title
textview.modified(full=True)
+
def date_changed(self, widget, data):
try:
Date.parse(widget.get_text())
@@ -666,73 +583,59 @@ def date_changed(self, widget, data):
valid = False
if valid:
- # If the date is valid, we write with default color in the widget
- # "none" will set the default color.
- widget.override_color(Gtk.StateType.NORMAL, None)
- widget.override_background_color(Gtk.StateType.NORMAL, None)
+ widget.remove_css_class("error")
else:
# We should write in red in the entry if the date is not valid
- text_color = Gdk.RGBA()
- text_color.parse("#F00")
- widget.override_color(Gtk.StateType.NORMAL, text_color)
-
- bg_color = Gdk.RGBA()
- bg_color.parse("#F88")
- widget.override_background_color(Gtk.StateType.NORMAL, bg_color)
+ widget.add_css_class("error")
- def date_focus_out(self, widget, event, date_kind):
+ def date_focus_out(self, widget, date_kind):
try:
datetoset = Date.parse(widget.get_text())
except ValueError:
datetoset = None
if datetoset is not None:
- # TODO: New Core
- t = self.app.ds.tasks.get(self.task.tid)
if date_kind == GTGCalendar.DATE_KIND_START:
- self.task.set_start_date(datetoset)
- t.date_start = datetoset
+ self.task.date_start = datetoset
self.start_popover.popdown()
elif date_kind == GTGCalendar.DATE_KIND_DUE:
- self.task.set_due_date(datetoset)
- t.date_due = datetoset
+ self.task.date_due = datetoset
self.due_popover.popdown()
elif date_kind == GTGCalendar.DATE_KIND_CLOSED:
- self.task.set_closed_date(datetoset)
- t.date_closed = datetoset
+ self.task.date_closed = datetoset
self.closed_popover.popdown()
self.refresh_editor()
def calendar_to_datetime(self, calendar):
"""
- Gtk.Calendar uses a 0-based convention for counting months.
- The rest of the world, including the datetime module, starts from 1.
+ Gtk.Calendar uses a GLib based convention for counting time.
+ The rest of the world, including the datetime module, doesn't use GLib.
This is a converter between the two. GTG follows the datetime
convention.
"""
-
- year, month, day = calendar.get_date()
- return datetime.date(year, month + 1, day)
+ gtime = calendar.get_date()
+ year, month, day = gtime.get_year(), gtime.get_month(), gtime.get_day_of_month()
+ return datetime.date(year, month, day)
def on_duedate_fuzzy(self, widget, date):
""" Callback when a fuzzy date is selected through the popup. """
- self.task.set_due_date(date)
+ self.task.date_due = date
self.due_entry.set_text(date.localized_str)
def on_date_cleared(self, widget, kind):
""" Callback when a date is cleared through the popups. """
if kind == GTGCalendar.DATE_KIND_START:
- self.task.set_start_date(Date.no_date())
+ self.task.date_start = Date.no_date()
self.start_entry.set_text('')
elif kind == GTGCalendar.DATE_KIND_DUE:
- self.task.set_due_date(Date.no_date())
+ self.task.date_due = Date.no_date()
self.due_entry.set_text('')
def on_date_selected(self, calendar, kind):
@@ -741,109 +644,101 @@ def on_date_selected(self, calendar, kind):
date = self.calendar_to_datetime(calendar)
if kind == GTGCalendar.DATE_KIND_START:
- self.task.set_start_date(Date(date))
+ self.task.date_start = Date(date)
self.start_entry.set_text(Date(date).localized_str)
elif kind == GTGCalendar.DATE_KIND_DUE:
- self.task.set_due_date(Date(date))
+ self.task.date_due = Date(date)
self.due_entry.set_text(Date(date).localized_str)
elif kind == GTGCalendar.DATE_KIND_CLOSED:
- self.task.set_closed_date(Date(date))
+ self.task.date_closed = Date(date)
self.closed_entry.set_text(Date(date).localized_str)
def on_date_changed(self, calendar):
date, date_kind = calendar.get_selected_date()
if date_kind == GTGCalendar.DATE_KIND_DUE:
- self.task.set_due_date(date)
+ self.task.date_due = date
elif date_kind == GTGCalendar.DATE_KIND_START:
- self.task.set_start_date(date)
+ self.task.date_start = date
elif date_kind == GTGCalendar.DATE_KIND_CLOSED:
- self.task.set_closed_date(date)
+ self.task.date_closed = date
self.refresh_editor()
def close_all_subtasks(self):
all_subtasks = []
def trace_subtasks(root):
- for i in root.get_subtasks():
- if i not in all_subtasks:
- all_subtasks.append(i)
- trace_subtasks(i)
+ for c in root.children:
+ if c.id not in all_subtasks:
+ all_subtasks.append(c)
+ trace_subtasks(c)
trace_subtasks(self.task)
for task in all_subtasks:
- self.app.close_task(task.get_id())
+ self.app.close_task(task.id)
def dismiss(self):
- stat = self.task.get_status()
- t = self.app.ds.tasks.get(self.task.tid)
- t.toggle_dismissed()
+ self.task.toggle_dismissed()
+ self.refresh_editor()
- if stat == Task.STA_DISMISSED:
- self.task.set_status(Task.STA_ACTIVE)
- self.refresh_editor()
- else:
- self.task.set_status(Task.STA_DISMISSED)
+ if self.task.status != Status.ACTIVE:
self.close_all_subtasks()
- self.close(None)
+ self.close()
+
def change_status(self):
- stat = self.task.get_status()
- t = self.app.ds.tasks.get(self.task.tid)
- t.toggle_active()
+ self.task.toggle_active()
+ self.refresh_editor()
- if stat == Task.STA_DONE:
- self.task.set_status(Task.STA_ACTIVE)
- self.refresh_editor()
- else:
- self.task.set_status(Task.STA_DONE)
+ if self.task.status != Status.ACTIVE:
self.close_all_subtasks()
- self.close(None)
+ self.close()
- def reopen(self):
- t = self.app.ds.tasks.get(self.task.tid)
- t.toggle_active()
- self.task.set_status(Task.STA_ACTIVE)
+ def reopen(self):
+ self.task.toggle_active()
self.refresh_editor()
def open_subtask(self, tid):
"""Open subtask (closing parent task)."""
- task = self.req.get_task(tid)
- self.app.open_task(tid)
- self.app.close_task(task.parents[0])
+ task = self.ds.tasks.lookup[tid]
+ self.app.open_task(task)
+
+ if task.parent:
+ self.app.close_task(task.parent.id)
- # Take the title as argument and return the subtask ID
def new_subtask(self, title=None, tid=None):
if tid:
- self.task.add_child(tid)
- self.app.ds.tasks.parent(self.task.tid, tid)
- elif title:
- subt = self.task.new_subtask()
- subt.set_title(title)
- tid = subt.get_id()
-
- # TODO: New Core
- t = self.app.ds.tasks.new(title, self.task.tid)
- t.id = tid
+ self.app.ds.tasks.parent(self.task.id, tid)
+
+ return self.app.ds.tasks.lookup[tid]
+
+ elif title and not tid:
+ t = self.app.ds.tasks.new(title, self.task.id)
+ tid = t.id
self.app.ds.tasks.refresh_lookup_cache()
- return tid
+ return t
+
+ elif title and tid:
+ t = self.app.ds.tasks.new(title, self.task.id)
+ t.id = tid
+ self.app.ds.tasks.refresh_lookup_cache()
+
+ return t
def remove_subtask(self, tid):
"""Remove a subtask of this task."""
- self.task.remove_child(tid)
- self.app.ds.tasks.unparent(tid, self.task.tid)
+ self.app.ds.tasks.unparent(tid, self.task.id)
def rename_subtask(self, tid, new_title):
"""Rename a subtask of this task."""
try:
- self.req.get_task(tid).set_title(new_title)
self.app.ds.tasks.get(tid).title = new_title
except (AttributeError, KeyError):
# There's no task at that tid
@@ -867,34 +762,23 @@ def open_parent(self):
then close the child to avoid various window management issues
and to prevent visible content divergence when the child title changes.
"""
- parents = self.task.get_parents()
+ parent = self.task.parent
self.save()
- if not parents:
- tags = [t.get_name() for t in self.task.get_tags()]
- parent = self.req.new_task(tags=tags, newtask=True)
- parent_id = parent.get_id()
-
- self.task.set_parent(parent_id)
-
- # TODO: New Core, remove old code when stable
- parent2 = Task2(title=_('New Task'), id=parent_id)
- parent2.tags = self.app.ds.tasks.get(self.task.tid).tags
- self.app.ds.tasks.add(parent2)
- self.app.ds.tasks.parent(self.task.tid, parent2.id)
+ if not parent:
+ parent = self.ds.tasks.new()
+ parent.tags = self.task.tags
+ self.app.ds.tasks.parent(self.task.id, parent.id)
+ self.app.open_task(parent)
- self.app.open_task(parent_id)
# Prevent WM issues and risks of conflicting content changes:
self.close()
- elif len(parents) == 1:
- self.app.open_task(parents[0])
- # Prevent WM issues and risks of conflicting content changes:
+ else:
+ self.app.open_task(parent)
self.close()
- elif len(parents) > 1:
- self.show_multiple_parent_popover(parents)
def show_multiple_parent_popover(self, parent_ids):
parent_box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
@@ -902,14 +786,12 @@ def show_multiple_parent_popover(self, parent_ids):
parent_name = self.req.get_task(parent).get_title()
button = Gtk.ToolButton.new(None, parent_name)
button.connect("clicked", self.on_parent_item_clicked, parent)
- parent_box.add(button)
+ parent_box.append(button)
self.parent_popover = Gtk.Popover.new(self.parent_button)
- self.parent_popover.add(parent_box)
- self.parent_popover.set_property("border-width", 0)
- self.parent_popover.set_position(Gtk.PositionType.BOTTOM)
+ self.parent_popover.set_child(parent_box)
self.parent_popover.set_transitions_enabled(True)
- self.parent_popover.show_all()
+ self.parent_popover.show()
# On click handler for open_parent menu items in the case of multiple parents
def on_parent_item_clicked(self, widget, parent_id):
@@ -920,19 +802,16 @@ def on_parent_item_clicked(self, widget, parent_id):
self.close()
def save(self):
- self.task.set_title(self.textview.get_title())
- self.task.set_text(self.textview.get_text())
- self.task.sync()
- if self.config is not None:
- self.config.save()
- self.time = time.time()
-
- # TODO: New core, remove previous code once stable
- t = self.app.ds.tasks.get(self.task.tid)
+ t = self.app.ds.tasks.get(self.task.id)
t.title = self.textview.get_title()
- t.contents = self.textview.get_text()
+ t.content = self.textview.get_text()
self.app.ds.save()
+ if self.task_config is not None:
+ self.task_config.save()
+
+ self.time = time.time()
+
# light_save save the task without refreshing every 30seconds
# We will reduce the time when the get_text will be in another thread
@@ -949,59 +828,73 @@ def light_save(self):
if tosave:
self.save()
- def present(self):
- # This tries to bring the Task Editor to the front.
- # If TaskEditor is a "utility" window type, this doesn't work on X11,
- # it only works on GNOME's Wayland session, unless the child is closed.
- # This is partly why we use self.close() in various places elsewhere.
- self.window.present()
-
- def get_position(self):
- return self.window.get_position()
-
- def on_move(self, widget, event):
- """ Save position and size of window """
+ @Gtk.Template.Callback()
+ def on_resize(self, widget, gparam):
+ """ Save size of window """
+ if self.get_realized():
+ self.task_config.set('size', list(self.get_default_size()))
- self.config.set('position', list(self.window.get_position()))
- self.config.set('size', list(self.window.get_size()))
-
- def on_textview_focus_in(self, widget, event):
+ def on_textview_focus_in(self, controller):
self.app.browser.toggle_delete_accel(False)
- def on_textview_focus_out(self, widget, event):
+ def on_textview_focus_out(self, controller):
self.app.browser.toggle_delete_accel(True)
+ def on_window_focus_change(self, window, gparam):
+ # if they are not popped down, alt-tab will look broken
+ if not self.is_active():
+ self.start_popover.popdown()
+ self.due_popover.popdown()
+ self.closed_popover.popdown()
+ # when focus returns on the window, the focus hasn't moved,
+ # so the focus callbacks won't fire to bring back the popovers
+ else:
+ # HACK: the text inside the entry is focused, not the entry itself.
+ # so we get the parent of the text, which is one of the entries.
+ # This is an implementation detail and can change at any moment.
+ # (get_parent() is basically undefined)
+ focus = self.get_focus().get_parent()
+ if focus == self.start_entry:
+ self.show_popover_start()
+ elif focus == self.due_entry:
+ self.show_popover_due()
+ elif focus == self.closed_entry:
+ self.show_popover_closed()
+
# We define dummy variable for when close is called from a callback
def close(self, action=None, param=None):
# We should also destroy the whole taskeditor object.
- if self.window:
- self.window.destroy()
- self.window = None
+ if self:
+ self.destruction()
+ super().close()
+ self = None
+
+
+ def is_new(self) -> bool:
+ return (self.task.title == DEFAULT_TITLE
+ and self.textview.get_text() == '')
+
def destruction(self, _=None):
- """Callback when destroying the window."""
+ """Callback when closing the window."""
# Save should be also called when buffer is modified
- self.pengine.onTaskClose(self.plugin_api)
- self.pengine.remove_api(self.plugin_api)
+ # self.pengine.onTaskClose(self.plugin_api)
+ # self.pengine.remove_api(self.plugin_api)
- tid = self.task.get_id()
+ tid = self.task.id
- if self.task.is_new():
- self.req.delete_task(tid)
+ if self.is_new():
self.app.ds.tasks.remove(tid)
else:
self.save()
- [sub.set_to_keep() for sub in self.task.get_subtasks() if sub]
try:
del self.app.open_tasks[tid]
except KeyError:
log.debug('Task %s was already removed from the open list', tid)
- def get_builder(self):
- return self.builder
def get_task(self):
return self.task
@@ -1010,5 +903,11 @@ def get_textview(self):
return self.textview
def get_window(self):
- return self.window
+ return self
+
+ def get_menu(self):
+ return self.editormenu
+
+ def get_plugin_box(self):
+ return self.plugin_box
# -----------------------------------------------------------------------------
diff --git a/GTG/gtk/editor/recurring_menu.py b/GTG/gtk/editor/recurring_menu.py
index dbff857064..ada27e7df9 100644
--- a/GTG/gtk/editor/recurring_menu.py
+++ b/GTG/gtk/editor/recurring_menu.py
@@ -16,141 +16,205 @@
# this program. If not, see .
# -----------------------------------------------------------------------------
+import os
from gettext import gettext as _
from datetime import datetime
+from gi.repository import Gtk, Gio, GLib, GObject
+from GTG.core.dirs import UI_DIR
-class RecurringMenu():
+@Gtk.Template(filename=os.path.join(UI_DIR, 'recurring_menu.ui'))
+class RecurringMenu(Gtk.PopoverMenu):
"""Provides a simple layer of abstraction
for the menu where the user enables a task to be repeating
"""
- def __init__(self, requester, tid, builder):
+ __gtype_name__ = 'RecurringMenu'
+
+ _menu_model = Gtk.Template.Child()
+
+ _month_calendar = Gtk.Template.Child()
+ _year_calendar = Gtk.Template.Child()
+
+ def __init__(self, editor, task):
+ # Setting up the actions
+ # Before super().__init__ as install_*_action acts on the class,
+ # and in this case doesn't work for all instances if you do it after
+ # initialization.
+ prefix = 'recurring_menu'
+ for action_disc in [
+ ('is_recurring', 'is-task-recurring'),
+ ('recurr_every_day', self._on_recurr_every_day, None),
+ ('recurr_every_otherday', self._on_recurr_every_otherday, None),
+ ('recurr_every_week', self._on_recurr_every_week, None),
+ ('recurr_week_day', self._on_recurr_week_day, 's'),
+ ('recurr_month_today', self._on_recurr_month_today, None),
+ ('recurr_year_today', self._on_recurr_year_today, None),
+ ]:
+ # is property action (property name instead of callback)
+ if type(action_disc[1]) == str:
+ self.install_property_action('.'.join([prefix, action_disc[0]]), action_disc[1])
+ else:
+ self.install_action('.'.join([prefix, action_disc[0]]), action_disc[2], action_disc[1])
+
+ super().__init__()
+
# General attributes
- self.task = requester.get_task(tid)
- self.selected_recurring_term = self.task.get_recurring_term()
-
- # Getting the necessary Gtk objects
- self.title = builder.get_object('title_label')
- self.title_separator = builder.get_object('title_separator')
- self.repeat_checkbox = builder.get_object('repeat_checkbutton')
- self.repeat_icon = builder.get_object('repeat_icon')
- self.icon_style = self.repeat_icon.get_style_context()
- self.stack = builder.get_object('main_stack')
- self.page1 = builder.get_object('stack_main_box')
- self._monthly_calendar = builder.get_object('month_calender')
- self._yearly_calendar = builder.get_object('year_calender')
-
- # Update the editor using the task recurring status
- self.update_header()
- self.update_calendar()
- self.repeat_checkbox.set_active(self.task.get_recurring())
- if self.task.get_recurring():
- self.icon_style.add_class('recurring-active')
-
- def update_repeat_button_icon(self, active=True):
- """ Update the icon color of the repeat-menu-button in the task editor """
- if active:
- self.icon_style.add_class('recurring-active')
- else:
- self.icon_style.remove_class('recurring-active')
+ self.task = task
+ self._editor = editor
+ self._selected_recurring_term = self.task.recurring_term
- def is_term_set(self):
- return self.selected_recurring_term is not None
+ self._is_header_menu_item_shown = False
- def set_selected_term(self, string):
- self.selected_recurring_term = string
+ self._update_header()
+ self._update_calendar()
- def update_repeat_checkbox(self):
+ # Prevent user from switching month in month calendar with
+ # scrollwheel.
+ # Why not only set month? Because if the month is the first or last
+ # of the year, the year switches and then we switch the month, which
+ # won't be the same one.
+ self._original_month = self._month_calendar.props.month
+ self._original_year = self._month_calendar.props.year
+ self._month_calendar.connect(
+ 'notify::month',
+ lambda o, g : self._month_calendar.set_property('month', self._original_month)
+ )
+ self._month_calendar.connect(
+ 'notify::year',
+ lambda o, g : self._month_calendar.set_property('year', self._original_year)
+ )
+
+ @GObject.Property(type=bool, default=False)
+ def is_task_recurring(self):
"""
- Update the task object recurring status and all indicators
- according to the repeat-checkbox-button status
+ Wrapper property for changing the tasks recurring status because
+ to have a checkbutton in a GtkPopoverMenu you need to use a
+ GPropertyAction, however the tag class itself doesn't use GObject
+ properties.
"""
- if self.repeat_checkbox.get_active():
- if not self.is_term_set():
- self.set_selected_term('day')
- self.update_term()
- self.update_repeat_button_icon()
+ return self.task._is_recurring
+
+ @is_task_recurring.setter
+ def is_task_recurring(self, recurs: bool):
+ if recurs:
+ if not self._is_term_set():
+ self._set_selected_term('day')
+ self._update_term()
else:
- self.update_task(False)
- self.update_repeat_button_icon(active=False)
+ self._update_task(False)
+ self._update_header()
+
+ self._editor.refresh_editor()
+
+ def _is_term_set(self):
+ return self._selected_recurring_term is not None
+
+ def _set_selected_term(self, string):
+ self._selected_recurring_term = string
+
+ def _update_term(self):
+ """
+ Update the header and the underlying task object.
+ NOTE: You should not call this, but set the GObject property instead
+ to ensure that the check in the menu is in sync.
+ """
+ self._update_task(True)
+ self._update_header()
+ self._update_calendar()
- def update_term(self):
+ def _update_task(self, enable=True):
"""
- Update the header and the task object(only if the repeat-checkbutton is checked)
- when a new term was selected
+ Updates the task object.
+ NOTE: You should not call this, but set the GObject property instead
+ to ensure that the check in the menu is in sync.
"""
- if not self.repeat_checkbox.get_active():
- self.repeat_checkbox.set_active(True)
- self.update_task(True)
- self.update_header()
- # self.update_calendar() would cause infinite recursion
-
- def update_task(self, enable=True):
- """ Updates the task object """
if enable:
- self.task.set_recurring(enable, self.selected_recurring_term, newtask=True)
+ self.task.set_recurring(enable, self._selected_recurring_term, newtask=True)
else:
self.task.set_recurring(enable)
- def update_header(self):
+ def _update_header(self):
""" Updates the header anytime a term is selected """
- if self.is_term_set():
- if self.selected_recurring_term.isdigit():
- if len(self.selected_recurring_term) <= 2: # Recurring monthly from selected date
+ if self._is_term_set():
+ if self._selected_recurring_term.isdigit():
+ if len(self._selected_recurring_term) <= 2: # Recurring monthly from selected date
# Translators: Recurring monthly
- mdval = datetime.strptime(f'{self.selected_recurring_term}', '%d')
+ mdval = datetime.strptime(f'{self._selected_recurring_term}', '%d')
mdval = mdval.strftime('%d')
- self.title.set_markup(_('Every {month_day} of the month').format(
- month_day=mdval))
+ markup = _('Every {month_day} of the month').format(month_day=mdval)
else: # Recurring yearly from selected date
- val = f'{self.selected_recurring_term[:2:]}-{self.selected_recurring_term[2::]}'
+ val = f'{self._selected_recurring_term[:2:]}-{self._selected_recurring_term[2::]}'
date = datetime.strptime(val, '%m-%d')
# Translators: Recurring yearly
- self.title.set_markup(_('Every {month} {day}').format(
- month=date.strftime('%B'), day=date.strftime('%d')))
- elif self.selected_recurring_term == 'day': # Recurring daily
- self.title.set_markup(_('Every day'))
- elif self.selected_recurring_term == 'other-day': # Recurring every other day
- self.title.set_markup(_('Every other day'))
- elif self.selected_recurring_term == 'week': # Recurring weekly from today
- self.title.set_markup(_('Every {week_day}').format(
- week_day=self.task.get_recurring_updated_date().strftime('%A')))
- elif self.selected_recurring_term == 'month': # Recurring monthly from today
- self.title.set_markup(_('Every {month_day} of the month').format(
- month_day=self.task.get_recurring_updated_date().strftime('%d')))
- elif self.selected_recurring_term == 'year': # Recurring yearly from today
- date = self.task.get_recurring_updated_date()
- self.title.set_markup(_('Every {month} {day}').format(
- month=date.strftime('%B'), day=date.strftime('%d')))
+ markup = _('Every {month} {day}').format(
+ month=date.strftime('%B'), day=date.strftime('%d'))
+ elif self._selected_recurring_term == 'day': # Recurring daily
+ markup = _('Every day')
+ elif self._selected_recurring_term == 'other-day': # Recurring every other day
+ markup = _('Every other day')
+ elif self._selected_recurring_term == 'week': # Recurring weekly from today
+ markup = _('Every {week_day}').format(
+ week_day=self.task.recurring_updated_date.strftime('%A'))
+ elif self._selected_recurring_term == 'month': # Recurring monthly from today
+ markup = _('Every {month_day} of the month').format(
+ month_day=self.task.recurring_updated_date.strftime('%d'))
+ elif self._selected_recurring_term == 'year': # Recurring yearly from today
+ date = self.task.recurring_updated_date
+ markup = _('Every {month} {day}').format(
+ month=date.strftime('%B'), day=date.strftime('%d'))
else: # Recurring weekly from selected week day
- week_day = _(self.selected_recurring_term)
- self.title.set_markup(_('Every {week_day}').format(week_day=week_day))
- self.title.show()
- self.title_separator.show()
+ week_day = _(self._selected_recurring_term)
+ markup = _('Every {week_day}').format(week_day=week_day)
+ menu_item = Gio.MenuItem.new(markup, None)
+ menu_item.set_attribute_value('use-markup', GLib.Variant.new_boolean(True))
+
+ if self._is_header_menu_item_shown:
+ self._menu_model.remove(0)
+ self._menu_model.insert_item(0, menu_item)
+ self._is_header_menu_item_shown = True
+
+ # HACK: we need to enable use-markup on the header label,
+ # this used to be the default for labels in PopoverMenus, but
+ # later that bug was fixed as it wasn't intended.
+ # In **extremely** recent versions of GTK4, PopoverMenu supports
+ # 'use-markup' attribute on menu items to enable Pango Markup.
+ # However that just doesn't work, (it is set above 100% correctly)
+ # At first I wanted to bundle a patched version of GTK instead,
+ # however I dicided to instead recursively traverse the widget tree
+ # and enable use-markup on all our labels.
+ def try_enable_markup_recursive(c: Gtk.Widget):
+ for c in c:
+ try:
+ c.set_property('use-markup', True)
+ except TypeError:
+ pass
+ try_enable_markup_recursive(c)
+ try_enable_markup_recursive(self)
else:
- self.title.hide()
- self.title_separator.hide()
+ if self._is_header_menu_item_shown:
+ self._menu_model.remove(0)
- def update_calendar(self, update_monthly=True, update_yearly=True):
+ def _update_calendar(self, update_monthly=True, update_yearly=True):
"""
Update the calendar widgets with the correct date of the recurring
task, if set.
"""
- if self.is_term_set():
+ self._month_calendar.set_property('month', 0)
+ if self._is_term_set():
need_month_hack = False
- if self.selected_recurring_term in ('month', 'year'):
+ if self._selected_recurring_term in ('month', 'year'):
# Recurring monthly/yearly from 'today'
- d = self.task.get_recurring_updated_date().date()
- need_month_hack = self.selected_recurring_term == 'month'
- elif self.selected_recurring_term.isdigit():
- if len(self.selected_recurring_term) <= 2:
+ d = self.task.recurring_updated_date.date()
+ need_month_hack = self._set_selected_term == 'month'
+ elif self._selected_recurring_term.isdigit():
+ if len(self._selected_recurring_term) <= 2:
# Recurring monthly from selected date
- d = datetime.strptime(f'{self.selected_recurring_term}', '%d')
+ d = datetime.strptime(f'{self._selected_recurring_term}', '%d')
need_month_hack = True
else:
# Recurring yearly from selected date
- val = f'{self.selected_recurring_term[:2:]}-{self.selected_recurring_term[2::]}'
+ val = f'{self._selected_recurring_term[:2:]}-{self._selected_recurring_term[2::]}'
d = datetime.strptime(val, '%m-%d')
d = d.replace(year=datetime.today().year) # Don't be stuck at 1900
@@ -159,11 +223,10 @@ def update_calendar(self, update_monthly=True, update_yearly=True):
return
if update_monthly:
- self._monthly_calendar.select_month(d.month - 1, d.year)
- self._monthly_calendar.select_day(d.day)
+ self._month_calendar.set_property('day', d.day)
if need_month_hack:
- # Don't show that we're secretly staying on January since
- # it has 31 days
+ # Don't show that we're secretly staying on January since it has
+ # 31 days
month = datetime.today().month
year = datetime.today().year
while True:
@@ -176,11 +239,43 @@ def update_calendar(self, update_monthly=True, update_yearly=True):
month = 1
year += 1
if update_yearly:
- self._yearly_calendar.select_month(d.month - 1, d.year)
- self._yearly_calendar.select_day(d.day)
-
- def reset_stack(self):
- """ Reset popup stack to the first page """
- self.stack.set_transition_duration(0)
- self.stack.set_visible_child(self.page1)
- self.stack.set_transition_duration(200)
+ gtime = GLib.DateTime.new_local(d.year, d.month, d.day, 0, 0, 0)
+ self._year_calendar.select_day(gtime)
+
+ def _on_recurr_every_day(self, widget, action_name, param: None):
+ self._set_selected_term('day')
+ self.set_property('is-task-recurring', True)
+
+ def _on_recurr_every_otherday(self, widget, action_name, param: None):
+ self._set_selected_term('other-day')
+ self.set_property('is-task-recurring', True)
+
+ def _on_recurr_every_week(self, widget, action_name, param: None):
+ self._set_selected_term('week')
+ self.set_property('is-task-recurring', True)
+
+ def _on_recurr_week_day(self, widget, action_name, param: GLib.Variant):
+ week_day = ''.join(param.get_string())
+ self._set_selected_term(week_day)
+ self.set_property('is_task_recurring', True)
+
+ def _on_recurr_month_today(self, widget, action_name, param: None):
+ self._set_selected_term('month')
+ self.set_property('is-task-recurring', True)
+
+ def _on_recurr_year_today(self, widget, action_name, param: None):
+ self._set_selected_term('year')
+ self.set_property('is-task-recurring', True)
+
+ @Gtk.Template.Callback()
+ def _on_monthly_selected(self, widget):
+ self._set_selected_term(str(self._month_calendar.props.day))
+ self.set_property('is-task-recurring', True)
+
+ @Gtk.Template.Callback()
+ def _on_yearly_selected(self, widget):
+ date_string = self._year_calendar.get_date().format(
+ r'%m%d'
+ )
+ self._set_selected_term(date_string)
+ self.set_property('is-task-recurring', True)
diff --git a/GTG/gtk/editor/taskview.py b/GTG/gtk/editor/taskview.py
index 5becb10cc8..5eb2b123c6 100644
--- a/GTG/gtk/editor/taskview.py
+++ b/GTG/gtk/editor/taskview.py
@@ -26,7 +26,8 @@
from gi.repository import Gtk, GLib, Gdk, GObject, GtkSource
-from GTG.core.requester import Requester
+from GTG.core.datastore import Datastore
+from GTG.core.tasks import Status
import GTG.core.urlregex as url_regex
from webbrowser import open as openurl
from gettext import gettext as _
@@ -75,10 +76,10 @@ class TaskView(GtkSource.View):
PROCESSING_DELAY = 250
- def __init__(self, req: Requester, clipboard) -> None:
+ def __init__(self, ds: Datastore, task, clipboard, dark) -> None:
super().__init__()
- self.req = req
+ self.ds = ds
self.clipboard = clipboard
# The timeout handler
@@ -90,12 +91,12 @@ def __init__(self, req: Requester, clipboard) -> None:
# Tags applied to this task
self.task_tags = set()
+ self.task = task
+
# Callbacks. These need to be set after init
self.browse_tag_cb = NotImplemented
self.add_tasktag_cb = NotImplemented
self.remove_tasktag_cb = NotImplemented
- self.get_subtasks_cb = NotImplemented
- self.get_taglist_cb = NotImplemented
self.new_subtask_cb = NotImplemented
self.open_task_cb = NotImplemented
self.delete_subtask_cb = NotImplemented
@@ -112,6 +113,7 @@ def __init__(self, req: Requester, clipboard) -> None:
self.clicked_link = None
# Basic textview setup
+ self.add_css_class('taskview')
self.set_left_margin(20)
self.set_right_margin(20)
self.set_wrap_mode(Gtk.WrapMode.WORD)
@@ -144,13 +146,24 @@ def __init__(self, req: Requester, clipboard) -> None:
'to_delete': []
}
+ if dark:
+ # TODO: It would be better to avoid hardcoding the style
+ manager = GtkSource.StyleSchemeManager().get_default()
+ scheme = manager.get_scheme('oblivion')
+ self.buffer.set_style_scheme(scheme)
+
# Signals and callbacks
self.id_modified = self.buffer.connect('changed', self.on_modified)
- self.motion_controller = Gtk.EventControllerMotion(widget=self)
- self.motion_controller.connect('motion', self.on_mouse_move)
- self.key_controller = Gtk.EventControllerKey(widget=self)
- self.key_controller.connect('key-pressed', self.on_key_pressed)
- self.key_controller.connect('key-released', self.on_key_released)
+ motion_controller = Gtk.EventControllerMotion()
+ motion_controller.connect('motion', self.on_mouse_move)
+ self.add_controller(motion_controller)
+ key_controller = Gtk.EventControllerKey()
+ key_controller.connect('key-pressed', self.on_key_pressed)
+ key_controller.connect('key-released', self.on_key_released)
+ self.add_controller(key_controller)
+ press_gesture = Gtk.GestureSingle(button=0)
+ press_gesture.connect('begin', self.on_single_begin)
+ self.add_controller(press_gesture)
def on_modified(self, buffer: Gtk.TextBuffer) -> None:
@@ -253,22 +266,21 @@ def detect_subtasks(self, text: str, start: Gtk.TextIter) -> bool:
# Remove the -
delete_end = start.copy()
delete_end.forward_chars(2)
- self.buffer.begin_not_undoable_action()
+ self.buffer.begin_irreversible_action()
self.buffer.delete(start, delete_end)
- self.buffer.end_not_undoable_action()
+ self.buffer.end_irreversible_action()
# Add new subtask
- tid = self.new_subtask_cb(text[2:])
- task = self.req.get_task(tid)
- status = task.get_status() if task else 'Active'
+ task = self.new_subtask_cb(text[2:])
+ status = task.status if task else Status.ACTIVE
# Add the checkbox
- self.add_checkbox(tid, start)
+ self.add_checkbox(task.id, start)
after_checkbox = start.copy()
after_checkbox.forward_char()
# Add the internal link
- link_tag = InternalLinkTag(tid, status)
+ link_tag = InternalLinkTag(task.id, status)
self.table.add(link_tag)
end = start.copy()
@@ -278,11 +290,11 @@ def detect_subtasks(self, text: str, start: Gtk.TextIter) -> bool:
# Add the subtask tag
start.backward_char()
- subtask_tag = SubTaskTag(tid)
+ subtask_tag = SubTaskTag(task.id)
self.table.add(subtask_tag)
self.buffer.apply_tag(subtask_tag, start, end)
- self.subtasks['tags'].append(tid)
+ self.subtasks['tags'].append(task.id)
return True
# A subtask already exists
@@ -300,11 +312,11 @@ def detect_subtasks(self, text: str, start: Gtk.TextIter) -> bool:
# Don't auto-remove it
tid = sub_tag.tid
- task = self.req.get_task(tid)
- parents = task.get_parents()
+ task = self.ds.tasks.lookup[tid]
+ parent = task.parent
# Remove if its not a child of this task
- if not parents or parents[0] != self.tid:
+ if not parent or parent != self.task:
log.debug('Task %s is not a subtask of %s', tid, self.tid)
log.debug('Removing subtask %s from content', tid)
@@ -348,7 +360,7 @@ def detect_subtasks(self, text: str, start: Gtk.TextIter) -> bool:
self.rename_subtask_cb(tid, text)
# Get the task and instantiate an internal link tag
- status = task.get_status() if task else 'Active'
+ status = task.status if task else Status.ACTIVE
link_tag = InternalLinkTag(tid, status)
self.table.add(link_tag)
@@ -375,27 +387,27 @@ def detect_subtasks(self, text: str, start: Gtk.TextIter) -> bool:
def on_checkbox_toggle(self, tid: uuid4) -> None:
"""Toggle a task status and refresh the subtask tag."""
- task = self.req.get_task(tid)
+ task = self.ds.tasks.lookup[tid]
if not task:
log.warn('Failed to toggle status for %s', tid)
return
- task.toggle_status()
+ task.toggle_active()
self.process()
def add_checkbox(self, tid: int, start: Gtk.TextIter) -> None:
"""Add a checkbox for a subtask."""
- task = self.req.get_task(tid)
+ task = self.ds.tasks.lookup[tid]
checkbox = Gtk.CheckButton.new()
- if task and task.status != task.STA_ACTIVE:
+ if task and task.status != Status.ACTIVE:
checkbox.set_active(True)
checkbox.connect('toggled', lambda _: self.on_checkbox_toggle(tid))
- checkbox.set_can_focus(False)
+ checkbox.set_focusable(False)
# Block the modified signal handler while we add the anchor
# for the checkbox widget
@@ -407,7 +419,6 @@ def add_checkbox(self, tid: int, start: Gtk.TextIter) -> None:
self.buffer.apply_tag(self.checkbox_tag, start, end)
self.buffer.set_modified(False)
- checkbox.show()
def detect_tag(self, text: str, start: Gtk.TextIter) -> None:
@@ -427,14 +438,15 @@ def detect_tag(self, text: str, start: Gtk.TextIter) -> None:
# I find this confusing too :)
tag_name = match.group(0)
tag_name = tag_name.replace('@', '')
- tag_tag = TaskTagTag(tag_name, self.req)
+
+ self.add_tasktag_cb(tag_name)
+ tag_tag = TaskTagTag(tag_name, self.ds)
self.tags_applied.append(tag_tag)
self.table.add(tag_tag)
self.buffer.apply_tag(tag_tag, tag_start, tag_end)
self.task_tags.add(tag_name)
- self.add_tasktag_cb(tag_name)
def detect_internal_link(self, text: str, start: Gtk.TextIter) -> None:
@@ -452,7 +464,7 @@ def detect_internal_link(self, text: str, start: Gtk.TextIter) -> None:
url_end.forward_chars(match.end())
tid = match.group(0).replace('gtg://', '')
- task = self.req.get_task(tid)
+ task = self.ds.tasks.lookup[tid]
if task:
link_tag = InternalLinkTag(task)
@@ -591,17 +603,33 @@ def on_key_released(self, controller, keyval, keycode, state):
except AttributeError:
pass
+ def on_single_begin(self, gesture, sequence) -> None:
+ """Callback when a mouse button press happens, passed to the
+ relevant custom tags to deal with it"""
+ _, x, y = gesture.get_point(sequence)
+ tx, ty = self.window_to_buffer_coords(Gtk.TextWindowType.WIDGET, x, y)
+ _, titer = self.get_iter_at_location(tx, ty)
+ for tag in titer.get_tags():
+ # In the case of a checkboxtag coming before for example
+ # an internallink, if we call do_clicked on the internallink
+ # the checkbox somehow misses that event and the conflicting internallink
+ # click consequences happen instead.
+ # We should return on encountering a checkbox, this doesn't seem
+ # to break anything
+ if isinstance(tag, CheckboxTag):
+ return
+ if hasattr(tag, "do_clicked"):
+ tag.do_clicked(self, gesture.get_current_button())
+
def on_mouse_move(self, controller, wx, wy) -> None:
"""Callback when the mouse moves."""
# Get the tag at the X, Y coords of the mosue cursor
- window = Gtk.Widget.get_window(self)
x, y = self.window_to_buffer_coords(Gtk.TextWindowType.TEXT, wx, wy)
tags = self.get_iter_at_location(x, y)[1].get_tags()
# Reset cursor and hover states
- cursor = Gdk.Cursor.new_from_name(window.get_display(),
- 'text')
+ cursor = Gdk.Cursor.new_from_name('text', None)
if self.hovered_tag:
try:
@@ -615,14 +643,13 @@ def on_mouse_move(self, controller, wx, wy) -> None:
try:
tag = tags[0]
tag.set_hover()
- cursor = Gdk.Cursor.new_from_name(window.get_display(),
- 'pointer')
+ cursor = Gdk.Cursor.new_from_name('pointer', None)
self.hovered_tag = tag
except (AttributeError, IndexError):
# Not an interactive tag, or no tag at all
pass
- window.set_cursor(cursor)
+ self.set_cursor(cursor)
def do_populate_popup(self, popup) -> None:
@@ -653,9 +680,8 @@ def do_populate_popup(self, popup) -> None:
def copy_url(self, menu_item, url: str) -> None:
"""Copy url to clipboard."""
- clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
- clipboard.set_text(url, -1)
- clipboard.store()
+ clipboard = self.get_clipboard()
+ clipboard.set(url)
# --------------------------------------------------------------------------
# PUBLIC API
@@ -741,14 +767,19 @@ def insert(self, text: str) -> None:
# Insert subtasks. Remove existing subtasks from the list, we
# will delete the rest at the end
for sub in subtasks.copy():
- self.insert_existing_subtask(*sub)
+ try:
+ _sub = self.ds.tasks.get(sub[0])
+ self.insert_existing_subtask(_sub, sub[1])
- if self.req.has_task(sub[0]):
- subtasks.remove(sub)
+ if sub[0] in self.ds.tasks.lookup.keys():
+ subtasks.remove(sub)
+ except KeyError:
+ # The task has been deleted
+ pass
# Remove non-existing subtasks (subtasks that have been deleted)
for sub in subtasks:
- start = self.buffer.get_iter_at_line(sub[1])
+ _, start = self.buffer.get_iter_at_line(sub[1])
end = start.copy()
end.forward_line()
@@ -773,7 +804,7 @@ def insert_tags(self, tags: List) -> None:
# after the title, otherwise add a leading comma to
# the text since we are appending to the tags in
# that line
- first_line = self.buffer.get_iter_at_line(1)
+ _, first_line = self.buffer.get_iter_at_line(1)
first_line_tags = first_line.get_tags()
first_line.forward_to_line_end()
@@ -810,16 +841,16 @@ def insert_new_subtask(self) -> None:
self.buffer.place_cursor(cursor_iter)
- def insert_existing_subtask(self, tid: str, line: int = None) -> None:
+ def insert_existing_subtask(self, task, line: int = None) -> None:
"""Insert an existing subtask in the buffer."""
# Check if the task exists first
- if not self.req.has_task(tid):
+ if task.id not in self.ds.tasks.lookup.keys():
log.debug('Task %s not found', tid)
return
if line is not None:
- start = self.buffer.get_iter_at_line(line)
+ _, start = self.buffer.get_iter_at_line(line)
else:
start = self.buffer.get_end_iter()
self.buffer.insert(start, '\n')
@@ -827,31 +858,30 @@ def insert_existing_subtask(self, tid: str, line: int = None) -> None:
line = start.get_line()
# Add subtask name
- task = self.req.get_task(tid)
- self.buffer.insert(start, task.get_title())
+ self.buffer.insert(start, task.title)
# Reset iterator
- start = self.buffer.get_iter_at_line(line)
+ _, start = self.buffer.get_iter_at_line(line)
# Add checkbox
- self.add_checkbox(tid, start)
+ self.add_checkbox(task.id, start)
# Apply link to subtask text
end = start.copy()
end.forward_to_line_end()
- link_tag = InternalLinkTag(tid, task.get_status())
+ link_tag = InternalLinkTag(task.id, task.status)
self.table.add(link_tag)
self.buffer.apply_tag(link_tag, start, end)
self.tags_applied.append(link_tag)
# Apply subtask tag to everything
start.backward_char()
- subtask_tag = SubTaskTag(tid)
+ subtask_tag = SubTaskTag(task.id)
self.table.add(subtask_tag)
self.buffer.apply_tag(subtask_tag, start, end)
- self.subtasks['tags'].append(tid)
+ self.subtasks['tags'].append(task.id)
# --------------------------------------------------------------------------
diff --git a/GTG/gtk/editor/text_tags.py b/GTG/gtk/editor/text_tags.py
index 9084282a02..cd7c72ec80 100644
--- a/GTG/gtk/editor/text_tags.py
+++ b/GTG/gtk/editor/text_tags.py
@@ -21,9 +21,9 @@
from uuid import uuid4
from gi.repository import Gtk, Pango, Gdk
-from GTG.core.task import Task
+from GTG.core.datastore import Datastore
+from GTG.core.tasks import Status
from GTG.gtk.colors import background_color
-from GTG.core.requester import Requester
from webbrowser import open as openurl
# ------------------------------------------------------------------------------
@@ -111,24 +111,19 @@ def __init__(self, tid: uuid4, status: str) -> None:
self.set_property('underline', Pango.Underline.SINGLE)
- if status == Task.STA_ACTIVE:
+ if status == Status.ACTIVE:
self.set_property('strikethrough', False)
self.set_property('foreground', colors['link_active'])
else:
self.set_property('strikethrough', True)
self.set_property('foreground', colors['link_inactive'])
- self.connect('event', self.on_tag)
-
- def on_tag(self, tag, view, event, _iter) -> None:
- """Callback for events that happen inside the tag."""
-
- button = event.get_button()
- is_press = event.get_event_type() == Gdk.EventType.BUTTON_PRESS
+ def do_clicked(self, view, button) -> None:
+ """Externally called callback for clicks that happen inside the tag."""
# If there was a click...
- if button[0] and button[1] == 1 and is_press:
+ if button == Gdk.BUTTON_PRIMARY:
view.open_subtask_cb(self.tid)
@@ -162,25 +157,17 @@ def __init__(self, url: str) -> None:
self.set_property('underline', Pango.Underline.SINGLE)
self.set_property('strikethrough', False)
- self.connect('event', self.on_tag)
-
-
- def on_tag(self, tag, view, event, _iter) -> None:
- """Callback for events that happen inside the tag."""
- button = event.get_button()
- is_press = event.get_event_type() == Gdk.EventType.BUTTON_PRESS
+ def do_clicked(self, view, button) -> None:
+ """Externally called callback for clicks that happen inside the tag."""
- # If there was a click...
- if button[0] and is_press:
-
- # Left click
- if button[1] == 1:
- openurl(self.url)
+ # Left click
+ if button == Gdk.BUTTON_PRIMARY:
+ openurl(self.url)
- # Right click
- elif button[1] == 3:
- view.clicked_link = self.url
+ # Right click
+ elif button == Gdk.BUTTON_SECONDARY:
+ view.clicked_link = self.url
def activate(self, view) -> None:
@@ -230,22 +217,22 @@ class TaskTagTag(Gtk.TextTag):
"""Text tag for task tags."""
- def __init__(self, tag: str, req: Requester) -> None:
+ def __init__(self, tag: str, ds: Datastore) -> None:
super().__init__()
self.tag_name = tag
- self.tag = req.get_tag(tag)
+ self.tag = ds.tags.lookup_names[tag]
try:
- self.color = background_color([self.tag]) or '#FFEA00'
+ # In darkmode, where the backdrop itself is dark we want
+ # to increase the brightness.
+ self.color = background_color([self.tag], use_alpha=False) or '#FFEA00'
except AttributeError:
self.color = '#FFEA00'
self.set_property('background', self.color)
self.set_property('foreground', 'black')
- self.connect('event', self.on_tag)
-
def set_hover(self) -> None:
"""Change tag appareance when hovering."""
@@ -263,13 +250,10 @@ def reset(self) -> None:
self.set_property('background', self.color)
- def on_tag(self, tag, view, event, _iter) -> None:
- """Callback for events that happen inside the tag."""
-
- button = event.get_button()
- is_press = event.get_event_type() == Gdk.EventType.BUTTON_PRESS
+ def do_clicked(self, view, button) -> None:
+ """Externally called callback for clicks that happen inside the tag."""
- if button[0] and button[1] == 1 and is_press:
+ if button == Gdk.BUTTON_PRIMARY:
view.browse_tag_cb(self.tag_name)
diff --git a/GTG/gtk/errorhandler.py b/GTG/gtk/errorhandler.py
index 979b188da4..fc2907e8ae 100644
--- a/GTG/gtk/errorhandler.py
+++ b/GTG/gtk/errorhandler.py
@@ -22,6 +22,7 @@ class Response(enum.IntEnum):
CONTINUE = enum.auto()
def __init__(self, exception=None, main_msg=None, ignorable: bool = False, context_info: str = None):
+ super().__init__(resizable=False, modal=True, destroy_with_parent=True, message_type=Gtk.MessageType.ERROR)
self.ignorable = ignorable
formatting = {
@@ -34,20 +35,18 @@ def __init__(self, exception=None, main_msg=None, ignorable: bool = False, conte
else:
title = _("Fatal internal error — GTG")
desc = _("""GTG encountered an internal fatal error and needs to exit.""")
+
title = title.format(**formatting)
desc = desc.format(**formatting)
desc2 = _("""Recently unsaved changes (from the last few seconds) may be lost, so make sure to check your recent changes when launching GTG again afterwards.
-
-Please report the bug in our issue tracker, with steps to trigger the problem and the error's details below.""")
+ Please report the bug in our issue tracker, with steps to trigger the problem and the error's details below.""")
desc2 = desc2.format(**formatting)
- super().__init__(None,
- Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
- Gtk.MessageType.ERROR,
- Gtk.ButtonsType.NONE,
- None)
- self.set_title(title)
+ # You may think that GtkWindow:title is the property you need,
+ # however GtkWindow:title is awkwardly styled on GtkMessageDialog,
+ # and GtkMessageDialog:text is styled like a title.
+ self.props.text = title
self.set_markup(desc)
self.props.secondary_text = desc2
self.props.secondary_use_markup = True
@@ -62,26 +61,20 @@ def __init__(self, exception=None, main_msg=None, ignorable: bool = False, conte
self._additional_info.set_buffer(Gtk.TextBuffer())
self._additional_info.set_editable(False)
self._additional_info.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
- self._additional_info.props.expand = True
- self._additional_info.set_border_width(6) # Internal padding around text
- self._additional_info.get_style_context().add_class("debug_text")
-
- expander_content = Gtk.ScrolledWindow()
- expander_content.set_border_width(12) # Outer padding around text
- expander_content.add(self._additional_info)
- expander = Gtk.Expander()
- expander.set_label(_("Details to paste in your bug report"))
- expander.add(expander_content)
- self.get_content_area().add(expander)
+ expander_content = Gtk.ScrolledWindow(vexpand=True, min_content_height=90)
+ expander_content.set_child(self._additional_info)
+ self._expander = Gtk.Expander(vexpand=True)
+ self._expander.set_label(_("Details to report"))
+ self._expander.set_child(expander_content)
+ self.get_content_area().append(self._expander)
+ self._expander.bind_property("expanded", self, "resizable",
+ GObject.BindingFlags.SYNC_CREATE)
# Prevent the window from becoming too tall, or having a weird aspect ratio:
expander_content.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
expander_content.props.height_request = 200
self.props.width_request = 450
- expander.bind_property("expanded", self, "resizable", GObject.BindingFlags.SYNC_CREATE)
- expander.show_all()
-
self._exception = exception
self.context_info = context_info # Also refreshes the text
@@ -209,7 +202,7 @@ def do_error_dialog(exception, context: str = None, ignorable: bool = True, main
"""
dialog = ExceptionHandlerDialog(exception, main_msg, ignorable, context)
dialog.connect('response', handle_response)
- dialog.show_all()
+ dialog.show()
return dialog
diff --git a/GTG/gtk/general_preferences.py b/GTG/gtk/general_preferences.py
index a6ce773a90..d6e06e6f36 100644
--- a/GTG/gtk/general_preferences.py
+++ b/GTG/gtk/general_preferences.py
@@ -22,7 +22,7 @@
import os
-from gi.repository import Gtk, Gdk
+from gi.repository import Gtk, Gdk, GLib
from GTG.core.dirs import UI_DIR
from gettext import gettext as _
@@ -32,31 +32,33 @@
log = logging.getLogger(__name__)
-class GeneralPreferences():
+@Gtk.Template(filename=os.path.join(UI_DIR, "general_preferences.ui"))
+class GeneralPreferences(Gtk.ScrolledWindow):
+ __gtype_name__ = 'GeneralPreferences'
- GENERAL_PREFERENCES_UI = os.path.join(UI_DIR, "general_preferences.ui")
- INVALID_COLOR = Gdk.Color(50000, 0, 0)
+ _preview_button = Gtk.Template.Child()
+ _bg_color_button = Gtk.Template.Child()
+ _font_button = Gtk.Template.Child()
- def __init__(self, req, app):
- self.req = req
- self.config = self.req.get_config('browser')
- builder = Gtk.Builder()
- builder.add_from_file(self.GENERAL_PREFERENCES_UI)
+ _refresh_time_entry = Gtk.Template.Child()
+ _autoclean_switch = Gtk.Template.Child()
+ _autoclean_days_spin = Gtk.Template.Child()
+ _dark_mode_switch = Gtk.Template.Child()
- self.ui_widget = builder.get_object("general_pref_window")
- self.preview_button = builder.get_object("preview_button")
- self.bg_color_button = builder.get_object("bg_color_button")
- self.font_button = builder.get_object("font_button")
+ def __init__(self, app):
+ super().__init__()
+ self.config = app.config
self.app = app
self.timer = app.timer
- self.refresh_time = builder.get_object("time_entry")
- self.autoclean_enable = builder.get_object("autoclean_enable")
- self.autoclean_days = builder.get_object("autoclean_days")
- self.dark_mode = builder.get_object("darkmode_enable")
- self._refresh_preferences_store()
- builder.connect_signals(self)
+ time_entry_focus_controller = Gtk.EventControllerFocus()
+ time_entry_focus_controller.connect("leave", self.on_leave_time_entry)
+ self._refresh_time_entry.add_controller(time_entry_focus_controller)
+ # setting preference values should not block,
+ # as setting certain preferences depends on the complete initialization
+ # of the app.
+ GLib.idle_add(self._refresh_preferences_store)
# Following 3 methods: get_name, get_title, get_ui are
# required for all children of stack in Preferences class.
@@ -69,75 +71,51 @@ def get_name(self):
def get_title(self):
return _('General')
- def get_ui(self):
- """
- This method returns widget displayed in Preferences window.
- """
- return self.ui_widget
-
def activate(self):
pass
def get_default_editor_font(self):
- editor_font = self.config.get("font_name")
- if editor_font == "":
- try:
- font = self.ui_widget.get_style_context().get_property(
- "font", Gtk.StateFlags.NORMAL)
- editor_font = font.to_string()
- except UnicodeError as e:
- log.warning("Using deprecated but still working font way (%r)",
- e)
- font = self.ui_widget.get_style_context().get_font(
- Gtk.StateFlags.NORMAL)
- editor_font = font.to_string()
+ if not self.config.get("font_name") or not self.config.get("font_size"):
+ editor_font = Gtk.Settings.get_default().get_property("gtk-font-name")
+ else:
+ editor_font = self.config.get("font_name") + " " + str(self.config.get("font_size"))
return editor_font
def _refresh_preferences_store(self):
""" Sets the correct value in the preferences checkboxes """
show_preview = self.config.get("contents_preview_enable")
- self.preview_button.set_active(show_preview)
+ self._preview_button.set_active(show_preview)
bg_color = self.config.get("bg_color_enable")
- self.bg_color_button.set_active(bg_color)
+ self._bg_color_button.set_active(bg_color)
- self.refresh_time.set_text(self.timer.get_formatted_time())
- self.refresh_time.modify_fg(Gtk.StateFlags.NORMAL, None)
+ self._refresh_time_entry.set_text(self.timer.get_formatted_time())
- self.font_button.set_font(self.get_default_editor_font())
+ self._font_button.set_font(self.get_default_editor_font())
enable_autoclean = self.config.get("autoclean")
- self.autoclean_enable.set_active(enable_autoclean)
+ self._autoclean_switch.set_active(enable_autoclean)
autoclean_days = self.config.get("autoclean_days")
- self.autoclean_days.set_value(autoclean_days)
+ self._autoclean_days_spin.set_value(autoclean_days)
dark_mode = self.config.get("dark_mode")
- self.dark_mode.set_active(dark_mode)
-
- def _refresh_task_browser(self):
- """ Refresh tasks in task browser """
-
- collapsed = self.config.get("collapsed_tasks")
- task_tree = self.req.get_tasks_tree(refresh=False).get_basetree()
- task_tree.refresh_all()
+ self._dark_mode_switch.set_active(dark_mode)
- self.app.browser.restore_collapsed_tasks(collapsed)
+ @Gtk.Template.Callback()
def on_valid_time_check(self, widget):
"""
This function checks for validity of the user input with
every new key-stroke from the user by parsing the input.
"""
try:
- input_time = self.refresh_time.get_text()
+ input_time = self._refresh_time_entry.get_text()
self.timer.parse_time(input_time)
- color = None
+ self._refresh_time_entry.remove_css_class("error")
except ValueError:
- color = self.INVALID_COLOR
-
- self.refresh_time.modify_fg(Gtk.StateFlags.NORMAL, color)
+ self._refresh_time_entry.add_css_class("error")
def on_leave_time_entry(self, widget, data=None):
"""
@@ -146,7 +124,7 @@ def on_leave_time_entry(self, widget, data=None):
sets the time value for the widget.
"""
try:
- input_time = self.refresh_time.get_text()
+ input_time = self._refresh_time_entry.get_text()
correct_time = self.timer.parse_time(input_time)
self.timer.set_configuration(correct_time)
except ValueError:
@@ -154,51 +132,50 @@ def on_leave_time_entry(self, widget, data=None):
self._refresh_preferences_store()
+ @Gtk.Template.Callback()
def on_preview_toggled(self, widget, state):
""" Toggle previews in the task view on or off."""
curstate = self.config.get("contents_preview_enable")
- if curstate != self.preview_button.get_active():
+ if curstate != self._preview_button.get_active():
self.config.set("contents_preview_enable", not curstate)
- self._refresh_task_browser()
+ @Gtk.Template.Callback()
def on_bg_color_toggled(self, widget, state):
""" Save configuration and refresh nodes to apply the change """
curstate = self.config.get("bg_color_enable")
- if curstate != self.bg_color_button.get_active():
+ if curstate != self._bg_color_button.get_active():
self.config.set("bg_color_enable", not curstate)
- self._refresh_task_browser()
+ for task in self.app.ds.tasks.lookup.values():
+ task.notify('row_css')
+
+ @Gtk.Template.Callback()
def on_font_change(self, widget):
""" Set a new font for editor """
- self.config.set("font_name", self.font_button.get_font())
+ self.config.set("font_name", self._font_button.get_font_family().get_name())
+ self.config.set("font_size", int(self._font_button.get_font_size()/1000))
+ @Gtk.Template.Callback()
def on_autoclean_toggled(self, widget, state):
"""Toggle automatic deletion of old closed tasks."""
self.config.set("autoclean", state)
+ @Gtk.Template.Callback()
def on_autoclean_days_changed(self, widget):
"""Update value for maximum days before removing a task."""
self.config.set("autoclean_days", int(widget.get_value()))
+ @Gtk.Template.Callback()
def on_purge_clicked(self, widget):
"""Purge old tasks immediately."""
self.app.purge_old_tasks(widget)
+ @Gtk.Template.Callback()
def on_dark_mode_toggled(self, widget, state):
"""Toggle darkmode."""
self.config.set("dark_mode", state)
- self.app.toggle_darkmode(state)
- collapsed = self.config.get("collapsed_tasks")
-
- # Refresh panes
- func = self.app.browser.tv_factory.get_task_bg_color
-
- for pane in self.app.browser.vtree_panes.values():
- pane.set_bg_color(func, 'bg_color')
- pane.basetree.get_basetree().refresh_all()
-
- self.app.browser.restore_collapsed_tasks(collapsed)
+ self.app.toggle_darkmode(state)
\ No newline at end of file
diff --git a/GTG/gtk/meson.build b/GTG/gtk/meson.build
index 1b495ae959..54bd86b2c6 100644
--- a/GTG/gtk/meson.build
+++ b/GTG/gtk/meson.build
@@ -31,16 +31,18 @@ gtg_browser_sources = [
'browser/__init__.py',
'browser/adaptive_button.py',
'browser/backend_infobar.py',
- 'browser/cell_renderer_tags.py',
'browser/delete_tag.py',
'browser/delete_task.py',
'browser/main_window.py',
'browser/modify_tags.py',
'browser/simple_color_selector.py',
- 'browser/tag_context_menu.py',
+ 'browser/sidebar_context_menu.py',
+ 'browser/search_editor.py',
'browser/tag_editor.py',
- 'browser/treeview_factory.py',
'browser/quick_add.py',
+ 'browser/sidebar.py',
+ 'browser/tag_pill.py',
+ 'browser/task_pane.py',
]
gtg_data_sources = [
@@ -48,14 +50,15 @@ gtg_data_sources = [
'data/calendar.ui',
'data/context_menus.ui',
'data/general_preferences.ui',
- 'data/help_overlay.ui',
'data/main_window.ui',
'data/modify_tags.ui',
'data/plugins.ui',
'data/preferences.ui',
'data/style.css',
'data/tag_editor.ui',
+ 'data/search_editor.ui',
'data/task_editor.ui',
+ 'data/recurring_menu.ui'
]
gtg_editor_sources = [
diff --git a/GTG/gtk/plugins.py b/GTG/gtk/plugins.py
index 8f603c8aab..7cbdd8ce68 100644
--- a/GTG/gtk/plugins.py
+++ b/GTG/gtk/plugins.py
@@ -66,29 +66,32 @@ def plugin_error_short_text(plugin):
def plugin_error_text(plugin):
""" Generate some helpful text about missing module dependencies. """
+ text = _('System support for plugin status: {}\n')
if not plugin.error:
- return GnomeConfig.CANLOAD
+ return text.format(GnomeConfig.CANLOAD)
# describe missing dependencies
- text = f"{GnomeConfig.CANNOTLOAD}. \n"
+ text = text.format(GnomeConfig.CANNOTLOAD)
# get lists
modules = plugin.missing_modules
dbus = plugin.missing_dbus
# convert to strings
if modules:
- modules = "%s" % ', '.join(modules)
+ modules = ', '.join(modules)
if dbus:
ifaces = [f"{a}:{b}" for (a, b) in dbus]
- dbus = "%s" % ', '.join(ifaces)
+ dbus = ', '.join(ifaces)
# combine
+ text += '\n'
+ text += _('System doesn\'t support plugin because:\n\n')
if modules and not dbus:
- text += '\n'.join((GnomeConfig.MODULEMISSING, modules))
+ text += '\n\n'.join((GnomeConfig.MODULEMISSING, modules))
elif dbus and not modules:
- text += '\n'.join((GnomeConfig.DBUSMISSING, dbus))
+ text += '\n\n'.join((GnomeConfig.DBUSMISSING, dbus))
elif modules and dbus:
- text += '\n'.join((GnomeConfig.MODULANDDBUS, modules, dbus))
+ text += '\n\n'.join((GnomeConfig.MODULANDDBUS, modules, dbus))
else:
text += GnomeConfig.UNKNOWN
@@ -117,36 +120,26 @@ def plugin_markup(column, cell, store, iterator, self):
store.get_value(iterator, PLUGINS_COL_ACTIVATABLE))
-class PluginsDialog():
+@Gtk.Template(filename=ViewConfig.PLUGINS_UI_FILE)
+class PluginsDialog(Gtk.Dialog):
""" Dialog for Plugins configuration """
- def __init__(self, requester):
- self.req = requester
- self.config = self.req.get_config("plugins")
- builder = Gtk.Builder()
+ __gtype_name__ = "PluginsDialog"
- builder.add_from_file(ViewConfig.PLUGINS_UI_FILE)
+ _plugin_tree = Gtk.Template.Child()
+ _plugin_configure_button = Gtk.Template.Child()
- self.dialog = builder.get_object("PluginsDialog")
- self.dialog.set_title(_("Plugins"))
- self.plugin_tree = builder.get_object("PluginTree")
- self.plugin_configure = builder.get_object("plugin_configure")
- self.plugin_about = builder.get_object("PluginAboutDialog")
- self.plugin_depends = builder.get_object('PluginDepends')
+ def __init__(self, config):
+ super().__init__()
+ self.config = config
+
+ self.set_title(_("Plugins"))
self.pengine = PluginEngine()
# see constants PLUGINS_COL_* for column meanings
self.plugin_store = Gtk.ListStore(str, bool, str, str, bool)
- builder.connect_signals({
- 'on_PluginsDialog_delete_event': self.on_close,
- 'on_PluginTree_cursor_changed': self.on_plugin_select,
- 'on_plugin_about': self.on_plugin_about,
- 'on_plugin_configure': self.on_plugin_configure,
- 'on_PluginAboutDialog_close': self.on_plugin_about_close,
- })
-
def _init_plugin_tree(self):
""" Initialize the PluginTree Gtk.TreeView.
@@ -164,7 +157,7 @@ def _init_plugin_tree(self):
column = Gtk.TreeViewColumn(None, renderer, active=PLUGINS_COL_ENABLED,
activatable=PLUGINS_COL_ACTIVATABLE,
sensitive=PLUGINS_COL_ACTIVATABLE)
- self.plugin_tree.append_column(column)
+ self._plugin_tree.append_column(column)
# plugin name column
column = Gtk.TreeViewColumn()
@@ -175,11 +168,11 @@ def _init_plugin_tree(self):
column.pack_start(name_renderer, True)
column.set_cell_data_func(name_renderer, plugin_markup, self)
- self.plugin_tree.append_column(column)
+ self._plugin_tree.append_column(column)
# finish setup
- self.plugin_tree.set_model(self.plugin_store)
- self.plugin_tree.set_search_column(2)
+ self._plugin_tree.set_model(self.plugin_store)
+ self._plugin_tree.set_search_column(2)
def _refresh_plugin_store(self):
""" Refresh status of plugins and put it in a Gtk.ListStore """
@@ -193,15 +186,16 @@ def _refresh_plugin_store(self):
def activate(self):
""" Refresh status of plugins and show the dialog """
- if len(self.plugin_tree.get_columns()) == 0:
+ if len(self._plugin_tree.get_columns()) == 0:
self._init_plugin_tree()
else:
self._refresh_plugin_store()
- self.dialog.show_all()
+ self.show()
+ @Gtk.Template.Callback()
def on_close(self, widget, data=None):
""" Close the plugins dialog."""
- self.dialog.hide()
+ self.hide()
return True
def on_plugin_toggle(self, widget, path):
@@ -228,6 +222,7 @@ def on_plugin_toggle(self, widget, path):
self.plugin_store.set_value(iterator, PLUGINS_COL_ENABLED, plugin.enabled)
self._update_plugin_configure(plugin)
+ @Gtk.Template.Callback()
def on_plugin_select(self, plugin_tree):
""" Callback when user select/unselect a plugin
@@ -241,20 +236,22 @@ def on_plugin_select(self, plugin_tree):
def _update_plugin_configure(self, plugin):
""" Enable the button "Configure Plugin" appropriate. """
configurable = plugin.active and plugin.is_configurable()
- self.plugin_configure.set_property('sensitive', configurable)
+ self._plugin_configure_button.set_property('sensitive', configurable)
+ @Gtk.Template.Callback()
def on_plugin_configure(self, widget):
""" Show the dialog for plugin configuration """
- _, iterator = self.plugin_tree.get_selection().get_selected()
+ _, iterator = self._plugin_tree.get_selection().get_selected()
if iterator is None:
return
plugin_id = self.plugin_store.get_value(iterator, PLUGINS_COL_ID)
plugin = self.pengine.get_plugin(plugin_id)
- plugin.instance.configure_dialog(self.dialog)
+ plugin.instance.configure_dialog(self)
+ @Gtk.Template.Callback()
def on_plugin_about(self, widget):
""" Display information about a plugin. """
- _, iterator = self.plugin_tree.get_selection().get_selected()
+ _, iterator = self._plugin_tree.get_selection().get_selected()
if iterator is None:
return
plugin_id = self.plugin_store.get_value(iterator, PLUGINS_COL_ID)
@@ -265,21 +262,16 @@ def on_plugin_about(self, widget):
# FIXME repair it!
# FIXME Author is not usually set and is preserved from
# previous plugin... :/
- self.plugin_about.set_program_name(plugin.full_name)
- self.plugin_about.set_version(plugin.version)
authors = plugin.authors
if isinstance(authors, str):
authors = "\n".join(author.strip()
for author in authors.split(','))
authors = [authors, ]
- self.plugin_about.set_authors(authors)
- description = plugin.description.replace(r'\n', "\n")
- self.plugin_about.set_comments(description)
- self.plugin_depends.set_label(plugin_error_text(plugin))
- self.plugin_about.show_all()
-
- def on_plugin_about_close(self, widget, data=None):
-
- """ Close the PluginAboutDialog. """
- self.plugin_about.hide()
- return True
+ about_dialog = Gtk.AboutDialog(
+ program_name=plugin.full_name,
+ logo_icon_name='system-run-symbolic',
+ version=plugin.version,
+ system_information=plugin_error_text(plugin),
+ comments=plugin.description
+ )
+ about_dialog.present()
diff --git a/GTG/gtk/preferences.py b/GTG/gtk/preferences.py
index 7aa565713e..d704cf8e0b 100644
--- a/GTG/gtk/preferences.py
+++ b/GTG/gtk/preferences.py
@@ -24,37 +24,31 @@
from GTG.gtk.general_preferences import GeneralPreferences
-class Preferences():
- """Preferences is a framework for diplaying and switching
+@Gtk.Template(filename=os.path.join(UI_DIR, "preferences.ui"))
+class Preferences(Gtk.Window):
+ """Preferences a framework for diplaying and switching
between indivitual parts of preferences: general, plugins
- and synchronisation. These will be accessed via get_ui() method"""
+ and synchronisation."""
+ __gtype_name__ = 'Preferences'
- PREFERENCES_UI_FILE = os.path.join(UI_DIR, "preferences.ui")
+ _page_stack = Gtk.Template.Child()
- def __init__(self, req, app):
- self.req = req
- self.config = self.req.get_config('browser')
- builder = Gtk.Builder()
- builder.add_from_file(self.PREFERENCES_UI_FILE)
-
- self.window = builder.get_object("Preferences")
-
- builder.connect_signals(self)
-
- self.headerbar = builder.get_object("right_header_bar")
- self.stack = builder.get_object("stack")
+ def __init__(self, app):
+ super().__init__()
+ self.config = app.config
self.pages = {}
- self.add_page(GeneralPreferences(req, app))
+ self.add_page(GeneralPreferences(app))
def activate(self):
""" Activate the preferences window."""
self.pages['general'].activate()
- self.window.show()
+ self.show()
+ @Gtk.Template.Callback()
def on_close(self, widget, data=None):
""" Close the preferences dialog."""
- self.window.hide()
+ self.hide()
return True
def add_page(self, page):
@@ -62,4 +56,4 @@ def add_page(self, page):
All children are added using this function from __init__"""
page_name = page.get_name()
self.pages[page_name] = page
- self.stack.add_titled(page.get_ui(), page_name, page.get_title())
+ self._page_stack.add_titled(page, page_name, page.get_title())
diff --git a/GTG/gtk/tag_completion.py b/GTG/gtk/tag_completion.py
index 84c9ab2589..febe71c561 100644
--- a/GTG/gtk/tag_completion.py
+++ b/GTG/gtk/tag_completion.py
@@ -63,7 +63,7 @@ class TagCompletion(Gtk.EntryCompletion):
The list of tasks is updated by LibLarch callbacks """
- def __init__(self, tree):
+ def __init__(self, tagstore):
""" Initialize entry completion
Create a list store which is connected to a LibLarch and
@@ -72,13 +72,6 @@ def __init__(self, tree):
self.tags = Gtk.ListStore(str)
- tree = tree.get_basetree()
- tree.add_filter(FILTER_NAME, tag_filter, {'flat': True})
- tag_tree = tree.get_viewtree('tag_completion', False)
- tag_tree.register_cllbck('node-added-inview', self._on_tag_added)
- tag_tree.register_cllbck('node-deleted-inview', self._on_tag_deleted)
- tag_tree.apply_filter(FILTER_NAME)
-
self.set_model(self.tags)
self.set_text_column(0)
self.set_match_func(tag_match, 0)
@@ -86,9 +79,14 @@ def __init__(self, tree):
self.set_inline_selection(True)
self.set_popup_single_match(False)
+ for tag in tagstore.lookup.values():
+ self._on_tag_added(tag.name)
+
+
def _try_insert(self, name):
""" Insert an item into ListStore if it is not already there.
It keeps the list sorted. """
+
position = 0
for position, row in enumerate(self.tags, 1):
if row[0] == name:
@@ -97,25 +95,32 @@ def _try_insert(self, name):
elif row[0] > name:
position -= 1
break
+
self.tags.insert(position, (name, ))
- def _on_tag_added(self, tag, path):
+
+ def _on_tag_added(self, tag):
""" Add all variants of tag """
+
tag = normalize_unicode(tag)
self._try_insert(tag)
self._try_insert('!' + tag)
self._try_insert(tag[1:])
self._try_insert('!' + tag[1:])
+
def _try_delete(self, name):
""" Delete an item if it is in the list """
+
for row in self.tags:
if row[0] == name:
self.tags.remove(row.iter)
break
+
def _on_tag_deleted(self, tag, path):
""" Delete all variants of tag """
+
tag = normalize_unicode(tag)
self._try_delete(tag)
self._try_delete('!' + tag)
diff --git a/GTG/plugins/dev_console/console.py b/GTG/plugins/dev_console/console.py
index 946302ce87..02233b5021 100644
--- a/GTG/plugins/dev_console/console.py
+++ b/GTG/plugins/dev_console/console.py
@@ -40,20 +40,22 @@ def __init__(self, app):
@property
@Namespace.shortcut
def app(self):
- """The Pitivi instance."""
+ """The GTG instance."""
return self._app
+
@property
@Namespace.shortcut
- def browser(self):
- """The Plugin Manager instance."""
- return self._app.browser
+ def ds(self):
+ """The Datastore instance."""
+ return self._app.ds
+
@property
@Namespace.shortcut
- def req(self):
- """The current project."""
- return self._app.req
+ def browser(self):
+ """The Plugin Manager instance."""
+ return self._app.browser
class DevConsolePlugin():
@@ -89,15 +91,19 @@ def activate(self, api: PluginAPI) -> None:
# Font and colors
self.terminal.set_font('Source Code Pro 10')
- self.terminal.set_color(Gdk.RGBA(1.0, 1.0, 1.0, 1.0))
- self.terminal.set_stderr_color(Gdk.RGBA(0.96, 0.5, 0.5, 1.0))
- self.terminal.set_stdout_color(Gdk.RGBA(1.0, 1.0, 1.0, 1.0))
+ c = Gdk.RGBA()
+ c.red, c.green, c.blue, c.alpha = 1.0, 1.0, 1.0, 1.0
+ self.terminal.set_color(c)
+ c.red, c.green, c.blue = 1.0, 1.0, 1.0
+ self.terminal.set_stdout_color(c)
+ c.red, c.green, c.blue = 0.96, 0.5, 0.5
+ self.terminal.set_stderr_color(c)
# Build window
self.window.set_default_size(600, 400)
self.window.set_title(_('Developer Console'))
- self.window.connect('delete-event', self.on_delete_event)
- self.window.add(self.terminal)
+ self.window.set_hide_on_close(True)
+ self.window.set_child(self.terminal)
# Add F12 shortcut
open_action = Gio.SimpleAction.new('plugin.open_console', None)
@@ -117,7 +123,7 @@ def welcome_message(self, namespace):
'You can use the following shortcuts:'
'\n'
'- app (The application class)\n'
- '- req (The requester class)\n'
+ '- ds (The datastore)\n'
'- browser (The main window)\n'
'\n'
'Type "help ()" for more information.'
@@ -133,16 +139,9 @@ def deactivate(self, api: PluginAPI) -> None:
def open_window(self, widget=None, unsued=None) -> None:
"""Open developer console."""
- self.window.show_all()
- self.window.set_keep_above(True)
+ self.window.show()
def eof_cb(self, unused_widget):
self.window.hide()
return True
-
-
- def on_delete_event(self, widget, data):
- """Callback when window is closed."""
-
- return self.window.hide_on_delete()
diff --git a/GTG/plugins/dev_console/window.py b/GTG/plugins/dev_console/window.py
index 20736e8c63..0facb0c238 100644
--- a/GTG/plugins/dev_console/window.py
+++ b/GTG/plugins/dev_console/window.py
@@ -67,9 +67,11 @@ def __init__(self, namespace, welcome_message=""):
buf = ConsoleBuffer(namespace, welcome_message)
self._view.set_buffer(buf)
self._view.set_editable(True)
- self.add(self._view)
+ self.set_child(self._view)
- self._view.connect("key-press-event", self.__key_press_event_cb)
+ key_controller = Gtk.EventControllerKey()
+ key_controller.connect("key-pressed", self.__key_press_event_cb)
+ self._view.add_controller(key_controller)
buf.connect("mark-set", self.__mark_set_cb)
buf.connect("insert-text", self.__insert_text_cb)
@@ -127,7 +129,7 @@ def set_color(self, color):
Args:
color (Gdk.RGBA): a color.
"""
- self._css_values["textview > *"]["color"] = color.to_string()
+ self._css_values["textview"]["color"] = color.to_string()
self._apply_css()
def _apply_css(self):
@@ -153,30 +155,30 @@ def set_stderr_color(self, color):
self._view.get_buffer().error.set_property("foreground-rgba", color)
# pylint: disable=too-many-return-statements
- def __key_press_event_cb(self, view, event):
- buf = view.get_buffer()
- state = event.state & Gtk.accelerator_get_default_mod_mask()
+ def __key_press_event_cb(self, controller, keyval, keycode, state):
+ buf = self._view.get_buffer()
+ state = state & Gtk.accelerator_get_default_mod_mask()
ctrl = state & Gdk.ModifierType.CONTROL_MASK
- if event.keyval == Gdk.KEY_Return:
+ if keyval == Gdk.KEY_Return:
buf.process_command_line()
return True
- if event.keyval in (Gdk.KEY_KP_Down, Gdk.KEY_Down):
+ if keyval in (Gdk.KEY_KP_Down, Gdk.KEY_Down):
buf.history.down(buf.get_command_line())
return True
- if event.keyval in (Gdk.KEY_KP_Up, Gdk.KEY_Up):
+ if keyval in (Gdk.KEY_KP_Up, Gdk.KEY_Up):
buf.history.up(buf.get_command_line())
return True
- if event.keyval in (Gdk.KEY_KP_Left, Gdk.KEY_Left, Gdk.KEY_BackSpace):
+ if keyval in (Gdk.KEY_KP_Left, Gdk.KEY_Left, Gdk.KEY_BackSpace):
return buf.is_cursor(at=True)
- if event.keyval in (Gdk.KEY_KP_Home, Gdk.KEY_Home):
+ if keyval in (Gdk.KEY_KP_Home, Gdk.KEY_Home):
buf.place_cursor(buf.get_iter_at_mark(buf.prompt_mark))
return True
- if (ctrl and event.keyval == Gdk.KEY_d) or event.keyval == Gdk.KEY_Escape:
+ if (ctrl and keyval == Gdk.KEY_d) or keyval == Gdk.KEY_Escape:
return self.emit("eof")
return False
diff --git a/GTG/plugins/export/export.py b/GTG/plugins/export/export.py
index d22ece3f30..99028287bb 100644
--- a/GTG/plugins/export/export.py
+++ b/GTG/plugins/export/export.py
@@ -93,10 +93,15 @@ def on_export_start(self, saving):
self.filename = None
if saving:
- self.filename = self.choose_file()
- if self.filename is None:
- return
+ def file_chosen_cb(filename):
+ self.filename = filename
+ if self.filename:
+ self.on_export_start_async_finish(tasks)
+ self.choose_file_async(file_chosen_cb)
+ else:
+ self.on_export_start_async_finish(tasks)
+ def on_export_start_async_finish(self, tasks):
self.save_button.set_sensitive(False)
self.open_button.set_sensitive(False)
@@ -159,6 +164,7 @@ def _init_gtk(self):
builder.add_from_file(builder_file)
self.combo = builder.get_object("export_combo_templ")
+ self.combo.connect("changed", self.on_combo_changed)
templates_list = Gtk.ListStore(
GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_STRING,
GObject.TYPE_STRING)
@@ -168,29 +174,22 @@ def _init_gtk(self):
self.combo.add_attribute(cell, 'text', 1)
self.export_dialog = builder.get_object("export_dialog")
- self.export_image = builder.get_object("export_image")
+ self.export_picture = builder.get_object("export_picture")
self.description_label = builder.get_object("label_description")
self.save_button = builder.get_object("export_btn_save")
+ self.save_button.connect("clicked", lambda widget: self.on_export_start(True))
self.open_button = builder.get_object("export_btn_open")
+ self.open_button.connect("clicked", lambda widget: self.on_export_start(False))
self.export_all_active = builder.get_object(
- "export_all_active_rb")
+ "export_all_active_cb")
self.export_all_active.set_active(True)
self.export_finished_last_week = builder.get_object(
- "export_finished_last_week_rb")
+ "export_finished_last_week_cb")
self.export_all_finished = builder.get_object(
- "export_all_finished_rb")
-
- builder.connect_signals({
- "on_export_btn_open_clicked":
- lambda widget: self.on_export_start(False),
- "on_export_btn_save_clicked":
- lambda widget: self.on_export_start(True),
- "on_export_dialog_delete_event":
- self._hide_dialog,
- "on_export_combo_templ_changed":
- self.on_combo_changed,
- })
+ "export_all_finished_cb")
+
+ self.export_dialog.connect("close-request", self._hide_dialog)
def _gtk_deactivate(self):
""" Remove Menu item for this plugin """
@@ -201,9 +200,9 @@ def show_dialog(self, action, param):
parent_window = self.plugin_api.get_ui().get_window()
self.export_dialog.set_transient_for(parent_window)
self._update_combobox()
- self.export_dialog.show_all()
+ self.export_dialog.present()
- def _hide_dialog(self, sender=None, data=None):
+ def _hide_dialog(self, sender=None):
""" Hide dialog """
self.export_dialog.hide()
@@ -244,12 +243,12 @@ def on_combo_changed(self, combo):
if image:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(image)
- width, height = self.export_image.get_size_request()
+ width, height = self.export_picture.get_size_request()
pixbuf = pixbuf.scale_simple(width, height,
GdkPixbuf.InterpType.BILINEAR)
- self.export_image.set_from_pixbuf(pixbuf)
+ self.export_picture.set_pixbuf(pixbuf)
else:
- self.export_image.clear()
+ self.export_picture.clear()
self.description_label.set_markup(f"{description}")
# Remember the last selected path
@@ -259,15 +258,15 @@ def on_combo_changed(self, combo):
def show_error_dialog(self, message):
""" Display an error """
dialog = Gtk.MessageDialog(
- parent=self.export_dialog,
- flags=Gtk.DialogFlags.DESTROY_WITH_PARENT,
- type=Gtk.MessageType.ERROR,
+ transient_for=self.export_dialog,
+ destroy_with_parent=True,
+ message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
- message_format=message)
- dialog.run()
- dialog.destroy()
+ text=message)
+ dialog.connect("response", lambda d, r : dialog.destroy())
+ dialog.present()
- def choose_file(self):
+ def choose_file_async(self, callback):
""" Let user choose a file to save and return its path """
chooser = Gtk.FileChooserNative.new(
_("Choose where to save your list"),
@@ -275,15 +274,27 @@ def choose_file(self):
Gtk.FileChooserAction.SAVE,
None,
None)
- chooser.set_do_overwrite_confirmation(True)
- chooser.set_current_folder(get_desktop_dir())
- response = chooser.run()
- filename = chooser.get_filename()
- chooser.destroy()
- if response == Gtk.ResponseType.ACCEPT:
- return filename
- else:
- return None
+ chooser.set_current_folder(Gio.File.new_for_path(get_desktop_dir()))
+ # GTK FREEZE BUG WORKAROUND:
+ # If we don't use idle_add, on response it immediately crashes.
+ # However if we do, on_filechooser_response gets called an unlimited
+ # amount of times, which is why we have to keep track of
+ # the filenames we have already added to prevent a freeze.
+ # This still pegs a CPU core at 100% however it doesn't block the main loop.
+ self.returned_chooser_filenames = []
+ GLib.idle_add(
+ lambda cb : chooser.connect("response", self.on_filechooser_response, cb),
+ callback
+ )
+ chooser.show()
+
+ def on_filechooser_response(self, chooser, response, callback):
+ filename = chooser.get_file().get_path()
+ if filename not in self.returned_chooser_filenames:
+ chooser.destroy()
+ if response == Gtk.ResponseType.ACCEPT and filename not in self.returned_chooser_filenames:
+ callback(filename)
+ self.returned_chooser_filenames.append(filename)
# Preferences methods #########################################################
@classmethod
diff --git a/GTG/plugins/export/export.ui b/GTG/plugins/export/export.ui
index ad45bcb38e..3422a75bc3 100644
--- a/GTG/plugins/export/export.ui
+++ b/GTG/plugins/export/export.ui
@@ -1,152 +1,78 @@
-
-
+
- False
Export tasks
- mouse
True
-
- True
- False
vertical
+ 15
+ 15
+ 15
+ 15
- True
- False
- 15
15
- True
- False
vertical
- True
- False
vertical
- True
- False
start
<b>Which tasks do you want to export?</b>
True
-
- False
- True
- 0
-
- True
- False
vertical
-
+
Active tasks shown in the browser
- True
- True
- False
True
- True
- export_finished_last_week_rb
+ export_finished_last_week_cb
-
- False
- True
- 0
-
-
+
Completed tasks shown in the browser
- True
- True
- False
- True
- export_finished_last_week_rb
+ export_finished_last_week_cb
-
- False
- True
- 1
-
-
+
Tasks finished last week
- True
- True
- False
- True
- True
-
- False
- True
- 2
-
-
- False
- True
- 1
-
-
- False
- True
- 0
-
- True
- False
20
vertical
+ 10
- True
- False
start
start
<b>Select a format for your tasks</b>
True
-
- False
- True
- 0
-
- True
- False
-
+ start
-
- False
- True
- 10
- 1
-
- True
- False
start
start
True
@@ -155,92 +81,35 @@
word-char
30
-
- False
- True
- 2
-
-
- False
- True
- 1
-
-
- False
- True
- 0
-
-
+
300
350
- True
- False
-
- False
- True
- 1
-
-
- False
- True
- 0
-
- 50
- True
- False
- 12
23
Open
- True
- True
- True
-
-
- False
- True
- 0
-
Save
- True
- True
- True
-
-
- False
- True
- 1
-
-
- False
- True
- 1
-
-
-
-
diff --git a/GTG/plugins/gamify/gamify.py b/GTG/plugins/gamify/gamify.py
index a983e40851..734248f490 100644
--- a/GTG/plugins/gamify/gamify.py
+++ b/GTG/plugins/gamify/gamify.py
@@ -10,21 +10,16 @@
from gettext import gettext as _
from gettext import ngettext
-from GTG.core.task import Task
+from GTG.core.tasks import Task
log = logging.getLogger(__name__)
+PLUGIN_PATH = os.path.dirname(os.path.abspath(__file__))
-class Gamify:
- PLUGIN_PATH = os.path.dirname(os.path.abspath(__file__))
- PLUGIN_NAMESPACE = 'gamify'
- DEFAULT_ANALYTICS = {
- "last_task_date": date.today(), # The date of the last task marked as done
- "last_task_number": 0, # The number of tasks done today
- "streak": 0, # The number of days in which the goal was achieved
- "goal_achieved": False, # achieved today's goal
- "score": 0
- }
+@Gtk.Template(filename=f'{PLUGIN_PATH}/prefs.ui')
+class GamifyPreferences(Gtk.Window):
+ __gtype_name__ = 'GamifyPreferences'
+
DEFAULT_PREFERENCES = {
"goal": 3,
"ui_type": "FULL",
@@ -34,6 +29,213 @@ class Gamify:
_('hard'): 3,
}
}
+
+ entry_sizegroup = Gtk.Template.Child('entry-sizegroup')
+
+ general_label = Gtk.Template.Child('general-label')
+ general_listbox = Gtk.Template.Child('general-listbox')
+ mappings_label = Gtk.Template.Child('mappings-label')
+ mappings_listbox = Gtk.Template.Child('mappings-listbox')
+
+ # target tasks
+ target_tasks = Gtk.Template.Child('target-tasks')
+ target_spinbutton = Gtk.Template.Child('target-spinbutton')
+ target_label = Gtk.Template.Child('target-label')
+
+ # UI mode Box
+ ui_mode = Gtk.Template.Child('ui-mode')
+ ui_combobox = Gtk.Template.Child('ui-combobox')
+ ui_label = Gtk.Template.Child('ui-label')
+
+ # Mappings objects
+ new_mapping_dialog = Gtk.Template.Child('new-mapping-dialog')
+ new_mapping_entry = Gtk.Template.Child('new-mapping-entry')
+ new_mapping_spinner = Gtk.Template.Child('new-mapping-spinner')
+
+ def __init__(self, plugin, api, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.plugin = plugin
+ self.api = api
+ self.load_general_listbox()
+
+ @Gtk.Template.Callback('on-preferences-closed')
+ def on_preferences_closed(self, widget=None, data=None):
+ self.hide()
+ return True
+
+ @Gtk.Template.Callback('on-preferences-changed')
+ def on_preferences_changed(self, widget=None, data=None):
+ self.load_preferences()
+
+ # Get the new preferences
+ self.plugin.preferences['goal'] = self.target_spinbutton.get_value_as_int()
+
+ ui_mode = int(self.get_ui_mode_combo_value())
+ if ui_mode == 0:
+ self.plugin.preferences['ui_type'] = "FULL"
+ elif ui_mode == 1:
+ self.plugin.preferences['ui_type'] = "BUTTON"
+ elif ui_mode == 2:
+ self.plugin.preferences['ui_type'] = "LEVELBAR"
+
+ # Save the new mappings
+ new_tag_mapping = {}
+ for row in list(self.mappings_listbox)[:-1]:
+ label, value = self.get_tag_value_from_mapping_row(row)
+ new_tag_mapping[label.get_label()] = value.get_value_as_int()
+
+ self.plugin.preferences['tag_mapping'] = new_tag_mapping
+
+ self.save_preferences()
+ self.load_general_listbox()
+ # Update the type of UI
+ self.plugin.update_ui()
+ # Update the goal in the widget(s)
+ self.plugin.update_goal()
+
+ @Gtk.Template.Callback('dismiss-new-mapping')
+ def on_dismiss_new_mapping(self, widget=None, event=None):
+ self.new_mapping_dialog.hide()
+
+ @Gtk.Template.Callback('submit-new-mapping')
+ def on_add_new_mapping(self, widget=None, event=None):
+ if tag := self.new_mapping_entry.get_text():
+ row = self.make_mapping_row(label_text=tag,
+ spin_value=self.new_mapping_spinner.get_value(),
+ entry_sizegroup=self.entry_sizegroup)
+ self.mappings_listbox.remove(self.add_row)
+ self.mappings_listbox.append(row)
+ self.mappings_listbox.append(self.add_row)
+
+ self.on_dismiss_new_mapping()
+
+ def load_preferences(self):
+ self.plugin.preferences = self.api.load_configuration_object(
+ self.plugin.PLUGIN_NAMESPACE, "preferences",
+ default_values=self.DEFAULT_PREFERENCES
+ )
+
+ def save_preferences(self):
+ self.api.save_configuration_object(
+ self.plugin.PLUGIN_NAMESPACE,
+ "preferences",
+ self.plugin.preferences
+ )
+
+ def make_mapping_row(self, label_text: str, spin_value, entry_sizegroup=None):
+ row = Gtk.ListBoxRow()
+ upper_box = Gtk.Box(spacing=3)
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, hexpand=True, halign=Gtk.Align.END)
+ label = Gtk.Label(label=label_text, margin_start=6)
+
+ spin = Gtk.SpinButton()
+ spin.set_hexpand(True)
+ spin.set_adjustment(Gtk.Adjustment(upper=100, step_increment=1, page_increment=10))
+ spin.set_numeric(True)
+ spin.set_value(int(spin_value))
+ if entry_sizegroup:
+ entry_sizegroup.add_widget(box)
+
+ button = Gtk.Button(icon_name="user-trash-symbolic")
+ button.connect("clicked", self.remove_mapping)
+
+ row.set_child(upper_box)
+ upper_box.append(label)
+ upper_box.append(box)
+ box.append(spin)
+ box.append(button)
+ return row
+
+ def load_mappings_listbox(self):
+ self.load_preferences()
+
+ # If there are any old children, remove them from the ListBox
+ for child in list(self.mappings_listbox):
+ self.mappings_listbox.remove(child)
+
+ # Construct the listBoxRows
+ for key, value in self.plugin.preferences['tag_mapping'].items():
+ row = self.make_mapping_row(
+ label_text=key, spin_value=value, entry_sizegroup=self.entry_sizegroup
+ )
+ self.mappings_listbox.append(row)
+
+ self.add_row = Gtk.ListBoxRow()
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ box.set_homogeneous(True)
+ box_click_gesture = Gtk.GestureSingle()
+ box_click_gesture.connect("begin", self.add_mapping_clicked)
+ box.add_controller(box_click_gesture)
+
+ add = Gtk.Image(icon_name="list-add-symbolic")
+ box.append(add)
+
+ self.add_row.set_child(box)
+ self.mappings_listbox.append(self.add_row)
+
+ def add_mapping_clicked(self, controller, sequence):
+ self.new_mapping_dialog.set_transient_for(self)
+
+ self.new_mapping_entry.set_text("")
+ self.new_mapping_spinner.set_value(0)
+
+ self.new_mapping_dialog.present()
+
+ def remove_mapping(self, widget, event=None):
+ self.mappings_listbox.remove(self.get_row_from_remove_mapping(widget))
+
+ def get_row_from_remove_mapping(self, button):
+ return button.get_parent().get_parent()
+
+ def get_tag_value_from_mapping_row(self, row):
+ label = list(row.get_child())[0]
+ spin = list(list(row.get_child())[1])[0]
+ return (label, spin)
+
+ def load_general_listbox(self):
+ self.load_ui_mode()
+ self.load_target_task()
+
+ for child in list(self.general_listbox):
+ child.set_child(None)
+ self.general_listbox.remove(child)
+
+ target_row = Gtk.ListBoxRow()
+ target_row.set_child(self.target_tasks)
+
+ ui_row = Gtk.ListBoxRow()
+ ui_row.set_child(self.ui_mode)
+
+ self.general_listbox.append(target_row)
+ self.general_listbox.append(ui_row)
+
+ def load_target_task(self):
+ self.load_preferences()
+ self.target_spinbutton.set_value(self.plugin.preferences['goal'])
+
+ def get_ui_mode_combo_value(self):
+ value = self.ui_combobox.get_active_id()
+ return value if value else 0
+
+ def load_ui_mode(self):
+ self.load_preferences()
+ if self.plugin.preferences['ui_type'] == 'FULL':
+ self.ui_combobox.set_active_id(str(0))
+ elif self.plugin.preferences['ui_type'] == 'BUTTON':
+ self.ui_combobox.set_active_id(str(1))
+ else:
+ self.ui_combobox.set_active_id(str(2))
+
+
+class Gamify:
+ PLUGIN_NAMESPACE = 'gamify'
+ DEFAULT_ANALYTICS = {
+ "last_task_date": date.today(), # The date of the last task marked as done
+ "last_task_number": 0, # The number of tasks done today
+ "streak": 0, # The number of days in which the goal was achieved
+ "goal_achieved": False, # achieved today's goal
+ "score": 0
+ }
LEVELS = {
100: _('Beginner'),
1000: _('Novice'),
@@ -50,50 +252,9 @@ class Gamify:
def __init__(self):
self.configureable = True
- self.builder = Gtk.Builder()
- path = f"{self.PLUGIN_PATH}/prefs.ui"
- self.builder.add_from_file(path)
-
self.data = None
self.preferences = None
-
- def _init_dialog_pref(self):
- # Get the dialog widget
- self.pref_dialog = self.builder.get_object('Preferences')
-
- # Get the listboxs
- self.general_label = self.builder.get_object('general-label')
- self.general_listbox = self.builder.get_object('general-listbox')
- self.mappings_label = self.builder.get_object('mappings-label')
- self.mappings_listbox = self.builder.get_object('mappings-listbox')
-
- # target tasks
- self.target_tasks = self.builder.get_object('target-tasks')
- self.target_spinbutton = self.builder.get_object('target-spinbutton')
- self.target_label = self.builder.get_object('target-label')
-
- # UI mode Box
- self.ui_mode = self.builder.get_object('ui-mode')
- self.ui_combobox = self.builder.get_object('ui-combobox')
- self.ui_label = self.builder.get_object('ui-label')
-
- # Mappings objects
- self.new_mapping_dialog = self.builder.get_object('new-mapping-dialog')
- self.new_mapping_entry = self.builder.get_object('new-mapping-entry')
- self.new_mapping_spinner = self.builder.get_object('new-mapping-spinner')
-
- if self.pref_dialog is None:
- raise ValueError('Cannot load preference dialog widget')
-
- self.load_general_listbox()
-
- SIGNALS = {
- "on-preferences-changed": self.on_preferences_changed,
- "on-preferences-closed": self.on_preferences_closed,
- "dismiss-new-mapping": self.on_dismiss_new_mapping,
- "submit-new-mapping": self.on_add_new_mapping
- }
- self.builder.connect_signals(SIGNALS)
+ self.builder = Gtk.Builder.new_from_file(f'{PLUGIN_PATH}/gamify.ui')
def activate(self, plugin_api):
self.plugin_api = plugin_api
@@ -103,23 +264,19 @@ def activate(self, plugin_api):
if plugin_api.is_editor():
return
- # Load preferences and data
- self.analytics_load()
- self.preferences_load()
-
- # Settings up the menu
- self.add_ui()
-
# Init the preference dialog
try:
- self._init_dialog_pref()
- except ValueError:
+ self.pref_dialog = GamifyPreferences(self, self.plugin_api)
+ except:
self.configureable = False
log.debug('Cannot load preference dialog widget')
- # Connect to the signals
- self.signal_connect_id = self.plugin_api.get_requester().connect("status-changed",
- self.on_status_changed)
+ # Load preferences and data
+ self.analytics_load()
+ self.pref_dialog.load_preferences()
+
+ # Settings up the menu
+ self.add_ui()
self.update_date()
self.update_streak()
@@ -127,28 +284,14 @@ def activate(self, plugin_api):
self.update_widget()
def deactivate(self, plugin_api):
- self.browser.disconnect(self.signal_connect_id)
+ self.pref_dialog.destroy()
self.remove_ui()
-
def is_configurable(self):
return True
# SAVE/LOAD DATA ##########################################################
- def preferences_load(self):
- self.preferences = self.plugin_api.load_configuration_object(
- self.PLUGIN_NAMESPACE, "preferences",
- default_values=self.DEFAULT_PREFERENCES
- )
-
- def save_preferences(self):
- self.plugin_api.save_configuration_object(
- self.PLUGIN_NAMESPACE,
- "preferences",
- self.preferences
- )
-
def analytics_load(self):
self.data = self.plugin_api.load_configuration_object(
self.PLUGIN_NAMESPACE, "analytics",
@@ -208,7 +351,7 @@ def on_status_changed(self, sender, task_id, old_status, status):
def on_marked_as_done(self, task_id):
log.debug('a task has been marked as done')
self.analytics_load()
- self.preferences_load()
+ self.pref_dialog.load_preferences()
# Update the date, if it is different from today
self.update_date()
@@ -224,7 +367,7 @@ def on_marked_as_done(self, task_id):
def on_marked_as_not_done(self, task_id):
log.debug('a task has been marked as not done')
self.analytics_load()
- self.preferences_load()
+ self.pref_dialog.load_preferences()
self.update_date()
@@ -251,7 +394,7 @@ def get_points_for_task(self, task_id):
2 points for @medium
3 points for @hard
"""
- task = self.plugin_api.get_requester().get_task(task_id)
+ task = self.plugin_api.ds.tasks.lookup[task_id]
return max(list(map(self.get_points, task.get_tags_name())), default=1)
# FRONTEND/UI #############################################################
@@ -274,7 +417,7 @@ def add_headerbar_button(self):
self.headerbar = self.plugin_api.get_header()
if self.headerbar:
- self.headerbar.add(self.headerbar_button)
+ self.headerbar.pack_start(self.headerbar_button)
def remove_headerbar_button(self):
self.headerbar.remove(self.headerbar_button)
@@ -283,7 +426,7 @@ def add_levelbar(self):
self.quickadd_pane = self.plugin_api.get_quickadd_pane()
self.levelbar = self.builder.get_object('goal-levelbar')
self.quickadd_pane.set_orientation(Gtk.Orientation.VERTICAL)
- self.quickadd_pane.add(self.levelbar)
+ self.quickadd_pane.append(self.levelbar)
def remove_levelbar(self):
self.quickadd_pane.set_orientation(Gtk.Orientation.HORIZONTAL)
@@ -393,171 +536,13 @@ def configure_dialog(self, manager_dialog):
log.debug('trying to open preference menu, but dialog widget not loaded')
return
- self.preferences_load()
+ self.pref_dialog.load_preferences()
self.pref_dialog.set_transient_for(manager_dialog)
# Tag Mapping
- self.load_mappings_listbox()
+ self.pref_dialog.load_mappings_listbox()
- self.load_ui_mode()
- self.load_target_task()
-
- self.pref_dialog.show_all()
-
- def on_preferences_closed(self, widget=None, data=None):
- self.pref_dialog.hide()
- return True
-
- def on_preferences_changed(self, widget=None, data=None):
- self.preferences_load()
-
- # Get the new preferences
- self.preferences['goal'] = self.target_spinbutton.get_value_as_int()
-
- ui_mode = int(self.get_ui_mode_combo_value())
- if ui_mode == 0:
- self.preferences['ui_type'] = "FULL"
- elif ui_mode == 1:
- self.preferences['ui_type'] = "BUTTON"
- elif ui_mode == 2:
- self.preferences['ui_type'] = "LEVELBAR"
-
- # Save the new mappings
- new_tag_mapping = {}
- for row in self.mappings_listbox.get_children()[:-1]:
- label, value = self.get_tag_value_from_mapping_row(row)
- new_tag_mapping[label.get_label()] = value.get_value_as_int()
-
- self.preferences['tag_mapping'] = new_tag_mapping
-
- self.save_preferences()
- # Update the type of UI
- self.update_ui()
- # Update the goal in the widget(s)
- self.update_goal()
-
- def get_ui_mode_combo_value(self):
- return self.ui_combobox.get_active_id()
-
- def make_mapping_row(self, label_text: str, spin_value):
- row = Gtk.ListBoxRow()
- upper_box = Gtk.Box(spacing=3)
- box = Gtk.HBox(orientation=Gtk.Orientation.HORIZONTAL)
- box.set_homogeneous(True)
- label = Gtk.Label(label_text)
- label.set_alignment(0.05, 0)
- label.set_valign(Gtk.Align.CENTER)
-
- spin = Gtk.SpinButton()
- spin.set_adjustment(Gtk.Adjustment(upper=100, step_increment=1, page_increment=10))
- spin.set_numeric(True)
- spin.set_value(int(spin_value))
-
- remove_icon = Gio.ThemedIcon(name="user-trash-symbolic")
- remove = Gtk.Image.new_from_gicon(remove_icon, Gtk.IconSize.BUTTON)
- button = Gtk.Button()
- button.connect("clicked", self.remove_mapping)
- button.add(remove)
-
- row.add(upper_box)
- upper_box.pack_start(box, True, True, 0)
- upper_box.pack_end(button, False, True, 0)
- box.add(label)
- box.add(spin)
- return row
-
- def load_mappings_listbox(self):
- self.mappings_label.set_alignment(0, 0)
- self.preferences_load()
-
- # If there are any old children, remove them from the ListBox
- for child in self.mappings_listbox.get_children():
- self.mappings_listbox.remove(child)
- child.destroy()
-
- # Construct the listBoxRows
- for key, value in self.preferences['tag_mapping'].items():
- row = self.make_mapping_row(label_text=key, spin_value=value)
- self.mappings_listbox.add(row)
-
- self.add_row = Gtk.ListBoxRow()
- box = Gtk.HBox(orientation=Gtk.Orientation.HORIZONTAL)
- box.set_homogeneous(True)
-
- add_icon = Gio.ThemedIcon(name="list-add-symbolic")
- add = Gtk.Image.new_from_gicon(add_icon, Gtk.IconSize.BUTTON)
- box.add(add)
-
- event_box = Gtk.EventBox()
- event_box.connect("button-press-event", self.add_mapping_clicked)
- event_box.add(box)
-
- self.add_row.add(event_box)
- self.mappings_listbox.add(self.add_row)
-
- def add_mapping_clicked(self, widget, event):
- self.new_mapping_dialog.set_transient_for(self.pref_dialog)
-
- self.new_mapping_entry.set_text("")
- self.new_mapping_spinner.set_value(0)
-
- self.new_mapping_dialog.show_all()
-
- def remove_mapping(self, widget, event=None):
- self.mappings_listbox.remove(self.get_row_from_remove_mapping(widget))
-
- def get_row_from_remove_mapping(self, button):
- return button.get_parent().get_parent()
-
- def get_tag_value_from_mapping_row(self, row):
- box = row.get_child().get_children()[0]
- box_children = box.get_children()
- return (box_children[0], box_children[1])
-
- def on_dismiss_new_mapping(self, widget=None, event=None):
- self.new_mapping_dialog.hide()
-
- def on_add_new_mapping(self, widget=None, event=None):
- if tag := self.new_mapping_entry.get_text():
- row = self.make_mapping_row(label_text=tag,
- spin_value=self.new_mapping_spinner.get_value())
- self.mappings_listbox.remove(self.add_row)
- self.mappings_listbox.add(row)
- self.mappings_listbox.add(self.add_row)
- self.mappings_listbox.show_all()
-
- self.on_dismiss_new_mapping()
-
- def load_general_listbox(self):
- self.general_label.set_alignment(0, 0)
- self.target_label.set_alignment(0, 0)
- self.ui_label.set_alignment(0, 0)
-
- self.load_ui_mode()
- self.load_target_task()
-
- for child in self.general_listbox.get_children():
- self.general_listbox.remove(child)
-
- target_row = Gtk.ListBoxRow()
- target_row.add(self.target_tasks)
-
- ui_row = Gtk.ListBoxRow()
- ui_row.add(self.ui_mode)
-
- self.general_listbox.add(target_row)
- self.general_listbox.add(ui_row)
-
- def load_target_task(self):
- self.preferences_load()
- self.target_spinbutton.set_value(self.preferences['goal'])
-
- def load_ui_mode(self):
- self.preferences_load()
- if self.preferences['ui_type'] == 'FULL':
- self.ui_combobox.set_active(0)
- elif self.preferences['ui_type'] == 'BUTTON':
- self.ui_combobox.set_active(1)
- else:
- self.ui_combobox.set_active(2)
+ self.pref_dialog.load_ui_mode()
+ self.pref_dialog.load_target_task()
+ self.pref_dialog.present()
diff --git a/GTG/plugins/gamify/gamify.ui b/GTG/plugins/gamify/gamify.ui
new file mode 100644
index 0000000000..466d3c0e10
--- /dev/null
+++ b/GTG/plugins/gamify/gamify.ui
@@ -0,0 +1,88 @@
+
+
+
+
+ list-remove-symbolic
+
+
+ 1
+ 100
+ 1
+ 10
+
+
+ 3
+ discrete
+
+
+
+
diff --git a/GTG/plugins/gamify/meson.build b/GTG/plugins/gamify/meson.build
index 439752ba94..9d9291c835 100644
--- a/GTG/plugins/gamify/meson.build
+++ b/GTG/plugins/gamify/meson.build
@@ -1,7 +1,8 @@
gtg_plugin_gamify = [
'__init__.py',
'gamify.py',
- 'prefs.ui',
+ 'gamify.ui',
+ 'prefs.ui'
]
python3.install_sources(gtg_plugin_gamify, subdir: 'GTG' / 'plugins' / 'gamify', pure: true)
diff --git a/GTG/plugins/gamify/prefs.ui b/GTG/plugins/gamify/prefs.ui
index 42bdaa8536..3294389161 100644
--- a/GTG/plugins/gamify/prefs.ui
+++ b/GTG/plugins/gamify/prefs.ui
@@ -1,235 +1,160 @@
-
-
-
- False
+
+
550
500
- dialog
-
-
+ 🗲Gamify Preferences
+
+
- True
- False
- True
- False
crossfade
-
- True
- False
- 25
- 22
- 15
- 9
- vertical
- 4
-
-
- True
- False
+
+ General
+
+
+ False
+ start
+ 25
+ 22
+ 15
+ 9
vertical
+ 4
-
- True
- False
- 7
- General
-
-
-
-
+
+ vertical
+
+
+ 7
+ General
+ 0.0
+
+
+
+
+
+
+
+
+ none
+ False
+
+
+
-
- False
- True
- 0
-
-
- True
- False
- none
- False
-
+
+ 24
-
- False
- True
- 1
-
-
-
- False
- True
- 0
-
-
-
-
- True
- False
- 24
-
-
- False
- True
- 1
-
-
-
-
- True
- False
- vertical
-
- True
- False
- 9
- Tag Mappings
-
-
-
-
+
+ vertical
+
+
+ 9
+ Tag Mappings
+ 0.0
+
+
+
+
+
+
+
+
+ none
+ False
+
+
+
-
- False
- True
- 0
-
-
- True
- False
- none
- False
-
+
+ True
+ True
+ end
+ end
+
+
+ Apply
+
+
+
-
- False
- True
- 1
-
-
- False
- True
- 2
-
-
-
-
- True
- False
-
-
- Apply
- True
- True
- True
-
-
-
-
- False
- True
- end
- 1
-
-
-
-
- False
- True
- end
- 3
-
-
+
-
- General
-
-
- False
- True
- 2
-
-
+
-
+
+
+
+
+ 6
+ 124
+ UI mode
+
+
+
+
+ True
+ 0
+
+ - Full/Both
+ - Button
+ - Discrete
+
-
-
- True
- False
- GDK_BUTTON_PRESS_MASK | GDK_STRUCTURE_MASK
- list-remove-symbolic
-
-
- 1
- 100
- 1
- 10
- True
- False
- True
- True
- False
- 13
- 117
- Target tasks per day
+ 6
+ Target Tasks per day
-
- False
- True
- 0
-
- True
- True
- number
+ True
+ end
adjustment1
1
True
- 1
-
- False
- True
- 1
-
+
+ horizontal
+
+
+
+
+
+
+ 1
+ 100
+ 1
+ 10
+
1
100
@@ -237,55 +162,58 @@
10
- False
- center-on-parent
- dialog
- True
False
-
+
- True
- False
vertical
-
- False
- True
- 0
-
- True
- False
12
12
27
@@ -295,269 +223,38 @@
True
- True
- False
True
- True
- False
Tag Name:
-
- False
- True
- 0
-
-
- True
- True
- True
-
-
- False
- True
- 1
-
+
-
- False
- True
- 0
-
- True
- False
True
- True
- False
Points:
-
- False
- True
- 0
-
- True
- True
- number
+ 1
adjustment2
1
1
-
- False
- True
- 1
-
-
- False
- True
- 1
-
-
- False
- True
- 1
-
-
-
-
-
-
- 1
- 100
- 1
- 10
-
-
- True
- False
- 3
- discrete
-
-
-
-
- True
- False
- True
-
-
- True
- False
- 13
- 124
- UI mode
-
-
- False
- True
- 0
-
-
-
-
- True
- False
- 0
- on
- True
- 0
-
- - Button + discrete bar
- - Button only
- - Discrete bar only
-
-
-
- False
- True
- 1
-
-
+
diff --git a/GTG/plugins/hamster/hamster.py b/GTG/plugins/hamster/hamster.py
index 2df04a8d8a..c0494d8cc4 100644
--- a/GTG/plugins/hamster/hamster.py
+++ b/GTG/plugins/hamster/hamster.py
@@ -23,9 +23,9 @@
from gettext import gettext as _
import dbus
-from gi.repository import Gtk
+from gi.repository import Gtk, Gio
-from GTG.core.task import Task
+from GTG.core.tasks import Task
from GTG.plugins.hamster.helper import FactBuilder
@@ -44,6 +44,12 @@ class HamsterPlugin():
" to the selected task")
START_ACTIVITY_LABEL = _("Start task in Hamster")
STOP_ACTIVITY_LABEL = _("Stop Hamster Activity")
+ EDIT_ACTIVITY_ACTION = "edit_task"
+ # having dots in prefix causes CRASH
+ EDIT_ACTIVITY_ACTION_PREF = "app_editor_" + PLUGIN_NAMESPACE
+ EDIT_ACTIVITY_ACTION_FULL = ".".join(
+ [EDIT_ACTIVITY_ACTION_PREF, EDIT_ACTIVITY_ACTION]
+ )
BUFFER_TIME = 60 # secs
PLUGIN_PATH = os.path.dirname(os.path.abspath(__file__))
@@ -57,14 +63,6 @@ def __init__(self):
self.liblarch_callbacks = []
self.tracked_task_id = None
- @staticmethod
- def get_icon_image(image_name):
- """ Get a gtk.Image with a gtk stock icon. """
- icon = Gtk.Image()
- icon.set_from_icon_name(image_name, Gtk.IconSize.BUTTON)
- icon.show()
- return icon
-
# Interaction with Hamster ###
def send_task(self, task):
"""Send a gtg task to hamster-applet"""
@@ -143,7 +141,7 @@ def on_task_deleted(self, task_id, path):
def on_task_modified(self, task_id, path):
""" Stop task if it is tracked and it is Done/Dismissed """
- task = self.plugin_api.get_requester().get_task(task_id)
+ task = self.plugin_api.ds.tasks.lookup[task_id]
if not task:
return
if task.get_status() in (Task.STA_DISMISSED, Task.STA_DONE):
@@ -157,19 +155,22 @@ def activate(self, plugin_api):
# add button
if plugin_api.is_browser():
- self.button.set_image(self.get_icon_image('alarm-symbolic'))
+ self.button.set_icon_name('alarm-symbolic')
self.button.set_tooltip_text(self.TOOLTIP_TEXT_START_ACTIVITY)
self.button.set_sensitive(False)
self.button.connect('clicked', self.browser_cb, plugin_api)
self.button.show()
- header_bar = plugin_api.get_gtk_builder().get_object('browser_headerbar')
+ header_bar = plugin_api.get_header()
header_bar.pack_end(self.button)
plugin_api.set_active_selection_changed_callback(self.selection_changed)
- self.subscribe_task_updates([
- ("node-modified-inview", self.on_task_modified),
- ("node-deleted-inview", self.on_task_deleted),
- ])
+ # self.subscribe_task_updates([
+ # ("node-modified-inview", self.on_task_modified),
+ # ("node-deleted-inview", self.on_task_deleted),
+ # ])
+
+ plugin_api.ds.tasks.tree_model.connect('items-changed', self.on_task_modified)
+ plugin_api.ds.tasks.tree_model.connect('items-changed', self.on_task_deleted)
# set up preferences
self.preference_dialog_init()
@@ -189,7 +190,17 @@ def onTaskOpened(self, plugin_api):
if task.get_status() != Task.STA_ACTIVE:
return
- task_menu_item = Gtk.ModelButton()
+ group = Gio.SimpleActionGroup()
+ track_task_action = Gio.SimpleAction.new(self.EDIT_ACTIVITY_ACTION, None)
+ track_task_action.connect('activate', self.task_cb, plugin_api)
+ group.add_action(track_task_action)
+ plugin_api.get_ui().insert_action_group(self.EDIT_ACTIVITY_ACTION_PREF, group)
+
+ task_menu_item = Gio.MenuItem.new(
+ (self.STOP_ACTIVITY_LABEL if self.is_task_active(task.get_id())
+ else self.START_ACTIVITY_LABEL),
+ self.EDIT_ACTIVITY_ACTION_FULL
+ )
self.task_menu_items.update({task.get_id(): task_menu_item})
if self.is_task_active(task.get_id()):
task_menu_item.props.text = self.STOP_ACTIVITY_LABEL
@@ -204,6 +215,7 @@ def onTaskOpened(self, plugin_api):
def onTaskClosed(self, plugin_api):
task = plugin_api.get_ui().get_task()
+ plugin_api.get_ui().insert_action_group(self.EDIT_ACTIVITY_ACTION_PREF, None)
if task.get_id() in self.task_menu_items:
del self.task_menu_items[task.get_id()]
self.check_task_selected()
@@ -228,11 +240,11 @@ def render_record_list(self, records, plugin_api):
header_grid = Gtk.Grid()
outer_grid = Gtk.Grid()
- vbox.pack_start(header_grid, True, True, 0)
- vbox.pack_start(Gtk.Separator(), True, True, 0)
- vbox.pack_start(inner_container, True, True, 4)
- vbox.pack_start(Gtk.Separator(), True, True, 0)
- vbox.pack_start(outer_grid, True, True, 4)
+ vbox.append(header_grid)
+ vbox.append(Gtk.Separator())
+ vbox.append(inner_container)
+ vbox.append(Gtk.Separator())
+ vbox.append(outer_grid)
total = 0
@@ -246,21 +258,21 @@ def add(row, content_1, content_2, top_offset, active=False):
content_2 = f"{content_2}"
column_1 = Gtk.Label(label=content_1)
+ column_1.set_xalign(0.0)
column_1.set_margin_start(18)
column_1.set_margin_end(18)
column_1.set_margin_top(6)
column_1.set_margin_bottom(6)
column_1.set_use_markup(True)
- column_1.set_alignment(xalign=Gtk.Align.START, yalign=Gtk.Align.CENTER)
row.attach(column_1, 0, top_offset, 1, 1)
column_2 = Gtk.Label(label=content_2)
+ column_2.set_hexpand(True)
column_2.set_use_markup(True)
+ column_2.set_xalign(1.0)
column_2.set_margin_end(18)
column_2.set_margin_top(6)
column_2.set_margin_bottom(6)
- column_2.set_alignment(xalign=Gtk.Align.END,
- yalign=Gtk.Align.CENTER)
row.attach(column_2, 1, top_offset, 4, 1)
add(header_grid, "Hamster Time Tracker Records:", "", 0)
@@ -282,7 +294,7 @@ def add(row, content_1, content_2, top_offset, active=False):
def deactivate(self, plugin_api):
if plugin_api.is_browser():
# plugin_api.remove_toolbar_item(self.button)
- header_bar = plugin_api.get_gtk_builder().get_object('browser_headerbar')
+ header_bar = plugin_api.get_header()
header_bar.remove(self.button)
else:
for _, menu_button in self.task_menu_items.items():
@@ -295,22 +307,22 @@ def deactivate(self, plugin_api):
self.liblarch_callbacks = []
def browser_cb(self, widget, plugin_api):
- task_id = plugin_api.get_browser().get_selected_task()
- task = plugin_api.get_requester().get_task(task_id)
+ task_id = plugin_api.browser.get_pane().get_selection()[0]
+ task = plugin_api.ds.tasks.lookup[task_id]
self.decide_start_or_stop_activity(task, widget)
- def task_cb(self, widget, plugin_api):
+ def task_cb(self, action, gparam, plugin_api):
task = plugin_api.get_ui().get_task()
- self.decide_start_or_stop_activity(task, widget)
+ self.decide_start_or_stop_activity(task, plugin_api)
- def decide_start_or_stop_activity(self, task, widget):
+ def decide_start_or_stop_activity(self, task, plugin_api):
if self.is_task_active(task.get_id()):
- self.change_button_to_start_activity(widget)
- self.change_task_menu_to_start_activity(task.get_id())
+ self.change_button_to_start_activity(self.button)
+ self.change_task_menu_to_start_activity(task.get_id(), plugin_api)
self.stop_task(task.get_id())
elif task.get_status() == Task.STA_ACTIVE:
- self.change_button_to_stop_activity(widget)
- self.change_task_menu_to_stop_activity(task.get_id())
+ self.change_button_to_stop_activity(self.button)
+ self.change_task_menu_to_stop_activity(task.get_id(), plugin_api)
self.send_task(task)
def selection_changed(self, selection):
@@ -325,35 +337,40 @@ def check_task_selected(self):
task_id = self.plugin_api.get_browser().get_selected_task()
if not task_id:
return
- task = self.plugin_api.get_requester().get_task(task_id)
+ task = self.plugin_api.ds.tasks.lookup[task_id]
self.decide_button_mode(self.button, task)
def decide_button_mode(self, button, task):
if self.is_task_active(task.get_id()):
self.change_button_to_stop_activity(button)
- self.change_task_menu_to_stop_activity(task.get_id())
else:
self.change_button_to_start_activity(button)
- self.change_task_menu_to_start_activity(task.get_id())
def change_button_to_start_activity(self, button):
button.set_tooltip_text(self.TOOLTIP_TEXT_START_ACTIVITY)
- button.set_image(self.get_icon_image('alarm-symbolic'))
+ button.set_icon_name('alarm-symbolic')
def change_button_to_stop_activity(self, button):
button.set_tooltip_text(self.TOOLTIP_TEXT_STOP_ACTIVITY)
- button.set_image(self.get_icon_image('process-stop-symbolic'))
+ button.set_icon_name('process-stop-symbolic')
- def change_task_menu_to_start_activity(self, task_id):
+ def change_task_menu_to_start_activity(self, task_id, plugin_api):
if task_id in self.task_menu_items:
- self.task_menu_items[task_id].set_label(self.START_ACTIVITY_LABEL)
-
- def change_task_menu_to_stop_activity(self, task_id):
- for item_id, button in self.task_menu_items.items():
- if item_id == task_id:
- button.set_label(self.STOP_ACTIVITY_LABEL)
- else:
- button.set_label(self.START_ACTIVITY_LABEL)
+ plugin_api.remove_menu_item(self.task_menu_items[task_id][0])
+ replacement_item = Gio.MenuItem.new(
+ self.START_ACTIVITY_LABEL, self.EDIT_ACTIVITY_ACTION_FULL
+ )
+ self.task_menu_items[task_id] = replacement_item
+ plugin_api.add_menu_item(replacement_item)
+
+ def change_task_menu_to_stop_activity(self, task_id, plugin_api):
+ if task_id in self.task_menu_items:
+ plugin_api.remove_menu_item(self.task_menu_items[task_id][0])
+ replacement_item = Gio.MenuItem.new(
+ self.STOP_ACTIVITY_LABEL, self.EDIT_ACTIVITY_ACTION_FULL
+ )
+ self.task_menu_items[task_id] = replacement_item
+ plugin_api.add_menu_item(replacement_item)
# Preference Handling ###
def is_configurable(self):
@@ -373,7 +390,7 @@ def pref_to_dialog(pref):
pref_to_dialog("description")
pref_to_dialog("tags")
- self.preferences_dialog.show_all()
+ self.preferences_dialog.present()
def on_preferences_close(self, widget=None, data=None):
@@ -408,10 +425,7 @@ def preference_dialog_init(self):
path = f"{self.PLUGIN_PATH}/prefs.ui"
self.builder.add_from_file(path)
self.preferences_dialog = self.builder.get_object("dialog1")
- SIGNAL_CONNECTIONS_DIC = {
- "prefs_close": self.on_preferences_close,
- }
- self.builder.connect_signals(SIGNAL_CONNECTIONS_DIC)
+ self.preferences_dialog.connect("close-request", self.on_preferences_close)
def format_date(task):
diff --git a/GTG/plugins/hamster/prefs.ui b/GTG/plugins/hamster/prefs.ui
index b79919e2a4..f2a4362aa8 100644
--- a/GTG/plugins/hamster/prefs.ui
+++ b/GTG/plugins/hamster/prefs.ui
@@ -1,51 +1,31 @@
-
+
+
+
+
+
+
+
+
+
+
- False
- 5
Hamster Preferences
- dialog
-
-
-
-
-
+
- True
- False
+ 5
+ 5
+ 5
+ 5
vertical
2
-
-
- True
- False
- end
-
-
-
-
-
- False
- False
- end
- 0
-
-
- True
- False
vertical
- top
- True
- False
- start
- 12
- 12
12
12
8
@@ -53,60 +33,31 @@
none
- True
- True
False
- True
- False
- 12
- 12
12
12
8
8
+ 18
- True
- False
Activity Title
0
-
- True
- True
- 0
-
-
-
-
- True
- False
- 18
- 1
+ True
+ end
on
- True
title
- Task Title
- Task Tag
-
-
- True
-
-
-
- False
- True
- end
- 2
-
@@ -114,62 +65,33 @@
- True
- True
False
- True
- False
- 12
- 12
12
12
8
8
+ 18
- True
- False
Category
0
-
- True
- True
- 0
-
-
-
-
- True
- False
- 18
+ True
+ end
1
on
- True
auto
- Let hamster choose
- GTG Tag
- Default GTG Tag if activity is unsorted
-
-
- True
- Let hamster choose
-
-
-
- False
- True
- end
- 2
-
@@ -177,61 +99,33 @@
- True
- True
False
- True
- False
- 12
- 12
12
12
8
8
+ 18
- True
- False
Description
0
-
- True
- True
- 0
-
-
-
-
- True
- False
- 18
+ True
+ end
1
on
- True
contents
- GTG task title
- GTG task contents
- None
-
-
- True
-
-
-
- False
- True
- end
- 2
-
@@ -239,62 +133,33 @@
- True
- True
False
- True
- False
- 12
- 12
12
12
8
8
+ 18
- True
- False
Tags
0
-
- True
- True
- 0
- 0
-
-
-
-
- True
- False
- 18
+ True
+ end
1
on
- True
existing
- All GTG tags
- GTG tags already used in Hamster
- None
-
-
- True
-
-
-
- False
- False
- end
- 2
-
@@ -304,18 +169,8 @@
-
- False
- True
- 0
-
-
- False
- True
- 1
-
diff --git a/GTG/plugins/send_email/sendEmail.py b/GTG/plugins/send_email/sendEmail.py
index 210e6d3022..5b9c185d4e 100644
--- a/GTG/plugins/send_email/sendEmail.py
+++ b/GTG/plugins/send_email/sendEmail.py
@@ -40,7 +40,7 @@ def onTaskOpened(self, plugin_api):
send_action = Gio.SimpleAction.new("send_as_email", None)
send_action.connect("activate", self._on_send_activate, plugin_api)
group.add_action(send_action)
- plugin_api.get_ui().window.insert_action_group(self.ACTION_GROUP_PREF, group)
+ plugin_api.get_ui().insert_action_group(self.ACTION_GROUP_PREF, group)
self.menu_item = Gio.MenuItem.new(
_("Send via email"), ".".join([self.ACTION_GROUP_PREF, "send_as_email"])
@@ -51,7 +51,7 @@ def onTaskClosed(self, plugin_api):
"""
Removes the button when a task is closed.
"""
- plugin_api.get_ui().window.insert_action_group(self.ACTION_GROUP_PREF, None)
+ plugin_api.get_ui().insert_action_group(self.ACTION_GROUP_PREF, None)
plugin_api.remove_menu_item(self.menu_item)
def deactivate(self, plugin_api):
@@ -67,14 +67,14 @@ def _on_send_activate(self, action, param, plugin_api):
task = plugin_api.get_ui().get_task()
# Body contains Status Tags, Subtasks and Content.
- body = _("Status: %s") % (task.get_status()) + \
- _("\nTags: %s") % (", ".join(task.get_tags_name())) + \
+ body = _("Status: %s") % (task.status) + \
+ _("\nTags: %s") % (", ".join(t.name for t in task.tags)) + \
_("\nSubtasks: %s") % (
- "".join(["\n- "+subtask.get_title() for subtask in task.get_subtasks()])) + \
- _("\nTask content:\n%s") % (task.get_text())
+ "".join(["\n- "+subtask.title for subtask in task.children])) + \
+ _("\nTask content:\n%s") % (task.content)
# Title contains the title and the start and due dates.
- title = _("Task: %(task_title)s") % {'task_title': task.get_title()}
+ title = _("Task: %(task_title)s") % {'task_title': task.title}
parameters = urllib.parse.urlencode({'subject': title, 'body': body})
parameters = parameters.replace('+', '%20')
diff --git a/GTG/plugins/untouched_tasks/untouchedTasks.py b/GTG/plugins/untouched_tasks/untouchedTasks.py
index 1a9290354f..8b3900e5af 100644
--- a/GTG/plugins/untouched_tasks/untouchedTasks.py
+++ b/GTG/plugins/untouched_tasks/untouchedTasks.py
@@ -47,16 +47,8 @@ def __init__(self):
self.builder.get_object("pref_spinbtn_max_days")
self.pref_tag_name = \
self.builder.get_object("pref_tag_name")
- SIGNAL_CONNECTIONS_DIC = {
- "on_preferences_dialog_delete_event":
- self.on_preferences_cancel,
- "on_btn_preferences_cancel_clicked":
- self.on_preferences_cancel,
- "on_btn_preferences_ok_clicked":
- self.on_preferences_ok,
- }
-
- self.builder.connect_signals(SIGNAL_CONNECTIONS_DIC)
+ self.preferences_dialog.connect('response', self.on_preferences_response)
+
self.menu_item = Gio.MenuItem.new(_("Add @untouched tag"), "app.plugin.add_untouched_tag")
def activate(self, plugin_api):
@@ -136,22 +128,21 @@ def configure_dialog(self, manager_dialog):
self.preferences["max_days"])
self.pref_tag_name.set_text(
self.preferences["default_tag"])
- self.preferences_dialog.show_all()
-
- def on_preferences_cancel(self, widget=None, data=None):
- self.preferences_dialog.hide()
- return True
-
- def on_preferences_ok(self, widget=None, data=None):
- self.preferences["is_automatic"] = \
- self.pref_chbox_is_automatic.get_active()
- self.preferences["max_days"] = \
- self.pref_spinbtn_max_days.get_value()
- self.preferences['default_tag'] = \
- self.pref_tag_name.get_text()
- self.preferences_apply()
- self.preferences_store()
- self.preferences_dialog.hide()
+ self.preferences_dialog.present()
+
+ def on_preferences_response(self, dialog, response):
+ if response == Gtk.ResponseType.OK:
+ self.preferences["is_automatic"] = \
+ self.pref_chbox_is_automatic.get_active()
+ self.preferences["max_days"] = \
+ self.pref_spinbtn_max_days.get_value()
+ self.preferences['default_tag'] = \
+ self.pref_tag_name.get_text()
+ self.preferences_apply()
+ self.preferences_store()
+ self.preferences_dialog.hide()
+ else:
+ self.preferences_dialog.hide()
def preferences_load(self):
self.preferences = self.plugin_api.load_configuration_object(
diff --git a/GTG/plugins/untouched_tasks/untouchedTasks.ui b/GTG/plugins/untouched_tasks/untouchedTasks.ui
index 78d138a31a..402b87222d 100644
--- a/GTG/plugins/untouched_tasks/untouchedTasks.ui
+++ b/GTG/plugins/untouched_tasks/untouchedTasks.ui
@@ -1,175 +1,85 @@
-
+
1
999
1
10
-
- False
- 10
- center-on-parent
- dialog
-
-
+
+
- True
- False
vertical
+ 10
+ 10
+ 10
+ 10
+ 12
-
- True
- False
- vertical
- 12
+
+ 20
-
- True
- False
-
-
- True
- False
- Add
-
-
- False
- True
- 0
-
-
-
-
- True
- True
- ●
- 15
-
-
- False
- True
- 1
-
-
-
-
- True
- False
- tag to task after it has been left untouched for at least
-
-
- False
- True
- 2
-
-
-
-
- True
- True
- 3
- •
- adjustment1
- 0.5
- True
- True
-
-
- False
- True
- 3
-
-
-
-
- True
- False
- days
-
-
- False
- True
- 4
-
-
+
+ Add
-
- False
- True
- 0
-
-
- Check for untouched tasks automatically
- True
- True
- False
- top
- True
+
+ ●
+ 15
-
- False
- True
- 1
-
-
-
- False
- True
- 21
- 0
-
-
-
-
- 30
- True
- False
- 50
-
- Cancel
- True
- True
- True
-
+
+ tag to task after it has been left untouched for at least
-
- False
- True
- 0
-
-
- Apply
- True
- True
- True
-
+
+ adjustment1
+ 0.5
+ True
+ True
-
- False
- True
- 1
-
+
+
+ days
+
+
+
+
+
+
+ Check for untouched tasks automatically
-
- False
- True
- 1
-
-
-
+
+
+ 6
+ 10
+ 10
+ 10
+ 10
+
+
+
+
+ Cancel
+
+
+
+
+ Apply
+
+
+ btn_preferences_cancel
+ btn_preferences_ok
+
diff --git a/GTG/plugins/urgency_color/preferences.ui b/GTG/plugins/urgency_color/preferences.ui
index a3a65e9ca2..87ab817bf8 100644
--- a/GTG/plugins/urgency_color/preferences.ui
+++ b/GTG/plugins/urgency_color/preferences.ui
@@ -1,345 +1,177 @@
-
+
100
5
10
-
- False
- 12
- Plugin Preferences
- dialog
-
+
+ 300
+ True
+
- False
+ 5
+ 5
+ 5
+ 5
vertical
12
- True
- False
- 0
- none
- True
- False
- 12
- 5
- 5
- 6
+ 12
+ 12
+ 5
+ 5
+ 5
- True
- False
- Danger zone span:
- 0
+ True
+ Danger zone span
-
- False
- True
- 0
-
- True
- True
- •
+ end
spinbutton_reddays_adjustment
10
True
-
- False
- True
- 1
-
- True
- False
%
-
- False
- True
- 5
- 2
-
- True
- False
- Danger zone
-
-
-
-
+ <b>Danger zone</b>
+ True
-
- False
- True
- 0
-
- True
- False
- 0
- none
- True
- False
- 12
- 5
- 5
+ 12
+ 5
+ 5
vertical
6
- True
- False
- 6
- False
- True
- True
- True
-
- False
- True
- 0
-
- True
- False
+ True
Overdue
0
-
- True
- True
- 1
-
-
- False
- True
- 0
-
- True
- False
- 6
- False
- True
- True
- True
-
- False
- True
- 0
-
- True
- False
+ True
High
0
-
- True
- True
- 1
-
-
- False
- True
- 1
-
- True
- False
- 6
- False
- True
- True
- True
-
- False
- True
- 0
-
- True
- False
+ True
Normal
0
-
- True
- True
- 1
-
-
- False
- True
- 2
-
- True
- False
- 6
- False
- True
- True
- True
-
- False
- True
- 0
-
- True
- False
+ True
Low
0
-
- True
- True
- 1
-
-
- False
- True
- 3
-
- True
- False
- Urgency level colors
-
-
-
-
+ <b>Urgency level color</b>
+ True
-
- False
- True
- 1
-
-
- True
- False
-
-
- False
- True
- 2
-
-
-
-
- False
- 5
- 6
+
+ True
+ center
+ 5
+ 6
+ 6
+ 6
Reset
- True
- True
- True
-
+
-
- False
- True
- 0
-
+ True
+ end
+ 6
+ 6
+ 6
Apply
- True
- True
- True
-
+
-
- False
- True
- 1
-
-
- True
- True
- 4
-
-
+
diff --git a/GTG/plugins/urgency_color/urgency_color.py b/GTG/plugins/urgency_color/urgency_color.py
index aa64074812..fdebf0b84b 100644
--- a/GTG/plugins/urgency_color/urgency_color.py
+++ b/GTG/plugins/urgency_color/urgency_color.py
@@ -22,7 +22,10 @@
from GTG.core.dates import Date
-class UrgencyColorPlugin():
+@Gtk.Template(filename=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'preferences.ui'))
+class UrgencyColorPreferences(Gtk.Window):
+
+ __gtype_name__ = 'UrgencyColorPreferences'
PLUGIN_NAME = 'Urgency Color'
DEFAULT_PREFS = {
@@ -32,16 +35,124 @@ class UrgencyColorPlugin():
'color_high': '#ff9784',
'color_overdue': '#b8b8b8'}
+ # Get the widgets
+ # Spin button
+ spinbutton_reddays = Gtk.Template.Child()
+
+ # Colorbutton - OVERDUE
+ colorbutton_overdue = Gtk.Template.Child()
+
+ # Colorbutton - HIGH
+ colorbutton_high = Gtk.Template.Child()
+
+ # Colorbutton - NORMAL
+ colorbutton_normal = Gtk.Template.Child()
+
+ # Colorbutton - LOW
+ colorbutton_low = Gtk.Template.Child()
+
+ # Buttons
+ button_apply = Gtk.Template.Child()
+ button_reset = Gtk.Template.Child()
+
+ def __init__(self, plugin_api):
+ super().__init__(title=f'GTG - {self.PLUGIN_NAME} preferences')
+ self._plugin_api = plugin_api
+ self.prefs_load()
+
+ # Update widget's values
+ self.prefs_update_widgets()
+
+ def prefs_update_widgets(self):
+ """ Synchronizes the widgets with the data in _pref_data """
+ # Spin button
+ self.spinbutton_reddays.set_value(self._pref_data['reddays'])
+ rgba = Gdk.RGBA()
+ # Colorbutton - OVERDUE
+ rgba.parse(self._pref_data['color_overdue'])
+ self.colorbutton_overdue.set_rgba(rgba)
+ # Colorbutton - HIGH
+ rgba.parse(self._pref_data['color_high'])
+ self.colorbutton_high.set_rgba(rgba)
+ # Colorbutton - NORMAL
+ rgba.parse(self._pref_data['color_normal'])
+ self.colorbutton_normal.set_rgba(rgba)
+ # Colorbutton - LOW
+ rgba.parse(self._pref_data['color_low'])
+ self.colorbutton_low.set_rgba(rgba)
+
+ def on_prefs_cancel(self, widget=None, data=None):
+ self.prefs_update_widgets()
+ self.hide()
+
+ def on_prefs_apply(self, widget=None, data=None):
+ self._pref_data = self._pref_data_potential
+ self.prefs_store()
+ self._refresh_task_color()
+ self.hide()
+
+ def on_prefs_reset(self, widget=None, data=None):
+ # Restore the default plugin settings
+ self._pref_data = self._pref_data_potential = dict(self.DEFAULT_PREFS)
+ self.prefs_update_widgets()
+
+ def on_prefs_spinbutton_reddays_changed(self, widget=None, data=None):
+ self._pref_data_potential['reddays'] = \
+ self.spinbutton_reddays.get_value()
+
+ def on_prefs_colorbutton_overdue_changed(self, widget=None, data=None):
+ self._pref_data_potential['color_overdue'] = \
+ self.colorbutton_overdue.get_color().to_string()
+
+ def on_prefs_colorbutton_high_changed(self, widget=None, data=None):
+ self._pref_data_potential['color_high'] = \
+ self.colorbutton_high.get_color().to_string()
+
+ def on_prefs_colorbutton_normal_changed(self, widget=None, data=None):
+ self._pref_data_potential['color_normal'] = \
+ self.colorbutton_normal.get_color().to_string()
+
+ def on_prefs_colorbutton_low_changed(self, widget=None, data=None):
+ self._pref_data_potential['color_low'] = \
+ self.colorbutton_low.get_color().to_string()
+
+ def prefs_load(self):
+ self._pref_data = self._plugin_api.load_configuration_object(
+ self.PLUGIN_NAME, "preferences",
+ default_values=self.DEFAULT_PREFS)
+
+ # CORRECT NAMES FROM OLD PREFERENCES
+ # This is a dirty fix and thus should be removed in a
+ # distant future, when nobody has "red", "yellow" or "green"
+ # settings
+ namepairs = {'red': 'high', 'yellow': 'normal', 'green': 'low'}
+ for oldname, newname in namepairs.items():
+ old_key, new_key = "color_" + oldname, "color_" + newname
+ if old_key in self._pref_data:
+ self._pref_data[new_key] = self._pref_data.pop(old_key)
+
+ def prefs_store(self):
+ self._plugin_api.save_configuration_object(
+ self.PLUGIN_NAME,
+ 'preferences',
+ self._pref_data)
+
+ def get_data_reference(self):
+ """ Get a reference to use to access plugin preference data """
+ return self._pref_data
+
+
+class UrgencyColorPlugin():
def __init__(self):
self._plugin_api = None
- self.req = None
+ self.ds = None
def activate(self, plugin_api):
""" Plugin is activated """
self._plugin_api = plugin_api
- self.req = self._plugin_api.get_requester()
- self.prefs_load()
- self.prefs_init()
+ self.ds = self._plugin_api.ds
+ self.prefs_window = UrgencyColorPreferences(plugin_api)
+ self._pref_data = self.prefs_window.get_data_reference()
# Set color function
self._refresh_task_color()
@@ -156,22 +267,20 @@ def __get_active_child_list(node):
(i.e - the subtasks which are not marked as 'Done' or 'Dismissed'
"""
child_list = []
- for child_id in node.children:
- child = node.req.get_task(child_id)
+ for child in node.children:
child_list += __get_active_child_list(child)
- if child.get_status() in [child.STA_ACTIVE]:
+ if child.is_active:
child_list.append(child_id)
return child_list
child_list = __get_active_child_list(node)
daysleft = None
- for child_id in child_list:
- child = self.req.get_task(child_id)
- if child.get_due_date() == Date.no_date():
+ for child in child_list:
+ if child.date_due == Date.no_date():
continue
- daysleft_of_child = child.get_due_date().days_left()
+ daysleft_of_child = child.date_due.days_left()
if daysleft is None:
daysleft = daysleft_of_child
color = self.get_node_bgcolor(child)
@@ -192,135 +301,5 @@ def is_configurable(self):
def configure_dialog(self, manager_dialog):
self._pref_data_potential = self._pref_data
- self.prefs_window.show_all()
+ self.prefs_window.present()
# self.prefs_window.set_transient_for(manager_dialog)
- pass
-
- def prefs_init(self):
- self.builder = Gtk.Builder()
- self.builder.add_from_file(os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- 'preferences.ui'))
-
- # Get the widgets
- # Window
- self.prefs_window = self.builder.get_object('prefs_window')
- self.prefs_window.set_size_request(300, -1)
- self.prefs_window.hide_on_delete()
-
- # Spin button
- self.spinbutton_reddays = self.builder.get_object('spinbutton_reddays')
-
- # Colorbutton - OVERDUE
- self.colorbutton_overdue = self.builder.get_object(
- 'colorbutton_overdue')
-
- # Colorbutton - HIGH
- self.colorbutton_high = self.builder.get_object('colorbutton_high')
-
- # Colorbutton - NORMAL
- self.colorbutton_normal = self.builder.get_object('colorbutton_normal')
-
- # Colorbutton - LOW
- self.colorbutton_low = self.builder.get_object('colorbutton_low')
-
- # Buttons
- self.button_apply = self.builder.get_object('button_apply')
- self.button_reset = self.builder.get_object('button_reset')
-
- # Update widget's values
- self.prefs_update_widgets()
-
- # Signal connections
- SIGNAL_CONNECTIONS_DIC = {
- 'on_prefs_window_delete_event':
- self.on_prefs_cancel,
- 'on_prefs_apply_event':
- self.on_prefs_apply,
- 'on_prefs_reset_event':
- self.on_prefs_reset,
- 'on_prefs_spinbutton_reddays_changed':
- self.on_prefs_spinbutton_reddays_changed,
- 'on_prefs_colorbutton_overdue_changed':
- self.on_prefs_colorbutton_overdue_changed,
- 'on_prefs_colorbutton_high_changed':
- self.on_prefs_colorbutton_high_changed,
- 'on_prefs_colorbutton_normal_changed':
- self.on_prefs_colorbutton_normal_changed,
- 'on_prefs_colorbutton_low_changed':
- self.on_prefs_colorbutton_low_changed}
- self.builder.connect_signals(SIGNAL_CONNECTIONS_DIC)
-
- def prefs_update_widgets(self):
- """ Synchronizes the widgets with the data in _pref_data """
- # Spin button
- self.spinbutton_reddays.set_value(self._pref_data['reddays'])
- # Colorbutton - OVERDUE
- self.colorbutton_overdue.set_color(
- Gdk.color_parse(self._pref_data['color_overdue']))
- # Colorbutton - HIGH
- self.colorbutton_high.set_color(
- Gdk.color_parse(self._pref_data['color_high']))
- # Colorbutton - NORMAL
- self.colorbutton_normal.set_color(
- Gdk.color_parse(self._pref_data['color_normal']))
- # Colorbutton - LOW
- self.colorbutton_low.set_color(
- Gdk.color_parse(self._pref_data['color_low']))
-
- def on_prefs_cancel(self, widget=None, data=None):
- self.prefs_update_widgets()
- self.prefs_window.hide()
- return True
-
- def on_prefs_apply(self, widget=None, data=None):
- self._pref_data = self._pref_data_potential
- self.prefs_store()
- self._refresh_task_color()
- self.prefs_window.hide()
-
- def on_prefs_reset(self, widget=None, data=None):
- # Restore the default plugin settings
- self._pref_data = self._pref_data_potential = dict(self.DEFAULT_PREFS)
- self.prefs_update_widgets()
-
- def prefs_load(self):
- self._pref_data = self._plugin_api.load_configuration_object(
- self.PLUGIN_NAME, "preferences",
- default_values=self.DEFAULT_PREFS)
-
- # CORRECT NAMES FROM OLD PREFERENCES
- # This is a dirty fix and thus should be removed in a
- # distant future, when nobody has "red", "yellow" or "green"
- # settings
- namepairs = {'red': 'high', 'yellow': 'normal', 'green': 'low'}
- for oldname, newname in namepairs.items():
- old_key, new_key = "color_" + oldname, "color_" + newname
- if old_key in self._pref_data:
- self._pref_data[new_key] = self._pref_data.pop(old_key)
-
- def prefs_store(self):
- self._plugin_api.save_configuration_object(
- self.PLUGIN_NAME,
- 'preferences',
- self._pref_data)
-
- def on_prefs_spinbutton_reddays_changed(self, widget=None, data=None):
- self._pref_data_potential['reddays'] = \
- self.spinbutton_reddays.get_value()
-
- def on_prefs_colorbutton_overdue_changed(self, widget=None, data=None):
- self._pref_data_potential['color_overdue'] = \
- self.colorbutton_overdue.get_color().to_string()
-
- def on_prefs_colorbutton_high_changed(self, widget=None, data=None):
- self._pref_data_potential['color_high'] = \
- self.colorbutton_high.get_color().to_string()
-
- def on_prefs_colorbutton_normal_changed(self, widget=None, data=None):
- self._pref_data_potential['color_normal'] = \
- self.colorbutton_normal.get_color().to_string()
-
- def on_prefs_colorbutton_low_changed(self, widget=None, data=None):
- self._pref_data_potential['color_low'] = \
- self.colorbutton_low.get_color().to_string()
diff --git a/data/icons/hicolor/16x16/actions/applications-internet.png b/data/icons/hicolor/16x16/actions/applications-internet.png
new file mode 100644
index 0000000000..f7f25d7bbb
Binary files /dev/null and b/data/icons/hicolor/16x16/actions/applications-internet.png differ
diff --git a/data/icons/hicolor/512x512/actions/applications-internet.png b/data/icons/hicolor/512x512/actions/applications-internet.png
new file mode 100644
index 0000000000..a1f0e19217
Binary files /dev/null and b/data/icons/hicolor/512x512/actions/applications-internet.png differ
diff --git a/docs/contributors/core architecture.md b/docs/contributors/core architecture.md
index 494ec875bc..c8aa0dc31b 100644
--- a/docs/contributors/core architecture.md
+++ b/docs/contributors/core architecture.md
@@ -78,8 +78,8 @@ and fill it with test data like this:
```python
-from GTG.core.datastore2 import DataStore2
-ds = DataStore2()
+from GTG.core.datastore import DataStore
+ds = DataStore()
ds.fill_with_samples(200)
ds.print_info()
```
diff --git a/flatpak/org.gnome.GTG.json b/flatpak/org.gnome.GTG.json
index 5b5fe6ea82..eb4b8abc22 100644
--- a/flatpak/org.gnome.GTG.json
+++ b/flatpak/org.gnome.GTG.json
@@ -11,6 +11,7 @@
"--socket=fallback-x11",
"--socket=wayland",
"--share=network",
+ "--device=dri",
"--system-talk-name=org.freedesktop.login1",
"--talk-name=org.gnome.Hamster",
"--own-name=org.gnome.GTGDevel"
diff --git a/pylint.rc b/pylint.rc
index 8aa7818fe8..b05b82d367 100644
--- a/pylint.rc
+++ b/pylint.rc
@@ -1,7 +1,7 @@
[MASTER]
# Python code to execute
-init-hook='import gi; gi.require_version("Gtk", "3.0");'
+init-hook='import gi; gi.require_version("Gtk", "4.0");'
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
diff --git a/run-tests b/run-tests
index 1dc45ecc83..c95b7f7a32 100755
--- a/run-tests
+++ b/run-tests
@@ -28,9 +28,9 @@ import sys
import pytest
import gi
-gi.require_version('Gdk', '3.0')
-gi.require_version('Gtk', '3.0')
-gi.require_version('GtkSource', '4')
+gi.require_version('Gdk', '4.0')
+gi.require_version('Gtk', '4.0')
+gi.require_version('GtkSource', '5')
if __name__ == "__main__":
diff --git a/tests/core/test_tag2.py b/tests/core/test_tag2.py
index 7aa921901d..657b4816c6 100644
--- a/tests/core/test_tag2.py
+++ b/tests/core/test_tag2.py
@@ -19,7 +19,7 @@
from unittest import TestCase
from uuid import uuid4
-from GTG.core.tags2 import Tag2, TagStore
+from GTG.core.tags import Tag, TagStore
from lxml.etree import Element, SubElement, XML
@@ -32,7 +32,7 @@ def test_new(self):
self.assertEqual(len(store.data), 1)
self.assertEqual(store.lookup[tag.id], tag)
- self.assertIsInstance(tag, Tag2)
+ self.assertIsInstance(tag, Tag)
tag2 = store.new('@a_tag')
self.assertEqual(len(store.data), 2)
diff --git a/tests/core/test_task2.py b/tests/core/test_task2.py
index a3e1e7c14e..6400941671 100644
--- a/tests/core/test_task2.py
+++ b/tests/core/test_task2.py
@@ -20,8 +20,8 @@
from uuid import uuid4
import datetime
-from GTG.core.tasks2 import Task2, Status, TaskStore, Filter
-from GTG.core.tags2 import Tag2, TagStore
+from GTG.core.tasks import Task, Status, TaskStore, Filter
+from GTG.core.tags import Tag, TagStore
from GTG.core.dates import Date
from lxml.etree import Element, SubElement, XML
@@ -30,12 +30,12 @@
class TestTask2(TestCase):
def test_title(self):
- task = Task2(id=uuid4(), title='\tMy Title\n')
+ task = Task(id=uuid4(), title='\tMy Title\n')
self.assertEqual(task.title, 'My Title')
def test_excerpt(self):
- task = Task2(id=uuid4(), title='A Task')
+ task = Task(id=uuid4(), title='A Task')
self.assertEqual(task.excerpt, '')
@@ -51,7 +51,7 @@ def test_excerpt(self):
def test_toggle_active_single(self):
- task = Task2(id=uuid4(), title='A Task')
+ task = Task(id=uuid4(), title='A Task')
self.assertEqual(task.status, Status.ACTIVE)
@@ -64,8 +64,8 @@ def test_toggle_active_single(self):
self.assertEqual(task.date_closed, Date.no_date())
def test_toggle_active_children(self):
- task = Task2(id=uuid4(), title='A Task')
- task2 = Task2(id=uuid4(), title='A Child Task')
+ task = Task(id=uuid4(), title='A Task')
+ task2 = Task(id=uuid4(), title='A Child Task')
task.children.append(task2)
task2.parent = task
@@ -83,7 +83,7 @@ def test_toggle_active_children(self):
def test_toggle_dismiss_single(self):
- task = Task2(id=uuid4(), title='A Task')
+ task = Task(id=uuid4(), title='A Task')
task.toggle_dismiss()
self.assertEqual(task.status, Status.DISMISSED)
@@ -95,8 +95,8 @@ def test_toggle_dismiss_single(self):
def test_toggle_dismiss_children(self):
- task = Task2(id=uuid4(), title='A Task')
- task2 = Task2(id=uuid4(), title='A Child Task')
+ task = Task(id=uuid4(), title='A Task')
+ task2 = Task(id=uuid4(), title='A Child Task')
task.children.append(task2)
task2.parent = task
@@ -114,8 +114,8 @@ def test_toggle_dismiss_children(self):
def test_tags(self):
- task = Task2(id=uuid4(), title='A Task')
- tag = Tag2(id=uuid4(), name='A Tag')
+ task = Task(id=uuid4(), title='A Task')
+ tag = Tag(id=uuid4(), name='A Tag')
task.add_tag(tag)
self.assertEqual(len(task.tags), 1)
@@ -133,11 +133,11 @@ def test_tags(self):
def test_tags_children(self):
- task1 = Task2(id=uuid4(), title='A Parent Task')
- task2 = Task2(id=uuid4(), title='A Child Task')
+ task1 = Task(id=uuid4(), title='A Parent Task')
+ task2 = Task(id=uuid4(), title='A Child Task')
- tag1 = Tag2(id=uuid4(), name='A Tag')
- tag2 = Tag2(id=uuid4(), name='Another Tag')
+ tag1 = Tag(id=uuid4(), name='A Tag')
+ tag2 = Tag(id=uuid4(), name='Another Tag')
task1.children.append(task2)
task1.add_tag(tag1)
@@ -152,12 +152,12 @@ def test_tags_children(self):
def test_due_date(self):
- task1 = Task2(id=uuid4(), title='A Parent Task')
- task2 = Task2(id=uuid4(), title='A Child Task')
- task3 = Task2(id=uuid4(), title='Another Child Task')
- task4 = Task2(id=uuid4(), title='Yet Another Child Task')
- task5 = Task2(id=uuid4(), title='So many Child Tasks')
- task6 = Task2(id=uuid4(), title='More childs')
+ task1 = Task(id=uuid4(), title='A Parent Task')
+ task2 = Task(id=uuid4(), title='A Child Task')
+ task3 = Task(id=uuid4(), title='Another Child Task')
+ task4 = Task(id=uuid4(), title='Yet Another Child Task')
+ task5 = Task(id=uuid4(), title='So many Child Tasks')
+ task6 = Task(id=uuid4(), title='More childs')
task1.children.append(task2)
task1.children.append(task3)
@@ -221,7 +221,7 @@ def test_new_simple(self):
store = TaskStore()
task = store.new('My Task')
- self.assertIsInstance(task, Task2)
+ self.assertIsInstance(task, Task)
self.assertEqual(store.get(task.id), task)
self.assertEqual(task.title, 'My Task')
self.assertEqual(store.count(), 1)
@@ -281,7 +281,7 @@ def test_xml_load_simple(self):
TAG_ID = '6f1ba7b3-a797-44b9-accd-303adaf04073'
TASK_ID = '1d34df07-4185-43ad-adbd-698a86193411'
- tag = Tag2(id=TAG_ID, name='My Tag')
+ tag = Tag(id=TAG_ID, name='My Tag')
tag_store.add(tag)
parsed_xml = XML(f'''
@@ -524,8 +524,8 @@ def test_filter_tag(self):
task3 = task_store.new('My Other Other Task')
task4 = task_store.new('My Other Other Other Task')
- tag1 = Tag2(id=uuid4(), name='A Tag')
- tag2 = Tag2(id=uuid4(), name='Another Tag')
+ tag1 = Tag(id=uuid4(), name='A Tag')
+ tag2 = Tag(id=uuid4(), name='Another Tag')
task1.add_tag(tag1)
task2.add_tag(tag2)