diff options
author | Jesús Eduardo <heckyel@hyperbola.info> | 2017-09-11 17:47:17 -0500 |
---|---|---|
committer | Jesús Eduardo <heckyel@hyperbola.info> | 2017-09-11 17:47:17 -0500 |
commit | 14738704ede6dfa6ac79f362a9c1f7f40f470cdc (patch) | |
tree | 31c83bdd188ae7b64d7169974d6f066ccfe95367 /lvc | |
parent | eb1896583afbbb622cadcde1a24e17173f61904f (diff) | |
download | librevideoconverter-14738704ede6dfa6ac79f362a9c1f7f40f470cdc.tar.lz librevideoconverter-14738704ede6dfa6ac79f362a9c1f7f40f470cdc.tar.xz librevideoconverter-14738704ede6dfa6ac79f362a9c1f7f40f470cdc.zip |
rename mvc at lvc
Diffstat (limited to 'lvc')
126 files changed, 20664 insertions, 0 deletions
diff --git a/lvc/__init__.py b/lvc/__init__.py new file mode 100644 index 0000000..1cc637e --- /dev/null +++ b/lvc/__init__.py @@ -0,0 +1,37 @@ +import os + +import multiprocessing +from lvc import converter +from lvc import conversion +from lvc import signals +from lvc import video + +VERSION = '1.0.1' + +class Application(signals.SignalEmitter): + + def __init__(self, simultaneous=None): + signals.SignalEmitter.__init__(self) + if simultaneous is None: + try: + simultaneous = multiprocessing.cpu_count() + except NotImplementedError: + pass + self.converter_manager = converter.ConverterManager() + self.conversion_manager = conversion.ConversionManager(simultaneous) + self.started = False + + def startup(self): + if self.started: + return + self.converter_manager.startup() + self.started = True + + def start_conversion(self, filename, converter_id): + self.startup() + converter = self.converter_manager.get_by_id(converter_id) + v = video.VideoFile(filename) + return self.conversion_manager.start_conversion(v, converter) + + def run(self): + raise NotImplementedError diff --git a/lvc/__main__.py b/lvc/__main__.py new file mode 100644 index 0000000..226f178 --- /dev/null +++ b/lvc/__main__.py @@ -0,0 +1,9 @@ +if __name__ == "__main__": + try: + from lvc.ui.widgets import Application + except ImportError: + from lvc.ui.console import Application + from lvc.widgets import app + from lvc.widgets import initialize + app.widgetapp = Application() + initialize(app.widgetapp) diff --git a/lvc/basicconverters.py b/lvc/basicconverters.py new file mode 100644 index 0000000..ddf99ec --- /dev/null +++ b/lvc/basicconverters.py @@ -0,0 +1,149 @@ +import logging +import re + +from lvc import converter + +class WebM_UHD(converter.FFmpegConverterInfo1080p): + media_type = 'format' + extension = 'webm' + parameters = ('-f webm -vcodec libvpx -g 120 -lag-in-frames 23 ' + '-deadline good -cpu-used 0 -vprofile 0 -qmax 51 -qmin 11 ' + '-slices 4 -b:v 4M -acodec libvorbis -ab 128k -map_metadata -1 ' + '-ar 44100') + +class WebM_HD(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'webm' + parameters = ('-f webm -vcodec libvpx -g 120 -lag-in-frames 16 ' + '-deadline good -cpu-used 0 -vprofile 0 -qmax 51 -qmin 11 ' + '-slices 4 -b:v 2M -acodec libvorbis -ab 112k -map_metadata -1 ' + '-ar 44100') + +class WebM_SD(converter.FFmpegConverterInfo480p): + media_type = 'format' + extension = 'webm' + parameters = ('-f webm -vcodec libvpx -g 120 -lag-in-frames 16 ' + '-deadline good -cpu-used 0 -vprofile 0 -qmax 53 -qmin 0 ' + '-b:v 768k -acodec libvorbis -ab 112k -map_metadata -1 ' + '-ar 44100') + +class WebM_VP9(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'webm' + parameters = ('-f webm -vcodec libvpx-vp9 -g 240 -threads 8 ' + '-quality good -crf 32 ' + '-b:v 0 -acodec libopus -map_metadata -1') + +class MP4(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'mp4' + parameters = ('-acodec aac -ab 96k -vcodec libx264 -preset slow -map_metadata -1' + '-f mp4 -crf 22') + +class MP3(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'mp3' + parameters = '-f mp3 -ac 2' + audio_only = True + +class OggVorbis(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'ogg' + parameters = '-f ogg -vn -acodec libvorbis -aq 60' + audio_only = True + +class OggTheora(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'ogv' + parameters = '-f ogg -codec:v libtheora -qscale:v 7 -codec:a libvorbis -qscale:a 5 -map_metadata -1' + +class DNxHD_1080(converter.FFmpegConverterInfo1080p): + media_type = 'format' + extension = 'mov' + parameters = ('-r 23.976 -f mov -vcodec dnxhd -b:v ' + '175M -acodec pcm_s16be -ar 48000') + +class DNxHD_720(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'mov' + parameters = ('-r 23.976 -f mov -vcodec dnxhd -b:v ' + '175M -acodec pcm_s16be -ar 48000') + +class PRORES_720(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec prores -profile 2 ' + '-acodec pcm_s16be -ar 48000') + +class PRORES_1080(converter.FFmpegConverterInfo1080p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec prores -profile 2 ' + '-acodec pcm_s16be -ar 48000') + +class AVC_INTRA_1080(converter.FFmpegConverterInfo1080p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec libx264 -pix_fmt yuv422p ' + '-crf 0 -intra -b:v 100M -acodec pcm_s16be -ar 48000') + +class AVC_INTRA_720(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec libx264 -pix_fmt yuv422p ' + '-crf 0 -intra -b:v 100M -acodec pcm_s16be -ar 48000') + +class NullConverter(converter.FFmpegConverterInfo): + media_type = 'format' + extension = None + + def get_parameters(self, video): + params = [] + if not video.audio_only and self.should_copy_video_size(video): + # -vcodec copy copies the video data exactly. Only use it if the + # output video is the same size as the input video (#19664) + params.extend(['-vcodec', 'copy']) + params.extend(['-acodec', 'copy']) + return params + + def should_copy_video_size(self, video): + if self.width is None or self.height is None: + return True + return (video.width == self.width and video.height == self.height) + + def get_extra_arguments(self, video, output): + if not video.container: + logging.warn("sameformat: video.container is None. Using mp4") + container = 'mp4' + elif isinstance(video.container, list): + # XXX: special case mov,mp4,m4a,3gp,3g2,mj2 + container = 'mp4' + else: + container = video.container + return ['-f', container] + +mp3 = MP3('MP3') +ogg_vorbis = OggVorbis('Ogg Vorbis') +audio_formats = ('Audio', [mp3, ogg_vorbis]) + +webm_uhd = WebM_UHD('WebM UHD') +webm_hd = WebM_HD('WebM HD') +webm_sd = WebM_SD('WebM SD') +webm_vp9 = WebM_VP9('WebM VP9') +mp4 = MP4('MP4') +theora = OggTheora('Ogg Theora') + +video_formats = ('Video', [webm_uhd, webm_hd, webm_sd, webm_vp9, mp4, theora]) + +dnxhd_1080 = DNxHD_1080('DNxHD 1080p') +dnxhd_720 = DNxHD_720('DNxHD 720p') +prores_1080 = PRORES_1080('Prores Ingest 1080p') +prores_720 = PRORES_720('Prores Ingest 720p') +avc_intra_1080 = PRORES_1080('AVC Intra 1080p') +avc_intra_720 = PRORES_720('AVC Intra 720p') + +ingest_formats = ('Ingest Formats', [dnxhd_1080, dnxhd_720, prores_1080, + prores_720, avc_intra_1080, avc_intra_720]) +null_converter = NullConverter('Same Format') + +converters = [video_formats, audio_formats, ingest_formats, null_converter] diff --git a/lvc/conversion.py b/lvc/conversion.py new file mode 100644 index 0000000..6d6f693 --- /dev/null +++ b/lvc/conversion.py @@ -0,0 +1,313 @@ +import collections +import errno +import os +import time +import tempfile +import threading +import shutil +import logging + +from lvc import execute +from lvc.utils import line_reader +from lvc.video import get_thumbnail_synchronous +from lvc.widgets import get_conversion_directory + +logger = logging.getLogger(__name__) + +class Conversion(object): + def __init__(self, video, converter, manager, output_dir=None): + self.video = video + self.manager = manager + if output_dir is None: + output_dir = get_conversion_directory() + self.output_dir = output_dir + self.lines = [] + self.thread = None + self.popen = None + self.status = 'initialized' + self.temp_output = None + self.error = None + self.started_at = None + self.duration = None + self.progress = None + self.progress_percent = None + self.create_thumbnail = False + self.eta = None + self.listeners = set() + self.set_converter(converter) + logger.info('created %r', self) + + def set_converter(self, converter): + if self.status != 'initialized': + raise RuntimeError("can't change converter after starting") + self.converter = converter + self.output = os.path.join(self.output_dir, + converter.get_output_filename(self.video)) + + def __repr__(self): + return unicode(self) + + def __str__(self): + return unicode(self).encode('utf8') + + def __unicode__(self): + return u'<Conversion (%s) %r -> %r>' % ( + self.converter.name, self.video.filename, self.output) + + def listen(self, f): + self.listeners.add(f) + + def unlisten(self, f): + self.listeners.remove(f) + + def notify_listeners(self): + self.manager.notify_queue.add(self) + + def run(self): + logger.info('starting %r', self) + try: + self.temp_output = tempfile.mktemp( + dir=os.path.dirname(self.output)) + except EnvironmentError,e : + logger.exception('while creating temp file for %r', + self.output) + self.error = str(e) + self.finalize() + return + logger.info('commandline: %r', ' '.join( + self.get_subprocess_arguments(self.temp_output))) + self.thread = threading.Thread(target=self._thread, + name="Thread:%s" % (self,)) + self.thread.setDaemon(True) + self.thread.start() + + def stop(self): + logger.info('stopping %r', self) + self.error = 'manually stopped' + if self.popen is None: + status = 'canceled' + try: + self.manager.remove(self) + except ValueError: + status = 'failed' + logger.exception('not running and not waiting %s' % (self,)) + self.status = status + return + else: + try: + self.popen.kill() + self.popen.wait() + # set the status transition last, if we had hit an exception + # then we will transition the next state to 'failed' in + # finalize() + self.status = 'canceled' + except EnvironmentError, e: + logger.exception('while stopping %s' % (self,)) + self.error = str(e) + self.popen = None + self.manager.conversion_finished(self) + + def _thread(self): + try: + commandline = self.get_subprocess_arguments(self.temp_output) + self.popen = execute.Popen(commandline, bufsize=1) + self.process_output() + if self.popen: + # if we stop the thread, we can get here after `.stop()` + # finishes. + self.popen.wait() + except OSError, e: + if e.errno == errno.ENOENT: + self.error = '%r does not exist' % ( + self.converter.get_executable(),) + else: + logger.exception('OSError in %s' % (self.thread.name,)) + self.error = str(e) + except Exception, e: + logger.exception('in %s' % (self.thread.name,)) + self.error = str(e) + + if self.create_thumbnail: + self.write_thumbnail_file() + self.finalize() + + def write_thumbnail_file(self): + try: + self._write_thumbnail_file() + except StandardError: + logging.warn("Error writing thumbnail", exc_info=True) + + def _write_thumbnail_file(self): + if self.video.audio_only: + logging.warning("write_thumbnail_file: audio_only=True " + "not writing thumbnail %s", self.video.filename) + return + output_basename = os.path.splitext(os.path.basename(self.output))[0] + logging.info("td: %s ob: %s", self._get_thumbnail_dir(), + output_basename) + thumbnail_path = os.path.join(self._get_thumbnail_dir(), + output_basename + '.png') + logging.info("creating thumbnail: %s", thumbnail_path) + width, height = self.converter.get_target_size(self.video) + get_thumbnail_synchronous(self.video.filename, width, height, + thumbnail_path) + if os.path.exists(thumbnail_path): + logging.info("thumbnail successful: %s", thumbnail_path) + else: + logging.warning("get_thumbnail_synchronous() succeeded, but the " + "thumbnail file is missing!") + + def _get_thumbnail_dir(self): + """Get the directory to store thumbnails in it. + + This method will create the directory if it doesn't exist + """ + thumbnail_dir = os.path.join(self.output_dir, 'thumbnails') + if not os.path.exists(thumbnail_dir): + os.mkdir(thumbnail_dir) + return thumbnail_dir + + def calc_progress_percent(self): + if not self.duration: + return 0.0 + + if self.create_thumbnail: + # assume that thumbnail creation takes as long as 2 seconds of + # video processing + effective_duration = self.duration + 2.0 + else: + effective_duration = self.duration + return self.progress / effective_duration + + def process_output(self): + self.started_at = time.time() + self.status = 'converting' + # We use line_reader, rather than just iterating over the file object, + # because iterating over the file object gives us all the lines when + # the process ends, and we're looking for real-time updates. + for line in line_reader(self.popen.stdout): + self.lines.append(line) # for debugging, if needed + try: + status = self.converter.process_status_line(self.video, line) + except StandardError: + logging.warn("error in process_status_line()", exc_info=True) + continue + if status is None: + continue + updated = set() + if 'finished' in status: + self.error = status.get('error', None) + break + if 'duration' in status: + updated.update(('duration', 'progress')) + self.duration = float(status['duration']) + if self.progress is None: + self.progress = 0.0 + if 'progress' in status: + updated.add('progress') + self.progress = min(float(status['progress']), + self.duration) + if 'eta' in status: + updated.add('eta') + self.eta = float(status['eta']) + + if updated: + self.progress_percent = self.calc_progress_percent() + if 'eta' not in updated: + if self.duration and 0 < self.progress_percent < 1.0: + progress = self.progress_percent * 100 + elapsed = time.time() - self.started_at + time_per_percent = elapsed / progress + self.eta = float( + time_per_percent * (100 - progress)) + else: + self.eta = 0.0 + + self.notify_listeners() + + def finalize(self): + self.progress = self.duration + self.progress_percent = 1.0 + self.eta = 0 + if self.error is None: + self.status = 'staging' + self.notify_listeners() + try: + self.converter.finalize(self.temp_output, self.output) + except EnvironmentError, e: + logger.exception('while trying to move %r to %r after %s', + self.temp_output, self.output, self) + self.error = str(e) + self.status = 'failed' + else: + self.status = 'finished' + else: + if self.temp_output is not None: + try: + os.unlink(self.temp_output) + except EnvironmentError: + pass # ignore errors removing temp files; they may not have + # been created + if self.status != 'canceled': + self.status = 'failed' + if self.status != 'canceled': + self.notify_listeners() + logger.info('finished %r; status: %s', self, self.status) + + def get_subprocess_arguments(self, output): + return ([self.converter.get_executable()] + + list(self.converter.get_arguments(self.video, output))) + +class ConversionManager(object): + def __init__(self, simultaneous=None): + self.notify_queue = set() + self.in_progress = set() + self.waiting = collections.deque() + self.simultaneous = simultaneous + self.running = False + self.create_thumbnails = False + + def get_conversion(self, video, converter, **kwargs): + return Conversion(video, converter, self, **kwargs) + + def remove(self, conversion): + self.waiting.remove(conversion) + + def start_conversion(self, video, converter): + return self.run_conversion(self.get_conversion(video, converter)) + + def run_conversion(self, conversion): + if (self.simultaneous is not None and + len(self.in_progress) >= self.simultaneous): + self.waiting.append(conversion) + else: + self._start_conversion(conversion) + self.running = True + return conversion + + def _start_conversion(self, conversion): + self.in_progress.add(conversion) + conversion.create_thumbnail = self.create_thumbnails + conversion.run() + + def check_notifications(self): + if not self.running: + # don't bother checking if we're not running + return + + self.notify_queue, changed = set(), self.notify_queue + + for conversion in changed: + if conversion.status in ('canceled', 'finished', 'failed'): + self.conversion_finished(conversion) + for listener in conversion.listeners: + listener(conversion) + + def conversion_finished(self, conversion): + self.in_progress.discard(conversion) + while (self.waiting and self.simultaneous is not None and + len(self.in_progress) < self.simultaneous): + c = self.waiting.popleft() + self._start_conversion(c) + if not self.in_progress: + self.running = False diff --git a/lvc/converter.py b/lvc/converter.py new file mode 100644 index 0000000..ed4e16c --- /dev/null +++ b/lvc/converter.py @@ -0,0 +1,278 @@ +import json +import logging +import os +import re +import shutil + +from lvc import resources, settings, utils +from lvc.utils import hms_to_seconds + +from lvc.qtfaststart import processor +from lvc.qtfaststart.exceptions import FastStartException + +logger = logging.getLogger(__name__) + +NON_WORD_CHARS = re.compile(r"[^a-zA-Z0-9]+") + +class ConverterInfo(object): + """Describes a particular output converter + + ConverterInfo is the base class for all converters. Subclasses must + implement get_executable() and get_arguments() + + :attribue name: user-friendly name for this converter + :attribute identifier: unique id for this converter + :attribute width: output width for this converter, or None to copy the + input width. This attribute is set to a default on construction, but can + be changed to reflect the user overriding the default. + :attribute height: output height for this converter. Works just like + width + :attribute dont_upsize: should we allow upsizing for conversions? + """ + media_type = None + bitrate = None + extension = None + audio_only = False + + def __init__(self, name, width=None, height=None, dont_upsize=True): + self.name = name + self.identifier = NON_WORD_CHARS.sub("", name).lower() + self.width = width + self.height = height + self.dont_upsize = dont_upsize + + def get_executable(self): + raise NotImplementedError + + def get_arguments(self, video, output): + raise NotImplementedError + + def get_output_filename(self, video): + basename = os.path.basename(video.filename) + name, ext = os.path.splitext(basename) + if ext and ext[0] == '.': + ext = ext[1:] + extension = self.extension if self.extension else ext + return '%s.%s.%s' % (name, self.identifier, extension) + + def get_output_size_guess(self, video): + if not self.bitrate or not video.duration: + return None + if video.duration: + return self.bitrate * video.duration / 8 + + def finalize(self, temp_output, output): + err = None + needs_remove = False + if self.media_type == 'format' and self.extension == 'mp4': + needs_remove = True + logging.debug('generic mp4 format detected. ' + 'Running qtfaststart...') + try: + processor.process(temp_output, output) + except FastStartException: + logging.exception('qtfaststart: exception occurred') + err = EnvironmentError('qtfaststart exception') + else: + try: + shutil.move(temp_output, output) + except EnvironmentError, e: + needs_remove = True + err = e + # If it didn't work for some reason try to clean up the stale stuff. + # And if that doesn't work ... just log, and re-raise the original + # error. + if needs_remove: + try: + os.remove(temp_output) + except EnvironmentError, e: + logging.error('finalize(): cannot remove stale file %r', + temp_output) + if err: + logging.error('finalize(): removal was in response to ' + 'error: %s', str(err)) + raise err + + def get_target_size(self, video): + """Get the size that we will convert to for a given video. + + :returns: (width, height) tuple + """ + return utils.rescale_video((video.width, video.height), + (self.width, self.height), + dont_upsize=self.dont_upsize) + + def process_status_line(self, line): + raise NotImplementedError + +class FFmpegConverterInfo(ConverterInfo): + """Base class for all ffmpeg-based conversions. + + Subclasses must override the parameters attribute and supply it with the + ffmpeg command line for the conversion. parameters can either be a list + of arguments, or a string in which case split() will be called to create + the list. + """ + DURATION_RE = re.compile(r'\W*Duration: (\d\d):(\d\d):(\d\d)\.(\d\d)' + '(, start:.*)?(, bitrate:.*)?') + PROGRESS_RE = re.compile(r'(?:frame=.* fps=.* q=.* )?size=.* time=(.*) ' + 'bitrate=(.*)') + LAST_PROGRESS_RE = re.compile(r'frame=.* fps=.* q=.* Lsize=.* time=(.*) ' + 'bitrate=(.*)') + + extension = None + parameters = None + + def get_executable(self): + return settings.get_ffmpeg_executable_path() + + def get_arguments(self, video, output): + args = ['-i', utils.convert_path_for_subprocess(video.filename), + '-strict', 'experimental'] + args.extend(settings.customize_ffmpeg_parameters( + self.get_parameters(video))) + if not (self.audio_only or video.audio_only): + width, height = self.get_target_size(video) + args.append("-s") + args.append('%ix%i' % (width, height)) + args.extend(self.get_extra_arguments(video, output)) + args.append(self.convert_output_path(output)) + return args + + def convert_output_path(self, output_path): + """Convert our output path so that it can be passed to ffmpeg.""" + # this is a bit tricky, because output_path doesn't exist on windows + # yet, so we can't just call convert_path_for_subprocess(). Instead, + # call convert_path_for_subprocess() on the output directory, and + # assume that the filename only contains safe characters + output_dir = os.path.dirname(output_path) + output_filename = os.path.basename(output_path) + return os.path.join(utils.convert_path_for_subprocess(output_dir), + output_filename) + + def get_extra_arguments(self, video, output): + """Subclasses can override this to add argumenst to the ffmpeg command + line. + """ + return [] + + def get_parameters(self, video): + if self.parameters is None: + raise ValueError("%s: parameters is None" % self) + elif isinstance(self.parameters, basestring): + return self.parameters.split() + else: + return list(self.parameters) + + @staticmethod + def _check_for_errors(line): + if line.startswith('Unknown'): + return line + if line.startswith("Error"): + if not line.startswith("Error while decoding stream"): + return line + + @classmethod + def process_status_line(klass, video, line): + error = klass._check_for_errors(line) + if error: + return {'finished': True, 'error': error} + + match = klass.DURATION_RE.match(line) + if match is not None: + hours, minutes, seconds, centi = [ + int(m) for m in match.groups()[:4]] + return {'duration': hms_to_seconds(hours, minutes, + seconds + 0.01 * centi)} + + match = klass.PROGRESS_RE.match(line) + if match is not None: + t = match.group(1) + if ':' in t: + hours, minutes, seconds = [float(m) for m in t.split(':')[:3]] + return {'progress': hms_to_seconds(hours, minutes, seconds)} + else: + return {'progress': float(t)} + + match = klass.LAST_PROGRESS_RE.match(line) + if match is not None: + return {'finished': True} + +class FFmpegConverterInfo1080p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 1920, 1080) + +class FFmpegConverterInfo720p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 1080, 720) + +class FFmpegConverterInfo480p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 720, 480) + +class ConverterManager(object): + def __init__(self): + self.converters = {} + # converter -> brand reverse map. XXX: this code, really, really sucks + # and not very scalable. + self.brand_rmap = {} + self.brand_map = {} + + def add_converter(self, converter): + self.converters[converter.identifier] = converter + + def startup(self): + self.load_simple_converters() + self.load_converters(resources.converter_scripts()) + + def brand_to_converters(self, brand): + try: + return self.brand_map[brand] + except KeyError: + return None + + def converter_to_brand(self, converter): + try: + return self.brand_rmap[converter] + except KeyError: + return None + + def load_simple_converters(self): + from lvc import basicconverters + for converter in basicconverters.converters: + if isinstance(converter, tuple): + brand, realconverters = converter + for realconverter in realconverters: + self.brand_rmap[realconverter] = brand + self.brand_map.setdefault(brand, []).append(realconverter) + self.add_converter(realconverter) + else: + self.brand_rmap[converter] = None + self.brand_map.setdefault(None, []).append(converter) + self.add_converter(converter) + + def load_converters(self, converters): + for converter_file in converters: + global_dict = {} + execfile(converter_file, global_dict) + if 'converters' in global_dict: + for converter in global_dict['converters']: + if isinstance(converter, tuple): + brand, realconverters = converter + for realconverter in realconverters: + self.brand_rmap[realconverter] = brand + self.brand_map.setdefault(brand, []).append(realconverter) + self.add_converter(realconverter) + else: + self.brand_rmap[converter] = None + self.brand_map.setdefault(None, []).append(converter) + self.add_converter(converter) + logger.info('load_converters: loaded %i from %r', + len(global_dict['converters']), + converter_file) + + def list_converters(self): + return self.converters.values() + + def get_by_id(self, id_): + return self.converters[id_] diff --git a/lvc/errors.py b/lvc/errors.py new file mode 100644 index 0000000..504948b --- /dev/null +++ b/lvc/errors.py @@ -0,0 +1,89 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""``miro.errors`` -- Miro exceptions. +""" + +class ActionUnavailableError(ValueError): + """The action attempted can not be done in the current state.""" + def __init__(self, reason): + self.reason = reason + +class WidgetActionError(ActionUnavailableError): + """The widget is not in the right state to perform the requested action. + This usually is not serious, but if not handled the UI will likely be in an + incorrect state. + """ + +class WidgetDomainError(WidgetActionError): + """The widget element requested is not available at this time. This may be a + temporary condition or a result of permanent changes. + """ + def __init__(self, domain, needle, haystack, details=None): + self.domain = domain + self.needle = needle + self.haystack = haystack + self.details = details + + @property + def reason(self): + reason = "looked for {0} in {2}, but found only {1}".format( + repr(self.needle), repr(self.haystack), self.domain) + if self.details: + reason += ": " + self.details + return reason + +class WidgetRangeError(WidgetDomainError): + """Class to handle neat display of ranges in WidgetDomainErrors. Handlers + should generally catch a parent of this. + """ + def __init__(self, domain, needle, start_range, end_range, details=None): + haystack = "{0} to {1}".format(repr(start_range), repr(end_range)) + WidgetDomainError.__init__(self, domain, needle, haystack, details) + +class WidgetNotReadyError(WidgetActionError): + """The widget is not ready to perfom the action given; this must be a + temporary condition that will be resolved when the widget finishes setting + up. + """ + def __init__(self, waiting_for): + self.waiting_for = waiting_for + + @property + def reason(self): + return "waiting for {0}".format(self.waiting_for) + +class UnexpectedWidgetError(ActionUnavailableError): + """The Spanish Inquisition of widget errors. A widget was asked to do + something, had every reason to do so, yet refused. This should always cause + at least a soft_failure; the UI is now in an incorrect state. + """ + +class WidgetUsageError(UnexpectedWidgetError): + """A widget error that is likely the result of incorrect widget usage.""" diff --git a/lvc/execute.py b/lvc/execute.py new file mode 100644 index 0000000..af6f463 --- /dev/null +++ b/lvc/execute.py @@ -0,0 +1,49 @@ +"""execute.py -- Run executable programs. + +lvc.execute wraps the standard subprocess module in for MVC. +""" + +import os +import subprocess +import sys + +CalledProcessError = subprocess.CalledProcessError + +def default_popen_args(): + retval = { + 'stdin': open(os.devnull, 'rb'), + 'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, + } + if sys.platform == 'win32': + retval['startupinfo'] = subprocess.STARTUPINFO() + retval['startupinfo'].dwFlags |= subprocess.STARTF_USESHOWWINDOW + return retval + +class Popen(subprocess.Popen): + """subprocess.Popen subclass that adds MVC default behavior. + + By default we: + - Use a /dev/null equivilent for stdin + - Use a pipe for stdout + - Redirect stderr to stdout + - use STARTF_USESHOWWINDOW to not open a console window on win32 + + These are just defaults though, they can be overriden by passing different + values to the constructor + """ + def __init__(self, commandline, **kwargs): + final_args = default_popen_args() + final_args.update(kwargs) + subprocess.Popen.__init__(self, commandline, **final_args) + +def check_output(commandline, **kwargs): + """MVC version of subprocess.check_output. + + This performs the same default behavior as the Popen class. + """ + final_args = default_popen_args() + # check_output doesn't use stdout + del final_args['stdout'] + final_args.update(kwargs) + return subprocess.check_output(commandline, **final_args) diff --git a/lvc/openfiles.py b/lvc/openfiles.py new file mode 100644 index 0000000..ef6710a --- /dev/null +++ b/lvc/openfiles.py @@ -0,0 +1,46 @@ +"""openfiles.py -- open files/folders.""" + +import logging +import os +import subprocess +import sys + + +# To open paths we use an OS-specific command. The approach is from: +# http://stackoverflow.com/questions/6631299/python-opening-a-folder-in-explorer-nautilus-mac-thingie + +def check_kde(): + return os.environ.get("KDE_FULL_SESSION", None) != None + +def _open_path_osx(path): + subprocess.call(['open', '--', path]) + +def _open_path_kde(path): + subprocess.call(["kfmclient", "exec", "file://" + path]) # kfmclient is part of konqueror + +def _open_path_gnome(path): + subprocess.call(["gnome-open",'--'. path]) + +def _open_path_windows(path): + subprocess.call(['explorer', path]) + +def _open_path(path): + if sys.platform == 'darwin': + _open_path_osx(path) + elif sys.platform == 'linux2': + if check_kde(): + _open_path_kde(path) + else: + _open_path_gnome(path) + elif sys.platform == 'win32': + _open_path_windows(path) + else: + logging.warn("unknown platform: %s", sys.platform) + +def reveal_folder(path): + """Show a folder in the desktop shell (finder/explorer/nautilous, etc).""" + logging.info("reveal_folder: %s", path) + if os.path.isdir(path): + _open_path(path) + else: + _open_path(os.path.dirname(path)) diff --git a/lvc/osx/__init__.py b/lvc/osx/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lvc/osx/__init__.py diff --git a/lvc/osx/app_main.py b/lvc/osx/app_main.py new file mode 100644 index 0000000..487253d --- /dev/null +++ b/lvc/osx/app_main.py @@ -0,0 +1,12 @@ +import os +import sys + +from lvc.osx import autoupdate +from lvc.widgets import app +from lvc.widgets import initialize +from lvc.ui.widgets import Application + +# run the app +autoupdate.initialize() +app.widgetapp = Application() +initialize(app.widgetapp) diff --git a/lvc/osx/autoupdate.py b/lvc/osx/autoupdate.py new file mode 100644 index 0000000..7b17d47 --- /dev/null +++ b/lvc/osx/autoupdate.py @@ -0,0 +1,9 @@ +from Foundation import * + +def load_sparkle_framework(): + bundlePath = '%s/Sparkle.framework' % Foundation.NSBundle.mainBundle().privateFrameworksPath() + objc.loadBundle('Sparkle', globals(), bundle_path=bundlePath) + +def initialize(): + load_sparkle_framework() + SUUpdater.sharedUpdater().setAutomaticallyChecksForUpdates_(YES) diff --git a/lvc/qtfaststart/__init__.py b/lvc/qtfaststart/__init__.py new file mode 100644 index 0000000..f985b5c --- /dev/null +++ b/lvc/qtfaststart/__init__.py @@ -0,0 +1 @@ +VERSION = "1.6" diff --git a/lvc/qtfaststart/exceptions.py b/lvc/qtfaststart/exceptions.py new file mode 100644 index 0000000..f0767e1 --- /dev/null +++ b/lvc/qtfaststart/exceptions.py @@ -0,0 +1,5 @@ +class FastStartException(Exception): + """ + Raised when something bad happens during processing. + """ + pass
\ No newline at end of file diff --git a/lvc/qtfaststart/processor.py b/lvc/qtfaststart/processor.py new file mode 100755 index 0000000..d0ed003 --- /dev/null +++ b/lvc/qtfaststart/processor.py @@ -0,0 +1,215 @@ +""" + The guts that actually do the work. This is available here for the + 'qtfaststart' script and for your application's direct use. +""" + +import logging +import os +import struct + +#from StringIO import StringIO +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +from lvc.qtfaststart.exceptions import FastStartException + +CHUNK_SIZE = 8192 + +log = logging.getLogger("qtfaststart") + +# Older versions of Python require this to be defined +if not hasattr(os, 'SEEK_CUR'): + os.SEEK_CUR = 1 + +def read_atom(datastream): + """ + Read an atom and return a tuple of (size, type) where size is the size + in bytes (including the 8 bytes already read) and type is a "fourcc" + like "ftyp" or "moov". + """ + return struct.unpack(">L4s", datastream.read(8)) + + +def get_index(datastream): + """ + Return an index of top level atoms, their absolute byte-position in the + file and their size in a list: + + index = [ + ("ftyp", 0, 24), + ("moov", 25, 2658), + ("free", 2683, 8), + ... + ] + + The tuple elements will be in the order that they appear in the file. + """ + index = [] + + log.debug("Getting index of top level atoms...") + + # Read atoms until we catch an error + while(datastream): + try: + skip = 8 + atom_size, atom_type = read_atom(datastream) + if atom_size == 1: + atom_size = struct.unpack(">Q", datastream.read(8))[0] + skip = 16 + log.debug("%s: %s" % (atom_type, atom_size)) + except: + break + + index.append((atom_type, datastream.tell() - skip, atom_size)) + + if atom_size == 0: + # Some files may end in mdat with no size set, which generally + # means to seek to the end of the file. We can just stop indexing + # as no more entries will be found! + break + + datastream.seek(atom_size - skip, os.SEEK_CUR) + + # Make sure the atoms we need exist + top_level_atoms = set([item[0] for item in index]) + for key in ["moov", "mdat"]: + if key not in top_level_atoms: + log.error("%s atom not found, is this a valid MOV/MP4 file?" % key) + raise FastStartException() + + return index + + +def find_atoms(size, datastream): + """ + This function is a generator that will yield either "stco" or "co64" + when either atom is found. datastream can be assumed to be 8 bytes + into the stco or co64 atom when the value is yielded. + + It is assumed that datastream will be at the end of the atom after + the value has been yielded and processed. + + size is the number of bytes to the end of the atom in the datastream. + """ + stop = datastream.tell() + size + + while datastream.tell() < stop: + try: + atom_size, atom_type = read_atom(datastream) + except: + log.exception("Error reading next atom!") + raise FastStartException() + + if atom_type in ["trak", "mdia", "minf", "stbl"]: + # Known ancestor atom of stco or co64, search within it! + for atype in find_atoms(atom_size - 8, datastream): + yield atype + elif atom_type in ["stco", "co64"]: + yield atom_type + else: + # Ignore this atom, seek to the end of it. + datastream.seek(atom_size - 8, os.SEEK_CUR) + + +def process(infilename, outfilename, limit=0): + """ + Convert a Quicktime/MP4 file for streaming by moving the metadata to + the front of the file. This method writes a new file. + + If limit is set to something other than zero it will be used as the + number of bytes to write of the atoms following the moov atom. This + is very useful to create a small sample of a file with full headers, + which can then be used in bug reports and such. + """ + datastream = open(infilename, "rb") + + # Get the top level atom index + index = get_index(datastream) + + mdat_pos = 999999 + free_size = 0 + + # Make sure moov occurs AFTER mdat, otherwise no need to run! + for atom, pos, size in index: + # The atoms are guaranteed to exist from get_index above! + if atom == "moov": + moov_pos = pos + moov_size = size + elif atom == "mdat": + mdat_pos = pos + elif atom == "free" and pos < mdat_pos: + # This free atom is before the mdat! + free_size += size + log.info("Removing free atom at %d (%d bytes)" % (pos, size)) + + # Offset to shift positions + offset = moov_size - free_size + + if moov_pos < mdat_pos: + # moov appears to be in the proper place, don't shift by moov size + offset -= moov_size + if not free_size: + # No free atoms and moov is correct, we are done! + log.error("This file appears to already be setup for streaming!") + raise FastStartException() + + # Read and fix moov + datastream.seek(moov_pos) + moov = StringIO(datastream.read(moov_size)) + + # Ignore moov identifier and size, start reading children + moov.seek(8) + + for atom_type in find_atoms(moov_size - 8, moov): + # Read either 32-bit or 64-bit offsets + ctype, csize = atom_type == "stco" and ("L", 4) or ("Q", 8) + + # Get number of entries + version, entry_count = struct.unpack(">2L", moov.read(8)) + + log.info("Patching %s with %d entries" % (atom_type, entry_count)) + + # Read entries + entries = struct.unpack(">" + ctype * entry_count, + moov.read(csize * entry_count)) + + # Patch and write entries + moov.seek(-csize * entry_count, os.SEEK_CUR) + moov.write(struct.pack(">" + ctype * entry_count, + *[entry + offset for entry in entries])) + + log.info("Writing output...") + outfile = open(outfilename, "wb") + + # Write ftype + for atom, pos, size in index: + if atom == "ftyp": + datastream.seek(pos) + outfile.write(datastream.read(size)) + + # Write moov + moov.seek(0) + outfile.write(moov.read()) + + # Write the rest + written = 0 + atoms = [item for item in index if item[0] not in ["ftyp", "moov", "free"]] + for atom, pos, size in atoms: + datastream.seek(pos) + + # Write in chunks to not use too much memory + for x in range(size / CHUNK_SIZE): + outfile.write(datastream.read(CHUNK_SIZE)) + written += CHUNK_SIZE + if limit and written >= limit: + # A limit was set and we've just passed it, stop writing! + break + + if size % CHUNK_SIZE: + outfile.write(datastream.read(size % CHUNK_SIZE)) + written += (size % CHUNK_SIZE) + if limit and written >= limit: + # A limit was set and we've just passed it, stop writing! + break diff --git a/lvc/resources/__init__.py b/lvc/resources/__init__.py new file mode 100644 index 0000000..005041d --- /dev/null +++ b/lvc/resources/__init__.py @@ -0,0 +1,21 @@ +import os.path +import glob +import sys + +def image_path(name): + return os.path.join(resources_dir(), 'images', name) + +def converter_scripts(): + return glob.glob(os.path.join(resources_dir(), 'converters', '*.py')) + + +def resources_dir(): + if in_py2exe(): + directory = os.path.join(os.path.dirname(sys.executable), "resources") + else: + directory = os.path.dirname(__file__) + return os.path.abspath(directory) + +def in_py2exe(): + return (hasattr(sys,"frozen") and + sys.frozen in ("windows_exe", "console_exe")) diff --git a/lvc/resources/converters/android.py b/lvc/resources/converters/android.py new file mode 100644 index 0000000..ffe73f2 --- /dev/null +++ b/lvc/resources/converters/android.py @@ -0,0 +1,61 @@ +from lvc.converter import FFmpegConverterInfo +from lvc.basicconverters import MP4 + +class AndroidConversion(FFmpegConverterInfo): + media_type = 'android' + extension = 'mp4' + parameters = ('-acodec aac -ac 2 -ab 160k ' + '-vcodec libx264 -preset slow -profile:v baseline -level 30 ' + '-maxrate 10000000 -bufsize 10000000 -f mp4 -threads 0 ') + simple = MP4 + +y = AndroidConversion('Galaxy Y', 320, 240) +mini = AndroidConversion('Galaxy Mini', 320, 240) +ace = AndroidConversion('Galaxy Ace', 480, 320) +admire = AndroidConversion('Galaxy Admire', 480, 320) +charge = AndroidConversion('Galaxy Charge', 800, 480) +s = AndroidConversion('Galaxy S / SII / S Plus', 800, 480) +siii = AndroidConversion('Galaxy SIII', 1280, 720) +nexus = AndroidConversion('Galaxy Nexus', 1280, 720) +tab = AndroidConversion('Galaxy Tab', 1024, 600) +tab_10 = AndroidConversion('Galaxy Tab 10.1', 1280, 800) +note = AndroidConversion('Galaxy Note', 1280, 800) +note = AndroidConversion('Galaxy Note II', 1920, 1080) +infuse = AndroidConversion('Galaxy Infuse', 1280, 800) +epic = AndroidConversion('Galaxy Epic', 800, 480) + +samsung_devices = ('Samsung', [y, mini, ace, admire, charge, s, siii, nexus, + tab, tab_10, note, infuse, epic]) + +wildfire = AndroidConversion('Wildfire', 320, 240) +desire = AndroidConversion('Desire', 800, 480) +incredible = AndroidConversion('Droid Incredible', 800, 480) +thunderbolt = AndroidConversion('Thunderbolt', 800, 480) +evo = AndroidConversion('Evo 4G', 800, 480) +sensation = AndroidConversion('Sensation', 960, 540) +rezound = AndroidConversion('Rezound', 1280, 720) +onex = AndroidConversion('One X', 1280, 720) + +htc_devices = ('HTC', [wildfire, desire, incredible, thunderbolt, evo, + sensation, rezound, onex]) + +droid = AndroidConversion('Droid', 854, 480) +droid_x2 = AndroidConversion('Droid X2', 1280, 720) +razr = AndroidConversion('RAZR', 960, 540) +xoom = AndroidConversion('XOOM', 1280, 800) + +motorola_devices = ('Motorola', [droid, droid_x2, razr, xoom]) + +zio = AndroidConversion('Zio', 800, 480) + +sanyo_devices = ('Sanyo', [zio]) + +small = AndroidConversion('Small (480x320)', 480, 320) +normal = AndroidConversion('Normal (800x480)', 800, 480) +large720 = AndroidConversion('Large (720p)', 1280, 720) +large1080 = AndroidConversion('Large (1080p)', 1920, 1080) + +more_devices = ('More Devices', [small, normal, large720, large1080]) + +converters = [samsung_devices, htc_devices, motorola_devices, sanyo_devices, + more_devices] diff --git a/lvc/resources/converters/apple.py b/lvc/resources/converters/apple.py new file mode 100644 index 0000000..88dc973 --- /dev/null +++ b/lvc/resources/converters/apple.py @@ -0,0 +1,28 @@ +from lvc.converter import FFmpegConverterInfo +from lvc.basicconverters import MP4 + +class AppleConversion(FFmpegConverterInfo): + media_type = 'apple' + extension = 'mp4' + parameters = ('-acodec aac -ac 2 -ab 160k ' + '-vcodec libx264 -preset slow -profile:v baseline -level 30 ' + '-maxrate 10000000 -bufsize 10000000 -vb 1200k -f mp4 ' + '-threads 0') + simple = MP4 + + +DEFAULT_SIZE = (480, 320) + +ipod = AppleConversion('iPod Nano/Classic', *DEFAULT_SIZE) +ipod_touch = AppleConversion('iPod Touch', 640, 480) +ipod_retina = AppleConversion('iPod Touch 4+', 960, 640) +iphone = AppleConversion('iPhone', 640, 480) +iphone_retina = AppleConversion('iPhone 4+', 960, 640) +iphone_5 = AppleConversion('iPhone 5', 1920, 1080) +ipad = AppleConversion('iPad', 1024, 768) +ipad_retina = AppleConversion('iPad 3', 1920, 1080) +apple_tv = AppleConversion('Apple TV', 1280, 720) +universal = AppleConversion('Apple Universal', 1280, 720) + +converters = [ipod, ipod_touch, ipod_retina, iphone, iphone_retina, iphone_5, + ipad, ipad_retina, apple_tv, universal] diff --git a/lvc/resources/converters/others.py b/lvc/resources/converters/others.py new file mode 100644 index 0000000..13ad3b0 --- /dev/null +++ b/lvc/resources/converters/others.py @@ -0,0 +1,20 @@ +from lvc.converter import FFmpegConverterInfo + +class PlaystationPortable(FFmpegConverterInfo): + media_type = 'other' + extension = 'mp4' + parameters = ('-b 512000 -ar 24000 -ab 64000 ' + '-f psp -r 29.97').split() + + +class KindleFire(FFmpegConverterInfo): + media_type = 'other' + extension = 'mp4' + parameters = ('-acodec aac -ab 96k -vcodec libx264 ' + '-preset slow -f mp4 -crf 22').split() + + +psp = PlaystationPortable('Playstation Portable', 320, 240) +kindle_fire = KindleFire('Kindle Fire', 1224, 600) + +converters = [psp, kindle_fire] diff --git a/lvc/resources/images/android-icon-off.png b/lvc/resources/images/android-icon-off.png Binary files differnew file mode 100644 index 0000000..5948f4c --- /dev/null +++ b/lvc/resources/images/android-icon-off.png diff --git a/lvc/resources/images/android-icon-on.png b/lvc/resources/images/android-icon-on.png Binary files differnew file mode 100644 index 0000000..85be5be --- /dev/null +++ b/lvc/resources/images/android-icon-on.png diff --git a/lvc/resources/images/apple-icon-off.png b/lvc/resources/images/apple-icon-off.png Binary files differnew file mode 100644 index 0000000..947bfae --- /dev/null +++ b/lvc/resources/images/apple-icon-off.png diff --git a/lvc/resources/images/apple-icon-on.png b/lvc/resources/images/apple-icon-on.png Binary files differnew file mode 100644 index 0000000..9949653 --- /dev/null +++ b/lvc/resources/images/apple-icon-on.png diff --git a/lvc/resources/images/arrow-down-off.png b/lvc/resources/images/arrow-down-off.png Binary files differnew file mode 100644 index 0000000..368079f --- /dev/null +++ b/lvc/resources/images/arrow-down-off.png diff --git a/lvc/resources/images/arrow-down-on.png b/lvc/resources/images/arrow-down-on.png Binary files differnew file mode 100644 index 0000000..8963b5b --- /dev/null +++ b/lvc/resources/images/arrow-down-on.png diff --git a/lvc/resources/images/audio.png b/lvc/resources/images/audio.png Binary files differnew file mode 100644 index 0000000..4d59605 --- /dev/null +++ b/lvc/resources/images/audio.png diff --git a/lvc/resources/images/clear-icon.png b/lvc/resources/images/clear-icon.png Binary files differnew file mode 100644 index 0000000..5b054fa --- /dev/null +++ b/lvc/resources/images/clear-icon.png diff --git a/lvc/resources/images/convert-button-off.png b/lvc/resources/images/convert-button-off.png Binary files differnew file mode 100644 index 0000000..307a8bd --- /dev/null +++ b/lvc/resources/images/convert-button-off.png diff --git a/lvc/resources/images/convert-button-on.png b/lvc/resources/images/convert-button-on.png Binary files differnew file mode 100644 index 0000000..2a66c76 --- /dev/null +++ b/lvc/resources/images/convert-button-on.png diff --git a/lvc/resources/images/convert-button-stop.png b/lvc/resources/images/convert-button-stop.png Binary files differnew file mode 100644 index 0000000..cb09a97 --- /dev/null +++ b/lvc/resources/images/convert-button-stop.png diff --git a/lvc/resources/images/converted_to-icon.png b/lvc/resources/images/converted_to-icon.png Binary files differnew file mode 100644 index 0000000..14ee6d3 --- /dev/null +++ b/lvc/resources/images/converted_to-icon.png diff --git a/lvc/resources/images/dropoff-icon-off.png b/lvc/resources/images/dropoff-icon-off.png Binary files differnew file mode 100644 index 0000000..e182d49 --- /dev/null +++ b/lvc/resources/images/dropoff-icon-off.png diff --git a/lvc/resources/images/dropoff-icon-on.png b/lvc/resources/images/dropoff-icon-on.png Binary files differnew file mode 100644 index 0000000..1dfd88f --- /dev/null +++ b/lvc/resources/images/dropoff-icon-on.png diff --git a/lvc/resources/images/dropoff-icon-small-off.png b/lvc/resources/images/dropoff-icon-small-off.png Binary files differnew file mode 100644 index 0000000..186a7e6 --- /dev/null +++ b/lvc/resources/images/dropoff-icon-small-off.png diff --git a/lvc/resources/images/dropoff-icon-small-on.png b/lvc/resources/images/dropoff-icon-small-on.png Binary files differnew file mode 100644 index 0000000..476ea49 --- /dev/null +++ b/lvc/resources/images/dropoff-icon-small-on.png diff --git a/lvc/resources/images/error-icon.png b/lvc/resources/images/error-icon.png Binary files differnew file mode 100644 index 0000000..656b2c3 --- /dev/null +++ b/lvc/resources/images/error-icon.png diff --git a/lvc/resources/images/item-completed.png b/lvc/resources/images/item-completed.png Binary files differnew file mode 100644 index 0000000..1400eda --- /dev/null +++ b/lvc/resources/images/item-completed.png diff --git a/lvc/resources/images/item-delete-button-off.png b/lvc/resources/images/item-delete-button-off.png Binary files differnew file mode 100644 index 0000000..12cd239 --- /dev/null +++ b/lvc/resources/images/item-delete-button-off.png diff --git a/lvc/resources/images/item-delete-button-on.png b/lvc/resources/images/item-delete-button-on.png Binary files differnew file mode 100644 index 0000000..45786e5 --- /dev/null +++ b/lvc/resources/images/item-delete-button-on.png diff --git a/lvc/resources/images/item-error.png b/lvc/resources/images/item-error.png Binary files differnew file mode 100644 index 0000000..710ff61 --- /dev/null +++ b/lvc/resources/images/item-error.png diff --git a/lvc/resources/images/lvc-logo.png b/lvc/resources/images/lvc-logo.png Binary files differnew file mode 100644 index 0000000..fce15e4 --- /dev/null +++ b/lvc/resources/images/lvc-logo.png diff --git a/lvc/resources/images/other-icon-off.png b/lvc/resources/images/other-icon-off.png Binary files differnew file mode 100644 index 0000000..a6c76f2 --- /dev/null +++ b/lvc/resources/images/other-icon-off.png diff --git a/lvc/resources/images/other-icon-on.png b/lvc/resources/images/other-icon-on.png Binary files differnew file mode 100644 index 0000000..6c60edc --- /dev/null +++ b/lvc/resources/images/other-icon-on.png diff --git a/lvc/resources/images/progressbar-base.png b/lvc/resources/images/progressbar-base.png Binary files differnew file mode 100644 index 0000000..298a6b6 --- /dev/null +++ b/lvc/resources/images/progressbar-base.png diff --git a/lvc/resources/images/queued-icon.png b/lvc/resources/images/queued-icon.png Binary files differnew file mode 100644 index 0000000..d4e9242 --- /dev/null +++ b/lvc/resources/images/queued-icon.png diff --git a/lvc/resources/images/settings-base_center.png b/lvc/resources/images/settings-base_center.png Binary files differnew file mode 100644 index 0000000..d5f3065 --- /dev/null +++ b/lvc/resources/images/settings-base_center.png diff --git a/lvc/resources/images/settings-base_left.png b/lvc/resources/images/settings-base_left.png Binary files differnew file mode 100644 index 0000000..a0f10c2 --- /dev/null +++ b/lvc/resources/images/settings-base_left.png diff --git a/lvc/resources/images/settings-base_right.png b/lvc/resources/images/settings-base_right.png Binary files differnew file mode 100644 index 0000000..14456eb --- /dev/null +++ b/lvc/resources/images/settings-base_right.png diff --git a/lvc/resources/images/settings-depth_center.png b/lvc/resources/images/settings-depth_center.png Binary files differnew file mode 100644 index 0000000..fb5f586 --- /dev/null +++ b/lvc/resources/images/settings-depth_center.png diff --git a/lvc/resources/images/settings-depth_left.png b/lvc/resources/images/settings-depth_left.png Binary files differnew file mode 100644 index 0000000..a13694b --- /dev/null +++ b/lvc/resources/images/settings-depth_left.png diff --git a/lvc/resources/images/settings-depth_right.png b/lvc/resources/images/settings-depth_right.png Binary files differnew file mode 100644 index 0000000..5ddd21f --- /dev/null +++ b/lvc/resources/images/settings-depth_right.png diff --git a/lvc/resources/images/settings-dropdown-bottom-bg.png b/lvc/resources/images/settings-dropdown-bottom-bg.png Binary files differnew file mode 100644 index 0000000..bc650f8 --- /dev/null +++ b/lvc/resources/images/settings-dropdown-bottom-bg.png diff --git a/lvc/resources/images/settings-icon-off.png b/lvc/resources/images/settings-icon-off.png Binary files differnew file mode 100644 index 0000000..340b516 --- /dev/null +++ b/lvc/resources/images/settings-icon-off.png diff --git a/lvc/resources/images/settings-icon-on.png b/lvc/resources/images/settings-icon-on.png Binary files differnew file mode 100644 index 0000000..be008d4 --- /dev/null +++ b/lvc/resources/images/settings-icon-on.png diff --git a/lvc/resources/images/showfile-icon.png b/lvc/resources/images/showfile-icon.png Binary files differnew file mode 100644 index 0000000..7f9040f --- /dev/null +++ b/lvc/resources/images/showfile-icon.png diff --git a/lvc/resources/nsis/lvc-logo.ico b/lvc/resources/nsis/lvc-logo.ico Binary files differnew file mode 100644 index 0000000..007a929 --- /dev/null +++ b/lvc/resources/nsis/lvc-logo.ico diff --git a/lvc/resources/nsis/modern-wizard.bmp b/lvc/resources/nsis/modern-wizard.bmp Binary files differnew file mode 100644 index 0000000..d8ea8d9 --- /dev/null +++ b/lvc/resources/nsis/modern-wizard.bmp diff --git a/lvc/resources/nsis/plugins/nsProcess.dll b/lvc/resources/nsis/plugins/nsProcess.dll Binary files differnew file mode 100644 index 0000000..4355d4a --- /dev/null +++ b/lvc/resources/nsis/plugins/nsProcess.dll diff --git a/lvc/resources/nsis/plugins/nsProcess.nsh b/lvc/resources/nsis/plugins/nsProcess.nsh new file mode 100644 index 0000000..76642e0 --- /dev/null +++ b/lvc/resources/nsis/plugins/nsProcess.nsh @@ -0,0 +1,21 @@ +!define nsProcess::FindProcess `!insertmacro nsProcess::FindProcess`
+
+!macro nsProcess::FindProcess _FILE _ERR
+ nsProcess::_FindProcess /NOUNLOAD `${_FILE}`
+ Pop ${_ERR}
+!macroend
+
+
+!define nsProcess::KillProcess `!insertmacro nsProcess::KillProcess`
+
+!macro nsProcess::KillProcess _FILE _ERR
+ nsProcess::_KillProcess /NOUNLOAD `${_FILE}`
+ Pop ${_ERR}
+!macroend
+
+
+!define nsProcess::Unload `!insertmacro nsProcess::Unload`
+
+!macro nsProcess::Unload
+ nsProcess::_Unload
+!macroend
diff --git a/lvc/resources/windows/README b/lvc/resources/windows/README new file mode 100644 index 0000000..bcc603e --- /dev/null +++ b/lvc/resources/windows/README @@ -0,0 +1,7 @@ +This directory contains resources files for the windows port. + +---- gtkrc --- + +Taken from +http://art.gnome.org/download/themes/gtk2/1203/GTK2-ClearlooksVisto.tar.bz2 +and modified for Libre Video Converter diff --git a/lvc/resources/windows/gtkrc b/lvc/resources/windows/gtkrc new file mode 100755 index 0000000..45a6969 --- /dev/null +++ b/lvc/resources/windows/gtkrc @@ -0,0 +1,182 @@ +# Clearlooks-Visto by Marius M. M. < devilx at gdesklets dot org> +# This theme is GPLed :) + +gtk-icon-sizes = "panel-menu=16,16:panel=22,22" + +style "clearlooks-default" +{ + GtkButton::default_border = { 0, 0, 0, 0 } + GtkButton::default_outside_border = { 0, 0, 0, 0 } + GtkRange::trough_border = 0 + + GtkWidget::focus_padding = 1 + + GtkPaned::handle_size = 6 + + GtkRange::slider_width = 15 + GtkRange::stepper_size = 15 + GtkScrollbar::min_slider_length = 30 + GtkCheckButton::indicator_size = 12 + GtkMenuBar::internal-padding = 0 + + GtkTreeView::expander_size = 14 + GtkTreeView::odd_row_color = "#EBF5FF" + GtkExpander::expander_size = 16 + + xthickness = 1 + ythickness = 1 + + fg[NORMAL] = "#505050" + fg[ACTIVE] = "#505050" + fg[SELECTED] = "#ffffff" + fg[INSENSITIVE] = "#9B9B9B" + + bg[NORMAL] = "#F5F5F5" + bg[ACTIVE] = "#f9f9f9" + bg[PRELIGHT] = "#888888" + bg[SELECTED] = "#095fb2" + bg[INSENSITIVE] = "#888888" + + base[NORMAL] = "#ffffff" + base[ACTIVE] = "#095fb2" + base[PRELIGHT] = "#FFFFFF" + base[INSENSITIVE]= "#ffffff" + base[SELECTED] = "#095fb2" + + text[INSENSITIVE]= "#9B9B9B" + text[SELECTED] = "#ffffff" + text[ACTIVE] = "#ffffff" + + engine "clearlooks" + { + contrast = 1.1 + menubarstyle = 2 # 0 = flat, 1 = sunken, 2 = flat gradient + menuitemstyle = 1 # 0 = flat, 1 = 3d-ish (gradient), 2 = 3d-ish (button) + listviewitemstyle = 1 # 0 = flat, 1 = 3d-ish (gradient) + progressbarstyle = 1 # 0 = candy bar, 1 = flat + } +} + + +style "clearlooks-progressbar" = "clearlooks-default" +{ + fg[PRELIGHT] = "#ffffff" + xthickness = 1 + ythickness = 1 + +} + +style "clearlooks-wide" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 2 +} + +style "clearlooks-button" = "clearlooks-default" +{ + xthickness = 3 + ythickness = 3 +} + +style "clearlooks-notebook" = "clearlooks-wide" +{ + bg[NORMAL] = "#FAFAFA" +} + +style "clearlooks-tasklist" = "clearlooks-default" +{ + xthickness = 5 + ythickness = 3 +} + +style "clearlooks-menu" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 1 +} + +style "clearlooks-menubar" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 2 + base[PRELIGHT] = "#63E62E" + base[SELECTED] = "#4DB224" +} + +style "clearlooks-menu-item" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 3 + fg[PRELIGHT] = "#ffffff" + text[PRELIGHT] = "#ffffff" +} + +style "clearlooks-tree" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 2 +} + +style "clearlooks-frame-title" = "clearlooks-default" +{ + fg[NORMAL] = "#505050" +} + +style "clearlooks-panel" = "clearlooks-default" +{ + xthickness = 3 + ythickness = 3 +} + +style "clearlooks-tooltips" = "clearlooks-default" +{ + xthickness = 4 + ythickness = 4 + bg[NORMAL] = { 1.0,1.0,0.75 } +} + +style "clearlooks-combo" = "clearlooks-default" +{ + xthickness = 1 + ythickness = 2 +} + +style "metacity-frame" +{ + bg[SELECTED] = "#095fb2" + fg[SELECTED] = "#ffffff" +} + +class "GtkWidget" style "clearlooks-default" +class "GtkButton" style "clearlooks-button" +class "GtkCombo" style "clearlooks-button" +class "GtkRange" style "clearlooks-wide" +class "GtkFrame" style "clearlooks-wide" +class "GtkMenu" style "clearlooks-menu" +class "GtkEntry" style "clearlooks-button" +class "GtkMenuItem" style "clearlooks-menu-item" +class "GtkStatusbar" style "clearlooks-wide" +class "GtkNotebook" style "clearlooks-notebook" +class "GtkProgressBar" style "clearlooks-progressbar" +class "*MenuBar*" style "clearlooks-menubar" +class "GtkMenuBar*" style "clearlooks-menubar" +class "MetaFrames" style "metacity-frame" + +widget_class "*MenuItem*" style "clearlooks-menu-item" + +widget_class "*.GtkComboBox.GtkButton" style "clearlooks-combo" +widget_class "*.GtkCombo.GtkButton" style "clearlooks-combo" + +widget_class "*.tooltips.*.GtkToggleButton" style "clearlooks-tasklist" +widget "gtk-tooltips" style "clearlooks-tooltips" + +widget_class "*.GtkTreeView.GtkButton" style "clearlooks-tree" +widget_class "*.GtkCTree.GtkButton" style "clearlooks-tree" +widget_class "*.GtkList.GtkButton" style "clearlooks-tree" +widget_class "*.GtkCList.GtkButton" style "clearlooks-tree" +widget_class "*.GtkFrame.GtkLabel" style "clearlooks-frame-title" + +widget_class "*.GtkNotebook.*.GtkEventBox" style "clearlooks-notebook" +widget_class "*.GtkNotebook.*.GtkViewport" style "clearlooks-notebook" + +widget_class "*MenuBar*" style "clearlooks-menubar" diff --git a/lvc/settings.py b/lvc/settings.py new file mode 100644 index 0000000..a9b8266 --- /dev/null +++ b/lvc/settings.py @@ -0,0 +1,88 @@ +import logging +import os +import sys + +from lvc import execute + +ffmpeg_version = None + +_search_path_extra = [] +def add_to_search_path(directory): + """Add a path to the list of paths that which() searches.""" + _search_path_extra.append(directory) + +def which(name): + if sys.platform == 'win32': + name = name + '.exe' # we're looking for ffmpeg.exe in this case + if sys.platform == 'darwin' and 'Contents/Resources' in __file__: + # look for a bundled version + path = os.path.join(os.path.dirname(__file__), + '..', '..', '..', '..', 'Helpers', name) + if os.path.exists(path): + return path + dirs_to_search = os.environ['PATH'].split(os.pathsep) + dirs_to_search += _search_path_extra + for dirname in dirs_to_search: + fullpath = os.path.join(dirname, name) + # XXX check for +x bit + if os.path.exists(fullpath): + return fullpath + logging.warn("Can't find path to %s (searched in %s)", name, + dirs_to_search) + +def memoize(func): + cache = [] + def wrapper(): + if not cache: + cache.append(func()) + return cache[0] + return wrapper + +@memoize +def get_ffmpeg_executable_path(): + return which("ffmpeg") + avconv = which('avconv') + if avconv is not None: + return avconv + return which("ffmpeg") + +def get_ffmpeg_version(): + global ffmpeg_version + if ffmpeg_version is None: + commandline = [get_ffmpeg_executable_path(), '-version'] + p = execute.Popen(commandline, stderr=open(os.devnull, "wb")) + stdout, _ = p.communicate() + lines = stdout.split('\n') + version = lines[0].rsplit(' ', 1)[1].split('.') + def maybe_int(v): + try: + return int(v) + except ValueError: + return v + ffmpeg_version = tuple(maybe_int(v) for v in version) + return ffmpeg_version + +def customize_ffmpeg_parameters(params): + """Takes a list of parameters and modifies it based on + platform-specific issues. Returns the newly modified list of + parameters. + + :param params: list of parameters to modify + + :returns: list of modified parameters that will get passed to + ffmpeg + """ + if get_ffmpeg_version() < (0, 8): + # Fallback for older versions of FFmpeg (Ubuntu Natty, in particular). + # see also #18969 + params = ['-vpre' if i == '-preset' else i for i in params] + try: + profile_index = params.index('-profile:v') + except ValueError: + pass + else: + if params[profile_index + 1] == 'baseline': + params[profile_index:profile_index+2] = [ + '-coder', '0', '-bf', '0', '-refs', '1', + '-flags2', '-wpred-dct8x8'] + return params diff --git a/lvc/signals.py b/lvc/signals.py new file mode 100644 index 0000000..2f64dc9 --- /dev/null +++ b/lvc/signals.py @@ -0,0 +1,299 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""signals.py + +GObject-like signal handling for Miro. +""" + +import itertools +import logging +import sys +import weakref + +class NestedSignalError(StandardError): + pass + +class WeakMethodReference: + """Used to handle weak references to a method. + + We can't simply keep a weak reference to method itself, because there + almost certainly aren't any other references to it. Instead we keep a + weak reference to the object, it's class and the unbound method. This + gives us enough info to recreate the bound method when we need it. + """ + + def __init__(self, method): + self.object = weakref.ref(method.im_self) + self.func = weakref.ref(method.im_func) + # don't create a weak reference to the class. That only works for + # new-style classes. It's highly unlikely the class will ever need to + # be garbage collected anyways. + self.cls = method.im_class + + def __call__(self): + func = self.func() + if func is None: return None + obj = self.object() + if obj is None: return None + return func.__get__(obj, self.cls) + +class Callback: + def __init__(self, func, extra_args): + self.func = func + self.extra_args = extra_args + + def invoke(self, obj, args): + return self.func(obj, *(args + self.extra_args)) + + def compare_function(self, func): + return self.func == func + + def is_dead(self): + return False + +class WeakCallback: + def __init__(self, method, extra_args): + self.ref = WeakMethodReference(method) + self.extra_args = extra_args + + def compare_function(self, func): + return self.ref() == func + + def invoke(self, obj, args): + callback = self.ref() + if callback is not None: + return callback(obj, *(args + self.extra_args)) + else: + return None + + def is_dead(self): + return self.ref() is None + +class SignalEmitter(object): + def __init__(self, *signal_names): + self.signal_callbacks = {} + self.id_generator = itertools.count() + self._currently_emitting = set() + self._frozen = False + for name in signal_names: + self.create_signal(name) + + def freeze_signals(self): + self._frozen = True + + def thaw_signals(self): + self._frozen = False + + def create_signal(self, name): + self.signal_callbacks[name] = {} + + def get_callbacks(self, signal_name): + try: + return self.signal_callbacks[signal_name] + except KeyError: + raise KeyError("Signal: %s doesn't exist" % signal_name) + + def _check_already_connected(self, name, func): + for callback in self.get_callbacks(name).values(): + if callback.compare_function(func): + raise ValueError("signal %s already connected to %s" % + (name, func)) + + def connect(self, name, func, *extra_args): + """Connect a callback to a signal. Returns an callback handle that + can be passed into disconnect(). + + If func is already connected to the signal, then a ValueError will be + raised. + """ + self._check_already_connected(name, func) + id_ = self.id_generator.next() + callbacks = self.get_callbacks(name) + callbacks[id_] = Callback(func, extra_args) + return (name, id_) + + def connect_weak(self, name, method, *extra_args): + """Connect a callback weakly. Callback must be a method of some + object. We create a weak reference to the method, so that the + connection doesn't keep the object from being garbage collected. + + If method is already connected to the signal, then a ValueError will be + raised. + """ + self._check_already_connected(name, method) + if not hasattr(method, 'im_self'): + raise TypeError("connect_weak must be called with object methods") + id_ = self.id_generator.next() + callbacks = self.get_callbacks(name) + callbacks[id_] = WeakCallback(method, extra_args) + return (name, id_) + + def disconnect(self, callback_handle): + """Disconnect a signal. callback_handle must be the return value from + connect() or connect_weak(). + """ + callbacks = self.get_callbacks(callback_handle[0]) + if callback_handle[1] in callbacks: + del callbacks[callback_handle[1]] + else: + logging.warning( + "disconnect called but callback_handle not in the callback") + + def disconnect_all(self): + for signal in self.signal_callbacks: + self.signal_callbacks[signal] = {} + + def emit(self, name, *args): + if self._frozen: + return + if name in self._currently_emitting: + raise NestedSignalError("Can't emit %s while handling %s" % + (name, name)) + self._currently_emitting.add(name) + try: + callback_returned_true = self._run_signal(name, args) + finally: + self._currently_emitting.discard(name) + self.clear_old_weak_references() + return callback_returned_true + + def _run_signal(self, name, args): + callback_returned_true = False + try: + self_callback = getattr(self, 'do_' + name.replace('-', '_')) + except AttributeError: + pass + else: + if self_callback(*args): + callback_returned_true = True + if not callback_returned_true: + for callback in self.get_callbacks(name).values(): + if callback.invoke(self, args): + callback_returned_true = True + break + return callback_returned_true + + def clear_old_weak_references(self): + for callback_map in self.signal_callbacks.values(): + for id_ in callback_map.keys(): + if callback_map[id_].is_dead(): + del callback_map[id_] + +class SystemSignals(SignalEmitter): + """System wide signals for Miro. These can be accessed from the singleton + object signals.system. Signals include: + + "error" - A problem occurred in Miro. The frontend should let the user + know this happened, hopefully with a nice dialog box or something that + lets the user report the error to bugzilla. + + Arguments: + - report -- string that can be submitted to the bug tracker + - exception -- Exception object (can be None) + + "startup-success" - The startup process is complete. The frontend should + wait for this signal to show the UI to the user. + + No arguments. + + "startup-failure" - The startup process fails. The frontend should inform + the user that this happened and quit. + + Arguments: + - summary -- Short, user-friendly, summary of the problem + - description -- Longer explanation of the problem + + "shutdown" - The backend has shutdown. The event loop is stopped at this + point. + + No arguments. + + "update-available" - A new version of LibreVideoConverter is available. + + Arguments: + - rssItem -- The RSS item for the latest version (in sparkle + appcast format). + + "new-dialog" - The backend wants to display a dialog to the user. + + Arguments: + - dialog -- The dialog to be displayed. + + "theme-first-run" - A theme was used for the first time + + Arguments: + - theme -- The name of the theme. + + "videos-added" -- Videos were added via the singleclick module. + Arguments: + - view -- A database view than contains the videos. + + "download-complete" -- A download was completed. + Arguments: + - item -- an Item of class Item. + + """ + def __init__(self): + SignalEmitter.__init__(self, 'error', 'startup-success', + 'startup-failure', 'shutdown', + 'update-available', 'new-dialog', + 'theme-first-run', 'videos-added', + 'download-complete') + + def shutdown(self): + self.emit('shutdown') + + def update_available(self, latest): + self.emit('update-available', latest) + + def new_dialog(self, dialog): + self.emit('new-dialog', dialog) + + def theme_first_run(self, theme): + self.emit('theme-first-run', theme) + + def videos_added(self, view): + self.emit('videos-added', view) + + def download_complete(self, item): + self.emit('download-complete', item) + + def failed_exn(self, when, details=None): + self.failed(when, with_exception=True, details=details) + + def failed(self, when, with_exception=False, details=None): + """Used to emit the error signal. Formats a nice crash report.""" + if with_exception: + exc_info = sys.exc_info() + else: + exc_info = None + logging.error('%s: %s' % (when, details), exc_info=exc_info) + +system = SystemSignals() diff --git a/lvc/ui/__init__.py b/lvc/ui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lvc/ui/__init__.py diff --git a/lvc/ui/console.py b/lvc/ui/console.py new file mode 100644 index 0000000..e7eec9c --- /dev/null +++ b/lvc/ui/console.py @@ -0,0 +1,120 @@ +import json +import operator +import optparse +import time +import sys + +import lvc +from lvc.widgets import app +from lvc.widgets import initialize + +parser = optparse.OptionParser( + usage='%prog [-l] [--list-converters] [-c <converter> <filenames..>]', + version='%prog ' + lvc.VERSION, + prog='python -m lvc.ui.console') +parser.add_option('-j', '--json', action='store_true', + dest='json', + help='Output JSON documents, rather than text.') +parser.add_option('-l', '--list-converters', action='store_true', + dest='list_converters', + help="Print a list of supported converter types.") +parser.add_option('-c', '--converter', dest='converter', + help="Specify the type of conversion to make.") + +class Application(lvc.Application): + + def run(self): + (options, args) = parser.parse_args() + + if options.list_converters: + for c in sorted(self.converter_manager.list_converters(), + key=operator.attrgetter('name')): + if options.json: + print json.dumps({'name': c.name, + 'identifier': c.identifier}) + else: + print '%s (-c %s)' % ( + c.name, + c.identifier) + return + + try: + self.converter_manager.get_by_id(options.converter) + except KeyError: + message = '%r is not a valid converter type.' % ( + options.converter,) + if options.json: + print json.dumps({'error': message}) + else: + print 'ERROR:', message + print 'Use "%s -l" to get a list of valid converters.' % ( + parser.prog,) + print + parser.print_help() + sys.exit(1) + + any_failed = False + + def changed(c): + if c.status == 'failed': + any_failed = True + if options.json: + output = { + 'filename': c.video.filename, + 'output': c.output, + 'status': c.status, + 'duration': c.duration, + 'progress': c.progress, + 'percent': (c.progress_percent * 100 if c.progress_percent + else 0), + } + if c.error is not None: + output['error'] = c.error + print json.dumps(output) + else: + if c.status == 'initialized': + line = 'starting (output: %s)' % (c.output,) + elif c.status == 'converting': + if c.progress_percent is not None: + line = 'converting (%i%% complete, %is remaining)' % ( + c.progress_percent * 100, c.eta) + else: + line = 'converting (0% complete, unknown remaining)' + elif c.status == 'staging': + line = 'staging' + elif c.status == 'failed': + line = 'failed (error: %r)' % (c.error,) + elif c.status == 'finished': + line = 'finished (output: %s)' % (c.output,) + else: + line = c.status + print '%s: %s' % (c.video.filename, line) + + for filename in args: + try: + c = app.start_conversion(filename, options.converter) + except ValueError: + message = 'could not parse %r' % filename + if options.json: + any_failed = True + print json.dumps({'status': 'failed', 'error': message, + 'filename': filename}) + else: + print 'ERROR:', message + continue + changed(c) + c.listen(changed) + + # XXX real mainloop + while self.conversion_manager.running: + self.conversion_manager.check_notifications() + time.sleep(1) + self.conversion_manager.check_notifications() # one last time + + sys.exit(0 if not any_failed else 1) + +if __name__ == "__main__": + initialize(None) + app.widgetapp = Application() + app.widgetapp.startup() + app.widgetapp.run() diff --git a/lvc/ui/widgets.py b/lvc/ui/widgets.py new file mode 100644 index 0000000..7849e2a --- /dev/null +++ b/lvc/ui/widgets.py @@ -0,0 +1,1540 @@ +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +import os +import sys + +try: + import lvc +except ImportError: + lvc_path = os.path.join(os.path.dirname(__file__), '..', '..') + sys.path.append(lvc_path) + import lvc + +import copy +import tempfile +import urllib +import urlparse + +from lvc.widgets import (initialize, idle_add, mainloop_start, mainloop_stop, + attach_menubar, reveal_file, get_conversion_directory) +from lvc.widgets import menus +from lvc.widgets import widgetset +from lvc.widgets import cellpack +from lvc.widgets import widgetconst +from lvc.widgets import widgetutil +from lvc.widgets import app + +from lvc.converter import ConverterInfo +from lvc.video import VideoFile +from lvc.resources import image_path +from lvc.utils import size_string, round_even, convert_path_for_subprocess +from lvc import openfiles + +BUTTON_FONT = widgetutil.font_scale_from_osx_points(15.0) +LARGE_FONT = widgetutil.font_scale_from_osx_points(13.0) +SMALL_FONT = widgetutil.font_scale_from_osx_points(10.0) + +DEFAULT_FONT="Helvetica" + +CONVERT_TO_FONT = "Gill Sans Light" +CONVERT_TO_FONTSIZE = widgetutil.font_scale_from_osx_points(14.0) + +SETTINGS_FONT = "Gill Sans Light" +SETTINGS_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) + +CONVERT_NOW_FONT = "Gill Sans Light" +CONVERT_NOW_FONTSIZE = widgetutil.font_scale_from_osx_points(18.0) + +DND_FONT = "Gill Sans Light" +DND_LARGE_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) +DND_SMALL_FONTSIZE = widgetutil.font_scale_from_osx_points(12.0) + +ITEM_TITLE_FONT = "Futura Medium" +ITEM_TITLE_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) + +ITEM_ICONS_FONT= "Century Gothic" +ITEM_ICONS_FONTSIZE= widgetutil.font_scale_from_osx_points(10.0) + +GRADIENT_TOP = widgetutil.css_to_color('#585f63') +GRADIENT_BOTTOM = widgetutil.css_to_color('#383d40') + +DRAG_AREA = widgetutil.css_to_color('#2b2e31') + +TEXT_DISABLED = widgetutil.css_to_color('#333333') +TEXT_ACTIVE = widgetutil.css_to_color('#ffffff') +TEXT_CLICKED = widgetutil.css_to_color('#cccccc') +TEXT_INFO = widgetutil.css_to_color('#808080') +TEXT_COLOR = widgetutil.css_to_color('#ffffff') +TEXT_SHADOW = widgetutil.css_to_color('#000000') + +TABLE_WIDTH, TABLE_HEIGHT = 470, 87 + +class CustomLabel(widgetset.Background): + def __init__(self, text=''): + widgetset.Background.__init__(self) + self.text = text + self.font = DEFAULT_FONT + self.font_scale = LARGE_FONT + self.color = TEXT_COLOR + + def set_text(self, text): + self.text = text + self.invalidate_size_request() + + def set_color(self, color): + self.color = color + self.queue_redraw() + + def set_font(self, font, font_scale): + self.font = font + self.font_scale = font_scale + self.invalidate_size_request() + + def textbox(self, layout_manager): + layout_manager.set_text_color(self.color) + layout_manager.set_font(self.font_scale, family=self.font) + font = layout_manager.set_font(self.font_scale, family=self.font) + return layout_manager.textbox(self.text) + + def draw(self, context, layout_manager): + layout_manager.set_text_color(self.color) + layout_manager.set_font(LARGE_FONT, family=self.font) + textbox = self.textbox(layout_manager) + size = textbox.get_size() + textbox.draw(context, 0, (context.height - size[1]) // 2, + context.width, context.height) + + def size_request(self, layout_manager): + return self.textbox(layout_manager).get_size() + +class WebStyleButton(widgetset.CustomButton): + def __init__(self): + super(WebStyleButton, self).__init__() + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.text = '' + self.font = DEFAULT_FONT + self.font_scale = LARGE_FONT + + def set_text(self, text): + self.text = text + self.invalidate_size_request() + + def set_font(self, font, font_scale): + self.font = font + self.font_scale = font_scale + self.invalidate_size_request() + + def textbox(self, layout_manager): + return layout_manager.textbox(self.text, underline=True) + + def size_request(self, layout_manager): + textbox = self.textbox(layout_manager) + return textbox.get_size() + + def draw(self, context, layout_manager): + layout_manager.set_text_color(TEXT_COLOR) + layout_manager.set_font(self.font_scale, family=self.font) + textbox = self.textbox(layout_manager) + size = textbox.get_size() + textbox.draw(context, 0, (context.height - size[1]) // 2, + context.width, context.height) + +class FileDropTarget(widgetset.SolidBackground): + + dropoff_on = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-on.png"))) + dropoff_off = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-off.png"))) + dropoff_small_on = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-small-on.png"))) + dropoff_small_off = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-small-off.png"))) + + def __init__(self): + super(FileDropTarget, self).__init__() + self.set_background_color(DRAG_AREA) + self.alignment = widgetset.Alignment( + xscale=0.0, yscale=0.5, + xalign=0.5, yalign=0.5, + top_pad=10, right_pad=40, + bottom_pad=10, left_pad=40) + self.add(self.alignment) + + self.widgets = { + False: self.build_large_widgets(), + True: self.build_small_widgets() + } + + self.normal, self.drag = self.widgets[False] + self.alignment.add(self.normal) + + self.in_drag = False + self.small = False + + def build_large_widgets(self): + height = 40 # arbitrary, but the same for both + normal = widgetset.VBox(spacing=20) + normal.pack_start(widgetutil.align_center(self.dropoff_off, + top_pad=60)) + label = CustomLabel("Drag videos here or") + label.set_color(TEXT_COLOR) + label.set_font(DND_FONT, DND_LARGE_FONTSIZE) + hbox = widgetset.HBox(spacing=4) + hbox.pack_start(widgetutil.align_middle(label)) + + cfb = WebStyleButton() + cfb.set_font(DND_FONT, DND_LARGE_FONTSIZE) + cfb.set_text('Choose Files...') + + cfb.connect('clicked', self.choose_file) + hbox.pack_start(widgetutil.align_middle(cfb)) + hbox.set_size_request(-1, height) + normal.pack_start(hbox) + + drag = widgetset.VBox(spacing=20) + drag.pack_start(widgetutil.align_center(self.dropoff_on, + top_pad=60)) + hbox = widgetset.HBox(spacing=4) + hbox.pack_start(widgetutil.align_center( + widgetset.Label("Release button to drop off", + color=TEXT_COLOR))) + hbox.set_size_request(-1, height) + drag.pack_start(hbox) + return normal, drag + + def build_small_widgets(self): + height = 40 # arbitrary, but the same for both + normal = widgetset.HBox(spacing=4) + normal.pack_start(widgetutil.align_middle(self.dropoff_small_off, + right_pad=7)) + drag_label = CustomLabel('Drag more videos here or') + drag_label.set_font(DND_FONT, DND_SMALL_FONTSIZE) + drag_label.set_color(TEXT_COLOR) + normal.pack_start(widgetutil.align_middle(drag_label)) + cfb = WebStyleButton() + cfb.set_text('Choose Files...') + cfb.set_font(DND_FONT, DND_SMALL_FONTSIZE) + cfb.connect('clicked', self.choose_file) + normal.pack_start(cfb) + normal.set_size_request(-1, height) + + drop_label = CustomLabel('Release button to drop off') + drop_label.set_font(DND_FONT, DND_SMALL_FONTSIZE) + drop_label.set_color(TEXT_COLOR) + drag = widgetset.HBox(spacing=10) + drag.pack_start(widgetutil.align_middle(self.dropoff_small_on)) + drag.pack_start(widgetutil.align_middle(drop_label)) + drag.set_size_request(-1, height) + + return normal, drag + + def set_small(self, small): + if small != self.small: + self.small = small + self.normal, self.drag = self.widgets[small] + self.set_in_drag(self.in_drag, force=True) + + def set_in_drag(self, in_drag, force=False): + if force or in_drag != self.in_drag: + self.in_drag = in_drag + if in_drag: + self.alignment.set_child(self.drag) + else: + self.alignment.set_child(self.normal) + self.queue_redraw() + + def choose_file(self, widget): + app.widgetapp.choose_file() + +BUTTON_BACKGROUND = widgetutil.ThreeImageSurface('settings-base') + +class SettingsButton(widgetset.CustomButton): + + arrow_on = widgetset.ImageSurface(widgetset.Image( + image_path('arrow-down-on.png'))) + arrow_off = widgetset.ImageSurface(widgetset.Image( + image_path('arrow-down-off.png'))) + + def __init__(self, name): + super(SettingsButton, self).__init__() + if name != 'settings': + self.name = name.title() + else: + self.name = None + self.selected = False + if name != 'format': + self.surface_on = widgetset.ImageSurface(widgetset.Image( + image_path('%s-icon-on.png' % name))) + self.surface_off = widgetset.ImageSurface(widgetset.Image( + image_path('%s-icon-off.png' % name))) + if self.surface_on.height != self.surface_off.height: + raise ValueError('invalid surface: height mismatch') + self.image_padding = self.calc_image_padding(name) + else: + self.surface_on = self.surface_off = None + + def calc_image_padding(self, name): + """Add some padding to the bottom of our image icon. This can be used + to fine tune where it gets placed. + + :returns: padding in as a (top, right, bottom, left) tuple + """ + + # NOTE: we vertically center the images, so in order to move it X + # pickels up, we need X*2 pixels of bottom padding + if name == 'android': + return (0, 0, 2, 0) + elif name in ('apple', 'other'): + return (0, 0, 4, 0) + else: + return (0, 0, 0, 0) + + def textbox(self, layout_manager): + layout_manager.set_font(SETTINGS_FONTSIZE, family=SETTINGS_FONT) + return layout_manager.textbox(self.name) + + def size_request(self, layout_manager): + hbox = self.build_hbox(layout_manager) + size = hbox.get_size() + height = max(BUTTON_BACKGROUND.height, size[1]) + return int(size[0]) + 2, int(height) + 2 # padding + + def build_hbox(self, layout_manager): + hbox = cellpack.HBox(spacing=5) + if self.selected: + image = self.surface_on + arrow = self.arrow_on + layout_manager.set_text_color(TEXT_ACTIVE) + else: + image = self.surface_off + arrow = self.arrow_off + layout_manager.set_text_color(TEXT_DISABLED) + if image: + padding = cellpack.Padding(image, *self.image_padding) + hbox.pack(cellpack.Alignment(padding, xscale=0, yscale=0, + yalign=0.5)) + if self.name: + vbox = cellpack.VBox() + textbox = self.textbox(layout_manager) + vbox.pack(textbox) + vbox.pack_space(1) + hbox.pack(cellpack.Alignment(vbox, yscale=0, yalign=0.5), + expand=True) + a = cellpack.Alignment(arrow, xscale=0, yscale=0, yalign=0.5) + hbox.pack(cellpack.Padding(a, left=5, right=12)) + alignment = cellpack.Padding(hbox, left=5) + return alignment + + def draw(self, context, layout_manager): + BUTTON_BACKGROUND.draw(context, 1, 1, context.width - 2) + alignment = self.build_hbox(layout_manager) + padding = cellpack.Padding(alignment, top=1, right=3, bottom=1, left=3) + padding.render_layout(context) + + def set_selected(self, selected): + self.selected = selected + self.queue_redraw() + + +class OptionMenuBackground(widgetset.Background): + def __init__(self): + widgetset.Background.__init__(self) + self.surface = widgetutil.ThreeImageSurface('settings-depth') + + def set_child(self, child): + widgetset.Background.set_child(self, child) + # re-create the image surface and scale it as it needs to cover + # the whole of the height of the child + _, h = child.get_size_request() + self.surface = widgetutil.ThreeImageSurface('settings-depth', height=h) + self.invalidate_size_request() + + def size_request(self, layout_manager): + return -1, self.surface.height + + def draw(self, context, layout_manager): + child_width = self.child.get_size_request()[0] + self.surface.draw(context, 0, 0, child_width) + + +class BottomBackground(widgetset.Background): + + def draw(self, context, layout_manager): + gradient = widgetset.Gradient(0, 0, 0, context.height) + gradient.set_start_color(GRADIENT_TOP) + gradient.set_end_color(GRADIENT_BOTTOM) + context.rectangle(0, 0, context.width, context.height) + context.gradient_fill(gradient) + + +class LabeledNumberEntry(widgetset.HBox): + + def __init__(self, label): + super(LabeledNumberEntry, self).__init__(spacing=5) + self.label = widgetset.Label(label, color=TEXT_COLOR) + self.label.set_size(widgetconst.SIZE_SMALL) + self.entry = widgetset.NumberEntry() + self.entry.set_size_request(50, 20) + self.pack_start(self.label) + self.pack_start(self.entry) + self.entry.connect('focus-out', lambda x: self.emit('focus-out')) + + def get_text(self): + return self.entry.get_text() + + def set_text(self, text): + self.entry.set_text(text) + + def get_value(self): + try: + return int(self.entry.get_text()) + except ValueError: + return None + + +class CustomOptions(widgetset.Background): + + background = widgetset.ImageSurface(widgetset.Image( + image_path('settings-dropdown-bottom-bg.png'))) + + def __init__(self): + super(CustomOptions, self).__init__() + self.create_signal('setting-changed') + self.reset() + + def reset(self): + self.options = { + 'destination': None, + 'custom-size': False, + 'width': None, + 'height': None, + 'custom-aspect': False, + 'aspect-ratio': 4.0/3.0, + 'dont-upsize': True + } + + self.top = self.create_top() + self.top.set_size_request(390, 50) + self.left = self.create_left() + self.left.set_size_request(212, 70) + self.right = self.create_right() + self.right.set_size_request(178, 70) + vbox = widgetset.VBox() + vbox.pack_start(self.top) + hbox = widgetset.HBox() + hbox.pack_start(self.left) + hbox.pack_start(self.right) + vbox.pack_start(hbox) + + self.box = widgetutil.align_left(vbox) + + if self.child: + self.set_child(self.box) + + def create_top(self): + hbox = widgetset.HBox(spacing=0) + path_label = WebStyleButton() + path_label.set_text('Show output folder') + path_label.set_font(DEFAULT_FONT, widgetconst.SIZE_SMALL) + path_label.connect('clicked', self.on_path_label_clicked) + create_thumbnails = widgetset.Checkbox('Create Thumbnails', + color=TEXT_COLOR) + create_thumbnails.set_size(widgetconst.SIZE_SMALL) + create_thumbnails.connect('toggled', + self.on_create_thumbnails_changed) + + hbox.pack_start(widgetutil.align(path_label, xalign=0.5), expand=True) + hbox.pack_start(widgetutil.align(create_thumbnails, xalign=0.5), + expand=True) + # XXX: disabled until we can figure out how to do this properly. + #button = widgetset.Button('...') + #button.connect('clicked', self.on_destination_clicked) + #reset = widgetset.Button('Reset') + #reset.connect('clicked', self.on_destination_reset) + #hbox.pack_start(button) + #hbox.pack_start(reset) + return widgetutil.align(hbox, xscale=1.0, yalign=0.5) + + def _get_save_to_path(self): + if self.options['destination'] is None: + return get_conversion_directory() + else: + return self.options['destination'] + + def on_path_label_clicked(self, label): + save_path = self._get_save_to_path() + save_path = convert_path_for_subprocess(save_path) + openfiles.reveal_folder(save_path) + + def create_left(self): + self.custom_size = widgetset.Checkbox('Custom Size', color=TEXT_COLOR) + self.custom_size.set_size(widgetconst.SIZE_SMALL) + self.custom_size.connect('toggled', self.on_custom_size_changed) + + dont_upsize = widgetset.Checkbox('Don\'t Upsize', color=TEXT_COLOR) + dont_upsize.set_checked(self.options['dont-upsize']) + dont_upsize.set_size(widgetconst.SIZE_SMALL) + dont_upsize.connect('toggled', self.on_dont_upsize_changed) + + bottom = widgetset.HBox(spacing=5) + self.width_widget = LabeledNumberEntry('Width') + self.width_widget.connect('focus-out', self.on_width_changed) + self.width_widget.entry.connect('activate', + self.on_width_changed) + self.width_widget.disable() + self.height_widget = LabeledNumberEntry('Height') + self.height_widget.connect('focus-out', self.on_height_changed) + self.height_widget.entry.connect('activate', + self.on_height_changed) + self.height_widget.disable() + bottom.pack_start(self.width_widget) + bottom.pack_start(self.height_widget) + + hbox = widgetset.HBox(spacing=5) + hbox.pack_start(self.custom_size) + hbox.pack_start(dont_upsize) + + vbox = widgetset.VBox(spacing=5) + vbox.pack_start(widgetutil.align_left(hbox, left_pad=10)) + vbox.pack_start(widgetutil.align_center(bottom)) + return widgetutil.align_middle(vbox) + + def create_right(self): + aspect = widgetset.Checkbox('Custom Aspect Ratio', color=TEXT_COLOR) + aspect.set_size(widgetconst.SIZE_SMALL) + aspect.connect('toggled', self.on_aspect_changed) + self.aspect_widget = aspect + self.button_group = widgetset.RadioButtonGroup() + b1 = widgetset.RadioButton('4:3', self.button_group, color=TEXT_COLOR) + b2 = widgetset.RadioButton('3:2', self.button_group, color=TEXT_COLOR) + b3 = widgetset.RadioButton('16:9', self.button_group, color=TEXT_COLOR) + b1.set_selected() + b1.set_size(widgetconst.SIZE_SMALL) + b2.set_size(widgetconst.SIZE_SMALL) + b3.set_size(widgetconst.SIZE_SMALL) + self.aspect_map = dict() + self.aspect_map[b1] = (4, 3) + self.aspect_map[b2] = (3, 2) + self.aspect_map[b3] = (16, 9) + hbox = widgetset.HBox(spacing=5) + # Because the custom size starts off as disabled, so should aspect + # ratio as aspect ratio is dependent on a custom size set. + self.aspect_widget.disable() + for button in self.button_group.get_buttons(): + button.disable() + button.set_size(widgetconst.SIZE_SMALL) + hbox.pack_start(button) + button.connect('clicked', self.on_aspect_size_changed) + + vbox = widgetset.VBox() + vbox.pack_start(widgetutil.align_center(aspect)) + vbox.pack_start(widgetutil.align_center(hbox)) + return widgetutil.align_middle(vbox) + + def draw(self, context, layout_manager): + self.background.draw(context, 0, 0, self.background.width, + self.background.height) + + def enable_custom_size(self): + self.custom_size.enable() + + def disable_custom_size(self): + self.custom_size.disable() + self.custom_size.set_checked(False) + + def update_setting(self, setting, value): + self.options[setting] = value + if setting in ('width', 'height'): + if value is not None: + widget_text = str(value) + else: + widget_text = '' + if setting == 'width': + self.width_widget.set_text(widget_text) + elif setting == 'height': + self.height_widget.set_text(widget_text) + + def do_setting_changed(self, setting, value): + logging.info('setting-changed: %s -> %s', setting, value) + + def _change_setting(self, setting, value): + """Handles setting changes in response to widget changes.""" + + self.options[setting] = value + self.emit('setting-changed', setting, value) + + def force_width_to_aspect_ratio(self): + aspect_ratio = self.options['aspect-ratio'] + width = self.width_widget.get_text() + height = self.height_widget.get_text() + if not height: + return + new_width = round_even(float(height) * aspect_ratio) + if new_width != width: + self.update_setting('width', new_width) + self.emit('setting-changed', 'width', new_width) + + def force_height_to_aspect_ratio(self): + aspect_ratio = self.options['aspect-ratio'] + width = self.width_widget.get_text() + height = self.height_widget.get_text() + if not width: + return + new_height = round_even(float(width) / aspect_ratio) + if new_height != height: + self.update_setting('height', new_height) + self.emit('setting-changed', 'height', new_height) + + def show(self): + self.set_child(self.box) + self.set_size_request(self.background.width, + self.background.height + 28) + self.queue_redraw() + + def hide(self): + self.remove() + self.set_size_request(0, 0) + self.queue_redraw() + + def toggle(self): + if self.child: + self.hide() + else: + self.show() + + # signal handlers + def on_destination_clicked(self, widget): + dialog = widgetset.DirectorySelectDialog('Destination Directory') + r = dialog.run() + if r == 0: # picked a directory + self._change_setting('destination', directory) + + def on_destination_reset(self, widget): + self._change_setting('destination', None) + + def on_dont_upsize_changed(self, widget): + self._change_setting('dont-upsize', widget.get_checked()) + + def on_custom_size_changed(self, widget): + self._change_setting('custom-size', widget.get_checked()) + if widget.get_checked(): + self.width_widget.enable() + self.height_widget.enable() + self.aspect_widget.enable() + self.on_aspect_changed(self.aspect_widget) + else: + self.width_widget.disable() + self.height_widget.disable() + self.aspect_widget.disable() + self.on_aspect_changed(self.aspect_widget) + for button in self.button_group.get_buttons(): + button.disable() + + def on_create_thumbnails_changed(self, widget): + self._change_setting('create-thumbnails', widget.get_checked()) + + def on_width_changed(self, widget): + self._change_setting('width', self.width_widget.get_value()) + if self.options['custom-aspect']: + self.force_height_to_aspect_ratio() + + def on_height_changed(self, widget): + self._change_setting('height', self.height_widget.get_value()) + if self.options['custom-aspect']: + self.force_width_to_aspect_ratio() + + def on_aspect_changed(self, widget): + self._change_setting('custom-aspect', widget.get_checked()) + if widget.get_checked(): + self.force_height_to_aspect_ratio() + for button in self.button_group.get_buttons(): + button.enable() + else: + for button in self.button_group.get_buttons(): + button.disable() + + def on_aspect_size_changed(self, widget): + if self.options['custom-aspect']: + width_ratio, height_ratio = [float(v) for v in + self.aspect_map[widget]] + ratio = width_ratio / height_ratio + self._change_setting('aspect-ratio', ratio) + self.force_height_to_aspect_ratio() + +EMPTY_CONVERTER = ConverterInfo("") + + +class ConversionModel(widgetset.TableModel): + def __init__(self): + super(ConversionModel, self).__init__( + 'text', # filename + 'numeric', # output_size + 'text', # converter + 'text', # status + 'numeric', # duration + 'numeric', # progress + 'numeric', # eta, + 'object', # image + 'object', # the actual conversion + ) + self.conversion_to_iter = {} + self.thumbnail_to_image = {None: widgetset.Image( + image_path('audio.png'))} + + def conversions(self): + return iter(self.conversion_to_iter) + + def all_conversions_done(self): + has_conversions = any(self.conversions()) + all_done = ((set(c.status for c in self.conversions()) - + set(['canceled', 'finished', 'failed'])) == set()) + return all_done and has_conversions + + def get_image(self, path): + if path not in self.thumbnail_to_image: + try: + image = widgetset.Image(path) + except ValueError: + image = self.thumbnail_to_image[None] + self.thumbnail_to_image[path] = image + return self.thumbnail_to_image[path] + + def update_conversion(self, conversion): + try: + output_size = os.stat(conversion.output).st_size + except OSError: + output_size = 0 + + def complete(): + # needs to do it on the update_conversion() from app object + # which calls model_changed() and redraws for us + app.widgetapp.update_conversion(conversion) + + values = (conversion.video.filename, + output_size, + conversion.converter.name, + conversion.status, + conversion.duration or 0, + conversion.progress or 0, + conversion.eta or 0, + self.get_image(conversion.video.get_thumbnail(complete, 90, 70)), + conversion + ) + iter_ = self.conversion_to_iter.get(conversion) + if iter_ is None: + self.conversion_to_iter[conversion] = self.append(*values) + else: + self.update(iter_, *values) + + def remove(self, iter_): + conversion = self[iter_][-1] + del self.conversion_to_iter[conversion] + + # XXX If we add/remove too quickly, we could still be processing + # thumbnails and this may return null, and the self.thumbnail_to_image + # dictionary may get out of sync + def complete(path): + logging.info('calling completion handler for get_thumbnail on ' + 'removal') + + thumbnail_path = conversion.video.get_thumbnail(complete, 90, 70) + if thumbnail_path: + del self.thumbnail_to_image[thumbnail_path] + return super(ConversionModel, self).remove(iter_) + + +class IconWithText(cellpack.HBox): + + def __init__(self, icon, textbox): + super(IconWithText, self).__init__(spacing=5) + self.pack(cellpack.Alignment(icon, yalign=0.5, xscale=0, yscale=0)) + self.pack(textbox) + + +class ConversionCellRenderer(widgetset.CustomCellRenderer): + + IGNORE_PADDING = True + + clear = widgetset.ImageSurface(widgetset.Image( + image_path("clear-icon.png"))) + converted_to = widgetset.ImageSurface(widgetset.Image( + image_path("converted_to-icon.png"))) + queued = widgetset.ImageSurface(widgetset.Image( + image_path("queued-icon.png"))) + showfile = widgetset.ImageSurface(widgetset.Image( + image_path("showfile-icon.png"))) + show_ffmpeg = widgetset.ImageSurface(widgetset.Image( + image_path("error-icon.png"))) + progressbar_base = widgetset.ImageSurface(widgetset.Image( + image_path("progressbar-base.png"))) + delete_on = widgetset.ImageSurface(widgetset.Image( + image_path("item-delete-button-on.png"))) + delete_off = widgetset.ImageSurface(widgetset.Image( + image_path("item-delete-button-off.png"))) + error = widgetset.ImageSurface(widgetset.Image( + image_path("item-error.png"))) + completed = widgetset.ImageSurface(widgetset.Image( + image_path("item-completed.png"))) + + def __init__(self): + super(ConversionCellRenderer, self).__init__() + self.alignment = None + + def get_size(self, style, layout_manager): + return TABLE_WIDTH, TABLE_HEIGHT + + def render(self, context, layout_manager, selected, hotspot, hover): + left_right = cellpack.HBox() + top_bottom = cellpack.VBox() + left_right.pack(self.layout_left(layout_manager)) + left_right.pack(top_bottom, expand=True) + layout_manager.set_text_color(TEXT_COLOR) + layout_manager.set_font(ITEM_TITLE_FONTSIZE, bold=True, + family=ITEM_TITLE_FONT) + title = layout_manager.textbox(os.path.basename(self.input)) + title.set_wrap_style('truncated-char') + alignment = cellpack.Padding(cellpack.TruncatedTextLine(title), + top=25) + top_bottom.pack(alignment) + layout_manager.set_font(ITEM_ICONS_FONTSIZE, family=ITEM_ICONS_FONT) + + bottom = self.layout_bottom(layout_manager, hotspot) + if bottom is not None: + top_bottom.pack(bottom) + left_right.pack(self.layout_right(layout_manager, hotspot)) + + alignment = cellpack.Alignment(left_right, yscale=0, yalign=0.5) + self.alignment = alignment + + background = cellpack.Background(alignment) + background.set_callback(self.draw_background) + background.render_layout(context) + + @staticmethod + def draw_background(context, x, y, width, height): + # draw main background + gradient = widgetset.Gradient(x, y, x, height) + gradient.set_start_color(GRADIENT_TOP) + gradient.set_end_color(GRADIENT_BOTTOM) + context.rectangle(x, y, width, height) + context.gradient_fill(gradient) + # draw bottom line + context.set_line_width(1) + context.set_color((0, 0, 0)) + context.move_to(0, height-0.5) + context.line_to(context.width, height-0.5) + context.stroke() + + def draw_progressbar(self, context, x, y, _, height, width): + # We're only drawing a certain amount of width, not however much we're + # allocated. So, we ignore the passed-in width and just use what we + # set in layout_bottom. + widgetutil.circular_rect(context, x, y, width-1, height-1) + context.set_color((1, 1, 1)) + context.fill() + + def layout_left(self, layout_manager): + surface = widgetset.ImageSurface(self.thumbnail) + return cellpack.Padding(surface, 10, 10, 10, 10) + + def layout_right(self, layout_manager, hotspot): + alignment_kwargs = dict( + xalign=0.5, + xscale=0, + yalign=0.5, + yscale=0, + min_width=80) + if self.status == 'finished': + return cellpack.Alignment(self.completed, **alignment_kwargs) + elif self.status in ('canceled', 'failed'): + return cellpack.Alignment(self.error, **alignment_kwargs) + else: + if hotspot == 'cancel': + image = self.delete_on + else: + image = self.delete_off + return cellpack.Alignment(cellpack.Hotspot('cancel', + image), + **alignment_kwargs) + + def layout_bottom(self, layout_manager, hotspot): + layout_manager.set_text_color(TEXT_COLOR) + if self.status in ('converting', 'staging'): + box = cellpack.HBox(spacing=5) + stack = cellpack.Stack() + stack.pack(cellpack.Alignment(self.progressbar_base, + yalign=0.5, + xscale=0, yscale=0)) + percent = self.progress / self.duration + width = max(int(percent * self.progressbar_base.width), + 5) + stack.pack(cellpack.DrawingArea( + width, self.progressbar_base.height, + self.draw_progressbar, width)) + box.pack(cellpack.Alignment(stack, + yalign=0.5, + xscale=0, yscale=0)) + textbox = layout_manager.textbox("%d%%" % ( + 100 * percent)) + box.pack(textbox) + return box + elif self.status == 'initialized': # queued + vbox = cellpack.VBox() + vbox.pack_space(2) + vbox.pack(IconWithText(self.queued, + layout_manager.textbox("Queued"))) + return vbox + elif self.status in ('finished', 'failed', 'canceled'): + vbox = cellpack.VBox(spacing=5) + vbox.pack_space(4) + top = cellpack.HBox(spacing=5) + if self.status == 'finished': + if hotspot == 'show-file': + layout_manager.set_text_color(TEXT_CLICKED) + top.pack(cellpack.Hotspot('show-file', IconWithText( + self.showfile, + layout_manager.textbox('Show File', + underline=True)))) + elif self.status in ('failed', 'canceled'): + color = TEXT_CLICKED if hotspot == 'show-log' else TEXT_COLOR + layout_manager.set_text_color(color) + # XXX Missing grey error icon + if self.status == 'failed': + text = 'Error - Show FFmpeg Output' + else: + text = 'Canceled - Show FFmpeg Output' + top.pack(cellpack.Hotspot('show-log', IconWithText( + self.show_ffmpeg, + layout_manager.textbox(text, underline=True)))) + color = TEXT_CLICKED if hotspot == 'clear' else TEXT_COLOR + layout_manager.set_text_color(color) + top.pack(cellpack.Hotspot('clear', IconWithText( + self.showfile, + layout_manager.textbox('Clear', underline=True)))) + vbox.pack(top) + if self.status == 'finished': + layout_manager.set_text_color(TEXT_INFO) + vbox.pack(IconWithText( + self.converted_to, + layout_manager.textbox("Converted to %s" % ( + size_string(self.output_size))))) + return vbox + + def hotspot_test(self, style, layout_manager, x, y, width, height): + if self.alignment is None: + return + hotspot_info = self.alignment.find_hotspot(x, y, width, height) + if hotspot_info: + return hotspot_info[0] + +class ConvertButton(widgetset.CustomButton): + off = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-off.png"))) + clear = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-off.png"))) + on = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-on.png"))) + stop = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-stop.png"))) + + def __init__(self): + super(ConvertButton, self).__init__() + self.hidden = False + self.set_off() + + def set_on(self): + self.label = 'Convert to %s' % app.widgetapp.current_converter.name + self.image = self.on + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.queue_redraw() + + def set_clear(self): + self.label = 'Clear and Start Over' + self.image = self.clear + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.queue_redraw() + + def set_off(self): + self.label = 'Convert Now' + self.image = self.off + self.set_cursor(widgetconst.CURSOR_NORMAL) + self.queue_redraw() + + def set_stop(self): + self.label = 'Stop All Conversions' + self.image = self.stop + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.queue_redraw() + + def hide(self): + self.hidden = True + self.invalidate_size_request() + self.queue_redraw() + + def show(self): + self.hidden = False + self.invalidate_size_request() + self.queue_redraw() + + def size_request(self, layout_manager): + if self.hidden: + return 0, 0 + return self.off.width, self.off.height + + def draw(self, context, layout_manager): + if self.hidden: + return + self.image.draw(context, 0, 0, self.image.width, self.image.height) + layout_manager.set_font(CONVERT_NOW_FONTSIZE, family=CONVERT_NOW_FONT) + if self.image == self.off: + layout_manager.set_text_shadow(widgetutil.Shadow(TEXT_SHADOW, + 0.5, (-1, -1), 0)) + layout_manager.set_text_color(TEXT_DISABLED) + else: + layout_manager.set_text_shadow(widgetutil.Shadow(TEXT_SHADOW, + 0.5, (1, 1), 0)) + layout_manager.set_text_color(TEXT_ACTIVE) + textbox = layout_manager.textbox(self.label) + alignment = cellpack.Alignment(textbox, xalign=0.5, xscale=0.0, + yalign=0.5, yscale=0) + alignment.render_layout(context) + +# XXX do we want to export this for general purpose use? +class TextDialog(widgetset.Dialog): + def __init__(self, title, description, window): + widgetset.Dialog.__init__(self, title, description) + self.set_transient_for(window) + self.add_button('OK') + self.textbox = widgetset.MultilineTextEntry() + self.textbox.set_editable(False) + scroller = widgetset.Scroller(False, True) + scroller.set_has_borders(True) + scroller.add(self.textbox) + scroller.set_size_request(400, 500) + self.set_extra_widget(scroller) + + def set_text(self, text): + self.textbox.set_text(text) + +class Application(lvc.Application): + def __init__(self, simultaneous=None): + lvc.Application.__init__(self, simultaneous) + self.create_signal('window-shown') + self.sent_window_shown = False + + def startup(self): + if self.started: + return + + self.current_converter = EMPTY_CONVERTER + + lvc.Application.startup(self) + + self.menu_manager = menus.MenuManager() + self.menu_manager.setup_menubar(self.menubar) + + self.window = widgetset.Window("Libre Video Converter") + self.window.connect('on-shown', self.on_window_shown) + self.window.connect('will-close', self.destroy) + + # # table on top + self.model = ConversionModel() + self.table = widgetset.TableView(self.model) + self.table.draws_selection = False + self.table.set_row_spacing(0) + self.table.enable_album_view_focus_hack() + self.table.set_fixed_height(True) + self.table.set_grid_lines(False, False) + self.table.set_show_headers(False) + + c = widgetset.TableColumn("Data", ConversionCellRenderer(), + **dict((n, v) for (v, n) in enumerate(( + 'input', 'output_size', 'converter', 'status', + 'duration', 'progress', 'eta', 'thumbnail', + 'conversion')))) + c.set_min_width(TABLE_WIDTH) + self.table.add_column(c) + self.table.connect('hotspot-clicked', self.hotspot_clicked) + + # bottom buttons + converter_types = ('apple', 'android', 'other', 'format') + converters = {} + for c in self.converter_manager.list_converters(): + media_type = c.media_type + if media_type not in converter_types: + media_type = 'others' + brand = self.converter_manager.converter_to_brand(c) + # None = top level. Otherwise tack on the brand name. + if brand is None: + converters.setdefault(media_type, set()).add(c) + else: + converters.setdefault(media_type, set()).add(brand) + + self.menus = [] + + self.button_bar = widgetset.HBox() + buttons = widgetset.HBox() + + for type_ in converter_types: + options = [] + more_devices = None + for c in converters[type_]: + if isinstance(c, str): + rconverters = self.converter_manager.brand_to_converters(c) + values = [] + for r in rconverters: + values.append((r.name, r.identifier)) + # yuck + if c == 'More Devices': + more_devices = (c, values) + else: + options.append((c, values)) + else: + options.append((c.name, c.identifier)) + # Don't sort if formats.. + self.sort_converter_menu(type_, options) + if more_devices: + options.append(more_devices) + menu = SettingsButton(type_) + menu.connect('clicked', self.show_options_menu, options) + self.menus.append(menu) + buttons.pack_start(menu) + omb = OptionMenuBackground() + omb.set_child(widgetutil.pad(buttons, top=2, bottom=2, + left=2, right=2)) + self.button_bar.pack_start(omb) + + self.settings_button = SettingsButton('settings') + omb = OptionMenuBackground() + omb.set_child(widgetutil.pad(self.settings_button, top=2, + bottom=2, left=2, right=2)) + self.button_bar.pack_end(omb) + + self.drop_target = FileDropTarget() + self.drop_target.set_size_request(-1, 70) + + # # finish up + vbox = widgetset.VBox() + self.vbox = vbox + + # add menubars, if we're not on windows + if sys.platform != 'win32': + attach_menubar() + + self.scroller = widgetset.Scroller(False, True) + self.scroller.set_size_request(0, 0) + self.scroller.set_background_color(DRAG_AREA) + self.scroller.add(self.table) + vbox.pack_start(self.scroller) + vbox.pack_start(self.drop_target, expand=True) + + bottom = BottomBackground() + bottom_box = widgetset.VBox() + self.convert_label = CustomLabel('Convert to') + self.convert_label.set_font(CONVERT_TO_FONT, CONVERT_TO_FONTSIZE) + self.convert_label.set_color(TEXT_COLOR) + bottom_box.pack_start(widgetutil.align_left(self.convert_label, + top_pad=10, + bottom_pad=10)) + bottom_box.pack_start(self.button_bar) + + self.options = CustomOptions() + self.options.connect('setting-changed', self.on_setting_changed) + self.settings_button.connect('clicked', self.on_settings_toggle) + bottom_box.pack_start(widgetutil.align_right(self.options, + right_pad=5)) + + self.convert_button = ConvertButton() + self.convert_button.connect('clicked', self.convert) + + bottom_box.pack_start(widgetutil.align(self.convert_button, + xalign=0.5, yalign=0.5, + top_pad=50, bottom_pad=50)) + bottom.set_child(widgetutil.pad(bottom_box, left=20, right=20)) + vbox.pack_start(bottom) + self.window.set_content_widget(vbox) + + idle_add(self.conversion_manager.check_notifications, 1) + + self.window.connect('file-drag-motion', self.drag_motion) + self.window.connect('file-drag-received', self.drag_data_received) + self.window.connect('file-drag-leave', self.drag_finished) + self.window.accept_file_drag(True) + + self.window.center() + self.window.show() + self.update_table_size() + + def sort_converter_menu(self, menu_type, options): + """Sort a list of converter options for the menus + + :param menu_type: type of the menu + :param options: list of (name, menu) tuples, where menu is either a + ConverterInfo or list of ConverterInfos. + """ + if menu_type == 'format': + order = ['Audio', 'Video', 'Ingest Formats', 'Same Format'] + options.sort(key=lambda (name, menu): order.index(name)) + else: + options.sort() + + def drag_finished(self, widget): + self.drop_target.set_in_drag(False) + + def drag_motion(self, widget): + self.drop_target.set_in_drag(True) + + def drag_data_received(self, widget, values): + for uri in values: + parsed = urlparse.urlparse(uri) + if parsed.scheme == 'file': + pathname = urllib.url2pathname(parsed.path) + self.file_activated(widget, pathname) + + def on_window_shown(self, window): + # only emit window-shown once, even if our window gets shown, hidden, + # and shown again + if not self.sent_window_shown: + self.emit("window-shown") + self.sent_window_shown = True + + def destroy(self, widget): + for conversion in self.conversion_manager.in_progress.copy(): + conversion.stop() + mainloop_stop() + + def run(self): + mainloop_start() + + def choose_file(self): + dialog = widgetset.FileOpenDialog('Choose Files...') + dialog.set_select_multiple(True) + if dialog.run() == 0: # success + for filename in dialog.get_filenames(): + self.file_activated(None, filename) + dialog.destroy() + + def about(self): + dialog = widgetset.AboutDialog() + dialog.set_transient_for(self.window) + try: + dialog.run() + finally: + dialog.destroy() + + def quit(self): + self.window.close() + + def _generate_suboptions_menu(self, widget, options): + submenu = [] + for option, id_ in options: + callback = lambda x, i: self.on_select_converter(widget, + options[i][1]) + value = (option, callback) + submenu.append(value) + return submenu + + def show_options_menu(self, widget, options): + optionlist = [] + identifiers = dict() + for option, submenu in options: + if isinstance(submenu, list): + callback = self._generate_suboptions_menu(widget, submenu) + else: + callback = lambda x, i: self.on_select_converter(widget, + options[i][1]) + value = (option, callback) + optionlist.append(value) + menu = widgetset.ContextMenu(optionlist) + menu.popup() + + def update_convert_button(self): + can_cancel = False + can_start = False + has_conversions = any(self.model.conversions()) + all_done = self.model.all_conversions_done() + for c in self.model.conversions(): + if c.status == 'converting': + can_cancel = True + break + elif c.status == 'initialized': + can_start = True + # if there are no conversions ... these can't be set + if not has_conversions: + for m in self.menus: + m.set_selected(False) + self.settings_button.set_selected(False) + self.convert_label.set_color(TEXT_DISABLED) + # Set the colors - all are enabled if all conversions complete, or + # if we have conversions conversions but the converter has not yet + # been set. + # the converter has not been set. + if ((self.current_converter is EMPTY_CONVERTER and has_conversions) or + all_done): + for m in self.menus: + m.set_selected(True) + self.settings_button.set_selected(True) + if self.current_converter is EMPTY_CONVERTER: + self.convert_label.set_text('Convert to') + elif can_cancel: + target = self.current_converter.name + self.convert_label.set_text('Converting to %s' % target) + elif can_start: + target = self.current_converter.name + self.convert_label.set_text('Will convert to %s' % target) + self.convert_label.set_color(TEXT_ACTIVE) + if all_done: + self.convert_button.set_clear() + elif (self.current_converter is EMPTY_CONVERTER or not + (can_cancel or can_start)): + self.convert_button.set_off() + elif (self.current_converter is not EMPTY_CONVERTER and + self.options.options['custom-size'] and + (not self.options.options['width'] or + not self.options.options['height'])): + self.convert_button.set_off() + else: + self.convert_button.set_on() + if can_cancel: + self.convert_button.set_stop() + self.button_bar.disable() + else: + if has_conversions: + self.button_bar.enable() + else: + self.button_bar.disable() + + def file_activated(self, widget, filename): + filename = os.path.realpath(filename) + for c in self.model.conversions(): + if c.video.filename == filename: + logger.info('ignoring duplicate: %r', filename) + return + # XXX disabled - don't want to allow individualized file outputs + # since the workflow isn't entirely clear for now. + #if self.options.options['destination'] is None: + # try: + # tempfile.TemporaryFile(dir=os.path.dirname(filename)) + # except EnvironmentError: + # # can't write to the destination directory; ask for a new one + # self.options.on_destination_clicked(None) + try: + vf = VideoFile(filename) + except ValueError: + logging.info('invalid file %r, cannot parse', filename, + exc_info=True) + return + c = self.conversion_manager.get_conversion( + vf, + self.current_converter, + output_dir=self.options.options['destination']) + c.listen(self.update_conversion) + if self.conversion_manager.running: + # start running automatically if a conversion is already in + # progress + self.conversion_manager.run_conversion(c) + self.update_conversion(c) + self.update_table_size() + + def on_select_converter(self, widget, identifier): + self.current_converter = self.converter_manager.get_by_id(identifier) + self.options.reset() + + self.converter_changed(widget) + + def converter_changed(self, widget): + if hasattr(self, '_doing_conversion_change'): + return + self._doing_conversion_change = True + + # If all conversions are done, then change the status of them back + # to 'initialized'. + # + # XXX TODO: what happens if the state is 'failed'? Should we reset? + all_done = self.model.all_conversions_done() + if all_done: + for c in self.model.conversions(): + c.status = 'initialized' + + if self.current_converter is not EMPTY_CONVERTER: + self.convert_label.set_text( + 'Will convert to %s' % self.current_converter.name) + else: + self.convert_label.set_text('Convert to') + + if not self.current_converter.audio_only: + self.options.enable_custom_size() + self.options.update_setting('width', + self.current_converter.width) + self.options.update_setting('height', + self.current_converter.height) + else: + self.options.disable_custom_size() + + for c in self.model.conversions(): + if c.status == 'initialized': + c.set_converter(self.current_converter) + self.model.update_conversion(c) + + # We likely either reset the status or we've changed the conversion + # output so let's just reload the table model. + self.table.model_changed() + + self.update_convert_button() + + widget.set_selected(True) + for menu in self.menus: + if menu is not widget: + menu.set_selected(False) + + del self._doing_conversion_change + + def convert(self, widget): + self.convert_button.disable() + if not self.conversion_manager.running: + if self.current_converter is not EMPTY_CONVERTER: + valid_resolution = True + if (self.options.options['custom-size'] and + not (self.options.options['width'] and + self.options.options['height'])): + valid_resolution = False + if valid_resolution: + for conversion in self.model.conversions(): + if conversion.status == 'initialized': + self.conversion_manager.run_conversion(conversion) + self.button_bar.disable() + # all done: no conversion job should be running at this point + all_done = self.model.all_conversions_done() + if all_done: + # take stuff off one by one from the list until we have none! + # might not be very efficient. + iter_ = self.model.first_iter() + while iter_ is not None: + conversion = self.model[iter_][-1] + if conversion.status in ('finished', + 'failed', + 'canceled', + 'initialized'): + try: + self.conversion_manager.remove(conversion) + except ValueError: + pass + iter_ = self.model.remove(iter_) + self.update_table_size() + else: + for conversion in self.model.conversions(): + conversion.stop() + self.update_conversion(conversion) + self.conversion_manager.running = False + self.update_convert_button() + self.convert_button.enable() + + def update_conversion(self, conversion): + self.model.update_conversion(conversion) + self.update_table_size() + + def update_table_size(self): + conversions = len(self.model) + total_height = 380 + if not conversions: + self.scroller.set_size_request(-1, 0) + self.drop_target.set_small(False) + self.drop_target.set_size_request(-1, total_height) + else: + height = min(TABLE_HEIGHT * conversions, 320) + self.scroller.set_size_request(-1, height) + self.drop_target.set_small(True) + self.drop_target.set_size_request(-1, total_height - height) + self.update_convert_button() + self.table.model_changed() + + def hotspot_clicked(self, widget, name, iter_): + conversion = self.model[iter_][-1] + if name == 'show-file': + reveal_file(conversion.output) + elif name == 'clear': + self.model.remove(iter_) + self.update_table_size() + elif name == 'show-log': + lines = ''.join(conversion.lines) + d = TextDialog('Log', '', self.window) + d.set_text(lines) + try: + d.run() + finally: + d.destroy() + elif name == 'cancel': + if conversion.status == 'initialized': + self.model.remove(iter_) + try: + self.conversion_manager.remove(conversion) + except ValueError: + pass + self.update_table_size() + else: + conversion.stop() + self.update_conversion(conversion) + + def on_settings_toggle(self, widget): + if not self.options.child: + # hidden, going to show + self.convert_button.hide() + self.options.toggle() + if not self.options.child: + # was shown, not hidden + self.convert_button.show() + + def on_setting_changed(self, widget, setting, value): + if setting == 'destination': + for c in self.model.conversions(): + if c.status == 'initialized': + if value is None: + c.output_dir = os.path.dirname(c.video.filename) + else: + c.output_dir = value + # update final path + c.set_converter(self.current_converter) + return + elif setting == 'dont-upsize': + setattr(self.current_converter, 'dont_upsize', value) + return + + if (self.current_converter.identifier != 'custom' and + setting != 'create-thumbnails'): + if hasattr(self.current_converter, 'simple'): + self.current_converter = self.current_converter.simple( + self.current_converter.name) + else: + if self.current_converter is EMPTY_CONVERTER: + self.current_converter = copy.copy(self.converter_manager.get_by_id('sameformat')) + else: + self.current_converter = copy.copy(self.current_converter) + # If the current converter name is resize only, then we don't + # want to call it a custom conversion. + if self.current_converter.identifier != 'sameformat': + self.current_converter.name = 'Custom' + self.current_converter.width = self.options.options['width'] + self.current_converter.height = self.options.options['height'] + self.converter_changed(self.menus[-1]) # formats menu + if setting in ('width', 'height'): + setattr(self.current_converter, setting, value) + elif setting == 'custom-size': + if not value: + self.current_converter.old_size = ( + self.current_converter.width, + self.current_converter.height) + self.current_converter.width = None + self.current_converter.height = None + elif hasattr(self.current_converter, 'old_size'): + old_size = self.current_converter.old_size + (self.current_converter.width, + self.current_converter.height) = old_size + elif setting == 'create-thumbnails': + self.conversion_manager.create_thumbnails = bool(value) + +if __name__ == "__main__": + sys.dont_write_bytecode = True + app.widgetapp = Application() + initialize(app.widgetapp) diff --git a/lvc/utils.py b/lvc/utils.py new file mode 100644 index 0000000..e0a64f3 --- /dev/null +++ b/lvc/utils.py @@ -0,0 +1,230 @@ +import ctypes +import itertools +import logging +import os +import sys + +def hms_to_seconds(hours, minutes, seconds): + return (hours * 3600 + + minutes * 60 + + seconds) + + +def round_even(num): + """This takes a number, converts it to an integer, then makes + sure it's even. + + Additional rules: this helper always rounds down to avoid stray black + pixels (see bz18122). + + This function makes sure that the value returned is always >= 0. + """ + num = int(num) + val = num - (num % 2) + return val if val > 0 else 0 + + +def rescale_video((source_width, source_height), + (target_width, target_height), + dont_upsize=True): + """ + Rescale a video given a (width, height) target. This returns the largest + (width, height) which maintains the original aspect ratio while fitting + within the target size. + + If dont_upsize is set, then don't resize it such that the rescaled size + will be larger than the original size. + """ + if source_width is None or source_height is None: + return (round_even(target_width), round_even(target_height)) + + if target_width is None or target_height is None: + return (round_even(source_width), round_even(source_height)) + + if (dont_upsize and + (source_width <= target_width or source_height <= target_height)): + return (round_even(source_width), round_even(source_height)) + + width_ratio = float(source_width) / float(target_width) + height_ratio = float(source_height) / float(target_height) + ratio = max(width_ratio, height_ratio) + return round_even(source_width / ratio), round_even(source_height / ratio) + +def line_reader(handle): + """Builds a line reading generator for the given handle. This + generator breaks on empty strings, \\r and \\n. + + This a little weird, but it makes it really easy to test error + checking and progress monitoring. + """ + def _readlines(): + chars = [] + c = handle.read(1) + while True: + if c in ["", "\r", "\n"]: + if chars: + yield "".join(chars) + if not c: + break + chars = [] + else: + chars.append(c) + c = handle.read(1) + return _readlines() + + +class Matrix(object): + """2 Dimensional matrix. + + Matrix objects are accessed like a list, except tuples are used as + indices, for example: + + >>> m = Matrix(5, 5) + >>> m[3, 4] = 'foo' + >>> m + None, None, None, None, None + None, None, None, None, None + None, None, None, None, None + None, None, None, None, None + None, None, None, 'foo', None + """ + + def __init__(self, columns, rows, initial_value=None): + self.columns = columns + self.rows = rows + self.data = [ initial_value ] * (columns * rows) + + def __getitem__(self, key): + return self.data[(key[0] * self.rows) + key[1]] + + def __setitem__(self, key, value): + self.data[(key[0] * self.rows) + key[1]] = value + + def __iter__(self): + return iter(self.data) + + def __repr__(self): + return "\n".join([", ".join([repr(r) + for r in list(self.row(i))]) + for i in xrange(self.rows)]) + + def remove(self, value): + """This sets the value to None--it does NOT remove the cell + from the Matrix because that doesn't make any sense. + """ + i = self.data.index(value) + self.data[i] = None + + def row(self, row): + """Iterator that yields all the objects in a row.""" + for i in xrange(self.columns): + yield self[i, row] + + def column(self, column): + """Iterator that yields all the objects in a column.""" + for i in xrange(self.rows): + yield self[column, i] + + +class Cache(object): + def __init__(self, size): + self.size = size + self.dict = {} + self.counter = itertools.count() + self.access_times = {} + self.invalidators = {} + + def get(self, key, invalidator=None): + if key in self.dict: + existing_invalidator = self.invalidators[key] + if (existing_invalidator is None or + not existing_invalidator(key)): + self.access_times[key] = self.counter.next() + return self.dict[key] + + value = self.create_new_value(key, invalidator=invalidator) + self.set(key, value, invalidator=invalidator) + return value + + def set(self, key, value, invalidator=None): + if len(self.dict) == self.size: + self.shrink_size() + self.access_times[key] = self.counter.next() + self.dict[key] = value + self.invalidators[key] = invalidator + + def remove(self, key): + if key in self.dict: + del self.dict[key] + del self.access_times[key] + if key in self.invalidators: + del self.invalidators[key] + + def keys(self): + return self.dict.iterkeys() + + def shrink_size(self): + # shrink by LRU + to_sort = self.access_times.items() + to_sort.sort(key=lambda m: m[1]) + new_dict = {} + new_access_times = {} + new_invalidators = {} + latest_times = to_sort[len(self.dict) // 2:] + for (key, time) in latest_times: + new_dict[key] = self.dict[key] + new_invalidators[key] = self.invalidators[key] + new_access_times[key] = time + self.dict = new_dict + self.access_times = new_access_times + + def create_new_value(self, val, invalidator=None): + raise NotImplementedError() + + +def size_string(nbytes): + # when switching from the enclosure reported size to the + # downloader reported size, it takes a while to get the new size + # and the downloader returns -1. the user sees the size go to -1B + # which is weird.... better to return an empty string. + if nbytes == -1 or nbytes == 0: + return "" + + # FIXME this is a repeat of util.format_size_for_user ... should + # probably ditch one of them. + if nbytes >= (1 << 30): + value = "%.1f" % (nbytes / float(1 << 30)) + return "%(size)s GB" % {"size": value} + elif nbytes >= (1 << 20): + value = "%.1f" % (nbytes / float(1 << 20)) + return "%(size)s MB" % {"size": value} + elif nbytes >= (1 << 10): + value = "%.1f" % (nbytes / float(1 << 10)) + return "%(size)s KB" % {"size": value} + else: + return "%(size)s B" % {"size": nbytes} + +def convert_path_for_subprocess(path): + """Convert a path to a form suitable for passing to a subprocess. + + This method converts unicode paths to bytestrings according to the system + fileencoding. On windows, it converts the path to a short filename for + maximum compatibility + + This method should only be called on a path that exists on the filesystem. + """ + if not os.path.exists(path): + raise ValueError("path %r doesn't exist" % path) + if not isinstance(path, unicode): + # path already is a bytestring, just return it + return path + if sys.platform != 'win32': + return path.encode(sys.getfilesystemencoding()) + else: + buf_size = 1024 + short_path_buf = ctypes.create_unicode_buffer(buf_size) + ctypes.windll.kernel32.GetShortPathNameW(path, + short_path_buf, buf_size) + logging.info("convert_path_for_subprocess: got short path %r", + short_path_buf.value) + return short_path_buf.value.encode('ascii') diff --git a/lvc/video.py b/lvc/video.py new file mode 100644 index 0000000..81283be --- /dev/null +++ b/lvc/video.py @@ -0,0 +1,287 @@ +import logging +import os +import re +import tempfile +import threading + +from lvc import execute +from lvc.widgets import idle_add +from lvc.settings import get_ffmpeg_executable_path +from lvc.utils import hms_to_seconds, convert_path_for_subprocess + +logger = logging.getLogger(__name__) + +class VideoFile(object): + def __init__(self, filename): + self.filename = filename + self.container = None + self.video_codec = None + self.audio_codec = None + self.width = None + self.height = None + self.duration = None + self.thumbnails = {} + self.parse() + + def parse(self): + self.__dict__.update( + get_media_info(self.filename)) + + @property + def audio_only(self): + return self.video_codec is None + + def get_thumbnail(self, completion, width=None, height=None, type_='.png'): + if self.audio_only: + # don't bother with thumbnails for audio files + return None + if width is None: + width = -1 + if height is None: + height = -1 + + if self.duration is None: + skip = 0 + else: + skip = min(int(self.duration / 3), 120) + + key = (width, height, type_) + + def complete(name): + self.thumbnails[key] = name + completion() + + if key not in self.thumbnails: + temp_path = tempfile.mktemp(suffix=type_) + get_thumbnail(self.filename, width, height, temp_path, complete, + skip=skip) + return None + + return self.thumbnails.get(key) + +class Node(object): + def __init__(self, line="", children=None): + self.line = line + if not children: + self.children = [] + else: + self.children = children + + if ": " in line: + self.key, self.value = line.split(": ", 1) + else: + self.key = "" + self.value = "" + + def add_node(self, node): + self.children.append(node) + + def pformat(self, indent=0): + s = (" " * indent) + ("Node: %s" % self.line) + "\n" + for mem in self.children: + s += mem.pformat(indent + 2) + return s + + def get_by_key(self, key): + if self.line.startswith(key): + return self + for mem in self.children: + ret = mem.get_by_key(key) + if ret: + return ret + return None + + def __repr__(self): + return "<Node %s: %s>" % (self.key, self.value) + + +def get_indent(line): + length = len(line) + line = line.lstrip() + return (length - len(line), line) + + +def parse_ffmpeg_output(output): + """Takes a list of strings and parses it into a loose AST-ish + thing. + + ffmpeg output uses indentation levels to indicate a hierarchy of + data. + + If there's a : in the line, then it's probably a key/value pair. + + :param output: the content to parse as a list of strings. + + :returns: a top level node of the ffmpeg output AST + """ + ast = Node() + node_stack = [ast] + indent_level = 0 + + for mem in output: + # skip blank lines + if len(mem.strip()) == 0: + continue + + indent, line = get_indent(mem) + node = Node(line) + + if indent == indent_level: + node_stack[-1].add_node(node) + elif indent > indent_level: + node_stack.append(node_stack[-1].children[-1]) + indent_level = indent + node_stack[-1].add_node(node) + else: + for dedent in range(indent, indent_level, 2): + # make sure we never pop everything off the stack. + # the root should always be on the stack. + if len(node_stack) <= 1: + break + node_stack.pop() + indent_level = indent + node_stack[-1].add_node(node) + + return ast + + +# there's always a space before the size and either a space or a comma +# afterwards. +SIZE_RE = re.compile(" (\\d+)x(\\d+)[ ,]") + + +def extract_info(ast): + info = {} + # logging.info("get_media_info: %s", ast.pformat()) + + input0 = ast.get_by_key("Input #0") + if not input0: + raise ValueError("no input #0") + + foo, info['container'], bar = input0.line.split(', ', 2) + if ',' in info['container']: + info['container'] = info['container'].split(',') + + metadata = input0.get_by_key("Metadata") + if metadata: + for key in ('title', 'artist', 'album', 'track', 'genre'): + node = metadata.get_by_key(key) + if node: + info[key] = node.line.split(':', 1)[1].strip() + major_brand_node = metadata.get_by_key("major_brand") + extra_container_types = [] + if major_brand_node: + major_brand = major_brand_node.line.split(':')[1].strip() + extra_container_types = [major_brand] + else: + major_brand = None + + compatible_brands_node = metadata.get_by_key("compatible_brands") + if compatible_brands_node: + line = compatible_brands_node.line.split(':')[1].strip() + extra_container_types.extend(line[i:i+4] for i in range(0, len(line), 4) + if line[i:i+4] != major_brand) + + if extra_container_types: + if not isinstance(info['container'], list): + info['container'] = [info['container']] + info['container'].extend(extra_container_types) + + duration = input0.get_by_key("Duration:") + if duration: + _, rest = duration.line.split(':', 1) + duration_string, _ = rest.split(', ', 1) + logging.info("duration: %r", duration_string) + try: + hours, minutes, seconds = [ + float(i) for i in duration_string.split(':')] + except ValueError: + if duration_string.strip() != "N/A": + logging.warn("Error parsing duration string: %r", + duration_string) + else: + info['duration'] = hms_to_seconds(hours, minutes, seconds) + for stream_node in duration.children: + stream = stream_node.line + if "Video:" in stream: + stream_number, video, data = stream.split(': ', 2) + video_codec = data.split(', ')[0] + if ' ' in video_codec: + video_codec, drmp = video_codec.split(' ', 1) + if 'drm' in drmp: + info.setdefault('has_drm', []).append('video') + info['video_codec'] = video_codec + match = SIZE_RE.search(data) + if match: + info["width"] = int(match.group(1)) + info["height"] = int(match.group(2)) + elif 'Audio:' in stream: + stream_number, video, data = stream.split(': ', 2) + audio_codec = data.split(', ')[0] + if ' ' in audio_codec: + audio_codec, drmp = audio_codec.split(' ', 1) + if 'drm' in drmp: + info.setdefault('has_drm', []).append('audio') + info['audio_codec'] = audio_codec + return info + +def get_ffmpeg_output(filepath): + + commandline = [get_ffmpeg_executable_path(), + "-i", convert_path_for_subprocess(filepath)] + logging.info("get_ffmpeg_output(): running %s", commandline) + try: + output = execute.check_output(commandline) + except execute.CalledProcessError, e: + if e.returncode != 1: + logger.exception("error calling %r\noutput:%s", commandline, + e.output) + # ffmpeg -i generally returns 1, so we ignore the exception and + # just get the output. + output = e.output + + return output + +def get_media_info(filepath): + """Takes a file path and returns a dict of information about + this media file that it extracted from ffmpeg -i. + + :param filepath: absolute path to the media file in question + + :returns: dict of media info possibly containing: height, width, + container, audio_codec, video_codec + """ + logger.info('get_media_info: %r', filepath) + output = get_ffmpeg_output(filepath) + ast = parse_ffmpeg_output(output.splitlines()) + info = extract_info(ast) + logger.info('get_media_info: %r', info) + return info + +def get_thumbnail(filename, width, height, output, completion, skip=0): + name = 'Thumbnail - %r @ %sx%s' % (filename, width, height) + def run(): + rv = get_thumbnail_synchronous(filename, width, height, output, skip) + idle_add(lambda: completion(rv)) + t = threading.Thread(target=run, name=name) + t.start() + +def get_thumbnail_synchronous(filename, width, height, output, skip=0): + executable = get_ffmpeg_executable_path() + filter_ = 'scale=%i:%i' % (width, height) + # bz19571: temporary disable: libav ffmpeg does not support this filter + #if 'ffmpeg' in executable: + # # supports the thumbnail filter, we hope + # filter_ = 'thumbnail,' + filter_ + commandline = [executable, + '-ss', str(skip), + '-i', convert_path_for_subprocess(filename), + '-vf', filter_, '-vframes', '1', output] + try: + execute.check_output(commandline) + except execute.CalledProcessError, e: + logger.exception('error calling %r\ncode:%s\noutput:%s', + commandline, e.returncode, e.output) + return None + else: + return output diff --git a/lvc/widgets/__init__.py b/lvc/widgets/__init__.py new file mode 100644 index 0000000..23a6edc --- /dev/null +++ b/lvc/widgets/__init__.py @@ -0,0 +1,30 @@ +import logging +import os +import sys + +if sys.platform == 'darwin': + import osx as plat + from .osx import widgetset +else: + import gtk as plat + from .gtk import widgetset + +attach_menubar = plat.attach_menubar +mainloop_start = plat.mainloop_start +mainloop_stop = plat.mainloop_stop +idle_add = plat.idle_add +idle_remove = plat.idle_remove +reveal_file = plat.reveal_file +get_conversion_directory = plat.get_conversion_directory + +def get_conversion_directory(): + return os.path.join(plat.get_conversion_directory(), 'Libre Video Converter') + """directorio donde se guardan los videos convertidos""" + +def initialize(app): + try: + os.makedirs(get_conversion_directory()) + except EnvironmentError, e: + logging.info('os.makedirs: %s', str(e)) + if app: + plat.initialize(app) diff --git a/lvc/widgets/app.py b/lvc/widgets/app.py new file mode 100644 index 0000000..531b745 --- /dev/null +++ b/lvc/widgets/app.py @@ -0,0 +1,4 @@ +# app.py + +widgetapp = None + diff --git a/lvc/widgets/cellpack.py b/lvc/widgets/cellpack.py new file mode 100644 index 0000000..1347f56 --- /dev/null +++ b/lvc/widgets/cellpack.py @@ -0,0 +1,843 @@ +"""``miro.frontends.widgets.cellpack`` -- Code to layout +CustomTableCells. + +We use the hbox/vbox model to lay things out with a couple changes. +The main difference here is that layouts are one-shot. We don't keep +state around inside the cell renderers, so we just set up the objects +at the start, then use them to calculate info. +""" + +class Margin(object): + """Helper object used to calculate margins. + """ + def __init__(self , margin): + if margin is None: + margin = (0, 0, 0, 0) + self.margin_left = margin[3] + self.margin_top = margin[0] + self.margin_width = margin[1] + margin[3] + self.margin_height = margin[0] + margin[2] + + def inner_rect(self, x, y, width, height): + """Returns the x, y, width, height of the inner + box. + """ + return (x + self.margin_left, + y + self.margin_top, + width - self.margin_width, + height - self.margin_height) + + def outer_size(self, inner_size): + """Returns the width, height of the outer box. + """ + return (inner_size[0] + self.margin_width, + inner_size[1] + self.margin_height) + + def point_in_margin(self, x, y, width, height): + """Returns whether a given point is inside of the + margins. + """ + return ((0 <= x - self.margin_left < width - self.margin_width) and + (0 <= y - self.margin_top < height - self.margin_height)) + +class Packing(object): + """Helper object used to layout Boxes. + """ + def __init__(self, child, expand): + self.child = child + self.expand = expand + + def calc_size(self, translate_func): + return translate_func(*self.child.get_size()) + + def draw(self, context, x, y, width, height): + self.child.draw(context, x, y, width, height) + +class WhitespacePacking(object): + """Helper object used to layout Boxes. + """ + def __init__(self, size, expand): + self.size = size + self.expand = expand + + def calc_size(self, translate_func): + return self.size, 0 + + def draw(self, context, x, y, width, height): + pass + +class Packer(object): + """Base class packing objects. Packer objects work similarly to widgets, + but they only used in custom cell renderers so there's a couple + differences. The main difference is that cell renderers don't keep state + around. Therefore Packers just get set up, used, then discarded. + Also Packers can't receive events directly, so they have a different + system to figure out where mouse clicks happened (the Hotspot class). + """ + + def render_layout(self, context): + """position the child elements then call draw() on them.""" + self._layout(context, 0, 0, context.width, context.height) + + def draw(self, context, x, y, width, height): + """Included so that Packer objects have a draw() method that matches + ImageSurfaces, TextBoxes, etc. + """ + self._layout(context, x, y, width, height) + + def _find_child_at(self, x, y, width, height): + raise NotImplementedError() + + def get_size(self): + """Get the minimum size required to hold the Packer. """ + try: + return self._size + except AttributeError: + self._size = self._calc_size() + return self._size + + def get_current_size(self): + """Get the minimum size required to hold the Packer at this point + + Call this method if you are going to change the packer after the call, + for example if you have more children to pack into a box. get_size() + saves caches it's result which is can mess things up. + """ + return self._calc_size() + + def find_hotspot(self, x, y, width, height): + """Find the hotspot at (x, y). width and height are the size of the + cell this Packer is rendering. + + If a hotspot is found, return the tuple (name, x, y, width, height) + where name is the name of the hotspot, x, y is the position relative + to the top-left of the hotspot area and width, height are the + dimensions of the hotspot. + + If no Hotspot is found return None. + """ + child_pos = self._find_child_at(x, y, width, height) + if child_pos: + child, child_x, child_y, child_width, child_height = child_pos + try: + return child.find_hotspot(x - child_x, y - child_y, + child_width, child_height) + except AttributeError: + pass # child is a TextBox, Button or something like that + return None + + def _layout(self, context, x, y, width, height): + """Layout our children and call ``draw()`` on them. + """ + raise NotImplementedError() + + def _calc_size(self): + """Calculate the size needed to hold the box. The return value gets + cached and return in ``get_size()``. + """ + raise NotImplementedError() + +class Box(Packer): + """Box is the base class for VBox and HBox. Box objects lay out children + linearly either left to right or top to bottom. + """ + + def __init__(self, spacing=0): + """Create a new Box. spacing is the amount of space to place + in-between children. + """ + self.spacing = spacing + self.children = [] + self.children_end = [] + self.expand_count = 0 + + def pack(self, child, expand=False): + """Add a new child to the box. The child will be placed after all the + children packed before with pack_start. + + :param child: child to pack. It can be anything with a + ``get_size()`` method, including TextBoxes, + ImageSurfarces, Buttons, Boxes and Backgrounds. + :param expand: If True, then the child will enlarge if space + available is more than the space required. + """ + if not (hasattr(child, 'draw') and hasattr(child, 'get_size')): + raise TypeError("%s can't be drawn" % child) + self.children.append(Packing(child, expand)) + if expand: + self.expand_count += 1 + + def pack_end(self, child, expand=False): + """Add a new child to the end box. The child will be placed before + all the children packed before with pack_end. + + :param child: child to pack. It can be anything with a + ``get_size()`` method, including TextBoxes, + ImageSurfarces, Buttons, Boxes and Backgrounds. + :param expand: If True, then the child will enlarge if space + available is more than the space required. + """ + if not (hasattr(child, 'draw') and hasattr(child, 'get_size')): + raise TypeError("%s can't be drawn" % child) + self.children_end.append(Packing(child, expand)) + if expand: + self.expand_count += 1 + + def pack_space(self, size, expand=False): + """Pack whitespace into the box. + """ + self.children.append(WhitespacePacking(size, expand)) + if expand: + self.expand_count += 1 + + def pack_space_end(self, size, expand=False): + """Pack whitespace into the end of box. + """ + self.children_end.append(WhitespacePacking(size, expand)) + if expand: + self.expand_count += 1 + + def _calc_size(self): + length = 0 + breadth = 0 + for packing in self.children + self.children_end: + child_length, child_breadth = packing.calc_size(self._translate) + length += child_length + breadth = max(breadth, child_breadth) + total_children = len(self.children) + len(self.children_end) + length += self.spacing * (total_children - 1) + return self._translate(length, breadth) + + def _extra_space_iter(self, total_extra_space): + """Generate the amount of extra space for children with expand set.""" + if total_extra_space <= 0: + while True: + yield 0 + average_extra_space, leftover = \ + divmod(total_extra_space, self.expand_count) + while leftover > 1: + # expand_count doesn't divide equally into total_extra_space, + # yield average_extra_space+1 for each extra pixel + yield average_extra_space + 1 + leftover -= 1 + # if there's a fraction of a pixel leftover, add that in + yield average_extra_space + leftover + while True: + # no more leftover space + yield average_extra_space + + def _position_children(self, total_length): + my_length, my_breadth = self._translate(*self.get_size()) + extra_space_iter = self._extra_space_iter(total_length - my_length) + + pos = 0 + for packing in self.children: + child_length, child_breadth = packing.calc_size(self._translate) + if packing.expand: + child_length += extra_space_iter.next() + yield packing, pos, child_length + pos += child_length + self.spacing + + pos = total_length + for packing in self.children_end: + child_length, child_breadth = packing.calc_size(self._translate) + if packing.expand: + child_length += extra_space_iter.next() + pos -= child_length + yield packing, pos, child_length + pos -= self.spacing + + def _layout(self, context, x, y, width, height): + total_length, total_breadth = self._translate(width, height) + pos, offset = self._translate(x, y) + position_iter = self._position_children(total_length) + for packing, child_pos, child_length in position_iter: + x, y = self._translate(pos + child_pos, offset) + width, height = self._translate(child_length, total_breadth) + packing.draw(context, x, y, width, height) + + def _find_child_at(self, x, y, width, height): + total_length, total_breadth = self._translate(width, height) + pos, offset = self._translate(x, y) + position_iter = self._position_children(total_length) + for packing, child_pos, child_length in position_iter: + if child_pos <= pos < child_pos + child_length: + x, y = self._translate(child_pos, 0) + width, height = self._translate(child_length, total_breadth) + if isinstance(packing, WhitespacePacking): + return None + return packing.child, x, y, width, height + elif child_pos > pos: + break + return None + + def _translate(self, x, y): + """Translate (x, y) coordinates into (length, breadth) and + vice-versa. + """ + raise NotImplementedError() + +class HBox(Box): + def _translate(self, x, y): + return x, y + +class VBox(Box): + def _translate(self, x, y): + return y, x + +class Table(Packer): + def __init__(self, row_length=1, col_length=1, + row_spacing=0, col_spacing=0): + """Create a new Table. + + :param row_length: how many rows long this should be + :param col_length: how many rows wide this should be + :param row_spacing: amount of spacing (in pixels) between rows + :param col_spacing: amount of spacing (in pixels) between columns + """ + assert min(row_length, col_length) > 0 + assert isinstance(row_length, int) and isinstance(col_length, int) + self.row_length = row_length + self.col_length = col_length + self.row_spacing = row_spacing + self.col_spacing = col_spacing + self.table_multiarray = self._generate_table_multiarray() + + def _generate_table_multiarray(self): + table_multiarray = [] + table_multiarray = [ + [None for col in range(self.col_length)] + for row in range(self.row_length)] + return table_multiarray + + def pack(self, child, row, column, expand=False): + # TODO: flesh out "expand" ability, maybe? + # + # possibly throw a special exception if outside the range. + # For now, just allowing an IndexError to be thrown. + self.table_multiarray[row][column] = Packing(child, expand) + + def _get_grid_sizes(self): + """Get the width and eights for both rows and columns + """ + row_sizes = {} + col_sizes = {} + for row_count, row in enumerate(self.table_multiarray): + row_sizes.setdefault(row_count, 0) + for col_count, col_packing in enumerate(row): + col_sizes.setdefault(col_count, 0) + if col_packing: + x, y = col_packing.calc_size(self._translate) + if y > row_sizes[row_count]: + row_sizes[row_count] = y + if x > col_sizes[col_count]: + col_sizes[col_count] = x + return col_sizes, row_sizes + + def _find_child_at(self, x, y, width, height): + col_sizes, row_sizes = self._get_grid_sizes() + row_distance = 0 + for row_count, row in enumerate(self.table_multiarray): + col_distance = 0 + for col_count, packing in enumerate(row): + child_width, child_height = packing.calc_size(self._translate) + if packing.child: + if (col_distance <= x < col_distance + child_width + and row_distance <= y < row_distance + child_height): + return (packing.child, + col_distance, row_distance, + child_width, child_height) + col_distance += col_sizes[col_count] + self.col_spacing + row_distance += row_sizes[row_count] + self.row_spacing + + def _calc_size(self): + col_sizes, row_sizes = self._get_grid_sizes() + x = sum(col_sizes.values()) + ( + (self.col_length - 1) * self.col_spacing) + y = sum(row_sizes.values()) + ( + (self.row_length - 1) * self.row_spacing) + return x, y + + def _layout(self, context, x, y, width, height): + col_sizes, row_sizes = self._get_grid_sizes() + + row_distance = 0 + for row_count, row in enumerate(self.table_multiarray): + col_distance = 0 + for col_count, packing in enumerate(row): + if packing: + child_width, child_height = packing.calc_size( + self._translate) + packing.child.draw(context, + x + col_distance, y + row_distance, + child_width, child_height) + col_distance += col_sizes[col_count] + self.col_spacing + row_distance += row_sizes[row_count] + self.row_spacing + + def _translate(self, x, y): + return x, y + + +class Alignment(Packer): + """Positions a child inside a larger space. + """ + def __init__(self, child, xscale=1.0, yscale=1.0, xalign=0.0, yalign=0.0, + min_width=0, min_height=0): + self.child = child + self.xscale = xscale + self.yscale = yscale + self.xalign = xalign + self.yalign = yalign + self.min_width = min_width + self.min_height = min_height + + def _calc_size(self): + width, height = self.child.get_size() + return max(self.min_width, width), max(self.min_height, height) + + def _calc_child_position(self, width, height): + req_width, req_height = self.child.get_size() + child_width = req_width + self.xscale * (width-req_width) + child_height = req_height + self.yscale * (height-req_height) + child_x = round(self.xalign * (width - child_width)) + child_y = round(self.yalign * (height - child_height)) + return child_x, child_y, child_width, child_height + + def _layout(self, context, x, y, width, height): + child_x, child_y, child_width, child_height = \ + self._calc_child_position(width, height) + self.child.draw(context, x + child_x, y + child_y, child_width, + child_height) + + def _find_child_at(self, x, y, width, height): + child_x, child_y, child_width, child_height = \ + self._calc_child_position(width, height) + if ((child_x <= x < child_x + child_width) and + (child_y <= y < child_y + child_height)): + return self.child, child_x, child_y, child_width, child_height + else: + return None # (x, y) is in the empty space around child + +class DrawingArea(Packer): + """Area that uses custom drawing code. + """ + def __init__(self, width, height, callback, *args): + self.width = width + self.height = height + self.callback_info = (callback, args) + + def _calc_size(self): + return self.width, self.height + + def _layout(self, context, x, y, width, height): + callback, args = self.callback_info + callback(context, x, y, width, height, *args) + + def _find_child_at(self, x, y, width, height): + return None + +class Background(Packer): + """Draws a background behind a child element. + """ + def __init__(self, child, min_width=0, min_height=0, margin=None): + self.child = child + self.min_width = min_width + self.min_height = min_height + self.margin = Margin(margin) + self.callback_info = None + + def set_callback(self, callback, *args): + self.callback_info = (callback, args) + + def _calc_size(self): + width, height = self.child.get_size() + width = max(self.min_width, width) + height = max(self.min_height, height) + return self.margin.outer_size((width, height)) + + def _layout(self, context, x, y, width, height): + if self.callback_info: + callback, args = self.callback_info + callback(context, x, y, width, height, *args) + self.child.draw(context, *self.margin.inner_rect(x, y, width, height)) + + def _find_child_at(self, x, y, width, height): + if not self.margin.point_in_margin(x, y, width, height): + return None + return (self.child,) + self.margin.inner_rect(0, 0, width, height) + +class Padding(Packer): + """Adds padding to the edges of a packer. + """ + def __init__(self, child, top=0, right=0, bottom=0, left=0): + self.child = child + self.margin = Margin((top, right, bottom, left)) + + def _calc_size(self): + return self.margin.outer_size(self.child.get_size()) + + def _layout(self, context, x, y, width, height): + self.child.draw(context, *self.margin.inner_rect(x, y, width, height)) + + def _find_child_at(self, x, y, width, height): + if not self.margin.point_in_margin(x, y, width, height): + return None + return (self.child,) + self.margin.inner_rect(0, 0, width, height) + +class TextBoxPacker(Packer): + """Base class for ClippedTextLine and ClippedTextBox. + """ + def _layout(self, context, x, y, width, height): + self.textbox.draw(context, x, y, width, height) + + def _find_child_at(self, x, y, width, height): + # We could return the TextBox here, but we know it doesn't have a + # find_hotspot() method + return None + +class ClippedTextBox(TextBoxPacker): + """A TextBox that gets clipped if it's larger than it's allocated + width. + """ + def __init__(self, textbox, min_width=0, min_height=0): + self.textbox = textbox + self.min_width = min_width + self.min_height = min_height + + def _calc_size(self): + height = max(self.min_height, self.textbox.font.line_height()) + return self.min_width, height + +class ClippedTextLine(TextBoxPacker): + """A single line of text that gets clipped if it's larger than the + space allocated to it. By default the clipping will happen at character + boundaries. + """ + def __init__(self, textbox, min_width=0): + self.textbox = textbox + self.textbox.set_wrap_style('char') + self.min_width = min_width + + def _calc_size(self): + return self.min_width, self.textbox.font.line_height() + +class TruncatedTextLine(ClippedTextLine): + def __init__(self, textbox, min_width=0): + ClippedTextLine.__init__(self, textbox, min_width) + self.textbox.set_wrap_style('truncated-char') + +class Hotspot(Packer): + """A Hotspot handles mouse click tracking. It's only purpose is + to store a name to return from ``find_hotspot()``. In terms of + layout, it simply renders it's child in it's allocated space. + """ + def __init__(self, name, child): + self.name = name + self.child = child + + def _calc_size(self): + return self.child.get_size() + + def _layout(self, context, x, y, width, height): + self.child.draw(context, x, y, width, height) + + def find_hotspot(self, x, y, width, height): + return self.name, x, y, width, height + +class Stack(Packer): + """Packer that stacks other packers on top of each other. + """ + def __init__(self): + self.children = [] + + def pack(self, packer): + self.children.append(packer) + + def pack_below(self, packer): + self.children.insert(0, packer) + + def _layout(self, context, x, y, width, height): + for packer in self.children: + packer._layout(context, x, y, width, height) + + def _calc_size(self): + """Calculate the size needed to hold the box. The return value gets + cached and return in get_size(). + """ + width = height = 0 + for packer in self.children: + child_width, child_height = packer.get_size() + width = max(width, child_width) + height = max(height, child_height) + return width, height + + def _find_child_at(self, x, y, width, height): + # Return the topmost packer + try: + top = self.children[-1] + except IndexError: + return None + else: + return top._find_child_at(x, y, width, height) + +def align_left(packer): + """Align a packer to the left side of it's allocated space.""" + return Alignment(packer, xalign=0.0, xscale=0.0) + +def align_right(packer): + """Align a packer to the right side of it's allocated space.""" + return Alignment(packer, xalign=1.0, xscale=0.0) + +def align_top(packer): + """Align a packer to the top side of it's allocated space.""" + return Alignment(packer, yalign=0.0, yscale=0.0) + +def align_bottom(packer): + """Align a packer to the bottom side of it's allocated space.""" + return Alignment(packer, yalign=1.0, yscale=0.0) + +def align_middle(packer): + """Align a packer to the middle of it's allocated space.""" + return Alignment(packer, yalign=0.5, yscale=0.0) + +def align_center(packer): + """Align a packer to the center of it's allocated space.""" + return Alignment(packer, xalign=0.5, xscale=0.0) + +def pad(packer, top=0, left=0, bottom=0, right=0): + """Add padding to a packer.""" + return Padding(packer, top, right, bottom, left) + +class LayoutRect(object): + """Lightweight object use to track rectangles inside a layout + + :attribute x: top coordinate, read-write + :attribute y: left coordinate, read-write + :attribute width: width of the rect, read-write + :attribute height: height of the rect, read-write + """ + + def __init__(self, x, y, width, height): + self.x = x + self.y = y + self.width = width + self.height = height + + def __str__(self): + return "LayoutRect(%s, %s, %s, %s)" % (self.x, self.y, self.width, + self.height) + + def __eq__(self, other): + my_values = (self.x, self.y, self.width, self.height) + try: + other_values = (other.x, other.y, other.width, other.height) + except AttributeError: + return NotImplemented + return my_values == other_values + + def subsection(self, left, right, top, bottom): + """Create a new LayoutRect from inside this one.""" + return LayoutRect(self.x + left, self.y + top, + self.width - left - right, self.height - top - bottom) + + def right_side(self, width): + """Create a new LayoutRect from the right side of this one.""" + return LayoutRect(self.right - width, self.y, width, self.height) + + def left_side(self, width): + """Create a new LayoutRect from the left side of this one.""" + return LayoutRect(self.x, self.y, width, self.height) + + def top_side(self, height): + """Create a new LayoutRect from the top side of this one.""" + return LayoutRect(self.x, self.y, self.width, height) + + def bottom_side(self, height): + """Create a new LayoutRect from the bottom side of this one.""" + return LayoutRect(self.x, self.bottom - height, self.width, height) + + def past_right(self, width): + """Create a LayoutRect width pixels to the right of this one>""" + return LayoutRect(self.right, self.y, width, self.height) + + def past_left(self, width): + """Create a LayoutRect width pixels to the right of this one>""" + return LayoutRect(self.x-width, self.y, width, self.height) + + def past_top(self, height): + """Create a LayoutRect height pixels above this one>""" + return LayoutRect(self.x, self.y-height, self.width, height) + + def past_bottom(self, height): + """Create a LayoutRect height pixels below this one>""" + return LayoutRect(self.x, self.bottom, self.width, height) + + def is_point_inside(self, x, y): + return (self.x <= x < self.x + self.width + and self.y <= y < self.y + self.height) + + def get_right(self): + return self.x + self.width + def set_right(self, right): + self.width = right - self.x + right = property(get_right, set_right) + + def get_bottom(self): + return self.y + self.height + def set_bottom(self, bottom): + self.height = bottom - self.y + bottom = property(get_bottom, set_bottom) + +class Layout(object): + """Store the layout for a cell + + Layouts are lightweight objects that keep track of where stuff is inside a + cell. They can be used for both rendering and hotspot tracking. + + :attribute last_rect: the LayoutRect most recently added to the layout + """ + + def __init__(self): + self._rects = [] + self.last_rect = None + + def rect_count(self): + """Get the number of rects in this layout.""" + return len(self._rects) + + def add(self, x, y, width, height, drawing_function=None, + hotspot=None): + """Add a new element to this Layout + + :param x: x coordinate + :param y: y coordinate + :param width: width + :param height: height + :param drawing_function: if set, call this function to render the + element on a DrawingContext + :param hotspot: if set, the hotspot for this element + + :returns: LayoutRect of the added element + """ + return self.add_rect(LayoutRect(x, y, width, height), + drawing_function, hotspot) + + def add_rect(self, layout_rect, drawing_function=None, hotspot=None): + """Add a new element to this Layout using a LayoutRect + + :param layout_rect: LayoutRect object for positioning + :param drawing_function: if set, call this function to render the + element on a DrawingContext + :param hotspot: if set, the hotspot for this element + :returns: LayoutRect of the added element + """ + self.last_rect = layout_rect + value = (layout_rect, drawing_function, hotspot) + self._rects.append(value) + return layout_rect + + def add_text_line(self, textbox, x, y, width, hotspot=None): + """Add one line of text from a text box to the layout + + This is convenience method that's equivelent to: + self.add(x, y, width, textbox.font.line_height(), textbox.draw, + hotspot) + """ + return self.add(x, y, width, textbox.font.line_height(), textbox.draw, + hotspot) + + def add_image(self, image, x, y, hotspot=None): + """Add an ImageSurface to the layout + + This is convenience method that's equivelent to: + self.add(x, y, image.width, image.height, image.draw, hotspot) + """ + width, height = image.get_size() + return self.add(x, y, width, height, image.draw, hotspot) + + def merge(self, layout): + """Add another layout's elements with this one + """ + self._rects.extend(layout._rects) + self.last_rect = layout.last_rect + + def translate(self, delta_x, delta_y): + """Move each element inside this layout """ + for rect, _, _ in self._rects: + rect.x += delta_x + rect.y += delta_y + + def max_width(self): + """Get the max width of the elements in current group.""" + return max(rect.width for (rect, _, _) in self._rects) + + def max_height(self): + """Get the max height of the elements in current group.""" + return max(rect.height for (rect, _, _) in self._rects) + + def center_x(self, left=None, right=None): + """Center each rect inside this layout horizontally. + + The left and right arguments control the area to center the rects to. + If one is missing, it will be calculated using largest width of the + layout. If both are missing, a ValueError will be thrown. + + :param left: left-side of the area to center to + :param right: right-side of the area to center to + """ + if left is None: + if right is None: + raise ValueError("both left and right are None") + left = right - self.max_width() + elif right is None: + right = left + self.max_width() + area_width = right - left + for rect, _, _ in self._rects: + rect.x = left + (area_width - rect.width) // 2 + + def center_y(self, top=None, bottom=None): + """Center each rect inside this layout vertically. + + The top and bottom arguments control the area to center the rects to. + If one is missing, it will be calculated using largest height in the + layout. If both are missing, a ValueError will be thrown. + + :param top: top of the area to center to + :param bottom: bottom of the area to center to + """ + if top is None: + if bottom is None: + raise ValueError("both top and bottom are None") + top = bottom - self.max_height() + elif bottom is None: + bottom = top + self.max_height() + area_height = bottom - top + for rect, _, _ in self._rects: + rect.y = top + (area_height - rect.height) // 2 + + def find_hotspot(self, x, y): + """Find a hotspot inside our rects. + + If (x, y) is inside any of the rects for this layout and that rect has + a hotspot set, a 3-tuple containing the hotspot name, and the x, y + coordinates relative to the hotspot rect. If no rect is found, we + return None. + + :param x: x coordinate to check + :param y: y coordinate to check + """ + for rect, drawing_function, hotspot in self._rects: + if hotspot is not None and rect.is_point_inside(x, y): + return hotspot, x - rect.x, y - rect.y + return None + + def draw(self, context): + """Render each layout rect onto context + + :param context: a DrawingContext to draw on + """ + + for rect, drawing_function, hotspot in self._rects: + if drawing_function is not None: + drawing_function(context, rect.x, rect.y, rect.width, + rect.height) diff --git a/lvc/widgets/dialogs.py b/lvc/widgets/dialogs.py new file mode 100644 index 0000000..b6b2b70 --- /dev/null +++ b/lvc/widgets/dialogs.py @@ -0,0 +1,276 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""``miro.frontends.widgets.dialogs`` -- Dialog boxes for the Widget +frontend. + +The difference between this module and rundialog.py is that rundialog +handles dialog boxes that are coming from the backend code. This +model handles dialogs that we create from the frontend + +One big difference is that we don't have to be as general about +dialogs, so they can present a somewhat nicer API. One important +difference is that all of the dialogs run modally. +""" + +from lvc.widgets import widgetset +from lvc.widgets import widgetutil + +class DialogButton(object): + def __init__(self, text): + self._text = text + def __eq__(self, other): + return isinstance(other, DialogButton) and self.text == other.text + def __str__(self): + return "DialogButton(%r)" % self.text + @property + def text(self): + return unicode(self._text) + +BUTTON_OK = DialogButton("OK") +BUTTON_APPLY = DialogButton("Apply") +BUTTON_CLOSE = DialogButton("Close") +BUTTON_CANCEL = DialogButton("Cancel") +BUTTON_DONE = DialogButton("Done") +BUTTON_YES = DialogButton("Yes") +BUTTON_NO = DialogButton("No") +BUTTON_QUIT = DialogButton("Quit") +BUTTON_CONTINUE = DialogButton("Continue") +BUTTON_IGNORE = DialogButton("Ignore") +BUTTON_IMPORT_FILES = DialogButton("Import Files") +BUTTON_SUBMIT_REPORT = DialogButton("Submit Crash Report") +BUTTON_MIGRATE = DialogButton("Migrate") +BUTTON_DONT_MIGRATE = DialogButton("Don't Migrate") +BUTTON_DOWNLOAD = DialogButton("Download") +BUTTON_REMOVE_ENTRY = DialogButton("Remove Entry") +BUTTON_DELETE_FILE = DialogButton("Delete File") +BUTTON_DELETE_FILES = DialogButton("Delete Files") +BUTTON_KEEP_VIDEOS = DialogButton("Keep Videos") +BUTTON_DELETE_VIDEOS = DialogButton("Delete Videos") +BUTTON_CREATE = DialogButton("Create") +BUTTON_CREATE_FEED = DialogButton("Create Podcast") +BUTTON_CREATE_FOLDER = DialogButton("Create Folder") +BUTTON_CHOOSE_NEW_FOLDER = DialogButton("Choose New Folder") +BUTTON_ADD_FOLDER = DialogButton("Add Folder") +BUTTON_ADD = DialogButton("Add") +BUTTON_ADD_INTO_NEW_FOLDER = DialogButton("Add Into New Folder") +BUTTON_KEEP = DialogButton("Keep") +BUTTON_DELETE = DialogButton("Delete") +BUTTON_REMOVE = DialogButton("Remove") +BUTTON_NOT_NOW = DialogButton("Not Now") +BUTTON_CLOSE_TO_TRAY = DialogButton("Close to Tray") +BUTTON_LAUNCH_MIRO = DialogButton("Launch Miro") +BUTTON_DOWNLOAD_ANYWAY = DialogButton("Download Anyway") +BUTTON_OPEN_IN_EXTERNAL_BROWSER = DialogButton( + "Open in External Browser") +BUTTON_DONT_INSTALL = DialogButton("Don't Install") +BUTTON_SUBSCRIBE = DialogButton("Subscribe") +BUTTON_STOP_WATCHING = DialogButton("Stop Watching") +BUTTON_RETRY = DialogButton("Retry") +BUTTON_START_FRESH = DialogButton("Start Fresh") +BUTTON_INCLUDE_DATABASE = DialogButton("Include Database") +BUTTON_DONT_INCLUDE_DATABASE = DialogButton( + "Don't Include Database") + +WARNING_MESSAGE = 0 +INFO_MESSAGE = 1 +CRITICAL_MESSAGE = 2 + + +class ProgressDialog(widgetset.Dialog): + def __init__(self, title): + widgetset.Dialog.__init__(self, title, description='') + self.progress_bar = widgetset.ProgressBar() + self.label = widgetset.Label() + self.label.set_size(1.2) + self.vbox = widgetset.VBox(spacing=6) + self.vbox.pack_end(widgetutil.align_center(self.label)) + self.vbox.pack_end(self.progress_bar) + self.set_extra_widget(self.vbox) + + def update(self, description, progress): + self.label.set_text(description) + if progress >= 0: + self.progress_bar.set_progress(progress) + self.progress_bar.stop_pulsing() + else: + self.progress_bar.start_pulsing() + +class DBUpgradeProgressDialog(widgetset.Dialog): + def __init__(self, title, text): + widgetset.Dialog.__init__(self, title) + self.progress_bar = widgetset.ProgressBar() + self.top_label = widgetset.Label() + self.top_label.set_text(text) + self.top_label.set_wrap(True) + self.top_label.set_size_request(350, -1) + self.label = widgetset.Label() + self.vbox = widgetset.VBox(spacing=6) + self.vbox.pack_end(widgetutil.align_center(self.label)) + self.vbox.pack_end(self.progress_bar) + self.vbox.pack_end(widgetutil.pad(self.top_label, bottom=6)) + self.set_extra_widget(self.vbox) + + def update(self, stage, stage_progress, progress): + self.label.set_text(stage) + self.progress_bar.set_progress(progress) + +def show_about(): + window = widgetset.AboutDialog() + set_transient_for_main(window) + try: + window.run() + finally: + window.destroy() + +def show_message(title, description, alert_type=INFO_MESSAGE, + transient_for=None): + """Display a message to the user and wait for them to click OK""" + window = widgetset.AlertDialog(title, description, alert_type) + _set_transient_for(window, transient_for) + try: + window.add_button(BUTTON_OK.text) + window.run() + finally: + window.destroy() + +def show_choice_dialog(title, description, choices, transient_for=None): + """Display a message to the user and wait for them to choose an option. + Returns the button object chosen.""" + window = widgetset.Dialog(title, description) + try: + for mem in choices: + window.add_button(mem.text) + response = window.run() + return choices[response] + finally: + window.destroy() + +def ask_for_string(title, description, initial_text=None, transient_for=None): + """Ask the user to enter a string in a TextEntry box. + + description - textual description with newlines + initial_text - None, string or callable to pre-populate the entry box + + Returns the value entered, or None if the user clicked cancel + """ + window = widgetset.Dialog(title, description) + try: + window.add_button(BUTTON_OK.text) + window.add_button(BUTTON_CANCEL.text) + entry = widgetset.TextEntry() + entry.set_activates_default(True) + if initial_text: + if callable(initial_text): + initial_text = initial_text() + entry.set_text(initial_text) + window.set_extra_widget(entry) + response = window.run() + if response == 0: + return entry.get_text() + else: + return None + finally: + window.destroy() + +def ask_for_choice(title, description, choices): + """Ask the user to enter a string in a TextEntry box. + + :param title: title for the window + :param description: textual description with newlines + :param choices: list of labels for choices + Returns the index of the value chosen, or None if the user clicked cancel + """ + window = widgetset.Dialog(title, description) + try: + window.add_button(BUTTON_OK.text) + window.add_button(BUTTON_CANCEL.text) + menu = widgetset.OptionMenu(choices) + window.set_extra_widget(menu) + response = window.run() + if response == 0: + return menu.get_selected() + else: + return None + finally: + window.destroy() + +def ask_for_open_pathname(title, initial_filename=None, filters=[], + transient_for=None, select_multiple=False): + """Returns the file pathname or None. + """ + window = widgetset.FileOpenDialog(title) + _set_transient_for(window, transient_for) + try: + if initial_filename: + window.set_filename(initial_filename) + + if filters: + window.add_filters(filters) + + if select_multiple: + window.set_select_multiple(select_multiple) + + response = window.run() + if response == 0: + if select_multiple: + return window.get_filenames() + else: + return window.get_filename() + finally: + window.destroy() + +def ask_for_save_pathname(title, initial_filename=None, transient_for=None): + """Returns the file pathname or None. + """ + window = widgetset.FileSaveDialog(title) + _set_transient_for(window, transient_for) + try: + if initial_filename: + window.set_filename(initial_filename) + response = window.run() + if response == 0: + return window.get_filename() + finally: + window.destroy() + +def ask_for_directory(title, initial_directory=None, transient_for=None): + """Returns the directory pathname or None. + """ + window = widgetset.DirectorySelectDialog(title) + _set_transient_for(window, transient_for) + try: + if initial_directory: + window.set_directory(initial_directory) + + response = window.run() + if response == 0: + return window.get_directory() + finally: + window.destroy() diff --git a/lvc/widgets/gtk/__init__.py b/lvc/widgets/gtk/__init__.py new file mode 100644 index 0000000..e3d666b --- /dev/null +++ b/lvc/widgets/gtk/__init__.py @@ -0,0 +1,65 @@ +import os +import sys +import gtk +import gobject + +def initialize(app): + from gtkmenus import MainWindowMenuBar + app.menubar = MainWindowMenuBar() + app.startup() + app.run() + +def attach_menubar(): + from lvc.widgets import app + app.widgetapp.vbox.pack_start(app.widgetapp.menubar) + +def mainloop_start(): + gobject.threads_init() + gtk.main() + +def mainloop_stop(): + gtk.main_quit() + +def idle_add(callback, periodic=None): + if periodic is not None and periodic < 0: + raise ValueError('periodic cannot be negative') + def wrapper(): + callback() + return periodic is not None + delay = periodic + if delay is not None: + delay *= 1000 # milliseconds + else: + delay = 0 + return gobject.timeout_add(delay, wrapper) + +def idle_remove(id_): + gobject.source_remove(id_) + +def check_kde(): + return os.environ.get("KDE_FULL_SESSION", None) != None + +def open_file_linux(filename): + if check_kde(): + os.spawnlp(os.P_NOWAIT, "kfmclient", "kfmclient", # kfmclient is part of konqueror + "exec", "file://" + filename) + else: + os.spawnlp(os.P_NOWAIT, "gnome-open", "gnome-open", filename) + +def reveal_file(filename): + if hasattr(os, 'startfile'): # Windows + os.startfile(os.path.dirname(filename)) + else: + open_file_linux(filename) + +def get_conversion_directory_windows(): + from lvc.windows import specialfolders + return specialfolders.base_movies_directory + +def get_conversion_directory_linux(): + return os.path.expanduser('~') + +if sys.platform == 'win32': + get_conversion_directory = get_conversion_directory_windows +else: + get_conversion_directory = get_conversion_directory_linux diff --git a/lvc/widgets/gtk/base.py b/lvc/widgets/gtk/base.py new file mode 100644 index 0000000..ed6129f --- /dev/null +++ b/lvc/widgets/gtk/base.py @@ -0,0 +1,300 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".base -- Base classes for GTK Widgets.""" + +import gtk + +from lvc import signals +import wrappermap +from .weakconnect import weak_connect +import keymap + +def make_gdk_color(miro_color): + def convert_value(value): + return int(round(value * 65535)) + + values = tuple(convert_value(c) for c in miro_color) + return gtk.gdk.Color(*values) + +class Widget(signals.SignalEmitter): + """Base class for GTK widgets. + + The actual GTK Widget is stored in '_widget'. + + signals: + + 'size-allocated' (widget, width, height): The widget had it's size + allocated. + """ + def __init__(self, *signal_names): + signals.SignalEmitter.__init__(self, *signal_names) + self.create_signal('size-allocated') + self.create_signal('key-press') + self.create_signal('focus-out') + self.style_mods = {} + self.use_custom_style = False + self._disabled = False + + def wrapped_widget_connect(self, signal, method, *user_args): + """Connect to a signal of the widget we're wrapping. + + We use a weak reference to ensures that we don't have circular + references between the wrapped widget and the wrapper widget. + """ + return weak_connect(self._widget, signal, method, *user_args) + + def set_widget(self, widget): + self._widget = widget + wrappermap.add(self._widget, self) + if self.should_connect_to_hierarchy_changed(): + self.wrapped_widget_connect('hierarchy_changed', + self.on_hierarchy_changed) + self.wrapped_widget_connect('size-allocate', self.on_size_allocate) + self.wrapped_widget_connect('key-press-event', self.on_key_press) + self.wrapped_widget_connect('focus-out-event', self.on_focus_out) + self.use_custom_style_callback = None + + def should_connect_to_hierarchy_changed(self): + # GTK creates windows to handle submenus, which messes with our + # on_hierarchy_changed callback. We don't care about custom styles + # for menus anyways, so just ignore the signal. + return not isinstance(self._widget, gtk.MenuItem) + + def set_can_focus(self, allow): + """Set if we allow the widget to hold keyboard focus. + """ + if allow: + self._widget.set_flags(gtk.CAN_FOCUS) + else: + self._widget.unset_flags(gtk.CAN_FOCUS) + + def on_hierarchy_changed(self, widget, previous_toplevel): + toplevel = widget.get_toplevel() + if not (toplevel.flags() & gtk.TOPLEVEL): + toplevel = None + if previous_toplevel != toplevel: + if self.use_custom_style_callback: + old_window = wrappermap.wrapper(previous_toplevel) + old_window.disconnect(self.use_custom_style_callback) + if toplevel is not None: + window = wrappermap.wrapper(toplevel) + callback_id = window.connect('use-custom-style-changed', + self.on_use_custom_style_changed) + self.use_custom_style_callback = callback_id + else: + self.use_custom_style_callback = None + if previous_toplevel is None: + # Setup our initial state + self.on_use_custom_style_changed(window) + + def on_size_allocate(self, widget, allocation): + self.emit('size-allocated', allocation.width, allocation.height) + + def on_key_press(self, widget, event): + key_modifiers = keymap.translate_gtk_event(event) + if key_modifiers: + key, modifiers = key_modifiers + return self.emit('key-press', key, modifiers) + + def on_focus_out(self, widget, event): + self.emit('focus-out') + + def on_use_custom_style_changed(self, window): + self.use_custom_style = window.use_custom_style + if not self.style_mods: + return # no need to do any work here + if self.use_custom_style: + for (what, state), color in self.style_mods.items(): + self.do_modify_style(what, state, color) + else: + # This should reset the style changes we've made + self._widget.modify_style(gtk.RcStyle()) + self.handle_custom_style_change() + + def handle_custom_style_change(self): + """Called when the user changes a from a theme where we don't want to + use our custom style to one where we do, or vice-versa. The Widget + class handles changes that used modify_style(), but subclasses might + want to do additional work. + """ + pass + + def modify_style(self, what, state, color): + """Change the style of our widget. This method checks to see if we + think the user's theme is compatible with our stylings, and doesn't + change things if not. what is either 'base', 'text', 'bg' or 'fg' + depending on which color is to be changed. + """ + if self.use_custom_style: + self.do_modify_style(what, state, color) + self.style_mods[(what, state)] = color + + def unmodify_style(self, what, state): + if (what, state) in self.style_mods: + del self.style_mods[(what, state)] + default_color = getattr(self.style, what)[state] + self.do_modify_style(what, state, default_color) + + def do_modify_style(self, what, state, color): + if what == 'base': + self._widget.modify_base(state, color) + elif what == 'text': + self._widget.modify_text(state, color) + elif what == 'bg': + self._widget.modify_bg(state, color) + elif what == 'fg': + self._widget.modify_fg(state, color) + else: + raise ValueError("Unknown what in do_modify_style: %s" % what) + + def get_window(self): + gtk_window = self._widget.get_toplevel() + return wrappermap.wrapper(gtk_window) + + def clear_size_request_cache(self): + # This is just an OS X hack + pass + + def get_size_request(self): + return self._widget.size_request() + + def invalidate_size_request(self): + self._widget.queue_resize() + + def set_size_request(self, width, height): + if not width >= -1 and height >= -1: + raise ValueError("invalid dimensions in set_size_request: %s" % + repr((width, height))) + self._widget.set_size_request(width, height) + + def relative_position(self, other_widget): + return other_widget._widget.translate_coordinates(self._widget, 0, 0) + + def convert_gtk_color(self, color): + return (color.red / 65535.0, color.green / 65535.0, + color.blue / 65535.0) + + def get_width(self): + try: + return self._widget.allocation.width + except AttributeError: + return -1 + width = property(get_width) + + def get_height(self): + try: + return self._widget.allocation.height + except AttributeError: + return -1 + height = property(get_height) + + def queue_redraw(self): + if self._widget: + self._widget.queue_draw() + + def redraw_now(self): + if self._widget: + self._widget.queue_draw() + self._widget.window.process_updates(True) + + def forward_signal(self, signal_name, forwarded_signal_name=None): + """Add a callback so that when the GTK widget emits a signal, we emit + signal from the wrapper widget. + """ + if forwarded_signal_name is None: + forwarded_signal_name = signal_name + self.wrapped_widget_connect(signal_name, self.do_forward_signal, + forwarded_signal_name) + + def do_forward_signal(self, widget, *args): + forwarded_signal_name = args[-1] + args = args[:-1] + self.emit(forwarded_signal_name, *args) + + def make_color(self, miro_color): + color = make_gdk_color(miro_color) + self._widget.get_colormap().alloc_color(color) + return color + + def enable(self): + self._disabled = False + self._widget.set_sensitive(True) + + def disable(self): + self._disabled = True + self._widget.set_sensitive(False) + + def set_disabled(self, disabled): + if disabled: + self.disable() + else: + self.enable() + + def get_disabled(self): + return self._disabled + +class Bin(Widget): + def __init__(self): + Widget.__init__(self) + self.child = None + + def add(self, child): + if self.child is not None: + raise ValueError("Already have a child: %s" % self.child) + if child._widget.parent is not None: + raise ValueError("%s already has a parent" % child) + self.child = child + self.add_child_to_widget() + child._widget.show() + + def add_child_to_widget(self): + self._widget.add(self.child._widget) + + def remove_child_from_widget(self): + if self._widget.get_child() is not None: + # otherwise gtkmozembed gets confused + self._widget.get_child().hide() + self._widget.remove(self._widget.get_child()) + + + def remove(self): + if self.child is not None: + self.child = None + self.remove_child_from_widget() + + def set_child(self, new_child): + self.remove() + self.add(new_child) + + def enable(self): + self.child.enable() + + def disable(self): + self.child.disable() diff --git a/lvc/widgets/gtk/const.py b/lvc/widgets/gtk/const.py new file mode 100644 index 0000000..5e9ec05 --- /dev/null +++ b/lvc/widgets/gtk/const.py @@ -0,0 +1,44 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".const -- Constants.""" + +import gtk + +DRAG_ACTION_NONE = 0 +DRAG_ACTION_COPY = gtk.gdk.ACTION_COPY +DRAG_ACTION_MOVE = gtk.gdk.ACTION_MOVE +DRAG_ACTION_LINK = gtk.gdk.ACTION_LINK +DRAG_ACTION_ALL = DRAG_ACTION_COPY | DRAG_ACTION_MOVE | DRAG_ACTION_LINK + +ITEM_TITLE_FONT = "Helvetica" +ITEM_DESC_FONT = "Helvetica" +ITEM_INFO_FONT = "Lucida Grande" + +TOOLBAR_GRAY = (0.2, 0.2, 0.2) diff --git a/lvc/widgets/gtk/contextmenu.py b/lvc/widgets/gtk/contextmenu.py new file mode 100644 index 0000000..cd5b6ba --- /dev/null +++ b/lvc/widgets/gtk/contextmenu.py @@ -0,0 +1,31 @@ +import gtk + +from .base import Widget + +class ContextMenu(Widget): + + def __init__(self, options): + super(ContextMenu, self).__init__() + self.set_widget(gtk.Menu()) + for i, item_info in enumerate(options): + if item_info is None: + # separator + item = gtk.SeparatorMenuItem() + else: + label, callback = item_info + item = gtk.MenuItem(label) + if isinstance(callback, list): + submenu = ContextMenu(callback) + item.set_submenu(submenu._widget) + elif callback is not None: + item.connect('activate', self.on_activate, callback, i) + else: + item.set_sensitive(False) + self._widget.append(item) + item.show() + + def popup(self): + self._widget.popup(None, None, None, 0, 0) + + def on_activate(self, widget, callback, i): + callback(self, i) diff --git a/lvc/widgets/gtk/controls.py b/lvc/widgets/gtk/controls.py new file mode 100644 index 0000000..26ce6d6 --- /dev/null +++ b/lvc/widgets/gtk/controls.py @@ -0,0 +1,337 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".controls -- Control Widgets.""" + +import gtk +import pango + +from lvc.widgets import widgetconst +import layout +from .base import Widget +from .simple import Label + +class BinBaselineCalculator(object): + """Mixin class that defines the baseline method for gtk.Bin subclasses, + where the child is the label that we are trying to get the baseline for. + """ + + def baseline(self): + my_size = self._widget.size_request() + child_size = self._widget.child.size_request() + ypad = (my_size[1] - child_size[1]) / 2 + + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + +class TextEntry(Widget): + entry_class = gtk.Entry + def __init__(self, initial_text=None): + Widget.__init__(self) + self.create_signal('activate') + self.create_signal('changed') + self.create_signal('validate') + self.set_widget(self.entry_class()) + self.forward_signal('activate') + self.forward_signal('changed') + if initial_text is not None: + self.set_text(initial_text) + + def focus(self): + self._widget.grab_focus() + + def start_editing(self, text): + self.set_text(text) + self.focus() + self._widget.emit('move-cursor', gtk.MOVEMENT_BUFFER_ENDS, 1, False) + + def set_text(self, text): + self._widget.set_text(text) + + def get_text(self): + return self._widget.get_text().decode('utf-8') + + def set_max_length(self, chars): + self._widget.set_max_length(chars) + + def set_width(self, chars): + self._widget.set_width_chars(chars) + + def set_invisible(self, setting): + self._widget.props.visibility = not setting + + def set_activates_default(self, setting): + self._widget.set_activates_default(setting) + + def baseline(self): + layout_height = pango.PIXELS(self._widget.get_layout().get_size()[1]) + ypad = (self._widget.size_request()[1] - layout_height) / 2 + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + + +class NumberEntry(TextEntry): + def __init__(self, initial_text=None): + TextEntry.__init__(self, initial_text) + self._widget.connect('changed', self.validate) + self.previous_text = initial_text or "" + + def validate(self, entry): + text = self.get_text() + if text.isdigit() or not text: + self.previous_text = text + else: + self._widget.set_text(self.previous_text) + +class SecureTextEntry(TextEntry): + def __init__(self, initial_text=None): + TextEntry.__init__(self, initial_text) + self.set_invisible(True) + +class MultilineTextEntry(Widget): + entry_class = gtk.TextView + def __init__(self, initial_text=None, border=False): + Widget.__init__(self) + self.set_widget(self.entry_class()) + if initial_text is not None: + self.set_text(initial_text) + self._widget.set_wrap_mode(gtk.WRAP_WORD) + self._widget.set_accepts_tab(False) + self.border = border + + def focus(self): + self._widget.grab_focus() + + def set_text(self, text): + self._widget.get_buffer().set_text(text) + + def get_text(self): + buffer_ = self._widget.get_buffer() + return buffer_.get_text(*(buffer_.get_bounds())).decode('utf-8') + + def baseline(self): + # FIXME + layout_height = pango.PIXELS(self._widget.get_layout().get_size()[1]) + ypad = (self._widget.size_request()[1] - layout_height) / 2 + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + + def set_editable(self, editable): + self._widget.set_editable(editable) + +class Checkbox(Widget, BinBaselineCalculator): + """Widget that the user can toggle on or off.""" + + def __init__(self, text=None, bold=False, color=None): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + if text is None: + text = '' + self.set_widget(gtk.CheckButton()) + self.label = Label(text, color=color) + self._widget.add(self.label._widget) + self.label._widget.show() + self.create_signal('toggled') + self.forward_signal('toggled') + if bold: + self.label.set_bold(True) + + def get_checked(self): + return self._widget.get_active() + + def set_checked(self, value): + self._widget.set_active(value) + + def set_size(self, scale_factor): + self.label.set_size(scale_factor) + + def get_text_padding(self): + """ + Returns the amount of space the checkbox takes up before the label. + """ + indicator_size = self._widget.style_get_property('indicator-size') + indicator_spacing = self._widget.style_get_property( + 'indicator-spacing') + focus_width = self._widget.style_get_property('focus-line-width') + focus_padding = self._widget.style_get_property('focus-padding') + return (indicator_size + 3 * indicator_spacing + 2 * (focus_width + + focus_padding)) + +class RadioButtonGroup(Widget, BinBaselineCalculator): + """RadioButtonGroup. + + Create the group, then create a bunch of RadioButtons passing in the group. + + NB: GTK has built-in radio button grouping functionality, and we should + be using that but we need this widget for portable code. We create + a dummy GTK radio button and make this the "root" button which gets + inherited by all buttons in this radio button group. + """ + def __init__(self): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + self.set_widget(gtk.RadioButton(label="")) + self._widget.set_active(False) + self._buttons = [] + + def add_button(self, button): + self._buttons.append(button) + + def get_buttons(self): + return self._buttons + + def get_selected(self): + for mem in self._buttons: + if mem.get_selected(): + return mem + + def set_selected(self, button): + for mem in self._buttons: + if mem is button: + mem._widget.set_active(True) + else: + mem._widget.set_active(False) + +class RadioButton(Widget, BinBaselineCalculator): + """RadioButton.""" + def __init__(self, label, group=None, color=None): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + if group: + self.group = group + else: + self.group = RadioButtonGroup() + self.set_widget(gtk.RadioButton(group=self.group._widget)) + self.label = Label(label, color=color) + self._widget.add(self.label._widget) + self.label._widget.show() + self.create_signal('clicked') + self.forward_signal('clicked') + + group.add_button(self) + + def set_size(self, size): + self.label.set_size(size) + + def get_group(self): + return self.group + + def get_selected(self): + return self._widget.get_active() + + def set_selected(self): + self.group.set_selected(self) + +class Button(Widget, BinBaselineCalculator): + def __init__(self, text, style='normal', width=None): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + # We just ignore style here, GTK users expect their own buttons. + self.set_widget(gtk.Button()) + self.create_signal('clicked') + self.forward_signal('clicked') + self.label = Label(text) + # only honor width if its bigger than the width we need to display the + # label (#18994) + if width and width > self.label.get_width(): + alignment = layout.Alignment(0.5, 0.5, 0, 0) + alignment.set_size_request(width, -1) + alignment.add(self.label) + self._widget.add(alignment._widget) + else: + self._widget.add(self.label._widget) + self.label._widget.show() + + def set_text(self, title): + self.label.set_text(title) + + def set_bold(self, bold): + self.label.set_bold(bold) + + def set_size(self, scale_factor): + self.label.set_size(scale_factor) + + def set_color(self, color): + self.label.set_color(color) + +class OptionMenu(Widget): + def __init__(self, options): + Widget.__init__(self) + self.create_signal('changed') + + self.set_widget(gtk.ComboBox(gtk.ListStore(str, str))) + self.cell = gtk.CellRendererText() + self._widget.pack_start(self.cell, True) + self._widget.add_attribute(self.cell, 'text', 0) + if options: + for option, value in options: + self._widget.get_model().append((option, value)) + self._widget.set_active(0) + self.options = options + self.wrapped_widget_connect('changed', self.on_changed) + + def baseline(self): + my_size = self._widget.size_request() + child_size = self._widget.child.size_request() + ypad = self.cell.props.ypad + (my_size[1] - child_size[1]) / 2 + + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + + def set_bold(self, bold): + if bold: + self.cell.props.weight = pango.WEIGHT_BOLD + else: + self.cell.props.weight = pango.WEIGHT_NORMAL + + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self.cell.props.scale = 1 + else: + self.cell.props.scale = 0.75 + + def set_color(self, color): + self.cell.props.foreground_gdk = self.make_color(color) + + def set_selected(self, index): + self._widget.set_active(index) + + def get_selected(self): + return self._widget.get_active() + + def on_changed(self, widget): + index = widget.get_active() + self.emit('changed', index) + + def set_width(self, width): + self._widget.set_property('width-request', width) diff --git a/lvc/widgets/gtk/customcontrols.py b/lvc/widgets/gtk/customcontrols.py new file mode 100644 index 0000000..ff5b068 --- /dev/null +++ b/lvc/widgets/gtk/customcontrols.py @@ -0,0 +1,517 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".controls -- Contains the ControlBox and +CustomControl classes. These handle the custom buttons/sliders used during +playback. +""" + +from __future__ import division +import math + +import gtk +import gobject + +import wrappermap +from .base import Widget +from .simple import Label, Image +from .drawing import (CustomDrawingMixin, Drawable, + ImageSurface) +from lvc.widgets import widgetconst + +class CustomControlMixin(CustomDrawingMixin): + def do_expose_event(self, event): + CustomDrawingMixin.do_expose_event(self, event) + if self.is_focus(): + style = self.get_style() + style.paint_focus(self.window, self.state, + event.area, self, None, self.allocation.x, + self.allocation.y, self.allocation.width, + self.allocation.height) + +class CustomButtonWidget(CustomControlMixin, gtk.Button): + def draw(self, wrapper, context): + if self.is_active(): + wrapper.state = 'pressed' + elif self.state == gtk.STATE_PRELIGHT: + wrapper.state = 'hover' + else: + wrapper.state = 'normal' + wrapper.draw(context, wrapper.layout_manager) + self.set_focus_on_click(False) + + def is_active(self): + return self.state == gtk.STATE_ACTIVE + +class ContinuousCustomButtonWidget(CustomButtonWidget): + def is_active(self): + return (self.state == gtk.STATE_ACTIVE or + wrappermap.wrapper(self).button_down) + +class DragableCustomButtonWidget(CustomButtonWidget): + def __init__(self): + CustomButtonWidget.__init__(self) + self.button_press_x = None + self.set_events(self.get_events() | gtk.gdk.POINTER_MOTION_MASK) + + def do_button_press_event(self, event): + self.button_press_x = event.x + self.last_drag_event = None + gtk.Button.do_button_press_event(self, event) + + def do_button_release_event(self, event): + self.button_press_x = None + gtk.Button.do_button_release_event(self, event) + + def do_motion_notify_event(self, event): + DRAG_THRESHOLD = 15 + if self.button_press_x is None: + # button not down + return + if (self.last_drag_event != 'right' and + event.x > self.button_press_x + DRAG_THRESHOLD): + wrappermap.wrapper(self).emit('dragged-right') + self.last_drag_event = 'right' + elif (self.last_drag_event != 'left' and + event.x < self.button_press_x - DRAG_THRESHOLD): + wrappermap.wrapper(self).emit('dragged-left') + self.last_drag_event = 'left' + + def do_clicked(self): + # only emit clicked if we didn't emit dragged-left or dragged-right + if self.last_drag_event is None: + wrappermap.wrapper(self).emit('clicked') + +class _DragInfo(object): + """Info about the start of a drag. + + Attributes: + + - button: button that started the drag + - start_pos: position of the slider + - click_pos: position of the click + + Note that start_pos and click_pos will be different if the user clicks + inside the slider. + """ + + def __init__(self, button, start_pos, click_pos): + self.button = button + self.start_pos = start_pos + self.click_pos = click_pos + +class CustomScaleMixin(CustomControlMixin): + def __init__(self): + CustomControlMixin.__init__(self) + self.drag_info = None + self.min = self.max = 0.0 + + def get_range(self): + return self.min, self.max + + def set_range(self, min, max): + self.min = float(min) + self.max = float(max) + gtk.Range.set_range(self, min, max) + + def is_continuous(self): + return wrappermap.wrapper(self).is_continuous() + + def is_horizontal(self): + # this comes from a mixin + pass + + def gtk_scale_class(self): + if self.is_horizontal(): + return gtk.HScale + else: + return gtk.VScale + + def get_slider_pos(self, value=None): + if value is None: + value = self.get_value() + if self.is_horizontal(): + size = self.allocation.width + else: + size = self.allocation.height + ratio = (float(value) - self.min) / (self.max - self.min) + start_pos = self.slider_size() / 2.0 + return start_pos + ratio * (size - self.slider_size()) + + def slider_size(self): + return wrappermap.wrapper(self).slider_size() + + def _event_pos(self, event): + """Get the position of an event. + + If we are horizontal, this will be the x coordinate. If we are + vertical, the y. + """ + if self.is_horizontal(): + return event.x + else: + return event.y + + def do_button_press_event(self, event): + if self.drag_info is not None: + return + current_pos = self.get_slider_pos() + event_pos = self._event_pos(event) + pos_difference = abs(current_pos - event_pos) + # only move the slider if the click was outside its boundaries + # (#18840) + if pos_difference > self.slider_size() / 2.0: + self.move_slider(event_pos) + current_pos = event_pos + self.drag_info = _DragInfo(event.button, current_pos, event_pos) + self.grab_focus() + wrappermap.wrapper(self).emit('pressed') + + def do_motion_notify_event(self, event): + if self.drag_info is not None: + event_pos = self._event_pos(event) + delta = event_pos - self.drag_info.click_pos + self.move_slider(self.drag_info.start_pos + delta) + + def move_slider(self, new_pos): + """Move the slider so that it's centered on new_pos.""" + if self.is_horizontal(): + size = self.allocation.width + else: + size = self.allocation.height + + slider_size = self.slider_size() + new_pos -= slider_size / 2 + size -= slider_size + ratio = max(0, min(1, float(new_pos) / size)) + self.set_value(ratio * (self.max - self.min)) + + wrappermap.wrapper(self).emit('moved', self.get_value()) + if self.is_continuous(): + wrappermap.wrapper(self).emit('changed', self.get_value()) + + def handle_drag_out_of_bounds(self): + if not self.is_continuous(): + self.set_value(self.start_value) + + def do_button_release_event(self, event): + if self.drag_info is None or event.button != self.drag_info.button: + return + self.drag_info = None + if (self.is_continuous and + (0 <= event.x < self.allocation.width) and + (0 <= event.y < self.allocation.height)): + wrappermap.wrapper(self).emit('changed', self.get_value()) + wrappermap.wrapper(self).emit('released') + + def do_scroll_event(self, event): + wrapper = wrappermap.wrapper(self) + if self.is_horizontal(): + if event.direction == gtk.gdk.SCROLL_UP: + event.direction = gtk.gdk.SCROLL_DOWN + elif event.direction == gtk.gdk.SCROLL_DOWN: + event.direction = gtk.gdk.SCROLL_UP + if (wrapper._scroll_step is not None and + event.direction in (gtk.gdk.SCROLL_UP, gtk.gdk.SCROLL_DOWN)): + # handle the scroll ourself + if event.direction == gtk.gdk.SCROLL_DOWN: + delta = wrapper._scroll_step + else: + delta = -wrapper._scroll_step + self.set_value(self.get_value() + delta) + else: + # let GTK handle the scroll + self.gtk_scale_class().do_scroll_event(self, event) + # Treat mouse scrolls as if the user clicked on the new position + wrapper.emit('pressed') + wrapper.emit('changed', self.get_value()) + wrapper.emit('released') + + def do_move_slider(self, scroll): + if self.is_horizontal(): + if scroll == gtk.SCROLL_STEP_UP: + scroll = gtk.SCROLL_STEP_DOWN + elif scroll == gtk.SCROLL_STEP_DOWN: + scroll = gtk.SCROLL_STEP_UP + elif scroll == gtk.SCROLL_PAGE_UP: + scroll = gtk.SCROLL_PAGE_DOWN + elif scroll == gtk.SCROLL_PAGE_DOWN: + scroll = gtk.SCROLL_PAGE_UP + elif scroll == gtk.SCROLL_START: + scroll = gtk.SCROLL_END + elif scroll == gtk.SCROLL_END: + scroll = gtk.SCROLL_START + return self.gtk_scale_class().do_move_slider(self, scroll) + +class CustomHScaleWidget(CustomScaleMixin, gtk.HScale): + def __init__(self): + CustomScaleMixin.__init__(self) + gtk.HScale.__init__(self) + + def is_horizontal(self): + return True + +class CustomVScaleWidget(CustomScaleMixin, gtk.VScale): + def __init__(self): + CustomScaleMixin.__init__(self) + gtk.VScale.__init__(self) + + def is_horizontal(self): + return False + +gobject.type_register(CustomButtonWidget) +gobject.type_register(ContinuousCustomButtonWidget) +gobject.type_register(DragableCustomButtonWidget) +gobject.type_register(CustomHScaleWidget) +gobject.type_register(CustomVScaleWidget) + +class CustomControlBase(Drawable, Widget): + def __init__(self): + Widget.__init__(self) + Drawable.__init__(self) + self._gtk_cursor = None + self._entry_handlers = None + + def _connect_enter_notify_handlers(self): + if self._entry_handlers is None: + self._entry_handlers = [ + self.wrapped_widget_connect('enter-notify-event', + self.on_enter_notify), + self.wrapped_widget_connect('leave-notify-event', + self.on_leave_notify), + self.wrapped_widget_connect('button-release-event', + self.on_click) + ] + + def _disconnect_enter_notify_handlers(self): + if self._entry_handlers is not None: + for handle in self._entry_handlers: + self._widget.disconnect(handle) + self._entry_handlers = None + + def set_cursor(self, cursor): + if cursor == widgetconst.CURSOR_NORMAL: + self._gtk_cursor = None + self._disconnect_enter_notify_handlers() + elif cursor == widgetconst.CURSOR_POINTING_HAND: + self._gtk_cursor = gtk.gdk.Cursor(gtk.gdk.HAND2) + self._connect_enter_notify_handlers() + else: + raise ValueError("Unknown cursor: %s" % cursor) + + def on_enter_notify(self, widget, event): + self._widget.window.set_cursor(self._gtk_cursor) + + def on_leave_notify(self, widget, event): + if self._widget.window: + self._widget.window.set_cursor(None) + + def on_click(self, widget, event): + self.emit('clicked') + return True + +class CustomButton(CustomControlBase): + def __init__(self): + """Create a new CustomButton. active_image will be displayed while + the button is pressed. The image must have the same size. + """ + CustomControlBase.__init__(self) + self.set_widget(CustomButtonWidget()) + self.create_signal('clicked') + self.forward_signal('clicked') + +class DragableCustomButton(CustomControlBase): + def __init__(self): + CustomControlBase.__init__(self) + self.set_widget(DragableCustomButtonWidget()) + self.create_signal('clicked') + self.create_signal('dragged-left') + self.create_signal('dragged-right') + +class CustomSlider(CustomControlBase): + def __init__(self): + CustomControlBase.__init__(self) + self.create_signal('pressed') + self.create_signal('released') + self.create_signal('changed') + self.create_signal('moved') + self._scroll_step = None + if self.is_horizontal(): + self.set_widget(CustomHScaleWidget()) + else: + self.set_widget(CustomVScaleWidget()) + self.wrapped_widget_connect('move-slider', self.on_slider_move) + + def on_slider_move(self, widget, scrolltype): + self.emit('changed', widget.get_value()) + self.emit('moved', widget.get_value()) + + def get_value(self): + return self._widget.get_value() + + def set_value(self, value): + self._widget.set_value(value) + + def get_range(self): + return self._widget.get_range() + + def get_slider_pos(self, value=None): + """Get the position for the slider for our current value. + + This will return position that the slider should be centered on to + display the value. It will be the x coordinate if is_horizontal() is + True and the y coordinate otherwise. + + This method takes into acount the size of the slider when calculating + the position. The slider position will start at (slider_size / 2) and + will end (slider_size / 2) px before the end of the widget. + + :param value: value to get the position for. Defaults to the current + value + """ + return self._widget.get_slider_pos(value) + + def set_range(self, min_value, max_value): + self._widget.set_range(min_value, max_value) + # set_digits controls the precision of the scale by limiting changes + # to a certain number of digits. If the range is [0, 1], this code + # will give us 4 digits of precision, which seems reasonable. + range = max_value - min_value + self._widget.set_digits(int(round(math.log10(10000.0 / range)))) + + def set_increments(self, small_step, big_step, scroll_step=None): + """Set the increments to scroll. + + :param small_step: scroll amount for up/down + :param big_step: scroll amount for page up/page down. + :param scroll_step: scroll amount for mouse wheel, or None to make + this 2 times the small step + """ + self._widget.set_increments(small_step, big_step) + self._scroll_step = scroll_step + +def to_miro_volume(value): + """Convert from 0 to 1.0 to 0.0 to MAX_VOLUME. + """ + if value == 0: + return 0.0 + return value * widgetconst.MAX_VOLUME + +def to_gtk_volume(value): + """Convert from 0.0 to MAX_VOLUME to 0 to 1.0. + """ + if value > 0.0: + value = (value / widgetconst.MAX_VOLUME) + return value + +if hasattr(gtk.VolumeButton, "get_popup"): + # FIXME - Miro on Windows has an old version of gtk (2.16) and + # doesn't have the get_popup method. Once we upgrade and + # fix that, we can take out the hasattr check. + + class VolumeMuter(Label): + """Empty space that has a clicked signal so it can be dropped + in place of the VolumeMuter. + """ + def __init__(self): + Label.__init__(self) + self.create_signal("clicked") + + class VolumeSlider(Widget): + """VolumeSlider that uses the gtk.VolumeButton(). + """ + def __init__(self): + Widget.__init__(self) + self.set_widget(gtk.VolumeButton()) + self.wrapped_widget_connect('value-changed', self.on_value_changed) + self._widget.get_popup().connect("hide", self.on_hide) + self.create_signal('changed') + self.create_signal('released') + + def on_value_changed(self, *args): + value = self.get_value() + self.emit('changed', value) + + def on_hide(self, *args): + self.emit('released') + + def get_value(self): + value = self._widget.get_property('value') + return to_miro_volume(value) + + def set_value(self, value): + value = to_gtk_volume(value) + self._widget.set_property('value', value) + +class ClickableImageButton(CustomButton): + """Image that can send clicked events. If max_width and/or max_height are + specified, resizes the image proportionally such that all constraints are + met. + """ + def __init__(self, image_path, max_width=None, max_height=None): + CustomButton.__init__(self) + self.max_width = max_width + self.max_height = max_height + self.image = None + self._width, self._height = None, None + if image_path: + self.set_path(image_path) + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + + def set_path(self, path): + image = Image(path) + if self.max_width: + image = image.resize_for_space(self.max_width, self.max_height) + self.image = ImageSurface(image) + self._width, self._height = image.width, image.height + + def size_request(self, layout): + w = self._width + h = self._height + if not w: + w = self.max_width + if not h: + h = self.max_height + return w, h + + def draw(self, context, layout): + if self.image: + self.image.draw(context, 0, 0, self._width, self._height) + w = self._width + h = self._height + if not w: + w = self.max_width + if not h: + h = self.max_height + w = min(context.width, w) + h = min(context.height, h) + context.rectangle(0, 0, w, h) + context.set_color((0, 0, 0)) # black + context.set_line_width(1) + context.stroke() diff --git a/lvc/widgets/gtk/drawing.py b/lvc/widgets/gtk/drawing.py new file mode 100644 index 0000000..5888851 --- /dev/null +++ b/lvc/widgets/gtk/drawing.py @@ -0,0 +1,268 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".drawing -- Contains classes used to draw on +widgets. +""" + +import cairo +import gobject +import gtk + +import wrappermap +from .base import Widget, Bin +from .layoutmanager import LayoutManager + +def css_to_color(css_string): + parts = (css_string[1:3], css_string[3:5], css_string[5:7]) + return tuple((int(value, 16) / 255.0) for value in parts) + +class ImageSurface: + def __init__(self, image): + format = cairo.FORMAT_RGB24 + if image.pixbuf.get_has_alpha(): + format = cairo.FORMAT_ARGB32 + self.image = cairo.ImageSurface( + format, int(image.width), int(image.height)) + context = cairo.Context(self.image) + gdkcontext = gtk.gdk.CairoContext(context) + gdkcontext.set_source_pixbuf(image.pixbuf, 0, 0) + gdkcontext.paint() + self.pattern = cairo.SurfacePattern(self.image) + self.pattern.set_extend(cairo.EXTEND_REPEAT) + self.width = image.width + self.height = image.height + + def get_size(self): + return self.width, self.height + + def _align_pattern(self, x, y): + """Line up our image pattern so that it's top-left corner is x, y.""" + m = cairo.Matrix() + m.translate(-x, -y) + self.pattern.set_matrix(m) + + def draw(self, context, x, y, width, height, fraction=1.0): + self._align_pattern(x, y) + cairo_context = context.context + cairo_context.save() + cairo_context.set_source(self.pattern) + cairo_context.new_path() + cairo_context.rectangle(x, y, width, height) + if fraction >= 1.0: + cairo_context.fill() + else: + cairo_context.clip() + cairo_context.paint_with_alpha(fraction) + cairo_context.restore() + + def draw_rect(self, context, dest_x, dest_y, source_x, source_y, + width, height, fraction=1.0): + + self._align_pattern(dest_x-source_x, dest_y-source_y) + cairo_context = context.context + cairo_context.save() + cairo_context.set_source(self.pattern) + cairo_context.new_path() + cairo_context.rectangle(dest_x, dest_y, width, height) + if fraction >= 1.0: + cairo_context.fill() + else: + cairo_context.clip() + cairo_context.paint_with_alpha(fraction) + cairo_context.restore() + +class DrawingStyle(object): + def __init__(self, widget, use_base_color=False, state=None): + if state is None: + state = widget._widget.state + self.use_custom_style = widget.use_custom_style + self.style = widget._widget.style + self.text_color = widget.convert_gtk_color(self.style.text[state]) + if use_base_color: + self.bg_color = widget.convert_gtk_color(self.style.base[state]) + else: + self.bg_color = widget.convert_gtk_color(self.style.bg[state]) + +class DrawingContext(object): + """DrawingContext. This basically just wraps a Cairo context and adds a + couple convenience methods. + """ + + def __init__(self, window, drawing_area, expose_area): + self.window = window + self.context = window.cairo_create() + self.context.rectangle(expose_area.x, expose_area.y, + expose_area.width, expose_area.height) + self.context.clip() + self.width = drawing_area.width + self.height = drawing_area.height + self.context.translate(drawing_area.x, drawing_area.y) + + def __getattr__(self, name): + return getattr(self.context, name) + + def set_color(self, (red, green, blue), alpha=1.0): + self.context.set_source_rgba(red, green, blue, alpha) + + def set_shadow(self, color, opacity, offset, blur_radius): + pass + + def gradient_fill(self, gradient): + old_source = self.context.get_source() + self.context.set_source(gradient.pattern) + self.context.fill() + self.context.set_source(old_source) + + def gradient_fill_preserve(self, gradient): + old_source = self.context.get_source() + self.context.set_source(gradient.pattern) + self.context.fill_preserve() + self.context.set_source(old_source) + +class Gradient(object): + def __init__(self, x1, y1, x2, y2): + self.pattern = cairo.LinearGradient(x1, y1, x2, y2) + + def set_start_color(self, (red, green, blue)): + self.pattern.add_color_stop_rgb(0, red, green, blue) + + def set_end_color(self, (red, green, blue)): + self.pattern.add_color_stop_rgb(1, red, green, blue) + +class CustomDrawingMixin(object): + def do_expose_event(self, event): + wrapper = wrappermap.wrapper(self) + if self.flags() & gtk.NO_WINDOW: + drawing_area = self.allocation + else: + drawing_area = gtk.gdk.Rectangle(0, 0, + self.allocation.width, self.allocation.height) + context = DrawingContext(event.window, drawing_area, event.area) + context.style = DrawingStyle(wrapper) + if self.flags() & gtk.CAN_FOCUS: + focus_space = (self.style_get_property('focus-padding') + + self.style_get_property('focus-line-width')) + if not wrapper.squish_width: + context.width -= focus_space * 2 + translate_x = focus_space + else: + translate_x = 0 + if not wrapper.squish_height: + context.height -= focus_space * 2 + translate_y = focus_space + else: + translate_y = 0 + context.translate(translate_x, translate_y) + wrapper.layout_manager.update_cairo_context(context.context) + self.draw(wrapper, context) + + def draw(self, wrapper, context): + wrapper.layout_manager.reset() + wrapper.draw(context, wrapper.layout_manager) + + def do_size_request(self, requesition): + wrapper = wrappermap.wrapper(self) + width, height = wrapper.size_request(wrapper.layout_manager) + requesition.width = width + requesition.height = height + if self.flags() & gtk.CAN_FOCUS: + focus_space = (self.style_get_property('focus-padding') + + self.style_get_property('focus-line-width')) + if not wrapper.squish_width: + requesition.width += focus_space * 2 + if not wrapper.squish_height: + requesition.height += focus_space * 2 + +class MiroDrawingArea(CustomDrawingMixin, gtk.Widget): + def __init__(self): + gtk.Widget.__init__(self) + CustomDrawingMixin.__init__(self) + self.set_flags(gtk.NO_WINDOW) + +class BackgroundWidget(CustomDrawingMixin, gtk.Bin): + def do_size_request(self, requesition): + CustomDrawingMixin.do_size_request(self, requesition) + if self.get_child(): + child_width, child_height = self.get_child().size_request() + requesition.width = max(child_width, requesition.width) + requesition.height = max(child_height, requesition.height) + + def do_expose_event(self, event): + CustomDrawingMixin.do_expose_event(self, event) + if self.get_child(): + self.propagate_expose(self.get_child(), event) + + def do_size_allocate(self, allocation): + gtk.Bin.do_size_allocate(self, allocation) + if self.get_child(): + self.get_child().size_allocate(allocation) + +gobject.type_register(MiroDrawingArea) +gobject.type_register(BackgroundWidget) + +class Drawable: + def __init__(self): + self.squish_width = self.squish_height = False + + def set_squish_width(self, setting): + self.squish_width = setting + + def set_squish_height(self, setting): + self.squish_height = setting + + def set_widget(self, drawing_widget): + if self.is_opaque() and 0: + box = gtk.EventBox() + box.add(drawing_widget) + Widget.set_widget(self, box) + else: + Widget.set_widget(self, drawing_widget) + self.layout_manager = LayoutManager(self._widget) + + def size_request(self, layout_manager): + return 0, 0 + + def draw(self, context, layout_manager): + pass + + def is_opaque(self): + return False + +class DrawingArea(Drawable, Widget): + def __init__(self): + Widget.__init__(self) + Drawable.__init__(self) + self.set_widget(MiroDrawingArea()) + +class Background(Drawable, Bin): + def __init__(self): + Bin.__init__(self) + Drawable.__init__(self) + self.set_widget(BackgroundWidget()) diff --git a/lvc/widgets/gtk/gtkmenus.py b/lvc/widgets/gtk/gtkmenus.py new file mode 100644 index 0000000..0e89fa8 --- /dev/null +++ b/lvc/widgets/gtk/gtkmenus.py @@ -0,0 +1,404 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""gtkmenus.py -- Manage menu layout.""" + +import gtk + +from lvc.widgets import app + +import base +import keymap +import wrappermap + +def _setup_accel(widget, name, shortcut=None): + """Setup accelerators for a menu item. + + This method sets an accel path for the widget and optionally connects a + shortcut to that accel path. + """ + # The GTK docs say that we should set the path using this form: + # <Window-Name>/Menu/Submenu/MenuItem + # ...but this is hard to do because we don't yet know what window/menu + # this menu item is going to be added to. gtk.Action and gtk.ActionGroup + # don't follow the above suggestion, so we don't need to either. + path = "<MiroActions>/MenuBar/%s" % name + widget.set_accel_path(path) + if shortcut is not None: + accel_string = keymap.get_accel_string(shortcut) + key, mods = gtk.accelerator_parse(accel_string) + if gtk.accel_map_lookup_entry(path) is None: + gtk.accel_map_add_entry(path, key, mods) + else: + gtk.accel_map_change_entry(path, key, mods, True) + +# map menu names to GTK stock ids. +_STOCK_IDS = { + "SaveItem": gtk.STOCK_SAVE, + "CopyItemURL": gtk.STOCK_COPY, + "RemoveItems": gtk.STOCK_REMOVE, + "StopItem": gtk.STOCK_MEDIA_STOP, + "NextItem": gtk.STOCK_MEDIA_NEXT, + "PreviousItem": gtk.STOCK_MEDIA_PREVIOUS, + "PlayPauseItem": gtk.STOCK_MEDIA_PLAY, + "Open": gtk.STOCK_OPEN, + "EditPreferences": gtk.STOCK_PREFERENCES, + "Quit": gtk.STOCK_QUIT, + "Help": gtk.STOCK_HELP, + "About": gtk.STOCK_ABOUT, + "Translate": gtk.STOCK_EDIT +} +try: + _STOCK_IDS['Fullscreen'] = gtk.STOCK_FULLSCREEN +except AttributeError: + # fullscreen not available on all GTK versions + pass + +class MenuItemBase(base.Widget): + """Base class for MenuItem and Separator.""" + + def show(self): + """Show this menu item.""" + self._widget.show() + + def hide(self): + """Hide and disable this menu item.""" + self._widget.hide() + + def remove_from_parent(self): + """Remove this menu item from it's parent Menu.""" + parent_menu = self._widget.get_parent() + if parent_menu is None: + return + parent_menu_item = parent_menu.get_attach_widget() + if parent_menu_item is None: + return + parent_menu_item.remove(self._widget) + + def _set_accel_group(self, accel_group): + # menu items don't care about the accel group, their parent Menu + # handles it for them + pass + +class MenuItem(MenuItemBase): + """Single item in the menu that can be clicked + + :param label: The label it has (must be internationalized) + :param name: String identifier for this item + :param shortcut: Shortcut object to use + + Signals: + - activate: menu item was clicked + + Example: + + >>> MenuItem(_("Preferences"), "EditPreferences") + >>> MenuItem(_("Cu_t"), "ClipboardCut", Shortcut("x", MOD)) + >>> MenuItem(_("_Update Podcasts and Library"), "UpdatePodcasts", + ... (Shortcut("r", MOD), Shortcut(F5))) + >>> MenuItem(_("_Play"), "PlayPauseItem", + ... play=_("_Play"), pause=_("_Pause")) + """ + + def __init__(self, label, name, shortcut=None): + MenuItemBase.__init__(self) + self.name = name + self.set_widget(self.make_widget(label)) + self.activate_id = self.wrapped_widget_connect('activate', + self._on_activate) + self._widget.show() + self.create_signal('activate') + _setup_accel(self._widget, self.name, shortcut) + + def _on_activate(self, menu_item): + self.emit('activate') + gtk_menubar = self._find_menubar() + if gtk_menubar is not None: + try: + menubar = wrappermap.wrapper(gtk_menubar) + except KeyError: + logging.exception('menubar activate: ' + 'no wrapper for gtbbk.MenuBar') + else: + menubar.emit('activate', self.name) + + def _find_menubar(self): + """Find the MenuBar that this menu item is attached to.""" + menu_item = self._widget + while True: + parent_menu = menu_item.get_parent() + if isinstance(parent_menu, gtk.MenuBar): + return parent_menu + elif parent_menu is None: + return None + menu_item = parent_menu.get_attach_widget() + if menu_item is None: + return None + + def make_widget(self, label): + """Create the menu item to use for this widget. + + Subclasses will probably want to override this. + """ + if self.name in _STOCK_IDS: + mi = gtk.ImageMenuItem(stock_id=_STOCK_IDS[self.name]) + mi.set_label(label) + return mi + else: + return gtk.MenuItem(label) + + def set_label(self, new_label): + self._widget.set_label(new_label) + + def get_label(self): + self._widget.get_label() + +class CheckMenuItem(MenuItem): + """MenuItem that toggles on/off""" + + def make_widget(self, label): + return gtk.CheckMenuItem(label) + + def set_state(self, active): + # prevent the activate signal from fireing in response to us manually + # changing a value + self._widget.handler_block(self.activate_id) + if active is not None: + self._widget.set_inconsistent(False) + self._widget.set_active(active) + else: + self._widget.set_inconsistent(True) + self._widget.set_active(False) + self._widget.handler_unblock(self.activate_id) + + def get_state(self): + return self._widget.get_active() + +class RadioMenuItem(CheckMenuItem): + """MenuItem that toggles on/off and is grouped with other RadioMenuItems. + """ + + def make_widget(self, label): + widget = gtk.RadioMenuItem() + widget.set_label(label) + return widget + + def set_group(self, group_item): + self._widget.set_group(group_item._widget) + + def remove_from_group(self): + """Remove this RadioMenuItem from its current group.""" + self._widget.set_group(None) + + def _on_activate(self, menu_item): + # GTK sends the activate signal for both the radio button that's + # toggled on and the one that gets turned off. Just emit our signal + # for the active radio button. + if self.get_state(): + MenuItem._on_activate(self, menu_item) + +class Separator(MenuItemBase): + """Separator item for menus""" + + def __init__(self): + MenuItemBase.__init__(self) + self.set_widget(gtk.SeparatorMenuItem()) + self._widget.show() + # Set name to be None just so that it has a similar API to other menu + # items. + self.name = None + +class MenuShell(base.Widget): + """Common code shared between Menu and MenuBar. + + Subclasses must define a _menu attribute that's a gtk.MenuShell subclass. + """ + + def __init__(self): + base.Widget.__init__(self) + self._accel_group = None + self.children = [] + + def append(self, menu_item): + """Add a menu item to the end of this menu.""" + self.children.append(menu_item) + menu_item._set_accel_group(self._accel_group) + self._menu.append(menu_item._widget) + + def insert(self, index, menu_item): + """Insert a menu item in the middle of this menu.""" + self.children.insert(index, menu_item) + menu_item._set_accel_group(self._accel_group) + self._menu.insert(menu_item._widget, index) + + def remove(self, menu_item): + """Remove a child menu item. + + :raises ValueError: menu_item is not a child of this menu + """ + self.children.remove(menu_item) + self._menu.remove(menu_item._widget) + menu_item._set_accel_group(None) + + def index(self, name): + """Get the position of a menu item in this list. + + :param name: name of the menu + :returns: index of the menu item, or -1 if not found. + """ + for i, menu_item in enumerate(self.children): + if menu_item.name == name: + return i + return -1 + + def get_children(self): + """Get the child menu items in order.""" + return list(self.children) + + def find(self, name): + """Search for a menu or menu item + + This method recursively searches the entire menu structure for a Menu + or MenuItem object with a given name. + + :raises KeyError: name not found + """ + found = self._find(name) + if found is None: + raise KeyError(name) + else: + return found + + def _find(self, name): + """Low-level helper-method for find(). + + :returns: found menu item or None. + """ + for menu_item in self.get_children(): + if menu_item.name == name: + return menu_item + if isinstance(menu_item, MenuShell): + submenu_find = menu_item._find(name) + if submenu_find is not None: + return submenu_find + return None + +class Menu(MenuShell): + """A Menu holds a list of MenuItems and Menus. + + Example: + >>> Menu(_("P_layback"), "Playback", [ + ... MenuItem(_("_Foo"), "Foo"), + ... MenuItem(_("_Bar"), "Bar") + ... ]) + >>> Menu("", "toplevel", [ + ... Menu(_("_File"), "File", [ ... ]) + ... ]) + """ + + def __init__(self, label, name, child_items): + MenuShell.__init__(self) + self.set_widget(gtk.MenuItem(label)) + self._widget.show() + self.name = name + # set up _menu for the MenuShell code + self._menu = gtk.Menu() + _setup_accel(self._menu, self.name) + self._widget.set_submenu(self._menu) + for item in child_items: + self.append(item) + + def show(self): + """Show this menu.""" + self._widget.show() + + def hide(self): + """Hide this menu.""" + self._widget.hide() + + def _set_accel_group(self, accel_group): + """Set the accel group for this widget. + + Accel groups get created by the MenuBar. Whenever a menu or menu item + is added to that menu bar, the parent calls _set_accel_group() to give + the accel group to the child. + """ + if accel_group == self._accel_group: + return + self._menu.set_accel_group(accel_group) + self._accel_group = accel_group + for child in self.children: + child._set_accel_group(accel_group) + +class MenuBar(MenuShell): + """Displays a list of Menu items. + + Signals: + + - activate(menu_bar, name): a menu item was activated + """ + + def __init__(self): + """Create a new MenuBar + + :param name: string id to use for our action group + """ + MenuShell.__init__(self) + self.create_signal('activate') + self.set_widget(gtk.MenuBar()) + self._widget.show() + self._accel_group = gtk.AccelGroup() + # set up _menu for the MenuShell code + self._menu = self._widget + + def get_accel_group(self): + return self._accel_group + +class MainWindowMenuBar(MenuBar): + """MenuBar for the main window. + + This gets installed into app.widgetapp.menubar on GTK. + """ + def add_initial_menus(self, menus): + """Add the initial set of menus. + + We modify the menu structure slightly for GTK. + """ + for menu in menus: + self.append(menu) + self._modify_initial_menus() + + def _modify_initial_menus(self): + """Update the portable root menu with GTK-specific stuff.""" + # on linux, we don't have a CheckVersion option because + # we update with the package system. + #this_platform = app.config.get(prefs.APP_PLATFORM) + #if this_platform == 'linux': + # self.find("CheckVersion").remove_from_parent() + #app.video_renderer.setup_subtitle_encoding_menu() diff --git a/lvc/widgets/gtk/keymap.py b/lvc/widgets/gtk/keymap.py new file mode 100644 index 0000000..537525a --- /dev/null +++ b/lvc/widgets/gtk/keymap.py @@ -0,0 +1,94 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""keymap.py -- Map portable key values to GTK ones. +""" + +import gtk + +from lvc.widgets import keyboard + +menubar_mod_map = { + keyboard.MOD: '<Ctrl>', + keyboard.CTRL: '<Ctrl>', + keyboard.ALT: '<Alt>', + keyboard.SHIFT: '<Shift>', +} + +menubar_key_map = { + keyboard.RIGHT_ARROW: 'Right', + keyboard.LEFT_ARROW: 'Left', + keyboard.UP_ARROW: 'Up', + keyboard.DOWN_ARROW: 'Down', + keyboard.SPACE: 'space', + keyboard.ENTER: 'Return', + keyboard.DELETE: 'Delete', + keyboard.BKSPACE: 'BackSpace', + keyboard.ESCAPE: 'Escape', + '>': 'greater', + '<': 'less' +} +for i in range(1, 13): + name = 'F%d' % i + menubar_key_map[getattr(keyboard, name)] = name + +# These are reversed versions of menubar_key_map and menubar_mod_map +gtk_key_map = dict((i[1], i[0]) for i in menubar_key_map.items()) + +def get_accel_string(shortcut): + mod_str = ''.join(menubar_mod_map[mod] for mod in shortcut.modifiers) + key_str = menubar_key_map.get(shortcut.shortcut, shortcut.shortcut) + return mod_str + key_str + +def translate_gtk_modifiers(event): + """Convert a keypress event to a set of modifiers from the shortcut + module. + """ + modifiers = set() + if event.state & gtk.gdk.CONTROL_MASK: + modifiers.add(keyboard.CTRL) + if event.state & gtk.gdk.MOD1_MASK: + modifiers.add(keyboard.ALT) + if event.state & gtk.gdk.SHIFT_MASK: + modifiers.add(keyboard.SHIFT) + return modifiers + +def translate_gtk_event(event): + """Convert a GTK key event into the tuple (key, modifiers) where + key and modifiers are from the shortcut module. + """ + gtk_keyval = gtk.gdk.keyval_name(event.keyval) + if gtk_keyval == None: + return None + if len(gtk_keyval) == 1: + key = gtk_keyval + else: + key = gtk_key_map.get(gtk_keyval) + modifiers = translate_gtk_modifiers(event) + return key, modifiers diff --git a/lvc/widgets/gtk/layout.py b/lvc/widgets/gtk/layout.py new file mode 100644 index 0000000..549311c --- /dev/null +++ b/lvc/widgets/gtk/layout.py @@ -0,0 +1,227 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".layout -- Layout widgets. """ + +import gtk + +from lvc.utils import Matrix +from .base import Widget, Bin + +class Box(Widget): + def __init__(self, spacing=0): + Widget.__init__(self) + self.children = set() + self.set_widget(self.WIDGET_CLASS(spacing=spacing)) + + def pack_start(self, widget, expand=False, padding=0): + self._widget.pack_start(widget._widget, expand, fill=True, + padding=padding) + widget._widget.show() + self.children.add(widget) + + def pack_end(self, widget, expand=False, padding=0): + self._widget.pack_end(widget._widget, expand, fill=True, + padding=padding) + widget._widget.show() + self.children.add(widget) + + def remove(self, widget): + widget._widget.hide() # otherwise gtkmozembed gets confused + self._widget.remove(widget._widget) + self.children.remove(widget) + + def enable(self): + for mem in self.children: + mem.enable() + + def disable(self): + for mem in self.children: + mem.disable() + +class HBox(Box): + WIDGET_CLASS = gtk.HBox + +class VBox(Box): + WIDGET_CLASS = gtk.VBox + +class Alignment(Bin): + def __init__(self, xalign=0, yalign=0, xscale=0, yscale=0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + Bin.__init__(self) + self.set_widget(gtk.Alignment(xalign, yalign, xscale, yscale)) + self.set_padding(top_pad, bottom_pad, left_pad, right_pad) + + def set(self, xalign=0, yalign=0, xscale=0, yscale=0): + self._widget.set(xalign, yalign, xscale, yscale) + + def set_padding(self, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + self._widget.set_padding(top_pad, bottom_pad, left_pad, right_pad) + +class DetachedWindowHolder(Alignment): + def __init__(self): + Alignment.__init__(self, xscale=1, yscale=1) + +class Splitter(Widget): + def __init__(self): + """Create a new splitter.""" + Widget.__init__(self) + self.set_widget(gtk.HPaned()) + + def set_left(self, widget): + """Set the left child widget.""" + self.left = widget + self._widget.pack1(widget._widget, resize=False, shrink=False) + widget._widget.show() + + def set_right(self, widget): + """Set the right child widget. """ + self.right = widget + self._widget.pack2(widget._widget, resize=True, shrink=False) + widget._widget.show() + + def remove_left(self): + """Remove the left child widget.""" + if self.left is not None: + self.left._widget.hide() # otherwise gtkmozembed gets confused + self._widget.remove(self.left._widget) + self.left = None + + def remove_right(self): + """Remove the right child widget.""" + if self.right is not None: + self.right._widget.hide() # otherwise gtkmozembed gets confused + self._widget.remove(self.right._widget) + self.right = None + + def set_left_width(self, width): + self._widget.set_position(width) + + def get_left_width(self): + return self._widget.get_position() + + def set_right_width(self, width): + self._widget.set_position(self.width - width) + # We should take into account the width of the bar, but this seems + # good enough. + +class Table(Widget): + """Lays out widgets in a table. It works very similar to the GTK Table + widget, or an HTML table. + """ + def __init__(self, columns, rows): + Widget.__init__(self) + self.set_widget(gtk.Table(rows, columns, homogeneous=False)) + self.children = Matrix(columns, rows) + + def pack(self, widget, column, row, column_span=1, row_span=1): + """Add a widget to the table. + """ + self.children[column, row] = widget + self._widget.attach(widget._widget, column, column + column_span, + row, row + row_span) + widget._widget.show() + + def remove(self, widget): + widget._widget.hide() # otherwise gtkmozembed gets confused + self.children.remove(widget) + self._widget.remove(widget._widget) + + def set_column_spacing(self, spacing): + self._widget.set_col_spacings(spacing) + + def set_row_spacing(self, spacing): + self._widget.set_row_spacings(spacing) + + def enable(self, row=None, column=None): + if row != None and column != None: + if self.children[column, row]: + self.children[column, row].enable() + elif row != None: + for mem in self.children.row(row): + if mem: mem.enable() + elif column != None: + for mem in self.children.column(column): + if mem: mem.enable() + else: + for mem in self.children: + if mem: mem.enable() + + def disable(self, row=None, column=None): + if row != None and column != None: + if self.children[column, row]: + self.children[column, row].disable() + elif row != None: + for mem in self.children.row(row): + if mem: mem.disable() + elif column != None: + for mem in self.children.column(column): + if mem: mem.disable() + else: + for mem in self.children: + if mem: mem.disable() + +class TabContainer(Widget): + def __init__(self, xalign=0, yalign=0, xscale=0, yscale=0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + Widget.__init__(self) + self.set_widget(gtk.Notebook()) + self._widget.set_tab_pos(gtk.POS_TOP) + self.children = [] + self._page_to_select = None + self.wrapped_widget_connect('realize', self._on_realize) + + def _on_realize(self, widget): + if self._page_to_select is not None: + self._widget.set_current_page(self._page_to_select) + self._page_to_select = None + + def append_tab(self, child_widget, text, image=None): + if image is not None: + label_widget = gtk.VBox(spacing=2) + image_widget = gtk.Image() + image_widget.set_from_pixbuf(image.pixbuf) + label_widget.pack_start(image_widget) + label_widget.pack_start(gtk.Label(text)) + label_widget.show_all() + else: + label_widget = gtk.Label(text) + + # switch from a center align to a top align + child_widget.set(0, 0, 1, 0) + child_widget.set_padding(10, 10, 10, 10) + + self._widget.append_page(child_widget._widget, label_widget) + self.children.append(child_widget) + + def select_tab(self, index): + if self._widget.flags() & gtk.REALIZED: + self._widget.set_current_page(index) + else: + self._page_to_select = index diff --git a/lvc/widgets/gtk/layoutmanager.py b/lvc/widgets/gtk/layoutmanager.py new file mode 100644 index 0000000..8097b2e --- /dev/null +++ b/lvc/widgets/gtk/layoutmanager.py @@ -0,0 +1,550 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""drawing.py -- Contains the LayoutManager class. LayoutManager is +handles laying out complex objects for the custom drawing code like +text blocks and buttons. +""" + +import math + +import cairo +import gtk +import pango + +from lvc import utils + +use_native_buttons = False # not implemented in MVC + +class FontCache(utils.Cache): + def get(self, context, description, scale_factor, bold, italic): + key = (context, description, scale_factor, bold, italic) + return utils.Cache.get(self, key) + + def create_new_value(self, key, invalidator=None): + (context, description, scale_factor, bold, italic) = key + return Font(context, description, scale_factor, bold, italic) + +_font_cache = FontCache(512) + +class LayoutManager(object): + def __init__(self, widget): + self.pango_context = widget.get_pango_context() + self.update_style(widget.style) + self.update_direction(widget.get_direction()) + widget.connect('style-set', self.on_style_set) + widget.connect('direction-changed', self.on_direction_changed) + self.widget = widget + self.reset() + + def reset(self): + self.current_font = self.font(1.0) + self.text_color = (0, 0, 0) + self.text_shadow = None + + def on_style_set(self, widget, previous_style): + old_font_desc = self.style_font_desc + self.update_style(widget.style) + if self.style_font_desc != old_font_desc: + # bug #17423 font changed, so the widget's width might have changed + widget.queue_resize() + + def on_direction_changed(self, widget, previous_direction): + self.update_direction(widget.get_direction()) + + def update_style(self, style): + self.style_font_desc = style.font_desc + self.style = style + + def update_direction(self, direction): + if direction == gtk.TEXT_DIR_RTL: + self.pango_context.set_base_dir(pango.DIRECTION_RTL) + else: + self.pango_context.set_base_dir(pango.DIRECTION_LTR) + + def font(self, scale_factor, bold=False, italic=False, family=None): + return _font_cache.get(self.pango_context, self.style_font_desc, + scale_factor, bold, italic) + + def set_font(self, scale_factor, bold=False, italic=False, family=None): + self.current_font = self.font(scale_factor, bold, italic) + + def set_text_color(self, color): + self.text_color = color + + def set_text_shadow(self, shadow): + self.text_shadow = shadow + + def textbox(self, text, underline=False): + textbox = TextBox(self.pango_context, self.current_font, + self.text_color, self.text_shadow) + textbox.set_text(text, underline=underline) + return textbox + + def button(self, text, pressed=False, disabled=False, style='normal'): + if style == 'webby': + return StyledButton(text, self.pango_context, self.current_font, + pressed, disabled) + elif use_native_buttons: + return NativeButton(text, self.pango_context, self.current_font, + pressed, self.style, self.widget) + else: + return StyledButton(text, self.pango_context, self.current_font, + pressed) + + def update_cairo_context(self, cairo_context): + cairo_context.update_context(self.pango_context) + +class Font(object): + def __init__(self, context, style_font_desc, scale, bold, italic): + self.context = context + self.description = style_font_desc.copy() + self.description.set_size(int(scale * style_font_desc.get_size())) + if bold: + self.description.set_weight(pango.WEIGHT_BOLD) + if italic: + self.description.set_style(pango.STYLE_ITALIC) + self.font_metrics = None + + def get_font_metrics(self): + if self.font_metrics is None: + self.font_metrics = self.context.get_metrics(self.description) + return self.font_metrics + + def ascent(self): + return pango.PIXELS(self.get_font_metrics().get_ascent()) + + def descent(self): + return pango.PIXELS(self.get_font_metrics().get_descent()) + + def line_height(self): + metrics = self.get_font_metrics() + # the +1: some glyphs can be slightly taller than ascent+descent + # (#17329) + return (pango.PIXELS(metrics.get_ascent()) + + pango.PIXELS(metrics.get_descent()) + 1) + +class TextBox(object): + def __init__(self, context, font, color, shadow): + self.layout = pango.Layout(context) + self.layout.set_wrap(pango.WRAP_WORD_CHAR) + self.font = font + self.color = color + self.layout.set_font_description(font.description.copy()) + self.width = self.height = None + self.shadow = shadow + + def set_text(self, text, font=None, color=None, underline=False): + self.text_chunks = [] + self.attributes = [] + self.text_length = 0 + self.underlines = [] + self.append_text(text, font, color, underline) + + def append_text(self, text, font=None, color=None, underline=False): + if text == None: + text = u"" + startpos = self.text_length + self.text_chunks.append(text) + endpos = self.text_length = self.text_length + len(text) + if font is not None: + attr = pango.AttrFontDesc(font.description, startpos, endpos) + self.attributes.append(attr) + if underline: + self.underlines.append((startpos, endpos)) + if color: + def convert(value): + return int(round(value * 65535)) + attr = pango.AttrForeground(convert(color[0]), convert(color[1]), + convert(color[2]), startpos, endpos) + self.attributes.append(attr) + self.text_set = False + + def set_width(self, width): + if width is not None: + self.layout.set_width(int(width * pango.SCALE)) + else: + self.layout.set_width(-1) + self.width = width + + def set_height(self, height): + # if height is not None: + # # not sure why set_height isn't in the python bindings, but it + # # isn't + # pygtkhacks.set_pango_layout_height(self.layout, + # int(height * pango.SCALE)) + self.height = height + + def set_wrap_style(self, wrap): + if wrap == 'word': + self.layout.set_wrap(pango.WRAP_WORD_CHAR) + elif wrap == 'char' or wrap == 'truncated-char': + self.layout.set_wrap(pango.WRAP_CHAR) + else: + raise ValueError("Unknown wrap value: %s" % wrap) + if wrap == 'truncated-char': + self.layout.set_ellipsize(pango.ELLIPSIZE_END) + else: + self.layout.set_ellipsize(pango.ELLIPSIZE_NONE) + + def set_alignment(self, align): + if align == 'left': + self.layout.set_alignment(pango.ALIGN_LEFT) + elif align == 'right': + self.layout.set_alignment(pango.ALIGN_RIGHT) + elif align == 'center': + self.layout.set_alignment(pango.ALIGN_CENTER) + else: + raise ValueError("Unknown align value: %s" % align) + + def ensure_layout(self): + if not self.text_set: + text = ''.join(self.text_chunks) + if len(text) > 100: + text = text[:self._calc_text_cutoff()] + self.layout.set_text(text) + attr_list = pango.AttrList() + for attr in self.attributes: + attr_list.insert(attr) + self.layout.set_attributes(attr_list) + self.text_set = True + + def _calc_text_cutoff(self): + """This method is a bit of a hack... GTK slows down if we pass too + much text to the layout. Even text that falls below our height has a + performance penalty. Try not to have too much more than is necessary. + """ + if None in (self.width, self.height): + return -1 + + chars_per_line = (self.width * pango.SCALE // + self.font.get_font_metrics().get_approximate_char_width()) + lines_available = self.height // self.font.line_height() + # overestimate these because it's better to have too many characters + # than too little. + return int(chars_per_line * lines_available * 1.2) + + def line_count(self): + self.ensure_layout() + return self.layout.get_line_count() + + def get_size(self): + self.ensure_layout() + return self.layout.get_pixel_size() + + def char_at(self, x, y): + self.ensure_layout() + x *= pango.SCALE + y *= pango.SCALE + width, height = self.layout.get_size() + if 0 <= x < width and 0 <= y < height: + index, leading = self.layout.xy_to_index(x, y) + # xy_to_index returns the nearest character, but that + # doesn't mean the user actually clicked on it. Double + # check that (x, y) is actually inside that char's + # bounding box + char_x, char_y, char_w, char_h = self.layout.index_to_pos(index) + if char_w > 0: # the glyph is LTR + left = char_x + right = char_x + char_w + else: # the glyph is RTL + left = char_x + char_w + right = char_x + if left <= x < right: + return index + return None + + + def draw(self, context, x, y, width, height): + self.set_width(width) + self.set_height(height) + self.ensure_layout() + cairo_context = context.context + cairo_context.save() + underline_drawer = UnderlineDrawer(self.underlines) + if self.shadow: + # draw shadow first so that it's underneath the regular text + # FIXME: we don't use the blur_radius setting + cairo_context.set_source_rgba(self.shadow.color[0], + self.shadow.color[1], self.shadow.color[2], + self.shadow.opacity) + self._draw_layout(context, x + self.shadow.offset[0], + y + self.shadow.offset[1], width, height, + underline_drawer) + cairo_context.set_source_rgb(*self.color) + self._draw_layout(context, x, y, width, height, underline_drawer) + cairo_context.restore() + cairo_context.new_path() + + def _draw_layout(self, context, x, y, width, height, underline_drawer): + line_height = 0 + alignment = self.layout.get_alignment() + for i in xrange(self.layout.get_line_count()): + line = self.layout.get_line_readonly(i) + extents = line.get_pixel_extents()[1] + next_line_height = line_height + extents[3] + if next_line_height > height: + break + if alignment == pango.ALIGN_CENTER: + line_x = max(x, x + (width - extents[2]) / 2.0) + elif alignment == pango.ALIGN_RIGHT: + line_x = max(x, x + width - extents[2]) + else: + line_x = x + baseline = y + line_height + pango.ASCENT(extents) + context.move_to(line_x, baseline) + context.context.show_layout_line(line) + underline_drawer.draw(context, line_x, baseline, line) + line_height = next_line_height + +class UnderlineDrawer(object): + """Class to draw our own underlines because cairo's don't look + that great at small fonts. We make sure that the underline is + always drawn at a pixel boundary and that there always is space + between the text and the baseline. + + This class makes a couple assumptions that might not be that + great. It assumes that the correct underline size is 1 pixel and + that the text color doesn't change in the middle of an underline. + """ + def __init__(self, underlines): + self.underline_iter = iter(underlines) + self.finished = False + self.next_underline() + + def next_underline(self): + try: + self.startpos, self.endpos = self.underline_iter.next() + except StopIteration: + self.finished = True + else: + # endpos is the char to stop underlining at + self.endpos -= 1 + + def draw(self, context, x, baseline, line): + baseline = round(baseline) + 0.5 + context.set_line_width(1) + while not self.finished and line.start_index <= self.startpos: + startpos = max(line.start_index, self.startpos) + endpos = min(self.endpos, line.start_index + line.length) + x1 = x + pango.PIXELS(line.index_to_x(startpos, 0)) + x2 = x + pango.PIXELS(line.index_to_x(endpos, 1)) + context.move_to(x1, baseline + 1) + context.line_to(x2, baseline + 1) + context.stroke() + if endpos < self.endpos: + break + else: + self.next_underline() + +class NativeButton(object): + ICON_PAD = 4 + + def __init__(self, text, context, font, pressed, style, widget): + self.layout = pango.Layout(context) + self.font = font + self.pressed = pressed + self.layout.set_font_description(font.description.copy()) + self.layout.set_text(text) + self.pad_x = style.xthickness + 11 + self.pad_y = style.ythickness + 1 + self.style = style + self.widget = widget + # The above code assumes an "inner-border" style property of + # 1. PyGTK doesn't seem to support Border objects very well, + # so can't get it from the widget style. + self.min_width = 0 + self.icon = None + + def set_min_width(self, width): + self.min_width = width + + def set_icon(self, icon): + self.icon = icon + + def get_size(self): + width, height = self.layout.get_pixel_size() + if self.icon: + width += self.icon.width + self.ICON_PAD + height = max(height, self.icon.height) + width += self.pad_x * 2 + height += self.pad_y * 2 + return max(self.min_width, width), height + + def draw(self, context, x, y, width, height): + text_width, text_height = self.layout.get_pixel_size() + if self.icon: + inner_width = text_width + self.icon.width + self.ICON_PAD + # calculate the icon position x and y are still in cairo + # coordinates + icon_x = x + (width - inner_width) / 2.0 + icon_y = y + (height - self.icon.height) / 2.0 + text_x = icon_x + self.icon.width + self.ICON_PAD + else: + text_x = x + (width - text_width) / 2.0 + text_y = y + (height - text_height) / 2.0 + + x, y = context.context.user_to_device(x, y) + text_x, text_y = context.context.user_to_device(text_x, text_y) + # Hmm, maybe we should somehow support floating point numbers + # here, but I don't know how to. + x, y, width, height = (int(f) for f in (x, y, width, height)) + context.context.get_target().flush() + self.draw_box(context.window, x, y, width, height) + self.draw_text(context.window, text_x, text_y) + if self.icon: + self.icon.draw(context, icon_x, icon_y, self.icon.width, + self.icon.height) + + def draw_box(self, window, x, y, width, height): + if self.pressed: + shadow = gtk.SHADOW_IN + state = gtk.STATE_ACTIVE + else: + shadow = gtk.SHADOW_OUT + state = gtk.STATE_NORMAL + if 'QtCurveStyle' in str(self.style): + # This is a horrible hack for the libqtcurve library. See + # http://bugzilla.pculture.org/show_bug.cgi?id=10380 for + # details + widget = window.get_user_data() + else: + widget = self.widget + + self.style.paint_box(window, state, shadow, None, widget, "button", + int(x), int(y), int(width), int(height)) + + def draw_text(self, window, x, y): + if self.pressed: + state = gtk.STATE_ACTIVE + else: + state = gtk.STATE_NORMAL + self.style.paint_layout(window, state, True, None, None, None, + int(x), int(y), self.layout) + +class StyledButton(object): + PAD_HORIZONTAL = 4 + PAD_VERTICAL = 3 + TOP_COLOR = (1, 1, 1) + BOTTOM_COLOR = (0.86, 0.86, 0.86) + LINE_COLOR_TOP = (0.71, 0.71, 0.71) + LINE_COLOR_BOTTOM = (0.45, 0.45, 0.45) + TEXT_COLOR = (0.184, 0.184, 0.184) + DISABLED_COLOR = (0.86, 0.86, 0.86) + DISABLED_TEXT_COLOR = (0.5, 0.5, 0.5) + ICON_PAD = 8 + + def __init__(self, text, context, font, pressed, disabled=False): + self.layout = pango.Layout(context) + self.font = font + self.layout.set_font_description(font.description.copy()) + self.layout.set_text(text) + self.min_width = 0 + self.pressed = pressed + self.disabled = disabled + self.icon = None + + def set_icon(self, icon): + self.icon = icon + + def set_min_width(self, width): + self.min_width = width + + def get_size(self): + width, height = self.layout.get_pixel_size() + if self.icon: + width += self.icon.width + self.ICON_PAD + height = max(height, self.icon.height) + height += self.PAD_VERTICAL * 2 + if height % 2 == 1: + # make height even so that the radius of our circle is + # whole + height += 1 + width += self.PAD_HORIZONTAL * 2 + height + return max(self.min_width, width), height + + def draw_path(self, context, x, y, width, height, radius): + inner_width = width - radius * 2 + context.move_to(x + radius, y) + context.rel_line_to(inner_width, 0) + context.arc(x + width - radius, y+radius, radius, -math.pi/2, + math.pi/2) + context.rel_line_to(-inner_width, 0) + context.arc(x + radius, y+radius, radius, math.pi/2, -math.pi/2) + + def draw_button(self, context, x, y, width, height, radius): + context.context.save() + self.draw_path(context, x, y, width, height, radius) + if self.disabled: + end_color = self.DISABLED_COLOR + start_color = self.DISABLED_COLOR + elif self.pressed: + end_color = self.TOP_COLOR + start_color = self.BOTTOM_COLOR + else: + context.set_line_width(1) + start_color = self.TOP_COLOR + end_color = self.BOTTOM_COLOR + gradient = cairo.LinearGradient(x, y, x, y + height) + gradient.add_color_stop_rgb(0, *start_color) + gradient.add_color_stop_rgb(1, *end_color) + context.context.set_source(gradient) + context.fill() + context.set_line_width(1) + self.draw_path(context, x+0.5, y+0.5, width, height, radius) + gradient = cairo.LinearGradient(x, y, x, y + height) + gradient.add_color_stop_rgb(0, *self.LINE_COLOR_TOP) + gradient.add_color_stop_rgb(1, *self.LINE_COLOR_BOTTOM) + context.context.set_source(gradient) + context.stroke() + context.context.restore() + + def draw(self, context, x, y, width, height): + radius = height / 2 + self.draw_button(context, x, y, width, height, radius) + + text_width, text_height = self.layout.get_pixel_size() + # draw the text in the center of the button + text_x = x + (width - text_width) / 2 + text_y = y + (height - text_height) / 2 + if self.icon: + icon_x = text_x - (self.icon.width + self.ICON_PAD) / 2 + text_x += (self.icon.width + self.ICON_PAD) / 2 + icon_y = y + (height - self.icon.height) / 2 + self.icon.draw(context, icon_x, icon_y, self.icon.width, + self.icon.height) + self.draw_text(context, text_x, text_y, width, height, radius) + + def draw_text(self, context, x, y, width, height, radius): + if self.disabled: + context.set_color(self.DISABLED_TEXT_COLOR) + else: + context.set_color(self.TEXT_COLOR) + context.move_to(x, y) + context.context.show_layout(self.layout) diff --git a/lvc/widgets/gtk/simple.py b/lvc/widgets/gtk/simple.py new file mode 100644 index 0000000..102fcd4 --- /dev/null +++ b/lvc/widgets/gtk/simple.py @@ -0,0 +1,313 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""simple.py -- Collection of simple widgets.""" + +import gtk +import gobject +import pango + +from lvc.widgets import widgetconst +from .base import Widget, Bin + +class Image(object): + def __init__(self, path): + try: + self._set_pixbuf(gtk.gdk.pixbuf_new_from_file(path)) + except gobject.GError, ge: + raise ValueError("%s" % ge) + self.width = self.pixbuf.get_width() + self.height = self.pixbuf.get_height() + + def _set_pixbuf(self, pixbuf): + self.pixbuf = pixbuf + self.width = self.pixbuf.get_width() + self.height = self.pixbuf.get_height() + + def resize(self, width, height): + width = int(round(width)) + height = int(round(height)) + resized_pixbuf = self.pixbuf.scale_simple(width, height, + gtk.gdk.INTERP_BILINEAR) + return TransformedImage(resized_pixbuf) + + def resize_for_space(self, width, height): + """Returns an image scaled to fit into the specified space at the + correct height/width ratio. + """ + ratio = min(1.0 * width / self.width, 1.0 * height / self.height) + return self.resize(ratio * self.width, ratio * self.height) + + def crop_and_scale(self, src_x, src_y, src_width, src_height, dest_width, + dest_height): + """Crop an image then scale it. + + The image will be cropped to the rectangle (src_x, src_y, src_width, + src_height), that rectangle will be scaled to a new Image with tisez + (dest_width, dest_height) + """ + dest = gtk.gdk.Pixbuf(self.pixbuf.get_colorspace(), + self.pixbuf.get_has_alpha(), + self.pixbuf.get_bits_per_sample(), dest_width, dest_height) + + scale_x = dest_width / float(src_width) + scale_y = dest_height / float(src_height) + + self.pixbuf.scale(dest, 0, 0, dest_width, dest_height, + -src_x * scale_x, -src_y * scale_y, scale_x, scale_y, + gtk.gdk.INTERP_BILINEAR) + return TransformedImage(dest) + +class TransformedImage(Image): + def __init__(self, pixbuf): + # XXX intentionally not calling direct super's __init__; we should do + # this differently + self._set_pixbuf(pixbuf) + +class ImageDisplay(Widget): + def __init__(self, image=None): + Widget.__init__(self) + self.set_widget(gtk.Image()) + self.set_image(image) + + def set_image(self, image): + self.image = image + if image is not None: + self._widget.set_from_pixbuf(image.pixbuf) + else: + self._widget.clear() + +class AnimatedImageDisplay(Widget): + def __init__(self, path): + Widget.__init__(self) + self.set_widget(gtk.Image()) + self._animation = gtk.gdk.PixbufAnimation(path) + # Set to animate before we are shown and stop animating after + # we disappear. + self._widget.connect('map', lambda w: self._set_animate(True)) + self._widget.connect('unmap-event', + lambda w, a: self._set_animate(False)) + + def _set_animate(self, enabled): + if enabled: + self._widget.set_from_animation(self._animation) + else: + self._widget.clear() + +class Label(Widget): + """Widget that displays simple text.""" + def __init__(self, text="", color=None): + Widget.__init__(self) + self.set_widget(gtk.Label()) + if text: + self.set_text(text) + self.attr_list = pango.AttrList() + self.font_description = self._widget.style.font_desc.copy() + self.scale_factor = 1.0 + if color is not None: + self.set_color(color) + self.wrapped_widget_connect('style-set', self.on_style_set) + + def set_bold(self, bold): + if bold: + weight = pango.WEIGHT_BOLD + else: + weight = pango.WEIGHT_NORMAL + self.font_description.set_weight(weight) + self.set_attr(pango.AttrFontDesc(self.font_description)) + + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self.scale_factor = 1 + elif size == widgetconst.SIZE_SMALL: + self.scale_factor = 0.75 + else: + self.scale_factor = size + baseline = self._widget.style.font_desc.get_size() + self.font_description.set_size(int(baseline * self.scale_factor)) + self.set_attr(pango.AttrFontDesc(self.font_description)) + + def get_preferred_width(self): + return self._widget.size_request()[0] + + def on_style_set(self, widget, old_style): + self.set_size(self.scale_factor) + + def set_wrap(self, wrap): + self._widget.set_line_wrap(wrap) + + def set_alignment(self, alignment): + # default to left. + gtkalignment = gtk.JUSTIFY_LEFT + if alignment == widgetconst.TEXT_JUSTIFY_LEFT: + gtkalignment = gtk.JUSTIFY_LEFT + elif alignment == widgetconst.TEXT_JUSTIFY_RIGHT: + gtkalignment = gtk.JUSTIFY_RIGHT + elif alignment == widgetconst.TEXT_JUSTIFY_CENTER: + gtkalignment = gtk.JUSTIFY_CENTER + self._widget.set_justify(gtkalignment) + + def get_alignment(self): + return self._widget.get_justify() + + def get_width(self): + return self._widget.get_layout().get_pixel_size()[0] + + def set_text(self, text): + self._widget.set_text(text) + + def get_text(self): + return self._widget.get_text().decode('utf-8') + + def set_selectable(self, val): + self._widget.set_selectable(val) + + def set_attr(self, attr): + attr.end_index = 65535 + self.attr_list.change(attr) + self._widget.set_attributes(self.attr_list) + + def set_color(self, color): + color_as_int = (int(65535 * c) for c in color) + self.set_attr(pango.AttrForeground(*color_as_int)) + + def baseline(self): + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self.font_description) + return pango.PIXELS(metrics.get_descent()) + + def hide(self): + self._widget.hide() + + def show(self): + self._widget.show() + +class Scroller(Bin): + def __init__(self, horizontal, vertical): + Bin.__init__(self) + self.set_widget(gtk.ScrolledWindow()) + if horizontal: + h_policy = gtk.POLICY_AUTOMATIC + else: + h_policy = gtk.POLICY_NEVER + if vertical: + v_policy = gtk.POLICY_AUTOMATIC + else: + v_policy = gtk.POLICY_NEVER + self._widget.set_policy(h_policy, v_policy) + + def set_has_borders(self, has_border): + pass + + def set_background_color(self, color): + pass + + def add_child_to_widget(self): + if (isinstance(self.child._widget, gtk.TreeView) or + isinstance(self.child._widget, gtk.TextView)): + # child has native scroller + self._widget.add(self.child._widget) + else: + self._widget.add_with_viewport(self.child._widget) + self._widget.get_child().set_shadow_type(gtk.SHADOW_NONE) + if isinstance(self.child._widget, gtk.TextView): + self._widget.set_shadow_type(gtk.SHADOW_IN) + else: + self._widget.set_shadow_type(gtk.SHADOW_NONE) + + def prepare_for_dark_content(self): + # this is just a hack for cocoa + pass + + +class SolidBackground(Bin): + def __init__(self, color=None): + Bin.__init__(self) + self.set_widget(gtk.EventBox()) + if color is not None: + self.set_background_color(color) + + def set_background_color(self, color): + self.modify_style('base', gtk.STATE_NORMAL, self.make_color(color)) + self.modify_style('bg', gtk.STATE_NORMAL, self.make_color(color)) + +class Expander(Bin): + def __init__(self, child=None): + Bin.__init__(self) + self.set_widget(gtk.Expander()) + if child is not None: + self.add(child) + self.label = None + # This is a complete hack. GTK expanders have a transparent + # background most of the time, except when they are prelighted. So we + # just set the background to white there because that's what should + # happen in the item list. + self.modify_style('bg', gtk.STATE_PRELIGHT, + gtk.gdk.color_parse('white')) + + def set_spacing(self, spacing): + self._widget.set_spacing(spacing) + + def set_label(self, widget): + self.label = widget + self._widget.set_label_widget(widget._widget) + widget._widget.show() + + def set_expanded(self, expanded): + self._widget.set_expanded(expanded) + +class ProgressBar(Widget): + def __init__(self): + Widget.__init__(self) + self.set_widget(gtk.ProgressBar()) + self._timer = None + + def set_progress(self, fraction): + self._widget.set_fraction(fraction) + + def start_pulsing(self): + if self._timer is None: + self._timer = gobject.timeout_add(100, self._do_pulse) + + def stop_pulsing(self): + if self._timer: + gobject.source_remove(self._timer) + self._timer = None + + def _do_pulse(self): + self._widget.pulse() + return True + +class HLine(Widget): + """A horizontal separator. Not to be confused with HSeparator, which is is + a DrawingArea, not a Widget. + """ + def __init__(self): + Widget.__init__(self) + self.set_widget(gtk.HSeparator()) diff --git a/lvc/widgets/gtk/tableview.py b/lvc/widgets/gtk/tableview.py new file mode 100644 index 0000000..df66990 --- /dev/null +++ b/lvc/widgets/gtk/tableview.py @@ -0,0 +1,1557 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tableview.py -- Wrapper for the GTKTreeView widget. It's used for the tab +list and the item list (AKA almost all of the miro). +""" + +import logging + +import itertools +import gobject +import gtk +from collections import namedtuple + +# These are probably wrong, and are placeholders for now, until custom headers +# are also implemented for GTK. +CUSTOM_HEADER_HEIGHT = 25 +HEADER_HEIGHT = 25 + +from lvc import signals +from lvc.errors import (WidgetActionError, WidgetDomainError, + WidgetRangeError, WidgetNotReadyError) +from lvc.widgets.tableselection import SelectionOwnerMixin +from lvc.widgets.tablescroll import ScrollbarOwnerMixin +import drawing +import wrappermap +from .base import Widget +from .simple import Image +from .layoutmanager import LayoutManager +from .weakconnect import weak_connect +from .tableviewcells import GTKCustomCellRenderer + + +PathInfo = namedtuple('PathInfo', 'path column x y') +Rect = namedtuple('Rect', 'x y width height') +_album_view_gtkrc_installed = False + +def _install_album_view_gtkrc(): + """Hack for styling GTKTreeView for the album view widget. + + We do a couple things: + - Remove the focus ring + - Remove any separator space. + + We do this so that we don't draw a box through the album view column for + selected rows. + """ + global _album_view_gtkrc_installed + if _album_view_gtkrc_installed: + return + rc_string = ('style "album-view-style"\n' + '{ \n' + ' GtkTreeView::vertical-separator = 0\n' + ' GtkTreeView::horizontal-separator = 0\n' + ' GtkWidget::focus-line-width = 0 \n' + '}\n' + 'widget "*.miro-album-view" style "album-view-style"\n') + gtk.rc_parse_string(rc_string) + _album_view_gtkrc_installed = True + +def rect_contains_point(rect, x, y): + return ((rect.x <= x < rect.x + rect.width) and + (rect.y <= y < rect.y + rect.height)) + +class TreeViewScrolling(object): + def __init__(self): + self.scrollbars = [] + self.scroll_positions = None, None + self.restoring_scroll = None + self.connect('parent-set', self.on_parent_set) + self.scroller = None + # hack necessary because of our weird widget hierarchy (GTK doesn't deal + # well with the Scroller's widget not being the direct parent of the + # TableView's widget.) + self._coords_working = False + + def scroll_range_changed(self): + """Faux-signal; this should all be integrated into + GTKScrollbarOwnerMixin, making this unnecessary. + """ + + @property + def manually_scrolled(self): + """Return whether the view has been scrolled explicitly by the user + since the last time it was set automatically. + """ + auto_pos = self.scroll_positions[1] + if auto_pos is None: + # if we don't have any position yet, user can't have manually + # scrolled + return False + real_pos = self.scrollbars[1].get_value() + return abs(auto_pos - real_pos) > 5 # allowing some fuzziness + + @property + def position_set(self): + """Return whether the scroll position has been set in any way.""" + return any(x is not None for x in self.scroll_positions) + + def on_parent_set(self, widget, old_parent): + """We have parent window now; we need to control its scrollbars.""" + self.set_scroller(widget.get_parent()) + + def set_scroller(self, window): + """Take control of the scrollbars of window.""" + if not isinstance(window, gtk.ScrolledWindow): + return + self.scroller = window + scrollbars = tuple(bar.get_adjustment() + for bar in (window.get_hscrollbar(), window.get_vscrollbar())) + self.scrollbars = scrollbars + for i, bar in enumerate(scrollbars): + weak_connect(bar, 'changed', self.on_scroll_range_changed, i) + if self.restoring_scroll: + self.set_scroll_position(self.restoring_scroll) + + def on_scroll_range_changed(self, adjustment, bar): + """The scrollbar might have a range now. Set its initial position if + we haven't already. + """ + self._coords_working = True + if self.restoring_scroll: + self.set_scroll_position(self.restoring_scroll) + # our wrapper handles the same thing for iters + self.scroll_range_changed() + + def set_scroll_position(self, scroll_position): + """Restore the scrollbars to a remembered state.""" + try: + self.scroll_positions = tuple(self._clip_pos(adj, x) + for adj, x in zip(self.scrollbars, scroll_position)) + except WidgetActionError, error: + logging.debug("can't scroll yet: %s", error.reason) + # try again later + self.restoring_scroll = scroll_position + else: + for adj, pos in zip(self.scrollbars, self.scroll_positions): + adj.set_value(pos) + self.restoring_scroll = None + + def _clip_pos(self, adj, pos): + lower = adj.get_lower() + upper = adj.get_upper() - adj.get_page_size() + # currently, StandardView gets an upper of 2.0 when it's not ready + # FIXME: don't count on that + if pos > upper and upper < 5: + raise WidgetRangeError("scrollable area", pos, lower, upper) + return min(max(pos, lower), upper) + + def get_path_rect(self, path): + """Return the Rect for the given item, in tree coords.""" + if not self._coords_working: + # part of solution to #17405; widget_to_tree_coords tends to return + # y=8 before the first scroll-range-changed signal. ugh. + raise WidgetNotReadyError('_coords_working') + rect = self.get_background_area(path, self.get_columns()[0]) + x, y = self.widget_to_tree_coords(rect.x, rect.y) + return Rect(x, y, rect.width, rect.height) + + @property + def _scrollbars(self): + if not self.scrollbars: + raise WidgetNotReadyError + return self.scrollbars + + def scroll_ancestor(self, newly_selected, down): + # Try to figure out what just became selected. If multiple things + # somehow became selected, select the outermost one + if len(newly_selected) == 0: + raise WidgetActionError("need at an item to scroll to") + if down: + path_to_show = max(newly_selected) + else: + path_to_show = min(newly_selected) + + if not self.scrollbars: + return + vadjustment = self.scrollbars[1] + + rect = self.get_background_area(path_to_show, self.get_columns()[0]) + _, top = self.translate_coordinates(self.scroller, 0, rect.y) + top += vadjustment.value + bottom = top + rect.height + if down: + if bottom > vadjustment.value + vadjustment.page_size: + bottom_value = min(bottom, vadjustment.upper) + vadjustment.set_value(bottom_value - vadjustment.page_size) + else: + if top < vadjustment.value: + vadjustment.set_value(max(vadjustment.lower, top)) + +class MiroTreeView(gtk.TreeView, TreeViewScrolling): + """Extends the GTK TreeView widget to help implement TableView + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + # Add a tiny bit of padding so that the user can drag feeds below + # the table, i.e. to the bottom row, as a top-level + PAD_BOTTOM = 3 + def __init__(self): + gtk.TreeView.__init__(self) + TreeViewScrolling.__init__(self) + self.height_without_pad_bottom = -1 + self.set_enable_search(False) + self.horizontal_separator = self.style_get_property("horizontal-separator") + self.expander_size = self.style_get_property("expander-size") + self.group_lines_enabled = False + self.group_line_color = (0, 0, 0) + self.group_line_width = 1 + + def do_size_request(self, req): + gtk.TreeView.do_size_request(self, req) + self.height_without_pad_bottom = req.height + req.height += self.PAD_BOTTOM + + def do_move_cursor(self, step, count): + if step == gtk.MOVEMENT_VISUAL_POSITIONS: + # GTK is asking us to move left/right. Since our TableViews don't + # support this, return False to let the key press propagate. See + # #15646 for more info. + return False + if isinstance(self.get_parent(), gtk.ScrolledWindow): + # If our parent is a ScrolledWindow, let GTK take care of this + handled = gtk.TreeView.do_move_cursor(self, step, count) + return handled + else: + # Otherwise, we have to search up the widget tree for a + # ScrolledWindow to take care of it + selection = self.get_selection() + model, start_selection = selection.get_selected_rows() + gtk.TreeView.do_move_cursor(self, step, count) + + model, end_selection = selection.get_selected_rows() + newly_selected = set(end_selection) - set(start_selection) + down = (count > 0) + + try: + self.scroll_ancestor(newly_selected, down) + except WidgetActionError: + # not possible + return False + return True + + def get_position_info(self, x, y): + """Wrapper for get_path_at_pos that converts the path_info to a named + tuple and handles rounding the coordinates. + """ + path_info = self.get_path_at_pos(int(round(x)), int(round(y))) + if path_info: + return PathInfo(*path_info) + +gobject.type_register(MiroTreeView) + +class HotspotTracker(object): + """Handles tracking hotspots. + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def __init__(self, treeview, event): + self.treeview = treeview + self.treeview_wrapper = wrappermap.wrapper(treeview) + self.hit = False + self.button = event.button + path_info = treeview.get_position_info(event.x, event.y) + if path_info is None: + return + self.path, self.column, background_x, background_y = path_info + # We always pack 1 renderer for each column + gtk_renderer = self.column.get_cell_renderers()[0] + if not isinstance(gtk_renderer, GTKCustomCellRenderer): + return + self.renderer = wrappermap.wrapper(gtk_renderer) + self.attr_map = self.treeview_wrapper.attr_map_for_column[self.column] + if not rect_contains_point(self.calc_cell_area(), event.x, event.y): + # Mouse is in the padding around the actual cell area + return + self.update_position(event) + self.iter = treeview.get_model().get_iter(self.path) + self.name = self.calc_hotspot() + if self.name is not None: + self.hit = True + + def is_for_context_menu(self): + return self.name == "#show-context-menu" + + def calc_cell_area(self): + cell_area = self.treeview.get_cell_area(self.path, self.column) + xpad = self.renderer._renderer.props.xpad + ypad = self.renderer._renderer.props.ypad + cell_area.x += xpad + cell_area.y += ypad + cell_area.width -= xpad * 2 + cell_area.height -= ypad * 2 + return cell_area + + def update_position(self, event): + self.x, self.y = int(event.x), int(event.y) + + def calc_cell_state(self): + if self.treeview.get_selection().path_is_selected(self.path): + if self.treeview.flags() & gtk.HAS_FOCUS: + return gtk.STATE_SELECTED + else: + return gtk.STATE_ACTIVE + else: + return gtk.STATE_NORMAL + + def calc_hotspot(self): + cell_area = self.calc_cell_area() + if rect_contains_point(cell_area, self.x, self.y): + model = self.treeview.get_model() + self.renderer.cell_data_func(self.column, self.renderer._renderer, + model, self.iter, self.attr_map) + style = drawing.DrawingStyle(self.treeview_wrapper, + use_base_color=True, state=self.calc_cell_state()) + x = self.x - cell_area.x + y = self.y - cell_area.y + return self.renderer.hotspot_test(style, + self.treeview_wrapper.layout_manager, + x, y, cell_area.width, cell_area.height) + else: + return None + + def update_hit(self): + if self.is_for_context_menu(): + return # we always keep hit = True for this one + old_hit = self.hit + self.hit = (self.calc_hotspot() == self.name) + if self.hit != old_hit: + self.redraw_cell() + + def redraw_cell(self): + # Check that the treeview is still around. We might have switched + # views in response to a hotspot being clicked. + if self.treeview.flags() & gtk.REALIZED: + cell_area = self.treeview.get_cell_area(self.path, self.column) + x, y = self.treeview.tree_to_widget_coords(cell_area.x, + cell_area.y) + self.treeview.queue_draw_area(x, y, + cell_area.width, cell_area.height) + +class TableColumn(signals.SignalEmitter): + """A single column of a TableView. + + Signals: + + clicked (table_column) -- The header for this column was clicked. + """ + # GTK hard-codes 4px of padding for each column + FIXED_PADDING = 4 + def __init__(self, title, renderer, header=None, **attrs): + # header widget not used yet in GTK (#15800) + signals.SignalEmitter.__init__(self) + self.create_signal('clicked') + self._column = gtk.TreeViewColumn(title, renderer._renderer) + self._column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) + self._column.set_clickable(True) + self.attrs = attrs + renderer.setup_attributes(self._column, attrs) + self.renderer = renderer + weak_connect(self._column, 'clicked', self._header_clicked) + self.do_horizontal_padding = True + + def set_right_aligned(self, right_aligned): + """Horizontal alignment of the header label.""" + if right_aligned: + self._column.set_alignment(1.0) + else: + self._column.set_alignment(0.0) + + def set_min_width(self, width): + self._column.props.min_width = width + TableColumn.FIXED_PADDING + + def set_max_width(self, width): + self._column.props.max_width = width + + def set_width(self, width): + self._column.set_fixed_width(width + TableColumn.FIXED_PADDING) + + def get_width(self): + return self._column.get_width() + + def _header_clicked(self, tablecolumn): + self.emit('clicked') + + def set_resizable(self, resizable): + """Set if the user can resize the column.""" + self._column.set_resizable(resizable) + + def set_do_horizontal_padding(self, horizontal_padding): + self.do_horizontal_padding = False + + def set_sort_indicator_visible(self, visible): + """Show/Hide the sort indicator for this column.""" + self._column.set_sort_indicator(visible) + + def get_sort_indicator_visible(self): + return self._column.get_sort_indicator() + + def set_sort_order(self, ascending): + """Display a sort indicator on the column header. Ascending can be + either True or False which affects the direction of the indicator. + """ + if ascending: + self._column.set_sort_order(gtk.SORT_ASCENDING) + else: + self._column.set_sort_order(gtk.SORT_DESCENDING) + + def get_sort_order_ascending(self): + """Returns if the sort indicator is displaying that the sort is + ascending. + """ + return self._column.get_sort_order() == gtk.SORT_ASCENDING + +class GTKSelectionOwnerMixin(SelectionOwnerMixin): + """GTK-specific methods for selection management. + + This subclass should not define any behavior. Methods that cannot be + completed in this widget state should raise WidgetActionError. + """ + def __init__(self): + SelectionOwnerMixin.__init__(self) + self.selection = self._widget.get_selection() + weak_connect(self.selection, 'changed', self.on_selection_changed) + + def _set_allow_multiple_select(self, allow): + if allow: + mode = gtk.SELECTION_MULTIPLE + else: + mode = gtk.SELECTION_SINGLE + self.selection.set_mode(mode) + + def _get_allow_multiple_select(self): + return self.selection.get_mode() == gtk.SELECTION_MULTIPLE + + def _get_selected_iters(self): + iters = [] + def collect(treemodel, path, iter_): + iters.append(iter_) + self.selection.selected_foreach(collect) + return iters + + def _get_selected_iter(self): + model, iter_ = self.selection.get_selected() + return iter_ + + @property + def num_rows_selected(self): + return self.selection.count_selected_rows() + + def _is_selected(self, iter_): + return self.selection.iter_is_selected(iter_) + + def _select(self, iter_): + self.selection.select_iter(iter_) + + def _unselect(self, iter_): + self.selection.unselect_iter(iter_) + + def _unselect_all(self): + self.selection.unselect_all() + + def _iter_to_string(self, iter_): + return self._model.get_string_from_iter(iter_) + + def _iter_from_string(self, string): + try: + return self._model.get_iter_from_string(string) + except ValueError: + raise WidgetDomainError( + "model iters", string, "%s other iters" % len(self.model)) + + def select_path(self, path): + self.selection.select_path(path) + + def _validate_iter(self, iter_): + if self.get_path(iter_) is None: + raise WidgetDomainError( + "model iters", iter_, "%s other iters" % len(self.model)) + real_model = self._widget.get_model() + if not real_model: + raise WidgetActionError("no model") + elif real_model != self._model: + raise WidgetActionError("wrong model?") + + def get_cursor(self): + """Return the path of the 'focused' item.""" + path, column = self._widget.get_cursor() + return path + + def set_cursor(self, path): + """Set the path of the 'focused' item.""" + if path is None: + # XXX: is there a way to clear the cursor? + return + path_as_string = ':'.join(str(component) for component in path) + with self.preserving_selection(): # set_cursor() messes up the selection + self._widget.set_cursor(path_as_string) + +class DNDHandlerMixin(object): + """TableView row DnD. + + Depends on arbitrary TableView methods; otherwise self-contained except: + on_button_press: may call start_drag + on_button_release: may unset drag_button_down + on_motion_notify: may call potential_drag_motion + """ + def __init__(self): + self.drag_button_down = False + self.drag_data = {} + self.drag_source = self.drag_dest = None + self.drag_start_x, self.drag_start_y = None, None + self.wrapped_widget_connect('drag-data-get', self.on_drag_data_get) + self.wrapped_widget_connect('drag-end', self.on_drag_end) + self.wrapped_widget_connect('drag-motion', self.on_drag_motion) + self.wrapped_widget_connect('drag-leave', self.on_drag_leave) + self.wrapped_widget_connect('drag-drop', self.on_drag_drop) + self.wrapped_widget_connect('drag-data-received', + self.on_drag_data_received) + self.wrapped_widget_connect('unrealize', self.on_drag_unrealize) + + def set_drag_source(self, drag_source): + self.drag_source = drag_source + # XXX: the following note no longer seems accurate: + # No need to call enable_model_drag_source() here, we handle it + # ourselves in on_motion_notify() + + def set_drag_dest(self, drag_dest): + """Set the drop handler.""" + self.drag_dest = drag_dest + if drag_dest is not None: + targets = self._gtk_target_list(drag_dest.allowed_types()) + self._widget.enable_model_drag_dest(targets, + drag_dest.allowed_actions()) + self._widget.drag_dest_set(0, targets, + drag_dest.allowed_actions()) + else: + self._widget.unset_rows_drag_dest() + self._widget.drag_dest_unset() + + def start_drag(self, treeview, event, path_info): + """Check whether the event is a drag event; return whether handled + here. + """ + if event.state & (gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK): + return False + model, row_paths = treeview.get_selection().get_selected_rows() + + if path_info.path not in row_paths: + # something outside the selection is being dragged. + # make it the new selection. + self.unselect_all(signal=False) + self.select_path(path_info.path) + row_paths = [path_info.path] + rows = self.model.get_rows(row_paths) + self.drag_data = rows and self.drag_source.begin_drag(self, rows) + self.drag_button_down = bool(self.drag_data) + if self.drag_button_down: + self.drag_start_x = int(event.x) + self.drag_start_y = int(event.y) + + if len(row_paths) > 1 and path_info.path in row_paths: + # handle multiple selection. If the current row is already + # selected, stop propagating the signal. We will only change + # the selection if the user doesn't start a DnD operation. + # This makes it more natural for the user to drag a block of + # selected items. + renderer = path_info.column.get_cell_renderers()[0] + if (not self._x_coord_in_expander(treeview, path_info) + and not isinstance(renderer, GTKCheckboxCellRenderer)): + self.delaying_press = True + # grab keyboard focus since we handled the event + self.focus() + return True + + def on_drag_data_get(self, treeview, context, selection, info, timestamp): + for typ, data in self.drag_data.items(): + selection.set(typ, 8, repr(data)) + + def on_drag_end(self, treeview, context): + self.drag_data = {} + + def find_type(self, drag_context): + return self._widget.drag_dest_find_target(drag_context, + self._widget.drag_dest_get_target_list()) + + def calc_positions(self, x, y): + """Given x and y coordinates, generate a list of drop positions to + try. The values are tuples in the form of (parent_path, position, + gtk_path, gtk_position), where parent_path and position is the + position to send to the Miro code, and gtk_path and gtk_position is an + equivalent position to send to the GTK code if the drag_dest validates + the drop. + """ + model = self._model + try: + gtk_path, gtk_position = self._widget.get_dest_row_at_pos(x, y) + except TypeError: + # Below the last row + yield (None, len(model), None, None) + return + + iter_ = model.get_iter(gtk_path) + if gtk_position in (gtk.TREE_VIEW_DROP_INTO_OR_BEFORE, + gtk.TREE_VIEW_DROP_INTO_OR_AFTER): + yield (iter_, -1, gtk_path, gtk_position) + + if hasattr(model, 'iter_is_valid'): + # tablist has this; item list does not + assert model.iter_is_valid(iter_) + parent_iter = model.iter_parent(iter_) + position = gtk_path[-1] + if gtk_position in (gtk.TREE_VIEW_DROP_BEFORE, + gtk.TREE_VIEW_DROP_INTO_OR_BEFORE): + # gtk gave us a "before" position, no need to change it + yield (parent_iter, position, gtk_path, gtk.TREE_VIEW_DROP_BEFORE) + else: + # gtk gave us an "after" position, translate that to before the + # next row for miro. + if (self._widget.row_expanded(gtk_path) and + model.iter_has_child(iter_)): + child_path = gtk_path + (0,) + yield (iter_, 0, child_path, gtk.TREE_VIEW_DROP_BEFORE) + else: + yield (parent_iter, position+1, gtk_path, + gtk.TREE_VIEW_DROP_AFTER) + + def on_drag_motion(self, treeview, drag_context, x, y, timestamp): + if not self.drag_dest: + return True + type = self.find_type(drag_context) + if type == "NONE": + drag_context.drag_status(0, timestamp) + return True + drop_action = 0 + for pos_info in self.calc_positions(x, y): + drop_action = self.drag_dest.validate_drop(self, self.model, type, + drag_context.actions, pos_info[0], pos_info[1]) + if isinstance(drop_action, (list, tuple)): + drop_action, iter = drop_action + path = self.model.get_path(iter) + pos = gtk.TREE_VIEW_DROP_INTO_OR_BEFORE + else: + path, pos = pos_info[2:4] + + if drop_action: + self.set_drag_dest_row(path, pos) + break + else: + self.unset_drag_dest_row() + drag_context.drag_status(drop_action, timestamp) + return True + + def set_drag_dest_row(self, path, position): + self._widget.set_drag_dest_row(path, position) + + def unset_drag_dest_row(self): + self._widget.unset_drag_dest_row() + + def on_drag_leave(self, treeview, drag_context, timestamp): + treeview.unset_drag_dest_row() + + def on_drag_drop(self, treeview, drag_context, x, y, timestamp): + # prevent the default handler + treeview.emit_stop_by_name('drag-drop') + target = self.find_type(drag_context) + if target == "NONE": + return False + treeview.drag_get_data(drag_context, target, timestamp) + treeview.unset_drag_dest_row() + + def on_drag_data_received(self, + treeview, drag_context, x, y, selection, info, timestamp): + # prevent the default handler + treeview.emit_stop_by_name('drag-data-received') + if not self.drag_dest: + return + type = self.find_type(drag_context) + if type == "NONE": + return + if selection.data is None: + return + drop_action = 0 + for pos_info in self.calc_positions(x, y): + drop_action = self.drag_dest.validate_drop(self, self.model, type, + drag_context.actions, pos_info[0], pos_info[1]) + if drop_action: + self.drag_dest.accept_drop(self, self.model, type, + drag_context.actions, pos_info[0], pos_info[1], + eval(selection.data)) + return True + return False + + def on_drag_unrealize(self, treeview): + self.drag_button_down = False + + def potential_drag_motion(self, treeview, event): + """A motion event has occurred and did not hit a hotspot; start a drag + if applicable. + """ + if (self.drag_data and self.drag_button_down and + treeview.drag_check_threshold(self.drag_start_x, + self.drag_start_y, int(event.x), int(event.y))): + self.delaying_press = False + treeview.drag_begin(self._gtk_target_list(self.drag_data.keys()), + self.drag_source.allowed_actions(), 1, event) + + @staticmethod + def _gtk_target_list(types): + count = itertools.count() + return [(type, gtk.TARGET_SAME_APP, count.next()) for type in types] + +class HotspotTrackingMixin(object): + def __init__(self): + self.hotspot_tracker = None + self.create_signal('hotspot-clicked') + self._hotspot_callback_handles = [] + self._connect_hotspot_signals() + self.wrapped_widget_connect('unrealize', self.on_hotspot_unrealize) + + def _connect_hotspot_signals(self): + SIGNALS = { + 'row-inserted': self.on_row_inserted, + 'row-deleted': self.on_row_deleted, + 'row-changed': self.on_row_changed, + } + self._hotspot_callback_handles.extend( + weak_connect(self._model, signal, handler) + for signal, handler in SIGNALS.iteritems()) + + def _disconnect_hotspot_signals(self): + for handle in self._hotspot_callback_handles: + self._model.disconnect(handle) + self._hotspot_callback_handles = [] + + def on_row_inserted(self, model, path, iter_): + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + + def on_row_deleted(self, model, path): + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + + def on_row_changed(self, model, path, iter_): + if self.hotspot_tracker: + self.hotspot_tracker.update_hit() + + def handle_hotspot_hit(self, treeview, event): + """Check whether the event is a hotspot event; return whether handled + here. + """ + if self.hotspot_tracker: + return + hotspot_tracker = HotspotTracker(treeview, event) + if hotspot_tracker.hit: + self.hotspot_tracker = hotspot_tracker + hotspot_tracker.redraw_cell() + if hotspot_tracker.is_for_context_menu(): + menu = self._popup_context_menu(self.hotspot_tracker.path, event) + if menu: + menu.connect('selection-done', + self._on_hotspot_context_menu_selection_done) + # grab keyboard focus since we handled the event + self.focus() + return True + + def _on_hotspot_context_menu_selection_done(self, menu): + # context menu is closed, we won't get the button-release-event in + # this case, but we can unset hotspot tracker here. + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + + def on_hotspot_unrealize(self, treeview): + self.hotspot_tracker = None + + def release_on_hotspot(self, event): + """A button_release occurred; return whether it has been handled as a + hotspot hit. + """ + hotspot_tracker = self.hotspot_tracker + if hotspot_tracker and event.button == hotspot_tracker.button: + hotspot_tracker.update_position(event) + hotspot_tracker.update_hit() + if (hotspot_tracker.hit and + not hotspot_tracker.is_for_context_menu()): + self.emit('hotspot-clicked', hotspot_tracker.name, + hotspot_tracker.iter) + hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + return True + + def hotspot_model_changed(self): + """A bulk change has ended; reconnect signals and update hotspots.""" + self._connect_hotspot_signals() + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker.update_hit() + +class ColumnOwnerMixin(object): + """Keeps track of the table's columns - including the list of columns, and + properties that we set for a table but need to apply to each column. + + This manages: + columns + attr_map_for_column + gtk_column_to_wrapper + for use throughout tableview. + """ + def __init__(self): + self._columns_draggable = False + self._renderer_xpad = self._renderer_ypad = 0 + self.columns = [] + self.attr_map_for_column = {} + self.gtk_column_to_wrapper = {} + self.create_signal('reallocate-columns') # not emitted on GTK + + def remove_column(self, index): + """Remove a column from the display and forget it from the column lists. + """ + column = self.columns.pop(index) + del self.attr_map_for_column[column._column] + del self.gtk_column_to_wrapper[column._column] + self._widget.remove_column(column._column) + + def get_columns(self): + """Returns the current columns, in order, by title.""" + # FIXME: this should probably return column objects, and really should + # not be keeping track of columns by title at all + titles = [column.get_title().decode('utf-8') + for column in self._widget.get_columns()] + return titles + + def add_column(self, column): + """Append a column to this table; setup all necessary mappings, and + setup the new column's properties to match the table's settings. + """ + self.model.check_new_column(column) + self._widget.append_column(column._column) + self.columns.append(column) + self.attr_map_for_column[column._column] = column.attrs + self.gtk_column_to_wrapper[column._column] = column + self.setup_new_column(column) + + def setup_new_column(self, column): + """Apply properties that we keep track of at the table level to a + newly-created column. + """ + if self.background_color: + column.renderer._renderer.set_property('cell-background-gdk', + self.background_color) + column._column.set_reorderable(self._columns_draggable) + if column.do_horizontal_padding: + column.renderer._renderer.set_property('xpad', self._renderer_xpad) + column.renderer._renderer.set_property('ypad', self._renderer_ypad) + + def set_column_spacing(self, space): + """Set the amount of space between columns.""" + self._renderer_xpad = space / 2 + for column in self.columns: + if column.do_horizontal_padding: + column.renderer._renderer.set_property('xpad', + self._renderer_xpad) + + def set_row_spacing(self, space): + """Set the amount of space between columns.""" + self._renderer_ypad = space / 2 + for column in self.columns: + column.renderer._renderer.set_property('ypad', self._renderer_ypad) + + def set_columns_draggable(self, setting): + """Set the draggability of existing and future columns.""" + self._columns_draggable = setting + for column in self.columns: + column._column.set_reorderable(setting) + + def set_column_background_color(self): + """Set the background color of existing columns to the table's + background_color. + """ + for column in self.columns: + column.renderer._renderer.set_property('cell-background-gdk', + self.background_color) + + def set_auto_resizes(self, setting): + # FIXME: to be implemented. + # At this point, GTK somehow does the right thing anyway in terms of + # auto-resizing. I'm not sure exactly what's happening, but I believe + # that if the column widths don't add up to the total width, + # gtk.TreeView allocates extra width for the last column. This works + # well enough for the tab list and item list, since there's only one + # column. + pass + +class HoverTrackingMixin(object): + """Handle mouse hover events - tooltips for some cells and hover events for + renderers which support them. + """ + def __init__(self): + self.hover_info = None + self.hover_pos = None + if hasattr(self, 'get_tooltip'): + # this should probably be something like self.set_tooltip_source + self._widget.set_property('has-tooltip', True) + self.wrapped_widget_connect('query-tooltip', self.on_tooltip) + self._last_tooltip_place = None + + def on_tooltip(self, treeview, x, y, keyboard_mode, tooltip): + # x, y are relative to the entire widget, but we want them to be + # relative to our bin window. The bin window doesn't include things + # like the column headers. + origin = treeview.window.get_origin() + bin_origin = treeview.get_bin_window().get_origin() + x += origin[0] - bin_origin[0] + y += origin[1] - bin_origin[1] + path_info = treeview.get_position_info(x, y) + if path_info is None: + self._last_tooltip_place = None + return False + if (self._last_tooltip_place is not None and + path_info[:2] != self._last_tooltip_place): + # the default GTK behavior is to keep the tooltip in the same + # position, but this is looks bad when we move to a different row. + # So return False once to stop this. + self._last_tooltip_place = None + return False + self._last_tooltip_place = path_info[:2] + iter_ = treeview.get_model().get_iter(path_info.path) + column = self.gtk_column_to_wrapper[path_info.column] + text = self.get_tooltip(iter_, column) + if text is None: + return False + pygtkhacks.set_tooltip_text(tooltip, text) + return True + + def _update_hover(self, treeview, event): + old_hover_info, old_hover_pos = self.hover_info, self.hover_pos + path_info = treeview.get_position_info(event.x, event.y) + if (path_info and + self.gtk_column_to_wrapper[path_info.column].renderer.want_hover): + self.hover_info = path_info.path, path_info.column + self.hover_pos = path_info.x, path_info.y + else: + self.hover_info = None + self.hover_pos = None + if (old_hover_info != self.hover_info or + old_hover_pos != self.hover_pos): + if (old_hover_info != self.hover_info and + old_hover_info is not None): + self._redraw_cell(treeview, *old_hover_info) + if self.hover_info is not None: + self._redraw_cell(treeview, *self.hover_info) + +class GTKScrollbarOwnerMixin(ScrollbarOwnerMixin): + # XXX this is half a wrapper for TreeViewScrolling. A lot of things will + # become much simpler when we integrate TVS into this + def __init__(self): + ScrollbarOwnerMixin.__init__(self) + # super uses this for postponed scroll_to_iter + # it's a faux-signal from our _widget; this hack is only necessary until + # we integrate TVS + self._widget.scroll_range_changed = (lambda *a: + self.emit('scroll-range-changed')) + + def set_scroller(self, scroller): + """Set the Scroller object for this widget, if its ScrolledWindow is + not a direct ancestor of the object. Standard View needs this. + """ + self._widget.set_scroller(scroller._widget) + + def _set_scroll_position(self, scroll_pos): + self._widget.set_scroll_position(scroll_pos) + + def _get_item_area(self, iter_): + return self._widget.get_path_rect(self.get_path(iter_)) + + @property + def _manually_scrolled(self): + return self._widget.manually_scrolled + + @property + def _position_set(self): + return self._widget.position_set + + def _get_visible_area(self): + """Return the Rect of the visible area, in tree coords. + + get_visible_rect gets this wrong for StandardView, always returning an + origin of (0, 0) - this is because our ScrolledWindow is not our direct + parent. + """ + bars = self._widget._scrollbars + x, y = (int(adj.get_value()) for adj in bars) + width, height = (int(adj.get_page_size()) for adj in bars) + if height == 0: + # this happens even after _widget._coords_working + raise WidgetNotReadyError('visible height') + return Rect(x, y, width, height) + + def _get_scroll_position(self): + """Get the current position of both scrollbars, to restore later.""" + try: + return tuple(int(bar.get_value()) for bar in self._widget._scrollbars) + except WidgetNotReadyError: + return None + +class TableView(Widget, GTKSelectionOwnerMixin, DNDHandlerMixin, + HotspotTrackingMixin, ColumnOwnerMixin, HoverTrackingMixin, + GTKScrollbarOwnerMixin): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + draws_selection = True + + def __init__(self, model, custom_headers=False): + Widget.__init__(self) + self.set_widget(MiroTreeView()) + self.model = model + self.model.add_to_tableview(self._widget) + self._model = self._widget.get_model() + wrappermap.add(self._model, model) + self._setup_colors() + self.background_color = None + self.context_menu_callback = None + self.in_bulk_change = False + self.delaying_press = False + self._use_custom_headers = False + self.layout_manager = LayoutManager(self._widget) + self.height_changed = None # 17178 hack + self._connect_signals() + # setting up mixins after general TableView init + GTKSelectionOwnerMixin.__init__(self) + DNDHandlerMixin.__init__(self) + HotspotTrackingMixin.__init__(self) + ColumnOwnerMixin.__init__(self) + HoverTrackingMixin.__init__(self) + GTKScrollbarOwnerMixin.__init__(self) + if custom_headers: + self._enable_custom_headers() + + # FIXME: should implement set_model() and make None a special case. + def unset_model(self): + """Disconnect our model from this table view. + + This should be called when you want to destroy a TableView and + there's a new TableView sharing its model. + """ + self._widget.set_model(None) + self.model = None + + def _connect_signals(self): + self.create_signal('row-expanded') + self.create_signal('row-collapsed') + self.create_signal('row-clicked') + self.create_signal('row-activated') + self.wrapped_widget_connect('row-activated', self.on_row_activated) + self.wrapped_widget_connect('row-expanded', self.on_row_expanded) + self.wrapped_widget_connect('row-collapsed', self.on_row_collapsed) + self.wrapped_widget_connect('button-press-event', self.on_button_press) + self.wrapped_widget_connect('button-release-event', + self.on_button_release) + self.wrapped_widget_connect('motion-notify-event', + self.on_motion_notify) + + def set_gradient_highlight(self, gradient): + # This is just an OS X thing. + pass + + def set_background_color(self, color): + self.background_color = self.make_color(color) + self.modify_style('base', gtk.STATE_NORMAL, self.background_color) + if not self.draws_selection: + self.modify_style('base', gtk.STATE_SELECTED, + self.background_color) + self.modify_style('base', gtk.STATE_ACTIVE, self.background_color) + if self.use_custom_style: + self.set_column_background_color() + + def set_group_lines_enabled(self, enabled): + """Enable/Disable group lines. + + This only has an effect if our model is an InfoListModel and it has a + grouping set. + + If group lines are enabled, we will draw a line below the last item in + the group. Use set_group_line_style() to change the look of the line. + """ + self._widget.group_lines_enabled = enabled + self.queue_redraw() + + def set_group_line_style(self, color, width): + self._widget.group_line_color = color + self._widget.group_line_width = width + self.queue_redraw() + + def handle_custom_style_change(self): + if self.background_color is not None: + if self.use_custom_style: + self.set_column_background_color() + else: + for column in self.columns: + column.renderer._renderer.set_property( + 'cell-background-set', False) + + def set_alternate_row_backgrounds(self, setting): + self._widget.set_rules_hint(setting) + + def set_grid_lines(self, horizontal, vertical): + if horizontal and vertical: + setting = gtk.TREE_VIEW_GRID_LINES_BOTH + elif horizontal: + setting = gtk.TREE_VIEW_GRID_LINES_HORIZONTAL + elif vertical: + setting = gtk.TREE_VIEW_GRID_LINES_VERTICAL + else: + setting = gtk.TREE_VIEW_GRID_LINES_NONE + self._widget.set_grid_lines(setting) + + def width_for_columns(self, total_width): + """Given the width allocated for the TableView, return how much of that + is available to column contents. Note that this depends on the number of + columns. + """ + column_spacing = TableColumn.FIXED_PADDING * len(self.columns) + return total_width - column_spacing + + def enable_album_view_focus_hack(self): + _install_album_view_gtkrc() + self._widget.set_name("miro-album-view") + + def focus(self): + self._widget.grab_focus() + + def _enable_custom_headers(self): + # NB: this is currently not used because the GTK tableview does not + # support custom headers. + self._use_custom_headers = True + + def set_show_headers(self, show): + self._widget.set_headers_visible(show) + self._widget.set_headers_clickable(show) + + def _setup_colors(self): + style = self._widget.style + if not self.draws_selection: + # if we don't want to draw selection, make the selected/active + # colors the same as the normal ones + self.modify_style('base', gtk.STATE_SELECTED, + style.base[gtk.STATE_NORMAL]) + self.modify_style('base', gtk.STATE_ACTIVE, + style.base[gtk.STATE_NORMAL]) + + def set_search_column(self, model_index): + self._widget.set_search_column(model_index) + + def set_fixed_height(self, fixed_height): + self._widget.set_fixed_height_mode(fixed_height) + + def set_row_expanded(self, iter_, expanded): + """Expand or collapse the row specified by iter_. Succeeds or raises + WidgetActionError. Causes row-expanded or row-collapsed to be emitted + when successful. + """ + path = self.get_path(iter_) + if expanded: + self._widget.expand_row(path, False) + else: + self._widget.collapse_row(path) + if bool(self._widget.row_expanded(path)) != bool(expanded): + raise WidgetActionError("cannot expand the given item - it " + "probably has no children.") + + def is_row_expanded(self, iter_): + path = self.get_path(iter_) + return self._widget.row_expanded(path) + + def set_context_menu_callback(self, callback): + self.context_menu_callback = callback + + # GTK is really good and it is safe to operate on table even when + # cells may be constantly changing in flux. + def set_volatile(self, volatile): + return + + def on_row_expanded(self, _widget, iter_, path): + self.emit('row-expanded', iter_, path) + + def on_row_collapsed(self, _widget, iter_, path): + self.emit('row-collapsed', iter_, path) + + def on_button_press(self, treeview, event): + """Handle a mouse button press""" + if event.type == gtk.gdk._2BUTTON_PRESS: + # already handled as row-activated + return False + + path_info = treeview.get_position_info(event.x, event.y) + if not path_info: + # no item was clicked, so it's not going to be a hotspot, drag, or + # context menu + return False + if event.type == gtk.gdk.BUTTON_PRESS: + # single click; emit the event but keep on running so we can handle + # stuff like drag and drop. + if not self._x_coord_in_expander(treeview, path_info): + iter_ = treeview.get_model().get_iter(path_info.path) + self.emit('row-clicked', iter_) + + if (event.button == 1 and self.handle_hotspot_hit(treeview, event)): + return True + if event.window != treeview.get_bin_window(): + # click is outside the content area, don't try to handle this. + # In particular, our DnD code messes up resizing table columns. + return False + if (event.button == 1 and self.drag_source and + not self._x_coord_in_expander(treeview, path_info)): + return self.start_drag(treeview, event, path_info) + elif event.button == 3 and self.context_menu_callback: + self.show_context_menu(treeview, event, path_info) + return True + + # FALLTHROUGH + return False + + def show_context_menu(self, treeview, event, path_info): + """Pop up a context menu for the given click event (which is a + right-click on a row). + """ + # hack for album view + if (treeview.group_lines_enabled and + path_info.column == treeview.get_columns()[0]): + self._select_all_rows_in_group(treeview, path_info.path) + self._popup_context_menu(path_info.path, event) + # grab keyboard focus since we handled the event + self.focus() + + def _select_all_rows_in_group(self, treeview, path): + """Select all items in the group """ + + # FIXME: this is very tightly coupled with the portable code. + + infolist = self.model + gtk_model = treeview.get_model() + if (not isinstance(infolist, InfoListModel) or + infolist.get_grouping() is None): + return + it = gtk_model.get_iter(path) + info, attrs, group_info = infolist.row_for_iter(it) + start_row = path[0] - group_info[0] + total_rows = group_info[1] + + with self._ignoring_changes(): + self.unselect_all() + for row in xrange(start_row, start_row + total_rows): + self.select_path((row,)) + self.emit('selection-changed') + + def _popup_context_menu(self, path, event): + if not self.selection.path_is_selected(path): + self.unselect_all(signal=False) + self.select_path(path) + menu = self.make_context_menu() + if menu: + menu.popup(None, None, None, event.button, event.time) + return menu + else: + return None + + # XXX treeview.get_cell_area handles what we're trying to use this for + def _x_coord_in_expander(self, treeview, path_info): + """Calculate if an x coordinate is over the expander triangle + + :param treeview: Gtk.TreeView + :param path_info: PathInfo( + tree path for the cell, + Gtk.TreeColumn, + x coordinate relative to column's cell area, + y coordinate relative to column's cell area (ignored), + ) + """ + if path_info.column != treeview.get_expander_column(): + return False + model = treeview.get_model() + if not model.iter_has_child(model.get_iter(path_info.path)): + return False + # GTK allocateds an extra 4px to the right of the expanders. This + # seems to be hardcoded as EXPANDER_EXTRA_PADDING in the source code. + total_exander_size = treeview.expander_size + 4 + # include horizontal_separator + # XXX: should this value be included in total_exander_size ? + offset = treeview.horizontal_separator / 2 + # allocate space for expanders for parent nodes + expander_start = total_exander_size * (len(path_info.path) - 1) + offset + expander_end = expander_start + total_exander_size + offset + return expander_start <= path_info.x < expander_end + + def on_row_activated(self, treeview, path, view_column): + iter_ = treeview.get_model().get_iter(path) + self.emit('row-activated', iter_) + + def make_context_menu(self): + def gen_menu(menu_items): + menu = gtk.Menu() + for menu_item_info in menu_items: + if menu_item_info is None: + item = gtk.SeparatorMenuItem() + else: + label, callback = menu_item_info + + if isinstance(label, tuple) and len(label) == 2: + text_label, icon_path = label + pixbuf = gtk.gdk.pixbuf_new_from_file(icon_path) + image = gtk.Image() + image.set_from_pixbuf(pixbuf) + item = gtk.ImageMenuItem(text_label) + item.set_image(image) + else: + item = gtk.MenuItem(label) + + if callback is None: + item.set_sensitive(False) + elif isinstance(callback, list): + item.set_submenu(gen_menu(callback)) + else: + item.connect('activate', self.on_context_menu_activate, + callback) + menu.append(item) + item.show() + return menu + + items = self.context_menu_callback(self) + if items: + return gen_menu(items) + else: + return None + + def on_context_menu_activate(self, item, callback): + callback() + + def on_button_release(self, treeview, event): + if self.release_on_hotspot(event): + return True + if event.button == 1: + self.drag_button_down = False + + if self.delaying_press: + # if dragging did not happen, unselect other rows and + # select current row + path_info = treeview.get_position_info(event.x, event.y) + if path_info is not None: + self.unselect_all(signal=False) + self.select_path(path_info.path) + self.delaying_press = False + + def _redraw_cell(self, treeview, path, column): + cell_area = treeview.get_cell_area(path, column) + x, y = treeview.convert_bin_window_to_widget_coords(cell_area.x, + cell_area.y) + treeview.queue_draw_area(x, y, cell_area.width, cell_area.height) + + def on_motion_notify(self, treeview, event): + self._update_hover(treeview, event) + + if self.hotspot_tracker: + self.hotspot_tracker.update_position(event) + self.hotspot_tracker.update_hit() + return True + + self.potential_drag_motion(treeview, event) + return None # XXX: used to fall through; not sure what retval does here + + def start_bulk_change(self): + self._widget.freeze_child_notify() + self._widget.set_model(None) + self._disconnect_hotspot_signals() + self.in_bulk_change = True + + def model_changed(self): + if self.in_bulk_change: + self._widget.set_model(self._model) + self._widget.thaw_child_notify() + self.hotspot_model_changed() + self.in_bulk_change = False + + def get_path(self, iter_): + """Always use this rather than the model's get_path directly - + if the iter isn't valid, a GTK assertion causes us to exit + without warning; this wrapper changes that to a much more useful + AssertionError. Example related bug: #17362. + """ + assert self.model.iter_is_valid(iter_) + return self._model.get_path(iter_) + +class TableModel(object): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + MODEL_CLASS = gtk.ListStore + + def __init__(self, *column_types): + self._model = self.MODEL_CLASS(*self.map_types(column_types)) + self._column_types = column_types + if 'image' in self._column_types: + self.convert_row_for_gtk = self.convert_row_for_gtk_slow + self.convert_value_for_gtk = self.convert_value_for_gtk_slow + else: + self.convert_row_for_gtk = self.convert_row_for_gtk_fast + self.convert_value_for_gtk = self.convert_value_for_gtk_fast + + def add_to_tableview(self, widget): + widget.set_model(self._model) + + def map_types(self, miro_column_types): + type_map = { + 'boolean': bool, + 'numeric': float, + 'integer': int, + 'text': str, + 'image': gtk.gdk.Pixbuf, + 'datetime': object, + 'object': object, + } + try: + return [type_map[type] for type in miro_column_types] + except KeyError, e: + raise ValueError("Unknown column type: %s" % e[0]) + + # If we store image data, we need to do some work to convert row data to + # send to GTK + def convert_value_for_gtk_slow(self, column_value): + if isinstance(column_value, Image): + return column_value.pixbuf + else: + return column_value + + def convert_row_for_gtk_slow(self, column_values): + return tuple(self.convert_value_for_gtk(c) for c in column_values) + + def check_new_column(self, column): + for value in column.attrs.values(): + if not isinstance(value, int): + msg = "Attribute values must be integers, not %r" % value + raise TypeError(msg) + if value < 0 or value >= len(self._column_types): + raise ValueError("Attribute index out of range: %s" % value) + + # If we don't store image data, we can don't need to do any work to + # convert row data to gtk + def convert_value_for_gtk_fast(self, value): + return value + + def convert_row_for_gtk_fast(self, column_values): + return column_values + + def append(self, *column_values): + return self._model.append(self.convert_row_for_gtk(column_values)) + + def update_value(self, iter_, index, value): + assert self._model.iter_is_valid(iter_) + self._model.set(iter_, index, self.convert_value_for_gtk(value)) + + def update(self, iter_, *column_values): + self._model[iter_] = self.convert_value_for_gtk(column_values) + + def remove(self, iter_): + if self._model.remove(iter_): + return iter_ + else: + return None + + def insert_before(self, iter_, *column_values): + row = self.convert_row_for_gtk(column_values) + return self._model.insert_before(iter_, row) + + def first_iter(self): + return self._model.get_iter_first() + + def next_iter(self, iter_): + return self._model.iter_next(iter_) + + def nth_iter(self, index): + assert index >= 0 + return self._model.iter_nth_child(None, index) + + def __iter__(self): + return iter(self._model) + + def __len__(self): + return len(self._model) + + def __getitem__(self, iter_): + return self._model[iter_] + + def get_rows(self, row_paths): + return [self._model[path] for path in row_paths] + + def get_path(self, iter_): + return self._model.get_path(iter_) + + def iter_is_valid(self, iter_): + return self._model.iter_is_valid(iter_) + +class TreeTableModel(TableModel): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + MODEL_CLASS = gtk.TreeStore + + def append(self, *column_values): + return self._model.append(None, self.convert_row_for_gtk( + column_values)) + + def insert_before(self, iter_, *column_values): + parent = self.parent_iter(iter_) + row = self.convert_row_for_gtk(column_values) + return self._model.insert_before(parent, iter_, row) + + def append_child(self, iter_, *column_values): + return self._model.append(iter_, self.convert_row_for_gtk( + column_values)) + + def child_iter(self, iter_): + return self._model.iter_children(iter_) + + def nth_child_iter(self, iter_, index): + assert index >= 0 + return self._model.iter_nth_child(iter_, index) + + def has_child(self, iter_): + return self._model.iter_has_child(iter_) + + def children_count(self, iter_): + return self._model.iter_n_children(iter_) + + def parent_iter(self, iter_): + assert self._model.iter_is_valid(iter_) + return self._model.iter_parent(iter_) diff --git a/lvc/widgets/gtk/tableviewcells.py b/lvc/widgets/gtk/tableviewcells.py new file mode 100644 index 0000000..6511970 --- /dev/null +++ b/lvc/widgets/gtk/tableviewcells.py @@ -0,0 +1,249 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tableviewcells.py - Cell renderers for TableView.""" + +import gobject +import gtk +import pango + +from lvc import signals +from lvc.widgets import widgetconst +import drawing +import wrappermap +from .base import make_gdk_color + +class CellRenderer(object): + """Simple Cell Renderer + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self): + self._renderer = gtk.CellRendererText() + self.want_hover = False + + def setup_attributes(self, column, attr_map): + column.add_attribute(self._renderer, 'text', attr_map['value']) + + def set_align(self, align): + if align == 'left': + self._renderer.props.xalign = 0.0 + elif align == 'center': + self._renderer.props.xalign = 0.5 + elif align == 'right': + self._renderer.props.xalign = 1.0 + else: + raise ValueError("unknown alignment: %s" % align) + + def set_color(self, color): + self._renderer.props.foreground_gdk = make_gdk_color(color) + + def set_bold(self, bold): + font_desc = self._renderer.props.font_desc + if bold: + font_desc.set_weight(pango.WEIGHT_BOLD) + else: + font_desc.set_weight(pango.WEIGHT_NORMAL) + self._renderer.props.font_desc = font_desc + + def set_text_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self._renderer.props.scale = 1.0 + elif size == widgetconst.SIZE_SMALL: + # FIXME: on 3.5 we just ignored the call. Always setting scale to + # 1.0 basically replicates that behavior, but should we actually + # try to implement the semantics of SIZE_SMALL? + self._renderer.props.scale = 1.0 + else: + raise ValueError("unknown size: %s" % size) + + def set_font_scale(self, scale_factor): + self._renderer.props.scale = scale_factor + +class ImageCellRenderer(object): + """Cell Renderer for images + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self): + self._renderer = gtk.CellRendererPixbuf() + self.want_hover = False + + def setup_attributes(self, column, attr_map): + column.add_attribute(self._renderer, 'pixbuf', attr_map['image']) + +class GTKCheckboxCellRenderer(gtk.CellRendererToggle): + def do_activate(self, event, treeview, path, background_area, cell_area, + flags): + iter = treeview.get_model().get_iter(path) + self.set_active(not self.get_active()) + wrappermap.wrapper(self).emit('clicked', iter) + +gobject.type_register(GTKCheckboxCellRenderer) + +class CheckboxCellRenderer(signals.SignalEmitter): + """Cell Renderer for booleans + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self): + signals.SignalEmitter.__init__(self) + self.create_signal("clicked") + self._renderer = GTKCheckboxCellRenderer() + wrappermap.add(self._renderer, self) + self.want_hover = False + + def set_control_size(self, size): + pass + + def setup_attributes(self, column, attr_map): + column.add_attribute(self._renderer, 'active', attr_map['value']) + +class GTKCustomCellRenderer(gtk.GenericCellRenderer): + """Handles the GTK hide of CustomCellRenderer + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def on_get_size(self, widget, cell_area=None): + wrapper = wrappermap.wrapper(self) + widget_wrapper = wrappermap.wrapper(widget) + style = drawing.DrawingStyle(widget_wrapper, use_base_color=True) + # NOTE: CustomCellRenderer.cell_data_func() sets up its attributes + # from the model itself, so we don't have to worry about setting them + # here. + width, height = wrapper.get_size(style, widget_wrapper.layout_manager) + x_offset = self.props.xpad + y_offset = self.props.ypad + width += self.props.xpad * 2 + height += self.props.ypad * 2 + if cell_area: + x_offset += cell_area.x + y_offset += cell_area.x + extra_width = max(0, cell_area.width - width) + extra_height = max(0, cell_area.height - height) + x_offset += int(round(self.props.xalign * extra_width)) + y_offset += int(round(self.props.yalign * extra_height)) + return x_offset, y_offset, width, height + + def on_render(self, window, widget, background_area, cell_area, expose_area, + flags): + widget_wrapper = wrappermap.wrapper(widget) + cell_wrapper = wrappermap.wrapper(self) + + selected = (flags & gtk.CELL_RENDERER_SELECTED) + if selected: + if widget.flags() & gtk.HAS_FOCUS: + state = gtk.STATE_SELECTED + else: + state = gtk.STATE_ACTIVE + else: + state = gtk.STATE_NORMAL + if cell_wrapper.IGNORE_PADDING: + area = background_area + else: + xpad = self.props.xpad + ypad = self.props.ypad + area = gtk.gdk.Rectangle(cell_area.x + xpad, cell_area.y + ypad, + cell_area.width - xpad * 2, cell_area.height - ypad * 2) + context = drawing.DrawingContext(window, area, expose_area) + if (selected and not widget_wrapper.draws_selection and + widget_wrapper.use_custom_style): + # Draw the base color as our background. This erases the gradient + # that GTK draws for selected items. + window.draw_rectangle(widget.style.base_gc[state], True, + background_area.x, background_area.y, + background_area.width, background_area.height) + context.style = drawing.DrawingStyle(widget_wrapper, + use_base_color=True, state=state) + widget_wrapper.layout_manager.update_cairo_context(context.context) + hotspot_tracker = widget_wrapper.hotspot_tracker + if (hotspot_tracker and hotspot_tracker.hit and + hotspot_tracker.column == self.column and + hotspot_tracker.path == self.path): + hotspot = hotspot_tracker.name + else: + hotspot = None + if (self.path, self.column) == widget_wrapper.hover_info: + hover = widget_wrapper.hover_pos + hover = (hover[0] - xpad, hover[1] - ypad) + else: + hover = None + # NOTE: CustomCellRenderer.cell_data_func() sets up its attributes + # from the model itself, so we don't have to worry about setting them + # here. + widget_wrapper.layout_manager.reset() + cell_wrapper.render(context, widget_wrapper.layout_manager, selected, + hotspot, hover) + + def on_activate(self, event, widget, path, background_area, cell_area, + flags): + pass + + def on_start_editing(self, event, widget, path, background_area, + cell_area, flags): + pass +gobject.type_register(GTKCustomCellRenderer) + +class CustomCellRenderer(object): + """Customizable Cell Renderer + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + IGNORE_PADDING = False + + def __init__(self): + self._renderer = GTKCustomCellRenderer() + self.want_hover = False + wrappermap.add(self._renderer, self) + + def setup_attributes(self, column, attr_map): + column.set_cell_data_func(self._renderer, self.cell_data_func, + attr_map) + + def cell_data_func(self, column, cell, model, iter, attr_map): + cell.column = column + cell.path = model.get_path(iter) + row = model[iter] + # Set attributes on self instead cell This works because cell is just + # going to turn around and call our methods to do the rendering. + for name, index in attr_map.items(): + setattr(self, name, row[index]) + + def hotspot_test(self, style, layout, x, y, width, height): + return None + +class InfoListRenderer(CustomCellRenderer): + """Custom Renderer for InfoListModels + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def cell_data_func(self, column, cell, model, iter, attr_map): + self.info, self.attrs, self.group_info = \ + wrappermap.wrapper(model).row_for_iter(iter) + cell.column = column + cell.path = model.get_path(iter) + +class InfoListRendererText(CellRenderer): + """Renderer for InfoListModels that only display text + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def setup_attributes(self, column, attr_map): + infolist.gtk.setup_text_cell_data_func(column, self._renderer, + self.get_value) diff --git a/lvc/widgets/gtk/weakconnect.py b/lvc/widgets/gtk/weakconnect.py new file mode 100644 index 0000000..204a855 --- /dev/null +++ b/lvc/widgets/gtk/weakconnect.py @@ -0,0 +1,56 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""weakconnect.py -- Connect to a signal of a GObject using a weak method +reference. This means that this connection will not keep the object alive. +This is a good thing because it prevents circular references between wrapper +widgets and the wrapped GTK widget. +""" + +from lvc import signals + +class WeakSignalHandler(object): + def __init__(self, method): + self.method = signals.WeakMethodReference(method) + + def connect(self, obj, signal, *user_args): + self.user_args = user_args + self.signal_handle = obj.connect(signal, self.handle_callback) + return self.signal_handle + + def handle_callback(self, obj, *args): + real_method = self.method() + if real_method is not None: + return real_method(obj, *(args + self.user_args)) + else: + obj.disconnect(self.signal_handle) + +def weak_connect(gobject, signal, method, *user_args): + handler = WeakSignalHandler(method) + return handler.connect(gobject, signal, *user_args) diff --git a/lvc/widgets/gtk/widgets.py b/lvc/widgets/gtk/widgets.py new file mode 100644 index 0000000..6c4280d --- /dev/null +++ b/lvc/widgets/gtk/widgets.py @@ -0,0 +1,47 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".widgets -- Contains portable implementations of +the GTK Widgets. These are shared between the windows port and the x11 port. +""" + +import gtk + +# Just use the GDK Rectangle class +class Rect(gtk.gdk.Rectangle): + @classmethod + def from_string(cls, rect_string): + x, y, width, height = [int(i) for i in rect_string.split(',')] + return Rect(x, y, width, height) + + def __str__(self): + return "%d,%d,%d,%d" % (self.x, self.y, self.width, self.height) + + def get_width(self): + return self.width diff --git a/lvc/widgets/gtk/widgetset.py b/lvc/widgets/gtk/widgetset.py new file mode 100644 index 0000000..c63855c --- /dev/null +++ b/lvc/widgets/gtk/widgetset.py @@ -0,0 +1,63 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +from .base import Widget, Bin +from .const import * +from .controls import TextEntry, NumberEntry, \ + SecureTextEntry, MultilineTextEntry, Checkbox, RadioButton, \ + RadioButtonGroup, OptionMenu, Button +from .customcontrols import ( + CustomButton, DragableCustomButton, CustomSlider, + ClickableImageButton) +# VolumeSlider and VolumeMuter aren't defined if gtk.VolumeButton +# doesn't have get_popup. +try: + from .customcontrols import ( + VolumeSlider, VolumeMuter) +except ImportError: + pass +from .contextmenu import ContextMenu +from .drawing import ImageSurface, DrawingContext, \ + DrawingArea, Background, Gradient +from .layout import HBox, VBox, Alignment, \ + Splitter, Table, TabContainer, DetachedWindowHolder +from .window import Window, MainWindow, Dialog, \ + FileOpenDialog, FileSaveDialog, DirectorySelectDialog, AboutDialog, \ + AlertDialog, DialogWindow +from .tableview import (TableView, TableModel, + TableColumn, TreeTableModel, CUSTOM_HEADER_HEIGHT) +from .tableviewcells import (CellRenderer, + ImageCellRenderer, CheckboxCellRenderer, CustomCellRenderer, + InfoListRenderer, InfoListRendererText) +from .simple import (Image, ImageDisplay, + AnimatedImageDisplay, Label, Scroller, Expander, SolidBackground, + ProgressBar, HLine) +from .widgets import Rect +from .gtkmenus import (MenuItem, RadioMenuItem, CheckMenuItem, Separator, + Menu, MenuBar, MainWindowMenuBar) diff --git a/lvc/widgets/gtk/window.py b/lvc/widgets/gtk/window.py new file mode 100644 index 0000000..de912cc --- /dev/null +++ b/lvc/widgets/gtk/window.py @@ -0,0 +1,708 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Jesus Eduardo (Heckyel) | 2017 +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".window -- GTK Window widget.""" + +import gobject +import gtk +import os + +from lvc import resources +from lvc import signals + +import keymap +import layout +import widgets +import wrappermap + +# keeps the objects alive until destroy() is called +alive_windows = set() +running_dialogs = set() + +class WrappedWindow(gtk.Window): + def do_map(self): + gtk.Window.do_map(self) + wrappermap.wrapper(self).emit('show') + + def do_unmap(self): + gtk.Window.do_unmap(self) + wrappermap.wrapper(self).emit('hide') + def do_focus_in_event(self, event): + gtk.Window.do_focus_in_event(self, event) + wrappermap.wrapper(self).emit('active-change') + def do_focus_out_event(self, event): + gtk.Window.do_focus_out_event(self, event) + wrappermap.wrapper(self).emit('active-change') + + def do_key_press_event(self, event): + if self.activate_key(event): # event activated a menu item + return + + if self.propagate_key_event(event): # event handled by widget + return + + ret = keymap.translate_gtk_event(event) + if ret is not None: + key, modifiers = ret + rv = wrappermap.wrapper(self).emit('key-press', key, modifiers) + if not rv: + gtk.Window.do_key_press_event(self, event) + + def _get_focused_wrapper(self): + """Get the wrapper of the widget with keyboard focus""" + focused = self.get_focus() + # some of our widgets created children for their use + # (GtkSearchTextEntry). If we don't find a wrapper for + # focused, try it's parents + while focused is not None: + try: + wrapper = wrappermap.wrapper(focused) + except KeyError: + focused = focused.get_parent() + else: + return wrapper + return None + + def change_focus_using_wrapper(self, direction): + my_wrapper = wrappermap.wrapper(self) + focused_wrapper = self._get_focused_wrapper() + if direction == gtk.DIR_TAB_FORWARD: + to_focus = my_wrapper.get_next_tab_focus(focused_wrapper, True) + elif direction == gtk.DIR_TAB_BACKWARD: + to_focus = my_wrapper.get_next_tab_focus(focused_wrapper, False) + else: + return False + if to_focus is not None: + to_focus.focus() + return True + return False + + def do_focus(self, direction): + if not self.change_focus_using_wrapper(direction): + gtk.Window.do_focus(self, direction) + +gobject.type_register(WrappedWindow) + +class WindowBase(signals.SignalEmitter): + def __init__(self): + signals.SignalEmitter.__init__(self) + self.create_signal('use-custom-style-changed') + self.create_signal('key-press') + self.create_signal('show') + self.create_signal('hide') + + def set_window(self, window): + self._window = window + window.connect('style-set', self.on_style_set) + wrappermap.add(window, self) + self.calc_use_custom_style() + + def on_style_set(self, widget, old_style): + old_use_custom_style = self.use_custom_style + self.calc_use_custom_style() + if old_use_custom_style != self.use_custom_style: + self.emit('use-custom-style-changed') + + def calc_use_custom_style(self): + if self._window is not None: + base = self._window.style.base[gtk.STATE_NORMAL] + # Decide if we should use a custom style. Right now the + # formula is the base color is a very light shade of + # gray/white (lighter than #f0f0f0). + self.use_custom_style = ((base.red == base.green == base.blue) and + base.red >= 61680) + + +class Window(WindowBase): + """The main Libre window. """ + + def __init__(self, title, rect=None): + """Create the Libre Main Window. Title is the name to give the + window, rect specifies the position it should have on screen. + """ + WindowBase.__init__(self) + self.set_window(self._make_gtk_window()) + self._window.set_title(title) + self.setup_icon() + if rect: + self._window.set_default_size(rect.width, rect.height) + self._window.set_default_size(rect.width, rect.height) + self._window.set_gravity(gtk.gdk.GRAVITY_CENTER) + self._window.move(rect.x, rect.y) + + self.create_signal('active-change') + self.create_signal('will-close') + self.create_signal('did-move') + self.create_signal('file-drag-motion') + self.create_signal('file-drag-received') + self.create_signal('file-drag-leave') + self.create_signal('on-shown') + self.drag_signals = [] + alive_windows.add(self) + + self._window.connect('delete-event', self.on_delete_window) + self._window.connect('map-event', lambda w, a: self.emit('on-shown')) + # XXX: Define MVCWindow/MiroWindow style not hard code this + self._window.set_resizable(False) + + def setup_icon(self): + icon_pixbuf = gtk.gdk.pixbuf_new_from_file( + resources.image_path("lvc-logo.png")) + self._window.set_icon(icon_pixbuf) + + + def accept_file_drag(self, val): + if not val: + self._window.drag_dest_set(0, [], 0) + for handle in self.drag_signals: + self.disconnect(handle) + self.drag_signals = [] + else: + self._window.drag_dest_set( + gtk.DEST_DEFAULT_MOTION | gtk.DEST_DEFAULT_DROP, + [('text/uri-list', 0, 0)], + gtk.gdk.ACTION_COPY) + for signal, callback in ( + ('drag-motion', self.on_drag_motion), + ('drag-data-received', self.on_drag_data_received), + ('drag-leave', self.on_drag_leave)): + self.drag_signals.append( + self._window.connect(signal, callback)) + + def on_drag_motion(self, widget, context, x, y, time): + self.emit('file-drag-motion') + + def on_drag_data_received(self, widget, context, x, y, selection_data, + info, time): + self.emit('file-drag-received', selection_data.get_uris()) + + def on_drag_leave(self, widget, context, time): + self.emit('file-drag-leave') + + def on_delete_window(self, widget, event): + # when the user clicks on the X in the corner of the window we + # want that to close the window, but also trigger our + # will-close signal and all that machinery unless the window + # is currently hidden--then we don't do anything. + if not self._window.window.is_visible(): + return + self.close() + return True + + def _make_gtk_window(self): + return WrappedWindow() + + def set_title(self, title): + self._window.set_title(title) + + def get_title(self): + self._window.get_title() + + def center(self): + self._window.set_position(gtk.WIN_POS_CENTER) + + def show(self): + if self not in alive_windows: + raise ValueError("Window destroyed") + self._window.show() + + def close(self): + if hasattr(self, "_closing"): + return + self._closing = True + # Keep a reference to the widget in case will-close signal handler + # calls destroy() + old_window = self._window + self.emit('will-close') + old_window.hide() + del self._closing + + def destroy(self): + self.close() + self._window = None + alive_windows.discard(self) + + def is_active(self): + return self._window.is_active() + + def is_visible(self): + return self._window.props.visible + + def get_next_tab_focus(self, current, is_forward): + return None + + def set_content_widget(self, widget): + """Set the widget that will be drawn in the content area for this + window. + + It will be allocated the entire area of the widget, except the + space needed for the titlebar, frame and other decorations. + When the window is resized, content should also be resized. + """ + self._add_content_widget(widget) + widget._widget.show() + self.content_widget = widget + + def _add_content_widget(self, widget): + self._window.add(widget._widget) + + def get_content_widget(self, widget): + """Get the current content widget.""" + return self.content_widget + + def get_frame(self): + pos = self._window.get_position() + size = self._window.get_size() + return widgets.Rect(pos[0], pos[1], size[0], size[1]) + + def set_frame(self, x=None, y=None, width=None, height=None): + if x is not None or y is not None: + pos = self._window.get_position() + x = x if x is not None else pos[0] + y = y if y is not None else pos[1] + self._window.move(x, y) + + if width is not None or height is not None: + size = self._window.get_size() + width = width if width is not None else size[0] + height = height if height is not None else size[1] + self._window.resize(width, height) + + def get_monitor_geometry(self): + """Returns a Rect of the geometry of the monitor that this + window is currently on. + + :returns: Rect + """ + gtkwindow = self._window + gdkwindow = gtkwindow.window + screen = gtkwindow.get_screen() + + monitor = screen.get_monitor_at_window(gdkwindow) + return screen.get_monitor_geometry(monitor) + + def check_position_and_fix(self): + """This pulls the geometry of the monitor of the screen this + window is on as well as the position of the window. + + It then makes sure that the position y is greater than the + monitor geometry y. This makes sure that the titlebar of + the window is showing. + """ + gtkwindow = self._window + gdkwindow = gtkwindow.window + monitor_geom = self.get_monitor_geometry() + + frame_extents = gdkwindow.get_frame_extents() + position = gtkwindow.get_position() + + # if the frame is not visible, then we move the window so that + # it is + if frame_extents.y < monitor_geom.y: + gtkwindow.move(position[0], + monitor_geom.y + (position[1] - frame_extents.y)) + + + +class DialogWindow(Window): + def __init__(self, title, rect=None): + Window.__init__(self, title, rect) + self._window.set_resizable(False) + +class MainWindow(Window): + def __init__(self, title, rect): + Window.__init__(self, title, rect) + self.vbox = gtk.VBox() + self._window.add(self.vbox) + self.vbox.show() + self._add_app_menubar() + self.create_signal('save-dimensions') + self.create_signal('save-maximized') + self._window.connect('key-release-event', self.on_key_release) + self._window.connect('window-state-event', self.on_window_state_event) + self._window.connect('configure-event', self.on_configure_event) + + def _make_gtk_window(self): + return WrappedWindow() + + def on_delete_window(self, widget, event): + return True + + def on_configure_event(self, widget, event): + (x, y) = self._window.get_position() + (width, height) = self._window.get_size() + self.emit('save-dimensions', x, y, width, height) + + def on_window_state_event(self, widget, event): + maximized = bool( + event.new_window_state & gtk.gdk.WINDOW_STATE_MAXIMIZED) + self.emit('save-maximized', maximized) + + def on_key_release(self, widget, event): + if app.playback_manager.is_playing: + if gtk.gdk.keyval_name(event.keyval) in ('Right', 'Left', + 'Up', 'Down'): + return True + + def _add_app_menubar(self): + self.menubar = app.widgetapp.menubar + self.vbox.pack_start(self.menubar._widget, expand=False) + self.connect_menu_keyboard_shortcuts() + + def _add_content_widget(self, widget): + self.vbox.pack_start(widget._widget, expand=True) + + +class DialogBase(WindowBase): + def set_transient_for(self, window): + self._window.set_transient_for(window._window) + + def run(self): + running_dialogs.add(self) + try: + return self._run() + finally: + running_dialogs.remove(self) + self._window = None + + def _run(self): + """Run the dialog. Must be implemented by subclasses.""" + raise NotImplementedError() + + def destroy(self): + if self._window is not None: + self._window.response(gtk.RESPONSE_NONE) + # don't set self._window to None yet. We will unset it when we + # return from the _run() method + +class Dialog(DialogBase): + def __init__(self, title, description=None): + """Create a dialog.""" + DialogBase.__init__(self) + self.create_signal('open') + self.create_signal('close') + self.set_window(gtk.Dialog(title)) + self._window.set_default_size(425, -1) + self.extra_widget = None + self.buttons_to_add = [] + wrappermap.add(self._window, self) + self.description = description + + def build_content(self): + packing_vbox = layout.VBox(spacing=20) + packing_vbox._widget.set_border_width(6) + if self.description is not None: + label = gtk.Label(self.description) + label.set_line_wrap(True) + label.set_size_request(390, -1) + label.set_selectable(True) + packing_vbox._widget.pack_start(label) + if self.extra_widget: + packing_vbox._widget.pack_start(self.extra_widget._widget) + return packing_vbox + + def add_button(self, text): + from lvc.widgets import dialogs + _stock = { + dialogs.BUTTON_OK.text: gtk.STOCK_OK, + dialogs.BUTTON_CANCEL.text: gtk.STOCK_CANCEL, + dialogs.BUTTON_YES.text: gtk.STOCK_YES, + dialogs.BUTTON_NO.text: gtk.STOCK_NO, + dialogs.BUTTON_QUIT.text: gtk.STOCK_QUIT, + dialogs.BUTTON_REMOVE.text: gtk.STOCK_REMOVE, + dialogs.BUTTON_DELETE.text: gtk.STOCK_DELETE, + } + if text in _stock: + # store both the text and the stock ID + text = _stock[text], text + self.buttons_to_add.append(text) + + def pack_buttons(self): + # There's a couple tricky things here: + # 1) We need to add them in the reversed order we got them, since GTK + # lays them out left-to-right + # + # 2) We can't use 0 as a response-id. GTK only reserves positive + # response_ids for the user. + response_id = len(self.buttons_to_add) + for text in reversed(self.buttons_to_add): + label = None + if isinstance(text, tuple): # stock ID, text + text, label = text + button = self._window.add_button(text, response_id) + if label is not None: + button.set_label(label) + response_id -= 1 + self.buttons_to_add = [] + self._window.set_default_response(1) + + def _run(self): + self.pack_buttons() + packing_vbox = self.build_content() + self._window.vbox.pack_start(packing_vbox._widget, True, True) + self._window.show_all() + response = self._window.run() + self._window.hide() + if response == gtk.RESPONSE_DELETE_EVENT: + return -1 + else: + return response - 1 # response IDs started at 1 + + def set_extra_widget(self, widget): + self.extra_widget = widget + + def get_extra_widget(self): + return self.extra_widget + +class FileDialogBase(DialogBase): + def _run(self): + ret = self._window.run() + self._window.hide() + if ret == gtk.RESPONSE_OK: + self._files = self._window.get_filenames() + return 0 + +class FileOpenDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._files = None + fcd = gtk.FileChooserDialog(title, + action=gtk.FILE_CHOOSER_ACTION_OPEN, + buttons=(gtk.STOCK_CANCEL, + gtk.RESPONSE_CANCEL, + gtk.STOCK_OPEN, + gtk.RESPONSE_OK)) + + self.set_window(fcd) + + def set_filename(self, text): + self._window.set_filename(text) + + def set_select_multiple(self, value): + self._window.set_select_multiple(value) + + def add_filters(self, filters): + for name, ext_list in filters: + f = gtk.FileFilter() + f.set_name(name) + for mem in ext_list: + f.add_pattern('*.%s' % mem) + self._window.add_filter(f) + + f = gtk.FileFilter() + f.set_name(_('All files')) + f.add_pattern('*') + self._window.add_filter(f) + + def get_filenames(self): + return [unicode(f) for f in self._files] + + def get_filename(self): + if self._files is None: + # clicked Cancel + return None + else: + return unicode(self._files[0]) + + # provide a common interface for file chooser dialogs + get_path = get_filename + def set_path(self, path): + # set_filename puts the whole path in the filename field + self._window.set_current_folder(os.path.dirname(path)) + self._window.set_current_name(os.path.basename(path)) + +class FileSaveDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._files = None + fcd = gtk.FileChooserDialog(title, + action=gtk.FILE_CHOOSER_ACTION_SAVE, + buttons=(gtk.STOCK_CANCEL, + gtk.RESPONSE_CANCEL, + gtk.STOCK_SAVE, + gtk.RESPONSE_OK)) + self.set_window(fcd) + + def set_filename(self, text): + self._window.set_current_name(text) + + def get_filename(self): + if self._files is None: + # clicked Cancel + return None + else: + return unicode(self._files[0]) + + # provide a common interface for file chooser dialogs + get_path = get_filename + def set_path(self, path): + # set_filename puts the whole path in the filename field + self._window.set_current_folder(os.path.dirname(path)) + self._window.set_current_name(os.path.basename(path)) + +class DirectorySelectDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._files = None + choose_str = 'Choose' + fcd = gtk.FileChooserDialog( + title, + action=gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER, + buttons=(gtk.STOCK_CANCEL, + gtk.RESPONSE_CANCEL, + choose_str, gtk.RESPONSE_OK)) + self.set_window(fcd) + + def set_directory(self, text): + self._window.set_filename(text) + + def get_directory(self): + if self._files is None: + # clicked Cancel + return None + else: + return unicode(self._files[0]) + + # provide a common interface for file chooser dialogs + get_path = get_directory + set_path = set_directory + +class AboutDialog(Dialog): + def __init__(self): + Dialog.__init__(self, "Libre Video Converter") +# _("About %(appname)s", +# {'appname': app.config.get(prefs.SHORT_APP_NAME)})) +# self.add_button(_("Close")) + self.add_button("Close") + self._window.set_has_separator(False) + + def build_content(self): + packing_vbox = layout.VBox(spacing=20) + #icon_pixbuf = gtk.gdk.pixbuf_new_from_file_at_size( + # resources.share_path('icons/hicolor/128x128/apps/miro.png'), + # 48, 48) + #packing_vbox._widget.pack_start(gtk.image_new_from_pixbuf(icon_pixbuf)) + #if app.config.get(prefs.APP_REVISION_NUM): + # version = "%s (%s)" % ( + # app.config.get(prefs.APP_VERSION), + # app.config.get(prefs.APP_REVISION_NUM)) + #else: + # version = "%s" % app.config.get(prefs.APP_VERSION) + version = '1.0.1' + #name_label = gtk.Label( + # '<span size="xx-large" weight="bold">%s %s</span>' % ( + # app.config.get(prefs.SHORT_APP_NAME), version)) + name_label = gtk.Label( + '<span size="xx-large" weight="bold">%s %s</span>' % ( + 'Libre Video Converter', version)) + name_label.set_use_markup(True) + packing_vbox._widget.pack_start(name_label) + copyright_text = 'Copyright (c) Jesus Eduardo (Heckyel) | 2017' + copyright_label = gtk.Label('<small>%s</small>' % copyright_text) + copyright_label.set_use_markup(True) + copyright_label.set_justify(gtk.JUSTIFY_CENTER) + packing_vbox._widget.pack_start(copyright_label) + + # FIXME - make the project url clickable + #packing_vbox._widget.pack_start( + # gtk.Label(app.config.get(prefs.PROJECT_URL))) + + #contributor_label = gtk.Label( + # _("Thank you to all the people who contributed to %(appname)s " + # "%(version)s:", + # {"appname": app.config.get(prefs.SHORT_APP_NAME), + # "version": app.config.get(prefs.APP_VERSION)})) + #contributor_label.set_justify(gtk.JUSTIFY_CENTER) + #packing_vbox._widget.pack_start(contributor_label) + + # get contributors, remove newlines and wrap it + #contributors = open(resources.path('CREDITS'), 'r').readlines() + #contributors = [c[2:].strip() + # for c in contributors if c.startswith("* ")] + #contributors = ", ".join(contributors) + + # show contributors + #contrib_buffer = gtk.TextBuffer() + #contrib_buffer.set_text(contributors) + + #contrib_view = gtk.TextView(contrib_buffer) + #contrib_view.set_editable(False) + #contrib_view.set_cursor_visible(False) + #contrib_view.set_wrap_mode(gtk.WRAP_WORD) + #contrib_window = gtk.ScrolledWindow() + #contrib_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) + #contrib_window.add(contrib_view) + #contrib_window.set_size_request(-1, 100) + #packing_vbox._widget.pack_start(contrib_window) + + # FIXME - make the project url clickable + #donate_label = gtk.Label( + # _("To help fund continued %(appname)s development, visit the " + # "donation page at:", + # {"appname": app.config.get(prefs.SHORT_APP_NAME)})) + #donate_label.set_justify(gtk.JUSTIFY_CENTER) + #packing_vbox._widget.pack_start(donate_label) + + #packing_vbox._widget.pack_start( + # gtk.Label(app.config.get(prefs.DONATE_URL))) + return packing_vbox + + def on_contrib_link_event(self, texttag, widget, event, iter_): + if event.type == gtk.gdk.BUTTON_PRESS: + resources.open_url('https://notabug.org/heckyel/librevideoconverter') + +type_map = { + 0: gtk.MESSAGE_WARNING, + 1: gtk.MESSAGE_INFO, + 2: gtk.MESSAGE_ERROR +} + +class AlertDialog(DialogBase): + def __init__(self, title, description, alert_type): + DialogBase.__init__(self) + message_type = type_map.get(alert_type, gtk.MESSAGE_INFO) + self.set_window(gtk.MessageDialog(type=message_type, + message_format=description)) + self._window.set_title(title) + self.description = description + + def add_button(self, text): + self._window.add_button(_stock.get(text, text), 1) + self._window.set_default_response(1) + + def _run(self): + self._window.set_modal(False) + self._window.show_all() + response = self._window.run() + self._window.hide() + if response == gtk.RESPONSE_DELETE_EVENT: + return -1 + else: + # response IDs start at 1 + return response - 1 diff --git a/lvc/widgets/gtk/wrappermap.py b/lvc/widgets/gtk/wrappermap.py new file mode 100644 index 0000000..c2b2aad --- /dev/null +++ b/lvc/widgets/gtk/wrappermap.py @@ -0,0 +1,50 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".wrappermap -- Map GTK Widgets to the Libre Widget +that wraps them. +""" + +import weakref + +# Maps gtk windows -> wrapper objects. We use a weak references to prevent +# circular references between the GTK widget and it's wrapper. (Keeping a +# reference to the GTK widget is fine, since if the wrapper is alive, the GTK +# widget should be). +widget_mapping = weakref.WeakValueDictionary() + +def wrapper(gtk_widget): + """Find the wrapper widget for a GTK widget.""" + try: + return widget_mapping[gtk_widget] + except KeyError: + raise KeyError("Widget wrapper no longer exists") + +def add(gtk_widget, wrapper): + widget_mapping[gtk_widget] = wrapper diff --git a/lvc/widgets/keyboard.py b/lvc/widgets/keyboard.py new file mode 100644 index 0000000..6700de2 --- /dev/null +++ b/lvc/widgets/keyboard.py @@ -0,0 +1,69 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""Define keyboard input in a platform-independant way.""" + +(CTRL, ALT, SHIFT, CMD, MOD, RIGHT_ARROW, LEFT_ARROW, UP_ARROW, + DOWN_ARROW, SPACE, ENTER, DELETE, BKSPACE, ESCAPE, + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12) = range(26) + +class Shortcut: + """Defines a shortcut key combination used to trigger this + menu item. + + The first argument is the shortcut key. Other arguments are + modifiers. + + Examples: + + >>> Shortcut("x", MOD) + >>> Shortcut(BKSPACE, MOD) + + This is wrong: + + >>> Shortcut(MOD, "x") + """ + def __init__(self, shortcut, *modifiers): + self.shortcut = shortcut + self.modifiers = modifiers + + def _get_key_symbol(self, value): + """Translate key values to their symbolic names.""" + if isinstance(self.shortcut, int): + shortcut_string = '<Unknown>' + for name, value in globals().iteritems(): + if value == self.shortcut: + return name + return repr(value) + + def __str__(self): + shortcut_string = self._get_key_symbol(self.shortcut) + mod_string = repr(set(self._get_key_symbol(k) for k in + self.modifiers)) + return "Shortcut(%s, %s)" % (shortcut_string, mod_string) diff --git a/lvc/widgets/menus.py b/lvc/widgets/menus.py new file mode 100644 index 0000000..4e23b46 --- /dev/null +++ b/lvc/widgets/menus.py @@ -0,0 +1,268 @@ +# menus.py +# +# Most of these are taken from libs/frontends/widgets/menus.py in the miro +# project. +# +# TODO: merge common bits! + +import collections + +from lvc import signals +from lvc.widgets import widgetutil +from lvc.widgets import widgetset +from lvc.widgets import app + +from lvc.widgets.keyboard import (Shortcut, CTRL, ALT, SHIFT, CMD, + MOD, RIGHT_ARROW, LEFT_ARROW, UP_ARROW, DOWN_ARROW, SPACE, ENTER, DELETE, + BKSPACE, ESCAPE, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12) + +# XXX hack: + +def _(text, *params): + if params: + return text % params[0] + return text + +class MenuItem(widgetset.MenuItem): + """Portable MenuItem class. + + This adds group handling to the platform menu items. + """ + # group_map is used for the legacy menu updater code + group_map = collections.defaultdict(set) + + def __init__(self, label, name, shortcut=None, groups=None, + **state_labels): + widgetset.MenuItem.__init__(self, label, name, shortcut) + # state_labels is used for the legacy menu updater code + self.state_labels = state_labels + if groups: + if len(groups) > 1: + raise ValueError("only support one group") + MenuItem.group_map[groups[0]].add(self) + +class MenuItemFetcher(object): + """Get MenuItems by their name quickly. """ + + def __init__(self): + self._cache = {} + + def __getitem__(self, name): + if name in self._cache: + return self._cache[name] + else: + menu_item = app.widgetapp.menubar.find(name) + self._cache[name] = menu_item + return menu_item + +def get_app_menu(): + """Returns the default menu structure.""" + + app_name = "Libre Video Converter" # XXX HACK + + file_menu = widgetset.Menu(_("_File"), "FileMenu", [ + MenuItem(_("_Open"), "Open", Shortcut("o", MOD), + groups=["NonPlaying"]), + MenuItem(_("_Quit"), "Quit", Shortcut("q", MOD)), + ]) + help_menu = widgetset.Menu(_("_Help"), "HelpMenu", [ + MenuItem(_("About %(name)s", + {'name': app_name}), + "About") + ]) + + all_menus = [file_menu, help_menu] + return all_menus + +action_handlers = {} +group_action_handlers = {} + +def on_menubar_activate(menubar, action_name): + callback = lookup_handler(action_name) + if callback is not None: + callback() + +def lookup_handler(action_name): + """For a given action name, get a callback to handle it. Return + None if no callback is found. + """ + + retval = _lookup_group_handler(action_name) + if retval is None: + retval = action_handlers.get(action_name) + return retval + +def _lookup_group_handler(action_name): + try: + group_name, callback_arg = action_name.split('-', 1) + except ValueError: + return None # split return tuple of length 1 + try: + group_handler = group_action_handlers[group_name] + except KeyError: + return None + else: + return lambda: group_handler(callback_arg) + +def action_handler(name): + """Decorator for functions that handle menu actions.""" + def decorator(func): + action_handlers[name] = func + return func + return decorator + +def group_action_handler(action_prefix): + def decorator(func): + group_action_handlers[action_prefix] = func + return func + return decorator + +# File menu +@action_handler("Open") +def on_open(): + app.widgetapp.choose_file() + +@action_handler("Quit") +def on_quit(): + app.widgetapp.quit() + +# Help menu +@action_handler("About") +def on_about(): + app.widgetapp.about() + +class MenuManager(signals.SignalEmitter): + """Updates the menu based on the current selection. + + This includes enabling/disabling menu items, changing menu text + for plural selection and enabling/disabling the play button. The + play button is obviously not a menu item, but it's pretty closely + related + + Whenever code makes a change that could possibly affect which menu + items should be enabled/disabled, it should call the + update_menus() method. + + Signals: + - menus-updated(reasons): Emitted whenever update_menus() is called + """ + def __init__(self): + signals.SignalEmitter.__init__(self, 'menus-updated') + self.menu_item_fetcher = MenuItemFetcher() + #self.subtitle_encoding_updater = SubtitleEncodingMenuUpdater() + self.subtitle_encoding_updater = None + + def setup_menubar(self, menubar): + """Setup the main miro menubar. + """ + menubar.add_initial_menus(get_app_menu()) + menubar.connect("activate", on_menubar_activate) + self.menu_updaters = [] + + def _set_play_pause(self): + if ((not app.playback_manager.is_playing + or app.playback_manager.is_paused)): + label = _('Play') + else: + label = _('Pause') + self.menu_item_fetcher['PlayPauseItem'].set_label(label) + + def add_subtitle_encoding_menu(self, category_label, *encodings): + """Set up a subtitles encoding menu. + + This method should be called for each category of subtitle encodings + (East Asian, Western European, Unicode, etc). Pass it the list of + encodings for that category. + + :param category_label: human-readable name for the category + :param encodings: list of (label, encoding) tuples. label is a + human-readable name, and encoding is a value that we can pass to + VideoDisplay.select_subtitle_encoding() + """ + self.subtitle_encoding_updater.add_menu(category_label, encodings) + + def select_subtitle_encoding(self, encoding): + if not self.subtitle_encoding_updater.has_encodings(): + # OSX never sets up the subtitle encoding menu + return + menu_item_name = self.subtitle_encoding_updater.action_name(encoding) + try: + self.menu_item_fetcher[menu_item_name].set_state(True) + except KeyError: + logging.warn("Error enabling subtitle encoding menu item: %s", + menu_item_name) + + def update_menus(self, *reasons): + """Call this when a change is made that could change the menus + + Use reasons to describe why the menus could change. Some MenuUpdater + objects will do some optimizations based on that + """ + reasons = set(reasons) + self._set_play_pause() + for menu_updater in self.menu_updaters: + menu_updater.update(reasons) + self.emit('menus-updated', reasons) + +class MenuUpdater(object): + """Base class for objects that dynamically update menus.""" + def __init__(self, menu_name): + self.menu_name = menu_name + self.first_update = False + + # we lazily access our menu item, since we are created before the menubar + # is fully setup. + def get_menu(self): + try: + return self._menu + except AttributeError: + self._menu = app.widgetapp.menubar.find(self.menu_name) + return self._menu + menu = property(get_menu) + + def update(self, reasons): + if not self.first_update and not self.should_process_update(reasons): + return + self.first_update = False + self.start_update() + if not self.should_show_menu(): + self.menu.hide() + return + + self.menu.show() + if self.should_rebuild_menu(): + self.clear_menu() + self.populate_menu() + self.update_items() + + def should_process_update(self, reasons): + """Test if we should ignore the update call. + + :param reasons: the reasons passed in to MenuManager.update_menus() + """ + return True + + def clear_menu(self): + """Remove items from our menu before rebuilding it.""" + for child in self.menu.get_children(): + self.menu.remove(child) + + def start_update(self): + """Called at the very start of the update method. """ + pass + + def should_show_menu(self): + """Should we display the menu? """ + return True + + def should_rebuild_menu(self): + """Should we rebuild the menu structure?""" + return False + + def populate_menu(self): + """Add MenuItems to our menu.""" + pass + + def update_items(self): + """Update our menu items.""" + pass diff --git a/lvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib b/lvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib new file mode 100644 index 0000000..b7fefd6 --- /dev/null +++ b/lvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib @@ -0,0 +1,145 @@ +<?xml version="1.0" encoding="UTF-8"?> +<archive type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="8.00"> + <data> + <int key="IBDocument.SystemTarget">1060</int> + <string key="IBDocument.SystemVersion">12A269</string> + <string key="IBDocument.InterfaceBuilderVersion">2549</string> + <string key="IBDocument.AppKitVersion">1187</string> + <string key="IBDocument.HIToolboxVersion">624.00</string> + <object class="NSMutableDictionary" key="IBDocument.PluginVersions"> + <string key="NS.key.0">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="NS.object.0">2549</string> + </object> + <array key="IBDocument.IntegratedClassDependencies"> + <string>NSCustomObject</string> + <string>NSMenu</string> + <string>NSMenuItem</string> + </array> + <array key="IBDocument.PluginDependencies"> + <string>com.apple.InterfaceBuilder.CocoaPlugin</string> + </array> + <object class="NSMutableDictionary" key="IBDocument.Metadata"> + <string key="NS.key.0">PluginDependencyRecalculationVersion</string> + <integer value="1" key="NS.object.0"/> + </object> + <array class="NSMutableArray" key="IBDocument.RootObjects" id="864178278"> + <object class="NSCustomObject" id="422340081"> + <object class="NSMutableString" key="NSClassName"> + <characters key="NS.bytes">NSApplication</characters> + </object> + </object> + <object class="NSCustomObject" id="99063961"> + <string key="NSClassName">FirstResponder</string> + </object> + <object class="NSCustomObject" id="399126242"> + <string key="NSClassName">NSApplication</string> + </object> + <object class="NSMenu" id="603720448"> + <string key="NSTitle">MainMenu</string> + <array class="NSMutableArray" key="NSMenuItems"> + <object class="NSMenuItem" id="726726549"> + <reference key="NSMenu" ref="603720448"/> + <string key="NSTitle">Libre Video Converter</string> + <string key="NSKeyEquiv"/> + <int key="NSKeyEquivModMask">1048576</int> + <int key="NSMnemonicLoc">2147483647</int> + <object class="NSCustomResource" key="NSOnImage"> + <string key="NSClassName">NSImage</string> + <string key="NSResourceName">NSMenuCheckmark</string> + </object> + <object class="NSCustomResource" key="NSMixedImage"> + <string key="NSClassName">NSImage</string> + <string key="NSResourceName">NSMenuMixedState</string> + </object> + <string key="NSAction">submenuAction:</string> + <object class="NSMenu" key="NSSubmenu" id="530441688"> + <string key="NSTitle">Libre Video Converter</string> + <array class="NSMutableArray" key="NSMenuItems"/> + <string key="NSName">_NSAppleMenu</string> + </object> + </object> + </array> + <string key="NSName">_NSMainMenu</string> + </object> + </array> + <object class="IBObjectContainer" key="IBDocument.Objects"> + <array class="NSMutableArray" key="connectionRecords"/> + <object class="IBMutableOrderedSet" key="objectRecords"> + <array key="orderedObjects"> + <object class="IBObjectRecord"> + <int key="objectID">0</int> + <array key="object" id="0"/> + <reference key="children" ref="864178278"/> + <nil key="parent"/> + </object> + <object class="IBObjectRecord"> + <int key="objectID">-2</int> + <reference key="object" ref="422340081"/> + <reference key="parent" ref="0"/> + <string key="objectName">File's Owner</string> + </object> + <object class="IBObjectRecord"> + <int key="objectID">-1</int> + <reference key="object" ref="99063961"/> + <reference key="parent" ref="0"/> + <string key="objectName">First Responder</string> + </object> + <object class="IBObjectRecord"> + <int key="objectID">29</int> + <reference key="object" ref="603720448"/> + <array class="NSMutableArray" key="children"> + <reference ref="726726549"/> + </array> + <reference key="parent" ref="0"/> + <string key="objectName">MainMenu</string> + </object> + <object class="IBObjectRecord"> + <int key="objectID">56</int> + <reference key="object" ref="726726549"/> + <array class="NSMutableArray" key="children"> + <reference ref="530441688"/> + </array> + <reference key="parent" ref="603720448"/> + </object> + <object class="IBObjectRecord"> + <int key="objectID">57</int> + <reference key="object" ref="530441688"/> + <reference key="parent" ref="726726549"/> + </object> + <object class="IBObjectRecord"> + <int key="objectID">-3</int> + <reference key="object" ref="399126242"/> + <reference key="parent" ref="0"/> + <string key="objectName">Application</string> + </object> + </array> + </object> + <dictionary class="NSMutableDictionary" key="flattenedProperties"> + <string key="-1.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="-2.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="-3.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="29.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="56.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="57.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + </dictionary> + <dictionary class="NSMutableDictionary" key="unlocalizedProperties"/> + <nil key="activeLocalization"/> + <dictionary class="NSMutableDictionary" key="localizations"/> + <nil key="sourceID"/> + <int key="maxID">248</int> + </object> + <object class="IBClassDescriber" key="IBDocument.Classes"/> + <int key="IBDocument.localizationMode">0</int> + <string key="IBDocument.TargetRuntimeIdentifier">IBCocoaFramework</string> + <object class="NSMutableDictionary" key="IBDocument.PluginDeclaredDependencies"> + <string key="NS.key.0">com.apple.InterfaceBuilder.CocoaPlugin.macosx</string> + <real value="1060" key="NS.object.0"/> + </object> + <bool key="IBDocument.PluginDeclaredDependenciesTrackSystemTargetVersion">YES</bool> + <int key="IBDocument.defaultPropertyAccessControl">3</int> + <dictionary class="NSMutableDictionary" key="IBDocument.LastKnownImageSizes"> + <string key="NSMenuCheckmark">{11, 11}</string> + <string key="NSMenuMixedState">{10, 3}</string> + </dictionary> + </data> +</archive> diff --git a/lvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib b/lvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib Binary files differnew file mode 100644 index 0000000..963b444 --- /dev/null +++ b/lvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib diff --git a/lvc/widgets/osx/__init__.py b/lvc/widgets/osx/__init__.py new file mode 100644 index 0000000..86653eb --- /dev/null +++ b/lvc/widgets/osx/__init__.py @@ -0,0 +1,74 @@ +import sys + +from objc import * +from Foundation import * +from AppKit import * + +from PyObjCTools import AppHelper + +size_request_manager = None + +class AppController(NSObject): + def applicationDidFinishLaunching_(self, notification): + from lvc.widgets.osx.osxmenus import MenuBar + self.portableApp.menubar = MenuBar() + self.portableApp.startup() + self.portableApp.run() + + def setPortableApp_(self, portableApp): + self.portableApp = portableApp + + def handleMenuActivate_(self, menu_item): + from lvc.widgets.osx import osxmenus + osxmenus.handle_menu_activate(menu_item) + +def initialize(app): + nsapp = NSApplication.sharedApplication() + delegate = AppController.alloc().init() + delegate.setPortableApp_(app) + nsapp.setDelegate_(delegate) + + global size_request_manager + from lvc.widgets.osx.widgetupdates import SizeRequestManager + size_request_manager = SizeRequestManager() + + NSApplicationMain(sys.argv) + +def attach_menubar(): + pass + +def mainloop_start(): + pass + +def mainloop_stop(): + NSApplication.sharedApplication().terminate_(nil) + +def idle_add(callback, periodic=None): + def wrapper(): + callback() + if periodic is not None: + AppHelper.callLater(periodic, wrapper) + if periodic is not None and periodic < 0: + raise ValueError('periodic cannot be negative') + # XXX: we have a lousy thread API that doesn't allocate pools for us... + pool = NSAutoreleasePool.alloc().init() + if periodic is not None: + AppHelper.callLater(periodic, wrapper) + else: + AppHelper.callAfter(wrapper) + del pool + +def idle_remove(id_): + pass + +def reveal_file(filename): + # XXX: dumb lousy type conversions ... + path = NSURL.fileURLWithPath_(filename.decode('utf-8')).path() + NSWorkspace.sharedWorkspace().selectFile_inFileViewerRootedAtPath_( + path, nil) + +def get_conversion_directory(): + url, error = NSFileManager.defaultManager().URLForDirectory_inDomain_appropriateForURL_create_error_(NSMoviesDirectory, NSUserDomainMask, nil, YES, None) + if error: + return None + return url.path().encode('utf-8') diff --git a/lvc/widgets/osx/base.py b/lvc/widgets/osx/base.py new file mode 100644 index 0000000..30536aa --- /dev/null +++ b/lvc/widgets/osx/base.py @@ -0,0 +1,367 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".base.py -- Widget base classes.""" + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from lvc import signals +import wrappermap +from .viewport import Viewport, BorrowedViewport + +class Widget(signals.SignalEmitter): + """Base class for Cocoa widgets. + + attributes: + + CREATES_VIEW -- Does the widget create a view for itself? If this is True + the widget must have an attribute named view, which is the view that the + widget uses. + + placement -- What portion of view the widget occupies. + """ + + CREATES_VIEW = True + + def __init__(self): + signals.SignalEmitter.__init__(self, 'size-request-changed', + 'size-allocated', 'key-press', 'focus-out') + self.create_signal('place-in-scroller') + self.viewport = None + self.parent_is_scroller = False + self.manual_size_request = None + self.cached_size_request = None + self._disabled = False + + def set_can_focus(self, allow): + assert isinstance(self.view, NSControl) + self.view.setRefusesFirstResponder_(not allow) + + def set_size_request(self, width, height): + self.manual_size_request = (width, height) + self.invalidate_size_request() + + def clear_size_request_cache(self): + from lvc.widgets.osx import size_request_manager + if size_request_manager is not None: + while size_request_manager.widgets_to_request: + size_request_manager._run_requests() + + def get_size_request(self): + if self.manual_size_request: + width, height = self.manual_size_request + if width == -1: + width = self.get_natural_size_request()[0] + if height == -1: + height = self.get_natural_size_request()[1] + return width, height + return self.get_natural_size_request() + + def get_natural_size_request(self): + if self.cached_size_request: + return self.cached_size_request + else: + self.cached_size_request = self.calc_size_request() + return self.cached_size_request + + def invalidate_size_request(self): + from lvc.widgets.osx import size_request_manager + if size_request_manager is not None: + size_request_manager.add_widget(self) + + def do_invalidate_size_request(self): + """Recalculate the size request for this widget.""" + old_size_request = self.cached_size_request + self.cached_size_request = None + self.emit('size-request-changed', old_size_request) + + def calc_size_request(self): + """Return the minimum size needed to display this widget. + Must be Implemented by subclasses. + """ + raise NotImplementedError() + + def _debug_size_request(self, nesting_level=0): + """Debug size request calculations. + + This method recursively prints out the size request for each widget. + """ + request = self.calc_size_request() + width = int(request[0]) + height = int(request[1]) + indent = ' ' * nesting_level + me = str(self.__class__).split('.')[-1] + print '%s%s: %sx%s' % (indent, me, width, height) + + def place(self, rect, containing_view): + """Place this widget on a view. """ + if self.viewport is None: + if self.CREATES_VIEW: + self.viewport = Viewport(self.view, rect) + containing_view.addSubview_(self.view) + wrappermap.add(self.view, self) + else: + self.viewport = BorrowedViewport(containing_view, rect) + self.viewport_created() + else: + if not self.viewport.at_position(rect): + self.viewport.reposition(rect) + self.viewport_repositioned() + self.emit('size-allocated', rect.size.width, rect.size.height) + + def remove_viewport(self): + if self.viewport is not None: + self.viewport.remove() + self.viewport = None + if self.CREATES_VIEW: + wrappermap.remove(self.view) + + def viewport_created(self): + """Called after we first create a viewport. Subclasses can override + this method if they want to handle this event. + """ + + def viewport_repositioned(self): + """Called when we reposition our viewport. Subclasses can override + this method if they want to handle this event. + """ + + def viewport_scrolled(self): + """Called by the Scroller widget on it's child widget when it is + scrolled. + """ + + def get_width(self): + return int(self.viewport.get_width()) + width = property(get_width) + + def get_height(self): + return int(self.viewport.get_height()) + height = property(get_height) + + def get_window(self): + if not self.viewport.view: + return None + return wrappermap.wrapper(self.viewport.view.window()) + + def queue_redraw(self): + if self.viewport: + self.viewport.queue_redraw() + + def redraw_now(self): + if self.viewport: + self.viewport.redraw_now() + + def relative_position(self, other_widget): + """Get the position of another widget, relative to this widget.""" + basePoint = self.viewport.view.convertPoint_fromView_( + other_widget.viewport.area().origin, + other_widget.viewport.view) + return (basePoint.x - self.viewport.area().origin.x, + basePoint.y - self.viewport.area().origin.y) + + def make_color(self, (red, green, blue)): + return NSColor.colorWithDeviceRed_green_blue_alpha_(red, green, blue, + 1.0) + + def enable(self): + self._disabled = False + + def disable(self): + self._disabled = True + + def set_disabled(self, disabled): + if disabled: + self.disable() + else: + self.enable() + + def get_disabled(self): + return self._disabled + +class Container(Widget): + """Widget that holds other widgets. """ + + def __init__(self): + Widget.__init__(self) + self.callback_handles = {} + + def on_child_size_request_changed(self, child, old_size): + self.invalidate_size_request() + + def connect_child_signals(self, child): + handle = child.connect_weak('size-request-changed', + self.on_child_size_request_changed) + self.callback_handles[child] = handle + + def disconnect_child_signals(self, child): + child.disconnect(self.callback_handles.pop(child)) + + def remove_viewport(self): + for child in self.children: + child.remove_viewport() + Widget.remove_viewport(self) + + def child_added(self, child): + """Must be called by subclasses when a child is added to the + Container.""" + self.connect_child_signals(child) + self.children_changed() + + def child_removed(self, child): + """Must be called by subclasses when a child is removed from the + Container.""" + self.disconnect_child_signals(child) + child.remove_viewport() + self.children_changed() + + def child_changed(self, old_child, new_child): + """Must be called by subclasses when a child is replaced by a new + child in the Container. To simplify things a bit for subclasses, + old_child can be None in which case this is the same as + child_added(new_child). + """ + if old_child is not None: + self.disconnect_child_signals(old_child) + old_child.remove_viewport() + self.connect_child_signals(new_child) + self.children_changed() + + def children_changed(self): + """Invoked when the set of children for this widget changes.""" + self.do_invalidate_size_request() + + def do_invalidate_size_request(self): + Widget.do_invalidate_size_request(self) + if self.viewport: + self.place_children() + + def viewport_created(self): + self.place_children() + + def viewport_repositioned(self): + self.place_children() + + def viewport_scrolled(self): + for child in self.children: + child.viewport_scrolled() + + def place_children(self): + """Layout our child widgets. Must be implemented by subclasses.""" + raise NotImplementedError() + + def _debug_size_request(self, nesting_level=0): + for child in self.children: + child._debug_size_request(nesting_level+1) + Widget._debug_size_request(self, nesting_level) + +class Bin(Container): + """Container that only has one child widget.""" + + def __init__(self, child=None): + Container.__init__(self) + self.child = None + if child is not None: + self.add(child) + + def get_children(self): + if self.child: + return [self.child] + else: + return [] + children = property(get_children) + + def add(self, child): + if self.child is not None: + raise ValueError("Already have a child: %s" % self.child) + self.child = child + self.child_added(self.child) + + def remove(self): + if self.child is not None: + old_child = self.child + self.child = None + self.child_removed(old_child) + + def set_child(self, new_child): + old_child = self.child + self.child = new_child + self.child_changed(old_child, new_child) + + def enable(self): + Container.enable(self) + self.child.enable() + + def disable(self): + Container.disable(self) + self.child.disable() + +class SimpleBin(Bin): + """Bin that whose child takes up it's entire space.""" + + def calc_size_request(self): + if self.child is None: + return (0, 0) + else: + return self.child.get_size_request() + + def place_children(self): + if self.child: + self.child.place(self.viewport.area(), self.viewport.view) + +class FlippedView(NSView): + """Flipped NSView. We use these internally to lessen the differences + between Cocoa and GTK. + """ + + def init(self): + self = super(FlippedView, self).init() + self.background = None + return self + + def initWithFrame_(self, rect): + self = super(FlippedView, self).initWithFrame_(rect) + self.background = None + return self + + def isFlipped(self): + return YES + + def isOpaque(self): + return self.background is not None + + def setBackgroundColor_(self, color): + self.background = color + + def drawRect_(self, rect): + if self.background: + self.background.set() + NSBezierPath.fillRect_(rect) diff --git a/lvc/widgets/osx/const.py b/lvc/widgets/osx/const.py new file mode 100644 index 0000000..ae0da40 --- /dev/null +++ b/lvc/widgets/osx/const.py @@ -0,0 +1,44 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +from AppKit import * + +"""const.py -- Constants""" + +DRAG_ACTION_NONE = NSDragOperationNone +DRAG_ACTION_COPY = NSDragOperationCopy +DRAG_ACTION_MOVE = NSDragOperationMove +DRAG_ACTION_LINK = NSDragOperationLink +DRAG_ACTION_ALL = (DRAG_ACTION_COPY | DRAG_ACTION_MOVE | DRAG_ACTION_LINK) + +ITEM_TITLE_FONT = "Helvetica" +ITEM_DESC_FONT = "Helvetica" +ITEM_INFO_FONT = "Lucida Grande" + +TOOLBAR_GRAY = (0.19, 0.19, 0.19) diff --git a/lvc/widgets/osx/contextmenu.py b/lvc/widgets/osx/contextmenu.py new file mode 100644 index 0000000..7a8fa55 --- /dev/null +++ b/lvc/widgets/osx/contextmenu.py @@ -0,0 +1,84 @@ +from AppKit import * +from objc import nil + +from .base import Widget + +class ContextMenuHandler(NSObject): + def initWithCallback_widget_i_(self, callback, widget, i): + self = super(ContextMenuHandler, self).init() + self.callback = callback + self.widget = widget + self.i = i + return self + + def handleMenuItem_(self, sender): + self.callback(self.widget, self.i) + + +class MiroContextMenu(NSMenu): + # Works exactly like NSMenu, except it keeps a reference to the menu + # handler objects. + def init(self): + self = super(MiroContextMenu, self).init() + self.handlers = set() + return self + + def addItem_(self, item): + if isinstance(item.target(), ContextMenuHandler): + self.handlers.add(item.target()) + return NSMenu.addItem_(self, item) + + +class ContextMenu(object): + + def __init__(self, options): + super(ContextMenu, self).__init__() + self.menu = MiroContextMenu.alloc().init() + for i, item_info in enumerate(options): + if item_info is None: + nsitem = NSMenuItem.separatorItem() + else: + label, callback = item_info + nsitem = NSMenuItem.alloc().init() + font_size = NSFont.systemFontSize() + font = NSFont.fontWithName_size_("Lucida Sans Italic", font_size) + if font is None: + font = NSFont.systemFontOfSize_(font_size) + attributes = {NSFontAttributeName: font} + attributed_label = NSAttributedString.alloc().initWithString_attributes_(label, attributes) + nsitem.setAttributedTitle_(attributed_label) + else: + nsitem.setTitle_(label) + if isinstance(callback, list): + submenu = ContextMenu(callback) + self.menu.setSubmenu_forItem_(submenu.menu, nsitem) + else: + handler = ContextMenuHandler.alloc().initWithCallback_widget_i_(callback, self, i) + nsitem.setTarget_(handler) + nsitem.setAction_('handleMenuItem:') + self.menu.addItem_(nsitem) + + def popup(self): + # support for non-window based popups thanks to + # http://stackoverflow.com/questions/9033534/how-can-i-pop-up-nsmenu-at-mouse-cursor-position + location = NSEvent.mouseLocation() + frame = NSMakeRect(location.x, location.y, 200, 200) + window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + frame, + NSBorderlessWindowMask, + NSBackingStoreBuffered, + NO) + window.setAlphaValue_(0) + window.makeKeyAndOrderFront_(NSApp) + location_in_window = window.convertScreenToBase_(location) + event = NSEvent.mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( + NSLeftMouseDown, + location_in_window, + 0, + 0, + window.windowNumber(), + nil, + 0, + 0, + 0) + NSMenu.popUpContextMenu_withEvent_forView_(self.menu, event, window.contentView()) diff --git a/lvc/widgets/osx/control.py b/lvc/widgets/osx/control.py new file mode 100644 index 0000000..63419d5 --- /dev/null +++ b/lvc/widgets/osx/control.py @@ -0,0 +1,530 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".control - Controls.""" + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from lvc.widgets import widgetconst +import wrappermap +from .base import Widget +from .helpers import NotificationForwarder + +class SizedControl(Widget): + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self.view.cell().setControlSize_(NSRegularControlSize) + font = NSFont.systemFontOfSize_(NSFont.systemFontSize()) + self.font_size = NSFont.systemFontSize() + elif size == widgetconst.SIZE_SMALL: + font = NSFont.systemFontOfSize_(NSFont.smallSystemFontSize()) + self.view.cell().setControlSize_(NSSmallControlSize) + self.font_size = NSFont.smallSystemFontSize() + else: + self.view.cell().setControlSize_(NSRegularControlSize) + font = NSFont.systemFontOfSize_(NSFont.systemFontSize() * size) + self.font_size = NSFont.systemFontSize() * size + self.view.setFont_(font) + +class BaseTextEntry(SizedControl): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, initial_text=None): + SizedControl.__init__(self) + self.view = self.make_view() + self.font = NSFont.systemFontOfSize_(NSFont.systemFontSize()) + self.view.setFont_(self.font) + self.view.setEditable_(YES) + self.view.cell().setScrollable_(YES) + self.view.cell().setLineBreakMode_(NSLineBreakByClipping) + self.sizer_cell = self.view.cell().copy() + if initial_text: + self.view.setStringValue_(initial_text) + self.set_width(len(initial_text)) + else: + self.set_width(10) + + self.notifications = NotificationForwarder.create(self.view) + + self.create_signal('activate') + self.create_signal('changed') + self.create_signal('validate') + + def focus(self): + if self.view.window() is not None: + self.view.window().makeFirstResponder_(self.view) + + def start_editing(self, initial_text): + self.set_text(initial_text) + self.focus() + # unselect the text and locate the cursor at the end of the entry + text_field = self.view.window().fieldEditor_forObject_(YES, self.view) + text_field.setSelectedRange_(NSMakeRange(len(self.get_text()), 0)) + + def viewport_created(self): + SizedControl.viewport_created(self) + self.notifications.connect(self.on_changed, 'NSControlTextDidChangeNotification') + self.notifications.connect(self.on_end_editing, + 'NSControlTextDidEndEditingNotification') + + def remove_viewport(self): + SizedControl.remove_viewport(self) + self.notifications.disconnect() + + def baseline(self): + return -self.view.font().descender() + 2 + + def on_changed(self, notification): + self.emit('changed') + + def on_end_editing(self, notification): + self.emit('focus-out') + + def calc_size_request(self): + size = self.sizer_cell.cellSize() + return size.width, size.height + + def set_text(self, text): + self.view.setStringValue_(text) + self.emit('changed') + + def get_text(self): + return self.view.stringValue() + + def set_width(self, chars): + self.sizer_cell.setStringValue_('X' * chars) + self.invalidate_size_request() + + def set_activates_default(self, setting): + pass + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + +class MiroTextField(NSTextField): + def textDidEndEditing_(self, notification): + wrappermap.wrapper(self).emit('activate') + return NSTextField.textDidEndEditing_(self, notification) + +class TextEntry(BaseTextEntry): + def make_view(self): + return MiroTextField.alloc().init() + +class NumberEntry(BaseTextEntry): + def make_view(self): + return MiroTextField.alloc().init() + + def set_max_length(self, length): + # TODO + pass + + def _filter_value(self): + """Discard any non-numeric characters""" + digits = ''.join(x for x in self.view.stringValue() if x.isdigit()) + self.view.setStringValue_(digits) + + def on_changed(self, notification): + # overriding on_changed rather than connecting to it ensures that we + # filter the value before anything else connected to the signal sees it + self._filter_value() + BaseTextEntry.on_changed(self, notification) + + def get_text(self): + # handles get_text between when text is entered and when on_changed + # filters it, in case that's possible + self._filter_value() + return BaseTextEntry.get_text(self) + +class MiroSecureTextField(NSSecureTextField): + def textDidEndEditing_(self, notification): + wrappermap.wrapper(self).emit('activate') + return NSSecureTextField.textDidEndEditing_(self, notification) + +class SecureTextEntry(BaseTextEntry): + def make_view(self): + return MiroSecureTextField.alloc().init() + +class MultilineTextEntry(Widget): + def __init__(self, initial_text=None): + Widget.__init__(self) + if initial_text is None: + initial_text = "" + self.view = NSTextView.alloc().initWithFrame_(NSRect((0,0),(50,50))) + self.view.setMaxSize_((1.0e7, 1.0e7)) + self.view.setHorizontallyResizable_(NO) + self.view.setVerticallyResizable_(YES) + self.notifications = NotificationForwarder.create(self.view) + self.create_signal('changed') + self.create_signal('focus-out') + if initial_text is not None: + self.set_text(initial_text) + self.set_size(widgetconst.SIZE_NORMAL) + + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + font = NSFont.systemFontOfSize_(NSFont.systemFontSize()) + elif size == widgetconst.SIZE_SMALL: + self.view.cell().setControlSize_(NSSmallControlSize) + else: + raise ValueError("Unknown size: %s" % size) + self.view.setFont_(font) + + def viewport_created(self): + Widget.viewport_created(self) + self.notifications.connect(self.on_changed, 'NSTextDidChangeNotification') + self.notifications.connect(self.on_end_editing, + 'NSControlTextDidEndEditingNotification') + self.invalidate_size_request() + + def remove_viewport(self): + Widget.remove_viewport(self) + self.notifications.disconnect() + + def focus(self): + if self.view.window() is not None: + self.view.window().makeFirstResponder_(self.view) + + def set_text(self, text): + self.view.setString_(text) + self.invalidate_size_request() + + def get_text(self): + return self.view.string() + + def on_changed(self, notification): + self.invalidate_size_request() + self.emit("changed") + + def on_end_editing(self, notification): + self.emit("focus-out") + + def calc_size_request(self): + layout_manager = self.view.layoutManager() + text_container = self.view.textContainer() + # The next line is there just to force cocoa to layout the text + layout_manager.glyphRangeForTextContainer_(text_container) + rect = layout_manager.usedRectForTextContainer_(text_container) + return rect.size.width, rect.size.height + + def set_editable(self, editable): + if editable: + self.view.setEditable_(YES) + else: + self.view.setEditable_(NO) + + +class MiroButton(NSButton): + + def initWithSignal_(self, signal): + self = super(MiroButton, self).init() + self.signal = signal + return self + + def sendAction_to_(self, action, to): + # We override the Cocoa machinery here and just send it to our wrapper + # widget. + wrappermap.wrapper(self).emit(self.signal) + return YES + +class Checkbox(SizedControl): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, text="", bold=False, color=None): + SizedControl.__init__(self) + self.create_signal('toggled') + self.view = MiroButton.alloc().initWithSignal_('toggled') + self.view.setButtonType_(NSSwitchButton) + self.bold = bold + self.title = text + self.font_size = NSFont.systemFontSize() + self.color = self.make_color(color) + self._set_title() + + def set_size(self, size): + SizedControl.set_size(self, size) + self._set_title() + + def _set_title(self): + if self.color is None: + self.view.setTitle_(self.title) + else: + attributes = { + NSForegroundColorAttributeName: self.color, + NSFontAttributeName: NSFont.systemFontOfSize_(self.font_size) + } + string = NSAttributedString.alloc().initWithString_attributes_( + self.title, attributes) + self.view.setAttributedTitle_(string) + + def calc_size_request(self): + if self.manual_size_request: + width, height = self.manual_size_request + if width == -1: + width = 10000 + if height == -1: + height = 10000 + size = self.view.cell().cellSizeForBounds_( + NSRect((0, 0), (width, height))) + else: + size = self.view.cell().cellSize() + return (size.width, size.height) + + def baseline(self): + return -self.view.font().descender() + 1 + + def get_checked(self): + return self.view.state() == NSOnState + + def set_checked(self, value): + if value: + self.view.setState_(NSOnState) + else: + self.view.setState_(NSOffState) + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + + def get_text_padding(self): + """ + Returns the amount of space the checkbox takes up before the label. + """ + # XXX FIXME + return 18 + +class Button(SizedControl): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, label, style='normal', width=0): + SizedControl.__init__(self) + self.color = None + self.title = label + self.create_signal('clicked') + self.view = MiroButton.alloc().initWithSignal_('clicked') + self.view.setButtonType_(NSMomentaryPushInButton) + self._set_title() + self.setup_style(style) + self.min_width = width + + def set_text(self, label): + self.title = label + self._set_title() + + def set_color(self, color): + self.color = self.make_color(color) + self._set_title() + + def _set_title(self): + if self.color is None: + self.view.setTitle_(self.title) + else: + attributes = { + NSForegroundColorAttributeName: self.color, + NSFontAttributeName: self.view.font() + } + string = NSAttributedString.alloc().initWithString_attributes_( + self.title, attributes) + self.view.setAttributedTitle_(string) + + def setup_style(self, style): + if style == 'normal': + self.view.setBezelStyle_(NSRoundedBezelStyle) + self.pad_height = 0 + self.pad_width = 10 + self.min_width = 112 + elif style == 'smooth': + self.view.setBezelStyle_(NSRoundRectBezelStyle) + self.pad_width = 0 + self.pad_height = 4 + self.paragraph_style = NSMutableParagraphStyle.alloc().init() + self.paragraph_style.setAlignment_(NSCenterTextAlignment) + + def make_default(self): + self.view.setKeyEquivalent_("\r") + + def calc_size_request(self): + size = self.view.cell().cellSize() + width = max(self.min_width, size.width + self.pad_width) + height = size.height + self.pad_height + return width, height + + def baseline(self): + return -self.view.font().descender() + 10 + self.pad_height + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + +class MiroPopupButton(NSPopUpButton): + + def init(self): + self = super(MiroPopupButton, self).init() + self.setTarget_(self) + self.setAction_('handleChange:') + return self + + def handleChange_(self, sender): + wrappermap.wrapper(self).emit('changed', self.indexOfSelectedItem()) + +class OptionMenu(SizedControl): + def __init__(self, options): + SizedControl.__init__(self) + self.create_signal('changed') + self.view = MiroPopupButton.alloc().init() + self.options = options + for option, value in options: + self.view.addItemWithTitle_(option) + + def baseline(self): + if self.view.cell().controlSize() == NSRegularControlSize: + return -self.view.font().descender() + 6 + else: + return -self.view.font().descender() + 5 + + def calc_size_request(self): + return self.view.cell().cellSize() + + def set_selected(self, index): + self.view.selectItemAtIndex_(index) + + def get_selected(self): + return self.view.indexOfSelectedItem() + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + + def set_width(self, width): + # TODO + pass + +class RadioButtonGroup: + def __init__(self): + self._buttons = [] + + def handle_click(self, widget): + self.set_selected(widget) + + def add_button(self, button): + self._buttons.append(button) + button.connect('clicked', self.handle_click) + if len(self._buttons) == 1: + button.view.setState_(NSOnState) + else: + button.view.setState_(NSOffState) + + def get_buttons(self): + return self._buttons + + def get_selected(self): + for mem in self._buttons: + if mem.get_selected(): + return mem + + def set_selected(self, button): + for mem in self._buttons: + if button is mem: + mem.view.setState_(NSOnState) + else: + mem.view.setState_(NSOffState) + +class RadioButton(SizedControl): + def __init__(self, label, group=None, bold=False, color=None): + SizedControl.__init__(self) + self.create_signal('clicked') + self.view = MiroButton.alloc().initWithSignal_('clicked') + self.view.setButtonType_(NSRadioButton) + self.color = self.make_color(color) + self.title = label + self.bold = bold + self.font_size = NSFont.systemFontSize() + self._set_title() + + if group is not None: + self.group = group + else: + self.group = RadioButtonGroup() + + self.group.add_button(self) + + def set_size(self, size): + SizedControl.set_size(self, size) + self._set_title() + + def _set_title(self): + if self.color is None: + self.view.setTitle_(self.title) + else: + attributes = { + NSForegroundColorAttributeName: self.color, + NSFontAttributeName: NSFont.systemFontOfSize_(self.font_size) + } + string = NSAttributedString.alloc().initWithString_attributes_( + self.title, attributes) + self.view.setAttributedTitle_(string) + + def calc_size_request(self): + size = self.view.cell().cellSize() + return (size.width, size.height) + + def baseline(self): + -self.view.font().descender() + 2 + + def get_group(self): + return self.group + + def get_selected(self): + return self.view.state() == NSOnState + + def set_selected(self): + self.group.set_selected(self) + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) diff --git a/lvc/widgets/osx/customcontrol.py b/lvc/widgets/osx/customcontrol.py new file mode 100644 index 0000000..4a32b8e --- /dev/null +++ b/lvc/widgets/osx/customcontrol.py @@ -0,0 +1,436 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".customcontrol -- CustomControl handlers. """ + +import collections + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from lvc.widgets import widgetconst +import wrappermap +from .base import Widget +import drawing +from .layoutmanager import LayoutManager + +class DrawableButtonCell(NSButtonCell): + def startTrackingAt_inView_(self, point, view): + view.setState_(NSOnState) + return YES + + def continueTracking_at_inView_(self, lastPoint, at, view): + view.setState_(NSOnState) + return YES + + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, view, mouseIsUp): + if not mouseIsUp: + view.mouse_inside = False + view.setState_(NSOffState) + +class DrawableButton(NSButton): + def init(self): + self = super(DrawableButton, self).init() + self.layout_manager = LayoutManager() + self.tracking_area = None + self.mouse_inside = False + self.custom_cursor = None + return self + + def resetCursorRects(self): + if self.custom_cursor is not None: + self.addCursorRect_cursor_(self.visibleRect(), self.custom_cursor) + self.custom_cursor.setOnMouseEntered_(YES) + + def updateTrackingAreas(self): + # remove existing tracking area if needed + if self.tracking_area: + self.removeTrackingArea_(self.tracking_area) + + # create a new tracking area for the entire view. This allows us to + # get mouseMoved events whenever the mouse is inside our view. + self.tracking_area = NSTrackingArea.alloc() + self.tracking_area.initWithRect_options_owner_userInfo_( + self.visibleRect(), + NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | + NSTrackingActiveInKeyWindow, + self, + nil) + self.addTrackingArea_(self.tracking_area) + + def mouseEntered_(self, event): + window = self.window() + if window is not nil and window.isMainWindow(): + self.mouse_inside = True + self.setNeedsDisplay_(YES) + + def mouseExited_(self, event): + window = self.window() + if window is not nil and window.isMainWindow(): + self.mouse_inside = False + self.setNeedsDisplay_(YES) + + def isOpaque(self): + return wrappermap.wrapper(self).is_opaque() + + def drawRect_(self, rect): + context = drawing.DrawingContext(self, self.bounds(), rect) + context.style = drawing.DrawingStyle() + wrapper = wrappermap.wrapper(self) + wrapper.state = 'normal' + disabled = wrapper.get_disabled() + if not disabled: + if self.state() == NSOnState: + wrapper.state = 'pressed' + elif self.mouse_inside: + wrapper.state = 'hover' + else: + wrapper.state = 'normal' + + wrapper.draw(context, self.layout_manager) + self.layout_manager.reset() + + def sendAction_to_(self, action, to): + # We override the Cocoa machinery here and just send it to our wrapper + # widget. + wrapper = wrappermap.wrapper(self) + disabled = wrapper.get_disabled() + if not disabled: + wrapper.emit('clicked') + # Tell Cocoa we handled it anyway, just not emit the actual clicked + # event. + return YES +DrawableButton.setCellClass_(DrawableButtonCell) + +class ContinousButtonCell(DrawableButtonCell): + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, view, mouseIsUp): + view.onStopTracking(at) + NSButtonCell.stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, + view, mouseIsUp) + +class ContinuousDrawableButton(DrawableButton): + def init(self): + self = super(ContinuousDrawableButton, self).init() + self.setContinuous_(YES) + return self + + def mouseDown_(self, event): + self.releaseInbounds = self.stopTracking = self.firedOnce = False + self.cell().trackMouse_inRect_ofView_untilMouseUp_(event, + self.bounds(), self, YES) + wrapper = wrappermap.wrapper(self) + if not wrapper.get_disabled(): + if self.firedOnce: + wrapper.emit('released') + elif self.releaseInbounds: + wrapper.emit('clicked') + + def sendAction_to_(self, action, to): + if self.stopTracking: + return NO + self.firedOnce = True + wrapper = wrappermap.wrapper(self) + if not wrapper.get_disabled(): + wrapper.emit('held-down') + return YES + + def onStopTracking(self, mouseLocation): + self.releaseInbounds = NSPointInRect(mouseLocation, self.bounds()) + self.stopTracking = True +ContinuousDrawableButton.setCellClass_(ContinousButtonCell) + +class DragableButtonCell(NSButtonCell): + def startTrackingAt_inView_(self, point, view): + self.start_x = point.x + return YES + + def continueTracking_at_inView_(self, lastPoint, at, view): + DRAG_THRESHOLD = 15 + wrapper = wrappermap.wrapper(view) + if not wrapper.get_disabled(): + if (view.last_drag_event != 'right' and + at.x > self.start_x + DRAG_THRESHOLD): + wrapper.emit("dragged-right") + view.last_drag_event = 'right' + elif (view.last_drag_event != 'left' and + at.x < self.start_x - DRAG_THRESHOLD): + view.last_drag_event = 'left' + wrapper.emit("dragged-left") + return YES + +class DragableDrawableButton(DrawableButton): + def mouseDown_(self, event): + self.last_drag_event = None + self.cell().trackMouse_inRect_ofView_untilMouseUp_(event, + self.bounds(), self, YES) + + def sendAction_to_(self, action, to): + # only send the click event if we didn't send a + # dragged-left/dragged-right event + wrapper = wrappermap.wrapper(self) + if self.last_drag_event is None and not wrapper.get_disabled(): + wrapper.emit('clicked') + return YES +DragableDrawableButton.setCellClass_(DragableButtonCell) + +MouseTrackingInfo = collections.namedtuple("MouseTrackingInfo", + "start_pos click_pos") + +class CustomSliderCell(NSSliderCell): + def calc_slider_amount(self, view, pos, size): + slider_size = wrappermap.wrapper(view).slider_size() + pos -= slider_size / 2 + size -= slider_size + return max(0, min(1, float(pos) / size)) + + def get_slider_pos(self, view, value=None): + if value is None: + value = view.floatValue() + if view.isVertical(): + size = view.bounds().size.height + else: + size = view.bounds().size.width + slider_size = view.knobThickness() + size -= slider_size + start_pos = slider_size / 2.0 + ratio = ((value - view.minValue()) / + view.maxValue() - view.minValue()) + return start_pos + (ratio * size) + + def startTrackingAt_inView_(self, at, view): + wrapper = wrappermap.wrapper(view) + start_pos = self.get_slider_pos(view) + if self.isVertical(): + click_pos = at.y + else: + click_pos = at.x + # only move the cursor if the click was outside the slider + if abs(click_pos - start_pos) > view.knobThickness() / 2: + self.moveSliderTo(view, click_pos) + start_pos = click_pos + view.mouse_tracking_info = MouseTrackingInfo(start_pos, click_pos) + if not wrapper.get_disabled(): + wrapper.emit('pressed') + return YES + + def moveSliderTo(self, view, pos): + if view.isVertical(): + size = view.bounds().size.height + else: + size = view.bounds().size.width + + slider_amount = self.calc_slider_amount(view, pos, size) + value = (self.maxValue() - self.minValue()) * slider_amount + self.setFloatValue_(value) + wrapper = wrappermap.wrapper(view) + if not wrapper.get_disabled(): + wrapper.emit('moved', value) + if self.isContinuous(): + wrapper.emit('changed', value) + + def continueTracking_at_inView_(self, lastPoint, at, view): + if view.isVertical(): + mouse_pos = at.y + else: + mouse_pos = at.x + + info = view.mouse_tracking_info + new_pos = info.start_pos + (mouse_pos - info.click_pos) + self.moveSliderTo(view, new_pos) + return YES + + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, view, mouseUp): + wrapper = wrappermap.wrapper(view) + if not wrapper.get_disabled(): + wrapper.emit('released') + view.mouse_tracking_info = None + +class CustomSliderView(NSSlider): + def init(self): + self = super(CustomSliderView, self).init() + self.layout_manager = LayoutManager() + self.custom_cursor = None + self.mouse_tracking_info = None + return self + + def get_slider_pos(self, value=None): + return self.cell().get_slider_pos(self, value) + + def resetCursorRects(self): + if self.custom_cursor is not None: + self.addCursorRect_cursor_(self.visibleRect(), self.custom_cursor) + self.custom_cursor.setOnMouseEntered_(YES) + + def isOpaque(self): + return wrappermap.wrapper(self).is_opaque() + + def knobThickness(self): + return wrappermap.wrapper(self).slider_size() + + def scrollWheel_(self, event): + wrapper = wrappermap.wrapper(self) + if wrapper.get_disabled(): + return + # NOTE: we ignore the scroll_step value passed into set_increments() + # and calculate the change using deltaY, which is in device + # coordinates. + slider_size = wrapper.slider_size() + if wrapper.is_horizontal(): + size = self.bounds().size.width + else: + size = self.bounds().size.height + size -= slider_size + + range = self.maxValue() - self.minValue() + value_change = (event.deltaY() / size) * range + self.setFloatValue_(self.floatValue() + value_change) + wrapper.emit('pressed') + wrapper.emit('changed', self.floatValue()) + wrapper.emit('released') + + def isVertical(self): + return not wrappermap.wrapper(self).is_horizontal() + + def drawRect_(self, rect): + context = drawing.DrawingContext(self, self.bounds(), rect) + context.style = drawing.DrawingStyle() + wrappermap.wrapper(self).draw(context, self.layout_manager) + self.layout_manager.reset() + + def sendAction_to_(self, action, to): + # We override the Cocoa machinery here and just send it to our wrapper + # widget. + wrapper = wrappermap.wrapper(self) + disabled = wrapper.get_disabled() + if not disabled: + wrapper.emit('changed', self.floatValue()) + # Total Cocoa we handled it anyway to prevent the event passed to + # upper layer. + return YES +CustomSliderView.setCellClass_(CustomSliderCell) + +class CustomControlBase(drawing.DrawingMixin, Widget): + def set_cursor(self, cursor): + if cursor == widgetconst.CURSOR_NORMAL: + self.view.custom_cursor = None + elif cursor == widgetconst.CURSOR_POINTING_HAND: + self.view.custom_cursor = NSCursor.pointingHandCursor() + else: + raise ValueError("Unknown cursor: %s" % cursor) + if self.view.window(): + self.view.window().invalidateCursorRectsForView_(self.view) + +class CustomButton(CustomControlBase): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomControlBase.__init__(self) + self.create_signal('clicked') + self.view = DrawableButton.alloc().init() + self.view.setRefusesFirstResponder_(NO) + self.view.setEnabled_(True) + + def enable(self): + Widget.enable(self) + self.view.setNeedsDisplay_(YES) + + def disable(self): + Widget.disable(self) + self.view.setNeedsDisplay_(YES) + +class ContinuousCustomButton(CustomButton): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomButton.__init__(self) + self.create_signal('held-down') + self.create_signal('released') + self.view = ContinuousDrawableButton.alloc().init() + self.view.setRefusesFirstResponder_(NO) + + def set_delays(self, initial, repeat): + self.view.cell().setPeriodicDelay_interval_(initial, repeat) + +class DragableCustomButton(CustomButton): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomButton.__init__(self) + self.create_signal('dragged-left') + self.create_signal('dragged-right') + self.view = DragableDrawableButton.alloc().init() + +class CustomSlider(CustomControlBase): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomControlBase.__init__(self) + self.create_signal('pressed') + self.create_signal('released') + self.create_signal('changed') + self.create_signal('moved') + self.view = CustomSliderView.alloc().init() + self.view.setRefusesFirstResponder_(NO) + if self.is_continuous(): + self.view.setContinuous_(YES) + else: + self.view.setContinuous_(NO) + self.view.setEnabled_(True) + + def get_slider_pos(self, value=None): + return self.view.get_slider_pos(value) + + def viewport_created(self): + self.view.cell().setKnobThickness_(self.slider_size()) + + def get_value(self): + return self.view.floatValue() + + def set_value(self, value): + self.view.setFloatValue_(value) + + def get_range(self): + return self.view.minValue(), self.view.maxValue() + + def set_range(self, min_value, max_value): + self.view.setMinValue_(min_value) + self.view.setMaxValue_(max_value) + + def set_increments(self, small_step, big_step, scroll_step=None): + # NOTE: we ignore all of these parameters. + # + # Cocoa doesn't have a concept of changing the increments for + # NSScroller. scroll_step is isn't really compatible with + # the event object that's passed to scrollWheel_() + pass + + def enable(self): + Widget.enable(self) + self.view.setNeedsDisplay_(YES) + + def disable(self): + Widget.disable(self) + self.view.setNeedsDisplay_(YES) diff --git a/lvc/widgets/osx/drawing.py b/lvc/widgets/osx/drawing.py new file mode 100644 index 0000000..aaad1e9 --- /dev/null +++ b/lvc/widgets/osx/drawing.py @@ -0,0 +1,289 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""miro.plat.frontend.widgets.drawing -- Draw on Views.""" + +import math + +from Foundation import * +from AppKit import * +#from Quartz import * +from objc import YES, NO, nil + + +class ImageSurface: + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, image): + """Create a new ImageSurface.""" + self.image = image.nsimage.copy() + self.width = image.width + self.height = image.height + + def get_size(self): + return self.width, self.height + + def draw(self, context, x, y, width, height, fraction=1.0): + if self.width == 0 or self.height == 0: + return + current_context = NSGraphicsContext.currentContext() + current_context.setShouldAntialias_(YES) + current_context.setImageInterpolation_(NSImageInterpolationHigh) + current_context.saveGraphicsState() + flip_context(y + height) + dest_rect = NSMakeRect(x, 0, width, height) + if self.width >= width and self.height >= height: + # drawing to area smaller than our image + source_rect = NSMakeRect(0, 0, width, height) + self.image.drawInRect_fromRect_operation_fraction_( + dest_rect, source_rect, NSCompositeSourceOver, fraction) + else: + # drawing to area larger than our image. Need to tile it. + NSColor.colorWithPatternImage_(self.image).set() + current_context.setPatternPhase_( + self._calc_pattern_phase(context, x, y)) + NSBezierPath.fillRect_(dest_rect) + current_context.restoreGraphicsState() + + def draw_rect(self, context, dest_x, dest_y, source_x, source_y, width, + height, fraction=1.0): + if width == 0 or height == 0: + return + current_context = NSGraphicsContext.currentContext() + current_context.setShouldAntialias_(YES) + current_context.setImageInterpolation_(NSImageInterpolationHigh) + current_context.saveGraphicsState() + flip_context(dest_y + height) + dest_y = 0 + dest_rect = NSMakeRect(dest_x, dest_y, width, height) + source_rect = NSMakeRect(source_x, self.height-source_y-height, + width, height) + self.image.drawInRect_fromRect_operation_fraction_( + dest_rect, source_rect, NSCompositeSourceOver, fraction) + current_context.restoreGraphicsState() + + def _calc_pattern_phase(self, context, x, y): + """Calculate the pattern phase to draw tiled images. + + When we draw with a pattern, we want the image in the pattern to start + at the top-left of where we're drawing to. This function does the + dirty work necessary. + + :returns: NSPoint to send to setPatternPhase_ + """ + # convert to view coords + view_point = NSPoint(context.origin.x + x, context.origin.y + y) + # convert to window coords, which is setPatternPhase_ uses + return context.view.convertPoint_toView_(view_point, nil) + +def convert_cocoa_color(color): + rgb = color.colorUsingColorSpaceName_(NSDeviceRGBColorSpace) + return (rgb.redComponent(), rgb.greenComponent(), rgb.blueComponent()) + +def convert_widget_color(color, alpha=1.0): + return NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], + color[2], alpha) +def flip_context(height): + """Make the current context's coordinates flipped. + + This is useful for drawing images, since they use the normal cocoa + coordinates and we use flipped versions. + + :param height: height of the current area we are drawing to. + """ + xform = NSAffineTransform.transform() + xform.translateXBy_yBy_(0, height) + xform.scaleXBy_yBy_(1.0, -1.0) + xform.concat() + +class DrawingStyle(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, bg_color=None, text_color=None): + self.use_custom_style = True + if text_color is None: + self.text_color = self.default_text_color + else: + self.text_color = convert_cocoa_color(text_color) + if bg_color is None: + self.bg_color = self.default_bg_color + else: + self.bg_color = convert_cocoa_color(bg_color) + + default_text_color = convert_cocoa_color(NSColor.textColor()) + default_bg_color = convert_cocoa_color(NSColor.textBackgroundColor()) + +class DrawingContext: + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, view, drawing_area, rect): + self.view = view + self.path = NSBezierPath.bezierPath() + self.color = NSColor.blackColor() + self.width = drawing_area.size.width + self.height = drawing_area.size.height + self.origin = drawing_area.origin + if drawing_area.origin != NSZeroPoint: + xform = NSAffineTransform.transform() + xform.translateXBy_yBy_(drawing_area.origin.x, + drawing_area.origin.y) + xform.concat() + + def move_to(self, x, y): + self.path.moveToPoint_(NSPoint(x, y)) + + def rel_move_to(self, dx, dy): + self.path.relativeMoveToPoint_(NSPoint(dx, dy)) + + def line_to(self, x, y): + self.path.lineToPoint_(NSPoint(x, y)) + + def rel_line_to(self, dx, dy): + self.path.relativeLineToPoint_(NSPoint(dx, dy)) + + def curve_to(self, x1, y1, x2, y2, x3, y3): + self.path.curveToPoint_controlPoint1_controlPoint2_( + NSPoint(x3, y3), NSPoint(x1, y1), NSPoint(x2, y2)) + + def rel_curve_to(self, dx1, dy1, dx2, dy2, dx3, dy3): + self.path.relativeCurveToPoint_controlPoint1_controlPoint2_( + NSPoint(dx3, dy3), NSPoint(dx1, dy1), NSPoint(dx2, dy2)) + + def arc(self, x, y, radius, angle1, angle2): + angle1 = (angle1 * 360) / (2 * math.pi) + angle2 = (angle2 * 360) / (2 * math.pi) + center = NSPoint(x, y) + self.path.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(center, radius, angle1, angle2) + + def arc_negative(self, x, y, radius, angle1, angle2): + angle1 = (angle1 * 360) / (2 * math.pi) + angle2 = (angle2 * 360) / (2 * math.pi) + center = NSPoint(x, y) + self.path.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_clockwise_(center, radius, angle1, angle2, YES) + + def rectangle(self, x, y, width, height): + rect = NSMakeRect(x, y, width, height) + self.path.appendBezierPathWithRect_(rect) + + def set_color(self, color, alpha=1.0): + self.color = convert_widget_color(color, alpha) + self.color.set() + + def set_shadow(self, color, opacity, offset, blur_radius): + shadow = NSShadow.alloc().init() + # shadow offset is always in the cocoa coordinates, so we need to + # reverse the y part + shadow.setShadowOffset_(NSPoint(offset[0], -offset[1])) + shadow.setShadowBlurRadius_(blur_radius) + shadow.setShadowColor_(convert_widget_color(color, opacity)) + shadow.set() + + def set_line_width(self, width): + self.path.setLineWidth_(width) + + def stroke(self): + self.path.stroke() + self.path.removeAllPoints() + + def stroke_preserve(self): + self.path.stroke() + + def fill(self): + self.path.fill() + self.path.removeAllPoints() + + def fill_preserve(self): + self.path.fill() + + def clip(self): + self.path.addClip() + self.path.removeAllPoints() + + def save(self): + NSGraphicsContext.currentContext().saveGraphicsState() + + def restore(self): + NSGraphicsContext.currentContext().restoreGraphicsState() + + def gradient_fill(self, gradient): + self.gradient_fill_preserve(gradient) + self.path.removeAllPoints() + + def gradient_fill_preserve(self, gradient): + context = NSGraphicsContext.currentContext() + context.saveGraphicsState() + self.path.addClip() + gradient.draw() + context.restoreGraphicsState() + +class Gradient(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, x1, y1, x2, y2): + self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2 + self.start_color = None + self.end_color = None + + def set_start_color(self, (red, green, blue)): + self.start_color = (red, green, blue) + + def set_end_color(self, (red, green, blue)): + self.end_color = (red, green, blue) + + def draw(self): + start_color = convert_widget_color(self.start_color) + end_color = convert_widget_color(self.end_color) + nsgradient = NSGradient.alloc().initWithStartingColor_endingColor_(start_color, end_color) + start_point = NSPoint(self.x1, self.y1) + end_point = NSPoint(self.x2, self.y2) + nsgradient.drawFromPoint_toPoint_options_(start_point, end_point, 0) + +class DrawingMixin(object): + def calc_size_request(self): + return self.size_request(self.view.layout_manager) + + # squish width / squish height only make sense on GTK + def set_squish_width(self, setting): + pass + + def set_squish_height(self, setting): + pass + + # Default implementations for methods that subclasses override. + + def is_opaque(self): + return False + + def size_request(self, layout_manager): + return 0, 0 + + def draw(self, context, layout_manager): + pass + + def viewport_repositioned(self): + # since this is a Mixin class, we want to make sure that our other + # classes see the viewport_repositioned() call. + super(DrawingMixin, self).viewport_repositioned() + self.queue_redraw() diff --git a/lvc/widgets/osx/drawingwidgets.py b/lvc/widgets/osx/drawingwidgets.py new file mode 100644 index 0000000..74e8232 --- /dev/null +++ b/lvc/widgets/osx/drawingwidgets.py @@ -0,0 +1,67 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""drawingviews.py -- views that support custom drawing.""" + +import wrappermap +import drawing +from .base import Widget, SimpleBin, FlippedView +from .layoutmanager import LayoutManager + +class DrawingView(FlippedView): + def init(self): + self = super(DrawingView, self).init() + self.layout_manager = LayoutManager() + return self + + def isOpaque(self): + return wrappermap.wrapper(self).is_opaque() + + def drawRect_(self, rect): + context = drawing.DrawingContext(self, self.bounds(), rect) + context.style = drawing.DrawingStyle() + wrappermap.wrapper(self).draw(context, self.layout_manager) + +class DrawingArea(drawing.DrawingMixin, Widget): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + Widget.__init__(self) + self.view = DrawingView.alloc().init() + +class Background(drawing.DrawingMixin, SimpleBin): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + SimpleBin.__init__(self) + self.view = DrawingView.alloc().init() + + def calc_size_request(self): + drawing_size = drawing.DrawingMixin.calc_size_request(self) + container_size = SimpleBin.calc_size_request(self) + return (max(container_size[0], drawing_size[0]), + max(container_size[1], drawing_size[1])) diff --git a/lvc/widgets/osx/fasttypes.c b/lvc/widgets/osx/fasttypes.c new file mode 100644 index 0000000..72d3b5b --- /dev/null +++ b/lvc/widgets/osx/fasttypes.c @@ -0,0 +1,540 @@ +/* +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + */ + +#include <Python.h> + +/* + * fasttypes.c + * + * Datastructures written in C to be fast. This used to be a big C++ file + * that depended on boost. Nowadays we only define LinkedList, which is easy + * enough to implement in pure C. + */ + +static int nodes_deleted = 0; // debugging only + +/* forward define python type objects */ + +static PyTypeObject LinkedListType; +static PyTypeObject LinkedListIterType; + +/* Structure definitions */ + +typedef struct LinkedListNode { + PyObject *obj; + struct LinkedListNode* next; + struct LinkedListNode* prev; + int deleted; // Has this node been removed? + int iter_count; // How many LinkedListIters point to this node? +} LinkedListNode; + +typedef struct { + PyObject_HEAD + int count; + LinkedListNode* sentinal; + // sentinal object to make list operations simpler/faster and equivalent + // to the boost API. It's prev node is the last element in the list and + // it's next node is the first +} LinkedListObject; + +typedef struct { + PyObject_HEAD + LinkedListNode* node; + LinkedListObject* list; +} LinkedListIterObject; + +/* LinkedListNode */ + +void check_node_deleted(LinkedListNode* node) +{ + if(node->iter_count <= 0 && node->deleted) { + free(node); + nodes_deleted += 1; + } +} + +static int remove_node(LinkedListObject* self, LinkedListNode* node) +{ + if(node->obj == NULL) { + PyErr_SetString(PyExc_IndexError, "can't remove lastIter()"); + return 0; + } + node->next->prev = node->prev; + node->prev->next = node->next; + node->deleted = 1; + self->count -= 1; + Py_DECREF(node->obj); + check_node_deleted(node); + return 1; +} + +/* LinkedListIter */ + +void switch_node(LinkedListIterObject* self, LinkedListNode* new_node) +{ + LinkedListNode* old_node; + + old_node = self->node; + self->node = new_node; + old_node->iter_count--; + self->node->iter_count++; + check_node_deleted(old_node); +} + +// Note that we don't expose the new method to python. We create +// LinkedListIters in the factory methods firstIter() and lastIter() +static LinkedListIterObject* LinkedListIterObject_new(LinkedListObject*list, + LinkedListNode* node) +{ + LinkedListIterObject* self; + + self = (LinkedListIterObject*)PyType_GenericAlloc(&LinkedListIterType, 0); + if(self != NULL) { + self->node = node; + self->list = list; + node->iter_count++; + } + return self; +} + +static void LinkedListIterObject_dealloc(LinkedListIterObject* self) +{ + self->node->iter_count--; + check_node_deleted(self->node); +} + +static PyObject *LinkedListIter_forward(LinkedListIterObject* self, PyObject *obj) +{ + switch_node(self, self->node->next); + Py_RETURN_NONE; +} + +static PyObject *LinkedListIter_back(LinkedListIterObject* self, PyObject *obj) +{ + switch_node(self, self->node->prev); + Py_RETURN_NONE; +} + +static PyObject *LinkedListIter_value(LinkedListIterObject* self, PyObject *obj) +{ + PyObject* retval; + + if(self->node->deleted) { + PyErr_SetString(PyExc_ValueError, "Node deleted"); + return NULL; + } + retval = self->node->obj; + if(retval == NULL) { + PyErr_SetString(PyExc_IndexError, "can't get value of lastIter()"); + return NULL; + } + Py_INCREF(retval); + return retval; +} + +static PyObject *LinkedListIter_copy(LinkedListIterObject* self, PyObject *obj) +{ + return (PyObject*)LinkedListIterObject_new(self->list, self->node); +} + +static PyObject *LinkedListIter_valid(LinkedListIterObject* self, PyObject *obj) +{ + return PyBool_FromLong(self->node->deleted == 0); +} + +PyObject* LinkedListIter_richcmp(LinkedListIterObject *o1, + LinkedListIterObject *o2, int opid) +{ + if(!PyObject_TypeCheck(o1, &LinkedListIterType) || + !PyObject_TypeCheck(o2, &LinkedListIterType)) { + return Py_NotImplemented; + } + switch(opid) { + case Py_EQ: + if(o1->node == o2->node) Py_RETURN_TRUE; + else Py_RETURN_FALSE; + case Py_NE: + if(o1->node != o2->node) Py_RETURN_TRUE; + else Py_RETURN_FALSE; + default: + return Py_NotImplemented; + } +} + +static PyMethodDef LinkedListIter_methods[] = { + {"forward", (PyCFunction)LinkedListIter_forward, METH_NOARGS, + "Move to the next element", + }, + {"back", (PyCFunction)LinkedListIter_back, METH_NOARGS, + "Move to the previous element", + }, + {"value", (PyCFunction)LinkedListIter_value, METH_NOARGS, + "Return the current element", + }, + {"copy", (PyCFunction)LinkedListIter_copy, METH_NOARGS, + "Duplicate iter", + }, + {"valid", (PyCFunction)LinkedListIter_valid, METH_NOARGS, + "Test if the iter is valid", + }, + {NULL}, +}; + +static PyTypeObject LinkedListIterType = { + PyObject_HEAD_INIT(NULL) + 0, /* ob_size */ + "fasttypes.LinkedListIter", /* tp_name */ + sizeof(LinkedListIterObject), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)LinkedListIterObject_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT|Py_TPFLAGS_HAVE_RICHCOMPARE, /* tp_flags */ + "fasttypes LinkedListIter", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + (richcmpfunc)LinkedListIter_richcmp, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + LinkedListIter_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +/* LinkedList */ + +LinkedListNode* make_new_node(PyObject* obj, LinkedListNode* prev, + LinkedListNode* next) +{ + LinkedListNode* retval; + retval = malloc(sizeof(LinkedListNode)); + if(!retval) { + PyErr_SetString(PyExc_MemoryError, "can't create new node"); + return NULL; + } + Py_XINCREF(obj); + retval->obj = obj; + retval->prev = prev; + retval->next = next; + retval->iter_count = retval->deleted = 0; + return retval; +} + +void set_iter_type_error(PyObject* obj) +{ + // Set an exception when we expected a LinkedListIter and got something + // else + PyObject* args; + PyObject* fmt; + PyObject* err_str; + + args = Py_BuildValue("(O)", obj); + fmt = PyString_FromString("Expected LinkedListIter, got %r"); + err_str = PyString_Format(fmt, args); + PyErr_SetObject(PyExc_TypeError, err_str); + Py_DECREF(fmt); + Py_DECREF(err_str); + Py_DECREF(args); +} + +static PyObject* insert_before(LinkedListObject* self, LinkedListNode* node, + PyObject* obj) +{ + LinkedListNode* new_node; + PyObject* retval; + + new_node = make_new_node(obj, node->prev, node); + if(!new_node) return NULL; + node->prev->next = new_node; + node->prev = new_node; + self->count += 1; + retval = (PyObject*)LinkedListIterObject_new(self, new_node); + return retval; +} + +static PyObject* LinkedList_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + LinkedListObject *self; + LinkedListNode *sentinal; + + self = (LinkedListObject *)type->tp_alloc(type, 0); + if (self == NULL) return NULL; + + sentinal = make_new_node(NULL, NULL, NULL); + if(!sentinal) { + Py_DECREF(self); + return NULL; + } + self->sentinal = sentinal->next = sentinal->prev = sentinal; + sentinal->iter_count = 1; // prevent the sentinal from being deleted + self->count = 0; + + return (PyObject *)self; +} + +static void LinkedList_dealloc(LinkedListObject* self) +{ + LinkedListNode *node, *tmp; + + node = self->sentinal->next; + while(node != self->sentinal) { + node->deleted = 1; + tmp = node->next; + check_node_deleted(node); + node = tmp; + } + + self->sentinal->iter_count -= 1; + check_node_deleted(self->sentinal); + return; +} + +static int LinkedList_init(LinkedListObject *self) +{ + self->count = 0; + return 0; +} + +static Py_ssize_t LinkedList_len(LinkedListObject *self) +{ + return self->count; +} + +static PyObject* LinkedList_get(LinkedListObject *self, + LinkedListIterObject *iter) +{ + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error((PyObject*)iter); + return NULL; + } + return PyObject_CallMethod((PyObject*)iter, "value", "()"); +} +int LinkedList_set(LinkedListObject *self, LinkedListIterObject *iter, + PyObject *value) +{ + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error((PyObject*)iter); + return -1; + } + if(iter->node->deleted) { + PyErr_SetString(PyExc_ValueError, "Node deleted"); + return -1; + } + if(iter->node->obj == NULL) { + PyErr_SetString(PyExc_IndexError, "can't set value of lastIter()"); + return -1; + } + if(value == NULL) { + if(!remove_node(self, iter->node)) return -1; + return 0; + } + Py_INCREF(value); + Py_DECREF(iter->node->obj); + iter->node->obj = value; + return 0; +} + +static PyObject *LinkedList_insertBefore(LinkedListObject* self, PyObject *args) +{ + LinkedListIterObject *iter; + PyObject *obj; + + if(!PyArg_ParseTuple(args, "OO", &iter, &obj)) return NULL; + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error(obj); + return NULL; + } + + return insert_before(self, iter->node, obj); +} + +static PyObject *LinkedList_append(LinkedListObject* self, PyObject *obj) +{ + return insert_before(self, self->sentinal, obj); +} + +static PyObject *LinkedList_remove(LinkedListObject* self, + LinkedListIterObject *iter) +{ + LinkedListNode* next_node; + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error((PyObject*)iter); + return NULL; + } + + next_node = iter->node->next; + if(!remove_node(self, iter->node)) return NULL; + return (PyObject*)LinkedListIterObject_new(self, next_node); +} + +static PyObject *LinkedList_firstIter(LinkedListObject* self, PyObject *obj) +{ + PyObject* retval; + retval = (PyObject*)LinkedListIterObject_new(self, self->sentinal->next); + return retval; +} + +static PyObject *LinkedList_lastIter(LinkedListObject* self, PyObject *obj) +{ + PyObject* retval; + retval = (PyObject*)LinkedListIterObject_new(self, self->sentinal); + return retval; +} + +static PyMappingMethods LinkedListMappingMethods = { + (lenfunc)LinkedList_len, + (binaryfunc)LinkedList_get, + (objobjargproc)LinkedList_set, +}; + +static PyMethodDef LinkedList_methods[] = { + {"insertBefore", (PyCFunction)LinkedList_insertBefore, METH_VARARGS, + "insert an element before iter", + }, + {"append", (PyCFunction)LinkedList_append, METH_O, + "append an element to the list", + }, + {"remove", (PyCFunction)LinkedList_remove, METH_O, + "remove an element to the list", + }, + {"firstIter", (PyCFunction)LinkedList_firstIter, METH_NOARGS, + "get an iter pointing to the first element in the list", + }, + {"lastIter", (PyCFunction)LinkedList_lastIter, METH_NOARGS, + "get an iter pointing to the last element in the list", + }, + {NULL}, +}; + +static PyTypeObject LinkedListType = { + PyObject_HEAD_INIT(NULL) + 0, /* ob_size */ + "fasttypes.LinkedList", /* tp_name */ + sizeof(LinkedListObject), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)LinkedList_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + &LinkedListMappingMethods, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + "fasttypes LinkedList", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + LinkedList_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)LinkedList_init, /* tp_init */ + 0, /* tp_alloc */ + LinkedList_new, /* tp_new */ +}; + +/* Module-level stuff */ + +static PyObject *count_nodes_deleted(PyObject *obj) +{ + return PyInt_FromLong(nodes_deleted); +} + +static PyObject *reset_nodes_deleted(PyObject *obj) +{ + nodes_deleted = 0; + Py_RETURN_NONE; +} + + +static PyMethodDef FasttypesMethods[] = +{ + {"_count_nodes_deleted", (PyCFunction)count_nodes_deleted, METH_NOARGS, + "get a count of how many nodes have been deleted (DEBUGGING ONLY)", + }, + {"_reset_nodes_deleted", (PyCFunction)reset_nodes_deleted, METH_NOARGS, + "reset the count of how many nodes have been deleted (DEBUGGING ONLY)", + }, + { NULL, NULL, 0, NULL } +}; + +PyMODINIT_FUNC initfasttypes(void) +{ + PyObject *m; + + if (PyType_Ready(&LinkedListType) < 0) + return; + + if (PyType_Ready(&LinkedListIterType) < 0) + return; + + m = Py_InitModule("fasttypes", FasttypesMethods); + + Py_INCREF(&LinkedListType); + Py_INCREF(&LinkedListIterType); + PyModule_AddObject(m, "LinkedList", (PyObject *)&LinkedListType); +} diff --git a/lvc/widgets/osx/helpers.py b/lvc/widgets/osx/helpers.py new file mode 100644 index 0000000..e4aa23a --- /dev/null +++ b/lvc/widgets/osx/helpers.py @@ -0,0 +1,95 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""helper classes.""" + +import logging +import traceback + +from Foundation import * +from objc import nil + +class NotificationForwarder(NSObject): + """Forward notifications from a Cocoa object to a python class. + """ + + def initWithNSObject_center_(self, nsobject, center): + """Initialize the NotificationForwarder nsobject is the NSObject to + forward notifications for. It can be nil in which case notifications + from all objects will be forwarded. + + center is the NSNotificationCenter to get notifications from. It can + be None, in which cas the default notification center is used. + """ + self.nsobject = nsobject + self.callback_map = {} + if center is None: + self.center = NSNotificationCenter.defaultCenter() + else: + self.center = center + return self + + @classmethod + def create(cls, object, center=None): + """Helper method to call aloc() then initWithNSObject_center_().""" + return cls.alloc().initWithNSObject_center_(object, center) + + def connect(self, callback, name): + """Register to listen for notifications. + Only one callback for each notification name can be connected. + """ + + if name in self.callback_map: + raise ValueError("%s already connected" % name) + + self.callback_map[name] = callback + self.center.addObserver_selector_name_object_(self, 'observe:', name, + self.nsobject) + + def disconnect(self, name=None): + if name is not None: + self.center.removeObserver_name_object_(self, name, self.nsobject) + self.callback_map.pop(name) + else: + self.center.removeObserver_(self) + self.callback_map.clear() + + def observe_(self, notification): + name = notification.name() + callback = self.callback_map[name] + if callback is None: + logging.warn("Callback for %s is dead", name) + self.center.removeObverser_name_object_(self, name, self.nsobject) + return + try: + callback(notification) + except: + logging.warn("Callback for %s raised exception:%s\n", + name.encode('utf-8'), + traceback.format_exc()) diff --git a/lvc/widgets/osx/layout.py b/lvc/widgets/osx/layout.py new file mode 100644 index 0000000..f18a47f --- /dev/null +++ b/lvc/widgets/osx/layout.py @@ -0,0 +1,748 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".layout -- Widgets that handle laying out other +widgets. + +We basically follow GTK's packing model. Widgets are packed into vboxes, +hboxes or other container widgets. The child widgets request a minimum size, +and the container widgets allocate space for their children. Widgets may get +more size then they requested in which case they have to deal with it. In +rare cases, widgets may get less size then they requested in which case they +should just make sure they don't throw an exception or segfault. + +Check out the GTK tutorial for more info. +""" + +import itertools + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil, signature, loadBundle + +import tableview +import wrappermap +from .base import Container, Bin, FlippedView +from lvc.utils import Matrix + +# These don't seem to be in pyobjc's AppKit (yet) +NSScrollerKnobStyleDefault = 0 +NSScrollerKnobStyleDark = 1 +NSScrollerKnobStyleLight = 2 + +NSScrollerStyleLegacy = 0 +NSScrollerStyleOverlay = 1 + +def _extra_space_iter(extra_length, count): + """Utility function to allocate extra space left over in containers.""" + if count == 0: + return + extra_space, leftover = divmod(extra_length, count) + while leftover >= 1: + yield extra_space + 1 + leftover -= 1 + yield extra_space + leftover + while True: + yield extra_space + +class BoxPacking: + """Utility class to store how we are packing a single widget.""" + + def __init__(self, widget, expand, padding): + self.widget = widget + self.expand = expand + self.padding = padding + +class Box(Container): + """Base class for HBox and VBox. """ + CREATES_VIEW = False + + def __init__(self, spacing=0): + self.spacing = spacing + Container.__init__(self) + self.packing_start = [] + self.packing_end = [] + self.expand_count = 0 + + def packing_both(self): + return itertools.chain(self.packing_start, self.packing_end) + + def get_children(self): + for packing in self.packing_both(): + yield packing.widget + children = property(get_children) + + # Internally Boxes use a (length, breadth) coordinate system. length and + # breadth will be either x or y depending on which way the box is + # oriented. The subclasses must provide methods to translate between the + # 2 coordinate systems. + + def translate_size(self, size): + """Translate a (width, height) tulple to (length, breadth).""" + raise NotImplementedError() + + def untranslate_size(self, size): + """Reverse the work of translate_size.""" + raise NotImplementedError() + + def make_child_rect(self, position, length): + """Create a rect to position a child with.""" + raise NotImplementedError() + + def pack_start(self, child, expand=False, padding=0): + self.packing_start.append(BoxPacking(child, expand, padding)) + if expand: + self.expand_count += 1 + self.child_added(child) + + def pack_end(self, child, expand=False, padding=0): + self.packing_end.append(BoxPacking(child, expand, padding)) + if expand: + self.expand_count += 1 + self.child_added(child) + + def _remove_from_packing(self, child): + for i in xrange(len(self.packing_start)): + if self.packing_start[i].widget is child: + return self.packing_start.pop(i) + for i in xrange(len(self.packing_end)): + if self.packing_end[i].widget is child: + return self.packing_end.pop(i) + raise LookupError("%s not found" % child) + + def remove(self, child): + packing = self._remove_from_packing(child) + if packing.expand: + self.expand_count -= 1 + self.child_removed(child) + + def translate_widget_size(self, widget): + return self.translate_size(widget.get_size_request()) + + def calc_size_request(self): + length = breadth = 0 + for packing in self.packing_both(): + child_length, child_breadth = \ + self.translate_widget_size(packing.widget) + length += child_length + if packing.padding: + length += packing.padding * 2 # Need to pad on both sides + breadth = max(breadth, child_breadth) + spaces = max(0, len(self.packing_start) + len(self.packing_end) - 1) + length += spaces * self.spacing + return self.untranslate_size((length, breadth)) + + def place_children(self): + request_length, request_breadth = self.translate_widget_size(self) + ps = self.viewport.placement.size + total_length, dummy = self.translate_size((ps.width, ps.height)) + total_extra_space = total_length - request_length + extra_space_iter = _extra_space_iter(total_extra_space, + self.expand_count) + start_end = self._place_packing_list(self.packing_start, + extra_space_iter, 0) + if self.expand_count == 0 and total_extra_space > 0: + # account for empty space after the end of pack_start list and + # before the pack_end list. + self.draw_empty_space(start_end, total_extra_space) + start_end += total_extra_space + self._place_packing_list(reversed(self.packing_end), extra_space_iter, + start_end) + + def draw_empty_space(self, start, length): + empty_rect = self.make_child_rect(start, length) + my_view = self.viewport.view + opaque_view = my_view.opaqueAncestor() + if opaque_view is not None: + empty_rect2 = opaque_view.convertRect_fromView_(empty_rect, my_view) + opaque_view.setNeedsDisplayInRect_(empty_rect2) + + def _place_packing_list(self, packing_list, extra_space_iter, position): + for packing in packing_list: + child_length, child_breadth = \ + self.translate_widget_size(packing.widget) + if packing.expand: + child_length += extra_space_iter.next() + if packing.padding: # space before + self.draw_empty_space(position, packing.padding) + position += packing.padding + child_rect = self.make_child_rect(position, child_length) + if packing.padding: # space after + self.draw_empty_space(position, packing.padding) + position += packing.padding + packing.widget.place(child_rect, self.viewport.view) + position += child_length + if self.spacing > 0: + self.draw_empty_space(position, self.spacing) + position += self.spacing + return position + + def enable(self): + Container.enable(self) + for mem in self.children: + mem.enable() + + def disable(self): + Container.disable(self) + for mem in self.children: + mem.disable() + +class VBox(Box): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def translate_size(self, size): + return (size[1], size[0]) + + def untranslate_size(self, size): + return (size[1], size[0]) + + def make_child_rect(self, position, length): + placement = self.viewport.placement + return NSMakeRect(placement.origin.x, placement.origin.y + position, + placement.size.width, length) + +class HBox(Box): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def translate_size(self, size): + return (size[0], size[1]) + + def untranslate_size(self, size): + return (size[0], size[1]) + + def make_child_rect(self, position, length): + placement = self.viewport.placement + return NSMakeRect(placement.origin.x + position, placement.origin.y, + length, placement.size.height) + +class Alignment(Bin): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + CREATES_VIEW = False + + def __init__(self, xalign=0.0, yalign=0.0, xscale=0.0, yscale=0.0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + Bin.__init__(self) + self.xalign = xalign + self.yalign = yalign + self.xscale = xscale + self.yscale = yscale + self.top_pad = top_pad + self.bottom_pad = bottom_pad + self.left_pad = left_pad + self.right_pad = right_pad + if self.child is not None: + self.place_children() + + def set(self, xalign=0.0, yalign=0.0, xscale=0.0, yscale=0.0): + self.xalign = xalign + self.yalign = yalign + self.xscale = xscale + self.yscale = yscale + if self.child is not None: + self.place_children() + + def set_padding(self, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + self.top_pad = top_pad + self.bottom_pad = bottom_pad + self.left_pad = left_pad + self.right_pad = right_pad + if self.child is not None and self.viewport is not None: + self.place_children() + + def vertical_pad(self): + return self.top_pad + self.bottom_pad + + def horizontal_pad(self): + return self.left_pad + self.right_pad + + def calc_size_request(self): + if self.child: + child_width, child_height = self.child.get_size_request() + return (child_width + self.horizontal_pad(), + child_height + self.vertical_pad()) + else: + return (0, 0) + + def calc_size(self, requested, total, scale): + extra_width = max(0, total - requested) + return requested + int(round(extra_width * scale)) + + def calc_position(self, size, total, align): + return int(round((total - size) * align)) + + def place_children(self): + if self.child is None: + return + + total_width = self.viewport.placement.size.width + total_height = self.viewport.placement.size.height + total_width -= self.horizontal_pad() + total_height -= self.vertical_pad() + request_width, request_height = self.child.get_size_request() + + child_width = self.calc_size(request_width, total_width, self.xscale) + child_height = self.calc_size(request_height, total_height, self.yscale) + child_x = self.calc_position(child_width, total_width, self.xalign) + child_y = self.calc_position(child_height, total_height, self.yalign) + child_x += self.left_pad + child_y += self.top_pad + + my_origin = self.viewport.area().origin + child_rect = NSMakeRect(my_origin.x + child_x, my_origin.y + child_y, child_width, child_height) + self.child.place(child_rect, self.viewport.view) + # Make sure the space not taken up by our child is redrawn. + self.viewport.queue_redraw() + +class DetachedWindowHolder(Alignment): + def __init__(self): + Alignment.__init__(self, bottom_pad=16, xscale=1.0, yscale=1.0) + +class _TablePacking(object): + """Utility class to help with packing Table widgets.""" + def __init__(self, widget, column, row, column_span, row_span): + self.widget = widget + self.column = column + self.row = row + self.column_span = column_span + self.row_span = row_span + + def column_indexes(self): + return range(self.column, self.column + self.column_span) + + def row_indexes(self): + return range(self.row, self.row + self.row_span) + +class Table(Container): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + CREATES_VIEW = False + + def __init__(self, columns, rows): + Container.__init__(self) + self._cells = Matrix(columns, rows) + self._children = [] # List of _TablePacking objects + self._children_sorted = True + self.rows = rows + self.columns = columns + self.row_spacing = self.column_spacing = 0 + + def _ensure_children_sorted(self): + if not self._children_sorted: + def cell_area(table_packing): + return table_packing.column_span * table_packing.row_span + self._children.sort(key=cell_area) + self._children_sorted = True + + def get_children(self): + return [cell.widget for cell in self._children] + children = property(get_children) + + def calc_size_request(self): + self._ensure_children_sorted() + self._calc_dimensions() + return self.total_width, self.total_height + + def _calc_dimensions(self): + self.column_widths = [0] * self.columns + self.row_heights = [0] * self.rows + + for tp in self._children: + child_width, child_height = tp.widget.get_size_request() + # recalc the width of the child's columns + self._recalc_dimension(child_width, self.column_widths, + tp.column_indexes()) + # recalc the height of the child's rows + self._recalc_dimension(child_height, self.row_heights, + tp.row_indexes()) + + self.total_width = (self.column_spacing * (self.columns - 1) + + sum(self.column_widths)) + self.total_height = (self.row_spacing * (self.rows - 1) + + sum(self.row_heights)) + + def _recalc_dimension(self, child_size, size_array, positions): + current_size = sum(size_array[p] for p in positions) + child_size_needed = child_size - current_size + if child_size_needed > 0: + iter = _extra_space_iter(child_size_needed, len(positions)) + for p in positions: + size_array[p] += iter.next() + + def place_children(self): + # This method depepnds on us calling _calc_dimensions() in + # calc_size_request(). Ensure that this happens. + if self.cached_size_request is None: + self.get_size_request() + column_positions = [0] + for width in self.column_widths[:-1]: + column_positions.append(width + column_positions[-1] + self.column_spacing) + row_positions = [0] + for height in self.row_heights[:-1]: + row_positions.append(height + row_positions[-1] + self.row_spacing) + + my_x= self.viewport.placement.origin.x + my_y = self.viewport.placement.origin.y + for tp in self._children: + x = my_x + column_positions[tp.column] + y = my_y + row_positions[tp.row] + width = sum(self.column_widths[i] for i in tp.column_indexes()) + height = sum(self.row_heights[i] for i in tp.row_indexes()) + rect = NSMakeRect(x, y, width, height) + tp.widget.place(rect, self.viewport.view) + + def pack(self, widget, column, row, column_span=1, row_span=1): + tp = _TablePacking(widget, column, row, column_span, row_span) + for c in tp.column_indexes(): + for r in tp.row_indexes(): + if self._cells[c, r]: + raise ValueError("Cell %d x %d is already taken" % (c, r)) + self._cells[column, row] = widget + self._children.append(tp) + self._children_sorted = False + self.child_added(widget) + + def remove(self, child): + for i in xrange(len(self._children)): + if self._children[i].widget is child: + self._children.remove(i) + break + else: + raise ValueError("%s is not a child of this Table" % child) + self._cells.remove(child) + self.child_removed(widget) + + def set_column_spacing(self, spacing): + self.column_spacing = spacing + self.invalidate_size_request() + + def set_row_spacing(self, spacing): + self.row_spacing = spacing + self.invalidate_size_request() + + def enable(self, row=None, column=None): + Container.enable(self) + if row != None and column != None: + if self._cells[column, row]: + self._cells[column, row].enable() + elif row != None: + for mem in self._cells.row(row): + if mem: mem.enable() + elif column != None: + for mem in self._cells.column(column): + if mem: mem.enable() + else: + for mem in self._cells: + if mem: mem.enable() + + def disable(self, row=None, column=None): + Container.disable(self) + if row != None and column != None: + if self._cells[column, row]: + self._cells[column, row].disable() + elif row != None: + for mem in self._cells.row(row): + if mem: mem.disable() + elif column != None: + for mem in self._cells.column(column): + if mem: mem.disable() + else: + for mem in self._cells: + if mem: mem.disable() + +class MiroScrollView(NSScrollView): + def tile(self): + NSScrollView.tile(self) + # tile is called when we need to layout our child view and scrollers. + # This probably means that we've either hidden or shown a scrollbar so + # call invalidate_size_request to ensure that things get re-layed out + # correctly. (#see 13842) + wrapper = wrappermap.wrapper(self) + if wrapper is not None: + wrapper.invalidate_size_request() + +class Scroller(Bin): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, horizontal, vertical): + Bin.__init__(self) + self.view = MiroScrollView.alloc().init() + self.view.setAutohidesScrollers_(YES) + self.view.setHasHorizontalScroller_(horizontal) + self.view.setHasVerticalScroller_(vertical) + self.document_view = FlippedView.alloc().init() + self.view.setDocumentView_(self.document_view) + + def prepare_for_dark_content(self): + try: + self.view.setScrollerKnobStyle_(NSScrollerKnobStyleLight) + except AttributeError: + # This only works on 10.7 and abvoe + pass + + def set_has_borders(self, has_border): + self.view.setBorderType_(NSBezelBorder) + + def viewport_repositioned(self): + # If the window is resized, this translates to a + # viewport_repositioned() event. Instead of calling + # place_children() one, which is what our suporclass does, we need + # some extra logic here. place the chilren to work out if we need a + # scrollbar, then get the new size, then replace the children (which + # now takes into account of scrollbar size.) + super(Scroller, self).viewport_repositioned() + self.cached_size_request = self.calc_size_request() + self.place_children() + + def set_background_color(self, color): + self.view.setBackgroundColor_(self.make_color(color)) + + def add(self, child): + child.parent_is_scroller = True + Bin.add(self, child) + + def remove(self): + child.parent_is_scroller = False + Bin.remove(self) + + def children_changed(self): + # since our size isn't dependent on our children, don't call + # invalidate_size_request() here. Just call place_children() so that + # they get positioned correctly in the document view. + # + # XXX dodgy - why are we laying out the children twice? When the + # children change, the scroller could appear/disappear. But you have + # no idea if that's going to happen without knowing how big your + # children are. So we lay it out, get the size, then, place the + # children again. This makes sure that the right side of the children + # are redrawn. There's got to be a better way?? + self.place_children() + self.cached_size_request = self.calc_size_request() + self.place_children() + + def calc_size_request(self): + if self.child: + width = height = 0 + try: + legacy = self.view.scrollerStyle() == NSScrollerStyleLegacy + except AttributeError: + legacy = True + if not self.view.hasHorizontalScroller(): + width = self.child.get_size_request()[0] + if not self.view.hasVerticalScroller(): + height = self.child.get_size_request()[1] + # Add a little room for the scrollbars (if necessary) + if legacy and self.view.hasHorizontalScroller(): + height += NSScroller.scrollerWidth() + if legacy and self.view.hasVerticalScroller(): + width += NSScroller.scrollerWidth() + return width, height + else: + return 0, 0 + + def place_children(self): + if self.child is not None: + scroll_view_size = self.view.contentView().frame().size + child_width, child_height = self.child.get_size_request() + child_width = max(child_width, scroll_view_size.width) + child_height = max(child_height, scroll_view_size.height) + frame = NSRect(NSPoint(0,0), NSSize(child_width, child_height)) + if isinstance(self.child, tableview.TableView) and self.child.is_showing_headers(): + # Hack to allow the content of a table view to scroll, but not + # the headers + self.child.place(frame, self.document_view) + if self.view.documentView() is not self.child.tableview: + self.view.setDocumentView_(self.child.tableview) + else: + self.child.place(frame, self.document_view) + self.document_view.setFrame_(frame) + self.document_view.setNeedsDisplay_(YES) + self.view.setNeedsDisplay_(YES) + self.child.emit('place-in-scroller') + +class ExpanderView(FlippedView): + def init(self): + self = super(ExpanderView, self).init() + self.label_rect = None + self.content_view = None + self.button = NSButton.alloc().init() + self.button.setState_(NSOffState) + self.button.setTitle_("") + self.button.setBezelStyle_(NSDisclosureBezelStyle) + self.button.setButtonType_(NSPushOnPushOffButton) + self.button.sizeToFit() + self.addSubview_(self.button) + self.button.setTarget_(self) + self.button.setAction_('buttonChanged:') + self.content_view = FlippedView.alloc().init() + return self + + def buttonChanged_(self, button): + if button.state() == NSOnState: + self.addSubview_(self.content_view) + else: + self.content_view.removeFromSuperview() + if self.window(): + wrappermap.wrapper(self).invalidate_size_request() + + def mouseDown_(self, event): + pass # Just need to respond to the selector so we get mouseUp_ + + def mouseUp_(self, event): + position = event.locationInWindow() + window_label_rect = self.convertRect_toView_(self.label_rect, None) + if NSPointInRect(position, window_label_rect): + self.button.setNextState() + self.buttonChanged_(self.button) + +class Expander(Bin): + BUTTON_PAD_TOP = 2 + BUTTON_PAD_LEFT = 4 + LABEL_SPACING = 4 + + def __init__(self, child): + Bin.__init__(self) + if child: + self.add(child) + self.label = None + self.spacing = 0 + self.view = ExpanderView.alloc().init() + self.button = self.view.button + self.button.setFrameOrigin_(NSPoint(self.BUTTON_PAD_LEFT, + self.BUTTON_PAD_TOP)) + self.content_view = self.view.content_view + + def remove_viewport(self): + Bin.remove_viewport(self) + if self.label is not None: + self.label.remove_viewport() + + def set_spacing(self, spacing): + self.spacing = spacing + + def set_label(self, widget): + if self.label is not None: + self.label.remove_viewport() + self.label = widget + self.children_changed() + + def set_expanded(self, expanded): + if expanded: + self.button.setState_(NSOnState) + else: + self.button.setState_(NSOffState) + self.view.buttonChanged_(self.button) + + def calc_top_size(self): + width = self.button.bounds().size.width + height = self.button.bounds().size.height + if self.label is not None: + label_width, label_height = self.label.get_size_request() + width += self.LABEL_SPACING + label_width + height = max(height, label_height) + width += self.BUTTON_PAD_LEFT + height += self.BUTTON_PAD_TOP + return width, height + + def calc_size_request(self): + width, height = self.calc_top_size() + if self.child is not None and self.button.state() == NSOnState: + child_width, child_height = self.child.get_size_request() + width = max(width, child_width) + height += self.spacing + child_height + return width, height + + def place_children(self): + top_width, top_height = self.calc_top_size() + if self.label: + label_width, label_height = self.label.get_size_request() + button_width = self.button.bounds().size.width + label_x = self.BUTTON_PAD_LEFT + button_width + self.LABEL_SPACING + label_rect = NSMakeRect(label_x, self.BUTTON_PAD_TOP, + label_width, label_height) + self.label.place(label_rect, self.viewport.view) + self.view.label_rect = label_rect + if self.child: + size = self.viewport.area().size + child_rect = NSMakeRect(0, 0, size.width, size.height - + top_height) + self.content_view.setFrame_(NSMakeRect(0, top_height, size.width, + size.height - top_height)) + self.child.place(child_rect, self.content_view) + + +class TabViewDelegate(NSObject): + def tabView_willSelectTabViewItem_(self, tab_view, tab_view_item): + try: + wrapper = wrappermap.wrapper(tab_view) + except KeyError: + pass # The NSTabView hasn't been placed yet, don't worry about it. + else: + wrapper.place_child_with_item(tab_view_item) + +class TabContainer(Container): + def __init__(self): + Container.__init__(self) + self.children = [] + self.item_to_child = {} + self.view = NSTabView.alloc().init() + self.view.setAllowsTruncatedLabels_(NO) + self.delegate = TabViewDelegate.alloc().init() + self.view.setDelegate_(self.delegate) + + def append_tab(self, child_widget, label, image): + item = NSTabViewItem.alloc().init() + item.setLabel_(label) + item.setView_(FlippedView.alloc().init()) + self.view.addTabViewItem_(item) + self.children.append(child_widget) + self.child_added(child_widget) + self.item_to_child[item] = child_widget + + def select_tab(self, index): + self.view.selectTabViewItemAtIndex_(index) + + def place_children(self): + self.place_child_with_item(self.view.selectedTabViewItem()) + + def place_child_with_item(self, tab_view_item): + child = self.item_to_child[tab_view_item] + child_view = tab_view_item.view() + content_rect =self.view.contentRect() + child_view.setFrame_(content_rect) + child.place(child_view.bounds(), child_view) + + def calc_size_request(self): + tab_size = self.view.minimumSize() + # make sure there's enough room for the tabs, plus a little extra + # space to make things look good + max_width = tab_size.width + 60 + max_height = 0 + for child in self.children: + width, height = child.get_size_request() + max_width = max(width, max_width) + max_height = max(height, max_height) + max_height += tab_size.height + + return max_width, max_height diff --git a/lvc/widgets/osx/layoutmanager.py b/lvc/widgets/osx/layoutmanager.py new file mode 100644 index 0000000..de4301b --- /dev/null +++ b/lvc/widgets/osx/layoutmanager.py @@ -0,0 +1,445 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""textlayout.py -- Contains the LayoutManager class. It handles laying text, +buttons, getting font metrics and other tasks that are required to size +things. +""" +import logging +import math + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +import drawing + +INFINITE = 1000000 # size of an "infinite" dimension + +class MiroLayoutManager(NSLayoutManager): + """Overide NSLayoutManager to draw better underlines.""" + + def drawUnderlineForGlyphRange_underlineType_baselineOffset_lineFragmentRect_lineFragmentGlyphRange_containerOrigin_(self, glyph_range, typ, offset, line_rect, line_glyph_range, container_origin): + container, _ = self.textContainerForGlyphAtIndex_effectiveRange_(glyph_range.location, None) + rect = self.boundingRectForGlyphRange_inTextContainer_(glyph_range, container) + x = container_origin.x + rect.origin.x + y = (container_origin.y + rect.origin.y + rect.size.height - offset) + underline_height, offset = self.calc_underline_extents(glyph_range) + y = math.ceil(y + offset) + underline_height / 2.0 + path = NSBezierPath.bezierPath() + path.setLineWidth_(underline_height) + path.moveToPoint_(NSPoint(x, y)) + path.relativeLineToPoint_(NSPoint(rect.size.width, 0)) + path.stroke() + + def calc_underline_extents(self, line_glyph_range): + index = self.characterIndexForGlyphAtIndex_(line_glyph_range.location) + font, _ = self.textStorage().attribute_atIndex_effectiveRange_(NSFontAttributeName, index, None) + # we use a couple of magic numbers that seems to work okay. I (BDK) + # got it from some old mozilla code. + height = font.ascender() - font.descender() + height = max(1.0, round(0.05 * height)) + offset = max(1.0, round(0.1 * height)) + return height, offset + +class TextBoxPool(object): + """Handles a pool of TextBox objects. We monitor the TextBox objects and + when those objects die, we reclaim them for the pool. + + Creating TextBoxes is fairly expensive and NSLayoutManager do a lot of + caching, so it's useful to keep them around rather than destroying them. + """ + + def __init__(self): + self.used_text_boxes = [] + self.available_text_boxes = [] + + def get(self): + """Get a NSLayoutManager, either from the pool or by creating a new + one. + """ + try: + rv = self.available_text_boxes.pop() + except IndexError: + rv = TextBox() + self.used_text_boxes.append(rv) + return rv + + def reclaim_textboxes(self): + """Move used TextBoxes back to the available pool. This should be + called after the code using text boxes is done using all of them. + """ + self.available_text_boxes.extend(self.used_text_boxes) + self.used_text_boxes[:] = [] + +text_box_pool = TextBoxPool() + +class Font(object): + line_height_sizer = NSLayoutManager.alloc().init() + + def __init__(self, nsfont): + self.nsfont = nsfont + + def ascent(self): + return self.nsfont.ascender() + + def descent(self): + return -self.nsfont.descender() + + def line_height(self): + return Font.line_height_sizer.defaultLineHeightForFont_(self.nsfont) + +class FontPool(object): + def __init__(self): + self._cached_fonts = {} + + def get(self, scale_factor, bold, italic, family): + cache_key = (scale_factor, bold, italic, family) + try: + return self._cached_fonts[cache_key] + except KeyError: + font = self._create(scale_factor, bold, italic, family) + self._cached_fonts[cache_key] = font + return font + + def _create(self, scale_factor, bold, italic, family): + size = round(scale_factor * NSFont.systemFontSize()) + nsfont = None + if family is not None: + if bold: + nsfont = NSFont.fontWithName_size_(family + " Bold", size) + else: + nsfont = NSFont.fontWithName_size_(family, size) + if nsfont is None: + logging.error('FontPool: family %s scale %s bold %s ' + 'italic %s not found', + family, scale_factor, bold, italic) + # Att his point either we have requested a custom font that failed + # to load or the system font was requested. + if nsfont is None: + if bold: + nsfont = NSFont.boldSystemFontOfSize_(size) + else: + nsfont = NSFont.systemFontOfSize_(size) + return Font(nsfont) + +class LayoutManager(object): + font_pool = FontPool() + default_font = font_pool.get(1.0, False, False, None) + + def __init__(self): + self.current_font = self.default_font + self.set_text_color((0, 0, 0)) + self.set_text_shadow(None) + + def font(self, scale_factor, bold=False, italic=False, family=None): + return self.font_pool.get(scale_factor, bold, italic, family) + + def set_font(self, scale_factor, bold=False, italic=False, family=None): + self.current_font = self.font(scale_factor, bold, italic, family) + + def set_text_color(self, color): + self.text_color = color + + def set_text_shadow(self, shadow): + self.shadow = shadow + + def textbox(self, text, underline=False): + text_box = text_box_pool.get() + color = NSColor.colorWithDeviceRed_green_blue_alpha_(self.text_color[0], self.text_color[1], self.text_color[2], 1.0) + text_box.reset(text, self.current_font, color, self.shadow, underline) + return text_box + + def button(self, text, pressed=False, disabled=False, style='normal'): + if style == 'webby': + return StyledButton(text, self.current_font, pressed, disabled) + else: + return NativeButton(text, self.current_font, pressed, disabled) + + def reset(self): + text_box_pool.reclaim_textboxes() + self.current_font = self.default_font + self.text_color = (0, 0, 0) + self.shadow = None + +class TextBox(object): + def __init__(self): + self.layout_manager = MiroLayoutManager.alloc().init() + container = NSTextContainer.alloc().init() + container.setLineFragmentPadding_(0) + self.layout_manager.addTextContainer_(container) + self.layout_manager.setUsesFontLeading_(NO) + self.text_storage = NSTextStorage.alloc().init() + self.text_storage.addLayoutManager_(self.layout_manager) + self.text_container = self.layout_manager.textContainers()[0] + + def reset(self, text, font, color, shadow, underline): + """Reset the text box so it's ready to be used by a new owner.""" + self.text_storage.deleteCharactersInRange_(NSRange(0, + self.text_storage.length())) + self.text_container.setContainerSize_(NSSize(INFINITE, INFINITE)) + self.paragraph_style = NSMutableParagraphStyle.alloc().init() + self.font = font + self.color = color + self.shadow = shadow + self.width = None + self.set_text(text, underline=underline) + + def make_attr_string(self, text, color, font, underline): + attributes = NSMutableDictionary.alloc().init() + if color is not None: + nscolor = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], color[2], 1.0) + attributes.setObject_forKey_(nscolor, NSForegroundColorAttributeName) + else: + attributes.setObject_forKey_(self.color, NSForegroundColorAttributeName) + if font is not None: + attributes.setObject_forKey_(font.nsfont, NSFontAttributeName) + else: + attributes.setObject_forKey_(self.font.nsfont, NSFontAttributeName) + if underline: + attributes.setObject_forKey_(NSUnderlineStyleSingle, NSUnderlineStyleAttributeName) + attributes.setObject_forKey_(self.paragraph_style.copy(), NSParagraphStyleAttributeName) + if text is None: + text = "" + return NSAttributedString.alloc().initWithString_attributes_(text, attributes) + + def set_text(self, text, color=None, font=None, underline=False): + string = self.make_attr_string(text, color, font, underline) + self.text_storage.setAttributedString_(string) + + def append_text(self, text, color=None, font=None, underline=False): + string = self.make_attr_string(text, color, font, underline) + self.text_storage.appendAttributedString_(string) + + def set_width(self, width): + if width is not None: + self.text_container.setContainerSize_(NSSize(width, INFINITE)) + else: + self.text_container.setContainerSize_(NSSize(INFINITE, INFINITE)) + self.width = width + + def update_paragraph_style(self): + attr = NSParagraphStyleAttributeName + value = self.paragraph_style.copy() + rnge = NSMakeRange(0, self.text_storage.length()) + self.text_storage.addAttribute_value_range_(attr, value, rnge) + + def set_wrap_style(self, wrap): + if wrap == 'word': + self.paragraph_style.setLineBreakMode_(NSLineBreakByWordWrapping) + elif wrap == 'char': + self.paragraph_style.setLineBreakMode_(NSLineBreakByCharWrapping) + elif wrap == 'truncated-char': + self.paragraph_style.setLineBreakMode_(NSLineBreakByTruncatingTail) + else: + raise ValueError("Unknown wrap value: %s" % wrap) + self.update_paragraph_style() + + def set_alignment(self, align): + if align == 'left': + self.paragraph_style.setAlignment_(NSLeftTextAlignment) + elif align == 'right': + self.paragraph_style.setAlignment_(NSRightTextAlignment) + elif align == 'center': + self.paragraph_style.setAlignment_(NSCenterTextAlignment) + else: + raise ValueError("Unknown align value: %s" % align) + self.update_paragraph_style() + + def get_size(self): + # The next line is there just to force cocoa to layout the text + self.layout_manager.glyphRangeForTextContainer_(self.text_container) + rect = self.layout_manager.usedRectForTextContainer_(self.text_container) + return rect.size.width, rect.size.height + + def char_at(self, x, y): + width, height = self.get_size() + if 0 <= x < width and 0 <= y < height: + index, _ = self.layout_manager.glyphIndexForPoint_inTextContainer_fractionOfDistanceThroughGlyph_(NSPoint(x, y), self.text_container, None) + return index + else: + return None + + def draw(self, context, x, y, width, height): + if self.shadow is not None: + context.save() + context.set_shadow(self.shadow.color, self.shadow.opacity, self.shadow.offset, self.shadow.blur_radius) + self.width = width + self.text_container.setContainerSize_(NSSize(width, height)) + glyph_range = self.layout_manager.glyphRangeForTextContainer_(self.text_container) + self.layout_manager.drawGlyphsForGlyphRange_atPoint_(glyph_range, NSPoint(x, y)) + if self.shadow is not None: + context.restore() + context.path.removeAllPoints() + +class NativeButton(object): + + def __init__(self, text, font, pressed, disabled=False): + self.min_width = 0 + self.cell = NSButtonCell.alloc().init() + self.cell.setBezelStyle_(NSRoundRectBezelStyle) + self.cell.setButtonType_(NSMomentaryPushInButton) + self.cell.setFont_(font.nsfont) + self.cell.setEnabled_(not disabled) + self.cell.setTitle_(text) + if pressed: + self.cell.setState_(NSOnState) + else: + self.cell.setState_(NSOffState) + self.cell.setImagePosition_(NSImageLeft) + + def set_icon(self, icon): + image = icon.image.copy() + image.setFlipped_(NO) + self.cell.setImage_(image) + + def get_size(self): + size = self.cell.cellSize() + return size.width, size.height + + def draw(self, context, x, y, width, height): + rect = NSMakeRect(x, y, width, height) + NSGraphicsContext.currentContext().saveGraphicsState() + self.cell.drawWithFrame_inView_(rect, context.view) + NSGraphicsContext.currentContext().restoreGraphicsState() + context.path.removeAllPoints() + +class StyledButton(object): + PAD_HORIZONTAL = 11 + BIG_PAD_VERTICAL = 4 + SMALL_PAD_VERTICAL = 2 + TOP_COLOR = (1, 1, 1) + BOTTOM_COLOR = (0.86, 0.86, 0.86) + LINE_COLOR_TOP = (0.71, 0.71, 0.71) + LINE_COLOR_BOTTOM = (0.45, 0.45, 0.45) + TEXT_COLOR = (0.19, 0.19, 0.19) + DISABLED_COLOR = (0.86, 0.86, 0.86) + DISABLED_TEXT_COLOR = (0.43, 0.43, 0.43) + ICON_PAD = 8 + + def __init__(self, text, font, pressed, disabled=False): + self.pressed = pressed + self.disabled = disabled + attributes = NSMutableDictionary.alloc().init() + attributes.setObject_forKey_(font.nsfont, NSFontAttributeName) + if self.disabled: + color = self.DISABLED_TEXT_COLOR + else: + color = self.TEXT_COLOR + nscolor = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], color[2], 1.0) + attributes.setObject_forKey_(nscolor, NSForegroundColorAttributeName) + self.title = NSAttributedString.alloc().initWithString_attributes_(text, attributes) + self.image = None + + def set_icon(self, icon): + self.image = icon.image.copy() + self.image.setFlipped_(YES) + + def get_size(self): + width, height = self.get_text_size() + if self.image is not None: + width += self.image.size().width + self.ICON_PAD + height = max(height, self.image.size().height) + height += self.BIG_PAD_VERTICAL * 2 + else: + height += self.SMALL_PAD_VERTICAL * 2 + if height % 2 == 1: + # make height even so that the radius of our circle is whole + height += 1 + width += self.PAD_HORIZONTAL * 2 + return width, height + + def get_text_size(self): + size = self.title.size() + return size.width, size.height + + def draw(self, context, x, y, width, height): + self._draw_button(context, x, y, width, height) + self._draw_title(context, x, y) + context.path.removeAllPoints() + + def _draw_button(self, context, x, y, width, height): + radius = height / 2 + self._draw_path(context, x, y, width, height, radius) + if self.disabled: + end_color = self.DISABLED_COLOR + start_color = self.DISABLED_COLOR + elif self.pressed: + end_color = self.TOP_COLOR + start_color = self.BOTTOM_COLOR + else: + context.set_line_width(1) + start_color = self.TOP_COLOR + end_color = self.BOTTOM_COLOR + gradient = drawing.Gradient(x, y, x, y+height) + gradient.set_start_color(start_color) + gradient.set_end_color(end_color) + context.gradient_fill(gradient) + self._draw_border(context, x, y, width, height, radius) + + def _draw_path(self, context, x, y, width, height, radius): + inner_width = width - radius * 2 + context.move_to(x + radius, y) + context.rel_line_to(inner_width, 0) + context.arc(x + width - radius, y+radius, radius, -math.pi/2, math.pi/2) + context.rel_line_to(-inner_width, 0) + context.arc(x + radius, y+radius, radius, math.pi/2, -math.pi/2) + + def _draw_path_reverse(self, context, x, y, width, height, radius): + inner_width = width - radius * 2 + context.move_to(x + radius, y) + context.arc_negative(x + radius, y+radius, radius, -math.pi/2, math.pi/2) + context.rel_line_to(inner_width, 0) + context.arc_negative(x + width - radius, y+radius, radius, math.pi/2, -math.pi/2) + context.rel_line_to(-inner_width, 0) + + def _draw_border(self, context, x, y, width, height, radius): + self._draw_path(context, x, y, width, height, radius) + self._draw_path_reverse(context, x+1, y+1, width-2, height-2, radius-1) + gradient = drawing.Gradient(x, y, x, y+height) + gradient.set_start_color(self.LINE_COLOR_TOP) + gradient.set_end_color(self.LINE_COLOR_BOTTOM) + context.save() + context.clip() + context.rectangle(x, y, width, height) + context.gradient_fill(gradient) + context.restore() + + def _draw_title(self, context, x, y): + c_width, c_height = self.get_size() + t_width, t_height = self.get_text_size() + x = x + self.PAD_HORIZONTAL + y = y + (c_height - t_height) / 2 + if self.image is not None: + self.image.drawAtPoint_fromRect_operation_fraction_( + NSPoint(x, y+3), NSZeroRect, NSCompositeSourceOver, 1.0) + x += self.image.size().width + self.ICON_PAD + else: + y += 0.5 + self.title.drawAtPoint_(NSPoint(x, y)) diff --git a/lvc/widgets/osx/osxmenus.py b/lvc/widgets/osx/osxmenus.py new file mode 100644 index 0000000..91baf3a --- /dev/null +++ b/lvc/widgets/osx/osxmenus.py @@ -0,0 +1,571 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""menus.py -- Menu handling code.""" + +import logging +import struct + +from objc import nil, NO, YES +import AppKit +from AppKit import * +from Foundation import * + +from lvc import signals +from lvc.widgets import keyboard +# import these names directly into our namespace for easy access +from lvc.widgets.keyboard import Shortcut, MOD + +# XXX hacks +def _(text, *params): + if params: + return text % params[0] + return text + +MODIFIERS_MAP = { + keyboard.MOD: NSCommandKeyMask, + keyboard.CMD: NSCommandKeyMask, + keyboard.SHIFT: NSShiftKeyMask, + keyboard.CTRL: NSControlKeyMask, + keyboard.ALT: NSAlternateKeyMask +} + +if isinstance(NSBackspaceCharacter, int): + backspace = NSBackspaceCharacter +else: + backspace = ord(NSBackspaceCharacter) + +KEYS_MAP = { + keyboard.SPACE: " ", + keyboard.ENTER: "\r", + keyboard.BKSPACE: struct.pack("H", backspace), + keyboard.DELETE: NSDeleteFunctionKey, + keyboard.RIGHT_ARROW: NSRightArrowFunctionKey, + keyboard.LEFT_ARROW: NSLeftArrowFunctionKey, + keyboard.UP_ARROW: NSUpArrowFunctionKey, + keyboard.DOWN_ARROW: NSDownArrowFunctionKey, + '.': '.', + ',': ',' +} +# add function keys +for i in range(1, 13): + portable_key = getattr(keyboard, "F%s" % i) + osx_key = getattr(AppKit, "NSF%sFunctionKey" % i) + KEYS_MAP[portable_key] = osx_key + +REVERSE_MODIFIERS_MAP = dict((i[1], i[0]) for i in MODIFIERS_MAP.items()) +REVERSE_KEYS_MAP = dict((i[1], i[0]) for i in KEYS_MAP.items() + if i[0] != keyboard.BKSPACE) +REVERSE_KEYS_MAP[u'\x7f'] = keyboard.BKSPACE +REVERSE_KEYS_MAP[u'\x1b'] = keyboard.ESCAPE + +def make_modifier_mask(shortcut): + mask = 0 + for modifier in shortcut.modifiers: + mask |= MODIFIERS_MAP[modifier] + return mask + +VIEW_ITEM_MAP = {} + +def _remove_mnemonic(label): + """Remove the underscore used by GTK for mnemonics. + + We totally ignore them on OSX, since they are now deprecated. + """ + return label.replace("_", "") + +def handle_menu_activate(ns_menu_item): + """Handle a menu item being activated. + + This gets called by our application delegate. + """ + + menu_item = ns_menu_item.representedObject() + menu_item.emit("activate") + menubar = menu_item._find_menubar() + if menubar is not None: + menubar.emit("activate", menu_item.name) + +class MenuItemBase(signals.SignalEmitter): + """Base class for MenuItem and Separator""" + def __init__(self): + signals.SignalEmitter.__init__(self) + self.name = None + self.parent = None + + def show(self): + self._menu_item.setHidden_(False) + + def hide(self): + self._menu_item.setHidden_(True) + + def enable(self): + self._menu_item.setEnabled_(True) + + def disable(self): + self._menu_item.setEnabled_(False) + + def remove_from_parent(self): + """Remove this menu item from it's parent Menu.""" + if self.parent is not None: + self.parent.remove(self) + +class MenuItem(MenuItemBase): + """See the GTK version of this method for the current docstring.""" + + # map Miro action names to standard OSX actions. + _STD_ACTION_MAP = { + "HideMiro": (NSApp(), 'hide:'), + "HideOthers": (NSApp(), 'hideOtherApplications:'), + "ShowAll": (NSApp(), 'unhideAllApplications:'), + "Cut": (nil, 'cut:'), + "Copy": (nil, 'copy:'), + "Paste": (nil, 'paste:'), + "Delete": (nil, 'delete:'), + "SelectAll": (nil, 'selectAll:'), + "Zoom": (nil, 'performZoom:'), + "Minimize": (nil, 'performMiniaturize:'), + "BringAllToFront": (nil, 'arrangeInFront:'), + "CloseWindow": (nil, 'performClose:'), + } + + def __init__(self, label, name, shortcut=None): + MenuItemBase.__init__(self) + self.name = name + self._menu_item = self._make_menu_item(label) + self.create_signal('activate') + self._setup_shortcut(shortcut) + + def _make_menu_item(self, label): + menu_item = NSMenuItem.alloc().init() + menu_item.setTitle_(_remove_mnemonic(label)) + # we set ourselves as the represented object for the menu item so we + # can easily translate one to the other + menu_item.setRepresentedObject_(self) + if self.name in self._STD_ACTION_MAP: + menu_item.setTarget_(self._STD_ACTION_MAP[self.name][0]) + menu_item.setAction_(self._STD_ACTION_MAP[self.name][1]) + else: + menu_item.setTarget_(NSApp().delegate()) + menu_item.setAction_('handleMenuActivate:') + return menu_item + + def _setup_shortcut(self, shortcut): + if shortcut is None: + key = '' + modifier_mask = 0 + elif isinstance(shortcut.shortcut, str): + key = shortcut.shortcut + modifier_mask = make_modifier_mask(shortcut) + elif shortcut.shortcut in KEYS_MAP: + key = KEYS_MAP[shortcut.shortcut] + modifier_mask = make_modifier_mask(shortcut) + else: + logging.warn("Don't know how to handle shortcut: %s", shortcut) + return + self._menu_item.setKeyEquivalent_(key) + self._menu_item.setKeyEquivalentModifierMask_(modifier_mask) + + def _change_shortcut(self, shortcut): + self._setup_shortcut(shortcut) + + def set_label(self, new_label): + self._menu_item.setTitle_(new_label) + + def get_label(self): + self._menu_item.title() + + def _find_menubar(self): + """Remove this menu item from it's parent Menu.""" + menu_item = self + while menu_item.parent is not None: + menu_item = menu_item.parent + if isinstance(menu_item, MenuBar): + return menu_item + else: + return None + +class CheckMenuItem(MenuItem): + """See the GTK version of this method for the current docstring.""" + def set_state(self, active): + if active is None: + state = NSMixedState + elif active: + state = NSOnState + else: + state = NSOffState + self._menu_item.setState_(state) + + def get_state(self): + return self._menu_item.state() == NSOnState + + def do_activate(self): + if self._menu_item.state() == NSOffState: + self._menu_item.setState_(NSOnState) + else: + self._menu_item.setState_(NSOffState) + +class RadioMenuItem(CheckMenuItem): + """See the GTK version of this method for the current docstring.""" + def __init__(self, label, name, shortcut=None): + CheckMenuItem.__init__(self, label, name, shortcut) + # The leader of a radio group stores the list of all items in the + # group + self.group_leader = None + self.others_in_group = set() + + def set_group(self, group_item): + if self.group_leader is not None: + raise ValueError("%s is already in a group" % self) + if group_item.group_leader is None: + group_leader = group_item + else: + group_leader = group_item.group_leader + if group_leader.group_leader is not None: + raise AssertionError("group_leader structure is wrong") + self.group_leader = group_leader + group_leader.others_in_group.add(self) + + def remove_from_group(self): + """Remove this RadioMenuItem from its current group.""" + if self.group_leader is not None: + # we have a group leader, remove ourself from their list. + # Note that this code will work even if we're the last item in + # others_in_group. + self.group_leader.others_in_group.remove(self) + self.group_leader = None + elif len(self.others_in_group) > 1: + # we're the group leader, hand off the leader to a different item + first_item = iter(self.others_in_group).next() + for other in self.others_in_group: + if other is first_item: + other.others_in_group = self.others_in_group + other.others_in_group.remove(first_item) + other.group_leader = None + else: + other.group_leader = first_item + self.others_in_group = set() + elif len(self.others_in_group) == 1: + # we're the group leader, but there's only 1 other item. unset + # everything. + for other in self.others_in_group: + other.group_leader = None + self.others_in_group = set() + + def _items_in_group(self): + if self.group_leader is not None: # we have a group leader + yield self.group_leader + for other in self.group_leader.others_in_group: + yield other + elif self.others_in_group: # we're the group leader + yield self + for other in self.others_in_group: + yield other + else: # we don't have a group set + yield self + + def do_activate(self): + for item in self._items_in_group(): + if item is not self: + item.set_state(False) + CheckMenuItem.do_activate(self) + +class Separator(MenuItemBase): + """See the GTK version of this method for the current docstring.""" + def __init__(self): + MenuItemBase.__init__(self) + self._menu_item = NSMenuItem.separatorItem() + +class MenuShell(signals.SignalEmitter): + def __init__(self, nsmenu): + signals.SignalEmitter.__init__(self) + self._menu = nsmenu + self.children = [] + self.parent = None + + def append(self, menu_item): + """Add a menu item to the end of this menu.""" + self.children.append(menu_item) + self._menu.addItem_(menu_item._menu_item) + menu_item.parent = self + + def insert(self, index, menu_item): + """Insert a menu item in the middle of this menu.""" + self.children.insert(index, menu_item) + self._menu.insertItem_atIndex_(menu_item._menu_item, index) + menu_item.parent = self + + def index(self, name): + """Find the position of a child menu item.""" + for i, menu_item in enumerate(self.children): + if menu_item.name == name: + return i + return -1 + + def remove(self, menu_item): + """Remove a child menu item. + + :raises ValueError: menu_item is not a child of this menu + """ + self.children.remove(menu_item) + self._menu.removeItem_(menu_item._menu_item) + menu_item.parent = None + + def get_children(self): + """Get the child menu items in order.""" + return list(self.children) + + def find(self, name): + """Search for a menu or menu item + + This method recursively searches the entire menu structure for a Menu + or MenuItem object with a given name. + + :raises KeyError: name not found + """ + found = self._find(name) + if found is None: + raise KeyError(name) + else: + return found + + def _find(self, name): + """Low-level helper-method for find(). + + :returns: found menu item or None. + """ + for menu_item in self.get_children(): + if menu_item.name == name: + return menu_item + if isinstance(menu_item, Menu): + submenu_find = menu_item._find(name) + if submenu_find is not None: + return submenu_find + return None + +class Menu(MenuShell): + """See the GTK version of this method for the current docstring.""" + def __init__(self, label, name, child_items=None): + MenuShell.__init__(self, NSMenu.alloc().init()) + self._menu.setTitle_(_remove_mnemonic(label)) + # we will enable/disable menu items manually + self._menu.setAutoenablesItems_(False) + self.name = name + if child_items is not None: + for item in child_items: + self.append(item) + self._menu_item = NSMenuItem.alloc().init() + self._menu_item.setTitle_(_remove_mnemonic(label)) + self._menu_item.setSubmenu_(self._menu) + # Hack to set the services menu + if name == "ServicesMenu": + NSApp().setServicesMenu_(self._menu_item) + + def show(self): + self._menu_item.setHidden_(False) + + def hide(self): + self._menu_item.setHidden_(True) + + def enable(self): + self._menu_item.setEnabled_(True) + + def disable(self): + self._menu_item.setEnabled_(False) + +class AppMenu(MenuShell): + """Wrapper for the application menu (AKA the Miro menu) + + We need to special case this because OSX automatically creates the menu + item. + """ + def __init__(self): + MenuShell.__init__(self, NSApp().mainMenu().itemAtIndex_(0).submenu()) + self.name = "Libre Video Converter" + +class MenuBar(MenuShell): + """See the GTK version of this method for the current docstring.""" + def __init__(self): + MenuShell.__init__(self, NSApp().mainMenu()) + self.create_signal('activate') + self._add_app_menu() + + def _add_app_menu(self): + """Add the app menu to this menu bar. + + We need to special case this because OSX automatically adds the + NSMenuItem for the app menu, we just need to set up our wrappers. + """ + self._app_menu = AppMenu() + self.children.append(self._app_menu) + self._app_menu.parent = self + + def add_initial_menus(self, menus): + for menu in menus: + self.append(menu) + self._modify_initial_menus() + + def _extract_menu_item(self, name): + """Helper method for changing the portable menu structure.""" + menu_item = self.find(name) + menu_item.remove_from_parent() + return menu_item + + def _modify_initial_menus(self): + short_appname = "Libre Video Converter" # XXX + + # Application menu + miroMenuItems = [ + self._extract_menu_item("About"), + Separator(), + self._extract_menu_item("Quit") + ] + + for item in miroMenuItems: + self._app_menu.append(item) + + self._app_menu.find("Quit").set_label(_("Quit %(appname)s", + {"appname": short_appname})) + + # Help Menu + #helpItem = self.find("Help") + #helpItem.set_label(_("%(appname)s Help", {"appname": short_appname})) + #helpItem._change_shortcut(Shortcut("?", MOD)) + + self._update_present_menu() + self._connect_to_signals() + + def do_activate(self, name): + # We handle a couple OSX-specific actions here + if name == "PresentActualSize": + NSApp().delegate().present_movie('natural-size') + elif name == "PresentDoubleSize": + NSApp().delegate().present_movie('double-size') + elif name == "PresentHalfSize": + NSApp().delegate().present_movie('half-size') + elif name == "ShowMain": + app.widgetapp.window.nswindow.makeKeyAndOrderFront_(self) + + def _connect_to_signals(self): + return + app.playback_manager.connect("will-play", self._on_playback_change) + app.playback_manager.connect("will-stop", self._on_playback_change) + + def _on_playback_change(self, playback_manager, *args): + self._update_present_menu() + + def _update_present_menu(self): + return + if self._should_enable_present_menu(): + for menu_item in self.present_menu.get_children(): + menu_item.enable() + else: + for menu_item in self.present_menu.get_children(): + menu_item.disable() + + def _should_enable_present_menu(self): + return False + if (app.playback_manager.is_playing and + not app.playback_manager.is_playing_audio): + # we're currently playing video, allow the user to fullscreen + return True + selection_info = app.item_list_controller_manager.get_selection_info() + if (selection_info.has_download and + selection_info.has_file_type('video')): + # A downloaded video is selected, allow the user to start playback + # in fullscreen + return True + return False + +#class ContextMenuHandler(NSObject): +# def initWithCallback_(self, callback): +# self = super(ContextMenuHandler, self).init() +# self.callback = callback +# return self +# +# def handleMenuItem_(self, sender): +# self.callback() +# +#class MiroContextMenu(NSMenu): +# # Works exactly like NSMenu, except it keeps a reference to the menu +# # handler objects. +# def init(self): +# self = super(MiroContextMenu, self).init() +# self.handlers = set() +# return self +# +# def addItem_(self, item): +# if isinstance(item.target(), ContextMenuHandler): +# self.handlers.add(item.target()) +# return NSMenu.addItem_(self, item) +# +def make_context_menu(menu_items): + nsmenu = MiroContextMenu.alloc().init() + for item in menu_items: + if item is None: + nsitem = NSMenuItem.separatorItem() + else: + label, callback = item + nsitem = NSMenuItem.alloc().init() + if isinstance(label, tuple) and len(label) == 2: + label, icon_path = label + image = NSImage.alloc().initWithContentsOfFile_(icon_path) + nsitem.setImage_(image) + if callback is None: + font_size = NSFont.systemFontSize() + font = NSFont.fontWithName_size_("Lucida Sans Italic", font_size) + if font is None: + font = NSFont.systemFontOfSize_(font_size) + attributes = {NSFontAttributeName: font} + attributed_label = NSAttributedString.alloc().initWithString_attributes_(label, attributes) + nsitem.setAttributedTitle_(attributed_label) + else: + nsitem.setTitle_(label) + if isinstance(callback, list): + submenu = make_context_menu(callback) + nsmenu.setSubmenu_forItem_(submenu, nsitem) + else: + handler = ContextMenuHandler.alloc().initWithCallback_(callback) + nsitem.setTarget_(handler) + nsitem.setAction_('handleMenuItem:') + nsmenu.addItem_(nsitem) + return nsmenu + +def translate_event_modifiers(event): + mods = set() + flags = event.modifierFlags() + if flags & NSCommandKeyMask: + mods.add(keyboard.CMD) + if flags & NSControlKeyMask: + mods.add(keyboard.CTRL) + if flags & NSAlternateKeyMask: + mods.add(keyboard.ALT) + if flags & NSShiftKeyMask: + mods.add(keyboard.SHIFT) + return mods diff --git a/lvc/widgets/osx/rect.py b/lvc/widgets/osx/rect.py new file mode 100644 index 0000000..3c8d448 --- /dev/null +++ b/lvc/widgets/osx/rect.py @@ -0,0 +1,78 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".rect -- Simple Rectangle class.""" + +from Foundation import NSMakeRect, NSRectFromString + +class Rect(object): + @classmethod + def from_string(cls, rect_string): + if rect_string.startswith('{{'): + return NSRectWrapper(NSRectFromString(rect_string)) + else: + try: + items = [int(i) for i in rect_string.split(',')] + return Rect(*items) + except: + return None + + def __init__(self, x, y, width, height): + self.nsrect = NSMakeRect(x, y, width, height) + + def get_x(self): + return self.nsrect.origin.x + def set_x(self, x): + self.nsrect.origin.x = x + x = property(get_x, set_x) + + def get_y(self): + return self.nsrect.origin.y + def set_y(self, y): + self.nsrect.origin.x = y + y = property(get_y, set_y) + + def get_width(self): + return self.nsrect.size.width + def set_width(self, width): + self.nsrect.size.width = width + width = property(get_width, set_width) + + def get_height(self): + return self.nsrect.size.height + def set_height(self, height): + self.nsrect.size.height = height + height = property(get_height, set_height) + + def __str__(self): + return "%d,%d,%d,%d" % (self.nsrect.origin.x, self.nsrect.origin.y, self.nsrect.size.width, self.nsrect.size.height) + +class NSRectWrapper(Rect): + def __init__(self, nsrect): + self.nsrect = nsrect diff --git a/lvc/widgets/osx/simple.py b/lvc/widgets/osx/simple.py new file mode 100644 index 0000000..37407a1 --- /dev/null +++ b/lvc/widgets/osx/simple.py @@ -0,0 +1,376 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +from __future__ import division +import logging +import math + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from lvc.widgets import widgetconst +from .base import Widget, SimpleBin, FlippedView +from .utils import filename_to_unicode +import drawing +import wrappermap + +"""A collection of various simple widgets.""" + +class Image(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, path): + self._set_image(NSImage.alloc().initByReferencingFile_( + filename_to_unicode(path))) + + def _set_image(self, nsimage): + self.nsimage = nsimage + self.width = self.nsimage.size().width + self.height = self.nsimage.size().height + if self.width * self.height == 0: + raise ValueError('Image has invalid size: (%d, %d)' % ( + self.width, self.height)) + + def resize(self, width, height): + return ResizedImage(self, width, height) + + def crop_and_scale(self, src_x, src_y, src_width, src_height, dest_width, + dest_height): + if dest_width <= 0 or dest_height <= 0: + logging.stacktrace("invalid dest sizes: %s %s" % (dest_width, + dest_height)) + return TransformedImage(self.nsimage) + + source_rect = NSMakeRect(src_x, src_y, src_width, src_height) + dest_rect = NSMakeRect(0, 0, dest_width, dest_height) + + dest = NSImage.alloc().initWithSize_(NSSize(dest_width, dest_height)) + dest.lockFocus() + try: + NSGraphicsContext.currentContext().setImageInterpolation_( + NSImageInterpolationHigh) + self.nsimage.drawInRect_fromRect_operation_fraction_(dest_rect, + source_rect, NSCompositeCopy, 1.0) + finally: + dest.unlockFocus() + return TransformedImage(dest) + + def resize_for_space(self, width, height): + """Returns an image scaled to fit into the specified space at the + correct height/width ratio. + """ + # this prevents division by 0. + if self.width == 0 and self.height == 0: + return self + elif self.width == 0: + ratio = height / self.height + return self.resize(self.width, ratio * self.height) + elif self.height == 0: + ratio = width / self.width + return self.resize(ratio * self.width, self.height) + + ratio = min(width / self.width, height / self.height) + return self.resize(ratio * self.width, ratio * self.height) + +class ResizedImage(Image): + def __init__(self, image, width, height): + nsimage = image.nsimage.copy() + nsimage.setCacheMode_(NSImageCacheNever) + nsimage.setScalesWhenResized_(YES) + nsimage.setSize_(NSSize(width, height)) + self._set_image(nsimage) + +class TransformedImage(Image): + def __init__(self, nsimage): + self._set_image(nsimage) + +class NSImageDisplay(NSView): + def init(self): + self = super(NSImageDisplay, self).init() + self.border = False + self.image = None + return self + + def isFlipped(self): + return YES + + def set_border(self, border): + self.border = border + + def set_image(self, image): + self.image = image + + def drawRect_(self, dest_rect): + if self.image is not None: + source_rect = self.calculateSourceRectFromDestRect_(dest_rect) + context = NSGraphicsContext.currentContext() + context.setShouldAntialias_(YES) + context.setImageInterpolation_(NSImageInterpolationHigh) + context.saveGraphicsState() + drawing.flip_context(self.bounds().size.height) + self.image.nsimage.drawInRect_fromRect_operation_fraction_( + dest_rect, source_rect, NSCompositeSourceOver, 1.0) + context.restoreGraphicsState() + if self.border: + context = drawing.DrawingContext(self, self.bounds(), dest_rect) + context.style = drawing.DrawingStyle() + context.set_line_width(1) + context.set_color((0, 0, 0)) # black + context.rectangle(0, 0, context.width, context.height) + context.stroke() + + def calculateSourceRectFromDestRect_(self, dest_rect): + """Calulate where dest_rect maps to on our image. + + This is tricky because our image might be scaled up, in which case + the rect from our image will be smaller than dest_rect. + """ + view_size = self.frame().size + x_scale = float(self.image.width) / view_size.width + y_scale = float(self.image.height) / view_size.height + + return NSMakeRect(dest_rect.origin.x * x_scale, + dest_rect.origin.y * y_scale, + dest_rect.size.width * x_scale, + dest_rect.size.height * y_scale) + + # XXX FIXME: should track mouse movement - mouseDown is not the correct + # event. + def mouseDown_(self, event): + wrappermap.wrapper(self).emit('clicked') + +class ImageDisplay(Widget): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, image=None): + Widget.__init__(self) + self.create_signal('clicked') + self.view = NSImageDisplay.alloc().init() + self.set_image(image) + + def set_image(self, image): + self.image = image + if image: + image.nsimage.setCacheMode_(NSImageCacheNever) + self.view.set_image(image) + self.invalidate_size_request() + + def set_border(self, border): + self.view.set_border(border) + + def calc_size_request(self): + if self.image is not None: + return self.image.width, self.image.height + else: + return 0, 0 + +class ClickableImageButton(ImageDisplay): + def __init__(self, image_path, max_width=None, max_height=None): + ImageDisplay.__init__(self) + self.set_border(True) + self.max_width = max_width + self.max_height = max_height + self.image = None + self._width, self._height = None, None + if image_path: + self.set_path(image_path) + + def set_path(self, path): + image = Image(path) + if self.max_width: + image = image.resize_for_space(self.max_width, self.max_height) + super(ClickableImageButton, self).set_image(image) + + def calc_size_request(self): + if self.max_width: + return self.max_width, self.max_height + else: + return ImageDisplay.calc_size_request(self) + +class MiroImageView(NSImageView): + def viewWillMoveToWindow_(self, aWindow): + self.setAnimates_(not aWindow == nil) + +class AnimatedImageDisplay(Widget): + def __init__(self, path): + Widget.__init__(self) + self.nsimage = NSImage.alloc().initByReferencingFile_( + filename_to_unicode(path)) + self.view = MiroImageView.alloc().init() + self.view.setImage_(self.nsimage) + # enabled when viewWillMoveToWindow:aWindow invoked + self.view.setAnimates_(NO) + + def calc_size_request(self): + return self.nsimage.size().width, self.nsimage.size().height + +class Label(Widget): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, text="", wrap=False, color=None): + Widget.__init__(self) + self.view = NSTextField.alloc().init() + self.view.setEditable_(NO) + self.view.setBezeled_(NO) + self.view.setBordered_(NO) + self.view.setDrawsBackground_(NO) + self.wrap = wrap + self.bold = False + self.size = NSFont.systemFontSize() + self.sizer_cell = self.view.cell().copy() + self.set_font() + self.set_text(text) + self.__color = self.view.textColor() + if color is not None: + self.set_color(color) + + def get_width(self): + return self.calc_size_request()[0] + + def set_bold(self, bold): + self.bold = bold + self.set_font() + + def set_size(self, size): + if size > 0: + self.size = NSFont.systemFontSize() * size + elif size == widgetconst.SIZE_SMALL: + self.size = NSFont.smallSystemFontSize() + elif size == widgetconst.SIZE_NORMAL: + self.size = NSFont.systemFontSize() + else: + raise ValueError("Unknown size constant: %s" % size) + + self.set_font() + + def set_color(self, color): + self.__color = self.make_color(color) + + if self.view.isEnabled(): + self.view.setTextColor_(self.__color) + else: + self.view.setTextColor_(self.__color.colorWithAlphaComponent_(0.5)) + + def set_background_color(self, color): + self.view.setBackgroundColor_(self.make_color(color)) + self.view.setDrawsBackground_(YES) + + def set_font(self): + if self.bold: + font = NSFont.boldSystemFontOfSize_(self.size) + else: + font= NSFont.systemFontOfSize_(self.size) + self.view.setFont_(font) + self.sizer_cell.setFont_(font) + self.invalidate_size_request() + + def calc_size_request(self): + if (self.wrap and self.manual_size_request is not None and + self.manual_size_request[0] > 0): + wrap_width = self.manual_size_request[0] + size = self.sizer_cell.cellSizeForBounds_(NSMakeRect(0, 0, + wrap_width, 10000)) + else: + size = self.sizer_cell.cellSize() + return math.ceil(size.width), math.ceil(size.height) + + def baseline(self): + return -self.view.font().descender() + + def set_text(self, text): + self.view.setStringValue_(text) + self.sizer_cell.setStringValue_(text) + self.invalidate_size_request() + + def get_text(self): + val = self.view.stringValue() + if not val: + val = u'' + return val + + def set_selectable(self, val): + self.view.setSelectable_(val) + + def set_alignment(self, alignment): + self.view.setAlignment_(alignment) + + def get_alignment(self, alignment): + return self.view.alignment() + + def set_wrap(self, wrap): + self.wrap = True + self.invalidate_size_request() + + def enable(self): + Widget.enable(self) + self.view.setTextColor_(self.__color) + self.view.setEnabled_(True) + + def disable(self): + Widget.disable(self) + self.view.setTextColor_(self.__color.colorWithAlphaComponent_(0.5)) + self.view.setEnabled_(False) + +class SolidBackground(SimpleBin): + def __init__(self, color=None): + SimpleBin.__init__(self) + self.view = FlippedView.alloc().init() + if color is not None: + self.set_background_color(color) + + def set_background_color(self, color): + self.view.setBackgroundColor_(self.make_color(color)) + +class ProgressBar(Widget): + def __init__(self): + Widget.__init__(self) + self.view = NSProgressIndicator.alloc().init() + self.view.setMaxValue_(1.0) + self.view.setIndeterminate_(False) + + def calc_size_request(self): + return 20, 20 + + def set_progress(self, fraction): + self.view.setIndeterminate_(False) + self.view.setDoubleValue_(fraction) + + def start_pulsing(self): + self.view.setIndeterminate_(True) + self.view.startAnimation_(nil) + + def stop_pulsing(self): + self.view.stopAnimation_(nil) + +class HLine(Widget): + def __init__(self): + Widget.__init__(self) + self.view = NSBox.alloc().init() + self.view.setBoxType_(NSBoxSeparator) + + def calc_size_request(self): + return self.view.frame().size.width, self.view.frame().size.height diff --git a/lvc/widgets/osx/tablemodel.py b/lvc/widgets/osx/tablemodel.py new file mode 100644 index 0000000..d81a8f5 --- /dev/null +++ b/lvc/widgets/osx/tablemodel.py @@ -0,0 +1,532 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tablemodel.py -- Model classes for TableView. """ + +import logging + +from AppKit import (NSDragOperationNone, NSDragOperationAll, NSTableViewDropOn, + NSOutlineViewDropOnItemIndex, protocols) +from Foundation import NSObject, NSNotFound, NSMutableIndexSet +from objc import YES, NO, nil + +from lvc import signals +from lvc.errors import WidgetActionError +import fasttypes +import wrappermap + +MIRO_DND_ITEM_LOCAL = 'miro-local-item' + +# XXX need unsigned but value comes out as signed. +NSDragOperationEvery = NSDragOperationAll + +def list_from_nsindexset(index_set): + rows = list() + index = index_set.firstIndex() + while (index != NSNotFound): + rows.append(index) + index = index_set.indexGreaterThanIndex_(index) + return rows + +class RowList(object): + """RowList is a Linked list that has some optimizations for looking up + rows by index number. + """ + def __init__(self): + self.list = fasttypes.LinkedList() + self.iter_cache = [] + + def firstIter(self): + return self.list.firstIter() + + def lastIter(self): + return self.list.lastIter() + + def insertBefore(self, iter, value): + self.iter_cache = [] + if iter is None: + return self.list.append(value) + else: + return self.list.insertBefore(iter, value) + + def append(self, value): + return self.list.append(value) + + def __len__(self): + return len(self.list) + + def __getitem__(self, iter): + return self.list[iter] + + def __iter__(self): + iter = self.firstIter() + while iter != self.lastIter(): + yield iter.value() + iter.forward() + + def remove(self, iter): + self.iter_cache = [] + return self.list.remove(iter) + + def nth_iter(self, index): + if index < 0: + raise IndexError(index) + elif index >= len(self): + raise LookupError() + if len(self.iter_cache) == 0: + self.iter_cache.append(self.firstIter()) + try: + return self.iter_cache[index].copy() + except IndexError: + pass + iter = self.iter_cache[-1].copy() + index -= len(self.iter_cache) - 1 + for x in xrange(index): + iter.forward() + self.iter_cache.append(iter.copy()) + return iter + +class TableModelBase(signals.SignalEmitter): + """Base class for TableModel and TreeTableModel.""" + def __init__(self, *column_types): + signals.SignalEmitter.__init__(self) + self.row_list = RowList() + self.column_types = column_types + self.create_signal('row-changed') + self.create_signal('structure-will-change') + + def check_column_values(self, column_values): + if len(self.column_types) != len(column_values): + raise ValueError("Wrong number of columns") + # We might want to do more typechecking here + + def get_column_data(self, row, column): + attr_map = column.attrs() + return dict((name, row[index]) for name, index in attr_map.items()) + + def update_value(self, iter, index, value): + iter.value().values[index] = value + self.emit('row-changed', iter) + + def update(self, iter, *column_values): + iter.value().update_values(column_values) + self.emit('row-changed', iter) + + def remove(self, iter): + self.emit('structure-will-change') + row_list = self.containing_list(iter) + rv = row_list.remove(iter) + if rv == row_list.lastIter(): + rv = None + return rv + + def nth_iter(self, index): + return self.row_list.nth_iter(index) + + def next_iter(self, iter): + row_list = self.containing_list(iter) + retval = iter.copy() + retval.forward() + if retval == row_list.lastIter(): + return None + else: + return retval + + def first_iter(self): + if len(self.row_list) > 0: + return self.row_list.firstIter() + else: + return None + + def __len__(self): + return len(self.row_list) + + def __getitem__(self, iter): + return iter.value() + + def __iter__(self): + return iter(self.row_list) + +class TableRow(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPITableView for a description of the API for this class.""" + def __init__(self, column_values): + self.update_values(column_values) + + def update_values(self, column_values): + self.values = list(column_values) + + def __getitem__(self, index): + return self.values[index] + + def __len__(self): + return len(self.values) + + def __iter__(self): + return iter(self.values) + +class TableModel(TableModelBase): + """See https://develop.participatoryculture.org/index.php/WidgetAPITableView for a description of the API for this class.""" + def __init__(self, *column_types): + TableModelBase.__init__(self, column_types) + self.row_indexes = {} + + def remember_row_at_index(self, row, index): + if row not in self.row_indexes: + self.row_indexes[row] = index + + def row_of_iter(self, tableview, iter): + row = iter.value() + try: + return self.row_indexes[row] + except KeyError: + iter = self.row_list.firstIter() + index = 0 + while iter != self.row_list.lastIter(): + current_row = iter.value() + self.row_indexes[current_row] = index + if current_row is row: + return index + index += 1 + iter.forward() + raise LookupError("%s is not in this table" % row) + + def containing_list(self, iter): + return self.row_list + + def append(self, *column_values): + self.emit('structure-will-change') + self.row_indexes = {} + retval = self.row_list.append(TableRow(column_values)) + return retval + + def remove(self, iter): + self.row_indexes = {} + return TableModelBase.remove(self, iter) + + def insert_before(self, iter, *column_values): + self.emit('structure-will-change') + self.row_indexes = {} + row = TableRow(column_values) + retval = self.row_list.insertBefore(iter, row) + return retval + + def iter_for_row(self, tableview, row): + return self.row_list.nth_iter(row) + + +class TreeNode(NSObject, TableRow): + """A row in a TreeTableModel""" + + # Implementation note: these need to be NSObjects because we return them + # to the NSOutlineView. + + def initWithValues_parent_(self, column_values, parent): + self.children = RowList() + self.update_values(column_values) + self.parent = parent + return self + + @staticmethod + def create_(values, parent): + return TreeNode.alloc().initWithValues_parent_(values, parent) + + def iterchildren(self): + return iter(self.children) + +class TreeTableModel(TableModelBase): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self, *column_values): + TableModelBase.__init__(self, *column_values) + self.iter_for_item = {} + + def containing_list(self, iter): + return self.row_list_for_iter(iter.value().parent) + + def row_list_for_iter(self, iter): + """Return the rows of all direct children of iter.""" + if iter is None: + return self.row_list + else: + return iter.value().children + + def remember_iter(self, iter): + self.iter_for_item[iter.value()] = iter + return iter + + def append(self, *column_values): + self.emit('structure-will-change') + retval = self.row_list.append(TreeNode.create_(column_values, None)) + return self.remember_iter(retval) + + def forget_iter_for_item(self, item): + del self.iter_for_item[item] + for child in item.children: + self.forget_iter_for_item(child) + + def remove(self, iter): + item = iter.value() + rv = TableModelBase.remove(self, iter) + self.forget_iter_for_item(item) + return rv + + def insert_before(self, iter, *column_values): + self.emit('structure-will-change') + row = TreeNode.create_(column_values, self.parent_iter(iter)) + retval = self.containing_list(iter).insertBefore(iter, row) + return self.remember_iter(retval) + + def append_child(self, iter, *column_values): + self.emit('structure-will-change') + row_list = self.row_list_for_iter(iter) + retval = row_list.append(TreeNode.create_(column_values, iter)) + return self.remember_iter(retval) + + def child_iter(self, iter): + row_list = iter.value().children + if len(row_list) == 0: + return None + else: + return row_list.firstIter() + + def nth_child_iter(self, iter, index): + row_list = self.row_list_for_iter(iter) + return row_list.nth_iter(index) + + def has_child(self, iter): + return len(iter.value().children) > 0 + + def children_count(self, iter): + if iter is not None: + return len(iter.value().children) + else: + return len(self.row_list) + + def children_iters(self, iter): + return self.iters_in_rowlist(self.row_list_for_iter(iter)) + + def parent_iter(self, iter): + return iter.value().parent + + def iter_for_row(self, tableview, row): + item = tableview.itemAtRow_(row) + if item in self.iter_for_item: + return self.iter_for_item[item] + elif item == -1: + raise WidgetActionError("no item at row %s" % row) + else: + raise WidgetActionError("no iter for item %s at row %s" % + (repr(item), row)) + + def row_of_iter(self, tableview, iter): + item = iter.value() + row = tableview.rowForItem_(item) + if row == -1: + raise LookupError("%s is not in this table" % repr(item)) + return row + + def get_path(self, iter_): + """Not implemented (yet?) for Cocoa. Currently the only place this is + needed is tablistmanager, where the situation that uses paths results + from GTK peculiarities. + """ + return NotImplemented + +class DataSourceBase(NSObject): + def initWithModel_(self, model): + self.model = model + self.drag_source = None + self.drag_dest = None + return self + + def setDragSource_(self, drag_source): + self.drag_source = drag_source + + def setDragDest_(self, drag_dest): + self.drag_dest = drag_dest + + def view_writeColumnData_ToPasteboard_(self, view, data, pasteboard): + if not self.drag_source: + return NO + wrapper = wrappermap.wrapper(view) + drag_data = self.drag_source.begin_drag(wrapper, data) + if not drag_data: + return NO + pasteboard.declareTypes_owner_((MIRO_DND_ITEM_LOCAL,), self) + for typ, value in drag_data.items(): + stringval = repr((repr(value), typ)) + pasteboard.setString_forType_(stringval, MIRO_DND_ITEM_LOCAL) + return YES + + def calcType_(self, drag_info): + source_actions = drag_info.draggingSourceOperationMask() + if not (self.drag_dest and + (self.drag_dest.allowed_actions() | source_actions)): + return None + types = self.drag_dest.allowed_types() + available = drag_info.draggingPasteboard().availableTypeFromArray_( + (MIRO_DND_ITEM_LOCAL,)) + if available: + # XXX using eval() sucks. + data = eval(drag_info.draggingPasteboard().stringForType_( + MIRO_DND_ITEM_LOCAL)) + if data: + _, typ = data + return typ + return None + + def validateDrop_dragInfo_parentIter_position_(self, view, drag_info, + parent, position): + typ = self.calcType_(drag_info) + if typ: + wrapper = wrappermap.wrapper(view) + drop_action = self.drag_dest.validate_drop( + wrapper, self.model, typ, + drag_info.draggingSourceOperationMask(), parent, + position) + if not drop_action: + return NSDragOperationNone + if isinstance(drop_action, (tuple, list)): + drop_action, iter = drop_action + view.setDropRow_dropOperation_( + self.model.row_of_iter(view, iter), + NSTableViewDropOn) + return drop_action + else: + return NSDragOperationNone + + def acceptDrop_dragInfo_parentIter_position_(self, view, drag_info, + parent, position): + typ = self.calcType_(drag_info) + if typ: + # XXX using eval sucks. + data = eval(drag_info.draggingPasteboard().stringForType_(MIRO_DND_ITEM_LOCAL)) + ids, _ = data + ids = eval(ids) + wrapper = wrappermap.wrapper(view) + self.drag_dest.accept_drop(wrapper, self.model, typ, + drag_info.draggingSourceOperationMask(), parent, + position, ids) + return YES + else: + return NO + +class MiroTableViewDataSource(DataSourceBase, protocols.NSTableDataSource): + def numberOfRowsInTableView_(self, table_view): + return len(self.model) + + def tableView_objectValueForTableColumn_row_(self, table_view, column, row): + node = self.model.nth_iter(row).value() + self.model.remember_row_at_index(node, row) + return self.model.get_column_data(node.values, column) + + def tableView_writeRowsWithIndexes_toPasteboard_(self, tableview, rowIndexes, + pasteboard): + indexes = list_from_nsindexset(rowIndexes) + data = [self.model[self.model.nth_iter(i)] for i in indexes] + return self.view_writeColumnData_ToPasteboard_(tableview, data, + pasteboard) + + def translateRow_operation_(self, row, operation): + if operation == NSTableViewDropOn: + return self.model.nth_iter(row), -1 + else: + return None, row + + def tableView_validateDrop_proposedRow_proposedDropOperation_(self, + tableview, drag_info, row, operation): + parent, position = self.translateRow_operation_(row, operation) + drop_action = self.validateDrop_dragInfo_parentIter_position_(tableview, + drag_info, parent, position) + if isinstance(drop_action, (list, tuple)): + # XXX nothing uses this yet + drop_action, iter = drop_action + tableview.setDropRow_dropOperation_( + self.model.row_of_iter(tableview, iter), + NSTableViewDropOn) + return drop_action + + def tableView_acceptDrop_row_dropOperation_(self, + tableview, drag_info, row, operation): + parent, position = self.translateRow_operation_(row, operation) + return self.acceptDrop_dragInfo_parentIter_position_(tableview, + drag_info, parent, position) + + +class MiroOutlineViewDataSource(DataSourceBase, protocols.NSOutlineViewDataSource): + def outlineView_child_ofItem_(self, view, child, item): + if item is nil: + row_list = self.model.row_list + else: + row_list = item.children + return row_list.nth_iter(child).value() + + def outlineView_isItemExpandable_(self, view, item): + if item is not nil and hasattr(item, 'children'): + return len(item.children) > 0 + else: + return len(self.model) > 0 + + def outlineView_numberOfChildrenOfItem_(self, view, item): + if item is not nil and hasattr(item, 'children'): + return len(item.children) + else: + return len(self.model) + + def outlineView_objectValueForTableColumn_byItem_(self, view, column, + item): + return self.model.get_column_data(item.values, column) + + def outlineView_writeItems_toPasteboard_(self, outline_view, items, + pasteboard): + data = [i.values for i in items] + return self.view_writeColumnData_ToPasteboard_(outline_view, data, + pasteboard) + + def outlineView_validateDrop_proposedItem_proposedChildIndex_(self, + outlineview, drag_info, item, child_index): + if item is None: + iter = None + else: + iter = self.model.iter_for_item[item] + drop_action = self.validateDrop_dragInfo_parentIter_position_( + outlineview, drag_info, iter, child_index) + if isinstance(drop_action, (tuple, list)): + drop_action, iter = drop_action + outlineview.setDropItem_dropChildIndex_( + iter.value(), NSOutlineViewDropOnItemIndex) + return drop_action + + def outlineView_acceptDrop_item_childIndex_(self, outlineview, drag_info, + item, child_index): + if item is None: + iter = None + else: + iter = self.model.iter_for_item[item] + return self.acceptDrop_dragInfo_parentIter_position_(outlineview, + drag_info, iter, child_index) diff --git a/lvc/widgets/osx/tableview.py b/lvc/widgets/osx/tableview.py new file mode 100644 index 0000000..9f490d2 --- /dev/null +++ b/lvc/widgets/osx/tableview.py @@ -0,0 +1,1629 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".tableview -- TableView widget and it's +associated classes. +""" + +import math +import logging +from contextlib import contextmanager +from collections import namedtuple + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from lvc import signals +from lvc import errors +from lvc.widgets import widgetconst +from lvc.widgets.tableselection import SelectionOwnerMixin +from lvc.widgets.tablescroll import ScrollbarOwnerMixin +from .utils import filename_to_unicode +import wrappermap +import tablemodel +import osxmenus +from .base import Widget +from .simple import Image +from .drawing import DrawingContext, DrawingStyle, Gradient, ImageSurface +from .helpers import NotificationForwarder +from .layoutmanager import LayoutManager + +EXPANDER_PADDING = 6 +HEADER_HEIGHT = 17 +CUSTOM_HEADER_HEIGHT = 25 + +def iter_range(ns_range): + """Iterate over an NSRange object""" + return xrange(ns_range.location, ns_range.location + ns_range.length) + +Rect = namedtuple('Rect', 'x y width height') +def NSRectToRect(nsrect): + origin, size = nsrect.origin, nsrect.size + return Rect(origin.x, origin.y, size.width, size.height) + +Point = namedtuple('Point', 'x y') +def NSPointToPoint(nspoint): + return Point(int(nspoint.x), int(nspoint.y)) + +class HotspotTracker(object): + """Contains the info on the currently tracked hotspot. See: + https://develop.participatoryculture.org/index.php/WidgetAPITableView + """ + def __init__(self, tableview, point): + self.tableview = tableview + self.row = tableview.rowAtPoint_(point) + self.column = tableview.columnAtPoint_(point) + if self.row == -1 or self.column == -1: + self.hit = False + return + model = tableview.dataSource().model + self.iter = model.iter_for_row(tableview, self.row) + self.table_column = tableview.tableColumns()[self.column] + self.cell = self.table_column.dataCell() + self.update_position(point) + if isinstance(self.cell, CustomTableCell): + self.name = self.calc_hotspot() + else: + self.name = None + self.hit = (self.name is not None) + + def is_for_context_menu(self): + return self.name == '#show-context-menu' + + def calc_cell_hotspot(self, column, row): + if (self.hit and self.column == column and self.row == row): + return self.name + else: + return None + + def update_position(self, point): + cell_frame = self.tableview.frameOfCellAtColumn_row_(self.column, + self.row) + self.pos = NSPoint(point.x - cell_frame.origin.x, + point.y - cell_frame.origin.y) + + def update_hit(self): + old_hit = self.hit + self.hit = (self.calc_hotspot() == self.name) + if old_hit != self.hit: + self.redraw_cell() + + def set_cell_data(self): + model = self.tableview.dataSource().model + row = model[self.iter] + value_dict = model.get_column_data(row, self.table_column) + self.cell.setObjectValue_(value_dict) + self.cell.set_wrapper_data() + + def calc_hotspot(self): + self.set_cell_data() + cell_frame = self.tableview.frameOfCellAtColumn_row_(self.column, + self.row) + style = self.cell.make_drawing_style(cell_frame, self.tableview) + layout_manager = self.cell.layout_manager + layout_manager.reset() + return self.cell.wrapper.hotspot_test(style, layout_manager, + self.pos.x, self.pos.y, cell_frame.size.width, + cell_frame.size.height) + + def redraw_cell(self): + # Check to see if we removed the table in response to a hotspot click. + if self.tableview.superview() is not nil: + cell_frame = self.tableview.frameOfCellAtColumn_row_(self.column, + self.row) + self.tableview.setNeedsDisplayInRect_(cell_frame) + +def _calc_interior_frame(total_frame, tableview): + """Calculate the inner cell area for a table cell. + + We tell cocoa that the intercell spacing is (0, 0) and instead handle the + spacing ourselves. This method calculates the area that a cell should + render to, given the total spacing. + """ + return NSMakeRect(total_frame.origin.x + tableview.column_spacing // 2, + total_frame.origin.y + tableview.row_spacing // 2, + total_frame.size.width - tableview.column_spacing, + total_frame.size.height - tableview.row_spacing) + +class MiroTableCell(NSTextFieldCell): + def init(self): + return super(MiroTableCell, self).initTextCell_('') + + def calcHeight_(self, view): + font = self.font() + return math.ceil(font.ascender() + abs(font.descender()) + + font.leading()) + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def setObjectValue_(self, value_dict): + if isinstance(value_dict, dict): + NSCell.setObjectValue_(self, value_dict['value']) + else: + # OS X calls setObjectValue_('') on intialization + NSCell.setObjectValue_(self, value_dict) + + def drawInteriorWithFrame_inView_(self, frame, view): + return NSTextFieldCell.drawInteriorWithFrame_inView_(self, + _calc_interior_frame(frame, view), view) + +class MiroTableInfoListTextCell(MiroTableCell): + def initWithAttrGetter_(self, attr_getter): + self = self.init() + self.setWraps_(NO) + self.attr_getter = attr_getter + self._textColor = self.textColor() + return self + + def drawWithFrame_inView_(self, frame, view): + # adjust frame based on the cell spacing + frame = _calc_interior_frame(frame, view) + if (self.isHighlighted() and frame is not None and + (view.isDescendantOf_(view.window().firstResponder()) or + view.gradientHighlight) and view.window().isMainWindow()): + self.setTextColor_(NSColor.whiteColor()) + else: + self.setTextColor_(self._textColor) + return MiroTableCell.drawWithFrame_inView_(self, frame, view) + + def titleRectForBounds_(self, rect): + frame = MiroTableCell.titleRectForBounds_(self, rect) + text_size = self.attributedStringValue().size() + frame.origin.y = rect.origin.y + (rect.size.height - text_size.height) / 2.0 + return frame + + def drawInteriorWithFrame_inView_(self, frame, view): + rect = self.titleRectForBounds_(frame) + self.attributedStringValue().drawInRect_(rect) + + def setObjectValue_(self, value): + if isinstance(value, tuple): + info, attrs, group_info = value + cell_text = self.attr_getter(info) + NSCell.setObjectValue_(self, cell_text) + else: + # Getting set to a something other than a model row, usually this + # happens in initialization + NSCell.setObjectValue_(self, '') + +class MiroTableImageCell(NSImageCell): + def calcHeight_(self, view): + return self.value_dict['image'].size().height + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def setObjectValue_(self, value_dict): + NSImageCell.setObjectValue_(self, value_dict['image']) + + def drawInteriorWithFrame_inView_(self, frame, view): + return NSImageCell.drawInteriorWithFrame_inView_(self, + _calc_interior_frame(frame, view), view) + +class MiroCheckboxCell(NSButtonCell): + def init(self): + self = super(MiroCheckboxCell, self).init() + self.setButtonType_(NSSwitchButton) + self.setTitle_('') + return self + + def calcHeight_(self, view): + return self.cellSize().height + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def setObjectValue_(self, value_dict): + if isinstance(value_dict, dict): + NSButtonCell.setObjectValue_(self, value_dict['value']) + else: + # OS X calls setObjectValue_('') on intialization + NSCell.setObjectValue_(self, value_dict) + + def startTrackingAt_inView_(self, point, view): + return YES + + def continueTracking_at_inView_(self, lastPoint, at, view): + return YES + + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, tableview, mouseIsUp): + if mouseIsUp: + column = tableview.columnAtPoint_(at) + row = tableview.rowAtPoint_(at) + if column != -1 and row != -1: + wrapper = wrappermap.wrapper(tableview) + column = wrapper.columns[column] + itr = wrapper.model.iter_for_row(tableview, row) + column.renderer.emit('clicked', itr) + return NSButtonCell.stopTracking_at_inView_mouseIsUp_(self, lastPoint, + at, tableview, mouseIsUp) + + def drawInteriorWithFrame_inView_(self, frame, view): + return NSButtonCell.drawInteriorWithFrame_inView_(self, + _calc_interior_frame(frame, view), view) + +class CellRendererBase(object): + DRAW_BACKGROUND = True + + def set_index(self, index): + self.index = index + + def get_index(self): + return self.index + +class CellRenderer(CellRendererBase): + def __init__(self): + self.cell = self.build_cell() + self._font_scale_factor = 1.0 + self._font_bold = False + self.set_align('left') + + def build_cell(self): + return MiroTableCell.alloc().init() + + def setDataCell_(self, column): + column.setDataCell_(self.cell) + + def set_text_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self._font_scale_factor = 1.0 + elif size == widgetconst.SIZE_SMALL: + # make the scale factor such so that the font size is 11.0 + self._font_scale_factor = 11.0 / NSFont.systemFontSize() + else: + raise ValueError("Unknown size: %s" % size) + self._set_font() + + def set_font_scale(self, scale_factor): + self._font_scale_factor = scale_factor + self._set_font() + + def set_bold(self, bold): + self._font_bold = bold + self._set_font() + + def _set_font(self): + size = NSFont.systemFontSize() * self._font_scale_factor + if self._font_bold: + font = NSFont.boldSystemFontOfSize_(size) + else: + font = NSFont.systemFontOfSize_(size) + self.cell.setFont_(font) + + def set_color(self, color): + color = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], + color[1], color[2], 1.0) + self.cell._textColor = color + self.cell.setTextColor_(color) + + def set_align(self, align): + if align == 'left': + ns_alignment = NSLeftTextAlignment + elif align == 'center': + ns_alignment = NSCenterTextAlignment + elif align == 'right': + ns_alignment = NSRightTextAlignment + else: + raise ValueError("unknown alignment: %s", align) + self.cell.setAlignment_(ns_alignment) + +class ImageCellRenderer(CellRendererBase): + def setDataCell_(self, column): + column.setDataCell_(MiroTableImageCell.alloc().init()) + +class CheckboxCellRenderer(CellRendererBase, signals.SignalEmitter): + def __init__(self): + signals.SignalEmitter.__init__(self, 'clicked') + self.size = widgetconst.SIZE_NORMAL + + def set_control_size(self, size): + self.size = size + + def setDataCell_(self, column): + cell = MiroCheckboxCell.alloc().init() + if self.size == widgetconst.SIZE_SMALL: + cell.setControlSize_(NSSmallControlSize) + column.setDataCell_(cell) + +class CustomTableCell(NSCell): + def init(self): + self = super(CustomTableCell, self).init() + self.layout_manager = LayoutManager() + self.hotspot = None + self.default_drawing_style = DrawingStyle() + return self + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def calcHeight_(self, view): + self.layout_manager.reset() + self.set_wrapper_data() + cell_size = self.wrapper.get_size(self.default_drawing_style, + self.layout_manager) + return cell_size[1] + + def make_drawing_style(self, frame, view): + text_color = None + if (self.isHighlighted() and frame is not None and + (view.isDescendantOf_(view.window().firstResponder()) or + view.gradientHighlight) and view.window().isMainWindow()): + text_color = NSColor.whiteColor() + return DrawingStyle(text_color=text_color) + + def drawInteriorWithFrame_inView_(self, frame, view): + NSGraphicsContext.currentContext().saveGraphicsState() + if not self.wrapper.IGNORE_PADDING: + # adjust frame based on the cell spacing. We also have to adjust + # the hover position to account for the new frame + original_frame = frame + frame = _calc_interior_frame(frame, view) + hover_adjustment = (frame.origin.x - original_frame.origin.x, + frame.origin.y - original_frame.origin.y) + else: + hover_adjustment = (0, 0) + if self.wrapper.outline_column: + pad_left = EXPANDER_PADDING + else: + pad_left = 0 + drawing_rect = NSMakeRect(frame.origin.x + pad_left, frame.origin.y, + frame.size.width - pad_left, frame.size.height) + context = DrawingContext(view, drawing_rect, drawing_rect) + context.style = self.make_drawing_style(frame, view) + self.layout_manager.reset() + self.set_wrapper_data() + column = self.wrapper.get_index() + hover_pos = view.get_hover(self.row, column) + if hover_pos is not None: + hover_pos = [hover_pos[0] - hover_adjustment[0], + hover_pos[1] - hover_adjustment[1]] + self.wrapper.render(context, self.layout_manager, self.isHighlighted(), + self.hotspot, hover_pos) + NSGraphicsContext.currentContext().restoreGraphicsState() + + def setObjectValue_(self, value): + self.object_value = value + + def set_wrapper_data(self): + self.wrapper.__dict__.update(self.object_value) + +class CustomCellRenderer(CellRendererBase): + CellClass = CustomTableCell + + IGNORE_PADDING = False + + def __init__(self): + self.outline_column = False + self.index = None + + def setDataCell_(self, column): + # Note that the ownership is the opposite of what happens in widgets. + # The NSObject owns it's wrapper widget. This happens for a couple + # reasons: + # 1) The data cell gets copied a bunch of times, so wrappermap won't + # work with it. + # 2) The Wrapper should only needs to stay around as long as the + # NSCell that it's wrapping is around. Once the column gets removed + # from the table, the wrapper can be deleted. + nscell = self.CellClass.alloc().init() + nscell.wrapper = self + column.setDataCell_(nscell) + + def hotspot_test(self, style, layout, x, y, width, height): + return None + +class InfoListTableCell(CustomTableCell): + def set_wrapper_data(self): + self.wrapper.info, self.wrapper.attrs, self.wrapper.group_info = \ + self.object_value + +class InfoListRenderer(CustomCellRenderer): + CellClass = InfoListTableCell + + def hotspot_test(self, style, layout, x, y, width, height): + return None + +class InfoListRendererText(CellRenderer): + def build_cell(self): + cell = MiroTableInfoListTextCell.alloc() + return cell.initWithAttrGetter_(self.get_value) + +def calc_row_height(view, model_row): + max_height = 0 + model = view.dataSource().model + for column in view.tableColumns(): + cell = column.dataCell() + data = model.get_column_data(model_row, column) + cell.setObjectValue_(data) + cell_height = cell.calcHeight_(view) + max_height = max(max_height, cell_height) + if max_height == 0: + max_height = 12 + return max_height + view.row_spacing + +class TableViewDelegate(NSObject): + def tableView_willDisplayCell_forTableColumn_row_(self, view, cell, + column, row): + column = view.column_index_map[column] + cell.column = column + cell.row = row + if view.hotspot_tracker: + cell.hotspot = view.hotspot_tracker.calc_cell_hotspot(column, row) + else: + cell.hotspot = None + + def tableView_didClickTableColumn_(self, tableview, column): + wrapper = wrappermap.wrapper(tableview) + for column_wrapper in wrapper.columns: + if column_wrapper._column is column: + column_wrapper.emit('clicked') + + def tableView_toolTipForCell_rect_tableColumn_row_mouseLocation_(self, tableview, cell, rect, column, row, location): + wrapper = wrappermap.wrapper(tableview) + iter = tableview.dataSource().model.iter_for_row(tableview, row) + for wrapper_column in wrapper.columns: + if wrapper_column._column is column: + break + return (wrapper.get_tooltip(iter, wrapper_column), rect) + +class VariableHeightTableViewDelegate(TableViewDelegate): + def tableView_heightOfRow_(self, table_view, row): + model = table_view.dataSource().model + iter = model.iter_for_row(table_view, row) + if iter is None: + return 12 + return calc_row_height(table_view, model[iter]) + + +# TableViewCommon is a hack to do a Mixin class. We want the same behaviour +# for our table views and our outline views. Normally we would use a Mixin, +# but that doesn't work with pyobjc. Instead we define the common code in +# TableViewCommon, then copy it into MiroTableView and MiroOutlineView + +class TableViewCommon(object): + def init(self): + self = super(self.__class__, self).init() + self.hotspot_tracker = None + self._tracking_rects = [] + self.hover_info = None + self.column_index_map = {} + self.setFocusRingType_(NSFocusRingTypeNone) + self.handled_last_mouse_down = False + self.gradientHighlight = False + self.tracking_area = None + self.group_lines_enabled = False + self.group_line_width = 1 + self.group_line_color = (0, 0, 0, 1.0) + # we handle cell spacing manually + self.setIntercellSpacing_(NSSize(0, 0)) + self.column_spacing = 3 + self.row_spacing = 2 + return self + + def updateTrackingAreas(self): + # remove existing tracking area if needed + if self.tracking_area: + self.removeTrackingArea_(self.tracking_area) + + # create a new tracking area for the entire view. This allows us to + # get mouseMoved events whenever the mouse is inside our view. + self.tracking_area = NSTrackingArea.alloc() + self.tracking_area.initWithRect_options_owner_userInfo_( + self.visibleRect(), + NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | + NSTrackingActiveInKeyWindow, + self, + nil) + self.addTrackingArea_(self.tracking_area) + + def addTableColumn_(self, column): + index = len(self.tableColumns()) + column.set_index(index) + self.column_index_map[column._column] = index + self.SuperClass.addTableColumn_(self, column._column) + + def removeTableColumn_(self, column): + self.SuperClass.removeTableColumn_(self, column) + removed = self.column_index_map.pop(column) + for key, index in self.column_index_map.items(): + if index > removed: + self.column_index_map[key] -= 1 + + def moveColumn_toColumn_(self, src, dest): + # Need to switch the TableColumn objects too + columns = wrappermap.wrapper(self).columns + columns[src], columns[dest] = columns[dest], columns[src] + for index, column in enumerate(columns): + column.set_index(index) + self.SuperClass.moveColumn_toColumn_(self, src, dest) + + def highlightSelectionInClipRect_(self, rect): + if wrappermap.wrapper(self).draws_selection: + if not self.gradientHighlight: + return self.SuperClass.highlightSelectionInClipRect_(self, + rect) + context = NSGraphicsContext.currentContext() + focused = self.isDescendantOf_(self.window().firstResponder()) + for row in tablemodel.list_from_nsindexset(self.selectedRowIndexes()): + self.drawBackgroundGradient(context, focused, row) + + def setFrameSize_(self, size): + if size.height == 0: + size.height = 4 + self.SuperClass.setFrameSize_(self, size) + + def drawBackgroundGradient(self, context, focused, row): + widget = wrappermap.wrapper(self) + window = widget.get_window() + if window and window.is_active(): + if focused: + start_color = (0.588, 0.717, 0.843) + end_color = (0.416, 0.568, 0.713) + top_line_color = (0.416, 0.569, 0.714, 1.0) + bottom_line_color = (0.416, 0.569, 0.714, 1.0) + else: + start_color = (168 / 255.0, 188 / 255.0, 208 / 255.0) + end_color = (129 / 255.0, 152 / 255.0, 176 / 255.0) + top_line_color = (129 / 255.0, 152 / 255.0, 175 / 255.0, 1.0) + bottom_line_color = (0.416, 0.569, 0.714, 1.0) + else: + start_color = (0.675, 0.722, 0.765) + end_color = (0.592, 0.659, 0.710) + top_line_color = (0.596, 0.635, 0.671, 1.0) + bottom_line_color = (0.522, 0.576, 0.620, 1.0) + + rect = self.rectOfRow_(row) + top = NSMakeRect(rect.origin.x, rect.origin.y, rect.size.width, 1) + context.saveGraphicsState() + # draw the top line + NSColor.colorWithDeviceRed_green_blue_alpha_(*top_line_color).set() + NSRectFill(top) + bottom = NSMakeRect(rect.origin.x, rect.origin.y + rect.size.height - 2, + rect.size.width, 1) + NSColor.colorWithDeviceRed_green_blue_alpha_(*bottom_line_color).set() + NSRectFill(bottom) + highlight = NSMakeRect(rect.origin.x, rect.origin.y + rect.size.height - 1, + rect.size.width, 1) + NSColor.colorWithDeviceRed_green_blue_alpha_(0.918, 0.925, 0.941, 1.0).set() + NSRectFill(highlight) + + # draw the gradient + rect.origin.y += 1 + rect.size.height -= 3 + NSRectClip(rect) + gradient = Gradient(rect.origin.x, rect.origin.y, + rect.origin.x, rect.origin.y + rect.size.height) + gradient.set_start_color(start_color) + gradient.set_end_color(end_color) + gradient.draw() + context.restoreGraphicsState() + + def drawBackgroundInClipRect_(self, clip_rect): + # save our graphics state, since we are about to modify the clip path + graphics_context = NSGraphicsContext.currentContext() + graphics_context.saveGraphicsState() + # create a NSBezierPath that contains the rects of the columns with + # DRAW_BACKGROUND True. + clip_path = NSBezierPath.bezierPath() + number_of_columns = len(self.tableColumns()) + for col_index in iter_range(self.columnsInRect_(clip_rect)): + column = wrappermap.wrapper(self.tableColumns()[col_index]) + column_rect = None + if column.renderer.DRAW_BACKGROUND: + # We should draw the background for this column, add it's rect + # to our clip rect. + column_rect = self.rectOfColumn_(col_index) + clip_path.appendBezierPathWithRect_(column_rect) + else: + # We shouldn't draw the background for this column. Don't add + # anything to the clip rect, but do draw the area before the + # first row and after the last row. + self.drawBackgroundOutsideContent_clipRect_(col_index, + clip_rect) + if col_index == number_of_columns - 1: # last column + if not column_rect: + column_rect = self.rectOfColumn_(col_index) + column_right = column_rect.origin.x + column_rect.size.width + clip_right = clip_rect.origin.x + clip_rect.size.width + if column_right < clip_right: + # there's space to the right, so add that to the clip_rect + remaining = clip_right - column_right + left_rect = NSMakeRect(column_right, clip_rect.origin.y, + remaining, clip_rect.size.height) + clip_path.appendBezierPathWithRect_(left_rect) + # clip to that path + clip_path.addClip() + # do the default drawing + self.SuperClass.drawBackgroundInClipRect_(self, clip_rect) + # restore graphics state + graphics_context.restoreGraphicsState() + + def drawBackgroundOutsideContent_clipRect_(self, index, clip_rect): + """Draw our background outside the rows with content + + We call this for cells with DRAW_BACKGROUND set to False. For those, + we let the cell draw their own background, but we still need to draw + the background before the first cell and after the last cell. + """ + + self.backgroundColor().set() + + total_rect = NSIntersectionRect(self.rectOfColumn_(index), clip_rect) + + if self.numberOfRows() == 0: + # if no rows are selected, draw the background over everything + NSRectFill(total_rect) + return + + # fill the area above the first row + first_row_rect = self.rectOfRow_(0) + if first_row_rect.origin.y > total_rect.origin.y: + height = first_row_rect.origin.y - total_rect.origin.y + NSRectFill(NSMakeRect(total_rect.origin.x, total_rect.origin.y, + total_rect.size.width, height)) + + # fill the area below the last row + last_row_rect = self.rectOfRow_(self.numberOfRows()-1) + if NSMaxY(last_row_rect) < NSMaxY(total_rect): + y = NSMaxY(last_row_rect) + 1 + height = NSMaxY(total_rect) - NSMaxY(last_row_rect) + NSRectFill(NSMakeRect(total_rect.origin.x, y, + total_rect.size.width, height)) + + def drawRow_clipRect_(self, row, clip_rect): + self.SuperClass.drawRow_clipRect_(self, row, clip_rect) + if self.group_lines_enabled: + self.drawGroupLine_(row) + + def drawGroupLine_(self, row): + infolist = wrappermap.wrapper(self).model + if (not isinstance(infolist, tablemodel.InfoListModel) or + infolist.get_grouping() is None): + return + + info, attrs, group_info = infolist[row] + if group_info[0] == group_info[1] - 1: + rect = self.rectOfRow_(row) + rect.origin.y = NSMaxY(rect) - self.group_line_width + rect.size.height = self.group_line_width + NSColor.colorWithDeviceRed_green_blue_alpha_( + *self.group_line_color).set() + NSRectFill(rect) + + def canDragRowsWithIndexes_atPoint_(self, indexes, point): + return YES + + def draggingSourceOperationMaskForLocal_(self, local): + drag_source = wrappermap.wrapper(self).drag_source + if drag_source and local: + return drag_source.allowed_actions() + return NSDragOperationNone + + def mouseMoved_(self, event): + location = self.convertPoint_fromView_(event.locationInWindow(), nil) + row = self.rowAtPoint_(location) + column = self.columnAtPoint_(location) + if (self.hover_info is not None and self.hover_info != (row, column)): + # left a cell, redraw it the old one + rect = self.frameOfCellAtColumn_row_(self.hover_info[1], + self.hover_info[0]) + self.setNeedsDisplayInRect_(rect) + if row == -1 or column == -1: + # corner case: we got a mouseMoved_ event, but the pointer is + # outside the view + self.hover_pos = self.hover_info = None + return + # queue a redraw on the cell currently hovered over + rect = self.frameOfCellAtColumn_row_(column, row) + self.setNeedsDisplayInRect_(rect) + # recalculate hover_pos and hover_info + self.hover_pos = (location[0] - rect[0][0], + location[0] - rect[0][1]) + self.hover_info = (row, column) + + def mouseExited_(self, event): + if self.hover_info: + # mouse left our window, unset hover and redraw the cell that the + # mouse was in + rect = self.frameOfCellAtColumn_row_(self.hover_info[1], + self.hover_info[0]) + self.setNeedsDisplayInRect_(rect) + self.hover_pos = self.hover_info = None + + def get_hover(self, row, column): + if self.hover_info == (row, column): + return self.hover_pos + else: + return None + + def mouseDown_(self, event): + if event.modifierFlags() & NSControlKeyMask: + self.handleContextMenu_(event) + self.handled_last_mouse_down = True + return + + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + + if event.clickCount() == 2: + if self.handled_last_mouse_down: + return + wrapper = wrappermap.wrapper(self) + row = self.rowAtPoint_(point) + if (row != -1 and self.point_should_click(point, row)): + iter = wrapper.model.iter_for_row(self, row) + wrapper.emit('row-activated', iter) + return + + # Like clickCount() == 2 but keep running so we can get to run the + # hotspot tracker et al. + if event.clickCount() == 1: + wrapper = wrappermap.wrapper(self) + row = self.rowAtPoint_(point) + if (row != -1 and self.point_should_click(point, row)): + + iter = wrapper.model.iter_for_row(self, row) + wrapper.emit('row-clicked', iter) + + hotspot_tracker = HotspotTracker(self, point) + if hotspot_tracker.hit: + self.hotspot_tracker = hotspot_tracker + self.hotspot_tracker.redraw_cell() + self.handled_last_mouse_down = True + if hotspot_tracker.is_for_context_menu(): + self.popup_context_menu(self.hotspot_tracker.row, event) + # once we're out of that call, we know the context menu is + # gone + hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + else: + self.handled_last_mouse_down = False + self.SuperClass.mouseDown_(self, event) + + def point_should_click(self, point, row): + """Should a click on a point result in a row-clicked signal? + + Subclasses can override if not every point should result in a click. + """ + return True + + def rightMouseDown_(self, event): + self.handleContextMenu_(event) + + def handleContextMenu_(self, event): + self.window().makeFirstResponder_(self) + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + column = self.columnAtPoint_(point) + row = self.rowAtPoint_(point) + if self.group_lines_enabled and column == 0: + self.selectAllItemsInGroupForRow_(row) + self.popup_context_menu(row, event) + + def selectAllItemsInGroupForRow_(self, row): + wrapper = wrappermap.wrapper(self) + infolist = wrapper.model + if (not isinstance(infolist, tablemodel.InfoListModel) or + infolist.get_grouping() is None): + return + + info, attrs, group_info = infolist[row] + select_range = NSMakeRange(row - group_info[0], group_info[1]) + index_set = NSIndexSet.indexSetWithIndexesInRange_(select_range) + self.selectRowIndexes_byExtendingSelection_(index_set, NO) + + def popup_context_menu(self, row, event): + selection = self.selectedRowIndexes() + if row != -1 and not selection.containsIndex_(row): + index_set = NSIndexSet.alloc().initWithIndex_(row) + self.selectRowIndexes_byExtendingSelection_(index_set, NO) + wrapper = wrappermap.wrapper(self) + if wrapper.context_menu_callback is not None: + menu_items = wrapper.context_menu_callback(wrapper) + menu = osxmenus.make_context_menu(menu_items) + NSMenu.popUpContextMenu_withEvent_forView_(menu, event, self) + + def mouseDragged_(self, event): + if self.hotspot_tracker is not None: + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + self.hotspot_tracker.update_position(point) + self.hotspot_tracker.update_hit() + else: + self.SuperClass.mouseDragged_(self, event) + + def mouseUp_(self, event): + if self.hotspot_tracker is not None: + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + self.hotspot_tracker.update_position(point) + self.hotspot_tracker.update_hit() + if self.hotspot_tracker.hit: + wrappermap.wrapper(self).send_hotspot_clicked() + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + else: + self.SuperClass.mouseUp_(self, event) + + def keyDown_(self, event): + mods = osxmenus.translate_event_modifiers(event) + if event.charactersIgnoringModifiers() == ' ' and len(mods) == 0: + # handle spacebar with no modifiers by sending the row-activated + # signal + wrapper = wrappermap.wrapper(self) + row = self.selectedRow() + if row >= 0: + iter = wrapper.model.iter_for_row(self, row) + wrapper.emit('row-activated', iter) + else: + self.SuperClass.keyDown_(self, event) + +class TableColumn(signals.SignalEmitter): + def __init__(self, title, renderer, header=None, **attrs): + signals.SignalEmitter.__init__(self) + self.create_signal('clicked') + self._column = MiroTableColumn.alloc().initWithIdentifier_(title) + self._column.set_attrs(attrs) + wrappermap.add(self._column, self) + header_cell = MiroTableHeaderCell.alloc().init() + self.custom_header = False + if header: + header_cell.set_widget(header) + self.custom_header = True + self._column.setHeaderCell_(header_cell) + self._column.headerCell().setStringValue_(title) + self._column.setEditable_(NO) + self._column.setResizingMask_(NSTableColumnNoResizing) + self.renderer = renderer + self.sort_order_ascending = True + self.sort_indicator_visible = False + self.do_horizontal_padding = True + self.min_width = self.max_width = None + renderer.setDataCell_(self._column) + + def set_do_horizontal_padding(self, horizontal_padding): + self.do_horizontal_padding = horizontal_padding + + def set_right_aligned(self, right_aligned): + if right_aligned: + self._column.headerCell().setAlignment_(NSRightTextAlignment) + else: + self._column.headerCell().setAlignment_(NSLeftTextAlignment) + + def set_min_width(self, width): + self.min_width = width + + def set_max_width(self, width): + self.max_width = width + + def set_width(self, width): + self._column.setWidth_(width) + + def get_width(self): + return self._column.width() + + def set_resizable(self, resizable): + mask = 0 + if resizable: + mask |= NSTableColumnUserResizingMask + self._column.setResizingMask_(mask) + + def set_sort_indicator_visible(self, visible): + self.sort_indicator_visible = visible + self._column.tableView().headerView().setNeedsDisplay_(True) + + def get_sort_indicator_visible(self): + return self.sort_indicator_visible + + def set_sort_order(self, ascending): + self.sort_order_ascending = ascending + self._column.tableView().headerView().setNeedsDisplay_(True) + + def get_sort_order_ascending(self): + return self.sort_order_ascending + + def set_index(self, index): + self.index = index + self.renderer.set_index(index) + +class MiroTableColumn(NSTableColumn): + def set_attrs(self, attrs): + self._attrs = attrs + + def attrs(self): + return self._attrs + +class MiroTableView(NSTableView): + SuperClass = NSTableView + for name, value in TableViewCommon.__dict__.items(): + locals()[name] = value + +class MiroTableHeaderView(NSTableHeaderView): + def initWithFrame_(self, frame): + # frame is not used + self = super(MiroTableHeaderView, self).initWithFrame_(frame) + self.selected = None + self.custom_header = False + return self + + def drawRect_(self, rect): + wrapper = wrappermap.wrapper(self.tableView()) + if self.selected: + self.selected.set_selected(False) + for column in wrapper.columns: + if column.sort_indicator_visible: + self.selected = column._column.headerCell() + self.selected.set_selected(True) + self.selected.set_ascending(column.sort_order_ascending) + break + NSTableHeaderView.drawRect_(self, rect) + if self.custom_header: + NSGraphicsContext.currentContext().saveGraphicsState() + # Draw the separator between the header and the contents. + context = DrawingContext(self, rect, rect) + context.set_line_width(1) + context.set_color((2 / 255.0, 2 / 255.0, 2 / 255.0)) + context.move_to(0, context.height - 0.5) + context.rel_line_to(context.width, 0) + context.stroke() + NSGraphicsContext.currentContext().restoreGraphicsState() + +class MiroTableHeaderCell(NSTableHeaderCell): + def init(self): + self = super(MiroTableHeaderCell, self).init() + self.layout_manager = LayoutManager() + self.button = None + return self + + def set_selected(self, selected): + self.button._enabled = selected + + def set_ascending(self, ascending): + self.button._ascending = ascending + + def set_widget(self, widget): + self.button = widget + + def drawWithFrame_inView_(self, frame, view): + if self.button is None: + # use the default behavior when set_widget hasn't been called + return NSTableHeaderCell.drawWithFrame_inView_(self, frame, view) + + NSGraphicsContext.currentContext().saveGraphicsState() + drawing_rect = NSMakeRect(frame.origin.x, frame.origin.y, + frame.size.width, frame.size.height) + context = DrawingContext(view, drawing_rect, drawing_rect) + context.style = self.make_drawing_style(frame, view) + self.layout_manager.reset() + columns = wrappermap.wrapper(view.tableView()).columns + header_cells = [c._column.headerCell() for c in columns] + background_only = not self in header_cells + self.button.draw(context, self.layout_manager, background_only) + NSGraphicsContext.currentContext().restoreGraphicsState() + + def make_drawing_style(self, frame, view): + text_color = None + if (self.isHighlighted() and frame is not None and + (view.isDescendantOf_(view.window().firstResponder()) or + view.gradientHighlight)): + text_color = NSColor.whiteColor() + return DrawingStyle(text_color=text_color) + +class CocoaSelectionOwnerMixin(SelectionOwnerMixin): + """Cocoa-specific methods for selection management. + + This subclass should not define any behavior. Methods that cannot be + completed in this widget state should raise WidgetActionError. + """ + + def _set_allow_multiple_select(self, allow): + self.tableview.setAllowsMultipleSelection_(allow) + + def _get_allow_multiple_select(self): + return self.tableview.allowsMultipleSelection() + + def _get_selected_iters(self): + selection = self.tableview.selectedRowIndexes() + selrows = tablemodel.list_from_nsindexset(selection) + return [self.model.iter_for_row(self.tableview, row) for row in selrows] + + def _get_selected_iter(self): + row = self.tableview.selectedRow() + if row == -1: + return None + return self.model.iter_for_row(self.tableview, row) + + def _get_selected_rows(self): + return [self.model[i] for i in self._get_selected_iters()] + + @property + def num_rows_selected(self): + return self.tableview.numberOfSelectedRows() + + def _is_selected(self, iter_): + row = self.row_of_iter(iter_) + return self.tableview.isRowSelected_(row) + + def _select(self, iter_): + row = self.row_of_iter(iter_) + index_set = NSIndexSet.alloc().initWithIndex_(row) + self.tableview.selectRowIndexes_byExtendingSelection_(index_set, YES) + + def _unselect(self, iter_): + self.tableview.deselectRow_(self.row_of_iter(iter_)) + + def _unselect_all(self): + self.tableview.deselectAll_(nil) + + def _iter_to_string(self, iter_): + return unicode(self.model.row_of_iter(self.tableview, iter_)) + + def _iter_from_string(self, row): + return self.model.iter_for_row(self.tableview, int(row)) + +class CocoaScrollbarOwnerMixin(ScrollbarOwnerMixin): + """Manages a TableView's scroll position.""" + def __init__(self): + ScrollbarOwnerMixin.__init__(self, _work_around_17153=True) + self.connect('place-in-scroller', self.on_place_in_scroller) + self.scroll_position = (0, 0) + self.clipview_notifications = None + self._position_set = False + + def _set_scroll_position(self, scroll_to): + """Restore a saved scroll position.""" + self.scroll_position = scroll_to + try: + scroller = self._scroller + except errors.WidgetNotReadyError: + return + self._position_set = True + clipview = scroller.contentView() + if not self.clipview_notifications: + self.clipview_notifications = NotificationForwarder.create(clipview) + # NOTE: intentional changes are BoundsChanged; bad changes are + # FrameChanged + clipview.setPostsFrameChangedNotifications_(YES) + self.clipview_notifications.connect(self.on_scroll_changed, + 'NSViewFrameDidChangeNotification') + # NOTE: scrollPoint_ just scrolls the point into view; we want to + # scroll the view so that the point becomes the origin + size = self.tableview.visibleRect().size + size = (size.width, size.height) + rect = NSMakeRect(scroll_to[0], scroll_to[1], size[0], size[1]) + self.tableview.scrollRectToVisible_(rect) + + def on_place_in_scroller(self, scroller): + # workaround for 17153.1 + if not self._position_set: + self._set_scroll_position(self.scroll_position) + + @property + def _manually_scrolled(self): + """Return whether the view has been scrolled explicitly by the user + since the last time it was set automatically. Ignores X coords. + """ + auto_y = self.scroll_position[1] + real_y = self.get_scroll_position()[1] + return abs(auto_y - real_y) > 5 + + def _get_item_area(self, iter_): + rect = self.tableview.rectOfRow_(self.row_of_iter(iter_)) + return NSRectToRect(rect) + + def _get_visible_area(self): + return NSRectToRect(self._scroller.contentView().documentVisibleRect()) + + def _get_scroll_position(self): + point = self._scroller.contentView().documentVisibleRect().origin + return NSPointToPoint(point) + + def on_scroll_changed(self, notification): + # we get this notification when the scroll position has been reset (when + # it should not have been); put it back + self.set_scroll_position(self.scroll_position) + # this notification also serves as the Cocoa equivalent to + # on_scroll_range_changed, which tells super when we may be ready to + # scroll to an iter + self.emit('scroll-range-changed') + + def set_scroller(self, scroller): + """For GTK; Cocoa tableview knows its enclosingScrollView""" + + @property + def _scroller(self): + """Return an NSScrollView or raise WidgetNotReadyError""" + scroller = self.tableview.enclosingScrollView() + if not scroller: + raise errors.WidgetNotReadyError('enclosingScrollView') + return scroller + +class SorterPadding(NSView): + # Why is this a Mac only widget? Because the wrappermap mechanism requires + # us to layout the widgets (so that we may call back to the portable API + # hooks of the widget. Since we only set the view component, this fake + # widget is never placed so the wrappermap mechanism fails to work. + # + # So far, this is okay because only the Mac uses custom headers. + def init(self): + self = super(SorterPadding, self).init() + image = Image(resources.path('images/headertoolbar.png')) + self.image = ImageSurface(image) + return self + + def isFlipped(self): + return YES + + def drawRect_(self, rect): + context = DrawingContext(self, self.bounds(), rect) + context.style = DrawingStyle() + self.image.draw(context, 0, 0, context.width, context.height) + # XXX this color doesn't take into account enable/disabled state + # of the sorting widgets. + edge = 72.0 / 255 + context.set_color((edge, edge, edge)) + context.set_line_width(1) + context.move_to(0.5, 0) + context.rel_line_to(0, context.height) + context.stroke() + +class TableView(CocoaSelectionOwnerMixin, CocoaScrollbarOwnerMixin, Widget): + """Displays data as a tabular list. TableView follows the GTK TreeView + widget fairly closely. + """ + + CREATES_VIEW = False + # Bit of a hack. We create several views. By setting CREATES_VIEW to + # False, we get to position the views manually. + + draws_selection = True + + def __init__(self, model, custom_headers=False): + Widget.__init__(self) + CocoaSelectionOwnerMixin.__init__(self) + CocoaScrollbarOwnerMixin.__init__(self) + self.create_signal('hotspot-clicked') + self.create_signal('row-clicked') + self.create_signal('row-activated') + self.create_signal('reallocate-columns') + self.model = model + self.columns = [] + self.drag_source = self.drag_dest = None + self.context_menu_callback = None + self.tableview = MiroTableView.alloc().init() + self.data_source = tablemodel.MiroTableViewDataSource.alloc() + types = (tablemodel.MIRO_DND_ITEM_LOCAL,) + self.tableview.registerForDraggedTypes_(types) + self.view = self.tableview + self.data_source.initWithModel_(self.model) + self.tableview.setDataSource_(self.data_source) + self.tableview.setVerticalMotionCanBeginDrag_(YES) + self.set_columns_draggable(False) + self.set_auto_resizes(False) + self.row_height_set = False + self.set_fixed_height(False) + self.auto_resizing = False + self.header_view = MiroTableHeaderView.alloc().initWithFrame_( + NSMakeRect(0, 0, 0, 0)) + self.tableview.setCornerView_(None) + self.custom_header = False + self.header_height = HEADER_HEIGHT + self.set_show_headers(True) + self.notifications = NotificationForwarder.create(self.tableview) + self.model_signal_ids = [ + self.model.connect_weak('row-changed', self.on_row_change), + self.model.connect_weak('structure-will-change', + self.on_model_structure_change), + ] + self.iters_to_update = [] + self.height_changed = self.reload_needed = False + self.old_selection = None + self._resizing = False + if custom_headers: + self._enable_custom_headers() + + def unset_model(self): + for signal_id in self.model_signal_ids: + self.model.disconnect(signal_id) + self.model = None + self.tableview.setDataSource_(None) + self.data_source = None + + def _enable_custom_headers(self): + self.custom_header = True + self.header_height = CUSTOM_HEADER_HEIGHT + self.header_view.custom_header = True + self.tableview.setCornerView_(SorterPadding.alloc().init()) + + def enable_album_view_focus_hack(self): + # this only matters on GTK + pass + + def focus(self): + if self.tableview.window() is not None: + self.tableview.window().makeFirstResponder_(self.tableview) + + def send_hotspot_clicked(self): + tracker = self.tableview.hotspot_tracker + self.emit('hotspot-clicked', tracker.name, tracker.iter) + + def on_row_change(self, model, iter): + self.iters_to_update.append(iter) + if not self.fixed_height: + self.height_changed = True + if self.tableview.hotspot_tracker is not None: + self.tableview.hotspot_tracker.update_hit() + + def on_model_structure_change(self, model): + self.will_need_reload() + self.cancel_hotspot_track() + + def will_need_reload(self): + if not self.reload_needed: + self.reload_needed = True + self.old_selection = self._get_selected_rows() + + def cancel_hotspot_track(self): + if self.tableview.hotspot_tracker is not None: + self.tableview.hotspot_tracker.redraw_cell() + self.tableview.hotspot_tracker = None + + def on_expanded(self, notification): + self.invalidate_size_request() + item = notification.userInfo()['NSObject'] + iter_ = self.model.iter_for_item[item] + self.emit('row-expanded', iter_, self.model.get_path(iter_)) + + def on_collapsed(self, notification): + self.invalidate_size_request() + item = notification.userInfo()['NSObject'] + iter_ = self.model.iter_for_item[item] + self.emit('row-collapsed', iter_, self.model.get_path(iter_)) + + def on_column_resize(self, notification): + if self.auto_resizing or self._resizing: + return + self._resizing = True + try: + column = notification.userInfo()['NSTableColumn'] + label = column.headerCell().stringValue() + self.emit('reallocate-columns', {label: column.width()}) + finally: + self._resizing = False + + def is_tree(self): + return isinstance(self.model, tablemodel.TreeTableModel) + + def set_row_expanded(self, iter, expanded): + """Expand or collapse the specified row. Succeeds or raises + WidgetActionError. + """ + item = iter.value() + if expanded: + self.tableview.expandItem_(item) + else: + self.tableview.collapseItem_(item) + if self.tableview.isItemExpanded_(item) != expanded: + raise errors.WidgetActionError( + "cannot expand iter. expandable: %r" % ( + self.tableview.isExpandable_(item),)) + self.invalidate_size_request() + + def is_row_expanded(self, iter): + return self.tableview.isItemExpanded_(iter.value()) + + def calc_size_request(self): + self.tableview.tile() + height = self.tableview.frame().size.height + if self._show_headers: + height += self.header_height + return self.calc_width(), height + + def viewport_repositioned(self): + self._do_layout() + + def viewport_created(self): + wrappermap.add(self.tableview, self) + self._do_layout() + self._add_views() + self.notifications.connect(self.on_selection_changed, + 'NSTableViewSelectionDidChangeNotification') + self.notifications.connect(self.on_column_resize, + 'NSTableViewColumnDidResizeNotification') + # scroll has been unset + self._position_set = False + + def remove_viewport(self): + if self.viewport is not None: + self._remove_views() + wrappermap.remove(self.tableview) + self.notifications.disconnect() + self.viewport = None + if self.clipview_notifications: + self.clipview_notifications.disconnect() + self.clipview_notifications = None + + def _should_place_header_view(self): + return self._show_headers and not self.parent_is_scroller + + def _add_views(self): + self.viewport.view.addSubview_(self.tableview) + if self._should_place_header_view(): + self.viewport.view.addSubview_(self.header_view) + + def _remove_views(self): + self.tableview.removeFromSuperview() + self.header_view.removeFromSuperview() + + def _do_layout(self): + x = self.viewport.placement.origin.x + y = self.viewport.placement.origin.y + width = self.viewport.get_width() + height = self.viewport.get_height() + if self._should_place_header_view(): + self.header_view.setFrame_(NSMakeRect(x, y, + width, self.header_height)) + self.tableview.setFrame_(NSMakeRect(x, y + self.header_height, + width, height - self.header_height)) + else: + self.header_view.setFrame_(NSMakeRect(x, y, + width, self.header_height)) + self.tableview.setFrame_(NSMakeRect(x, y, width, height)) + + if self.auto_resize: + self.auto_resizing = True + # ListView sizes itself in do_size_allocated; + # this is necessary for tablist and StandardView + columns = self.tableview.tableColumns() + if len(columns) == 1: + columns[0].setWidth_(self.viewport.area().size.width) + self.auto_resizing = False + self.queue_redraw() + + def calc_width(self): + if self.column_count() == 0: + return 0 + width = 0 + columns = self.tableview.tableColumns() + if self.auto_resize: + # Table auto-resizes, we can shrink to min-width for each column + width = sum(column.minWidth() for column in columns) + width += self.tableview.column_spacing * self.column_count() + else: + # Table doesn't auto-resize, the columns can't get smaller than + # their current width + width = sum(column.width() for column in columns) + return width + + def start_bulk_change(self): + # stop our model from emitting signals, which is slow if we're + # adding/removing/changing a bunch of rows. Instead, just reload the + # model afterwards. + self.will_need_reload() + self.cancel_hotspot_track() + self.model.freeze_signals() + + def model_changed(self): + if not self.row_height_set and self.fixed_height: + self.try_to_set_row_height() + self.model.thaw_signals() + size_changed = False + if self.reload_needed: + self.tableview.reloadData() + new_selection = self._get_selected_rows() + if new_selection != self.old_selection: + self.on_selection_changed(self.tableview) + self.old_selection = None + size_changed = True + elif self.iters_to_update: + if self.fixed_height or not self.height_changed: + # our rows don't change height, just update cell areas + if self.is_tree(): + for iter in self.iters_to_update: + self.tableview.reloadItem_(iter.value()) + else: + for iter in self.iters_to_update: + row = self.row_of_iter(iter) + rect = self.tableview.rectOfRow_(row) + self.tableview.setNeedsDisplayInRect_(rect) + else: + # our rows can change height inform Cocoa that their heights + # might have changed (this will redraw them) + index_set = NSMutableIndexSet.alloc().init() + for iter in self.iters_to_update: + try: + index_set.addIndex_(self.row_of_iter(iter)) + except LookupError: + # This happens when the iter's parent is unexpanded, + # just ignore. + pass + self.tableview.noteHeightOfRowsWithIndexesChanged_(index_set) + size_changed = True + else: + return + if size_changed: + self.invalidate_size_request() + self.height_changed = self.reload_needed = False + self.iters_to_update = [] + + def width_for_columns(self, width): + """If the table is width pixels big, how much width is available for + the table's columns. + """ + # XXX this used to do some calculation with the spacing of each column, + # but it doesn't appear like we need it to be that complicated anymore + # (see #18273) + return width - 2 + + def set_column_spacing(self, column_spacing): + self.tableview.column_spacing = column_spacing + + def set_row_spacing(self, row_spacing): + self.tableview.row_spacing = row_spacing + + def set_alternate_row_backgrounds(self, setting): + self.tableview.setUsesAlternatingRowBackgroundColors_(setting) + + def set_grid_lines(self, horizontal, vertical): + mask = 0 + if horizontal: + mask |= NSTableViewSolidHorizontalGridLineMask + if vertical: + mask |= NSTableViewSolidVerticalGridLineMask + self.tableview.setGridStyleMask_(mask) + + def set_gradient_highlight(self, setting): + self.tableview.gradientHighlight = setting + + def set_group_lines_enabled(self, enabled): + self.tableview.group_lines_enabled = enabled + self.queue_redraw() + + def set_group_line_style(self, color, width): + self.tableview.group_line_color = color + (1.0,) + self.tableview.group_line_width = width + self.queue_redraw() + + def get_tooltip(self, iter, column): + return None + + def add_column(self, column): + if not self.custom_header == column.custom_header: + raise ValueError('Column header does not match type ' + 'required by TableView') + self.columns.append(column) + self.tableview.addTableColumn_(column) + self._set_min_max_column_widths(column) + # Adding a column means that each row could have a different height. + # call noteNumberOfRowsChanged() to have OS X recalculate the heights + self.tableview.noteNumberOfRowsChanged() + self.invalidate_size_request() + self.try_to_set_row_height() + + def _set_min_max_column_widths(self, column): + if column.do_horizontal_padding: + spacing = self.tableview.column_spacing + else: + spacing = 0 + if column.min_width > 0: + column._column.setMinWidth_(column.min_width + spacing) + if column.max_width > 0: + column._column.setMaxWidth_(column.max_width + spacing) + + def column_count(self): + return len(self.tableview.tableColumns()) + + def remove_column(self, index): + column = self.columns.pop(index) + self.tableview.removeTableColumn_(column._column) + self.invalidate_size_request() + + def get_columns(self): + titles = [] + columns = self.tableview.tableColumns() + for column in columns: + titles.append(column.headerCell().stringValue()) + return titles + + def set_background_color(self, (red, green, blue)): + color = NSColor.colorWithDeviceRed_green_blue_alpha_(red, green, blue, + 1.0) + self.tableview.setBackgroundColor_(color) + + def set_show_headers(self, show): + self._show_headers = show + if show: + self.tableview.setHeaderView_(self.header_view) + else: + self.tableview.setHeaderView_(None) + if self.viewport is not None: + self._remove_views() + self._do_layout() + self._add_views() + self.invalidate_size_request() + self.queue_redraw() + + def is_showing_headers(self): + return self._show_headers + + def set_search_column(self, model_index): + pass + + def try_to_set_row_height(self): + if len(self.model) > 0: + first_iter = self.model.first_iter() + height = calc_row_height(self.tableview, self.model[first_iter]) + self.tableview.setRowHeight_(height) + self.row_height_set = True + + def set_auto_resizes(self, setting): + self.auto_resize = setting + + def set_columns_draggable(self, dragable): + self.tableview.setAllowsColumnReordering_(dragable) + + def set_fixed_height(self, fixed): + if fixed: + self.fixed_height = True + delegate_class = TableViewDelegate + self.row_height_set = False + self.try_to_set_row_height() + else: + self.fixed_height = False + delegate_class = VariableHeightTableViewDelegate + self.delegate = delegate_class.alloc().init() + self.tableview.setDelegate_(self.delegate) + self.tableview.reloadData() + + def row_of_iter(self, iter): + return self.model.row_of_iter(self.tableview, iter) + + def set_context_menu_callback(self, callback): + self.context_menu_callback = callback + + # disable the drag when the cells are constantly updating. Mac OS X + # deals badly with this.. + def set_volatile(self, volatile): + if volatile: + self.data_source.setDragSource_(None) + self.data_source.setDragDest_(None) + else: + self.data_source.setDragSource_(self.drag_source) + self.data_source.setDragDest_(self.drag_dest) + + def set_drag_source(self, drag_source): + self.drag_source = drag_source + self.data_source.setDragSource_(drag_source) + + def set_drag_dest(self, drag_dest): + self.drag_dest = drag_dest + if drag_dest is None: + self.data_source.setDragDest_(None) + else: + types = drag_dest.allowed_types() + self.data_source.setDragDest_(drag_dest) diff --git a/lvc/widgets/osx/utils.py b/lvc/widgets/osx/utils.py new file mode 100644 index 0000000..c0c2d85 --- /dev/null +++ b/lvc/widgets/osx/utils.py @@ -0,0 +1,2 @@ +def filename_to_unicode(filename): + return filename.decode('utf8') diff --git a/lvc/widgets/osx/viewport.py b/lvc/widgets/osx/viewport.py new file mode 100644 index 0000000..e6564d4 --- /dev/null +++ b/lvc/widgets/osx/viewport.py @@ -0,0 +1,101 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".viewport.py -- Viewport classes + +A Viewport represents the area where a Widget is located. +""" + +from objc import YES, NO, nil +from Foundation import * + +class Viewport(object): + """Used when a widget creates it's own NSView.""" + def __init__(self, view, initial_frame): + self.view = view + self.view.setFrame_(initial_frame) + self.placement = initial_frame + + def at_position(self, rect): + """Check if a viewport is currently positioned at rect.""" + return self.placement == rect + + def reposition(self, rect): + """Move the viewport to a different position.""" + self.view.setFrame_(rect) + self.placement = rect + + def remove(self): + self.view.removeFromSuperview() + + def area(self): + """Area of our view that is occupied by the viewport.""" + return NSRect(self.view.bounds().origin, self.placement.size) + + def get_width(self): + return self.view.frame().size.width + + def get_height(self): + return self.view.frame().size.height + + def queue_redraw(self): + opaque_view = self.view.opaqueAncestor() + if opaque_view is not None: + rect = opaque_view.convertRect_fromView_(self.area(), self.view) + opaque_view.setNeedsDisplayInRect_(rect) + + def redraw_now(self): + self.view.displayRect_(self.area()) + +class BorrowedViewport(Viewport): + """Used when a widget uses the NSView of one of it's ancestors. We store + the view that we borrow as well as an NSRect specifying where on that view + we are placed. + """ + def __init__(self, view, placement): + self.view = view + self.placement = placement + + def at_position(self, rect): + return self.placement == rect + + def reposition(self, rect): + self.placement = rect + + def remove(self): + pass + + def area(self): + return self.placement + + def get_width(self): + return self.placement.size.width + + def get_height(self): + return self.placement.size.height diff --git a/lvc/widgets/osx/widgetset.py b/lvc/widgets/osx/widgetset.py new file mode 100644 index 0000000..1203566 --- /dev/null +++ b/lvc/widgets/osx/widgetset.py @@ -0,0 +1,58 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".widgetset -- Contains all the +platform-specific widgets. This module doesn't have any actual code in it, it +just imports the widgets from their actual locations. +""" + +from .const import * +from .control import (TextEntry, NumberEntry, + SecureTextEntry, MultilineTextEntry) +from .control import Checkbox, Button, OptionMenu, RadioButtonGroup, RadioButton +from .customcontrol import (CustomButton, + ContinuousCustomButton, CustomSlider, DragableCustomButton) +from .contextmenu import ContextMenu +from .drawing import DrawingContext, ImageSurface, Gradient +from .drawingwidgets import DrawingArea, Background +from .rect import Rect +from .layout import VBox, HBox, Alignment, Table, Scroller, Expander, TabContainer, DetachedWindowHolder +from .window import Window, MainWindow, Dialog, FileSaveDialog, FileOpenDialog +from .window import DirectorySelectDialog, AboutDialog, AlertDialog, PreferencesWindow, DonateWindow, DialogWindow, get_first_time_dialog_coordinates +from .simple import (Image, ImageDisplay, Label, + SolidBackground, ClickableImageButton, AnimatedImageDisplay, + ProgressBar, HLine) +from .tableview import (TableView, TableColumn, + CellRenderer, CustomCellRenderer, ImageCellRenderer, + CheckboxCellRenderer, + CUSTOM_HEADER_HEIGHT) +from .tablemodel import (TableModel, + TreeTableModel) +from .osxmenus import (MenuBar, Menu, Separator, MenuItem, RadioMenuItem, CheckMenuItem) +from .base import Widget diff --git a/lvc/widgets/osx/widgetupdates.py b/lvc/widgets/osx/widgetupdates.py new file mode 100644 index 0000000..30677c2 --- /dev/null +++ b/lvc/widgets/osx/widgetupdates.py @@ -0,0 +1,72 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""widgetupdates.py -- Handle updates to our widgets +""" + +from PyObjCTools import AppHelper + +class SizeRequestManager(object): + """Helper object to manage size requests + + If something changes in a widget that makes us want to request a new size, + we avoid calculating it immediately. The reason is that the + new-size-request will cascade all the way up the widget tree, and then + result in our widget being placed. We don't necessary want all of this + action to happen while we are in the middle of handling an event + (especially with TableView). It's also inefficient to calculate things + immediately, since we might do something else to invalidate the size + request in the current event. + + SizeRequestManager stores which widgets need to have their size + recalculated, then calls do_invalidate_size_request() using callAfter + """ + + def __init__(self): + self.widgets_to_request = set() + #app.widgetapp.connect("event-processed", self._on_event_processed) + + def add_widget(self, widget): + if len(self.widgets_to_request) == 0: + AppHelper.callAfter(self._run_requests) + self.widgets_to_request.add(widget) + + def _run_requests(self): + this_run = self.widgets_to_request + self.widgets_to_request = set() + for widget in this_run: + widget.do_invalidate_size_request() + + def _on_event_processed(self, app): + # once we finishing handling an event, process our size requests ASAP + # to avoid any potential weirdness. Note: that we also schedule a + # call using callAfter(), often that will do nothing, but it's + # possible size requests get scheduled outside of an event + while self.widgets_to_request: + self._run_requests() diff --git a/lvc/widgets/osx/window.py b/lvc/widgets/osx/window.py new file mode 100644 index 0000000..53b1091 --- /dev/null +++ b/lvc/widgets/osx/window.py @@ -0,0 +1,896 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".window -- Top-level Window class. """ + +import logging + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil +from PyObjCTools import AppHelper + +from lvc import signals +from lvc.widgets import widgetconst +import wrappermap +import osxmenus +from .helpers import NotificationForwarder +from .base import Widget, FlippedView +from .layout import VBox, HBox, Alignment +from .control import Button +from .simple import Label +from .rect import Rect, NSRectWrapper +from .utils import filename_to_unicode + +# Tracks all windows that haven't been destroyed. This makes sure there +# object stay alive as long as the window is alive. +alive_windows = set() + +class MiroResponderInterceptor(NSResponder): + """Intercepts cocoa events and gives our wrappers and chance to handle + them first. + """ + + def initWithResponder_(self, responder): + """Initialize a MiroResponderInterceptor + + We will give the wrapper for responder a chance to handle the event, + then pass it along to responder. + """ + self = super(MiroResponderInterceptor, self).init() + self.responder = responder + return self + + def keyDown_(self, event): + if self.sendKeyDownToWrapper_(event): + return # signal handler returned True, stop processing + + # If our responder is the last in the chain, we can stop intercepting + if self.responder.nextResponder() is None: + self.responder.keyDown_(event) + return + + # Here's the tricky part, we want to call keyDown_ on our responder, + # but if it doesn't handle the event, then it will pass it along to + # it's next responder. We need to set things up so that we will + # intercept that call. + + # Make a new MiroResponderInterceptor whose responder is the next + # responder down the chain. + next_intercepter = MiroResponderInterceptor.alloc().initWithResponder_( + self.responder.nextResponder()) + # Install the interceptor + self.responder.setNextResponder_(next_intercepter) + # Send event along + self.responder.keyDown_(event) + # Restore old nextResponder value + self.responder.setNextResponder_(next_intercepter.responder) + + def sendKeyDownToWrapper_(self, event): + """Give a keyDown event to the wrapper for our responder + + Return True if the wrapper handled the event + """ + key = event.charactersIgnoringModifiers() + if len(key) != 1 or not key.isalnum(): + key = osxmenus.REVERSE_KEYS_MAP.get(key) + mods = osxmenus.translate_event_modifiers(event) + wrapper = wrappermap.wrapper(self.responder) + if isinstance(wrapper, Widget) or isinstance(wrapper, Window): + if wrapper.emit('key-press', key, mods): + return True + return False + +class MiroWindow(NSWindow): + def initWithContentRect_styleMask_backing_defer_(self, rect, mask, + backing, defer): + self = NSWindow.initWithContentRect_styleMask_backing_defer_(self, + rect, mask, backing, defer) + self._last_focus_chain = None + return self + + def handleKeyDown_(self, event): + if self.handle_tab_navigation(event): + return + interceptor = MiroResponderInterceptor.alloc().initWithResponder_( + self.firstResponder()) + interceptor.keyDown_(event) + + def handle_tab_navigation(self, event): + """Handle tab navigation through the window. + + :returns: True if we handled the event + """ + keystr = event.charactersIgnoringModifiers() + if keystr[0] == NSTabCharacter: + # handle cycling through views with Tab. + self.focusNextKeyView_(True) + return True + elif keystr[0] == NSBackTabCharacter: + self.focusNextKeyView_(False) + return True + return False + + def acceptsMouseMovedEvents(self): + # HACK: for some reason calling setAcceptsMouseMovedEvents_() doesn't + # work, we have to forcefully override this method. + return NO + + def sendEvent_(self, event): + if event.type() == NSKeyDown: + self.handleKeyDown_(event) + else: + NSWindow.sendEvent_(self, event) + + def _calc_current_focus_wrapper(self): + responder = self.firstResponder() + while responder: + wrapper = wrappermap.wrapper(responder) + # check if we have a wrapper for the view, if not try the parent + # view + if wrapper is not None: + return wrapper + responder = responder.superview() + return None + + def focusNextKeyView_(self, is_forward): + current_focus = self._calc_current_focus_wrapper() + my_wrapper = wrappermap.wrapper(self) + next_focus = my_wrapper.get_next_tab_focus(current_focus, is_forward) + if next_focus is not None: + next_focus.focus() + + def draggingEntered_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.draggingEntered_(info) or NSDragOperationNone + + def draggingUpdated_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.draggingUpdated_(info) or NSDragOperationNone + + def draggingExited_(self, info): + wrapper = wrappermap.wrapper(self) + wrapper.draggingExited_(info) + + def prepareForDragOperation_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.prepareForDragOperation_(info) or NO + + def performDragOperation_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.performDragOperation_(info) or NO + +class MainMiroWindow(MiroWindow): + def isMovableByWindowBackground(self): + return YES + +class Window(signals.SignalEmitter): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, title, rect=None): + signals.SignalEmitter.__init__(self) + self.create_signal('active-change') + self.create_signal('will-close') + self.create_signal('did-move') + self.create_signal('key-press') + self.create_signal('show') + self.create_signal('hide') + self.create_signal('on-shown') + self.create_signal('file-drag-motion') + self.create_signal('file-drag-received') + self.create_signal('file-drag-leave') + self.is_closing = False + if rect is None: + rect = Rect(0, 0, 470, 600) + self.nswindow = MainMiroWindow.alloc().initWithContentRect_styleMask_backing_defer_( + rect.nsrect, + self.get_style_mask(), + NSBackingStoreBuffered, + NO) + self.nswindow.setTitle_(title) + self.nswindow.setMinSize_(NSSize(470, 600)) + self.nswindow.setReleasedWhenClosed_(NO) + self.content_view = FlippedView.alloc().initWithFrame_(rect.nsrect) + self.content_view.setAutoresizesSubviews_(NO) + self.nswindow.setContentView_(self.content_view) + self.content_widget = None + self.view_notifications = NotificationForwarder.create(self.content_view) + self.view_notifications.connect(self.on_frame_change, 'NSViewFrameDidChangeNotification') + self.window_notifications = NotificationForwarder.create(self.nswindow) + self.window_notifications.connect(self.on_activate, 'NSWindowDidBecomeMainNotification') + self.window_notifications.connect(self.on_deactivate, 'NSWindowDidResignMainNotification') + self.window_notifications.connect(self.on_did_move, 'NSWindowDidMoveNotification') + self.window_notifications.connect(self.on_will_close, 'NSWindowWillCloseNotification') + wrappermap.add(self.nswindow, self) + alive_windows.add(self) + + def get_next_tab_focus(self, current, is_forward): + """Return the next widget to cycle through for keyboard focus + + Subclasses can override this to for find-grained control of keyboard + focus. + + :param current: currently-focused widget + :param is_forward: are we tabbing forward? + """ + return None + + # XXX Use MainWindow not Window for MVCStyle/MiroStyle + def get_style_mask(self): + return (NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask) + + def set_title(self, title): + self.nswindow.setTitle_(title) + + def get_title(self): + return self.nswindow.title() + + def on_frame_change(self, notification): + self.place_child() + + def on_activate(self, notification): + self.emit('active-change') + + def on_deactivate(self, notification): + self.emit('active-change') + + def on_did_move(self, notification): + self.emit('did-move') + + def on_will_close(self, notification): + # unset the first responder. This allows text entry widgets to get + # the NSControlTextDidEndEditingNotification + if self.is_closing: + logging.info('on_will_close: already closing') + return + self.is_closing = True + self.nswindow.makeFirstResponder_(nil) + self.emit('will-close') + self.emit('hide') + self.is_closing = False + + def is_active(self): + return self.nswindow.isMainWindow() + + def is_visible(self): + return self.nswindow.isVisible() + + def show(self): + if self not in alive_windows: + raise ValueError("Window destroyed") + self.nswindow.makeKeyAndOrderFront_(nil) + self.nswindow.makeMainWindow() + self.emit('show') + # Cocoa doesn't apply default selections as forcefully as GTK, so + # currently there's no need for on-shown to actually wait until the + # window has been shown here + self.emit('on-shown') + + def close(self): + self.nswindow.close() + + def destroy(self): + self.close() + self.window_notifications.disconnect() + self.view_notifications.disconnect() + self.nswindow.setContentView_(nil) + wrappermap.remove(self.nswindow) + alive_windows.discard(self) + self.nswindow = None + + def place_child(self): + rect = self.nswindow.contentRectForFrameRect_(self.nswindow.frame()) + self.content_widget.place(NSRect(NSPoint(0, 0), rect.size), + self.content_view) + + def hookup_content_widget_signals(self): + self.size_req_handler = self.content_widget.connect('size-request-changed', + self.on_content_widget_size_request_change) + + def unhook_content_widget_signals(self): + self.content_widget.disconnect(self.size_req_handler) + self.size_req_handler = None + + def on_content_widget_size_request_change(self, widget, old_size): + self.update_size_constraints() + + def set_content_widget(self, widget): + if self.content_widget: + self.content_widget.remove_viewport() + self.unhook_content_widget_signals() + self.content_widget = widget + self.hookup_content_widget_signals() + self.place_child() + self.update_size_constraints() + + def update_size_constraints(self): + width, height = self.content_widget.get_size_request() + # It is possible the window is torn down between the size invalidate + # request and the actual size invalidation invocation. So check + # to see if nswindow is there if not then do not do anything. + if self.nswindow: + # FIXME: I'm not sure that this code does what we want it to do. + # It enforces the min-size when the user drags the window, but I + # think it should also call setContentSize_ if the window is + # currently too small to fit the content - BDK + self.nswindow.setContentMinSize_(NSSize(width, height)) + rect = self.nswindow.contentRectForFrameRect_(self.nswindow.frame()) + if rect.size.width < width or rect.size.height < height: + logging.warn("Content widget too large for this window " + "size available: %dx%d widget size: %dx%d", + rect.size.width, rect.size.height, width, height) + + def get_content_widget(self): + return self.content_widget + + def get_frame(self): + frame = self.nswindow.frame() + frame.size.height -= 22 + return NSRectWrapper(frame) + + def connect_menu_keyboard_shortcuts(self): + # All OS X windows are connected to the menu shortcuts + pass + + def accept_file_drag(self, val): + if not val: + self.drag_dest = None + else: + self.drag_dest = NSDragOperationCopy + self.nswindow.registerForDraggedTypes_([NSFilenamesPboardType]) + + def prepareForDragOperation_(self, info): + return NO if self.drag_dest is None else YES + + def performDragOperation_(self, info): + pb = info.draggingPasteboard() + available_types = set(pb.types()) & set([NSFilenamesPboardType]) + drag_ok = False + if available_types: + type_ = available_types.pop() + # DANCE! Everybody dance for portable Python code! + values = [unicode( + NSURL.fileURLWithPath_(v).filePathURL()).encode('utf-8') + for v in list(pb.propertyListForType_(type_))] + self.emit('file-drag-received', values) + drag_ok = True + self.draggingExited_(info) + return drag_ok + + def draggingEntered_(self, info): + return self.draggingUpdated_(info) + + def draggingUpdated_(self, info): + self.emit('file-drag-motion') + return self.drag_dest + + def draggingExited_(self, info): + self.emit('file-drag-leave') + + def center(self): + self.nswindow.center() + +class MainWindow(Window): + def __init__(self, title, rect): + Window.__init__(self, title, rect) + self.nswindow.setReleasedWhenClosed_(NO) + + def close(self): + self.nswindow.orderOut_(nil) + +class DialogBase(object): + def __init__(self): + self.sheet_parent = None + def set_transient_for(self, window): + self.sheet_parent = window + +class MiroPanel(NSPanel): + def cancelOperation_(self, event): + wrappermap.wrapper(self).end_with_code(-1) + +class Dialog(DialogBase): + def __init__(self, title, description=None): + DialogBase.__init__(self) + self.title = title + self.description = description + self.buttons = [] + self.extra_widget = None + self.window = None + self.running = False + + def add_button(self, text): + button = Button(text) + button.set_size(widgetconst.SIZE_NORMAL) + button.connect('clicked', self.on_button_clicked, len(self.buttons)) + self.buttons.append(button) + + def on_button_clicked(self, button, code): + self.end_with_code(code) + + def end_with_code(self, code): + if self.sheet_parent is not None: + NSApp().endSheet_returnCode_(self.window, code) + else: + NSApp().stopModalWithCode_(code) + + def build_text(self): + vbox = VBox(spacing=6) + if self.description is not None: + description_label = Label(self.description, wrap=True) + description_label.set_bold(True) + description_label.set_size_request(360, -1) + vbox.pack_start(description_label) + return vbox + + def build_buttons(self): + hbox = HBox(spacing=12) + for button in reversed(self.buttons): + hbox.pack_start(button) + alignment = Alignment(xalign=1.0, yscale=1.0) + alignment.add(hbox) + return alignment + + def build_content(self): + vbox = VBox(spacing=12) + vbox.pack_start(self.build_text()) + if self.extra_widget: + vbox.pack_start(self.extra_widget) + vbox.pack_start(self.build_buttons()) + alignment = Alignment(xscale=1.0, yscale=1.0) + alignment.set_padding(12, 12, 17, 17) + alignment.add(vbox) + return alignment + + def build_window(self): + self.content_widget = self.build_content() + width, height = self.content_widget.get_size_request() + width = max(width, 400) + window = MiroPanel.alloc() + window.initWithContentRect_styleMask_backing_defer_( + NSMakeRect(400, 400, width, height), + NSTitledWindowMask, NSBackingStoreBuffered, NO) + view = FlippedView.alloc().initWithFrame_(NSMakeRect(0, 0, width, + height)) + window.setContentView_(view) + window.setTitle_(self.title) + self.content_widget.place(view.frame(), view) + if self.buttons: + self.buttons[0].make_default() + return window + + def hookup_content_widget_signals(self): + self.size_req_handler = self.content_widget.connect( + 'size-request-changed', + self.on_content_widget_size_request_change) + + def unhook_content_widget_signals(self): + self.content_widget.disconnect(self.size_req_handler) + self.size_req_handler = None + + def on_content_widget_size_request_change(self, widget, old_size): + width, height = self.content_widget.get_size_request() + # It is possible the window is torn down between the size invalidate + # request and the actual size invalidation invocation. So check + # to see if nswindow is there if not then do not do anything. + if self.window and (width, height) != old_size: + self.change_content_size(width, height) + + def change_content_size(self, width, height): + content_rect = self.window.contentRectForFrameRect_( + self.window.frame()) + # Cocoa's coordinate system is funky, adjust y so that the top stays + # in place + content_rect.origin.y += (content_rect.size.height - height) + # change our frame to fit the new content. It would be nice to + # animate the change, but timers don't work when we are displaying a + # modal dialog + content_rect.size = NSSize(width, height) + new_frame = self.window.frameRectForContentRect_(content_rect) + self.window.setFrame_display_(new_frame, NO) + # Need to call place() again, since our window has changed size + contentView = self.window.contentView() + self.content_widget.place(contentView.frame(), contentView) + + def run(self): + self.window = self.build_window() + wrappermap.add(self.window, self) + self.hookup_content_widget_signals() + self.running = True + if self.sheet_parent is None: + response = NSApp().runModalForWindow_(self.window) + if self.window: + self.window.close() + else: + delegate = SheetDelegate.alloc().init() + NSApp().beginSheet_modalForWindow_modalDelegate_didEndSelector_contextInfo_( + self.window, self.sheet_parent.nswindow, + delegate, 'sheetDidEnd:returnCode:contextInfo:', 0) + response = NSApp().runModalForWindow_(self.window) + if self.window: + # self.window won't be around if we call destroy() to cancel + # the dialog + self.window.orderOut_(nil) + self.running = False + self.unhook_content_widget_signals() + + if response < 0: + return -1 + return response + + def destroy(self): + if self.running: + NSApp().stopModalWithCode_(-1) + + if self.window is not None: + self.window.setContentView_(None) + self.window.close() + self.window = None + self.buttons = None + self.extra_widget = None + + def set_extra_widget(self, widget): + self.extra_widget = widget + + def get_extra_widget(self): + return self.extra_widget + +class SheetDelegate(NSObject): + @AppHelper.endSheetMethod + def sheetDidEnd_returnCode_contextInfo_(self, sheet, return_code, info): + NSApp().stopModalWithCode_(return_code) + +class FileDialogBase(DialogBase): + def __init__(self): + DialogBase.__init__(self) + self._types = None + self._filename = None + self._directory = None + self._filter_on_run = True + + def run(self): + self._panel.setAllowedFileTypes_(self._types) + if self.sheet_parent is None: + if self._filter_on_run: + response = self._panel.runModalForDirectory_file_types_(self._directory, self._filename, self._types) + else: + response = self._panel.runModalForDirectory_file_(self._directory, self._filename) + else: + delegate = SheetDelegate.alloc().init() + if self._filter_on_run: + self._panel.beginSheetForDirectory_file_types_modalForWindow_modalDelegate_didEndSelector_contextInfo_( + self._directory, self._filename, self._types, + self.sheet_parent.nswindow, delegate, 'sheetDidEnd:returnCode:contextInfo:', 0) + else: + self._panel.beginSheetForDirectory_file_modalForWindow_modalDelegate_didEndSelector_contextInfo_( + self._directory, self._filename, + self.sheet_parent.nswindow, delegate, 'sheetDidEnd:returnCode:contextInfo:', 0) + response = NSApp().runModalForWindow_(self._panel) + self._panel.orderOut_(nil) + return response + +class FileSaveDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._title = title + self._panel = NSSavePanel.savePanel() + self._panel.setCanChooseFiles_(YES) + self._panel.setCanChooseDirectories_(NO) + self._filename = None + self._filter_on_run = False + + def set_filename(self, s): + self._filename = filename_to_unicode(s) + + def get_filename(self): + # Use encode('utf-8') instead of unicode_to_filename, because + # unicode_to_filename has code to make sure nextFilename works, but it's + # more important here to not change the filename. + return self._filename.encode('utf-8') + + def run(self): + response = FileDialogBase.run(self) + if response == NSFileHandlingPanelOKButton: + self._filename = self._panel.filename() + return 0 + self._filename = "" + + def destroy(self): + self._panel = None + + set_path = set_filename + get_path = get_filename + +class FileOpenDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._title = title + self._panel = NSOpenPanel.openPanel() + self._panel.setCanChooseFiles_(YES) + self._panel.setCanChooseDirectories_(NO) + self._filenames = None + + def set_select_multiple(self, value): + if value: + self._panel.setAllowsMultipleSelection_(YES) + else: + self._panel.setAllowsMultipleSelection_(NO) + + def set_directory(self, d): + self._directory = filename_to_unicode(d) + + def set_filename(self, s): + self._filename = filename_to_unicode(s) + + def add_filters(self, filters): + self._types = [] + for _, t in filters: + self._types += t + + def get_filename(self): + if self._filenames is None: + # canceled + return None + return self.get_filenames()[0] + + def get_filenames(self): + if self._filenames is None: + # canceled + return [] + # Use encode('utf-8') instead of unicode_to_filename, because + # unicode_to_filename has code to make sure nextFilename works, but it's + # more important here to not change the filename. + return [f.encode('utf-8') for f in self._filenames] + + def run(self): + response = FileDialogBase.run(self) + if response == NSFileHandlingPanelOKButton: + self._filenames = self._panel.filenames() + return 0 + self._filename = '' + self._filenames = None + + def destroy(self): + self._panel = None + + set_path = set_filename + get_path = get_filename + +class DirectorySelectDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._title = title + self._panel = NSOpenPanel.openPanel() + self._panel.setCanChooseFiles_(NO) + self._panel.setCanChooseDirectories_(YES) + self._directory = None + + def set_directory(self, d): + self._directory = filename_to_unicode(d) + + def get_directory(self): + # Use encode('utf-8') instead of unicode_to_filename, because + # unicode_to_filename has code to make sure nextFilename + # works, but it's more important here to not change the + # filename. + return self._directory.encode('utf-8') + + def run(self): + response = FileDialogBase.run(self) + if response == NSFileHandlingPanelOKButton: + self._directory = self._panel.filenames()[0] + return 0 + self._directory = "" + + def destroy(self): + self._panel = None + + set_path = set_directory + get_path = get_directory + +class AboutDialog(DialogBase): + def run(self): + optionsDictionary = dict() + #revision = app.config.get(prefs.APP_REVISION_NUM) + #if revision: + # optionsDictionary['Version'] = revision + if not optionsDictionary: + optionsDictionary = nil + NSApplication.sharedApplication().orderFrontStandardAboutPanelWithOptions_(optionsDictionary) + def destroy(self): + pass + +class AlertDialog(DialogBase): + def __init__(self, title, message, alert_type): + DialogBase.__init__(self) + self._nsalert = NSAlert.alloc().init(); + self._nsalert.setMessageText_(title) + self._nsalert.setInformativeText_(message) + self._nsalert.setAlertStyle_(alert_type) + def add_button(self, text): + self._nsalert.addButtonWithTitle_(text) + def run(self): + self._nsalert.runModal() + def destroy(self): + self._nsalert = nil + +class PreferenceItem(NSToolbarItem): + + def setPanel_(self, panel): + self.panel = panel + +class PreferenceToolbarDelegate(NSObject): + + def initWithPanels_identifiers_window_(self, panels, identifiers, window): + self = super(PreferenceToolbarDelegate, self).init() + self.panels = panels + self.identifiers = identifiers + self.window = window + return self + + def toolbarAllowedItemIdentifiers_(self, toolbar): + return self.identifiers + + def toolbarDefaultItemIdentifiers_(self, toolbar): + return self.identifiers + + def toolbarSelectableItemIdentifiers_(self, toolbar): + return self.identifiers + + def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_(self, toolbar, + itemIdentifier, + flag): + panel = self.panels[itemIdentifier] + item = PreferenceItem.alloc().initWithItemIdentifier_(itemIdentifier) + item.setLabel_(unicode(panel[1])) + item.setImage_(NSImage.imageNamed_(u"pref_tab_%s" % itemIdentifier)) + item.setAction_("switchPreferenceView:") + item.setTarget_(self) + item.setPanel_(panel[0]) + return item + + def validateToolbarItem_(self, item): + return YES + + def switchPreferenceView_(self, sender): + self.window.do_select_panel(sender.panel, YES) + +class DialogWindow(Window): + def __init__(self, title, rect, allow_miniaturize=False): + self.allow_miniaturize = allow_miniaturize + Window.__init__(self, title, rect) + self.nswindow.setShowsToolbarButton_(NO) + + def get_style_mask(self): + mask = (NSTitledWindowMask | NSClosableWindowMask) + if self.allow_miniaturize: + mask |= NSMiniaturizableWindowMask + return mask + +class DonateWindow(Window): + def __init__(self, title): + Window.__init__(self, title, Rect(0, 0, 640, 440)) + self.panels = dict() + self.identifiers = list() + self.first_show = True + self.nswindow.setShowsToolbarButton_(NO) + self.nswindow.setReleasedWhenClosed_(NO) + self.app_notifications = NotificationForwarder.create(NSApp()) + self.app_notifications.connect(self.on_app_quit, + 'NSApplicationWillTerminateNotification') + + def destroy(self): + super(PreferencesWindow, self).destroy() + self.app_notifications.disconnect() + + def get_style_mask(self): + return (NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask) + + def show(self): + if self.first_show: + self.nswindow.center() + self.first_show = False + Window.show(self) + + def on_app_quit(self, notification): + self.close() + +class PreferencesWindow(Window): + def __init__(self, title): + Window.__init__(self, title, Rect(0, 0, 640, 440)) + self.panels = dict() + self.identifiers = list() + self.first_show = True + self.nswindow.setShowsToolbarButton_(NO) + self.nswindow.setReleasedWhenClosed_(NO) + self.app_notifications = NotificationForwarder.create(NSApp()) + self.app_notifications.connect(self.on_app_quit, + 'NSApplicationWillTerminateNotification') + + def destroy(self): + super(PreferencesWindow, self).destroy() + self.app_notifications.disconnect() + + def get_style_mask(self): + return (NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask) + + def append_panel(self, name, panel, title, image_name): + self.panels[name] = (panel, title) + self.identifiers.append(name) + + def finish_panels(self): + self.tbdelegate = PreferenceToolbarDelegate.alloc().initWithPanels_identifiers_window_(self.panels, self.identifiers, self) + toolbar = NSToolbar.alloc().initWithIdentifier_(u"Preferences") + toolbar.setAllowsUserCustomization_(NO) + toolbar.setDelegate_(self.tbdelegate) + + self.nswindow.setToolbar_(toolbar) + + def select_panel(self, index): + panel = self.identifiers[index] + self.nswindow.toolbar().setSelectedItemIdentifier_(panel) + self.do_select_panel(self.panels[panel][0], NO) + + def do_select_panel(self, panel, animate): + wframe = self.nswindow.frame() + vsize = list(panel.get_size_request()) + if vsize[0] < 650: + vsize[0] = 650 + if vsize[1] < 200: + vsize[1] = 200 + + toolbarHeight = wframe.size.height - self.nswindow.contentView().frame().size.height + wframe.origin.y += wframe.size.height - vsize[1] - toolbarHeight + wframe.size = (vsize[0], vsize[1] + toolbarHeight) + + self.set_content_widget(panel) + self.nswindow.setFrame_display_animate_(wframe, YES, animate) + + def show(self): + if self.first_show: + self.nswindow.center() + self.first_show = False + Window.show(self) + + def on_app_quit(self, notification): + self.close() + +def get_first_time_dialog_coordinates(width, height): + """Returns the coordinates for the first time dialog. + """ + # windowFrame is None on first run. in that case, we want + # to put librevideoconverter in the middle. + mainscreen = NSScreen.mainScreen() + rect = mainscreen.frame() + + x = (rect.size.width - width) / 2 + y = (rect.size.height - height) / 2 + + return x, y diff --git a/lvc/widgets/osx/wrappermap.py b/lvc/widgets/osx/wrappermap.py new file mode 100644 index 0000000..624a496 --- /dev/null +++ b/lvc/widgets/osx/wrappermap.py @@ -0,0 +1,48 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".wrappermap -- Map NSViews and NSWindows to the +Widget that wraps them. +""" + +# Maps NSViews/NSWinows -> wrapper objects. +wrapper_mapping = dict() + +def wrapper(wrapped): + """Find the wrapper object for an NSView/NSWindow.""" + try: + return wrapper_mapping[wrapped] + except KeyError: + return None + +def add(wrapped, wrapper): + wrapper_mapping[wrapped] = wrapper + +def remove(wrapped): + del wrapper_mapping[wrapped] diff --git a/lvc/widgets/tablescroll.py b/lvc/widgets/tablescroll.py new file mode 100644 index 0000000..f8b1acb --- /dev/null +++ b/lvc/widgets/tablescroll.py @@ -0,0 +1,154 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tablescroll.py -- High-level scroll management. This ensures that behavior +like scroll_to_item works the same way across platforms. +""" + +from lvc.errors import WidgetActionError + + +class ScrollbarOwnerMixin(object): + """Scrollbar management for TableView. + + External methods have undecorated names; internal methods start with _. + + External methods: + - handle failure themselves (e.g. return None or retry later) + - return basic data types (e.g. (x, y) tuples) + - use "tree" coordinates + + Internal methods (intended to be used by ScrollbarOwnerMixin and the + platform implementations): + - raise WidgetActionError subclasses on failure + - use Rect/Point structs + - also use "tree" coordinates + """ + def __init__(self, _work_around_17153=False): + self.__work_around_17153 = _work_around_17153 + self._scroll_to_iter_callback = None + self.create_signal('scroll-range-changed') + + def scroll_to_iter(self, iter_, manual=True, recenter=False): + """Scroll the given item into view. + + manual: scroll even if we were not following the playing item + recenter: scroll even if item is in top half of view + """ + try: + item = self._get_item_area(iter_) + visible = self._get_visible_area() + manually_scrolled = self._manually_scrolled + except WidgetActionError: + if self._scroll_to_iter_callback: + # We just retried and failed. Do nothing; we will retry again + # next time scrollable range changes. + return + # We just tried and failed; schedule a retry when the scrollable + # range changes. + self._scroll_to_iter_callback = self.connect('scroll-range-changed', + lambda *a: self.scroll_to_iter(iter_, manual, recenter)) + return + # If the above succeeded, we know the iter's position; this means we can + # set_scroll_position to that position. That may work now or be + # postponed until later, but either way we're done with scroll_to_iter. + if self._scroll_to_iter_callback: + self.disconnect(self._scroll_to_iter_callback) + self._scroll_to_iter_callback = None + visible_bottom = visible.y + visible.height + visible_middle = visible.y + visible.height // 2 + item_bottom = item.y + item.height + item_middle = item.y + item.height // 2 + in_top = item_bottom >= visible.y and item.y <= visible_middle + in_bottom = item_bottom >= visible_middle and item.y <= visible_bottom + if self._should_scroll( + manual, in_top, in_bottom, recenter, manually_scrolled): + destination = item_middle - visible.height // 2 + self._set_vertical_scroll(destination) + # set_scroll_position will take care of scroll to the position when + # possible; this may or may not be now, but our work here is done. + + def set_scroll_position(self, position, restore_only=False, + _hack_for_17153=False): + """Scroll the top left corner to the given (x, y) offset from the origin + of the view. + + restore_only: set the value only if no other value has been set yet + """ + if _hack_for_17153 and not self.__work_around_17153: + return + if not restore_only or not self._position_set: + self._set_scroll_position(position) + + @classmethod + def _should_scroll(cls, + manual, in_top, in_bottom, recenter, manually_scrolled): + if not manual and manually_scrolled: + # The user has moved the scrollbars since we last autoscrolled, and + # we're deciding whether we should resume autoscrolling. + # We want to do that when the currently-playing item catches up to + # the center of the screen i.e. is part above the center, part below + return in_top and in_bottom + # This is a manual scroll, or we're already autoscrolling - so we no + # longer need to worry about either manual or manually_scrolled + if in_top: + # The item is in the top half; let playback catch up with the + # current scroll position, unless recentering has been requested + return recenter + if in_bottom: + # We land here when: + # - playback has begun with an item in the bottom half of the screen + # - scroll is following sequential playback + # Either way we want to jump down to the item. + return True + # We're scrolling to an item that's not in view because: + # - playback has begun with an item that is out of sight + # - we're autoscrolling on shuffle + # Either way we want to show the item. + return True + + def reset_scroll(self): + """To scroll back to the origin; platform code might want to do + something special to forget the current position when this happens. + """ + self.set_scroll_position((0, 0)) + + def get_scroll_position(self): + """Returns the current scroll position, or None if not ready.""" + try: + return tuple(self._get_scroll_position()) + except WidgetActionError: + return None + + def _set_vertical_scroll(self, pos): + """Helper to set our vertical position without affecting our horizontal + position. + """ + # FIXME: shouldn't reset horizontal position + self.set_scroll_position((0, pos)) diff --git a/lvc/widgets/tableselection.py b/lvc/widgets/tableselection.py new file mode 100644 index 0000000..ee6472c --- /dev/null +++ b/lvc/widgets/tableselection.py @@ -0,0 +1,220 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tableselection.py -- High-level selection management. Subclasses defined in +the platform tableview modules provide the platform-specific methods used here. +""" + +from contextlib import contextmanager + +from lvc.errors import WidgetActionError, WidgetUsageError + +class SelectionOwnerMixin(object): + """Encapsulates the selection functionality of a TableView, for + consistent behavior across platforms. + + Emits: + :signal selection-changed: the selection has been changed + :signal selection-invalid: the item selected can no longer be selected + :signal deselected: all items have been deselected + """ + def __init__(self): + self._ignore_selection_changed = 0 + self._allow_multiple_select = None + self.create_signal('selection-changed') + self.create_signal('selection-invalid') + self.create_signal('deselected') + + @property + def allow_multiple_select(self): + """Return whether the widget allows multiple selection.""" + if self._allow_multiple_select is None: + self._allow_multiple_select = self._get_allow_multiple_select() + return self._allow_multiple_select + + @allow_multiple_select.setter + def allow_multiple_select(self, allow): + """Set whether to allow multiple selection; this method is expected + always to work. + """ + if self._allow_multiple_select != allow: + self._set_allow_multiple_select(allow) + self._allow_multiple_select = allow + + @property + def num_rows_selected(self): + """Override on platforms with a way to count rows without having to + retrieve them. + """ + if self.allow_multiple_select: + return len(self._get_selected_iters()) + else: + return int(self._get_selected_iter() is not None) + + def select(self, iter_, signal=False): + """Try to select an iter. + + :raises WidgetActionError: iter does not exist or is not selectable + """ + self.select_iters((iter_,), signal) + + def select_iters(self, iters, signal=False): + """Try to select multiple iters (signaling at most once). + + :raises WidgetActionError: iter does not exist or is not selectable + """ + with self._ignoring_changes(not signal): + for iter_ in iters: + self._select(iter_) + if not all(self._is_selected(iter_) for iter_ in iters): + raise WidgetActionError("the specified iter cannot be selected") + + def is_selected(self, iter_): + """Test if an iter is selected""" + return self._is_selected(iter_) + + def unselect(self, iter_): + """Unselect an Iter. Fails silently if the Iter is not selected. + """ + self._validate_iter(iter_) + with self._ignoring_changes(): + self._unselect(iter_) + + def unselect_iters(self, iters): + """Unselect iters. Fails silently if the iters are not selected.""" + with self._ignoring_changes(): + for iter_ in iters: + self.unselect(iter_) + + def unselect_all(self, signal=True): + """Unselect all. emits only the 'deselected' signal.""" + with self._ignoring_changes(): + self._unselect_all() + if signal: + self.emit('deselected') + + def on_selection_changed(self, _widget_or_notification): + """When we receive a selection-changed signal, we forward it if we're + not in a 'with _ignoring_changes' block. Selection-changed + handlers are run in an ignoring block, and anything that changes the + selection to reflect the current state. + """ + # don't bother sending out a second selection-changed signal if + # the handler changes the selection (#15767) + if not self._ignore_selection_changed: + with self._ignoring_changes(): + self.emit('selection-changed') + + def get_selection_as_strings(self): + """Returns the current selection as a list of strings. + """ + return [self._iter_to_string(iter_) for iter_ in self.get_selection()] + + def set_selection_as_strings(self, selected): + """Given a list of selection strings, selects each Iter represented by + the strings. + + Raises WidgetActionError upon failure. + """ + # iter may not be destringable (yet) - bounds error + # destringed iter not selectable if parent isn't open (yet) + self.set_selection(self._iter_from_string(sel) for sel in selected) + + def get_cursor(self): + """Get the location of the keyboard cursor for the tableview. + + Returns a string that represents the row that the keyboard cursor is + on. + """ + + def set_cursor(self, location): + """Set the location of the keyboard cursor for the tableview. + + :param location: return value from a call to get_cursor() + + Raises WidgetActionError upon failure. + """ + + def get_selection(self): + """Returns a list of GTK Iters. Works regardless of whether multiple + selection is enabled. + """ + return self._get_selected_iters() + + def get_selected(self): + """Return the single selected item. + + :raises WidgetUsageError: multiple selection is enabled + """ + if self.allow_multiple_select: + raise WidgetUsageError("table allows multiple selection") + return self._get_selected_iter() + + def _validate_iter(self, iter_): + """Check whether an iter is valid. + + :raises WidgetDomainError: the iter is not valid + :raises WidgetActionError: there is no model right now + """ + + @contextmanager + def _ignoring_changes(self, ignoring=True): + """Use this with with to prevent sending signals when we're changing + our own selection; that way, when we get a signal, we know it's + something important. + """ + if ignoring: + self._ignore_selection_changed += 1 + try: + yield + finally: + if ignoring: + self._ignore_selection_changed -= 1 + + @contextmanager + def preserving_selection(self): + """Prevent selection changes in a block from having any effect or + sticking - no signals will be sent, and the selection will be restored + to its original value when the block exits. + """ + iters = self._get_selected_iters() + with self._ignoring_changes(): + try: + yield + finally: + self.set_selection(iters) + + def set_selection(self, iters, signal=False): + """Set the selection to the given iters, replacing any previous + selection and signaling at most once. + """ + self.unselect_all(signal=False) + for iter_ in iters: + self.select(iter_, signal=False) + if signal: self.emit('selection-changed') diff --git a/lvc/widgets/widgetconst.py b/lvc/widgets/widgetconst.py new file mode 100644 index 0000000..bbb513c --- /dev/null +++ b/lvc/widgets/widgetconst.py @@ -0,0 +1,44 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""``miro.frontends.widgets.widgetconst`` -- Constants for the widgets +frontend. +""" + +# Control sizes +SIZE_NORMAL = -1 +SIZE_SMALL = -2 + +TEXT_JUSTIFY_LEFT = 0 +TEXT_JUSTIFY_RIGHT = 1 +TEXT_JUSTIFY_CENTER = 2 + +# cursors +CURSOR_NORMAL = 0 +CURSOR_POINTING_HAND = 1 diff --git a/lvc/widgets/widgetutil.py b/lvc/widgets/widgetutil.py new file mode 100644 index 0000000..861ed5f --- /dev/null +++ b/lvc/widgets/widgetutil.py @@ -0,0 +1,225 @@ +from math import pi as PI +from lvc.widgets import widgetset +from lvc.resources import image_path + +def make_surface(image_name, height=None): + path = image_path(image_name + '.png') + image = widgetset.Image(path) + if height is not None: + image = image.resize(image.width, height) + return widgetset.ImageSurface(image) + +def font_scale_from_osx_points(points): + """Create a font scale so that it's points large on OS X. + + Assumptions (these should be true for OS X) + - the default font size is 13pt + - the DPI is 72ppi + """ + return points / 13.0 + +def css_to_color(css_string): + parts = (css_string[1:3], css_string[3:5], css_string[5:7]) + return tuple((int(value, 16) / 255.0) for value in parts) + +def align(widget, xalign=0, yalign=0, xscale=0, yscale=0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Create an alignment, then add widget to it and return the alignment. + """ + alignment = widgetset.Alignment(xalign, yalign, xscale, yscale, + top_pad, bottom_pad, left_pad, right_pad) + alignment.add(widget) + return alignment + +def align_center(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will center it horizontally. + """ + return align(widget, 0.5, 0, 0, 1, + top_pad, bottom_pad, left_pad, right_pad) + +def align_right(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align it left. + """ + return align(widget, 1, 0, 0, 1, top_pad, bottom_pad, left_pad, right_pad) + +def align_left(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align it right. + """ + return align(widget, 0, 0, 0, 1, top_pad, bottom_pad, left_pad, right_pad) + +def align_middle(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will center it vertically. + """ + return align(widget, 0, 0.5, 1, 0, + top_pad, bottom_pad, left_pad, right_pad) + +def align_top(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align to the top. + """ + return align(widget, 0, 0, 1, 0, top_pad, bottom_pad, left_pad, right_pad) + +def align_bottom(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align to the bottom. + """ + return align(widget, 0, 1, 1, 0, top_pad, bottom_pad, left_pad, right_pad) + +def pad(widget, top=0, bottom=0, left=0, right=0): + """Wrap a widget in an Alignment that will pad it. + """ + alignment = widgetset.Alignment(0, 0, 1, 1, + top, bottom, left, right) + alignment.add(widget) + return alignment + +def round_rect(context, x, y, width, height, edge_radius): + """Specifies path of a rectangle with rounded corners. + """ + edge_radius = min(edge_radius, min(width, height)/2.0) + inner_width = width - edge_radius*2 + inner_height = height - edge_radius*2 + x_inner1 = x + edge_radius + x_inner2 = x + width - edge_radius + y_inner1 = y + edge_radius + y_inner2 = y + height - edge_radius + + context.move_to(x+edge_radius, y) + context.rel_line_to(inner_width, 0) + context.arc(x_inner2, y_inner1, edge_radius, -PI/2, 0) + context.rel_line_to(0, inner_height) + context.arc(x_inner2, y_inner2, edge_radius, 0, PI/2) + context.rel_line_to(-inner_width, 0) + context.arc(x_inner1, y_inner2, edge_radius, PI/2, PI) + context.rel_line_to(0, -inner_height) + context.arc(x_inner1, y_inner1, edge_radius, PI, PI*3/2) + +def round_rect_reverse(context, x, y, width, height, edge_radius): + """Specifies path of a rectangle with rounded corners. + + This specifies the rectangle in a counter-clockwise fashion. + """ + edge_radius = min(edge_radius, min(width, height)/2.0) + inner_width = width - edge_radius*2 + inner_height = height - edge_radius*2 + x_inner1 = x + edge_radius + x_inner2 = x + width - edge_radius + y_inner1 = y + edge_radius + y_inner2 = y + height - edge_radius + + context.move_to(x+edge_radius, y) + context.arc_negative(x_inner1, y_inner1, edge_radius, PI*3/2, PI) + context.rel_line_to(0, inner_height) + context.arc_negative(x_inner1, y_inner2, edge_radius, PI, PI/2) + context.rel_line_to(inner_width, 0) + context.arc_negative(x_inner2, y_inner2, edge_radius, PI/2, 0) + context.rel_line_to(0, -inner_height) + context.arc_negative(x_inner2, y_inner1, edge_radius, 0, -PI/2) + context.rel_line_to(-inner_width, 0) + +def circular_rect(context, x, y, width, height): + """Make a path for a rectangle with the left/right side being circles. + """ + radius = height / 2.0 + inner_width = width - height + inner_y = y + radius + inner_x1 = x + radius + inner_x2 = inner_x1 + inner_width + + context.move_to(inner_x1, y) + context.rel_line_to(inner_width, 0) + context.arc(inner_x2, inner_y, radius, -PI/2, PI/2) + context.rel_line_to(-inner_width, 0) + context.arc(inner_x1, inner_y, radius, PI/2, -PI/2) + +def circular_rect_negative(context, x, y, width, height): + """The same path as ``circular_rect()``, but going counter clockwise. + """ + radius = height / 2.0 + inner_width = width - height + inner_y = y + radius + inner_x1 = x + radius + inner_x2 = inner_x1 + inner_width + + context.move_to(inner_x1, y) + context.arc_negative(inner_x1, inner_y, radius, -PI/2, PI/2) + context.rel_line_to(inner_width, 0) + context.arc_negative(inner_x2, inner_y, radius, PI/2, -PI/2) + context.rel_line_to(-inner_width, 0) + +class Shadow(object): + """Encapsulates all parameters required to draw shadows. + """ + def __init__(self, color, opacity, offset, blur_radius): + self.color = color + self.opacity = opacity + self.offset = offset + self.blur_radius = blur_radius + +class ThreeImageSurface(object): + """Takes a left, center and right image and draws them to an arbitrary + width. If the width is greater than the combined width of the 3 images, + then the center image will be tiled to compensate. + + Example: + + >>> timelinebar = ThreeImageSurface("timelinebar") + + This creates a ``ThreeImageSurface`` using the images + ``images/timelinebar_left.png``, ``images/timelinebar_center.png``, and + ``images/timelinebar_right.png``. + + Example: + + >>> timelinebar = ThreeImageSurface() + >>> img_left = make_surface("timelinebar_left") + >>> img_center = make_surface("timelinebar_center") + >>> img_right = make_surface("timelinebar_right") + >>> timelinebar.set_images(img_left, img_center, img_right) + + This does the same thing, but allows you to explicitly set which images + get used. + """ + def __init__(self, basename=None, height=None): + self.left = self.center = self.right = None + self.height = 0 + self.width = None + if basename is not None: + left = make_surface(basename + '_left', height) + center = make_surface(basename + '_center', height) + right = make_surface(basename + '_right', height) + self.set_images(left, center, right) + + def set_images(self, left, center, right): + """Sets the left, center and right images to use. + """ + self.left = left + self.center = center + self.right = right + if not (self.left.height == self.center.height == self.right.height): + raise ValueError("Images aren't the same height") + self.height = self.left.height + + def set_width(self, width): + """Manually set a width. + + When ThreeImageSurface have a width, then they have pretty much the + same API as ImageSurface does. In particular, they can now be nested + in another ThreeImageSurface. + """ + self.width = width + + def get_size(self): + return self.width, self.height + + def draw(self, context, x, y, width, fraction=1.0): + left_width = min(self.left.width, width) + self.left.draw(context, x, y, left_width, self.height, fraction) + self.draw_right(context, x + left_width, y, width - left_width, fraction) + + def draw_right(self, context, x, y, width, fraction=1.0): + # draws only the right two images + + right_width = min(self.right.width, width) + center_width = int(width - right_width) + + self.center.draw(context, x, y, center_width, self.height, fraction) + self.right.draw(context, x + center_width, y, right_width, self.height, fraction) diff --git a/lvc/windows/__init__.py b/lvc/windows/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lvc/windows/__init__.py diff --git a/lvc/windows/autoupdate.py b/lvc/windows/autoupdate.py new file mode 100644 index 0000000..f6d47c8 --- /dev/null +++ b/lvc/windows/autoupdate.py @@ -0,0 +1,101 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2017 +# Jesus E. +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""Autoupdate functionality """ + +import ctypes +import _winreg as winreg +import logging + +winsparkle = ctypes.cdll.WinSparkle + +APPCAST_URL = 'http://miro-updates.participatoryculture.org/lvc-appcast.xml' + +def startup(): + enable_automatic_checks() + winsparkle.win_sparkle_set_appcast_url(APPCAST_URL) + winsparkle.win_sparkle_init() + +def shutdown(): + winsparkle.win_sparkle_cleanup() + +def enable_automatic_checks(): + # We should be able to use win_sparkle_set_automatic_check_for_updates, + # but that's only available after version 0.4 and the current release + # version is 0.4 + with open_winsparkle_key() as winsparkle_key: + if not check_for_updates_set(winsparkle_key): + set_default_check_for_updates(winsparkle_key) + +def open_winsparkle_key(): + """Open the MVC WinSparkle registry key + + If any components are not created yet, we will try to create them + """ + with open_or_create_key(winreg.HKEY_CURRENT_USER, "Software") as software: + with open_or_create_key(software, + "Participatory Culture Foundation") as pcf: + with open_or_create_key(pcf, "Libre Video Converter") as lvc: + return open_or_create_key(lvc, "WinSparkle", + write_access=True) + +def open_or_create_key(key, subkey, write_access=False): + if write_access: + sam = winreg.KEY_READ | winreg.KEY_WRITE + else: + sam = winreg.KEY_READ + try: + return winreg.OpenKey(key, subkey, 0, sam) + except OSError, e: + if e.errno == 2: + # Not Found error. We should create the key + return winreg.CreateKey(key, subkey) + else: + raise + +def check_for_updates_set(winsparkle_key): + try: + winreg.QueryValueEx(winsparkle_key, "CheckForUpdates") + except OSError, e: + if e.errno == 2: + # not found error. + return False + else: + raise + else: + return True + + +def set_default_check_for_updates(winsparkle_key): + """Initialize the WinSparkle regstry values with our defaults. + + :param lvc_key winreg.HKey object for to the MVC registry + """ + logging.info("Writing WinSparkle keys") + winreg.SetValueEx(winsparkle_key, "CheckForUpdates", 0, winreg.REG_SZ, "1") diff --git a/lvc/windows/exe_main.py b/lvc/windows/exe_main.py new file mode 100755 index 0000000..66b97a5 --- /dev/null +++ b/lvc/windows/exe_main.py @@ -0,0 +1,22 @@ +# before anything else, settup logging +from lvc.windows import exelogging +exelogging.setup_logging() + +import os +import sys + +from lvc import settings +from lvc.windows import autoupdate +from lvc.widgets import app +from lvc.widgets import initialize +from lvc.ui.widgets import Application + +# add the directories for ffmpeg and avconv to our search path +exe_dir = os.path.dirname(sys.executable) +settings.add_to_search_path(os.path.join(exe_dir, 'ffmpeg')) +settings.add_to_search_path(os.path.join(exe_dir, 'avconv')) +# run the app +app.widgetapp = Application() +app.widgetapp.connect("window-shown", lambda w: autoupdate.startup()) +initialize(app.widgetapp) +autoupdate.shutdown() diff --git a/lvc/windows/exelogging.py b/lvc/windows/exelogging.py new file mode 100644 index 0000000..a63c836 --- /dev/null +++ b/lvc/windows/exelogging.py @@ -0,0 +1,91 @@ +"""lvc.windows.exelogging -- handle logging inside an exe file + +Most of this is copied from the Miro code. +""" + +import logging +import os +import sys +import tempfile +from StringIO import StringIO +from logging.handlers import RotatingFileHandler + +class ApatheticRotatingFileHandler(RotatingFileHandler): + """The whole purpose of this class is to prevent rotation errors + from percolating up into stdout/stderr and popping up a dialog + that's not particularly useful to users or us. + """ + def doRollover(self): + # If you shut down LibreVideoConverter then start it up again immediately + # afterwards, then we get in this squirrely situation where + # the log is opened by another process. We ignore the + # exception, but make sure we have an open file. (bug #11228) + try: + RotatingFileHandler.doRollover(self) + except WindowsError: + if not self.stream or self.stream.closed: + self.stream = open(self.baseFilename, "a") + try: + RotatingFileHandler.doRollover(self) + except WindowsError: + pass + + def shouldRollover(self, record): + # if doRollover doesn't work, then we don't want to find + # ourselves in a situation where we're trying to do things on + # a closed stream. + if self.stream.closed: + self.stream = open(self.baseFilename, "a") + return RotatingFileHandler.shouldRollover(self, record) + + def handleError(self, record): + # ignore logging errors that occur rather than printing them to + # stdout/stderr which isn't helpful to us + + pass +class AutoLoggingStream(StringIO): + """Create a stream that intercepts write calls and sends them to + the log. + """ + def __init__(self, logging_callback, prefix): + StringIO.__init__(self) + # We init from StringIO to give us a bunch of stream-related + # methods, like closed() and read() automatically. + self.logging_callback = logging_callback + self.prefix = prefix + + def write(self, data): + if isinstance(data, unicode): + data = data.encode('ascii', 'backslashreplace') + if data.endswith("\n"): + data = data[:-1] + if data: + self.logging_callback(self.prefix + data) + +FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" +def setup_logging(): + """Setup logging for when we're running inside a windows exe. + + The object here is to avoid logging anything to stderr since + windows will consider that an error. + + We also catch things written to sys.stdout and forward that to the logging + system. + + Finally we also copy the log output to stdout so that when MVC is run in + console mode we see the logs + """ + + log_path = os.path.join(tempfile.gettempdir(), "MVC.log") + rotater = ApatheticRotatingFileHandler( + log_path, mode="a", maxBytes=100000, backupCount=5) + + formatter = logging.Formatter(FORMAT) + rotater.setFormatter(formatter) + logger = logging.getLogger('') + logger.addHandler(rotater) + logger.addHandler(logging.StreamHandler(sys.stdout)) + logger.setLevel(logging.INFO) + rotater.doRollover() + sys.stdout = AutoLoggingStream(logging.warn, '(from stdout) ') + sys.stderr = AutoLoggingStream(logging.error, '(from stderr) ') diff --git a/lvc/windows/specialfolders.py b/lvc/windows/specialfolders.py new file mode 100644 index 0000000..2e1e7c6 --- /dev/null +++ b/lvc/windows/specialfolders.py @@ -0,0 +1,94 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""Contains the locations of special windows folders like "My +Documents". +""" + +import ctypes +import os +# from miro import u3info + +GetShortPathName = ctypes.windll.kernel32.GetShortPathNameW + +_special_folder_CSIDLs = { + "Fonts": 0x0014, + "AppData": 0x001a, + "My Music": 0x000d, + "My Pictures": 0x0027, + "My Videos": 0x000e, + "My Documents": 0x0005, + "Desktop": 0x0000, + "Common AppData": 0x0023, + "System": 0x0025 +} + +def get_short_path_name(name): + """Given a path, returns the shortened path name. + """ + buf = ctypes.c_wchar_p(name) + buf2 = ctypes.create_unicode_buffer(1024) + + if GetShortPathName(buf, buf2, 1024): + return buf2.value + else: + return buf.value + +def get_special_folder(name): + """Get the location of a special folder. name should be one of + the following: 'AppData', 'My Music', 'My Pictures', 'My Videos', + 'My Documents', 'Desktop'. + + The path to the folder will be returned, or None if the lookup + fails + """ + try: + csidl = _special_folder_CSIDLs[name] + except KeyError: + # FIXME - this will silently fail if the dev did a typo + # for the path name. e.g. My Musc + return None + + buf = ctypes.create_unicode_buffer(260) + SHGetSpecialFolderPath = ctypes.windll.shell32.SHGetSpecialFolderPathW + if SHGetSpecialFolderPath(None, buf, csidl, False): + return buf.value + else: + return None + +common_app_data_directory = get_special_folder("Common AppData") +app_data_directory = get_special_folder("AppData") + +base_movies_directory = get_special_folder('My Videos') +non_video_directory = get_special_folder('Desktop') +# The "My Videos" folder isn't guaranteed to be listed. If it isn't +# there, we do this hack. +if base_movies_directory is None: + base_movies_directory = os.path.join( + get_special_folder('My Documents'), 'My Videos') |