From f0ff3b8fac71bc1961bba13b68c458950c9e7d0a Mon Sep 17 00:00:00 2001 From: Tomeu Vizoso Date: Wed, 10 Sep 2008 14:30:41 +0000 Subject: Merge the journal into the shell --- (limited to 'src') diff --git a/src/Makefile.am b/src/Makefile.am index 48fb84f..e1da7ce 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -1,4 +1,4 @@ -SUBDIRS = controlpanel hardware model view intro +SUBDIRS = controlpanel hardware journal model view intro sugardir = $(pkgdatadir)/shell sugar_PYTHON = \ diff --git a/src/journal/Makefile.am b/src/journal/Makefile.am new file mode 100644 index 0000000..f9944e6 --- /dev/null +++ b/src/journal/Makefile.am @@ -0,0 +1,18 @@ +sugardir = $(pkgdatadir)/shell/journal +sugar_PYTHON = \ + __init__.py \ + collapsedentry.py \ + detailview.py \ + expandedentry.py \ + journalactivity.py \ + journalentrybundle.py \ + journaltoolbox.py \ + keepicon.py \ + listview.py \ + misc.py \ + modalalert.py \ + objectchooser.py \ + palettes.py \ + query.py \ + volumesmanager.py \ + volumestoolbar.py diff --git a/src/journal/__init__.py b/src/journal/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/journal/__init__.py diff --git a/src/journal/collapsedentry.py b/src/journal/collapsedentry.py new file mode 100644 index 0000000..c69960a --- /dev/null +++ b/src/journal/collapsedentry.py @@ -0,0 +1,385 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +from gettext import gettext as _ + +import gobject +import gtk +import hippo +import json + +from sugar.graphics.icon import CanvasIcon +from sugar.graphics.xocolor import XoColor +from sugar.graphics import style +from sugar.datastore import datastore +from sugar.graphics.entry import CanvasEntry + +from journal.keepicon import KeepIcon +from journal.palettes import ObjectPalette, BuddyPalette +from journal import misc + +class BuddyIcon(CanvasIcon): + def __init__(self, buddy, **kwargs): + CanvasIcon.__init__(self, **kwargs) + self._buddy = buddy + + def create_palette(self): + return BuddyPalette(self._buddy) + +class BuddyList(hippo.CanvasBox): + def __init__(self, model, width): + hippo.CanvasBox.__init__(self, + orientation=hippo.ORIENTATION_HORIZONTAL, + box_width=width, + xalign=hippo.ALIGNMENT_START) + self.set_model(model) + + def set_model(self, model): + for item in self.get_children(): + self.remove(item) + + for buddy in model[0:3]: + nick_, color = buddy + icon = BuddyIcon(buddy, + icon_name='computer-xo', + xo_color=XoColor(color), + cache=True) + self.append(icon) + +class EntryIcon(CanvasIcon): + def __init__(self, **kwargs): + CanvasIcon.__init__(self, **kwargs) + self._jobject = None + + def set_jobject(self, jobject): + self._jobject = jobject + self.props.file_name = misc.get_icon_name(jobject) + self.palette = None + + def create_palette(self): + if self.show_palette: + return ObjectPalette(self._jobject) + else: + return None + + show_palette = gobject.property(type=bool, default=False) + +class BaseCollapsedEntry(hippo.CanvasBox): + __gtype_name__ = 'BaseCollapsedEntry' + + _DATE_COL_WIDTH = style.GRID_CELL_SIZE * 3 + _BUDDIES_COL_WIDTH = style.GRID_CELL_SIZE * 3 + _PROGRESS_COL_WIDTH = style.GRID_CELL_SIZE * 5 + + def __init__(self): + hippo.CanvasBox.__init__(self, + spacing=style.DEFAULT_SPACING, + padding_top=style.DEFAULT_PADDING, + padding_bottom=style.DEFAULT_PADDING, + padding_left=style.DEFAULT_PADDING * 2, + padding_right=style.DEFAULT_PADDING * 2, + box_height=style.GRID_CELL_SIZE, + orientation=hippo.ORIENTATION_HORIZONTAL) + + self._jobject = None + self._is_selected = False + + self.keep_icon = self._create_keep_icon() + self.append(self.keep_icon) + + self.icon = self._create_icon() + self.append(self.icon) + + self.title = self._create_title() + self.append(self.title, hippo.PACK_EXPAND) + + self.buddies_list = self._create_buddies_list() + self.append(self.buddies_list) + + self.date = self._create_date() + self.append(self.date) + + # Progress controls + self.progress_bar = self._create_progress_bar() + self.append(self.progress_bar) + + self.cancel_button = self._create_cancel_button() + self.append(self.cancel_button) + + def _create_keep_icon(self): + keep_icon = KeepIcon(False) + keep_icon.connect('button-release-event', + self.__keep_icon_button_release_event_cb) + return keep_icon + + def _create_date(self): + date = hippo.CanvasText(text='', + xalign=hippo.ALIGNMENT_START, + font_desc=style.FONT_NORMAL.get_pango_desc(), + box_width=self._DATE_COL_WIDTH) + return date + + def _create_icon(self): + icon = EntryIcon(size=style.STANDARD_ICON_SIZE, cache=True) + return icon + + def _create_title(self): + # TODO: We'd prefer to ellipsize in the middle + title = hippo.CanvasText(text='', + xalign=hippo.ALIGNMENT_START, + font_desc=style.FONT_BOLD.get_pango_desc(), + size_mode=hippo.CANVAS_SIZE_ELLIPSIZE_END) + return title + + def _create_buddies_list(self): + return BuddyList([], self._BUDDIES_COL_WIDTH) + + def _create_progress_bar(self): + progress_bar = gtk.ProgressBar() + return hippo.CanvasWidget(widget=progress_bar, + yalign=hippo.ALIGNMENT_CENTER, + box_width=self._PROGRESS_COL_WIDTH) + + def _create_cancel_button(self): + button = CanvasIcon(icon_name='activity-stop', + size=style.SMALL_ICON_SIZE, + box_width=style.GRID_CELL_SIZE) + button.connect('button-release-event', + self._cancel_button_release_event_cb) + return button + + def _decode_buddies(self): + if self.jobject.metadata.has_key('buddies') and \ + self.jobject.metadata['buddies']: + # json cannot read unicode strings + buddies_str = self.jobject.metadata['buddies'].encode('utf8') + buddies = json.read(buddies_str).values() + else: + buddies = [] + return buddies + + def update_visibility(self): + in_process = self.is_in_progress() + + self.buddies_list.set_visible(not in_process) + self.date.set_visible(not in_process) + + self.progress_bar.set_visible(in_process) + self.cancel_button.set_visible(in_process) + + # TODO: determine the appearance of in-progress entries + def _update_color(self): + if self.is_in_progress(): + self.props.background_color = style.COLOR_WHITE.get_int() + else: + self.props.background_color = style.COLOR_WHITE.get_int() + + def is_in_progress(self): + return self._jobject.metadata.has_key('progress') and \ + int(self._jobject.metadata['progress']) < 100 + + def get_keep(self): + keep = int(self._jobject.metadata.get('keep', 0)) + return keep == 1 + + def __keep_icon_button_release_event_cb(self, button, event): + logging.debug('__keep_icon_button_release_event_cb') + jobject = datastore.get(self._jobject.object_id) + try: + if self.get_keep(): + jobject.metadata['keep'] = 0 + else: + jobject.metadata['keep'] = 1 + datastore.write(jobject, update_mtime=False) + finally: + jobject.destroy() + + self.keep_icon.props.keep = self.get_keep() + self._update_color() + + return True + + def _cancel_button_release_event_cb(self, button, event): + logging.debug('_cancel_button_release_event_cb') + datastore.delete(self._jobject.object_id) + return True + + def set_selected(self, is_selected): + self._is_selected = is_selected + self._update_color() + + def set_jobject(self, jobject): + self._jobject = jobject + self._is_selected = False + + self.keep_icon.props.keep = self.get_keep() + + self.date.props.text = misc.get_date(jobject) + + self.icon.set_jobject(jobject) + if jobject.is_activity_bundle(): + self.icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() + self.icon.props.stroke_color = style.COLOR_BUTTON_GREY.get_svg() + else: + if jobject.metadata.has_key('icon-color') and \ + jobject.metadata['icon-color']: + self.icon.props.xo_color = XoColor( \ + jobject.metadata['icon-color']) + else: + self.icon.props.xo_color = None + + if jobject.metadata.get('title', ''): + title_text = jobject.metadata['title'] + else: + title_text = _('Untitled') + self.title.props.text = title_text + + self.buddies_list.set_model(self._decode_buddies()) + + if jobject.metadata.has_key('progress'): + self.progress_bar.props.widget.props.fraction = \ + int(jobject.metadata['progress']) / 100.0 + + self.update_visibility() + self._update_color() + + def get_jobject(self): + return self._jobject + + jobject = property(get_jobject, set_jobject) + + def update_date(self): + self.date.props.text = misc.get_date(self._jobject) + +class CollapsedEntry(BaseCollapsedEntry): + __gtype_name__ = 'CollapsedEntry' + + __gsignals__ = { + 'detail-clicked': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([])) + } + + def __init__(self): + BaseCollapsedEntry.__init__(self) + + self.icon.props.show_palette = True + self.icon.connect('button-release-event', + self.__icon_button_release_event_cb) + + self.title.connect('button_release_event', + self.__title_button_release_event_cb) + + self._title_entry = self._create_title_entry() + self.insert_after(self._title_entry, self.title, hippo.PACK_EXPAND) + self._title_entry.set_visible(False) + + self._detail_button = self._create_detail_button() + self._detail_button.connect('motion-notify-event', + self.__detail_button_motion_notify_event_cb) + self.append(self._detail_button) + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + self.reverse() + + def _create_title_entry(self): + title_entry = CanvasEntry() + title_entry.set_background(style.COLOR_WHITE.get_html()) + title_entry.props.widget.connect('focus-out-event', + self.__title_entry_focus_out_event_cb) + title_entry.props.widget.connect('activate', + self.__title_entry_activate_cb) + title_entry.connect('key-press-event', + self.__title_entry_key_press_event_cb) + return title_entry + + def _create_detail_button(self): + button = CanvasIcon(icon_name='go-right', + size=style.SMALL_ICON_SIZE, + box_width=style.GRID_CELL_SIZE * 3 / 5, + fill_color=style.COLOR_BUTTON_GREY.get_svg()) + button.connect('button-release-event', + self.__detail_button_release_event_cb) + return button + + def update_visibility(self): + BaseCollapsedEntry.update_visibility(self) + self._detail_button.set_visible(not self.is_in_progress()) + + def set_jobject(self, jobject): + BaseCollapsedEntry.set_jobject(self, jobject) + self._title_entry.props.text = self.title.props.text + + jobject = property(BaseCollapsedEntry.get_jobject, set_jobject) + + def __detail_button_release_event_cb(self, button, event): + logging.debug('_detail_button_release_event_cb') + if not self.is_in_progress(): + self.emit('detail-clicked') + return True + + def __detail_button_motion_notify_event_cb(self, button, event): + if event.detail == hippo.MOTION_DETAIL_ENTER: + button.props.fill_color = style.COLOR_TOOLBAR_GREY.get_svg() + elif event.detail == hippo.MOTION_DETAIL_LEAVE: + button.props.fill_color = style.COLOR_BUTTON_GREY.get_svg() + + def __icon_button_release_event_cb(self, button, event): + logging.debug('__icon_button_release_event_cb') + self.jobject.resume() + return True + + def __title_button_release_event_cb(self, button, event): + self.title.set_visible(False) + self._title_entry.set_visible(True) + self._title_entry.props.widget.grab_focus() + + def __title_entry_focus_out_event_cb(self, entry, event): + self._apply_title_change(entry.props.text) + + def __title_entry_activate_cb(self, entry): + self._apply_title_change(entry.props.text) + + def __title_entry_key_press_event_cb(self, entry, event): + if event.key == hippo.KEY_ESCAPE: + self._cancel_title_change() + + def _apply_title_change(self, title): + self._title_entry.set_visible(False) + self.title.set_visible(True) + + if title == '': + self._cancel_title_change() + elif self.title.props.text != title: + self.title.props.text = title + self._jobject.metadata['title'] = title + self._jobject.metadata['title_set_by_user'] = '1' + datastore.write(self._jobject, update_mtime=False, + reply_handler=self._datastore_write_cb, + error_handler=self._datastore_write_error_cb) + + def _cancel_title_change(self): + self._title_entry.props.text = self.title.props.text + self._title_entry.set_visible(False) + self.title.set_visible(True) + + def _datastore_write_cb(self): + pass + + def _datastore_write_error_cb(self, error): + logging.error('CollapsedEntry._datastore_write_error_cb: %r' % error) + diff --git a/src/journal/detailview.py b/src/journal/detailview.py new file mode 100644 index 0000000..3e9a721 --- /dev/null +++ b/src/journal/detailview.py @@ -0,0 +1,133 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +from gettext import gettext as _ + +import gobject +import gtk +import hippo + +from sugar.graphics import style +from sugar.graphics.icon import CanvasIcon +from sugar.datastore import datastore + +from journal.expandedentry import ExpandedEntry + +class DetailView(gtk.VBox): + __gtype_name__ = 'DetailView' + + __gproperties__ = { + 'jobject' : (object, None, None, + gobject.PARAM_READWRITE) + } + + __gsignals__ = { + 'go-back-clicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])) + } + + def __init__(self, **kwargs): + self._jobject = None + self._expanded_entry = None + + canvas = hippo.Canvas() + + self._root = hippo.CanvasBox() + self._root.props.background_color = style.COLOR_PANEL_GREY.get_int() + canvas.set_root(self._root) + + back_bar = BackBar() + back_bar.connect('button-release-event', + self.__back_bar_release_event_cb) + self._root.append(back_bar) + + gobject.GObject.__init__(self, **kwargs) + + self.pack_start(canvas) + canvas.show() + + def _fav_icon_activated_cb(self, fav_icon): + keep = not self._expanded_entry.get_keep() + self._expanded_entry.set_keep(keep) + fav_icon.props.keep = keep + + def __back_bar_release_event_cb(self, back_bar, event): + self.emit('go-back-clicked') + return False + + def _update_view(self): + if self._expanded_entry: + self._root.remove(self._expanded_entry) + + # Work around pygobject bug #479227 + self._expanded_entry.remove_all() + import gc + gc.collect() + if self._jobject: + self._expanded_entry = ExpandedEntry(self._jobject.object_id) + self._root.append(self._expanded_entry, hippo.PACK_EXPAND) + + def refresh(self): + logging.debug('DetailView.refresh') + if self._jobject: + self._jobject = datastore.get(self._jobject.object_id) + self._update_view() + + def do_set_property(self, pspec, value): + if pspec.name == 'jobject': + self._jobject = value + self._update_view() + else: + raise AssertionError + + def do_get_property(self, pspec): + if pspec.name == 'jobject': + return self._jobject + else: + raise AssertionError + + +class BackBar(hippo.CanvasBox): + def __init__(self): + hippo.CanvasBox.__init__(self, + orientation=hippo.ORIENTATION_HORIZONTAL, + border=style.LINE_WIDTH, + background_color=style.COLOR_PANEL_GREY.get_int(), + border_color=style.COLOR_SELECTION_GREY.get_int(), + padding=style.DEFAULT_PADDING, + padding_left=style.DEFAULT_SPACING, + spacing=style.DEFAULT_SPACING) + + icon = CanvasIcon(icon_name='go-previous', + size=style.SMALL_ICON_SIZE, + fill_color=style.COLOR_TOOLBAR_GREY.get_svg()) + self.append(icon) + + label = hippo.CanvasText(text=_('Back'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + self.append(label) + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + self.reverse() + + self.connect('motion-notify-event', self.__motion_notify_event_cb) + + def __motion_notify_event_cb(self, box, event): + if event.detail == hippo.MOTION_DETAIL_ENTER: + box.props.background_color = style.COLOR_SELECTION_GREY.get_int() + elif event.detail == hippo.MOTION_DETAIL_LEAVE: + box.props.background_color = style.COLOR_PANEL_GREY.get_int() + return False diff --git a/src/journal/expandedentry.py b/src/journal/expandedentry.py new file mode 100644 index 0000000..fd591fa --- /dev/null +++ b/src/journal/expandedentry.py @@ -0,0 +1,412 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +from gettext import gettext as _ +import StringIO + +import hippo +import cairo +import gobject +import gtk +import json + +from sugar.graphics import style +from sugar.graphics.icon import CanvasIcon +from sugar.graphics.xocolor import XoColor +from sugar.graphics.entry import CanvasEntry +from sugar.datastore import datastore + +from journal.keepicon import KeepIcon +from journal.palettes import ObjectPalette, BuddyPalette +from journal import misc + +class Separator(hippo.CanvasBox, hippo.CanvasItem): + def __init__(self, orientation): + hippo.CanvasBox.__init__(self, + background_color=style.COLOR_PANEL_GREY.get_int()) + + if orientation == hippo.ORIENTATION_VERTICAL: + self.props.box_width = style.LINE_WIDTH + else: + self.props.box_height = style.LINE_WIDTH + +class CanvasTextView(hippo.CanvasWidget): + def __init__(self, text, **kwargs): + hippo.CanvasWidget.__init__(self, **kwargs) + self.text_view_widget = gtk.TextView() + self.text_view_widget.props.buffer.props.text = text + self.text_view_widget.props.left_margin = style.DEFAULT_SPACING + self.text_view_widget.props.right_margin = style.DEFAULT_SPACING + self.text_view_widget.props.wrap_mode = gtk.WRAP_WORD + self.text_view_widget.show() + + # TODO: These fields should expand vertically instead of scrolling + scrolled_window = gtk.ScrolledWindow() + scrolled_window.set_shadow_type(gtk.SHADOW_OUT) + scrolled_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) + scrolled_window.add(self.text_view_widget) + + self.props.widget = scrolled_window + +class BuddyList(hippo.CanvasBox): + def __init__(self, model): + hippo.CanvasBox.__init__(self, xalign=hippo.ALIGNMENT_START, + orientation=hippo.ORIENTATION_HORIZONTAL) + + for buddy in model: + nick_, color = buddy + hbox = hippo.CanvasBox(orientation=hippo.ORIENTATION_HORIZONTAL) + icon = CanvasIcon(icon_name='computer-xo', + xo_color=XoColor(color), + size=style.STANDARD_ICON_SIZE) + icon.set_palette(BuddyPalette(buddy)) + hbox.append(icon) + self.append(hbox) + +class ExpandedEntry(hippo.CanvasBox): + def __init__(self, object_id): + hippo.CanvasBox.__init__(self) + self.props.orientation = hippo.ORIENTATION_VERTICAL + self.props.background_color = style.COLOR_WHITE.get_int() + self.props.padding_top = style.DEFAULT_SPACING * 3 + + self._jobject = datastore.get(object_id) + self._update_title_sid = None + + # Create header + header = hippo.CanvasBox(orientation=hippo.ORIENTATION_HORIZONTAL, + padding=style.DEFAULT_PADDING, + padding_right=style.GRID_CELL_SIZE, + spacing=style.DEFAULT_SPACING) + self.append(header) + + # Create two column body + + body = hippo.CanvasBox(orientation=hippo.ORIENTATION_HORIZONTAL, + spacing=style.DEFAULT_SPACING * 3, + padding_left=style.GRID_CELL_SIZE, + padding_right=style.GRID_CELL_SIZE, + padding_top=style.DEFAULT_SPACING * 3) + + self.append(body, hippo.PACK_EXPAND) + + first_column = hippo.CanvasBox(orientation=hippo.ORIENTATION_VERTICAL, + spacing=style.DEFAULT_SPACING) + body.append(first_column) + + second_column = hippo.CanvasBox(orientation=hippo.ORIENTATION_VERTICAL, + spacing=style.DEFAULT_SPACING) + body.append(second_column, hippo.PACK_EXPAND) + + # Header + + self._keep_icon = self._create_keep_icon() + header.append(self._keep_icon) + + self._icon = self._create_icon() + header.append(self._icon) + + self._title = self._create_title() + header.append(self._title, hippo.PACK_EXPAND) + + # TODO: create a version list popup instead of a date label + self._date = self._create_date() + header.append(self._date) + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + header.reverse() + + # First column + + self._preview = self._create_preview() + first_column.append(self._preview) + + # Second column + + description_box, self._description = self._create_description() + second_column.append(description_box) + + tags_box, self._tags = self._create_tags() + second_column.append(tags_box) + + self._buddy_list = self._create_buddy_list() + second_column.append(self._buddy_list) + + def _create_keep_icon(self): + keep = int(self._jobject.metadata.get('keep', 0)) == 1 + keep_icon = KeepIcon(keep) + keep_icon.connect('activated', self._keep_icon_activated_cb) + return keep_icon + + def _create_icon(self): + icon = CanvasIcon(file_name=misc.get_icon_name(self._jobject)) + icon.connect_after('button-release-event', + self._icon_button_release_event_cb) + + if self._jobject.is_activity_bundle(): + icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() + icon.props.stroke_color = style.COLOR_BUTTON_GREY.get_svg() + else: + if self._jobject.metadata.has_key('icon-color') and \ + self._jobject.metadata['icon-color']: + icon.props.xo_color = XoColor( \ + self._jobject.metadata['icon-color']) + + icon.set_palette(ObjectPalette(self._jobject)) + + return icon + + def _create_title(self): + title = CanvasEntry() + title.set_background(style.COLOR_WHITE.get_html()) + title.props.text = self._jobject.metadata.get('title', _('Untitled')) + title.props.widget.connect('focus-out-event', + self._title_focus_out_event_cb) + return title + + def _create_date(self): + date = hippo.CanvasText(xalign=hippo.ALIGNMENT_START, + font_desc=style.FONT_NORMAL.get_pango_desc(), + text = misc.get_date(self._jobject)) + return date + + def _create_preview(self): + width = style.zoom(320) + height = style.zoom(240) + box = hippo.CanvasBox() + + if self._jobject.metadata.has_key('preview') and \ + len(self._jobject.metadata['preview']) > 4: + + if self._jobject.metadata['preview'][1:4] == 'PNG': + preview_data = self._jobject.metadata['preview'] + else: + # TODO: We are close to be able to drop this. + import base64 + preview_data = base64.b64decode( + self._jobject.metadata['preview']) + + png_file = StringIO.StringIO(preview_data) + try: + surface = cairo.ImageSurface.create_from_png(png_file) + has_preview = True + except Exception, e: + logging.error('Error while loading the preview: %r' % e) + has_preview = False + else: + has_preview = False + + if has_preview: + preview_box = hippo.CanvasImage(image=surface, + border=style.LINE_WIDTH, + border_color=style.COLOR_BUTTON_GREY.get_int(), + xalign=hippo.ALIGNMENT_CENTER, + yalign=hippo.ALIGNMENT_CENTER, + scale_width=width, + scale_height=height) + else: + preview_box = hippo.CanvasText(text=_('No preview'), + font_desc=style.FONT_NORMAL.get_pango_desc(), + xalign=hippo.ALIGNMENT_CENTER, + yalign=hippo.ALIGNMENT_CENTER, + border=style.LINE_WIDTH, + border_color=style.COLOR_BUTTON_GREY.get_int(), + color=style.COLOR_BUTTON_GREY.get_int(), + box_width=width, + box_height=height) + preview_box.connect_after('button-release-event', + self._preview_box_button_release_event_cb) + box.append(preview_box) + return box + + def _create_buddy_list(self): + + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + + text = hippo.CanvasText(text=_('Participants:'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + text.props.color = style.COLOR_BUTTON_GREY.get_int() + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + text.props.xalign = hippo.ALIGNMENT_END + else: + text.props.xalign = hippo.ALIGNMENT_START + + vbox.append(text) + + if self._jobject.metadata.has_key('buddies') and \ + self._jobject.metadata['buddies']: + # json cannot read unicode strings + buddies_str = self._jobject.metadata['buddies'].encode('utf8') + buddies = json.read(buddies_str).values() + vbox.append(BuddyList(buddies)) + return vbox + else: + return vbox + + def _create_description(self): + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + + text = hippo.CanvasText(text=_('Description:'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + text.props.color = style.COLOR_BUTTON_GREY.get_int() + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + text.props.xalign = hippo.ALIGNMENT_END + else: + text.props.xalign = hippo.ALIGNMENT_START + + vbox.append(text) + + description = self._jobject.metadata.get('description', '') + text_view = CanvasTextView(description, + box_height=style.GRID_CELL_SIZE * 2) + vbox.append(text_view, hippo.PACK_EXPAND) + + text_view.text_view_widget.props.accepts_tab = False + text_view.text_view_widget.connect('focus-out-event', + self._description_focus_out_event_cb) + + return vbox, text_view + + def _create_tags(self): + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + + text = hippo.CanvasText(text=_('Tags:'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + text.props.color = style.COLOR_BUTTON_GREY.get_int() + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + text.props.xalign = hippo.ALIGNMENT_END + else: + text.props.xalign = hippo.ALIGNMENT_START + + vbox.append(text) + + tags = self._jobject.metadata.get('tags', '') + text_view = CanvasTextView(tags, box_height=style.GRID_CELL_SIZE * 2) + vbox.append(text_view, hippo.PACK_EXPAND) + + text_view.text_view_widget.props.accepts_tab = False + text_view.text_view_widget.connect('focus-out-event', + self._tags_focus_out_event_cb) + + return vbox, text_view + + def _create_version_list(self): + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + # TODO: Enable again when we have versions in the DS + """ + jobjects, count = datastore.find({'uid': self._jobject.object_id}, + sorting=['-mtime']) + for jobject in jobjects: + hbox = hippo.CanvasBox(orientation=hippo.ORIENTATION_HORIZONTAL) + hbox.props.spacing = style.DEFAULT_SPACING + + icon = CanvasIcon(file_name=misc.get_icon_name(jobject), + size=style.SMALL_ICON_SIZE) + if jobject.metadata.has_key('icon-color') and \ + jobject.metadata['icon-color']: + icon.props.xo_color = XoColor(jobject.metadata['icon-color']) + hbox.append(icon) + + date = hippo.CanvasText(text=misc.get_date(jobject), + xalign=hippo.ALIGNMENT_START, + font_desc=style.FONT_NORMAL.get_pango_desc()) + hbox.append(date) + + vbox.append(hbox) + """ + return vbox + + def _title_notify_text_cb(self, entry, pspec): + if not self._update_title_sid: + self._update_title_sid = gobject.timeout_add(1000, + self._update_title_cb) + + def _datastore_write_cb(self): + pass + + def _datastore_write_error_cb(self, error): + logging.error('ExpandedEntry._datastore_write_error_cb: %r' % error) + + def _title_focus_out_event_cb(self, entry, event): + self._update_entry() + + def _description_focus_out_event_cb(self, text_view, event): + self._update_entry() + + def _tags_focus_out_event_cb(self, text_view, event): + self._update_entry() + + def _update_entry(self): + needs_update = False + + old_title = self._jobject.metadata.get('title', None) + if old_title != self._title.props.text: + self._icon.palette.props.primary_text = self._title.props.text + self._jobject.metadata['title'] = self._title.props.text + self._jobject.metadata['title_set_by_user'] = '1' + needs_update = True + + old_tags = self._jobject.metadata.get('tags', None) + new_tags = self._tags.text_view_widget.props.buffer.props.text + if old_tags != new_tags: + self._jobject.metadata['tags'] = new_tags + needs_update = True + + old_description = self._jobject.metadata.get('description', None) + new_description = \ + self._description.text_view_widget.props.buffer.props.text + if old_description != new_description: + self._jobject.metadata['description'] = new_description + needs_update = True + + if needs_update: + datastore.write(self._jobject, update_mtime=False, + reply_handler=self._datastore_write_cb, + error_handler=self._datastore_write_error_cb) + + self._update_title_sid = None + + def get_keep(self): + return self._jobject.metadata.has_key('keep') and \ + self._jobject.metadata['keep'] == 1 + + def _keep_icon_activated_cb(self, keep_icon): + if self.get_keep(): + self._jobject.metadata['keep'] = 0 + else: + self._jobject.metadata['keep'] = 1 + datastore.write(self._jobject, update_mtime=False) + + keep_icon.props.keep = self.get_keep() + + def _icon_button_release_event_cb(self, button, event): + logging.debug('_icon_button_release_event_cb') + self._jobject.resume() + return True + + def _preview_box_button_release_event_cb(self, button, event): + logging.debug('_preview_box_button_release_event_cb') + self._jobject.resume() + return True + diff --git a/src/journal/journalactivity.py b/src/journal/journalactivity.py new file mode 100644 index 0000000..c83ca46 --- /dev/null +++ b/src/journal/journalactivity.py @@ -0,0 +1,338 @@ +# Copyright (C) 2006, Red Hat, Inc. +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +from gettext import gettext as _ +import sys +import traceback +import uuid + +import gtk +import dbus +import statvfs +import os + +from sugar.graphics.window import Window +from sugar.bundle.bundle import ZipExtractException, RegistrationException +from sugar.datastore import datastore +from sugar import env +from sugar.activity import activityfactory +from sugar import wm + +from journal.journaltoolbox import MainToolbox, DetailToolbox +from journal.listview import ListView +from journal.detailview import DetailView +from journal.volumestoolbar import VolumesToolbar +from journal import misc +from journal.journalentrybundle import JournalEntryBundle +from journal.objectchooser import ObjectChooser +from journal.modalalert import ModalAlert + +DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' +DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' +DS_DBUS_PATH = '/org/laptop/sugar/DataStore' + +J_DBUS_SERVICE = 'org.laptop.Journal' +J_DBUS_INTERFACE = 'org.laptop.Journal' +J_DBUS_PATH = '/org/laptop/Journal' + +_SPACE_TRESHOLD = 52428800 +_BUNDLE_ID = 'org.laptop.JournalActivity' + +class JournalActivityDBusService(dbus.service.Object): + def __init__(self, parent): + self._parent = parent + session_bus = dbus.SessionBus() + bus_name = dbus.service.BusName(J_DBUS_SERVICE, + bus=session_bus, replace_existing=False, allow_replacement=False) + logging.debug('bus_name: %r', bus_name) + dbus.service.Object.__init__(self, bus_name, J_DBUS_PATH) + + @dbus.service.method(J_DBUS_INTERFACE, + in_signature='', out_signature='') + def FocusSearch(self): + """Become visible and give focus to the search entry + """ + self._parent.present() + self._parent.show_main_view() + self._parent.search_grab_focus() + + @dbus.service.method(J_DBUS_INTERFACE, + in_signature='s', out_signature='') + def ShowObject(self, object_id): + """Pop-up journal and show object with object_id""" + + logging.debug('Trying to show object %s', object_id) + + if self._parent.show_object(object_id): + self._parent.present() + + def _chooser_response_cb(self, chooser, response_id, chooser_id): + logging.debug('JournalActivityDBusService._chooser_response_cb') + if response_id == gtk.RESPONSE_ACCEPT: + object_id = chooser.get_selected_object_id() + self.ObjectChooserResponse(chooser_id, object_id) + else: + self.ObjectChooserCancelled(chooser_id) + chooser.destroy() + del chooser + + @dbus.service.method(J_DBUS_INTERFACE, in_signature='i', out_signature='s') + def ChooseObject(self, parent_xid): + chooser_id = uuid.uuid4().hex + if parent_xid > 0: + parent = gtk.gdk.window_foreign_new(parent_xid) + else: + parent = None + chooser = ObjectChooser(parent) + chooser.connect('response', self._chooser_response_cb, chooser_id) + chooser.show() + + return chooser_id + + @dbus.service.signal(J_DBUS_INTERFACE, signature="ss") + def ObjectChooserResponse(self, chooser_id, object_id): + pass + + @dbus.service.signal(J_DBUS_INTERFACE, signature="s") + def ObjectChooserCancelled(self, chooser_id): + pass + +class JournalActivity(Window): + def __init__(self): + Window.__init__(self) + + self.set_title(_('Journal')) + + self._main_view = None + self._secondary_view = None + self._list_view = None + self._detail_view = None + self._main_toolbox = None + self._detail_toolbox = None + + self._setup_main_view() + self._setup_secondary_view() + + self.add_events(gtk.gdk.ALL_EVENTS_MASK | + gtk.gdk.VISIBILITY_NOTIFY_MASK) + self._realized_sid = self.connect('realize', self.__realize_cb) + self.connect('visibility-notify-event', + self.__visibility_notify_event_cb) + self.connect('window-state-event', self.__window_state_event_cb) + self.connect('key-press-event', self._key_press_event_cb) + self.connect('focus-in-event', self._focus_in_event_cb) + + bus = dbus.SessionBus() + data_store = dbus.Interface( + bus.get_object(DS_DBUS_SERVICE, DS_DBUS_PATH), DS_DBUS_INTERFACE) + data_store.connect_to_signal('Created', self.__data_store_created_cb) + data_store.connect_to_signal('Updated', self.__data_store_updated_cb) + data_store.connect_to_signal('Deleted', self.__data_store_deleted_cb) + + self._dbus_service = JournalActivityDBusService(self) + + self.iconify() + + self._critical_space_alert = None + self._check_available_space() + + def __realize_cb(self, window): + wm.set_bundle_id(window.window, _BUNDLE_ID) + activity_id = activityfactory.create_activity_id() + wm.set_activity_id(window.window, str(activity_id)) + self.disconnect(self._realized_sid) + self._realized_sid = None + + def can_close(self): + return False + + def _setup_main_view(self): + self._main_toolbox = MainToolbox() + self._main_view = gtk.VBox() + + self._list_view = ListView() + self._list_view.connect('detail-clicked', self.__detail_clicked_cb) + self._main_view.pack_start(self._list_view) + self._list_view.show() + + volumes_toolbar = VolumesToolbar() + volumes_toolbar.connect('volume-changed', self._volume_changed_cb) + self._main_view.pack_start(volumes_toolbar, expand=False) + + search_toolbar = self._main_toolbox.search_toolbar + search_toolbar.connect('query-changed', self._query_changed_cb) + search_toolbar.set_volume_id(datastore.mounts()[0]['id']) + + def _setup_secondary_view(self): + self._secondary_view = gtk.VBox() + + self._detail_toolbox = DetailToolbox() + entry_toolbar = self._detail_toolbox.entry_toolbar + + self._detail_view = DetailView() + self._detail_view.connect('go-back-clicked', self.__go_back_clicked_cb) + self._secondary_view.pack_end(self._detail_view) + self._detail_view.show() + + def _key_press_event_cb(self, widget, event): + keyname = gtk.gdk.keyval_name(event.keyval) + logging.info(keyname) + logging.info(event.state) + if keyname == 'Escape': + self.show_main_view() + + def __detail_clicked_cb(self, list_view, entry): + self._show_secondary_view(entry.jobject) + + def __go_back_clicked_cb(self, detail_view): + self.show_main_view() + + def _query_changed_cb(self, toolbar, query): + self._list_view.update_with_query(query) + self.show_main_view() + + def show_main_view(self): + if self.toolbox != self._main_toolbox: + self.set_toolbox(self._main_toolbox) + self._main_toolbox.show() + + if self.canvas != self._main_view: + self.set_canvas(self._main_view) + self._main_view.show() + + def _show_secondary_view(self, jobject): + try: + self._detail_toolbox.entry_toolbar.set_jobject(jobject) + except Exception: + logging.error('Exception while displaying entry:\n' + \ + ''.join(traceback.format_exception(*sys.exc_info()))) + + self.set_toolbox(self._detail_toolbox) + self._detail_toolbox.show() + + try: + self._detail_view.props.jobject = jobject + except Exception: + logging.error('Exception while displaying entry:\n' + \ + ''.join(traceback.format_exception(*sys.exc_info()))) + + self.set_canvas(self._secondary_view) + self._secondary_view.show() + + def show_object(self, object_id): + jobject = datastore.get(object_id) + if jobject is None: + return False + else: + self._show_secondary_view(jobject) + return True + + def _volume_changed_cb(self, volume_toolbar, volume_id): + logging.debug('Selected volume: %r.' % volume_id) + self._main_toolbox.search_toolbar.set_volume_id(volume_id) + self._main_toolbox.set_current_toolbar(0) + + def __data_store_created_cb(self, uid): + jobject = datastore.get(uid) + if jobject is None: + return + try: + self._check_for_bundle(jobject) + finally: + jobject.destroy() + self._main_toolbox.search_toolbar.refresh_filters() + self._check_available_space() + + def __data_store_updated_cb(self, uid): + jobject = datastore.get(uid) + if jobject is None: + return + try: + self._check_for_bundle(jobject) + finally: + jobject.destroy() + self._check_available_space() + + def __data_store_deleted_cb(self, uid): + if self.canvas == self._secondary_view and \ + uid == self._detail_view.props.jobject.object_id: + self.show_main_view() + + def _focus_in_event_cb(self, window, event): + self.search_grab_focus() + self._list_view.update_dates() + + def _check_for_bundle(self, jobject): + bundle = misc.get_bundle(jobject) + if bundle is None: + return + + if bundle.is_installed(): + return + try: + bundle.install() + except (ZipExtractException, RegistrationException), e: + logging.warning('Could not install bundle %s: %r' % + (jobject.file_path, e)) + return + + if jobject.metadata['mime_type'] == JournalEntryBundle.MIME_TYPE: + datastore.delete(jobject.object_id) + + def search_grab_focus(self): + search_toolbar = self._main_toolbox.search_toolbar + search_toolbar.give_entry_focus() + + def __window_state_event_cb(self, window, event): + logging.debug('window_state_event_cb %r' % self) + if event.changed_mask & gtk.gdk.WINDOW_STATE_ICONIFIED: + state = event.new_window_state + visible = not state & gtk.gdk.WINDOW_STATE_ICONIFIED + self._list_view.set_is_visible(visible) + + def __visibility_notify_event_cb(self, window, event): + logging.debug('visibility_notify_event_cb %r' % self) + visible = event.state != gtk.gdk.VISIBILITY_FULLY_OBSCURED + self._list_view.set_is_visible(visible) + + def _check_available_space(self): + ''' Check available space on device + + If the available space is below 50MB an alert will be + shown which encourages to delete old journal entries. + ''' + + if self._critical_space_alert: + return + stat = os.statvfs(env.get_profile_path()) + free_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BAVAIL] + if free_space < _SPACE_TRESHOLD: + self._critical_space_alert = ModalAlert() + self._critical_space_alert.connect('destroy', + self.__alert_closed_cb) + self._critical_space_alert.show() + + def __alert_closed_cb(self, data): + self.show_main_view() + self.present() + self._critical_space_alert = None + +def start(): + journal = JournalActivity() + journal.show() + diff --git a/src/journal/journalentrybundle.py b/src/journal/journalentrybundle.py new file mode 100644 index 0000000..8862ca3 --- /dev/null +++ b/src/journal/journalentrybundle.py @@ -0,0 +1,96 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import os +import tempfile +import shutil + +import json + +import dbus +from sugar.datastore import datastore +from sugar.bundle.bundle import Bundle, MalformedBundleException + +class JournalEntryBundle(Bundle): + """A Journal entry bundle + + See http://wiki.laptop.org/go/Journal_entry_bundles for details + """ + + MIME_TYPE = 'application/vnd.olpc-journal-entry' + + _zipped_extension = '.xoj' + _unzipped_extension = None + _infodir = None + + def __init__(self, path): + Bundle.__init__(self, path) + + def install(self): + if os.environ.has_key('SUGAR_ACTIVITY_ROOT'): + install_dir = os.path.join(os.environ['SUGAR_ACTIVITY_ROOT'], + 'data') + else: + install_dir = tempfile.gettempdir() + bundle_dir = os.path.join(install_dir, self._zip_root_dir) + uid = self._zip_root_dir + self._unzip(install_dir) + try: + metadata = self._read_metadata(bundle_dir) + jobject = datastore.create() + try: + for key, value in metadata.iteritems(): + jobject.metadata[key] = value + + preview = self._read_preview(uid, bundle_dir) + if preview is not None: + jobject.metadata['preview'] = dbus.ByteArray(preview) + + jobject.metadata['uid'] = '' + jobject.file_path = os.path.join(bundle_dir, uid) + datastore.write(jobject) + finally: + jobject.destroy() + finally: + shutil.rmtree(bundle_dir, ignore_errors=True) + + def _read_metadata(self, bundle_dir): + metadata_path = os.path.join(bundle_dir, '_metadata.json') + if not os.path.exists(metadata_path): + raise MalformedBundleException( + 'Bundle must contain the file "_metadata.json"') + f = open(metadata_path, 'r') + try: + json_data = f.read() + finally: + f.close() + return json.read(json_data) + + def _read_preview(self, uid, bundle_dir): + preview_path = os.path.join(bundle_dir, 'preview', uid) + if not os.path.exists(preview_path): + return '' + f = open(preview_path, 'r') + try: + preview_data = f.read() + finally: + f.close() + return preview_data + + def is_installed(self): + # These bundles can be reinstalled as many times as desired. + return False + diff --git a/src/journal/journaltoolbox.py b/src/journal/journaltoolbox.py new file mode 100644 index 0000000..9b2f487 --- /dev/null +++ b/src/journal/journaltoolbox.py @@ -0,0 +1,418 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from gettext import gettext as _ +import logging +from datetime import datetime, timedelta +import os + +import gobject +import gtk + +from sugar.graphics.toolbox import Toolbox +from sugar.graphics.toolcombobox import ToolComboBox +from sugar.graphics.toolbutton import ToolButton +from sugar.graphics.combobox import ComboBox +from sugar.graphics.menuitem import MenuItem +from sugar.graphics.icon import Icon +from sugar.graphics import iconentry +from sugar.graphics import style +from sugar import activity +from sugar import profile +from sugar import mime +from sugar.datastore import datastore + +from journal import volumesmanager +from journal import misc + +_AUTOSEARCH_TIMEOUT = 1000 + +_ACTION_ANYTIME = 0 +_ACTION_TODAY = 1 +_ACTION_SINCE_YESTERDAY = 2 +_ACTION_PAST_WEEK = 3 +_ACTION_PAST_MONTH = 4 +_ACTION_PAST_YEAR = 5 + +_ACTION_ANYTHING = 0 + +_ACTION_EVERYBODY = 0 +_ACTION_MY_FRIENDS = 1 +_ACTION_MY_CLASS = 2 + +class MainToolbox(Toolbox): + def __init__(self): + Toolbox.__init__(self) + + self.search_toolbar = SearchToolbar() + self.search_toolbar.set_size_request(-1, style.GRID_CELL_SIZE) + self.add_toolbar(_('Search'), self.search_toolbar) + self.search_toolbar.show() + + #self.manage_toolbar = ManageToolbar() + #self.add_toolbar(_('Manage'), self.manage_toolbar) + #self.manage_toolbar.show() + +class SearchToolbar(gtk.Toolbar): + __gtype_name__ = 'SearchToolbar' + + __gsignals__ = { + 'query-changed': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([object])) + } + + def __init__(self): + gtk.Toolbar.__init__(self) + + self._volume_id = None + + self._search_entry = iconentry.IconEntry() + self._search_entry.set_icon_from_name(iconentry.ICON_ENTRY_PRIMARY, + 'system-search') + self._search_entry.connect('activate', self._search_entry_activated_cb) + self._search_entry.connect('changed', self._search_entry_changed_cb) + self._search_entry.add_clear_button() + self._autosearch_timer = None + self._add_widget(self._search_entry, expand=True) + + self._what_search_combo = ComboBox() + self._what_combo_changed_sid = self._what_search_combo.connect( + 'changed', self._combo_changed_cb) + tool_item = ToolComboBox(self._what_search_combo) + self.insert(tool_item, -1) + tool_item.show() + + self._when_search_combo = self._get_when_search_combo() + tool_item = ToolComboBox(self._when_search_combo) + self.insert(tool_item, -1) + tool_item.show() + + # TODO: enable it when the DS supports saving the buddies. + #self._with_search_combo = self._get_with_search_combo() + #tool_item = ToolComboBox(self._with_search_combo) + #self.insert(tool_item, -1) + #tool_item.show() + + self._query = self._build_query() + + self.refresh_filters() + + def give_entry_focus(self): + self._search_entry.grab_focus() + + def _get_when_search_combo(self): + when_search = ComboBox() + when_search.append_item(_ACTION_ANYTIME, _('Anytime')) + when_search.append_separator() + when_search.append_item(_ACTION_TODAY, _('Today')) + when_search.append_item(_ACTION_SINCE_YESTERDAY, + _('Since yesterday')) + # TRANS: Filter entries modified during the last 7 days. + when_search.append_item(_ACTION_PAST_WEEK, _('Past week')) + # TRANS: Filter entries modified during the last 30 days. + when_search.append_item(_ACTION_PAST_MONTH, _('Past month')) + # TRANS: Filter entries modified during the last 356 days. + when_search.append_item(_ACTION_PAST_YEAR, _('Past year')) + when_search.set_active(0) + when_search.connect('changed', self._combo_changed_cb) + return when_search + + def _get_with_search_combo(self): + with_search = ComboBox() + with_search.append_item(_ACTION_EVERYBODY, _('Anyone')) + with_search.append_separator() + with_search.append_item(_ACTION_MY_FRIENDS, _('My friends')) + with_search.append_item(_ACTION_MY_CLASS, _('My class')) + with_search.append_separator() + + # TODO: Ask the model for buddies. + with_search.append_item(3, 'Dan', 'theme:xo') + + with_search.set_active(0) + with_search.connect('changed', self._combo_changed_cb) + return with_search + + def _add_widget(self, widget, expand=False): + tool_item = gtk.ToolItem() + tool_item.set_expand(expand) + + tool_item.add(widget) + widget.show() + + self.insert(tool_item, -1) + tool_item.show() + + def _build_query(self): + query = {} + if self._volume_id: + query['mountpoints'] = [self._volume_id] + if self._what_search_combo.props.value: + value = self._what_search_combo.props.value + generic_type = mime.get_generic_type(value) + if generic_type: + mime_types = generic_type.mime_types + query['mime_type'] = mime_types + else: + query['activity'] = self._what_search_combo.props.value + if self._when_search_combo.props.value: + date_from, date_to = self._get_date_range() + query['mtime'] = {'start': date_from, 'end': date_to} + if self._search_entry.props.text: + text = self._search_entry.props.text.strip() + + if not text.startswith('"'): + query_text = '' + words = text.split(' ') + for word in words: + if word: + if query_text: + query_text += ' ' + query_text += word + '*' + else: + query_text = text + + if query_text: + query['query'] = query_text + + return query + + def _get_date_range(self): + today_start = datetime.today().replace(hour=0, minute=0, second=0) + right_now = datetime.today() + if self._when_search_combo.props.value == _ACTION_TODAY: + date_range = (today_start, right_now) + elif self._when_search_combo.props.value == _ACTION_SINCE_YESTERDAY: + date_range = (today_start - timedelta(1), right_now) + elif self._when_search_combo.props.value == _ACTION_PAST_WEEK: + date_range = (today_start - timedelta(7), right_now) + elif self._when_search_combo.props.value == _ACTION_PAST_MONTH: + date_range = (today_start - timedelta(30), right_now) + elif self._when_search_combo.props.value == _ACTION_PAST_YEAR: + date_range = (today_start - timedelta(356), right_now) + + return (date_range[0].isoformat(), + date_range[1].isoformat()) + + def _combo_changed_cb(self, combo): + new_query = self._build_query() + if self._query != new_query: + self._query = new_query + self.emit('query-changed', self._query) + + def _search_entry_activated_cb(self, search_entry): + if self._autosearch_timer: + gobject.source_remove(self._autosearch_timer) + new_query = self._build_query() + if self._query != new_query: + self._query = new_query + self.emit('query-changed', self._query) + + def _search_entry_changed_cb(self, search_entry): + if not search_entry.props.text: + search_entry.activate() + return + + if self._autosearch_timer: + gobject.source_remove(self._autosearch_timer) + self._autosearch_timer = gobject.timeout_add(_AUTOSEARCH_TIMEOUT, + self._autosearch_timer_cb) + + def _autosearch_timer_cb(self): + logging.debug('_autosearch_timer_cb') + self._autosearch_timer = None + self._search_entry.activate() + return False + + def set_volume_id(self, volume_id): + self._volume_id = volume_id + new_query = self._build_query() + if self._query != new_query: + self._query = new_query + self.emit('query-changed', self._query) + + def refresh_filters(self): + current_value = self._what_search_combo.props.value + current_value_index = 0 + + self._what_search_combo.handler_block(self._what_combo_changed_sid) + try: + self._what_search_combo.remove_all() + # TRANS: Item in a combo box that filters by entry type. + self._what_search_combo.append_item(_ACTION_ANYTHING, _('Anything')) + + registry = activity.get_registry() + appended_separator = False + for service_name in datastore.get_unique_values('activity'): + activity_info = registry.get_activity(service_name) + if not activity_info is None: + if not appended_separator: + self._what_search_combo.append_separator() + appended_separator = True + + if os.path.exists(activity_info.icon): + self._what_search_combo.append_item(service_name, + activity_info.name, + file_name=activity_info.icon) + else: + self._what_search_combo.append_item(service_name, + activity_info.name, + icon_name='application-octet-stream') + + if service_name == current_value: + current_value_index = \ + len(self._what_search_combo.get_model()) - 1 + + self._what_search_combo.append_separator() + + types = mime.get_all_generic_types() + for generic_type in types : + self._what_search_combo.append_item( + generic_type.type_id, generic_type.name, generic_type.icon) + if generic_type.type_id == current_value: + current_value_index = \ + len(self._what_search_combo.get_model()) - 1 + + self._what_search_combo.set_active(current_value_index) + finally: + self._what_search_combo.handler_unblock( + self._what_combo_changed_sid) + +class ManageToolbar(gtk.Toolbar): + __gtype_name__ = 'ManageToolbar' + + def __init__(self): + gtk.Toolbar.__init__(self) + +class DetailToolbox(Toolbox): + def __init__(self): + Toolbox.__init__(self) + + self.entry_toolbar = EntryToolbar() + self.add_toolbar('', self.entry_toolbar) + self.entry_toolbar.show() + +class EntryToolbar(gtk.Toolbar): + def __init__(self): + gtk.Toolbar.__init__(self) + + self._jobject = None + + self._resume = ToolButton('activity-start') + self._resume.connect('clicked', self._resume_clicked_cb) + self.add(self._resume) + self._resume.show() + + self._copy = ToolButton() + + icon = Icon(icon_name='edit-copy', xo_color=profile.get_color()) + self._copy.set_icon_widget(icon) + icon.show() + + self._copy.set_tooltip(_('Copy')) + self._copy.connect('clicked', self._copy_clicked_cb) + self.add(self._copy) + self._copy.show() + + separator = gtk.SeparatorToolItem() + self.add(separator) + separator.show() + + erase_button = ToolButton('list-remove') + erase_button.set_tooltip(_('Erase')) + erase_button.connect('clicked', self._erase_button_clicked_cb) + self.add(erase_button) + erase_button.show() + + def set_jobject(self, jobject): + self._jobject = jobject + self._refresh_copy_palette() + self._refresh_resume_palette() + + def _resume_clicked_cb(self, button): + if self._jobject: + self._jobject.resume() + + def _copy_clicked_cb(self, button): + clipboard = gtk.Clipboard() + clipboard.set_with_data([('text/uri-list', 0, 0)], + self._clipboard_get_func_cb, + self._clipboard_clear_func_cb) + + def _clipboard_get_func_cb(self, clipboard, selection_data, info, data): + selection_data.set('text/uri-list', 8, self._jobject.file_path) + + def _clipboard_clear_func_cb(self, clipboard, data): + pass + + def _erase_button_clicked_cb(self, button): + if self._jobject: + bundle = misc.get_bundle(self._jobject) + if bundle is not None and bundle.is_installed(): + bundle.uninstall() + datastore.delete(self._jobject.object_id) + + def _resume_menu_item_activate_cb(self, menu_item, service_name): + if self._jobject: + self._jobject.resume(service_name) + + def _copy_menu_item_activate_cb(self, menu_item, volume): + if self._jobject: + datastore.copy(self._jobject, volume.id) + + def _refresh_copy_palette(self): + palette = self._copy.get_palette() + + for menu_item in palette.menu.get_children(): + palette.menu.remove(menu_item) + menu_item.destroy() + + volumes_manager = volumesmanager.get_volumes_manager() + for volume in volumes_manager.get_volumes(): + if self._jobject.metadata['mountpoint'] == volume.id: + continue + menu_item = MenuItem(volume.name) + menu_item.set_image(Icon(icon_name=volume.icon_name, + icon_size=gtk.ICON_SIZE_MENU)) + menu_item.connect('activate', + self._copy_menu_item_activate_cb, + volume) + palette.menu.append(menu_item) + menu_item.show() + + def _refresh_resume_palette(self): + if self._jobject.metadata.get('activity_id', ''): + # TRANS: Action label for resuming an activity. + self._resume.set_tooltip(_('Resume')) + else: + # TRANS: Action label for starting an entry. + self._resume.set_tooltip(_('Start')) + + palette = self._resume.get_palette() + + for menu_item in palette.menu.get_children(): + palette.menu.remove(menu_item) + menu_item.destroy() + + for activity_info in self._jobject.get_activities(): + menu_item = MenuItem(activity_info.name) + menu_item.set_image(Icon(file=activity_info.icon, + icon_size=gtk.ICON_SIZE_MENU)) + menu_item.connect('activate', self._resume_menu_item_activate_cb, + activity_info.bundle_id) + palette.menu.append(menu_item) + menu_item.show() + diff --git a/src/journal/keepicon.py b/src/journal/keepicon.py new file mode 100644 index 0000000..8a86c83 --- /dev/null +++ b/src/journal/keepicon.py @@ -0,0 +1,57 @@ +# Copyright (C) 2006, Red Hat, Inc. +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gobject +import hippo + +from sugar.graphics.icon import CanvasIcon +from sugar.graphics import style +from sugar import profile + +class KeepIcon(CanvasIcon): + def __init__(self, keep): + CanvasIcon.__init__(self, icon_name='emblem-favorite', + box_width=style.GRID_CELL_SIZE * 3 / 5, + size=style.SMALL_ICON_SIZE) + self.connect('motion-notify-event', self.__motion_notify_event_cb) + + self._keep = None + self.set_keep(keep) + + def set_keep(self, keep): + if keep == self._keep: + return + + self._keep = keep + if keep: + self.props.xo_color = profile.get_color() + else: + self.props.stroke_color = style.COLOR_BUTTON_GREY.get_svg() + self.props.fill_color = style.COLOR_TRANSPARENT.get_svg() + + def get_keep(self): + return self._keep + + keep = gobject.property(type=int, default=0, getter=get_keep, + setter=set_keep) + + def __motion_notify_event_cb(self, icon, event): + if not self._keep: + if event.detail == hippo.MOTION_DETAIL_ENTER: + icon.props.fill_color = style.COLOR_BUTTON_GREY.get_svg() + elif event.detail == hippo.MOTION_DETAIL_LEAVE: + icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() + diff --git a/src/journal/listview.py b/src/journal/listview.py new file mode 100644 index 0000000..a0f71e5 --- /dev/null +++ b/src/journal/listview.py @@ -0,0 +1,460 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +import traceback +import sys +from gettext import gettext as _ + +import hippo +import gobject +import gtk +import dbus + +from sugar.graphics import style +from sugar.graphics.icon import CanvasIcon + +from journal.collapsedentry import CollapsedEntry +from journal import query + +DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' +DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' +DS_DBUS_PATH = '/org/laptop/sugar/DataStore' + +UPDATE_INTERVAL = 300000 + +EMPTY_JOURNAL = _("Your Journal is empty") +NO_MATCH = _("No matching entries ") + +class BaseListView(gtk.HBox): + __gtype_name__ = 'BaseListView' + + def __init__(self): + self._query = {} + self._result_set = None + self._entries = [] + self._page_size = 0 + self._last_value = -1 + self._reflow_sid = 0 + + gtk.HBox.__init__(self) + self.set_flags(gtk.HAS_FOCUS|gtk.CAN_FOCUS) + self.connect('key-press-event', self._key_press_event_cb) + + self._box = hippo.CanvasBox( + orientation=hippo.ORIENTATION_VERTICAL, + background_color=style.COLOR_WHITE.get_int()) + + self._canvas = hippo.Canvas() + self._canvas.set_root(self._box) + + self.pack_start(self._canvas) + self._canvas.show() + + self._vadjustment = gtk.Adjustment(value=0, lower=0, upper=0, + step_incr=1, page_incr=0, + page_size=0) + self._vadjustment.connect('value-changed', + self._vadjustment_value_changed_cb) + self._vadjustment.connect('changed', self._vadjustment_changed_cb) + + self._vscrollbar = gtk.VScrollbar(self._vadjustment) + self.pack_end(self._vscrollbar, expand=False, fill=False) + self._vscrollbar.show() + + self.connect('scroll-event', self._scroll_event_cb) + self.connect('destroy', self.__destroy_cb) + + # DND stuff + self._pressed_button = None + self._press_start_x = None + self._press_start_y = None + self._last_clicked_entry = None + self._canvas.drag_source_set(0, [], 0) + self._canvas.add_events(gtk.gdk.BUTTON_PRESS_MASK | + gtk.gdk.POINTER_MOTION_HINT_MASK) + self._canvas.connect_after("motion_notify_event", + self._canvas_motion_notify_event_cb) + self._canvas.connect("button_press_event", + self._canvas_button_press_event_cb) + self._canvas.connect("drag_end", self._drag_end_cb) + self._canvas.connect("drag_data_get", self._drag_data_get_cb) + + # Auto-update stuff + self._fully_obscured = True + self._dirty = False + self._refresh_idle_handler = None + self._update_dates_timer = None + + bus = dbus.SessionBus() + datastore = dbus.Interface( + bus.get_object(DS_DBUS_SERVICE, DS_DBUS_PATH), DS_DBUS_INTERFACE) + self._datastore_created_handler = \ + datastore.connect_to_signal('Created', + self.__datastore_created_cb) + self._datastore_updated_handler = \ + datastore.connect_to_signal('Updated', + self.__datastore_updated_cb) + + self._datastore_deleted_handler = \ + datastore.connect_to_signal('Deleted', + self.__datastore_deleted_cb) + + def __destroy_cb(self, widget): + self._datastore_created_handler.remove() + self._datastore_updated_handler.remove() + self._datastore_deleted_handler.remove() + + if self._result_set: + self._result_set.destroy() + + def _vadjustment_changed_cb(self, vadjustment): + if vadjustment.props.upper > self._page_size: + self._vscrollbar.show() + else: + self._vscrollbar.hide() + + def _vadjustment_value_changed_cb(self, vadjustment): + gobject.idle_add(self._do_scroll) + + def _do_scroll(self, force=False): + import time + t = time.time() + + value = int(self._vadjustment.props.value) + + if value == self._last_value and not force: + return + self._last_value = value + + self._result_set.seek(value) + jobjects = self._result_set.read(self._page_size) + + if self._result_set.length != self._vadjustment.props.upper: + self._vadjustment.props.upper = self._result_set.length + self._vadjustment.changed() + + self._refresh_view(jobjects) + self._dirty = False + + logging.debug('_do_scroll %r %r\n' % (value, (time.time() - t))) + + return False + + def _refresh_view(self, jobjects): + logging.debug('ListView %r' % self) + # Indicate when the Journal is empty + if len(jobjects) == 0: + self._show_message(EMPTY_JOURNAL) + return + + # Refresh view and create the entries if they don't exist yet. + for i in range(0, self._page_size): + try: + if i < len(jobjects): + if i >= len(self._entries): + entry = self.create_entry() + self._box.append(entry) + self._entries.append(entry) + entry.jobject = jobjects[i] + else: + entry = self._entries[i] + entry.jobject = jobjects[i] + entry.set_visible(True) + elif i < len(self._entries): + entry = self._entries[i] + entry.set_visible(False) + except Exception: + logging.error('Exception while displaying entry:\n' + \ + ''.join(traceback.format_exception(*sys.exc_info()))) + + def create_entry(self): + """ Create a descendant of BaseCollapsedEntry + """ + raise NotImplementedError + + def update_with_query(self, query_dict): + logging.debug('ListView.update_with_query') + self._query = query_dict + if self._page_size > 0: + self.refresh() + + def refresh(self): + if self._result_set: + self._result_set.destroy() + self._result_set = query.find(self._query) + self._vadjustment.props.upper = self._result_set.length + self._vadjustment.changed() + + self._vadjustment.props.value = min(self._vadjustment.props.value, + self._result_set.length - self._page_size) + if self._result_set.length == 0: + if self._query.get('query', '') or \ + self._query.get('mime_type', '') or \ + self._query.get('mtime', ''): + self._show_message(NO_MATCH) + else: + self._show_message(EMPTY_JOURNAL) + else: + self._clear_message() + self._do_scroll(force=True) + + def _scroll_event_cb(self, hbox, event): + if event.direction == gtk.gdk.SCROLL_UP: + if self._vadjustment.props.value > self._vadjustment.props.lower: + self._vadjustment.props.value -= 1 + elif event.direction == gtk.gdk.SCROLL_DOWN: + max_value = self._result_set.length - self._page_size + if self._vadjustment.props.value < max_value: + self._vadjustment.props.value += 1 + + def do_focus(self, direction): + if not self.is_focus(): + self.grab_focus() + return True + return False + + def _key_press_event_cb(self, widget, event): + keyname = gtk.gdk.keyval_name(event.keyval) + + if keyname == 'Up': + if self._vadjustment.props.value > self._vadjustment.props.lower: + self._vadjustment.props.value -= 1 + elif keyname == 'Down': + max_value = self._result_set.length - self._page_size + if self._vadjustment.props.value < max_value: + self._vadjustment.props.value += 1 + elif keyname == 'Page_Up' or keyname == 'KP_Page_Up': + new_position = max(0, + self._vadjustment.props.value - self._page_size) + if new_position != self._vadjustment.props.value: + self._vadjustment.props.value = new_position + elif keyname == 'Page_Down' or keyname == 'KP_Page_Down': + new_position = min(self._result_set.length - self._page_size, + self._vadjustment.props.value + self._page_size) + if new_position != self._vadjustment.props.value: + self._vadjustment.props.value = new_position + elif keyname == 'Home' or keyname == 'KP_Home': + new_position = 0 + if new_position != self._vadjustment.props.value: + self._vadjustment.props.value = new_position + elif keyname == 'End' or keyname == 'KP_End': + new_position = max(0, self._result_set.length - self._page_size) + if new_position != self._vadjustment.props.value: + self._vadjustment.props.value = new_position + else: + return False + + return True + + def do_size_allocate(self, allocation): + gtk.HBox.do_size_allocate(self, allocation) + new_page_size = int(allocation.height / style.GRID_CELL_SIZE) + + logging.debug("do_size_allocate: %r" % new_page_size) + + if new_page_size != self._page_size: + self._page_size = new_page_size + self._queue_reflow() + + def _queue_reflow(self): + if not self._reflow_sid: + self._reflow_sid = gobject.idle_add(self._reflow_idle_cb) + + def _reflow_idle_cb(self): + self._box.clear() + self._entries = [] + + self._vadjustment.props.page_size = self._page_size + self._vadjustment.props.page_increment = self._page_size + self._vadjustment.changed() + + if self._result_set is None: + self._result_set = query.find(self._query) + + max_value = max(0, self._result_set.length - self._page_size) + if self._vadjustment.props.value > max_value: + self._vadjustment.props.value = max_value + else: + self._do_scroll(force=True) + + self._reflow_sid = 0 + + def _show_message(self, message): + box = hippo.CanvasBox(orientation=hippo.ORIENTATION_VERTICAL, + background_color=style.COLOR_WHITE.get_int(), + yalign=hippo.ALIGNMENT_CENTER) + icon = CanvasIcon(size=style.LARGE_ICON_SIZE, + icon_name='activity-journal', + stroke_color = style.COLOR_BUTTON_GREY.get_svg(), + fill_color = style.COLOR_TRANSPARENT.get_svg()) + text = hippo.CanvasText(text=message, + xalign=hippo.ALIGNMENT_CENTER, + font_desc=style.FONT_NORMAL.get_pango_desc(), + color = style.COLOR_BUTTON_GREY.get_int()) + + box.append(icon) + box.append(text) + self._canvas.set_root(box) + + def _clear_message(self): + self._canvas.set_root(self._box) + + # TODO: Dnd methods. This should be merged somehow inside hippo-canvas. + def _canvas_motion_notify_event_cb(self, widget, event): + if not self._pressed_button: + return True + + # if the mouse button is not pressed, no drag should occurr + if not event.state & gtk.gdk.BUTTON1_MASK: + self._pressed_button = None + return True + + logging.debug("motion_notify_event_cb") + + if event.is_hint: + x, y, state_ = event.window.get_pointer() + else: + x = event.x + y = event.y + + if widget.drag_check_threshold(int(self._press_start_x), + int(self._press_start_y), + int(x), + int(y)): + context_ = widget.drag_begin([('text/uri-list', 0, 0), + ('journal-object-id', 0, 0)], + gtk.gdk.ACTION_COPY, + 1, + event) + return True + + def _drag_end_cb(self, widget, drag_context): + logging.debug("drag_end_cb") + self._pressed_button = None + self._press_start_x = None + self._press_start_y = None + self._last_clicked_entry = None + + def _drag_data_get_cb(self, widget, context, selection, target_type, + event_time): + logging.debug("drag_data_get_cb: requested target " + selection.target) + + jobject = self._last_clicked_entry.jobject + if selection.target == 'text/uri-list': + selection.set(selection.target, 8, jobject.file_path) + elif selection.target == 'journal-object-id': + selection.set(selection.target, 8, jobject.object_id) + + def _canvas_button_press_event_cb(self, widget, event): + logging.debug("button_press_event_cb") + + if event.button == 1 and event.type == gtk.gdk.BUTTON_PRESS: + self._last_clicked_entry = \ + self._get_entry_at_coords(event.x, event.y) + if self._last_clicked_entry: + self._pressed_button = event.button + self._press_start_x = event.x + self._press_start_y = event.y + + return False + + def _get_entry_at_coords(self, x, y): + for entry in self._box.get_children(): + entry_x, entry_y = entry.get_context().translate_to_widget(entry) + entry_width, entry_height = entry.get_allocation() + + if (x >= entry_x ) and (x <= entry_x + entry_width) and \ + (y >= entry_y ) and (y <= entry_y + entry_height): + return entry + return None + + def update_dates(self): + logging.debug('ListView.update_dates') + for entry in self._entries: + entry.update_date() + + def __datastore_created_cb(self, uid): + self._set_dirty() + + def __datastore_updated_cb(self, uid): + self._set_dirty() + + def __datastore_deleted_cb(self, uid): + self._set_dirty() + + def _set_dirty(self): + if self._fully_obscured: + self._dirty = True + else: + self._schedule_refresh() + + def _schedule_refresh(self): + if self._refresh_idle_handler is None: + logging.debug('Add refresh idle callback') + self._refresh_idle_handler = \ + gobject.idle_add(self.__refresh_idle_cb) + + def __refresh_idle_cb(self): + self.refresh() + if self._refresh_idle_handler is not None: + logging.debug('Remove refresh idle callback') + gobject.source_remove(self._refresh_idle_handler) + self._refresh_idle_handler = None + return False + + def set_is_visible(self, visible): + logging.debug('canvas_visibility_notify_event_cb %r' % visible) + if visible: + self._fully_obscured = False + if self._dirty: + self._schedule_refresh() + if self._update_dates_timer is None: + logging.debug('Adding date updating timer') + self._update_dates_timer = \ + gobject.timeout_add(UPDATE_INTERVAL, + self.__update_dates_timer_cb) + else: + self._fully_obscured = True + if self._update_dates_timer is not None: + logging.debug('Remove date updating timer') + gobject.source_remove(self._update_dates_timer) + self._update_dates_timer = None + + def __update_dates_timer_cb(self): + self.update_dates() + return True + +class ListView(BaseListView): + __gtype_name__ = 'ListView' + + __gsignals__ = { + 'detail-clicked': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([object])) + } + + def __init__(self): + BaseListView.__init__(self) + + def create_entry(self): + entry = CollapsedEntry() + entry.connect('detail-clicked', self.__entry_activated_cb) + return entry + + def __entry_activated_cb(self, entry): + self.emit('detail-clicked', entry) + diff --git a/src/journal/misc.py b/src/journal/misc.py new file mode 100644 index 0000000..8ce86d3 --- /dev/null +++ b/src/journal/misc.py @@ -0,0 +1,109 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +import time +import traceback +import sys +import os +from gettext import gettext as _ + +import gtk + +from sugar import activity +from sugar import mime +from sugar.bundle.activitybundle import ActivityBundle +from sugar.bundle.contentbundle import ContentBundle +from sugar.bundle.bundle import MalformedBundleException +from sugar import util + +from journal.journalentrybundle import JournalEntryBundle + +def _get_icon_file_name(icon_name): + icon_theme = gtk.icon_theme_get_default() + info = icon_theme.lookup_icon(icon_name, gtk.ICON_SIZE_LARGE_TOOLBAR, 0) + if not info: + # display standard icon when icon for mime type is not found + info = icon_theme.lookup_icon('application-octet-stream', + gtk.ICON_SIZE_LARGE_TOOLBAR, 0) + fname = info.get_filename() + del info + return fname + +_icon_cache = util.LRU(50) + +def get_icon_name(jobject): + + cache_key = (jobject.object_id, jobject.metadata.get('timestamp', None)) + if cache_key in _icon_cache: + return _icon_cache[cache_key] + + file_name = None + + if jobject.is_activity_bundle() and jobject.file_path: + try: + bundle = ActivityBundle(jobject.file_path) + file_name = bundle.get_icon() + except Exception: + logging.warning('Could not read bundle:\n' + \ + ''.join(traceback.format_exception(*sys.exc_info()))) + file_name = _get_icon_file_name('application-octet-stream') + + if not file_name and jobject.metadata['activity']: + service_name = jobject.metadata['activity'] + activity_info = activity.get_registry().get_activity(service_name) + if activity_info: + file_name = activity_info.icon + + mime_type = jobject.metadata['mime_type'] + if not file_name and mime_type: + icon_name = mime.get_mime_icon(mime_type) + if icon_name: + file_name = _get_icon_file_name(icon_name) + + if not file_name or not os.path.exists(file_name): + file_name = _get_icon_file_name('application-octet-stream') + + _icon_cache[cache_key] = file_name + + return file_name + +def get_date(jobject): + """ Convert from a string in iso format to a more human-like format. """ + if jobject.metadata.has_key('timestamp'): + timestamp = float(jobject.metadata['timestamp']) + return util.timestamp_to_elapsed_string(timestamp) + elif jobject.metadata.has_key('mtime'): + ti = time.strptime(jobject.metadata['mtime'], "%Y-%m-%dT%H:%M:%S") + return util.timestamp_to_elapsed_string(time.mktime(ti)) + else: + return _('No date') + +def get_bundle(jobject): + try: + if jobject.is_activity_bundle() and jobject.file_path: + return ActivityBundle(jobject.file_path) + elif jobject.is_content_bundle() and jobject.file_path: + return ContentBundle(jobject.file_path) + elif jobject.metadata['mime_type'] == JournalEntryBundle.MIME_TYPE \ + and jobject.file_path: + return JournalEntryBundle(jobject.file_path) + else: + return None + except MalformedBundleException, e: + logging.warning('Incorrect bundle: %r' % e) + return None + diff --git a/src/journal/modalalert.py b/src/journal/modalalert.py new file mode 100644 index 0000000..6c7bce9 --- /dev/null +++ b/src/journal/modalalert.py @@ -0,0 +1,93 @@ +# Copyright (C) 2008 One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gtk +from gettext import gettext as _ + +from sugar.graphics.icon import Icon +from sugar.graphics import style +from sugar import profile + +class ModalAlert(gtk.Window): + + __gtype_name__ = 'SugarModalAlert' + + def __init__(self): + gtk.Window.__init__(self) + + self.set_border_width(style.LINE_WIDTH) + offset = style.GRID_CELL_SIZE + width = gtk.gdk.screen_width() - offset * 2 + height = gtk.gdk.screen_height() - offset * 2 + self.set_size_request(width, height) + self.set_position(gtk.WIN_POS_CENTER_ALWAYS) + self.set_decorated(False) + self.set_resizable(False) + self.set_modal(True) + + self._main_view = gtk.EventBox() + self._vbox = gtk.VBox() + self._vbox.set_spacing(style.DEFAULT_SPACING) + self._vbox.set_border_width(style.GRID_CELL_SIZE * 2) + self._main_view.modify_bg(gtk.STATE_NORMAL, + style.COLOR_BLACK.get_gdk_color()) + self._main_view.add(self._vbox) + self._vbox.show() + + icon = Icon(icon_name='activity-journal', + pixel_size=style.XLARGE_ICON_SIZE, + xo_color=profile.get_color()) + self._vbox.pack_start(icon, False) + icon.show() + + self._title = gtk.Label() + self._title.modify_fg(gtk.STATE_NORMAL, + style.COLOR_WHITE.get_gdk_color()) + self._title.set_markup('%s' % _('Your Journal is full')) + self._vbox.pack_start(self._title, False) + self._title.show() + + self._message = gtk.Label(_('Please delete some old Journal' + ' entries to make space for new ones.')) + self._message.modify_fg(gtk.STATE_NORMAL, + style.COLOR_WHITE.get_gdk_color()) + self._vbox.pack_start(self._message, False) + self._message.show() + + alignment = gtk.Alignment(xalign=0.5, yalign=0.5) + self._vbox.pack_start(alignment, expand=False) + alignment.show() + + self._show_journal = gtk.Button() + self._show_journal.set_label(_('Show Journal')) + alignment.add(self._show_journal) + self._show_journal.show() + self._show_journal.connect('clicked', self.__show_journal_cb) + + self.add(self._main_view) + self._main_view.show() + + self.connect("realize", self.__realize_cb) + + def __realize_cb(self, widget): + self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG) + self.window.set_accept_focus(True) + + def __show_journal_cb(self, button): + '''The opener will listen on the destroy signal + ''' + self.destroy() + diff --git a/src/journal/objectchooser.py b/src/journal/objectchooser.py new file mode 100644 index 0000000..00e4a26 --- /dev/null +++ b/src/journal/objectchooser.py @@ -0,0 +1,199 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from gettext import gettext as _ +import logging + +import gobject +import gtk +import hippo + +from sugar.graphics import style +from sugar.graphics.toolbutton import ToolButton +from sugar.datastore import datastore + +from journal.listview import ListView +from journal.collapsedentry import BaseCollapsedEntry +from journal.journaltoolbox import SearchToolbar +from journal.volumestoolbar import VolumesToolbar + +class ObjectChooser(gtk.Window): + + __gtype_name__ = 'ObjectChooser' + + __gsignals__ = { + 'response': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([int])) + } + + def __init__(self, parent=None): + gtk.Window.__init__(self) + self.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG) + self.set_decorated(False) + self.set_position(gtk.WIN_POS_CENTER_ALWAYS) + self.set_border_width(style.LINE_WIDTH) + + self._selected_object_id = None + + self.add_events(gtk.gdk.VISIBILITY_NOTIFY_MASK) + self.connect('visibility-notify-event', + self.__visibility_notify_event_cb) + self.connect('delete-event', self.__delete_event_cb) + self.connect('key-press-event', self.__key_press_event_cb) + if parent is not None: + self.connect('realize', self.__realize_cb, parent) + + vbox = gtk.VBox() + self.add(vbox) + vbox.show() + + title_box = TitleBox() + title_box.connect('volume-changed', self.__volume_changed_cb) + title_box.close_button.connect('clicked', + self.__close_button_clicked_cb) + title_box.set_size_request(-1, style.GRID_CELL_SIZE) + vbox.pack_start(title_box, expand=False) + title_box.show() + + separator = gtk.HSeparator() + vbox.pack_start(separator, expand=False) + separator.show() + + self._toolbar = SearchToolbar() + self._toolbar.connect('query-changed', self.__query_changed_cb) + self._toolbar.set_size_request(-1, style.GRID_CELL_SIZE) + vbox.pack_start(self._toolbar, expand=False) + self._toolbar.show() + + self._list_view = ChooserListView() + self._list_view.connect('entry-activated', self.__entry_activated_cb) + vbox.pack_start(self._list_view) + self._list_view.show() + + self._toolbar.set_volume_id(datastore.mounts()[0]['id']) + + width = gtk.gdk.screen_width() - style.GRID_CELL_SIZE * 2 + height = gtk.gdk.screen_height() - style.GRID_CELL_SIZE * 2 + self.set_size_request(width, height) + + def __realize_cb(self, chooser, parent): + self.window.set_transient_for(parent) + # TODO: Should we disconnect the signal here? + + def __entry_activated_cb(self, list_view, entry): + self._selected_object_id = entry.jobject.object_id + self.emit('response', gtk.RESPONSE_ACCEPT) + + def __delete_event_cb(self, chooser, event): + self.emit('response', gtk.RESPONSE_DELETE_EVENT) + + def __key_press_event_cb(self, widget, event): + keyname = gtk.gdk.keyval_name(event.keyval) + if keyname == 'Escape': + self.emit('response', gtk.RESPONSE_DELETE_EVENT) + + def __close_button_clicked_cb(self, button): + self.emit('response', gtk.RESPONSE_DELETE_EVENT) + + def get_selected_object_id(self): + return self._selected_object_id + + def __query_changed_cb(self, toolbar, query): + self._list_view.update_with_query(query) + + def __volume_changed_cb(self, volume_toolbar, volume_id): + logging.debug('Selected volume: %r.' % volume_id) + self._toolbar.set_volume_id(volume_id) + + def __visibility_notify_event_cb(self, window, event): + logging.debug('visibility_notify_event_cb %r' % self) + visible = event.state == gtk.gdk.VISIBILITY_FULLY_OBSCURED + self._list_view.set_is_visible(visible) + +class TitleBox(VolumesToolbar): + __gtype_name__ = 'TitleBox' + + def __init__(self): + VolumesToolbar.__init__(self) + + label = gtk.Label() + label.set_markup('%s' % _('Choose an object')) + label.set_alignment(0, 0.5) + self._add_widget(label, expand=True) + + self.close_button = ToolButton(icon_name='dialog-cancel') + self.close_button.set_tooltip(_('Close')) + self.insert(self.close_button, -1) + self.close_button.show() + + def _add_widget(self, widget, expand=False): + tool_item = gtk.ToolItem() + tool_item.set_expand(expand) + + tool_item.add(widget) + widget.show() + + self.insert(tool_item, -1) + tool_item.show() + +class ChooserCollapsedEntry(BaseCollapsedEntry): + __gtype_name__ = 'ChooserCollapsedEntry' + + __gsignals__ = { + 'entry-activated': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([])) + } + + def __init__(self): + BaseCollapsedEntry.__init__(self) + + self.connect_after('button-release-event', + self.__button_release_event_cb) + self.connect('motion-notify-event', self.__motion_notify_event_cb) + + def __button_release_event_cb(self, entry, event): + self.emit('entry-activated') + return True + + def __motion_notify_event_cb(self, entry, event): + if event.detail == hippo.MOTION_DETAIL_ENTER: + self.props.background_color = style.COLOR_PANEL_GREY.get_int() + elif event.detail == hippo.MOTION_DETAIL_LEAVE: + self.props.background_color = style.COLOR_WHITE.get_int() + return False + +class ChooserListView(ListView): + __gtype_name__ = 'ChooserListView' + + __gsignals__ = { + 'entry-activated': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([object])) + } + + def __init__(self): + ListView.__init__(self) + + def create_entry(self): + entry = ChooserCollapsedEntry() + entry.connect('entry-activated', self.__entry_activated_cb) + return entry + + def __entry_activated_cb(self, entry): + self.emit('entry-activated', entry) + diff --git a/src/journal/palettes.py b/src/journal/palettes.py new file mode 100644 index 0000000..b1072b2 --- /dev/null +++ b/src/journal/palettes.py @@ -0,0 +1,113 @@ +# Copyright (C) 2008 One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from gettext import gettext as _ + +import gtk + +from sugar import profile +from sugar.graphics import style +from sugar.graphics.palette import Palette +from sugar.graphics.menuitem import MenuItem +from sugar.graphics.icon import Icon +from sugar.datastore import datastore +from sugar.graphics.xocolor import XoColor + +from journal import misc + +class ObjectPalette(Palette): + def __init__(self, jobject): + + self._jobject = jobject + + activity_icon = Icon(icon_size=gtk.ICON_SIZE_LARGE_TOOLBAR) + activity_icon.props.file = misc.get_icon_name(jobject) + if jobject.metadata.has_key('icon-color') and \ + jobject.metadata['icon-color']: + activity_icon.props.xo_color = \ + XoColor(jobject.metadata['icon-color']) + else: + activity_icon.props.xo_color = \ + XoColor('%s,%s' % (style.COLOR_BUTTON_GREY.get_svg(), + style.COLOR_TRANSPARENT.get_svg())) + + if jobject.metadata.has_key('title'): + title = jobject.metadata['title'] + else: + title = _('Untitled') + + Palette.__init__(self, primary_text=title, + icon=activity_icon) + + if jobject.metadata.get('activity_id', ''): + resume_label = _('Resume') + else: + resume_label = _('Start') + menu_item = MenuItem(resume_label, 'activity-start') + menu_item.connect('activate', self.__start_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + # TODO: Add "Start with" menu item + + menu_item = MenuItem(_('Copy')) + icon = Icon(icon_name='edit-copy', xo_color=profile.get_color(), + icon_size=gtk.ICON_SIZE_MENU) + menu_item.set_image(icon) + menu_item.connect('activate', self.__copy_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + menu_item = MenuItem(_('Erase'), 'list-remove') + menu_item.connect('activate', self.__erase_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + def __start_activate_cb(self, menu_item): + self._jobject.resume() + + def __copy_activate_cb(self, menu_item): + clipboard = gtk.Clipboard() + clipboard.set_with_data([('text/uri-list', 0, 0)], + self.__clipboard_get_func_cb, + self.__clipboard_clear_func_cb) + + def __clipboard_get_func_cb(self, clipboard, selection_data, info, data): + selection_data.set('text/uri-list', 8, self._jobject.file_path) + + def __clipboard_clear_func_cb(self, clipboard, data): + pass + + def __erase_activate_cb(self, menu_item): + bundle = misc.get_bundle(self._jobject) + if bundle is not None and bundle.is_installed(): + bundle.uninstall() + datastore.delete(self._jobject.object_id) + + +class BuddyPalette(Palette): + def __init__(self, buddy): + self._buddy = buddy + + nick, colors = buddy + buddy_icon = Icon(icon_name='computer-xo', + icon_size=style.STANDARD_ICON_SIZE, + xo_color=XoColor(colors)) + + Palette.__init__(self, primary_text=nick, + icon=buddy_icon) + + # TODO: Support actions on buddies, like make friend, invite, etc. diff --git a/src/journal/query.py b/src/journal/query.py new file mode 100644 index 0000000..04d9b16 --- /dev/null +++ b/src/journal/query.py @@ -0,0 +1,266 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging + +from sugar.datastore import datastore + +# Properties the journal cares about. +PROPERTIES = ['uid', 'title', 'mtime', 'timestamp', 'keep', 'buddies', + 'icon-color', 'mime_type', 'progress', 'activity', 'mountpoint', + 'activity_id'] + +class _Cache(object): + + __gtype_name__ = 'query_Cache' + + def __init__(self, jobjects=None): + self._array = [] + self._dict = {} + if jobjects is not None: + self.append_all(jobjects) + + def prepend_all(self, jobjects): + for jobject in jobjects[::-1]: + self._array.insert(0, jobject) + self._dict[jobject.object_id] = jobject + + def append_all(self, jobjects): + for jobject in jobjects: + self._array.append(jobject) + self._dict[jobject.object_id] = jobject + + def remove_all(self, jobjects): + jobjects = jobjects[:] + for jobject in jobjects: + obj = self._dict[jobject.object_id] + self._array.remove(obj) + del self._dict[obj.object_id] + obj.destroy() + + def __len__(self): + return len(self._array) + + def __getitem__(self, key): + if isinstance(key, basestring): + return self._dict[key] + else: + return self._array[key] + + def destroy(self): + self._destroy_jobjects(self._array) + self._array = [] + self._dict = {} + + def _destroy_jobjects(self, jobjects): + for jobject in jobjects: + jobject.destroy() + +class ResultSet(object): + + _CACHE_LIMIT = 80 + + def __init__(self, query, sorting): + self._total_count = -1 + self._position = -1 + self._query = query + self._sorting = sorting + + self._offset = 0 + self._cache = _Cache() + + def destroy(self): + self._cache.destroy() + + def get_length(self): + if self._total_count == -1: + jobjects, self._total_count = datastore.find(self._query, + sorting=self._sorting, + limit=ResultSet._CACHE_LIMIT, + properties=PROPERTIES) + self._cache.append_all(jobjects) + self._offset = 0 + return self._total_count + + length = property(get_length) + + def seek(self, position): + self._position = position + + def read(self, max_count): + logging.debug('ResultSet.read position: %r' % self._position) + + if max_count * 5 > ResultSet._CACHE_LIMIT: + raise RuntimeError( + 'max_count (%i) too big for ResultSet._CACHE_LIMIT' + ' (%i).' % (max_count, ResultSet._CACHE_LIMIT)) + + if self._position == -1: + self.seek(0) + + if self._position < self._offset: + remaining_forward_entries = 0 + else: + remaining_forward_entries = self._offset + len(self._cache) - \ + self._position + + if self._position > self._offset + len(self._cache): + remaining_backwards_entries = 0 + else: + remaining_backwards_entries = self._position - self._offset + + last_cached_entry = self._offset + len(self._cache) + + if (remaining_forward_entries <= 0 and + remaining_backwards_entries <= 0) or \ + max_count > ResultSet._CACHE_LIMIT: + + # Total cache miss: remake it + offset = max(0, self._position - max_count) + logging.debug('remaking cache, offset: %r limit: %r' % \ + (offset, max_count * 2)) + jobjects, self._total_count = datastore.find(self._query, + sorting=self._sorting, + offset=offset, + limit=ResultSet._CACHE_LIMIT, + properties=PROPERTIES) + + self._cache.remove_all(self._cache) + self._cache.append_all(jobjects) + self._offset = offset + + elif remaining_forward_entries < 2 * max_count and \ + last_cached_entry < self._total_count: + + # Add one page to the end of cache + logging.debug('appending one more page, offset: %r' % \ + last_cached_entry) + jobjects, self._total_count = datastore.find(self._query, + sorting=self._sorting, + offset=last_cached_entry, + limit=max_count, + properties=PROPERTIES) + # update cache + self._cache.append_all(jobjects) + + # apply the cache limit + objects_excess = len(self._cache) - ResultSet._CACHE_LIMIT + if objects_excess > 0: + self._offset += objects_excess + self._cache.remove_all(self._cache[:objects_excess]) + + elif remaining_backwards_entries < 2 * max_count and self._offset > 0: + + # Add one page to the beginning of cache + limit = min(self._offset, max_count) + self._offset = max(0, self._offset - max_count) + + logging.debug('prepending one more page, offset: %r limit: %r' % + (self._offset, limit)) + jobjects, self._total_count = datastore.find(self._query, + sorting=self._sorting, + offset=self._offset, + limit=limit, + properties=PROPERTIES) + + # update cache + self._cache.prepend_all(jobjects) + + # apply the cache limit + objects_excess = len(self._cache) - ResultSet._CACHE_LIMIT + if objects_excess > 0: + self._cache.remove_all(self._cache[-objects_excess:]) + else: + logging.debug('cache hit and no need to grow the cache') + + first_pos = self._position - self._offset + last_pos = self._position - self._offset + max_count + return self._cache[first_pos:last_pos] + +def find(query, sorting=None): + if sorting is None: + sorting = ['-mtime'] + result_set = ResultSet(query, sorting) + return result_set + +def test(): + TOTAL_ITEMS = 1000 + SCREEN_SIZE = 10 + + def mock_debug(string): + print "\tDEBUG: %s" % string + logging.debug = mock_debug + + def mock_find(query, sorting=None, limit=None, offset=None, + properties=None): + if properties is None: + properties = [] + + print "mock_find %r %r" % (offset, (offset + limit)) + + if limit is None or offset is None: + raise RuntimeError("Unimplemented test.") + + result = [] + for index in range(offset, offset + limit): + obj = datastore.DSObject(index, datastore.DSMetadata({}), '') + result.append(obj) + + return result, TOTAL_ITEMS + datastore.find = mock_find + + result_set = find({}) + + print "Get first page" + objects = result_set.read(SCREEN_SIZE) + print [obj.object_id for obj in objects] + assert range(0, SCREEN_SIZE) == [obj.object_id for obj in objects] + print "" + + print "Scroll to 5th item" + result_set.seek(5) + objects = result_set.read(SCREEN_SIZE) + print [obj.object_id for obj in objects] + assert range(5, SCREEN_SIZE + 5) == [obj.object_id for obj in objects] + print "" + + print "Scroll back to beginning" + result_set.seek(0) + objects = result_set.read(SCREEN_SIZE) + print [obj.object_id for obj in objects] + assert range(0, SCREEN_SIZE) == [obj.object_id for obj in objects] + print "" + + print "Hit PgDn five times" + for i in range(0, 5): + result_set.seek((i + 1) * SCREEN_SIZE) + objects = result_set.read(SCREEN_SIZE) + print [obj.object_id for obj in objects] + assert range((i + 1) * SCREEN_SIZE, (i + 2) * SCREEN_SIZE) == \ + [obj.object_id for obj in objects] + print "" + + print "Hit PgUp five times" + for i in range(0, 5)[::-1]: + result_set.seek(i * SCREEN_SIZE) + objects = result_set.read(SCREEN_SIZE) + print [obj.object_id for obj in objects] + assert range(i * SCREEN_SIZE, (i + 1) * SCREEN_SIZE) == \ + [obj.object_id for obj in objects] + print "" + +if __name__ == "__main__": + test() diff --git a/src/journal/volumesmanager.py b/src/journal/volumesmanager.py new file mode 100644 index 0000000..b2ef08d --- /dev/null +++ b/src/journal/volumesmanager.py @@ -0,0 +1,315 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +from gettext import gettext as _ + +import gobject +import dbus + +from sugar import profile +from sugar.datastore import datastore + +HAL_SERVICE_NAME = 'org.freedesktop.Hal' +HAL_MANAGER_PATH = '/org/freedesktop/Hal/Manager' +HAL_MANAGER_IFACE = 'org.freedesktop.Hal.Manager' +HAL_DEVICE_IFACE = 'org.freedesktop.Hal.Device' +HAL_VOLUME_IFACE = 'org.freedesktop.Hal.Device.Volume' + +MOUNT_OPTION_UID = 500 +MOUNT_OPTION_UMASK = 000 + +_volumes_manager = None + +class VolumesManager(gobject.GObject): + + __gtype_name__ = 'VolumesManager' + + __gsignals__ = { + 'volume-added': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([object])), + 'volume-removed': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([object])) + } + + def __init__(self): + gobject.GObject.__init__(self) + + self._volumes = [] + + # Internal flash is not in HAL + internal_fash_id = datastore.mounts()[0]['id'] + self._volumes.append(Volume(internal_fash_id, _('Journal'), + 'activity-journal', profile.get_color(), + None, False)) + + bus = dbus.SystemBus() + proxy = bus.get_object(HAL_SERVICE_NAME, HAL_MANAGER_PATH) + self._hal_manager = dbus.Interface(proxy, HAL_MANAGER_IFACE) + self._hal_manager.connect_to_signal('DeviceAdded', + self._hal_device_added_cb) + + for udi in self._hal_manager.FindDeviceByCapability('volume'): + if self._is_device_relevant(udi): + try: + self._add_hal_device(udi) + except Exception, e: + logging.error('Exception when mounting device %r: %r' % \ + (udi, e)) + + def get_volumes(self): + return self._volumes + + def _get_volume_by_udi(self, udi): + for volume in self._volumes: + if volume.udi == udi: + return volume + return None + + def _hal_device_added_cb(self, udi): + bus = dbus.SystemBus() + device_object = bus.get_object(HAL_SERVICE_NAME, udi) + device = dbus.Interface(device_object, HAL_DEVICE_IFACE) + if device.QueryCapability('volume'): + logging.debug('VolumesManager._hal_device_added_cb: %r', udi) + if self._is_device_relevant(udi): + self._add_hal_device(udi) + + def _is_device_relevant(self, udi): + bus = dbus.SystemBus() + device_object = bus.get_object(HAL_SERVICE_NAME, udi) + device = dbus.Interface(device_object, HAL_DEVICE_IFACE) + + # Ignore volumes without a filesystem. + if device.GetProperty('volume.fsusage') != 'filesystem': + return False + # Ignore root. + if device.GetProperty('volume.mount_point') == '/': + return False + + storage_udi = device.GetProperty('block.storage_device') + obj = bus.get_object(HAL_SERVICE_NAME, storage_udi) + storage_device = dbus.Interface(obj, HAL_DEVICE_IFACE) + + # Ignore non-removable storage. + if not storage_device.GetProperty('storage.hotpluggable'): + return False + + return True + + def _add_hal_device(self, udi): + logging.debug('VolumeToolbar._add_hal_device: %r' % udi) + + bus = dbus.SystemBus() + device_object = bus.get_object(HAL_SERVICE_NAME, udi) + device = dbus.Interface(device_object, HAL_DEVICE_IFACE) + + # listen to mount/unmount + device.connect_to_signal('PropertyModified', + lambda *args: self._hal_device_property_modified_cb(udi, *args)) + + bus.add_signal_receiver(self._hal_device_removed_cb, + 'DeviceRemoved', + HAL_MANAGER_IFACE, HAL_SERVICE_NAME, + HAL_MANAGER_PATH, arg0=udi) + + if device.GetProperty('volume.is_mounted'): + volume_id = self._mount_in_datastore(udi) + return + + label = device.GetProperty('volume.label') + fs_type = device.GetProperty('volume.fstype') + valid_options = device.GetProperty('volume.mount.valid_options') + options = [] + + if 'uid=' in valid_options: + options.append('uid=%i' % MOUNT_OPTION_UID) + + if 'umask=' in valid_options: + options.append('umask=%i' % MOUNT_OPTION_UMASK) + + if 'noatime' in valid_options: + options.append('noatime') + + if 'utf8' in valid_options: + options.append('utf8') + + if 'iocharset=' in valid_options: + options.append('iocharset=utf8') + + mount_point = label + if not mount_point: + mount_point = device.GetProperty('volume.uuid') + + volume = dbus.Interface(device_object, HAL_VOLUME_IFACE) + + # Try 100 times to get a mount point + mounted = False + i = 0 + while not mounted: + try: + if i > 0: + volume.Mount('%s_%d' % (mount_point, i), fs_type, options) + else: + volume.Mount(mount_point, fs_type, options) + mounted = True + except dbus.DBusException, e: + s = 'org.freedesktop.Hal.Device.Volume.MountPointNotAvailable' + if i < 100 and e.get_dbus_name() == s: + i += 1 + else: + raise + + def _hal_device_property_modified_cb(self, udi, count, changes): + if 'volume.is_mounted' in [change[0] for change in changes]: + logging.debug('VolumesManager._hal_device_property_modified: %r' % \ + (udi)) + bus = dbus.SystemBus() + #proxy = bus.get_object(HAL_SERVICE_NAME, HAL_MANAGER_PATH) + #hal_manager = dbus.Interface(proxy, HAL_MANAGER_IFACE) + # TODO: Why this doesn't work? + #if not hal_manager.DeviceExists(udi): + # return + + proxy = bus.get_object(HAL_SERVICE_NAME, udi) + device = dbus.Interface(proxy, HAL_DEVICE_IFACE) + try: + is_mounted = device.GetProperty('volume.is_mounted') + except dbus.DBusException, e: + logging.debug('e: %s' % e) + return + + if is_mounted: + if self._get_volume_by_udi(udi) is not None: + # device already mounted in the datastore + return + volume_id = self._mount_in_datastore(udi) + else: + self.unmount_from_datastore(udi) + return + + def _mount_in_datastore(self, udi): + logging.debug('VolumeToolbar._mount_in_datastore: %r' % udi) + + bus = dbus.SystemBus() + device_object = bus.get_object(HAL_SERVICE_NAME, udi) + device = dbus.Interface(device_object, HAL_DEVICE_IFACE) + + mount_point = device.GetProperty('volume.mount_point') + ds_mounts = datastore.mounts() + for ds_mount in ds_mounts: + if mount_point == ds_mount['uri']: + return ds_mount['id'] + + mount_id = datastore.mount('inplace:' + mount_point, + dict(title=mount_point)) + if not mount_id: + self._unmount_hal_device(udi) + raise RuntimeError('datastore.mount(%r, %r) failed.' % ( + 'inplace:' + mount_point, + dict(title=mount_point))) + + volume_name = device.GetProperty('volume.label') + if not volume_name: + volume_name = device.GetProperty('volume.uuid') + volume = Volume(mount_id, + volume_name, + self._get_icon_for_volume(udi), + profile.get_color(), + udi, + True) + self._volumes.append(volume) + self.emit('volume-added', volume) + + logging.debug('mounted volume %s' % mount_point) + + def _hal_device_removed_cb(self, udi): + logging.debug('VolumesManager._hal_device_removed_cb: %r', udi) + bus = dbus.SystemBus() + #proxy = bus.get_object(HAL_SERVICE_NAME, HAL_MANAGER_PATH) + #hal_manager = dbus.Interface(proxy, HAL_MANAGER_IFACE) + # TODO: Why this doesn't work? + #if not hal_manager.DeviceExists(udi): + # self._unmount_from_datastore(udi) + # self._remove_button(udi) + # return + + proxy = bus.get_object(HAL_SERVICE_NAME, udi) + device = dbus.Interface(proxy, HAL_DEVICE_IFACE) + try: + is_mounted = device.GetProperty('volume.is_mounted') + except dbus.DBusException, e: + logging.debug('e: %s' % e) + self.unmount_from_datastore(udi) + return + + if is_mounted: + self._unmount_from_datastore(udi) + self._unmount_hal_device(udi) + + def unmount_from_datastore(self, udi): + logging.debug('VolumesManager._unmount_from_datastore: %r', udi) + volume = self._get_volume_by_udi(udi) + if volume is not None: + datastore.unmount(volume.id) + + self._volumes.remove(volume) + self.emit('volume-removed', volume) + + def unmount_hal_device(self, udi): + logging.debug('VolumesManager._unmount_hal_device: %r', udi) + bus = dbus.SystemBus() + device_object = bus.get_object(HAL_SERVICE_NAME, udi) + volume = dbus.Interface(device_object, HAL_VOLUME_IFACE) + volume.Unmount([]) + + def _get_icon_for_volume(self, udi): + bus = dbus.SystemBus() + device_object = bus.get_object(HAL_SERVICE_NAME, udi) + device = dbus.Interface(device_object, HAL_DEVICE_IFACE) + + storage_udi = device.GetProperty('block.storage_device') + obj = bus.get_object(HAL_SERVICE_NAME, storage_udi) + storage_device = dbus.Interface(obj, HAL_DEVICE_IFACE) + + storage_drive_type = storage_device.GetProperty('storage.drive_type') + if storage_drive_type == 'sd_mmc': + return 'media-flash-sd-mmc' + else: + return 'media-flash-usb' + +class Volume(object): + def __init__(self, volume_id, name, icon_name, icon_color, udi, + can_unmount): + self.id = volume_id + self.name = name + self.icon_name = icon_name + self.icon_color = icon_color + self.udi = udi + self.can_unmount = can_unmount + + def unmount(self): + get_volumes_manager().unmount_from_datastore(self.udi) + get_volumes_manager().unmount_hal_device(self.udi) + +def get_volumes_manager(): + global _volumes_manager + if _volumes_manager is None: + _volumes_manager = VolumesManager() + return _volumes_manager + diff --git a/src/journal/volumestoolbar.py b/src/journal/volumestoolbar.py new file mode 100644 index 0000000..372db8b --- /dev/null +++ b/src/journal/volumestoolbar.py @@ -0,0 +1,137 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging +from gettext import gettext as _ + +import gobject +import gtk + +from sugar.datastore import datastore +from sugar.graphics.radiotoolbutton import RadioToolButton +from sugar.graphics.palette import Palette + +from journal import volumesmanager + +class VolumesToolbar(gtk.Toolbar): + __gtype_name__ = 'VolumesToolbar' + + __gsignals__ = { + 'volume-changed': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([str])) + } + + def __init__(self): + gtk.Toolbar.__init__(self) + self._volume_buttons = [] + self._volume_added_hid = None + self._volume_removed_hid = None + + self.connect('destroy', self.__destroy_cb) + + gobject.idle_add(self._set_up_volumes) + + def __destroy_cb(self, widget): + volumes_manager = volumesmanager.get_volumes_manager() + volumes_manager.disconnect(self._volume_added_hid) + volumes_manager.disconnect(self._volume_removed_hid) + + def _set_up_volumes(self): + volumes_manager = volumesmanager.get_volumes_manager() + self._volume_added_hid = \ + volumes_manager.connect('volume-added', self._volume_added_cb) + self._volume_removed_hid = \ + volumes_manager.connect('volume-removed', + self._volume_removed_cb) + + for volume in volumes_manager.get_volumes(): + self._add_button(volume) + + def _volume_added_cb(self, volumes_manager, volume): + self._add_button(volume) + + def _volume_removed_cb(self, volumes_manager, volume): + self._remove_button(volume) + + def _add_button(self, volume): + logging.debug('VolumeToolbar._add_button: %r' % volume.name) + + if self._volume_buttons: + group = self._volume_buttons[0] + else: + group = None + + palette = Palette(volume.name) + + button = VolumeButton(volume, group) + button.set_palette(palette) + button.connect('toggled', self._button_toggled_cb, volume) + if self._volume_buttons: + position = self.get_item_index(self._volume_buttons[-1]) + 1 + else: + position = 0 + self.insert(button, position) + button.show() + + self._volume_buttons.append(button) + + if volume.can_unmount: + menu_item = gtk.MenuItem(_('Unmount')) + menu_item.connect('activate', self._unmount_activated_cb, volume) + palette.menu.append(menu_item) + menu_item.show() + + if len(self.get_children()) > 1: + self.show() + + def _button_toggled_cb(self, button, volume): + if button.props.active: + self.emit('volume-changed', volume.id) + + def _unmount_activated_cb(self, menu_item, volume): + logging.debug('VolumesToolbar._unmount_activated_cb: %r', volume.udi) + volume.unmount() + + def _remove_button(self, volume): + for button in self.get_children(): + if button.volume.id == volume.id: + self._volume_buttons.remove(button) + self.remove(button) + self.get_children()[0].props.active = True + + if len(self.get_children()) < 2: + self.hide() + return + +class VolumeButton(RadioToolButton): + def __init__(self, volume, group): + RadioToolButton.__init__(self) + self.props.named_icon = volume.icon_name + self.props.xo_color = volume.icon_color + self.props.group = group + + self.volume = volume + self.drag_dest_set(gtk.DEST_DEFAULT_ALL, + [('journal-object-id', 0, 0)], + gtk.gdk.ACTION_COPY) + self.connect('drag-data-received', self._drag_data_received_cb) + + def _drag_data_received_cb(self, widget, drag_context, x, y, selection_data, + info, timestamp): + jobject = datastore.get(selection_data.data) + datastore.copy(jobject, self.volume.id) + diff --git a/src/model/homeactivity.py b/src/model/homeactivity.py index 9dc4f9c..bde7fa3 100644 --- a/src/model/homeactivity.py +++ b/src/model/homeactivity.py @@ -16,6 +16,7 @@ import time import logging +import os import gobject import dbus @@ -23,6 +24,9 @@ import dbus from sugar.graphics.xocolor import XoColor from sugar.presence import presenceservice from sugar import profile +from sugar import wm + +import config _SERVICE_NAME = "org.laptop.Activity" _SERVICE_PATH = "/org/laptop/Activity" @@ -44,7 +48,7 @@ class HomeActivity(gobject.GObject): gobject.PARAM_READWRITE), } - def __init__(self, activity_info, activity_id): + def __init__(self, activity_info, activity_id, window=None): """Initialise the HomeActivity activity_info -- sugar.activity.registry.ActivityInfo instance, @@ -53,10 +57,11 @@ class HomeActivity(gobject.GObject): the "type" of activity being created. activity_id -- unique identifier for this instance of the activity type + window -- Main WnckWindow of the activity """ gobject.GObject.__init__(self) - self._window = None + self._window = window self._xid = None self._pid = None self._service = None @@ -105,7 +110,9 @@ class HomeActivity(gobject.GObject): def get_icon_path(self): """Retrieve the activity's icon (file) name""" - if self._activity_info: + if self.is_journal(): + return os.path.join(config.data_path, 'icons/activity-journal.svg') + elif self._activity_info: return self._activity_info.icon else: return None @@ -162,10 +169,7 @@ class HomeActivity(gobject.GObject): def get_type(self): """Retrieve the activity bundle id for future reference""" - if self._activity_info: - return self._activity_info.bundle_id - else: - return None + return wm.get_bundle_id(self._window) def is_journal(self): """Returns boolean if the activity is of type JournalActivity""" diff --git a/src/model/homemodel.py b/src/model/homemodel.py index 9f3db8e..668b60e 100644 --- a/src/model/homemodel.py +++ b/src/model/homemodel.py @@ -169,10 +169,10 @@ class HomeModel(gobject.GObject): home_activity = self._get_activity_by_id(activity_id) if not home_activity: - home_activity = HomeActivity(activity_info, activity_id) + home_activity = HomeActivity(activity_info, activity_id, window) self._add_activity(home_activity) - - home_activity.set_window(window) + else: + home_activity.set_window(window) if get_sugar_window_type(window) != 'launcher': home_activity.props.launching = False diff --git a/src/view/Shell.py b/src/view/Shell.py index 89c0bb5..dbc1546 100644 --- a/src/view/Shell.py +++ b/src/view/Shell.py @@ -39,6 +39,7 @@ from view.keyhandler import KeyHandler from view.home.HomeWindow import HomeWindow from view.launchwindow import LaunchWindow from model import shellmodel +from journal import journalactivity # #3903 - this constant can be removed and assumed to be 1 when dbus-python # 0.82.3 is the only version used @@ -78,18 +79,15 @@ class Shell(gobject.GObject): try: datastore.mount(ds_path, [], timeout=120 * \ DBUS_PYTHON_TIMEOUT_UNITS_PER_SECOND) - except Exception: + except Exception, e: # Don't explode if there's corruption; move the data out of the way # and attempt to create a store from scratch. + logging.error(e) shutil.move(ds_path, os.path.abspath(ds_path) + str(time.time())) datastore.mount(ds_path, [], timeout=120 * \ DBUS_PYTHON_TIMEOUT_UNITS_PER_SECOND) - # Checking for the bundle existence will also ensure - # that the shell service is started up. - registry = activity.get_registry() - if registry.get_activity('org.laptop.JournalActivity'): - self.start_activity('org.laptop.JournalActivity') + journalactivity.start() def __launch_started_cb(self, home_model, home_activity): if home_activity.is_journal(): diff --git a/src/view/frame/activitiestray.py b/src/view/frame/activitiestray.py index 1311f07..0291732 100644 --- a/src/view/frame/activitiestray.py +++ b/src/view/frame/activitiestray.py @@ -55,14 +55,6 @@ class ActivityButton(RadioToolButton): self.set_icon_widget(self._icon) self._icon.show() - if self._home_activity.is_journal(): - palette = JournalPalette(self._home_activity) - else: - palette = CurrentActivityPalette(self._home_activity) - palette.props.invoker = FrameWidgetInvoker(self) - palette.set_group_id('frame') - self.set_palette(palette) - if home_activity.props.launching: self._icon.props.pulsing = True self._notify_launching_hid = home_activity.connect( \ @@ -71,6 +63,15 @@ class ActivityButton(RadioToolButton): self._notify_launching_hid = None self._notif_icon = None + def create_palette(self): + if self._home_activity.is_journal(): + palette = JournalPalette(self._home_activity) + else: + palette = CurrentActivityPalette(self._home_activity) + palette.props.invoker = FrameWidgetInvoker(self) + palette.set_group_id('frame') + self.set_palette(palette) + def __notify_launching_cb(self, home_activity, pspec): if not home_activity.props.launching: self._icon.props.pulsing = False diff --git a/src/view/keyhandler.py b/src/view/keyhandler.py index b2085a4..142bab4 100644 --- a/src/view/keyhandler.py +++ b/src/view/keyhandler.py @@ -243,7 +243,7 @@ class KeyHandler(object): bus = dbus.SessionBus() obj = bus.get_object(J_DBUS_SERVICE, J_DBUS_PATH) journal = dbus.Interface(obj, J_DBUS_INTERFACE) - journal.FocusSearch({}) + journal.FocusSearch() def handle_open_search(self): self.focus_journal_search() -- cgit v0.9.1