Re: [Usability] An extended default applications dialog



Am Sonntag, den 14.01.2007, 14:27 +0000 schrieb Matt Medland:
> Okay, thanks. I think I'll wait for the next implementation then.
> There's a link to the current script on the wiki page anyhow.

OK, I just finished the next iteration. It feels way better IMO, the UI
is completely different.

Feedback appreciated.

-- 
Christian Neumair <chris gnome-de org>
#!/usr/bin/python
# * demo application for a new "default application" chooser
# * GUI experiment
# * started after failed GUI experiment, my experience failure is describe at
#      http://mail.gnome.org/archives/usability/2007-January/msg00064.html
#
# GPL, (C) 2007 Christian Neumair <chris gnome-de org>

import pygtk
import gtk
import gobject
import gnomevfs
from sets import Set

class MimeCategory:
	def __init__ (self, name, combo_label, exception_label, mime_types, default_application_id_fallback):
		self.name = name
		self.combo_label = combo_label
		self.exception_label = exception_label
		self.mime_types = mime_types
		self.default_application_id_fallback = default_application_id_fallback
		self.update_from_vfs()

	def update_from_vfs (self):
		self.all_applications = {} # id->application
		self.applications = {} # MIME type -> application
		self.default_applications = {} # MIME type -> application

		# applications that can not handle all MIME types, TODO
		# these would not be available for the category combos
		self.exception_applications = None

		unhandled_apps = None

		# find out available apps + default app for all mime types
		for mime_type in self.mime_types:
			self.applications[mime_type] = gnomevfs.mime_get_all_applications (mime_type)
			self.default_applications[mime_type] = gnomevfs.mime_get_default_application (mime_type)

			for application in self.applications[mime_type]:
				self.all_applications[application[0]] = (application)

		# find default app FIXME this should be read out somewhere
		default_app = None

		self.default_application_id = default_app and default_app[0] or self.default_application_id_fallback

class ApplicationChooserModel (gtk.ListStore):
	def __init__ (self, applications):
		gtk.ListStore.__init__ (self,
					gobject.TYPE_PYOBJECT, # GnomeVFSMIMEApplication
					gobject.TYPE_INT, # sort index
					gobject.TYPE_STRING) # application name
		self.set_default_sort_func (self.sort_func)
		self.set_sort_column_id (-1, gtk.SORT_ASCENDING)

		self.applications = applications 
		for application in applications:
			# application[1] is the name
			self.append ([application, -1, application[1]])

		# TODO 
		#self.append ([None, 0, None])
		#self.append ([None, 1, 'Custom...'])

	def sort_func (self, model, a, b):
		cmp = self.get_value (a, 1) - self.get_value (b, 1)
		if (cmp != 0):
			return cmp

		if self.get_value (a, 2) > self.get_value (b, 2):
			return 1
		elif self.get_value (a, 2) < self.get_value (b, 2):
			return -1
		else:
			return 0

class ApplicationChooser (gtk.ComboBox):
	def __init__ (self, applications, default_application_id):
		gtk.ComboBox.__init__ (self)
		self.set_row_separator_func(self.row_separator_func)

		self.set_model (ApplicationChooserModel (applications))

		iter = self.get_model().get_iter_first()
		i = 0
		while (iter != None):
			application = self.get_model().get_value (iter, 0)
			if application != None and application[0] == default_application_id:
				self.set_active (i)
				break
			i = i + 1
			iter = self.get_model().iter_next (iter)
			

		cell = gtk.CellRendererText()
		self.pack_start(cell, True)
		self.add_attribute(cell, 'text', 2)

	def row_separator_func (self, model, iter):
		return model.get_value (iter, 0) == None

class MIMEChooserModel (gtk.ListStore):
	def __init__ (self, mime_types):
		gtk.ListStore.__init__ (self,
					gobject.TYPE_STRING, # MIME Type
					gobject.TYPE_STRING) # MIME Type (human-readable)
		self.set_default_sort_func (self.sort_func)
		self.set_sort_column_id (0, gtk.SORT_ASCENDING)

		self.mime_types = mime_types
		for mime_type in mime_types:
			desc = gnomevfs.mime_get_description (mime_type) or mime_type
			print "appended " + mime_type
			self.append ([mime_type, desc])

	def sort_func (self, model, a, b):
		cmp = self.get_value (a, 1) - self.get_value (b, 1)
		if (cmp != 0):
			return cmp

		if self.get_value (a, 0) > self.get_value (b, 0):
			return 1
		elif self.get_value (a, 0) < self.get_value (b, 0):
			return -1
		else:
			return 0

# Details view allowing to add MIME types that have handlers
# that override the default handler
# TODO proper error handling etc.
# TODO implement saving/reloading/resynching with MIME data upon changes
# TODO decide whether default application itself should show up
class DetailsView (gtk.TreeView):
	def __init__(self, category):
		gtk.TreeView.__init__(self)

		self.category = category
		self.adding_mime_type = False

		liststore = gtk.ListStore(gobject.TYPE_STRING,   # MIME Type
					  gobject.TYPE_STRING,   # MIME Type (human-readable)
					  gtk.ListStore,         # MIME Model
					  gobject.TYPE_PYOBJECT, # Active Application
					  gobject.TYPE_STRING,   # Active Application (as string)
					  gtk.ListStore)         # Application Model
		self.set_model (liststore)
		self.get_model ().set_sort_column_id (1, gtk.SORT_ASCENDING)

		self.get_selection().set_mode (gtk.SELECTION_MULTIPLE)

		cell = self.file_type_renderer = gtk.CellRendererCombo ()
		cell.set_property ('text-column', 1)
		cell.set_property ('editable', False)
		cell.set_property ('has-entry', False)
		column = gtk.TreeViewColumn ('File Type', cell, model=2, text=1)
		self.append_column (column)

		cell.connect ("edited", self.file_type_edited)
		cell.connect ("editing-canceled", self.file_type_editing_canceled)

		cell = self.application_renderer = gtk.CellRendererCombo ()
		cell.set_property ('text-column', 2)
		cell.set_property ('editable', True)
		cell.set_property ('has-entry', False)
		column = gtk.TreeViewColumn ('Application', cell, model=5, text=4)
		self.append_column (column)

		cell.connect ("edited", self.application_edited)
		cell.connect ("editing-canceled", self.application_editing_canceled)

		self.unhandled_mime_types = []
		self.handled_undisplayed_mime_types = []
		self.handled_displayed_mime_types = []

		# add entries for all the MIME types where we have apps
		for mime_type in self.category.mime_types:
			app = self.category.default_applications[mime_type]
			apps = self.category.applications[mime_type]
			desc = gnomevfs.mime_get_description (mime_type)
			if desc == None:
				print 'mime type "' + mime_type + '" has no description.'
				desc = mime_type

			if app == None:
				self.unhandled_mime_types.append (mime_type)
			elif app[0] != self.category.default_application_id:
				self.handled_displayed_mime_types.append (mime_type)
				liststore.append ([mime_type, desc, MIMEChooserModel ([]), \
						  app, app and app[1] or None, ApplicationChooserModel (apps)])
			else:
				# handled by default application
				self.handled_undisplayed_mime_types.append (mime_type)
				continue

		if liststore.get_iter_first() == None:
			liststore.append ([None, None, MIMEChooserModel ([]), \
					  None, '(No exceptions defined)', ApplicationChooserModel ([]) ])

	def file_type_edited (self, cell, path_string, mime_type_string):
		cell.set_property ('editable', False)

		iter = self.get_model ().get_iter_from_string (path_string)

		# EWW my eyes bleed! how do we figure out the MIME type from a description string?
		# we need the active index of the submodel, i.e. the GtkComboBox!
		submodel = self.get_model ().get_value (iter, 2)

		subiter = submodel.get_iter_first()
		while (subiter != None):
			model_mime_type_string = submodel.get_value (subiter, 1)
			if model_mime_type_string == mime_type_string:
				break
			subiter = submodel.iter_next (subiter)

		if (subiter == None): # eww should not happen!
			self.file_type_editing_canceled (cell)
			return

		mime_type = submodel.get_value (subiter, 0)

		self.get_model ().set_value (iter, 0, mime_type)
		self.get_model ().set_value (iter, 1, mime_type_string)

		model = ApplicationChooserModel (self.category.applications[mime_type])
		self.get_model ().set_value (iter, 5, model)
		self.set_cursor_on_cell (self.get_model ().get_path (iter), self.get_column (1), self.application_renderer, True)

	def file_type_editing_canceled (self, cell):
		cell.set_property ('editable', False)

		if self.adding_mime_type:
			(liststore, iter) = self.get_selection ().get_selected ()
			liststore.remove (iter)
			self.adding_mime_type = False

	def application_edited (self, cell, path_string, application_string):
		iter = self.get_model ().get_iter_from_string (path_string)

		mime_type = self.get_model ().get_value (iter, 0)

		# EWW my eyes bleed! how do we figure out the application from a its string?
		# we need the active index of the submodel, i.e. the GtkComboBox!
		submodel = self.get_model ().get_value (iter, 5)

		subiter = submodel.get_iter_first()
		while (subiter != None):
			model_application_string = submodel.get_value (subiter, 2)
			if model_application_string == application_string:
				break
			subiter = submodel.iter_next (subiter)

		if (subiter == None): # eww should not happen!
			self.application_editing_canceled (cell)
			return

		application = submodel.get_value (subiter, 0)

		self.get_model ().set_value (iter, 3, application)
		self.get_model ().set_value (iter, 4, application[1])

		if self.adding_mime_type:
			self.adding_mime_type = False

			self.handled_undisplayed_mime_types.remove (mime_type)
			self.handled_displayed_mime_types.append (mime_type)

			# TODO maybe emit mime-type-added instead
			print 'Introduced handler ' + application[1] + ' for MIME type ' + mime_type
		else:
			print 'Changed handler to ' + application[1] + ' for MIME type ' + mime_type

		self.emit ("mime-type-changed", mime_type)
		# TODO write out changes, possibly in the handler

	def application_editing_canceled (self, cell):
		if self.adding_mime_type:
			(liststore, iter) = self.get_selection ().get_selected ()
			liststore.remove (iter)

	def add_mime_type (self):
		self.adding_mime_type = True

		# remove dummy row if appropriate
		liststore = self.get_model ()
		first_iter = liststore.get_iter_first()
		if first_iter != None:
			desc = liststore.get_value (first_iter, 1)
			if desc == None:
				liststore.remove (first_iter)

		# add new row
		iter = liststore.append ([None, '(none)', MIMEChooserModel (self.handled_undisplayed_mime_types), \
					 None, '', ApplicationChooserModel ([]) ])
		self.file_type_renderer.set_property ('editable', True)
		self.set_cursor_on_cell (self.get_model ().get_path (iter), self.get_column (0), self.file_type_renderer, True)

	def remove_mime_type (self):
		(liststore, rows) = self.get_selection ().get_selected_rows ()

		references = []
		for row in rows:
			references.append (gtk.TreeRowReference (liststore, row))

		for reference in references:
			path = reference.get_path ()
			iter = liststore.get_iter (path)
			if iter != None:
				mime_type = liststore.get_value (iter, 0)
				liststore.remove (iter)
				self.handled_displayed_mime_types.remove (mime_type)
				self.handled_undisplayed_mime_types.append (mime_type)
				self.emit ("mime-type-removed", mime_type)
		

class DetailsDialog (gtk.Dialog):
	def __init__ (self, category):
		gtk.Dialog.__init__ (self)

		self.category = category

		self.ensure_style ()
		self.set_border_width (12)
		self.action_area.set_border_width (0)
		self.vbox.set_spacing (12)
		self.set_has_separator (0)

		self.set_title ('Details for \"%s\"' % category.name)

		self.connect("response", self.response)  

		self.add_button (gtk.STOCK_ADD, gtk.RESPONSE_OK)
		self.add_button (gtk.STOCK_REMOVE, gtk.RESPONSE_CANCEL)
		self.add_button (gtk.STOCK_CLOSE, gtk.RESPONSE_CLOSE)


		vbox = gtk.VBox ()
		vbox.set_border_width (0)
		vbox.set_spacing (18)
		vbox.show()
		self.vbox.add (vbox)

		label_text = category.exception_label % (category.all_applications[category.default_application_id][1])

		label = gtk.Label (label_text)
		label.set_alignment (0.0, 0.5)
		vbox.pack_start (label, False, False)
		label.show ()

		scrolled_window = gtk.ScrolledWindow ()
		scrolled_window.set_policy (gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
		scrolled_window.set_shadow_type (gtk.SHADOW_IN)
		scrolled_window.set_size_request (-1, 200)
		vbox.add (scrolled_window)
		scrolled_window.show ()

		self.details_view = DetailsView (category)
		scrolled_window.add (self.details_view)
		self.details_view.show ()
		self.details_view.connect ("mime-type-changed", self.details_view_mime_type_changed)
		self.details_view.connect ("mime-type-removed", self.details_view_mime_type_removed)
		self.details_view.get_selection().connect ("changed", self.details_view_selection_changed)

		self.update_response_sensitivity ()

	def response (self, dialog, response):
		if response == gtk.RESPONSE_OK:
			self.details_view.add_mime_type ()
		elif response == gtk.RESPONSE_CANCEL:
			self.details_view.remove_mime_type ()
		else: # FIXME handle destruction request differently?
			None

	def update_response_sensitivity (self):
		self.set_response_sensitive (gtk.RESPONSE_OK, len (self.details_view.handled_undisplayed_mime_types) > 0)
		self.set_response_sensitive (gtk.RESPONSE_CANCEL, len (self.details_view.handled_displayed_mime_types) > 0 and \
								  self.details_view.get_selection().count_selected_rows > 0)

	def details_view_mime_type_changed (self, details_view, mime_type):
		self.update_response_sensitivity ()

	def details_view_mime_type_removed (self, details_view, mime_type):
		self.update_response_sensitivity ()

	def details_view_selection_changed (self, selection):
		self.update_response_sensitivity ()


class ApplicationDialog (gtk.Dialog):
	def __init__ (self, categories):
		gtk.Dialog.__init__ (self)

		self.set_title ('Default Applications')

		self.ensure_style ()
		self.set_border_width (7)
		self.action_area.set_border_width (0)
		self.vbox.set_spacing (2)
		self.set_has_separator (0)

		self.add_button (gtk.STOCK_CLOSE, gtk.RESPONSE_CLOSE)

		table = gtk.Table (len (categories), 3, False)
		table.set_row_spacings (6)
		table.set_col_spacings (12)
		self.vbox.add (table)
		table.set_border_width (5)
		table.show ()

		self.categories = categories
		for category in self.categories:
			y = categories.index (category)

			label = gtk.Label ()
			label.set_text_with_mnemonic (category.combo_label)
			label.set_alignment (0.0, 0.5)
			table.attach (label, 0, 1, y, y+1, gtk.FILL, 0)
			label.show ()

			chooser = ApplicationChooser (category.all_applications.values(),
						      category.default_application_id)
			label.set_mnemonic_widget (chooser)
			table.attach (chooser, 1, 2, y, y+1, gtk.EXPAND|gtk.FILL, 0)
			chooser.show ()

			if len (category.mime_types) > 1:
				button = gtk.Button ('Details')
				table.attach (button, 2, 3, y, y+1, 0, 0)
				button.show ()
				button.connect("clicked", self.detailsClicked, category)  

#			expander = gtk.Expander ('Details')
#			table.attach (expander, 0, 2, 1, 2, gtk.EXPAND|gtk.FILL, gtk.EXPAND|gtk.FILL)
#			if len (category.applications.keys()) > 1:
#				expander.show ()
#			else:
#				expander.hide ()

#			exception_view = DetailsView (category)
#			expander.add (exception_view)
#			exception_view.show ()
		else:
			print "EWW no handler for " + str (category.name)

	def detailsClicked (self, button, category):
		print "Displaying details for " + str (category.name)

		details_dialog = DetailsDialog (category)
		details_dialog.set_transient_for (self)
		details_dialog.set_position (gtk.WIN_POS_CENTER_ON_PARENT)

		while 1:
			res = details_dialog.run ()
			if res == gtk.RESPONSE_OK or \
			   res == gtk.RESPONSE_CANCEL:
				continue
			break

		details_dialog.destroy ()

gobject.signal_new ("mime-type-changed", DetailsView,
		    gobject.SIGNAL_RUN_FIRST,
		    gobject.TYPE_NONE,
		    (gobject.TYPE_STRING,))
gobject.signal_new ("mime-type-removed", DetailsView,
		    gobject.SIGNAL_RUN_FIRST,
		    gobject.TYPE_NONE,
		    (gobject.TYPE_STRING,))


audio_category = MimeCategory ('Audio', '_Audio Player:', 'All audio files will be played with "%s" by default, except:', [ 'audio/x-wav', 'audio/x-vorbis+ogg', 'audio/x-flac+ogg', 'audio/x-speex+ogg', 'audio/mpeg' ], 'totem.desktop' )
video_category = MimeCategory ('Video', '_Video Player:', 'All video files will be played with "%s" by default, except:', [ 'video/mpeg', 'video/x-ms-wmv', 'video/x-msvideo', 'video/x-nsv', 'video/x-sgi-movie', 'video/wavelet', 'video/quicktime', 'video/isivideo', 'video/dv', 'audio/vnd.rn-realvideo', 'video/mp4', 'application/x-matroska', 'application/x-flash-video' ], 'totem.desktop' )
image_category = MimeCategory ('Image', '_Image Viewer:', 'All image files will be opened with "%s" by default, except:', [ 'image/vnd.rn-realpix', 'image/bmp', 'image/cgm', 'image/fax-g3', 'image/g3fax', 'image/gif', 'image/ief', 'image/jpeg', 'image/jpeg2000', 'image/x-pict', 'image/png', 'image/rle', 'image/svg+xml', 'image/tiff', 'image/vnd.dwg', 'image/vnd.dxf', 'image/x-3ds', 'image/x-applix-graphics', 'image/x-cmu-raster', 'image/x-compressed-xcf', 'image/x-dib', 'image/vnd.djvu', 'image/dpx', 'image/x-eps', 'image/x-fits', 'image/x-fpx', 'image/x-ico', 'image/x-iff', 'image/x-ilbm', 'image/x-jng', 'image/x-lwo', 'image/x-lws', 'image/x-msod', 'image/x-niff', 'image/x-pcx', 'image/x-photo-cd', 'image/x-portable-anymap', 'image/x-portable-bitmap', 'image/x-portable-graymap', 'image/x-portable-pixmap', 'image/x-psd', 'image/x-rgb', 'image/x-sgi', 'image/x-sun-raster', 'image/x-tga', 'image/x-win-bitmap', 'image/x-wmf', 'image/x-xbitmap', 'image/x-xcf', 'image/x-xfig', 'image/x-xpixmap', 'image/x-xwindowdump' ], 'eog.desktop')
text_editor_category = MimeCategory ('Text Editor', '_Text Editor:', '', [ 'text/plain' ], 'gedit.desktop' )
word_processing_category = MimeCategory ('Word Processing', '_Word Processor:', 'All text documents will be opened with "%s" by default, except:', [ 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.text-template','application/vnd.oasis.opendocument.text-master', 'application/vnd.sun.xml.writer', 'application/vnd.sun.xml.writer.template', 'application/vnd.sun.xml.writer.global', 'application/vnd.stardivision.writer', 'application/msword', 'application/rtf' ], 'abiword.desktop' )
spreadsheet_category = MimeCategory ('Spreadsheet', '_Spreadsheet:', 'All spreadsheet files will be opened with "%s", except:', [ 'application/vnd.ms-excel', 'application/x-gnumeric' ], 'gnumeric.desktop')

dialog = ApplicationDialog ([audio_category, video_category, image_category, text_editor_category, word_processing_category, spreadsheet_category])
dialog.present()
dialog.run()


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]