aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJesús Eduardo <heckyel@hyperbola.info>2017-05-31 18:08:31 -0500
committerJesús Eduardo <heckyel@hyperbola.info>2017-05-31 18:08:31 -0500
commite1180428ed3e7634fe1596103511fbb1da05f228 (patch)
tree13de9592bcde7050b089b9644839668024c518b3
downloadlibrevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.tar.lz
librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.tar.xz
librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.zip
first commit
-rw-r--r--LICENSE674
-rw-r--r--MANIFEST.in11
-rw-r--r--README.md41
-rw-r--r--README.widgets17
-rwxr-xr-xbuild-windows.sh12
-rw-r--r--build.sh27
-rw-r--r--build_installer.sh6
-rwxr-xr-xclean.sh5
-rwxr-xr-xhelperscripts/debian_ubuntu.sh17
-rw-r--r--helperscripts/windows-virtualenv/README.txt20
-rwxr-xr-xhelperscripts/windows-virtualenv/__main__.py353
-rwxr-xr-xhelperscripts/windows-virtualenv/virtualenv.py2274
-rwxr-xr-xmake_disk_image.sh15
-rw-r--r--mvc/__init__.py37
-rw-r--r--mvc/__init__.pycbin0 -> 1589 bytes
-rw-r--r--mvc/__main__.py9
-rw-r--r--mvc/basicconverters.py132
-rw-r--r--mvc/conversion.py313
-rw-r--r--mvc/conversion.pycbin0 -> 10706 bytes
-rw-r--r--mvc/converter.py278
-rw-r--r--mvc/converter.pycbin0 -> 11008 bytes
-rw-r--r--mvc/errors.py89
-rw-r--r--mvc/errors.pycbin0 -> 3525 bytes
-rw-r--r--mvc/execute.py49
-rw-r--r--mvc/execute.pycbin0 -> 1874 bytes
-rw-r--r--mvc/openfiles.py46
-rw-r--r--mvc/openfiles.pycbin0 -> 1888 bytes
-rw-r--r--mvc/osx/__init__.py0
-rw-r--r--mvc/osx/app_main.py12
-rw-r--r--mvc/osx/autoupdate.py9
-rw-r--r--mvc/qtfaststart/__init__.py1
-rw-r--r--mvc/qtfaststart/__init__.pycbin0 -> 140 bytes
-rw-r--r--mvc/qtfaststart/exceptions.py5
-rw-r--r--mvc/qtfaststart/exceptions.pycbin0 -> 400 bytes
-rwxr-xr-xmvc/qtfaststart/processor.py215
-rw-r--r--mvc/qtfaststart/processor.pycbin0 -> 5484 bytes
-rw-r--r--mvc/resources/__init__.py21
-rw-r--r--mvc/resources/__init__.pycbin0 -> 1141 bytes
-rw-r--r--mvc/resources/converters/android.py61
-rw-r--r--mvc/resources/converters/apple.py28
-rw-r--r--mvc/resources/converters/others.py20
-rw-r--r--mvc/resources/images/android-icon-off.pngbin0 -> 3403 bytes
-rw-r--r--mvc/resources/images/android-icon-on.pngbin0 -> 3264 bytes
-rw-r--r--mvc/resources/images/apple-icon-off.pngbin0 -> 3301 bytes
-rw-r--r--mvc/resources/images/apple-icon-on.pngbin0 -> 3140 bytes
-rw-r--r--mvc/resources/images/arrow-down-off.pngbin0 -> 2806 bytes
-rw-r--r--mvc/resources/images/arrow-down-on.pngbin0 -> 2762 bytes
-rw-r--r--mvc/resources/images/audio.pngbin0 -> 4857 bytes
-rw-r--r--mvc/resources/images/clear-icon.pngbin0 -> 3051 bytes
-rw-r--r--mvc/resources/images/convert-button-off.pngbin0 -> 3727 bytes
-rw-r--r--mvc/resources/images/convert-button-on.pngbin0 -> 3912 bytes
-rw-r--r--mvc/resources/images/convert-button-stop.pngbin0 -> 3670 bytes
-rw-r--r--mvc/resources/images/converted_to-icon.pngbin0 -> 3023 bytes
-rw-r--r--mvc/resources/images/dropoff-icon-off.pngbin0 -> 5363 bytes
-rw-r--r--mvc/resources/images/dropoff-icon-on.pngbin0 -> 4967 bytes
-rw-r--r--mvc/resources/images/dropoff-icon-small-off.pngbin0 -> 3998 bytes
-rw-r--r--mvc/resources/images/dropoff-icon-small-on.pngbin0 -> 3792 bytes
-rw-r--r--mvc/resources/images/error-icon.pngbin0 -> 3018 bytes
-rw-r--r--mvc/resources/images/item-completed.pngbin0 -> 3417 bytes
-rw-r--r--mvc/resources/images/item-delete-button-off.pngbin0 -> 3492 bytes
-rw-r--r--mvc/resources/images/item-delete-button-on.pngbin0 -> 3608 bytes
-rw-r--r--mvc/resources/images/item-error.pngbin0 -> 3449 bytes
-rw-r--r--mvc/resources/images/mvc-logo.pngbin0 -> 2617 bytes
-rw-r--r--mvc/resources/images/other-icon-off.pngbin0 -> 3008 bytes
-rw-r--r--mvc/resources/images/other-icon-on.pngbin0 -> 2930 bytes
-rw-r--r--mvc/resources/images/progressbar-base.pngbin0 -> 2950 bytes
-rw-r--r--mvc/resources/images/queued-icon.pngbin0 -> 2744 bytes
-rw-r--r--mvc/resources/images/settings-base_center.pngbin0 -> 2833 bytes
-rw-r--r--mvc/resources/images/settings-base_left.pngbin0 -> 3024 bytes
-rw-r--r--mvc/resources/images/settings-base_right.pngbin0 -> 3044 bytes
-rw-r--r--mvc/resources/images/settings-depth_center.pngbin0 -> 2791 bytes
-rw-r--r--mvc/resources/images/settings-depth_left.pngbin0 -> 2966 bytes
-rw-r--r--mvc/resources/images/settings-depth_right.pngbin0 -> 2959 bytes
-rw-r--r--mvc/resources/images/settings-dropdown-bottom-bg.pngbin0 -> 3956 bytes
-rw-r--r--mvc/resources/images/settings-icon-off.pngbin0 -> 3503 bytes
-rw-r--r--mvc/resources/images/settings-icon-on.pngbin0 -> 3266 bytes
-rw-r--r--mvc/resources/images/showfile-icon.pngbin0 -> 3030 bytes
-rw-r--r--mvc/resources/nsis/modern-wizard.bmpbin0 -> 154542 bytes
-rw-r--r--mvc/resources/nsis/mvc-logo.icobin0 -> 15086 bytes
-rw-r--r--mvc/resources/nsis/plugins/nsProcess.dllbin0 -> 4096 bytes
-rw-r--r--mvc/resources/nsis/plugins/nsProcess.nsh21
-rw-r--r--mvc/resources/windows/README7
-rwxr-xr-xmvc/resources/windows/gtkrc182
-rw-r--r--mvc/settings.py88
-rw-r--r--mvc/settings.pycbin0 -> 3368 bytes
-rw-r--r--mvc/signals.py301
-rw-r--r--mvc/signals.pycbin0 -> 11000 bytes
-rw-r--r--mvc/ui/__init__.py0
-rw-r--r--mvc/ui/__init__.pycbin0 -> 105 bytes
-rw-r--r--mvc/ui/console.py120
-rw-r--r--mvc/ui/widgets.py1540
-rw-r--r--mvc/utils.py230
-rw-r--r--mvc/utils.pycbin0 -> 8299 bytes
-rw-r--r--mvc/video.py287
-rw-r--r--mvc/video.pycbin0 -> 9180 bytes
-rw-r--r--mvc/widgets/__init__.py30
-rw-r--r--mvc/widgets/__init__.pycbin0 -> 1066 bytes
-rw-r--r--mvc/widgets/app.py4
-rw-r--r--mvc/widgets/app.pycbin0 -> 134 bytes
-rw-r--r--mvc/widgets/cellpack.py843
-rw-r--r--mvc/widgets/cellpack.pycbin0 -> 38099 bytes
-rw-r--r--mvc/widgets/dialogs.py276
-rw-r--r--mvc/widgets/gtk/__init__.py65
-rw-r--r--mvc/widgets/gtk/__init__.pycbin0 -> 3063 bytes
-rw-r--r--mvc/widgets/gtk/base.py300
-rw-r--r--mvc/widgets/gtk/base.pycbin0 -> 11779 bytes
-rw-r--r--mvc/widgets/gtk/const.py44
-rw-r--r--mvc/widgets/gtk/const.pycbin0 -> 592 bytes
-rw-r--r--mvc/widgets/gtk/contextmenu.py31
-rw-r--r--mvc/widgets/gtk/contextmenu.pycbin0 -> 1441 bytes
-rw-r--r--mvc/widgets/gtk/controls.py337
-rw-r--r--mvc/widgets/gtk/controls.pycbin0 -> 14667 bytes
-rw-r--r--mvc/widgets/gtk/customcontrols.py517
-rw-r--r--mvc/widgets/gtk/customcontrols.pycbin0 -> 21044 bytes
-rw-r--r--mvc/widgets/gtk/drawing.py268
-rw-r--r--mvc/widgets/gtk/drawing.pycbin0 -> 11357 bytes
-rw-r--r--mvc/widgets/gtk/gtkmenus.py404
-rw-r--r--mvc/widgets/gtk/gtkmenus.pycbin0 -> 13932 bytes
-rw-r--r--mvc/widgets/gtk/keymap.py94
-rw-r--r--mvc/widgets/gtk/keymap.pycbin0 -> 2518 bytes
-rw-r--r--mvc/widgets/gtk/layout.py227
-rw-r--r--mvc/widgets/gtk/layout.pycbin0 -> 9307 bytes
-rw-r--r--mvc/widgets/gtk/layoutmanager.py550
-rw-r--r--mvc/widgets/gtk/layoutmanager.pycbin0 -> 20860 bytes
-rw-r--r--mvc/widgets/gtk/simple.py313
-rw-r--r--mvc/widgets/gtk/simple.pycbin0 -> 13793 bytes
-rw-r--r--mvc/widgets/gtk/tableview.py1557
-rw-r--r--mvc/widgets/gtk/tableview.pycbin0 -> 61350 bytes
-rw-r--r--mvc/widgets/gtk/tableviewcells.py249
-rw-r--r--mvc/widgets/gtk/tableviewcells.pycbin0 -> 10099 bytes
-rw-r--r--mvc/widgets/gtk/weakconnect.py56
-rw-r--r--mvc/widgets/gtk/weakconnect.pycbin0 -> 1566 bytes
-rw-r--r--mvc/widgets/gtk/widgets.py47
-rw-r--r--mvc/widgets/gtk/widgets.pycbin0 -> 1125 bytes
-rw-r--r--mvc/widgets/gtk/widgetset.py63
-rw-r--r--mvc/widgets/gtk/widgetset.pycbin0 -> 2414 bytes
-rw-r--r--mvc/widgets/gtk/window.py708
-rw-r--r--mvc/widgets/gtk/window.pycbin0 -> 25481 bytes
-rw-r--r--mvc/widgets/gtk/wrappermap.py50
-rw-r--r--mvc/widgets/gtk/wrappermap.pycbin0 -> 731 bytes
-rw-r--r--mvc/widgets/keyboard.py69
-rw-r--r--mvc/widgets/keyboard.pycbin0 -> 2000 bytes
-rw-r--r--mvc/widgets/menus.py268
-rw-r--r--mvc/widgets/menus.pycbin0 -> 10853 bytes
-rw-r--r--mvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib145
-rw-r--r--mvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nibbin0 -> 1609 bytes
-rw-r--r--mvc/widgets/osx/__init__.py74
-rw-r--r--mvc/widgets/osx/base.py367
-rw-r--r--mvc/widgets/osx/const.py44
-rw-r--r--mvc/widgets/osx/contextmenu.py84
-rw-r--r--mvc/widgets/osx/control.py530
-rw-r--r--mvc/widgets/osx/customcontrol.py436
-rw-r--r--mvc/widgets/osx/drawing.py289
-rw-r--r--mvc/widgets/osx/drawingwidgets.py67
-rw-r--r--mvc/widgets/osx/fasttypes.c540
-rw-r--r--mvc/widgets/osx/helpers.py95
-rw-r--r--mvc/widgets/osx/layout.py748
-rw-r--r--mvc/widgets/osx/layoutmanager.py445
-rw-r--r--mvc/widgets/osx/osxmenus.py571
-rw-r--r--mvc/widgets/osx/rect.py78
-rw-r--r--mvc/widgets/osx/simple.py376
-rw-r--r--mvc/widgets/osx/tablemodel.py532
-rw-r--r--mvc/widgets/osx/tableview.py1629
-rw-r--r--mvc/widgets/osx/utils.py2
-rw-r--r--mvc/widgets/osx/viewport.py101
-rw-r--r--mvc/widgets/osx/widgetset.py58
-rw-r--r--mvc/widgets/osx/widgetupdates.py72
-rw-r--r--mvc/widgets/osx/window.py896
-rw-r--r--mvc/widgets/osx/wrappermap.py48
-rw-r--r--mvc/widgets/tablescroll.py154
-rw-r--r--mvc/widgets/tablescroll.pycbin0 -> 4234 bytes
-rw-r--r--mvc/widgets/tableselection.py220
-rw-r--r--mvc/widgets/tableselection.pycbin0 -> 8450 bytes
-rw-r--r--mvc/widgets/widgetconst.py44
-rw-r--r--mvc/widgets/widgetconst.pycbin0 -> 438 bytes
-rw-r--r--mvc/widgets/widgetutil.py225
-rw-r--r--mvc/widgets/widgetutil.pycbin0 -> 9690 bytes
-rw-r--r--mvc/windows/__init__.py0
-rw-r--r--mvc/windows/autoupdate.py101
-rwxr-xr-xmvc/windows/exe_main.py22
-rw-r--r--mvc/windows/exelogging.py91
-rw-r--r--mvc/windows/specialfolders.py94
-rwxr-xr-xrun-windows.sh12
-rw-r--r--scripts/libre-video-converter.py10
-rw-r--r--setup-files/linux/debian-precise/changelog5
-rw-r--r--setup-files/linux/debian-precise/compat1
-rw-r--r--setup-files/linux/debian-precise/control17
-rw-r--r--setup-files/linux/debian-precise/copyright38
-rwxr-xr-xsetup-files/linux/debian-precise/rules7
-rw-r--r--setup-files/linux/debian-precise/source/format1
-rw-r--r--setup-files/linux/debian-quantal/changelog5
-rw-r--r--setup-files/linux/debian-quantal/compat1
-rw-r--r--setup-files/linux/debian-quantal/control17
-rw-r--r--setup-files/linux/debian-quantal/copyright38
-rwxr-xr-xsetup-files/linux/debian-quantal/rules7
-rw-r--r--setup-files/linux/debian-quantal/source/format1
-rw-r--r--setup-files/linux/icons/hicolor/16x16/apps/librevideoconverter.pngbin0 -> 693 bytes
-rw-r--r--setup-files/linux/icons/hicolor/22x22/apps/librevideoconverter.pngbin0 -> 1070 bytes
-rw-r--r--setup-files/linux/icons/hicolor/32x32/apps/librevideoconverter.pngbin0 -> 1375 bytes
-rw-r--r--setup-files/linux/icons/hicolor/48x48/apps/librevideoconverter.pngbin0 -> 2617 bytes
-rw-r--r--setup-files/linux/librevideoconverter.desktop11
-rw-r--r--setup-files/linux/setup.py95
-rw-r--r--setup-files/osx/Info.plist406
-rw-r--r--setup-files/osx/mvc3.icnsbin0 -> 824602 bytes
-rw-r--r--setup-files/osx/mvc3_definition.plist15
-rw-r--r--setup-files/osx/setup.py123
-rw-r--r--setup-files/osx/sparkle-keys/dsa_pub.pem20
-rwxr-xr-xsetup-files/windows/mvc.nsi195
-rw-r--r--setup-files/windows/setup.py146
-rw-r--r--setup.py53
-rw-r--r--sign.sh28
-rw-r--r--test/base.py7
-rw-r--r--test/mock.py2356
-rw-r--r--test/runtests.py17
-rw-r--r--test/test_conversion.py190
-rw-r--r--test/test_converter.py1330
-rw-r--r--test/test_utils.py48
-rw-r--r--test/test_video.py258
-rw-r--r--test/testdata/drm.m4vbin0 -> 2097152 bytes
-rw-r--r--test/testdata/fake_converter.py31
-rw-r--r--test/testdata/mp3-0.mp3bin0 -> 17168 bytes
-rw-r--r--test/testdata/mp3-1.mp3bin0 -> 17144 bytes
-rw-r--r--test/testdata/mp3-2.mp3bin0 -> 8793 bytes
-rw-r--r--test/testdata/mp4-0.mp4bin0 -> 262144 bytes
-rw-r--r--test/testdata/nuls.mp3bin0 -> 17168 bytes
-rw-r--r--test/testdata/theora.ogvbin0 -> 109570 bytes
-rw-r--r--test/testdata/theora_with_ogg_extension.oggbin0 -> 16384 bytes
-rw-r--r--test/testdata/webm-0.webmbin0 -> 21446 bytes
-rw-r--r--test/uitests.sikuli/config.py47
-rw-r--r--test/uitests.sikuli/datafiles.py170
-rw-r--r--test/uitests.sikuli/devices.py109
-rw-r--r--test/uitests.sikuli/mvc_steps.py228
-rw-r--r--test/uitests.sikuli/mvcgui.py368
-rw-r--r--test/uitests.sikuli/nose.cfg6
-rw-r--r--test/uitests.sikuli/readme.md19
-rw-r--r--test/uitests.sikuli/test_android_conversions.py66
-rw-r--r--test/uitests.sikuli/test_apple_conversions.py63
-rw-r--r--test/uitests.sikuli/test_choose_files.py166
-rw-r--r--test/uitests.sikuli/test_clear_finished_conversions.py91
-rw-r--r--test/uitests.sikuli/test_conversions.py196
-rw-r--r--test/uitests.sikuli/test_other_conversions.py39
-rw-r--r--test/uitests.sikuli/test_output_settings.py74
-rw-r--r--test/uitests.sikuli/test_remove_files.py96
-rw-r--r--test/uitests.sikuli/testdata/baby_block.m4vbin0 -> 4182953 bytes
-rw-r--r--test/uitests.sikuli/testdata/fake_video.mp4bin0 -> 69367 bytes
-rw-r--r--test/uitests.sikuli/testdata/story_stuff.movbin0 -> 4089489 bytes
-rw-r--r--test/uitests.sikuli/uitests.py13
247 files changed, 31361 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..20d40b6
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>. \ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..947e61c
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,11 @@
+include mvc/resources/converters/*.py
+include *.sh
+include LICENSE
+include README.md
+include README.widgets
+include mvc/widgets/osx/fasttypes.c
+include mvc/widgets/osx/Resources-Widgets/MainMenu.nib/*.nib
+recursive-include helperscripts *
+recursive-include setup-files *
+recursive-include mvc/resources *
+recursive-include test *
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..58b6924
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# Libre Video Converter #
+
+## Acerca ##
+
+Con [Libre Video Converter](https://notabug.org/Heckyel/LibreVideoConverter) ahora es súper sencillo convertir casi cualquier vídeo a formato WebM (VP8), MP4, Ogg Theora para cualquier dispositivo u ordenador.
+
+Este programa es una versión modificada de la aplicación existente [Miro Video Converter](https://github.com/pculture/mirovideoconverter3).
+
+## Requisitos ##
+
+* Python 2.7
+* FFmpeg
+* FFmpeg2Theora
+* GTK2 and its Python bindings (for the GTK UI)
+
+Una copia de [qtfaststart](https://github.com/danielgtaylor/qtfaststart) está incluido dentro de esta aplicación, es
+bajo la licencia GPL versión 3.
+
+Puede instalar en derivados de Debian/Ubuntu como Trisquel GNU/Linux
+
+ $ sudo apt-get install -y python2.7 ffmpeg ffmpeg2theora python-gtk2
+
+Para instalar en derivados de Archlinux como Parabola GNU/Linux-Libre
+
+ $ sudo pacman -S python2 ffmpeg ffmpeg2theora gtk2
+
+## Ejecutando el programa ##
+
+ $ git clone https://notabug.org/Heckyel/LibreVideoConverter
+ $ cd LibreVideoConverter
+ $ python2.7 test/runtests.py # para realizar pruebas.
+ $ python2.7 -m mvc.ui.widgets # Interfaz en GTK
+ $ python2.7 -m mvc.ui.console [filename to convert] [conversion type] # desde la terminal o consola
+
+## Contribuir ##
+
+LibreVideoConverter es una librería de [Software Libre](https://www.gnu.org/home.es.html), y apreciamos cualquier ayuda que estés dispuesto a dar.
+
+## Licencia ##
+
+LibreVideoConverter esta bajo la licencia GNU GPLv3+ [Ver el archivo de licencia](LICENSE)
diff --git a/README.widgets b/README.widgets
new file mode 100644
index 0000000..d58c305
--- /dev/null
+++ b/README.widgets
@@ -0,0 +1,17 @@
+For now the widgets are to be grown automatically. It is mirrored after
+the installed structure of the LibreVideoConverter library so follow it please, so you can
+do diff on it.
+
+General rule is that anything in gtk/cocoa is pretty much fair game,
+there's usually some good reason for stuff in miro.frontends.widgets.
+
+For stuff in LibreVideoConverter namespace, think carefully before adding it, with
+the exception if it is utilty functions (which we will further abstract
+away) or a base class of a platform impementation (such as player.Player).
+
+Python is dynamically interpreted so it is generally safe to remove
+the top-level imports and still have the thing run.
+
+As a convention, please prepend the line with ### when you are removing
+something, and when you are adding something add ###XXXMVC on the line
+before your first addition.
diff --git a/build-windows.sh b/build-windows.sh
new file mode 100755
index 0000000..2dba3cf
--- /dev/null
+++ b/build-windows.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+if [ ! -e mvc-env ] ; then
+ echo "LVC virtualenv is not present. Run "
+ echo
+ echo " python helperscripts/windows-virtualenv/ mvc-env"
+ echo
+ echo "to build it"
+ exit 1
+fi
+
+PYTHONPATH="." mvc-env/Scripts/python.exe setup.py bdist_nsis
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..7052441
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -e
+
+if [ -z ${SANDBOX_PATH} ]; then
+ echo "you must set SANDBOX_PATH to point to the built LibreVideoConverter sandbox"
+ exit 1
+fi
+
+if [ -z ${BKIT_PATH} ]; then
+ echo "you must set BKIT_PATH to point to the LibreVideoConverter binary kit"
+ exit
+fi
+
+export MACOSX_DEPLOYMENT_TARGET=10.6
+
+${SANDBOX_PATH}/Frameworks/Python.framework/Versions/2.7/bin/python setup.py develop
+
+${SANDBOX_PATH}/Frameworks/Python.framework/Versions/2.7/bin/python setup.py py2app
+
+if [ "$1" = "--sign" ]; then
+ source sign.sh
+fi
+
+if [ "$2" = "--installer" ] ; then
+ source build_installer.sh
+fi
diff --git a/build_installer.sh b/build_installer.sh
new file mode 100644
index 0000000..2d7e4d3
--- /dev/null
+++ b/build_installer.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+productbuild \
+ --component "dist/Libre Video Converter.app" /Applications \
+ --sign 'Freedom System: Heckyel | 2017' \
+ --product setup-files/osx/mvc3_definition.plist mvc3.pkg
diff --git a/clean.sh b/clean.sh
new file mode 100755
index 0000000..a38eb67
--- /dev/null
+++ b/clean.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+rm -fr build
+rm -fr dist
+rm -fr librevideoconverter.egg-info
diff --git a/helperscripts/debian_ubuntu.sh b/helperscripts/debian_ubuntu.sh
new file mode 100755
index 0000000..57f8df0
--- /dev/null
+++ b/helperscripts/debian_ubuntu.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+# This script installs dependencies for building and running LVC on
+# Debian 8.5 (Jessie)
+#
+# You run this sript AT YOUR OWN RISK. Read through the whole thing
+# before running it!
+#
+# This script must be run with sudo.
+
+# Last updated: 2017-04-18
+# Last updated by: Jesús Eduardo
+
+apt-get install \
+ python-gtk2 \
+ ffmpeg \
+ ffmpeg2theora
diff --git a/helperscripts/windows-virtualenv/README.txt b/helperscripts/windows-virtualenv/README.txt
new file mode 100644
index 0000000..3c164c5
--- /dev/null
+++ b/helperscripts/windows-virtualenv/README.txt
@@ -0,0 +1,20 @@
+Helper script to create a windows virtual environment.
+
+Dependencies:
+ - cygwin
+ - 7zip
+
+Instructions:
+ Run python <path to window-virtualenv-dir> <env-directory>
+
+ After this <env-directory> will be a virtualenv directory that has pygtk and
+ ffmpeg loaded into it.
+
+ Run:
+
+ source <env-directory>/Scripts/activate
+
+ To set your PATH and other env variables to use that virtualenv.
+
+Warning: using the ~ char didn't seem to work for me under cygwin
+
diff --git a/helperscripts/windows-virtualenv/__main__.py b/helperscripts/windows-virtualenv/__main__.py
new file mode 100755
index 0000000..93ce533
--- /dev/null
+++ b/helperscripts/windows-virtualenv/__main__.py
@@ -0,0 +1,353 @@
+import contextlib
+import hashlib
+import os
+import sys
+import urllib
+import urlparse
+import shutil
+import subprocess
+import tarfile
+import time
+import zipfile
+from optparse import OptionParser
+
+env_dir = build_dir = site_packages_dir = scripts_dir = python_dir = None
+seven_zip_path = None
+working_dir = downloads_dir = None
+options = args = None
+
+# list of (md5 hash, url) tuples for all files that we download
+download_info = [
+ ('1694578c49e56eb1dce494408666e465',
+ 'http://lessmsi.googlecode.com/files/lessmsi-v1.0.8.zip'),
+ ('c846d7a5ed186707d3675564a9838cc2',
+ 'http://python.org/ftp/python/2.7.3/python-2.7.3.msi'),
+ ('788df97c3ceb11368c3a938e5acef0b2',
+ ('http://downloads.sourceforge.net'
+ '/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.zip')),
+ ('4bddf847f81d8de2d73048b113da3dd5',
+ ('http://ftp.gnome.org/pub/GNOME/binaries/win32/pygtk/2.24/'
+ 'pygtk-all-in-one-2.24.2.win32-py2.7.msi')),
+ ('9633cf25444f41b2fb78b0bb3f509ec3',
+ ('http://ffmpeg.zeranoe.com/builds/win32/static/'
+ 'ffmpeg-20120426-git-a4b58fd-win32-static.7z')),
+ ('d7e43beabc017a7d892a3d6663e988d4',
+ 'http://sourceforge.net/projects/nsis/files/'
+ 'NSIS%202/2.46/nsis-2.46.zip/download'),
+ ('9bd44a22bffe0e4e0b71b8b4cf3a80e2',
+ 'http://downloads.sourceforge.net/'
+ 'project/sevenzip/7-Zip/9.20/7z920.msi'),
+ ('9aa6c2d7229a37a3996270bff411ab22',
+ 'http://win32.libav.org/win32/libav-win32-20120821.7z'),
+ ('8f0347331b7023e343dc460378e23c4e',
+ 'http://downloads.sourceforge.net/'
+ 'project/winsparkle/0.3/WinSparkle-0.3.zip'),
+ ('05b59fdc6b6e6c4530154c0ac52f8a94',
+ 'http://downloads.sourceforge.net/'
+ 'project/gtk-win/GTK%2B%20Themes%20Package/2009-09-07/'
+ 'gtk2-themes-2009-09-07-win32_bin.zip'),
+
+]
+
+def get_download_hash(url):
+ """Get the md5 hash value for a downloaded file.
+
+ :param url: url for the download
+ :returns: md5 hash of the contents of the file, or None if no file found
+ """
+ download_path = get_download_path(url)
+ md5 = hashlib.md5()
+ block_size = 1024 * 10
+ if not os.path.exists(download_path):
+ return None
+ with open(download_path, 'rb') as f:
+ data = f.read(block_size)
+ while data:
+ md5.update(data)
+ data = f.read(block_size)
+ return md5.hexdigest()
+
+
+def download_files():
+ if not os.path.exists(downloads_dir):
+ os.makedirs(downloads_dir)
+ for md5hash, url in download_info:
+ download_path = get_download_path(url)
+ if os.path.exists(download_path):
+ download_hash = get_download_hash(url)
+ if download_hash == md5hash:
+ continue
+ else:
+ writeout("md5 hash verification failed")
+ writeout(" %s", url)
+ writeout(" correct: %s", md5hash)
+ writeout(" downloaded: %s", download_hash)
+ download_url(url)
+
+def download_url(url):
+ """Download a url to the build directory."""
+
+ download_path = get_download_path(url)
+ basename = os.path.basename(download_path)
+ writeout("* Downloading %s", basename)
+ time_info = { 'last': 0}
+ def reporthook(block_count, block_size, total_size):
+ now = time.time()
+ if total_size > 0 and now - time_info['last'] > 0.5:
+ percent_complete = (block_count * block_size * 100.0) / total_size
+ writeout_and_stay(" %0.1f complete", percent_complete)
+ time_info['last'] = now
+ urllib.urlretrieve(url, download_path, reporthook)
+ writeout_and_stay(" 100.0%% complete")
+
+
+def get_download_path(url):
+ """Get the path to a downloaded file.
+
+ :param url:
+ """
+ parsed_url = urlparse.urlparse(url)
+ if parsed_url.netloc == 'sourceforge.net':
+ # sourceforge adds an extra "/download" at the end of the url
+ basename = os.path.basename(os.path.dirname(parsed_url.path))
+ else:
+ # default case
+ basename = os.path.basename(parsed_url.path)
+ return os.path.join(downloads_dir, basename)
+
+
+def setup_global_dirs(parser_args):
+ global env_dir, working_dir, build_dir, site_packages_dir, scripts_dir
+ global python_dir, downloads_dir
+
+ env_dir = os.path.abspath(parser_args[0])
+ working_dir = os.getcwd()
+ downloads_dir = os.path.abspath(
+ os.path.join(os.path.dirname(__file__), '..', '..', 'downloads',
+ 'windows-virtualenv'))
+ build_dir = os.path.abspath(os.path.join('mvc-env-build'))
+ site_packages_dir = os.path.join(env_dir, "Lib", "site-packages")
+ scripts_dir = os.path.join(env_dir, "Scripts")
+ python_dir = os.path.join(env_dir, "Python")
+
+def parse_args():
+ global options, args
+
+ usage = "usage: %prog [options] env-directory"
+ parser = OptionParser(usage)
+
+ parser.add_option("-f", "--force", dest="force",
+ action="store_true",
+ help="overwrite env directory if it exists")
+
+ (options, args) = parser.parse_args()
+ if len(args) < 1:
+ parser.error("must specify env-directory")
+ if len(args) > 1:
+ parser.error("can only specify one env-directory")
+ setup_global_dirs(args)
+ if os.path.exists(env_dir) and not options.force:
+ parser.error("%s exists. Use -f to overwrite" % env_dir)
+
+def writeout(msg, *args):
+ """write a line to stdout."""
+ print msg % args
+
+def writeout_and_stay(msg, *args):
+ """write a line to stdout and stay on the same line."""
+
+ # clear out old line
+ print " " * 70 + "\r",
+ # write out new line
+ print (msg % args) + "\r",
+ sys.stdout.flush()
+
+def rmtree_if_exists(directory):
+ """Remove a directory tree, if it exists."""
+ if os.path.exists(directory):
+ if not options.force:
+ raise AssertionError("%s exists and force not set" % directory)
+ writeout("Removing %s", directory)
+ shutil.rmtree(directory)
+
+def check_call(*command_line):
+ subprocess.check_call(command_line)
+
+@contextlib.contextmanager
+def build_dir_context():
+ """Context to create a build directory and delete it afterwards.
+ """
+
+ rmtree_if_exists(build_dir)
+ os.makedirs(build_dir)
+ yield
+ shutil.rmtree(build_dir)
+
+def movetree(source_dir, dest_dir):
+ """Move the contents of source_dir into dest_dir
+
+ For each file/directory in source dir, copy it to dest_dir. If this would
+ overwrite a file/directory, then an IOError will be raised
+ """
+ for name in os.listdir(source_dir):
+ source_child = os.path.join(source_dir, name)
+ writeout("* moving %s to %s", name, dest_dir)
+ shutil.move(source_child, os.path.join(dest_dir, name))
+
+def extract_zip(zip_path, dest_dir):
+ writeout("* Extracting %s", zip_path)
+ archive = zipfile.ZipFile(zip_path, 'r')
+ for name in archive.namelist():
+ writeout("** %s", name)
+ archive.extract(name, dest_dir)
+ archive.close()
+
+def extract_tarball(tarball_path, dest_dir):
+ writeout("* Extracting %s", tarball_path)
+ archive = tarfile.open(tarball_path, 'r')
+ archive.extractall(dest_dir)
+ archive.close()
+
+def run_pip_install(package_name, version):
+ pip_path = os.path.join(scripts_dir, 'pip.exe')
+ check_call(pip_path, 'install', "%s==%s" % (package_name, version))
+
+def install_gtk_theme_files():
+ gtk_themes_url = ('http://downloads.sourceforge.net/'
+ 'project/gtk-win/GTK%2B%20Themes%20Package/2009-09-07/'
+ 'gtk2-themes-2009-09-07-win32_bin.zip')
+ extract_zip(get_download_path(gtk_themes_url), env_dir)
+
+def install_nsis():
+ url = ('http://sourceforge.net/projects/nsis/files/'
+ 'NSIS%202/2.46/nsis-2.46.zip/download')
+ zip_path = get_download_path(url)
+ # extract directory to the env directory since all files in the archive
+ # are inside nsis-2.46 directory
+ extract_zip(zip_path, env_dir)
+
+def install_lessmsi():
+ url = "http://lessmsi.googlecode.com/files/lessmsi-v1.0.8.zip"
+ zip_path = get_download_path(url)
+ extract_zip(zip_path, os.path.join(build_dir, 'lessmsi'))
+ # make all files executable
+ for filename in ('lessmsi.exe', 'wix.dll', 'wixcab.dll'):
+ path = os.path.join(build_dir, 'lessmsi', filename)
+ check_call("chmod", "+x", path)
+
+def run_lessmsi(msi_path, output_dir):
+ writeout("* Extracting MSI %s", os.path.basename(msi_path))
+ lessmsi_path = os.path.join(build_dir, 'lessmsi', 'lessmsi.exe')
+ check_call(lessmsi_path, "/x", msi_path, output_dir)
+
+def install_7zip():
+ global seven_zip_path
+ url = 'http://downloads.sourceforge.net/project/sevenzip/7-Zip/9.20/7z920.msi'
+ msi_path = get_download_path(url)
+ build_path = os.path.join(build_dir, 'z7ip')
+ run_lessmsi(msi_path, build_path)
+
+ seven_zip_path = os.path.join(build_path, "SourceDir", "Files", "7-Zip", "7z.exe")
+
+def make_env_dir():
+ rmtree_if_exists(env_dir)
+ os.makedirs(env_dir)
+
+def install_virtualenv():
+ virtualenv_path = os.path.join(os.path.dirname(__file__), "virtualenv.py")
+ python_path = os.path.join(python_dir, "python.exe")
+ check_call("python", virtualenv_path, "-p", python_path, '--clear',
+ env_dir)
+
+def install_python():
+ url = "http://python.org/ftp/python/2.7.3/python-2.7.3.msi"
+ download_path = get_download_path(url)
+ build_path = os.path.join(build_dir, 'Python')
+ run_lessmsi(download_path, build_path)
+ shutil.move(os.path.join(build_path, "SourceDir"), python_dir)
+
+def install_py2exe():
+ url = ("http://downloads.sourceforge.net"
+ "/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.zip")
+ zip_path = get_download_path(url)
+ extract_zip(zip_path, os.path.join(build_dir, 'py2exe'))
+ writeout("* Installing py2exe")
+ os.chdir(os.path.join(build_dir, 'py2exe', 'py2exe-0.6.9'))
+ check_call(os.path.join(scripts_dir, "python.exe"), 'setup.py', 'install')
+ os.chdir(working_dir)
+
+def install_pygtk():
+ url = ('http://ftp.gnome.org/pub/GNOME/binaries/win32/pygtk/2.24/'
+ 'pygtk-all-in-one-2.24.2.win32-py2.7.msi')
+ msi_path = get_download_path(url)
+ build_path = os.path.join(build_dir, 'pygtk-all-in-one')
+ run_lessmsi(msi_path, build_path)
+
+ source_package_dir = os.path.join(build_path, "SourceDir", "Lib",
+ "site-packages")
+ writeout("* Copying pygtk site-packages")
+ movetree(source_package_dir, site_packages_dir)
+
+def install_ffmpeg():
+ url = ("http://ffmpeg.zeranoe.com/builds/win32/static/"
+ "ffmpeg-20120426-git-a4b58fd-win32-static.7z")
+ download_path = get_download_path(url)
+ check_call(seven_zip_path, "x", download_path, '-o' + build_dir)
+
+ archive_dir = os.path.join(build_dir,
+ os.path.splitext(os.path.basename(url))[0])
+ dest_dir = os.path.join(env_dir, "ffmpeg")
+
+ os.mkdir(dest_dir)
+ shutil.move(os.path.join(archive_dir, "presets"),
+ os.path.join(dest_dir, "presets"))
+
+ for exe_name in ("ffmpeg.exe", "ffplay.exe", "ffprobe.exe"):
+ shutil.move(os.path.join(archive_dir, "bin", exe_name),
+ os.path.join(dest_dir, exe_name))
+
+def install_avconv():
+ # We use a nightly build of avconv because I (BDK) couldn't find any
+ # official builds after version 7.7, released in June 2011
+ url = 'http://win32.libav.org/win32/libav-win32-20120821.7z'
+ download_path = get_download_path(url)
+ check_call(seven_zip_path, "x", download_path, '-o' + build_dir)
+
+ archive_dir = os.path.join(build_dir,
+ os.path.splitext(os.path.basename(url))[0])
+ dest_dir = os.path.join(env_dir, "avconv")
+ bin_dir = os.path.join(archive_dir, "usr", "bin")
+ lib_dir = os.path.join(archive_dir, "usr", "lib")
+ preset_dir = os.path.join(archive_dir, "usr", "share", "avconv")
+
+ os.mkdir(dest_dir)
+ for src_dir in (bin_dir, lib_dir, preset_dir):
+ for filename in os.listdir(src_dir):
+ shutil.move(os.path.join(src_dir, filename),
+ os.path.join(dest_dir, filename))
+
+def install_winsparkle():
+ url = ('http://downloads.sourceforge.net/'
+ 'project/winsparkle/0.3/WinSparkle-0.3.zip')
+ download_path = get_download_path(url)
+ extract_zip(download_path, env_dir)
+
+def main():
+ parse_args()
+ make_env_dir()
+ download_files()
+ with build_dir_context():
+ install_lessmsi()
+ install_7zip()
+ install_python()
+ install_virtualenv()
+ install_py2exe()
+ install_pygtk()
+ install_ffmpeg()
+ install_avconv()
+ install_winsparkle()
+ install_gtk_theme_files()
+ install_nsis()
+
+if __name__ == '__main__':
+ main()
diff --git a/helperscripts/windows-virtualenv/virtualenv.py b/helperscripts/windows-virtualenv/virtualenv.py
new file mode 100755
index 0000000..9cff773
--- /dev/null
+++ b/helperscripts/windows-virtualenv/virtualenv.py
@@ -0,0 +1,2274 @@
+#!/usr/bin/env python
+"""Create a "virtual" Python installation
+"""
+
+# If you change the version here, change it in setup.py
+# and docs/conf.py as well.
+virtualenv_version = "1.7.1.2"
+
+import base64
+import sys
+import os
+import optparse
+import re
+import shutil
+import logging
+import tempfile
+import zlib
+import errno
+import distutils.sysconfig
+from distutils.util import strtobool
+
+try:
+ import subprocess
+except ImportError:
+ if sys.version_info <= (2, 3):
+ print('ERROR: %s' % sys.exc_info()[1])
+ print('ERROR: this script requires Python 2.4 or greater; or at least the subprocess module.')
+ print('If you copy subprocess.py from a newer version of Python this script will probably work')
+ sys.exit(101)
+ else:
+ raise
+try:
+ set
+except NameError:
+ from sets import Set as set
+try:
+ basestring
+except NameError:
+ basestring = str
+
+try:
+ import ConfigParser
+except ImportError:
+ import configparser as ConfigParser
+
+join = os.path.join
+py_version = 'python%s.%s' % (sys.version_info[0], sys.version_info[1])
+
+is_jython = sys.platform.startswith('java')
+is_pypy = hasattr(sys, 'pypy_version_info')
+is_win = (sys.platform == 'win32')
+abiflags = getattr(sys, 'abiflags', '')
+
+user_dir = os.path.expanduser('~')
+if sys.platform == 'win32':
+ user_dir = os.environ.get('APPDATA', user_dir) # Use %APPDATA% for roaming
+ default_storage_dir = os.path.join(user_dir, 'virtualenv')
+else:
+ default_storage_dir = os.path.join(user_dir, '.virtualenv')
+default_config_file = os.path.join(default_storage_dir, 'virtualenv.ini')
+
+if is_pypy:
+ expected_exe = 'pypy'
+elif is_jython:
+ expected_exe = 'jython'
+else:
+ expected_exe = 'python'
+
+
+REQUIRED_MODULES = ['os', 'posix', 'posixpath', 'nt', 'ntpath', 'genericpath',
+ 'fnmatch', 'locale', 'encodings', 'codecs',
+ 'stat', 'UserDict', 'readline', 'copy_reg', 'types',
+ 're', 'sre', 'sre_parse', 'sre_constants', 'sre_compile',
+ 'zlib']
+
+REQUIRED_FILES = ['lib-dynload', 'config']
+
+majver, minver = sys.version_info[:2]
+if majver == 2:
+ if minver >= 6:
+ REQUIRED_MODULES.extend(['warnings', 'linecache', '_abcoll', 'abc'])
+ if minver >= 7:
+ REQUIRED_MODULES.extend(['_weakrefset'])
+ if minver <= 3:
+ REQUIRED_MODULES.extend(['sets', '__future__'])
+elif majver == 3:
+ # Some extra modules are needed for Python 3, but different ones
+ # for different versions.
+ REQUIRED_MODULES.extend(['_abcoll', 'warnings', 'linecache', 'abc', 'io',
+ '_weakrefset', 'copyreg', 'tempfile', 'random',
+ '__future__', 'collections', 'keyword', 'tarfile',
+ 'shutil', 'struct', 'copy'])
+ if minver >= 2:
+ REQUIRED_FILES[-1] = 'config-%s' % majver
+ if minver == 3:
+ # The whole list of 3.3 modules is reproduced below - the current
+ # uncommented ones are required for 3.3 as of now, but more may be
+ # added as 3.3 development continues.
+ REQUIRED_MODULES.extend([
+ #"aifc",
+ #"antigravity",
+ #"argparse",
+ #"ast",
+ #"asynchat",
+ #"asyncore",
+ "base64",
+ #"bdb",
+ #"binhex",
+ "bisect",
+ #"calendar",
+ #"cgi",
+ #"cgitb",
+ #"chunk",
+ #"cmd",
+ #"codeop",
+ #"code",
+ #"colorsys",
+ #"_compat_pickle",
+ #"compileall",
+ #"concurrent",
+ #"configparser",
+ #"contextlib",
+ #"cProfile",
+ #"crypt",
+ #"csv",
+ #"ctypes",
+ #"curses",
+ #"datetime",
+ #"dbm",
+ #"decimal",
+ #"difflib",
+ #"dis",
+ #"doctest",
+ #"dummy_threading",
+ "_dummy_thread",
+ #"email",
+ #"filecmp",
+ #"fileinput",
+ #"formatter",
+ #"fractions",
+ #"ftplib",
+ #"functools",
+ #"getopt",
+ #"getpass",
+ #"gettext",
+ #"glob",
+ #"gzip",
+ "hashlib",
+ "heapq",
+ "hmac",
+ #"html",
+ #"http",
+ #"idlelib",
+ #"imaplib",
+ #"imghdr",
+ #"importlib",
+ #"inspect",
+ #"json",
+ #"lib2to3",
+ #"logging",
+ #"macpath",
+ #"macurl2path",
+ #"mailbox",
+ #"mailcap",
+ #"_markupbase",
+ #"mimetypes",
+ #"modulefinder",
+ #"multiprocessing",
+ #"netrc",
+ #"nntplib",
+ #"nturl2path",
+ #"numbers",
+ #"opcode",
+ #"optparse",
+ #"os2emxpath",
+ #"pdb",
+ #"pickle",
+ #"pickletools",
+ #"pipes",
+ #"pkgutil",
+ #"platform",
+ #"plat-linux2",
+ #"plistlib",
+ #"poplib",
+ #"pprint",
+ #"profile",
+ #"pstats",
+ #"pty",
+ #"pyclbr",
+ #"py_compile",
+ #"pydoc_data",
+ #"pydoc",
+ #"_pyio",
+ #"queue",
+ #"quopri",
+ "reprlib",
+ "rlcompleter",
+ #"runpy",
+ #"sched",
+ #"shelve",
+ #"shlex",
+ #"smtpd",
+ #"smtplib",
+ #"sndhdr",
+ #"socket",
+ #"socketserver",
+ #"sqlite3",
+ #"ssl",
+ #"stringprep",
+ #"string",
+ #"_strptime",
+ #"subprocess",
+ #"sunau",
+ #"symbol",
+ #"symtable",
+ #"sysconfig",
+ #"tabnanny",
+ #"telnetlib",
+ #"test",
+ #"textwrap",
+ #"this",
+ #"_threading_local",
+ #"threading",
+ #"timeit",
+ #"tkinter",
+ #"tokenize",
+ #"token",
+ #"traceback",
+ #"trace",
+ #"tty",
+ #"turtledemo",
+ #"turtle",
+ #"unittest",
+ #"urllib",
+ #"uuid",
+ #"uu",
+ #"wave",
+ "weakref",
+ #"webbrowser",
+ #"wsgiref",
+ #"xdrlib",
+ #"xml",
+ #"xmlrpc",
+ #"zipfile",
+ ])
+
+if is_pypy:
+ # these are needed to correctly display the exceptions that may happen
+ # during the bootstrap
+ REQUIRED_MODULES.extend(['traceback', 'linecache'])
+
+class Logger(object):
+
+ """
+ Logging object for use in command-line script. Allows ranges of
+ levels, to avoid some redundancy of displayed information.
+ """
+
+ DEBUG = logging.DEBUG
+ INFO = logging.INFO
+ NOTIFY = (logging.INFO+logging.WARN)/2
+ WARN = WARNING = logging.WARN
+ ERROR = logging.ERROR
+ FATAL = logging.FATAL
+
+ LEVELS = [DEBUG, INFO, NOTIFY, WARN, ERROR, FATAL]
+
+ def __init__(self, consumers):
+ self.consumers = consumers
+ self.indent = 0
+ self.in_progress = None
+ self.in_progress_hanging = False
+
+ def debug(self, msg, *args, **kw):
+ self.log(self.DEBUG, msg, *args, **kw)
+ def info(self, msg, *args, **kw):
+ self.log(self.INFO, msg, *args, **kw)
+ def notify(self, msg, *args, **kw):
+ self.log(self.NOTIFY, msg, *args, **kw)
+ def warn(self, msg, *args, **kw):
+ self.log(self.WARN, msg, *args, **kw)
+ def error(self, msg, *args, **kw):
+ self.log(self.WARN, msg, *args, **kw)
+ def fatal(self, msg, *args, **kw):
+ self.log(self.FATAL, msg, *args, **kw)
+ def log(self, level, msg, *args, **kw):
+ if args:
+ if kw:
+ raise TypeError(
+ "You may give positional or keyword arguments, not both")
+ args = args or kw
+ rendered = None
+ for consumer_level, consumer in self.consumers:
+ if self.level_matches(level, consumer_level):
+ if (self.in_progress_hanging
+ and consumer in (sys.stdout, sys.stderr)):
+ self.in_progress_hanging = False
+ sys.stdout.write('\n')
+ sys.stdout.flush()
+ if rendered is None:
+ if args:
+ rendered = msg % args
+ else:
+ rendered = msg
+ rendered = ' '*self.indent + rendered
+ if hasattr(consumer, 'write'):
+ consumer.write(rendered+'\n')
+ else:
+ consumer(rendered)
+
+ def start_progress(self, msg):
+ assert not self.in_progress, (
+ "Tried to start_progress(%r) while in_progress %r"
+ % (msg, self.in_progress))
+ if self.level_matches(self.NOTIFY, self._stdout_level()):
+ sys.stdout.write(msg)
+ sys.stdout.flush()
+ self.in_progress_hanging = True
+ else:
+ self.in_progress_hanging = False
+ self.in_progress = msg
+
+ def end_progress(self, msg='done.'):
+ assert self.in_progress, (
+ "Tried to end_progress without start_progress")
+ if self.stdout_level_matches(self.NOTIFY):
+ if not self.in_progress_hanging:
+ # Some message has been printed out since start_progress
+ sys.stdout.write('...' + self.in_progress + msg + '\n')
+ sys.stdout.flush()
+ else:
+ sys.stdout.write(msg + '\n')
+ sys.stdout.flush()
+ self.in_progress = None
+ self.in_progress_hanging = False
+
+ def show_progress(self):
+ """If we are in a progress scope, and no log messages have been
+ shown, write out another '.'"""
+ if self.in_progress_hanging:
+ sys.stdout.write('.')
+ sys.stdout.flush()
+
+ def stdout_level_matches(self, level):
+ """Returns true if a message at this level will go to stdout"""
+ return self.level_matches(level, self._stdout_level())
+
+ def _stdout_level(self):
+ """Returns the level that stdout runs at"""
+ for level, consumer in self.consumers:
+ if consumer is sys.stdout:
+ return level
+ return self.FATAL
+
+ def level_matches(self, level, consumer_level):
+ """
+ >>> l = Logger([])
+ >>> l.level_matches(3, 4)
+ False
+ >>> l.level_matches(3, 2)
+ True
+ >>> l.level_matches(slice(None, 3), 3)
+ False
+ >>> l.level_matches(slice(None, 3), 2)
+ True
+ >>> l.level_matches(slice(1, 3), 1)
+ True
+ >>> l.level_matches(slice(2, 3), 1)
+ False
+ """
+ if isinstance(level, slice):
+ start, stop = level.start, level.stop
+ if start is not None and start > consumer_level:
+ return False
+ if stop is not None and stop <= consumer_level:
+ return False
+ return True
+ else:
+ return level >= consumer_level
+
+ #@classmethod
+ def level_for_integer(cls, level):
+ levels = cls.LEVELS
+ if level < 0:
+ return levels[0]
+ if level >= len(levels):
+ return levels[-1]
+ return levels[level]
+
+ level_for_integer = classmethod(level_for_integer)
+
+# create a silent logger just to prevent this from being undefined
+# will be overridden with requested verbosity main() is called.
+logger = Logger([(Logger.LEVELS[-1], sys.stdout)])
+
+def mkdir(path):
+ if not os.path.exists(path):
+ logger.info('Creating %s', path)
+ os.makedirs(path)
+ else:
+ logger.info('Directory %s already exists', path)
+
+def copyfileordir(src, dest):
+ if os.path.isdir(src):
+ shutil.copytree(src, dest, True)
+ else:
+ shutil.copy2(src, dest)
+
+def copyfile(src, dest, symlink=True):
+ if not os.path.exists(src):
+ # Some bad symlink in the src
+ logger.warn('Cannot find file %s (bad symlink)', src)
+ return
+ if os.path.exists(dest):
+ logger.debug('File %s already exists', dest)
+ return
+ if not os.path.exists(os.path.dirname(dest)):
+ logger.info('Creating parent directories for %s' % os.path.dirname(dest))
+ os.makedirs(os.path.dirname(dest))
+ if not os.path.islink(src):
+ srcpath = os.path.abspath(src)
+ else:
+ srcpath = os.readlink(src)
+ if symlink and hasattr(os, 'symlink') and not is_win:
+ logger.info('Symlinking %s', dest)
+ try:
+ os.symlink(srcpath, dest)
+ except (OSError, NotImplementedError):
+ logger.info('Symlinking failed, copying to %s', dest)
+ copyfileordir(src, dest)
+ else:
+ logger.info('Copying to %s', dest)
+ copyfileordir(src, dest)
+
+def writefile(dest, content, overwrite=True):
+ if not os.path.exists(dest):
+ logger.info('Writing %s', dest)
+ f = open(dest, 'wb')
+ f.write(content.encode('utf-8'))
+ f.close()
+ return
+ else:
+ f = open(dest, 'rb')
+ c = f.read()
+ f.close()
+ if c != content:
+ if not overwrite:
+ logger.notify('File %s exists with different content; not overwriting', dest)
+ return
+ logger.notify('Overwriting %s with new content', dest)
+ f = open(dest, 'wb')
+ f.write(content.encode('utf-8'))
+ f.close()
+ else:
+ logger.info('Content %s already in place', dest)
+
+def rmtree(dir):
+ if os.path.exists(dir):
+ logger.notify('Deleting tree %s', dir)
+ shutil.rmtree(dir)
+ else:
+ logger.info('Do not need to delete %s; already gone', dir)
+
+def make_exe(fn):
+ if hasattr(os, 'chmod'):
+ oldmode = os.stat(fn).st_mode & 0xFFF # 0o7777
+ newmode = (oldmode | 0x16D) & 0xFFF # 0o555, 0o7777
+ os.chmod(fn, newmode)
+ logger.info('Changed mode of %s to %s', fn, oct(newmode))
+
+def _find_file(filename, dirs):
+ for dir in reversed(dirs):
+ if os.path.exists(join(dir, filename)):
+ return join(dir, filename)
+ return filename
+
+def _install_req(py_executable, unzip=False, distribute=False,
+ search_dirs=None, never_download=False):
+
+ if search_dirs is None:
+ search_dirs = file_search_dirs()
+
+ if not distribute:
+ setup_fn = 'setuptools-0.6c11-py%s.egg' % sys.version[:3]
+ project_name = 'setuptools'
+ bootstrap_script = EZ_SETUP_PY
+ source = None
+ else:
+ setup_fn = None
+ source = 'distribute-0.6.24.tar.gz'
+ project_name = 'distribute'
+ bootstrap_script = DISTRIBUTE_SETUP_PY
+
+ if setup_fn is not None:
+ setup_fn = _find_file(setup_fn, search_dirs)
+
+ if source is not None:
+ source = _find_file(source, search_dirs)
+
+ if is_jython and os._name == 'nt':
+ # Jython's .bat sys.executable can't handle a command line
+ # argument with newlines
+ fd, ez_setup = tempfile.mkstemp('.py')
+ os.write(fd, bootstrap_script)
+ os.close(fd)
+ cmd = [py_executable, ez_setup]
+ else:
+ cmd = [py_executable, '-c', bootstrap_script]
+ if unzip:
+ cmd.append('--always-unzip')
+ env = {}
+ remove_from_env = []
+ if logger.stdout_level_matches(logger.DEBUG):
+ cmd.append('-v')
+
+ old_chdir = os.getcwd()
+ if setup_fn is not None and os.path.exists(setup_fn):
+ logger.info('Using existing %s egg: %s' % (project_name, setup_fn))
+ cmd.append(setup_fn)
+ if os.environ.get('PYTHONPATH'):
+ env['PYTHONPATH'] = setup_fn + os.path.pathsep + os.environ['PYTHONPATH']
+ else:
+ env['PYTHONPATH'] = setup_fn
+ else:
+ # the source is found, let's chdir
+ if source is not None and os.path.exists(source):
+ logger.info('Using existing %s egg: %s' % (project_name, source))
+ os.chdir(os.path.dirname(source))
+ # in this case, we want to be sure that PYTHONPATH is unset (not
+ # just empty, really unset), else CPython tries to import the
+ # site.py that it's in virtualenv_support
+ remove_from_env.append('PYTHONPATH')
+ else:
+ if never_download:
+ logger.fatal("Can't find any local distributions of %s to install "
+ "and --never-download is set. Either re-run virtualenv "
+ "without the --never-download option, or place a %s "
+ "distribution (%s) in one of these "
+ "locations: %r" % (project_name, project_name,
+ setup_fn or source,
+ search_dirs))
+ sys.exit(1)
+
+ logger.info('No %s egg found; downloading' % project_name)
+ cmd.extend(['--always-copy', '-U', project_name])
+ logger.start_progress('Installing %s...' % project_name)
+ logger.indent += 2
+ cwd = None
+ if project_name == 'distribute':
+ env['DONT_PATCH_SETUPTOOLS'] = 'true'
+
+ def _filter_ez_setup(line):
+ return filter_ez_setup(line, project_name)
+
+ if not os.access(os.getcwd(), os.W_OK):
+ cwd = tempfile.mkdtemp()
+ if source is not None and os.path.exists(source):
+ # the current working dir is hostile, let's copy the
+ # tarball to a temp dir
+ target = os.path.join(cwd, os.path.split(source)[-1])
+ shutil.copy(source, target)
+ try:
+ call_subprocess(cmd, show_stdout=False,
+ filter_stdout=_filter_ez_setup,
+ extra_env=env,
+ remove_from_env=remove_from_env,
+ cwd=cwd)
+ finally:
+ logger.indent -= 2
+ logger.end_progress()
+ if os.getcwd() != old_chdir:
+ os.chdir(old_chdir)
+ if is_jython and os._name == 'nt':
+ os.remove(ez_setup)
+
+def file_search_dirs():
+ here = os.path.dirname(os.path.abspath(__file__))
+ dirs = ['.', here,
+ join(here, 'virtualenv_support')]
+ if os.path.splitext(os.path.dirname(__file__))[0] != 'virtualenv':
+ # Probably some boot script; just in case virtualenv is installed...
+ try:
+ import virtualenv
+ except ImportError:
+ pass
+ else:
+ dirs.append(os.path.join(os.path.dirname(virtualenv.__file__), 'virtualenv_support'))
+ return [d for d in dirs if os.path.isdir(d)]
+
+def install_setuptools(py_executable, unzip=False,
+ search_dirs=None, never_download=False):
+ _install_req(py_executable, unzip,
+ search_dirs=search_dirs, never_download=never_download)
+
+def install_distribute(py_executable, unzip=False,
+ search_dirs=None, never_download=False):
+ _install_req(py_executable, unzip, distribute=True,
+ search_dirs=search_dirs, never_download=never_download)
+
+_pip_re = re.compile(r'^pip-.*(zip|tar.gz|tar.bz2|tgz|tbz)$', re.I)
+def install_pip(py_executable, search_dirs=None, never_download=False):
+ if search_dirs is None:
+ search_dirs = file_search_dirs()
+
+ filenames = []
+ for dir in search_dirs:
+ filenames.extend([join(dir, fn) for fn in os.listdir(dir)
+ if _pip_re.search(fn)])
+ filenames = [(os.path.basename(filename).lower(), i, filename) for i, filename in enumerate(filenames)]
+ filenames.sort()
+ filenames = [filename for basename, i, filename in filenames]
+ if not filenames:
+ filename = 'pip'
+ else:
+ filename = filenames[-1]
+ easy_install_script = 'easy_install'
+ if sys.platform == 'win32':
+ easy_install_script = 'easy_install-script.py'
+ cmd = [join(os.path.dirname(py_executable), easy_install_script), filename]
+ if sys.platform == 'win32':
+ cmd.insert(0, py_executable)
+ if filename == 'pip':
+ if never_download:
+ logger.fatal("Can't find any local distributions of pip to install "
+ "and --never-download is set. Either re-run virtualenv "
+ "without the --never-download option, or place a pip "
+ "source distribution (zip/tar.gz/tar.bz2) in one of these "
+ "locations: %r" % search_dirs)
+ sys.exit(1)
+ logger.info('Installing pip from network...')
+ else:
+ logger.info('Installing existing %s distribution: %s' % (
+ os.path.basename(filename), filename))
+ logger.start_progress('Installing pip...')
+ logger.indent += 2
+ def _filter_setup(line):
+ return filter_ez_setup(line, 'pip')
+ try:
+ call_subprocess(cmd, show_stdout=False,
+ filter_stdout=_filter_setup)
+ finally:
+ logger.indent -= 2
+ logger.end_progress()
+
+def filter_ez_setup(line, project_name='setuptools'):
+ if not line.strip():
+ return Logger.DEBUG
+ if project_name == 'distribute':
+ for prefix in ('Extracting', 'Now working', 'Installing', 'Before',
+ 'Scanning', 'Setuptools', 'Egg', 'Already',
+ 'running', 'writing', 'reading', 'installing',
+ 'creating', 'copying', 'byte-compiling', 'removing',
+ 'Processing'):
+ if line.startswith(prefix):
+ return Logger.DEBUG
+ return Logger.DEBUG
+ for prefix in ['Reading ', 'Best match', 'Processing setuptools',
+ 'Copying setuptools', 'Adding setuptools',
+ 'Installing ', 'Installed ']:
+ if line.startswith(prefix):
+ return Logger.DEBUG
+ return Logger.INFO
+
+
+class UpdatingDefaultsHelpFormatter(optparse.IndentedHelpFormatter):
+ """
+ Custom help formatter for use in ConfigOptionParser that updates
+ the defaults before expanding them, allowing them to show up correctly
+ in the help listing
+ """
+ def expand_default(self, option):
+ if self.parser is not None:
+ self.parser.update_defaults(self.parser.defaults)
+ return optparse.IndentedHelpFormatter.expand_default(self, option)
+
+
+class ConfigOptionParser(optparse.OptionParser):
+ """
+ Custom option parser which updates its defaults by by checking the
+ configuration files and environmental variables
+ """
+ def __init__(self, *args, **kwargs):
+ self.config = ConfigParser.RawConfigParser()
+ self.files = self.get_config_files()
+ self.config.read(self.files)
+ optparse.OptionParser.__init__(self, *args, **kwargs)
+
+ def get_config_files(self):
+ config_file = os.environ.get('VIRTUALENV_CONFIG_FILE', False)
+ if config_file and os.path.exists(config_file):
+ return [config_file]
+ return [default_config_file]
+
+ def update_defaults(self, defaults):
+ """
+ Updates the given defaults with values from the config files and
+ the environ. Does a little special handling for certain types of
+ options (lists).
+ """
+ # Then go and look for the other sources of configuration:
+ config = {}
+ # 1. config files
+ config.update(dict(self.get_config_section('virtualenv')))
+ # 2. environmental variables
+ config.update(dict(self.get_environ_vars()))
+ # Then set the options with those values
+ for key, val in config.items():
+ key = key.replace('_', '-')
+ if not key.startswith('--'):
+ key = '--%s' % key # only prefer long opts
+ option = self.get_option(key)
+ if option is not None:
+ # ignore empty values
+ if not val:
+ continue
+ # handle multiline configs
+ if option.action == 'append':
+ val = val.split()
+ else:
+ option.nargs = 1
+ if option.action in ('store_true', 'store_false', 'count'):
+ val = strtobool(val)
+ try:
+ val = option.convert_value(key, val)
+ except optparse.OptionValueError:
+ e = sys.exc_info()[1]
+ print("An error occured during configuration: %s" % e)
+ sys.exit(3)
+ defaults[option.dest] = val
+ return defaults
+
+ def get_config_section(self, name):
+ """
+ Get a section of a configuration
+ """
+ if self.config.has_section(name):
+ return self.config.items(name)
+ return []
+
+ def get_environ_vars(self, prefix='VIRTUALENV_'):
+ """
+ Returns a generator with all environmental vars with prefix VIRTUALENV
+ """
+ for key, val in os.environ.items():
+ if key.startswith(prefix):
+ yield (key.replace(prefix, '').lower(), val)
+
+ def get_default_values(self):
+ """
+ Overridding to make updating the defaults after instantiation of
+ the option parser possible, update_defaults() does the dirty work.
+ """
+ if not self.process_default_values:
+ # Old, pre-Optik 1.5 behaviour.
+ return optparse.Values(self.defaults)
+
+ defaults = self.update_defaults(self.defaults.copy()) # ours
+ for option in self._get_all_options():
+ default = defaults.get(option.dest)
+ if isinstance(default, basestring):
+ opt_str = option.get_opt_string()
+ defaults[option.dest] = option.check_value(opt_str, default)
+ return optparse.Values(defaults)
+
+
+def main():
+ parser = ConfigOptionParser(
+ version=virtualenv_version,
+ usage="%prog [OPTIONS] DEST_DIR",
+ formatter=UpdatingDefaultsHelpFormatter())
+
+ parser.add_option(
+ '-v', '--verbose',
+ action='count',
+ dest='verbose',
+ default=0,
+ help="Increase verbosity")
+
+ parser.add_option(
+ '-q', '--quiet',
+ action='count',
+ dest='quiet',
+ default=0,
+ help='Decrease verbosity')
+
+ parser.add_option(
+ '-p', '--python',
+ dest='python',
+ metavar='PYTHON_EXE',
+ help='The Python interpreter to use, e.g., --python=python2.5 will use the python2.5 '
+ 'interpreter to create the new environment. The default is the interpreter that '
+ 'virtualenv was installed with (%s)' % sys.executable)
+
+ parser.add_option(
+ '--clear',
+ dest='clear',
+ action='store_true',
+ help="Clear out the non-root install and start from scratch")
+
+ parser.add_option(
+ '--no-site-packages',
+ dest='no_site_packages',
+ action='store_true',
+ help="Don't give access to the global site-packages dir to the "
+ "virtual environment")
+
+ parser.add_option(
+ '--system-site-packages',
+ dest='system_site_packages',
+ action='store_true',
+ help="Give access to the global site-packages dir to the "
+ "virtual environment")
+
+ parser.add_option(
+ '--unzip-setuptools',
+ dest='unzip_setuptools',
+ action='store_true',
+ help="Unzip Setuptools or Distribute when installing it")
+
+ parser.add_option(
+ '--relocatable',
+ dest='relocatable',
+ action='store_true',
+ help='Make an EXISTING virtualenv environment relocatable. '
+ 'This fixes up scripts and makes all .pth files relative')
+
+ parser.add_option(
+ '--distribute',
+ dest='use_distribute',
+ action='store_true',
+ help='Use Distribute instead of Setuptools. Set environ variable '
+ 'VIRTUALENV_DISTRIBUTE to make it the default ')
+
+ default_search_dirs = file_search_dirs()
+ parser.add_option(
+ '--extra-search-dir',
+ dest="search_dirs",
+ action="append",
+ default=default_search_dirs,
+ help="Directory to look for setuptools/distribute/pip distributions in. "
+ "You can add any number of additional --extra-search-dir paths.")
+
+ parser.add_option(
+ '--never-download',
+ dest="never_download",
+ action="store_true",
+ help="Never download anything from the network. Instead, virtualenv will fail "
+ "if local distributions of setuptools/distribute/pip are not present.")
+
+ parser.add_option(
+ '--prompt=',
+ dest='prompt',
+ help='Provides an alternative prompt prefix for this environment')
+
+ if 'extend_parser' in globals():
+ extend_parser(parser)
+
+ options, args = parser.parse_args()
+
+ global logger
+
+ if 'adjust_options' in globals():
+ adjust_options(options, args)
+
+ verbosity = options.verbose - options.quiet
+ logger = Logger([(Logger.level_for_integer(2-verbosity), sys.stdout)])
+
+ if options.python and not os.environ.get('VIRTUALENV_INTERPRETER_RUNNING'):
+ env = os.environ.copy()
+ interpreter = resolve_interpreter(options.python)
+ if interpreter == sys.executable:
+ logger.warn('Already using interpreter %s' % interpreter)
+ else:
+ logger.notify('Running virtualenv with interpreter %s' % interpreter)
+ env['VIRTUALENV_INTERPRETER_RUNNING'] = 'true'
+ file = __file__
+ if file.endswith('.pyc'):
+ file = file[:-1]
+ popen = subprocess.Popen([interpreter, file] + sys.argv[1:], env=env)
+ raise SystemExit(popen.wait())
+
+ # Force --distribute on Python 3, since setuptools is not available.
+ if majver > 2:
+ options.use_distribute = True
+
+ if os.environ.get('PYTHONDONTWRITEBYTECODE') and not options.use_distribute:
+ print(
+ "The PYTHONDONTWRITEBYTECODE environment variable is "
+ "not compatible with setuptools. Either use --distribute "
+ "or unset PYTHONDONTWRITEBYTECODE.")
+ sys.exit(2)
+ if not args:
+ print('You must provide a DEST_DIR')
+ parser.print_help()
+ sys.exit(2)
+ if len(args) > 1:
+ print('There must be only one argument: DEST_DIR (you gave %s)' % (
+ ' '.join(args)))
+ parser.print_help()
+ sys.exit(2)
+
+ home_dir = args[0]
+
+ if os.environ.get('WORKING_ENV'):
+ logger.fatal('ERROR: you cannot run virtualenv while in a workingenv')
+ logger.fatal('Please deactivate your workingenv, then re-run this script')
+ sys.exit(3)
+
+ if 'PYTHONHOME' in os.environ:
+ logger.warn('PYTHONHOME is set. You *must* activate the virtualenv before using it')
+ del os.environ['PYTHONHOME']
+
+ if options.relocatable:
+ make_environment_relocatable(home_dir)
+ return
+
+ if options.no_site_packages:
+ logger.warn('The --no-site-packages flag is deprecated; it is now '
+ 'the default behavior.')
+
+ create_environment(home_dir,
+ site_packages=options.system_site_packages,
+ clear=options.clear,
+ unzip_setuptools=options.unzip_setuptools,
+ use_distribute=options.use_distribute,
+ prompt=options.prompt,
+ search_dirs=options.search_dirs,
+ never_download=options.never_download)
+ if 'after_install' in globals():
+ after_install(options, home_dir)
+
+def call_subprocess(cmd, show_stdout=True,
+ filter_stdout=None, cwd=None,
+ raise_on_returncode=True, extra_env=None,
+ remove_from_env=None):
+ cmd_parts = []
+ for part in cmd:
+ if len(part) > 45:
+ part = part[:20]+"..."+part[-20:]
+ if ' ' in part or '\n' in part or '"' in part or "'" in part:
+ part = '"%s"' % part.replace('"', '\\"')
+ if hasattr(part, 'decode'):
+ try:
+ part = part.decode(sys.getdefaultencoding())
+ except UnicodeDecodeError:
+ part = part.decode(sys.getfilesystemencoding())
+ cmd_parts.append(part)
+ cmd_desc = ' '.join(cmd_parts)
+ if show_stdout:
+ stdout = None
+ else:
+ stdout = subprocess.PIPE
+ logger.debug("Running command %s" % cmd_desc)
+ if extra_env or remove_from_env:
+ env = os.environ.copy()
+ if extra_env:
+ env.update(extra_env)
+ if remove_from_env:
+ for varname in remove_from_env:
+ env.pop(varname, None)
+ else:
+ env = None
+ try:
+ proc = subprocess.Popen(
+ cmd, stderr=subprocess.STDOUT, stdin=None, stdout=stdout,
+ cwd=cwd, env=env)
+ except Exception:
+ e = sys.exc_info()[1]
+ logger.fatal(
+ "Error %s while executing command %s" % (e, cmd_desc))
+ raise
+ all_output = []
+ if stdout is not None:
+ stdout = proc.stdout
+ encoding = sys.getdefaultencoding()
+ fs_encoding = sys.getfilesystemencoding()
+ while 1:
+ line = stdout.readline()
+ try:
+ line = line.decode(encoding)
+ except UnicodeDecodeError:
+ line = line.decode(fs_encoding)
+ if not line:
+ break
+ line = line.rstrip()
+ all_output.append(line)
+ if filter_stdout:
+ level = filter_stdout(line)
+ if isinstance(level, tuple):
+ level, line = level
+ logger.log(level, line)
+ if not logger.stdout_level_matches(level):
+ logger.show_progress()
+ else:
+ logger.info(line)
+ else:
+ proc.communicate()
+ proc.wait()
+ if proc.returncode:
+ if raise_on_returncode:
+ if all_output:
+ logger.notify('Complete output from command %s:' % cmd_desc)
+ logger.notify('\n'.join(all_output) + '\n----------------------------------------')
+ raise OSError(
+ "Command %s failed with error code %s"
+ % (cmd_desc, proc.returncode))
+ else:
+ logger.warn(
+ "Command %s had error code %s"
+ % (cmd_desc, proc.returncode))
+
+
+def create_environment(home_dir, site_packages=False, clear=False,
+ unzip_setuptools=False, use_distribute=False,
+ prompt=None, search_dirs=None, never_download=False):
+ """
+ Creates a new environment in ``home_dir``.
+
+ If ``site_packages`` is true, then the global ``site-packages/``
+ directory will be on the path.
+
+ If ``clear`` is true (default False) then the environment will
+ first be cleared.
+ """
+ home_dir, lib_dir, inc_dir, bin_dir = path_locations(home_dir)
+
+ py_executable = os.path.abspath(install_python(
+ home_dir, lib_dir, inc_dir, bin_dir,
+ site_packages=site_packages, clear=clear))
+
+ install_distutils(home_dir)
+
+ # use_distribute also is True if VIRTUALENV_DISTRIBUTE env var is set
+ # we also check VIRTUALENV_USE_DISTRIBUTE for backwards compatibility
+ if use_distribute or os.environ.get('VIRTUALENV_USE_DISTRIBUTE'):
+ install_distribute(py_executable, unzip=unzip_setuptools,
+ search_dirs=search_dirs, never_download=never_download)
+ else:
+ install_setuptools(py_executable, unzip=unzip_setuptools,
+ search_dirs=search_dirs, never_download=never_download)
+
+ install_pip(py_executable, search_dirs=search_dirs, never_download=never_download)
+
+ install_activate(home_dir, bin_dir, prompt)
+
+def path_locations(home_dir):
+ """Return the path locations for the environment (where libraries are,
+ where scripts go, etc)"""
+ # XXX: We'd use distutils.sysconfig.get_python_inc/lib but its
+ # prefix arg is broken: http://bugs.python.org/issue3386
+ if sys.platform == 'win32':
+ # Windows has lots of problems with executables with spaces in
+ # the name; this function will remove them (using the ~1
+ # format):
+ mkdir(home_dir)
+ if ' ' in home_dir:
+ try:
+ import win32api
+ except ImportError:
+ print('Error: the path "%s" has a space in it' % home_dir)
+ print('To handle these kinds of paths, the win32api module must be installed:')
+ print(' http://sourceforge.net/projects/pywin32/')
+ sys.exit(3)
+ home_dir = win32api.GetShortPathName(home_dir)
+ lib_dir = join(home_dir, 'Lib')
+ inc_dir = join(home_dir, 'Include')
+ bin_dir = join(home_dir, 'Scripts')
+ elif is_jython:
+ lib_dir = join(home_dir, 'Lib')
+ inc_dir = join(home_dir, 'Include')
+ bin_dir = join(home_dir, 'bin')
+ elif is_pypy:
+ lib_dir = home_dir
+ inc_dir = join(home_dir, 'include')
+ bin_dir = join(home_dir, 'bin')
+ else:
+ lib_dir = join(home_dir, 'lib', py_version)
+ inc_dir = join(home_dir, 'include', py_version + abiflags)
+ bin_dir = join(home_dir, 'bin')
+ return home_dir, lib_dir, inc_dir, bin_dir
+
+
+def change_prefix(filename, dst_prefix):
+ prefixes = [sys.prefix]
+
+ if sys.platform == "darwin":
+ prefixes.extend((
+ os.path.join("/Library/Python", sys.version[:3], "site-packages"),
+ os.path.join(sys.prefix, "Extras", "lib", "python"),
+ os.path.join("~", "Library", "Python", sys.version[:3], "site-packages")))
+
+ if hasattr(sys, 'real_prefix'):
+ prefixes.append(sys.real_prefix)
+ prefixes = list(map(os.path.abspath, prefixes))
+ filename = os.path.abspath(filename)
+ for src_prefix in prefixes:
+ if filename.startswith(src_prefix):
+ _, relpath = filename.split(src_prefix, 1)
+ assert relpath[0] == os.sep
+ relpath = relpath[1:]
+ return join(dst_prefix, relpath)
+ assert False, "Filename %s does not start with any of these prefixes: %s" % \
+ (filename, prefixes)
+
+def copy_required_modules(dst_prefix):
+ import imp
+ # If we are running under -p, we need to remove the current
+ # directory from sys.path temporarily here, so that we
+ # definitely get the modules from the site directory of
+ # the interpreter we are running under, not the one
+ # virtualenv.py is installed under (which might lead to py2/py3
+ # incompatibility issues)
+ _prev_sys_path = sys.path
+ if os.environ.get('VIRTUALENV_INTERPRETER_RUNNING'):
+ sys.path = sys.path[1:]
+ try:
+ for modname in REQUIRED_MODULES:
+ if modname in sys.builtin_module_names:
+ logger.info("Ignoring built-in bootstrap module: %s" % modname)
+ continue
+ try:
+ f, filename, _ = imp.find_module(modname)
+ except ImportError:
+ logger.info("Cannot import bootstrap module: %s" % modname)
+ else:
+ if f is not None:
+ f.close()
+ dst_filename = change_prefix(filename, dst_prefix)
+ copyfile(filename, dst_filename)
+ if filename.endswith('.pyc'):
+ pyfile = filename[:-1]
+ if os.path.exists(pyfile):
+ copyfile(pyfile, dst_filename[:-1])
+ finally:
+ sys.path = _prev_sys_path
+
+def install_python(home_dir, lib_dir, inc_dir, bin_dir, site_packages, clear):
+ """Install just the base environment, no distutils patches etc"""
+ if sys.executable.startswith(bin_dir):
+ print('Please use the *system* python to run this script')
+ return
+
+ if clear:
+ rmtree(lib_dir)
+ ## FIXME: why not delete it?
+ ## Maybe it should delete everything with #!/path/to/venv/python in it
+ logger.notify('Not deleting %s', bin_dir)
+
+ if hasattr(sys, 'real_prefix'):
+ logger.notify('Using real prefix %r' % sys.real_prefix)
+ prefix = sys.real_prefix
+ else:
+ prefix = sys.prefix
+ mkdir(lib_dir)
+ fix_lib64(lib_dir)
+ stdlib_dirs = [os.path.dirname(os.__file__)]
+ if sys.platform == 'win32':
+ stdlib_dirs.append(join(os.path.dirname(stdlib_dirs[0]), 'DLLs'))
+ elif sys.platform == 'darwin':
+ stdlib_dirs.append(join(stdlib_dirs[0], 'site-packages'))
+ if hasattr(os, 'symlink'):
+ logger.info('Symlinking Python bootstrap modules')
+ else:
+ logger.info('Copying Python bootstrap modules')
+ logger.indent += 2
+ try:
+ # copy required files...
+ for stdlib_dir in stdlib_dirs:
+ if not os.path.isdir(stdlib_dir):
+ continue
+ for fn in os.listdir(stdlib_dir):
+ bn = os.path.splitext(fn)[0]
+ if fn != 'site-packages' and bn in REQUIRED_FILES:
+ copyfile(join(stdlib_dir, fn), join(lib_dir, fn))
+ # ...and modules
+ copy_required_modules(home_dir)
+ finally:
+ logger.indent -= 2
+ mkdir(join(lib_dir, 'site-packages'))
+ import site
+ site_filename = site.__file__
+ if site_filename.endswith('.pyc'):
+ site_filename = site_filename[:-1]
+ elif site_filename.endswith('$py.class'):
+ site_filename = site_filename.replace('$py.class', '.py')
+ site_filename_dst = change_prefix(site_filename, home_dir)
+ site_dir = os.path.dirname(site_filename_dst)
+ writefile(site_filename_dst, SITE_PY)
+ writefile(join(site_dir, 'orig-prefix.txt'), prefix)
+ site_packages_filename = join(site_dir, 'no-global-site-packages.txt')
+ if not site_packages:
+ writefile(site_packages_filename, '')
+ else:
+ if os.path.exists(site_packages_filename):
+ logger.info('Deleting %s' % site_packages_filename)
+ os.unlink(site_packages_filename)
+
+ if is_pypy or is_win:
+ stdinc_dir = join(prefix, 'include')
+ else:
+ stdinc_dir = join(prefix, 'include', py_version + abiflags)
+ if os.path.exists(stdinc_dir):
+ copyfile(stdinc_dir, inc_dir)
+ else:
+ logger.debug('No include dir %s' % stdinc_dir)
+
+ # pypy never uses exec_prefix, just ignore it
+ if sys.exec_prefix != prefix and not is_pypy:
+ if sys.platform == 'win32':
+ exec_dir = join(sys.exec_prefix, 'lib')
+ elif is_jython:
+ exec_dir = join(sys.exec_prefix, 'Lib')
+ else:
+ exec_dir = join(sys.exec_prefix, 'lib', py_version)
+ for fn in os.listdir(exec_dir):
+ copyfile(join(exec_dir, fn), join(lib_dir, fn))
+
+ if is_jython:
+ # Jython has either jython-dev.jar and javalib/ dir, or just
+ # jython.jar
+ for name in 'jython-dev.jar', 'javalib', 'jython.jar':
+ src = join(prefix, name)
+ if os.path.exists(src):
+ copyfile(src, join(home_dir, name))
+ # XXX: registry should always exist after Jython 2.5rc1
+ src = join(prefix, 'registry')
+ if os.path.exists(src):
+ copyfile(src, join(home_dir, 'registry'), symlink=False)
+ copyfile(join(prefix, 'cachedir'), join(home_dir, 'cachedir'),
+ symlink=False)
+
+ mkdir(bin_dir)
+ py_executable = join(bin_dir, os.path.basename(sys.executable))
+ if 'Python.framework' in prefix:
+ if re.search(r'/Python(?:-32|-64)*$', py_executable):
+ # The name of the python executable is not quite what
+ # we want, rename it.
+ py_executable = os.path.join(
+ os.path.dirname(py_executable), 'python')
+
+ logger.notify('New %s executable in %s', expected_exe, py_executable)
+ pcbuild_dir = os.path.dirname(sys.executable)
+ pyd_pth = os.path.join(lib_dir, 'site-packages', 'virtualenv_builddir_pyd.pth')
+ if is_win and os.path.exists(os.path.join(pcbuild_dir, 'build.bat')):
+ logger.notify('Detected python running from build directory %s', pcbuild_dir)
+ logger.notify('Writing .pth file linking to build directory for *.pyd files')
+ writefile(pyd_pth, pcbuild_dir)
+ else:
+ pcbuild_dir = None
+ if os.path.exists(pyd_pth):
+ logger.info('Deleting %s (not Windows env or not build directory python)' % pyd_pth)
+ os.unlink(pyd_pth)
+
+ if sys.executable != py_executable:
+ ## FIXME: could I just hard link?
+ executable = sys.executable
+ if sys.platform == 'cygwin' and os.path.exists(executable + '.exe'):
+ # Cygwin misreports sys.executable sometimes
+ executable += '.exe'
+ py_executable += '.exe'
+ logger.info('Executable actually exists in %s' % executable)
+ shutil.copyfile(executable, py_executable)
+ make_exe(py_executable)
+ if sys.platform == 'win32' or sys.platform == 'cygwin':
+ pythonw = os.path.join(os.path.dirname(sys.executable), 'pythonw.exe')
+ if os.path.exists(pythonw):
+ logger.info('Also created pythonw.exe')
+ shutil.copyfile(pythonw, os.path.join(os.path.dirname(py_executable), 'pythonw.exe'))
+ python_d = os.path.join(os.path.dirname(sys.executable), 'python_d.exe')
+ python_d_dest = os.path.join(os.path.dirname(py_executable), 'python_d.exe')
+ if os.path.exists(python_d):
+ logger.info('Also created python_d.exe')
+ shutil.copyfile(python_d, python_d_dest)
+ elif os.path.exists(python_d_dest):
+ logger.info('Removed python_d.exe as it is no longer at the source')
+ os.unlink(python_d_dest)
+ # we need to copy the DLL to enforce that windows will load the correct one.
+ # may not exist if we are cygwin.
+ py_executable_dll = 'python%s%s.dll' % (
+ sys.version_info[0], sys.version_info[1])
+ py_executable_dll_d = 'python%s%s_d.dll' % (
+ sys.version_info[0], sys.version_info[1])
+ pythondll = os.path.join(os.path.dirname(sys.executable), py_executable_dll)
+ pythondll_d = os.path.join(os.path.dirname(sys.executable), py_executable_dll_d)
+ pythondll_d_dest = os.path.join(os.path.dirname(py_executable), py_executable_dll_d)
+ if os.path.exists(pythondll):
+ logger.info('Also created %s' % py_executable_dll)
+ shutil.copyfile(pythondll, os.path.join(os.path.dirname(py_executable), py_executable_dll))
+ if os.path.exists(pythondll_d):
+ logger.info('Also created %s' % py_executable_dll_d)
+ shutil.copyfile(pythondll_d, pythondll_d_dest)
+ elif os.path.exists(pythondll_d_dest):
+ logger.info('Removed %s as the source does not exist' % pythondll_d_dest)
+ os.unlink(pythondll_d_dest)
+ if is_pypy:
+ # make a symlink python --> pypy-c
+ python_executable = os.path.join(os.path.dirname(py_executable), 'python')
+ logger.info('Also created executable %s' % python_executable)
+ copyfile(py_executable, python_executable)
+
+ if os.path.splitext(os.path.basename(py_executable))[0] != expected_exe:
+ secondary_exe = os.path.join(os.path.dirname(py_executable),
+ expected_exe)
+ py_executable_ext = os.path.splitext(py_executable)[1]
+ if py_executable_ext == '.exe':
+ # python2.4 gives an extension of '.4' :P
+ secondary_exe += py_executable_ext
+ if os.path.exists(secondary_exe):
+ logger.warn('Not overwriting existing %s script %s (you must use %s)'
+ % (expected_exe, secondary_exe, py_executable))
+ else:
+ logger.notify('Also creating executable in %s' % secondary_exe)
+ shutil.copyfile(sys.executable, secondary_exe)
+ make_exe(secondary_exe)
+
+ if '.framework' in prefix:
+ if 'Python.framework' in prefix:
+ logger.debug('MacOSX Python framework detected')
+ # Make sure we use the the embedded interpreter inside
+ # the framework, even if sys.executable points to
+ # the stub executable in ${sys.prefix}/bin
+ # See http://groups.google.com/group/python-virtualenv/
+ # browse_thread/thread/17cab2f85da75951
+ original_python = os.path.join(
+ prefix, 'Resources/Python.app/Contents/MacOS/Python')
+ if 'EPD' in prefix:
+ logger.debug('EPD framework detected')
+ original_python = os.path.join(prefix, 'bin/python')
+ shutil.copy(original_python, py_executable)
+
+ # Copy the framework's dylib into the virtual
+ # environment
+ virtual_lib = os.path.join(home_dir, '.Python')
+
+ if os.path.exists(virtual_lib):
+ os.unlink(virtual_lib)
+ copyfile(
+ os.path.join(prefix, 'Python'),
+ virtual_lib)
+
+ # And then change the install_name of the copied python executable
+ try:
+ call_subprocess(
+ ["install_name_tool", "-change",
+ os.path.join(prefix, 'Python'),
+ '@executable_path/../.Python',
+ py_executable])
+ except:
+ logger.fatal(
+ "Could not call install_name_tool -- you must have Apple's development tools installed")
+ raise
+
+ # Some tools depend on pythonX.Y being present
+ py_executable_version = '%s.%s' % (
+ sys.version_info[0], sys.version_info[1])
+ if not py_executable.endswith(py_executable_version):
+ # symlinking pythonX.Y > python
+ pth = py_executable + '%s.%s' % (
+ sys.version_info[0], sys.version_info[1])
+ if os.path.exists(pth):
+ os.unlink(pth)
+ os.symlink('python', pth)
+ else:
+ # reverse symlinking python -> pythonX.Y (with --python)
+ pth = join(bin_dir, 'python')
+ if os.path.exists(pth):
+ os.unlink(pth)
+ os.symlink(os.path.basename(py_executable), pth)
+
+ if sys.platform == 'win32' and ' ' in py_executable:
+ # There's a bug with subprocess on Windows when using a first
+ # argument that has a space in it. Instead we have to quote
+ # the value:
+ py_executable = '"%s"' % py_executable
+ cmd = [py_executable, '-c', """
+import sys
+prefix = sys.prefix
+if sys.version_info[0] == 3:
+ prefix = prefix.encode('utf8')
+if hasattr(sys.stdout, 'detach'):
+ sys.stdout = sys.stdout.detach()
+elif hasattr(sys.stdout, 'buffer'):
+ sys.stdout = sys.stdout.buffer
+sys.stdout.write(prefix)
+"""]
+ logger.info('Testing executable with %s %s "%s"' % tuple(cmd))
+ try:
+ proc = subprocess.Popen(cmd,
+ stdout=subprocess.PIPE)
+ proc_stdout, proc_stderr = proc.communicate()
+ except OSError:
+ e = sys.exc_info()[1]
+ if e.errno == errno.EACCES:
+ logger.fatal('ERROR: The executable %s could not be run: %s' % (py_executable, e))
+ sys.exit(100)
+ else:
+ raise e
+
+ proc_stdout = proc_stdout.strip().decode("utf-8")
+ proc_stdout = os.path.normcase(os.path.abspath(proc_stdout))
+ norm_home_dir = os.path.normcase(os.path.abspath(home_dir))
+ if hasattr(norm_home_dir, 'decode'):
+ norm_home_dir = norm_home_dir.decode(sys.getfilesystemencoding())
+ if proc_stdout != norm_home_dir:
+ logger.fatal(
+ 'ERROR: The executable %s is not functioning' % py_executable)
+ logger.fatal(
+ 'ERROR: It thinks sys.prefix is %r (should be %r)'
+ % (proc_stdout, norm_home_dir))
+ logger.fatal(
+ 'ERROR: virtualenv is not compatible with this system or executable')
+ if sys.platform == 'win32':
+ logger.fatal(
+ 'Note: some Windows users have reported this error when they '
+ 'installed Python for "Only this user" or have multiple '
+ 'versions of Python installed. Copying the appropriate '
+ 'PythonXX.dll to the virtualenv Scripts/ directory may fix '
+ 'this problem.')
+ sys.exit(100)
+ else:
+ logger.info('Got sys.prefix result: %r' % proc_stdout)
+
+ pydistutils = os.path.expanduser('~/.pydistutils.cfg')
+ if os.path.exists(pydistutils):
+ logger.notify('Please make sure you remove any previous custom paths from '
+ 'your %s file.' % pydistutils)
+ ## FIXME: really this should be calculated earlier
+
+ fix_local_scheme(home_dir)
+
+ return py_executable
+
+def install_activate(home_dir, bin_dir, prompt=None):
+ home_dir = os.path.abspath(home_dir)
+ if sys.platform == 'win32' or is_jython and os._name == 'nt':
+ files = {
+ 'activate.bat': ACTIVATE_BAT,
+ 'deactivate.bat': DEACTIVATE_BAT,
+ 'activate.ps1': ACTIVATE_PS,
+ }
+
+ # MSYS needs paths of the form /c/path/to/file
+ drive, tail = os.path.splitdrive(home_dir.replace(os.sep, '/'))
+ home_dir_msys = (drive and "/%s%s" or "%s%s") % (drive[:1], tail)
+
+ # Run-time conditional enables (basic) Cygwin compatibility
+ home_dir_sh = ("""$(if [ "$OSTYPE" "==" "cygwin" ]; then cygpath -u '%s'; else echo '%s'; fi;)""" %
+ (home_dir, home_dir_msys))
+ files['activate'] = ACTIVATE_SH.replace('__VIRTUAL_ENV__', home_dir_sh)
+
+ else:
+ files = {'activate': ACTIVATE_SH}
+
+ # suppling activate.fish in addition to, not instead of, the
+ # bash script support.
+ files['activate.fish'] = ACTIVATE_FISH
+
+ # same for csh/tcsh support...
+ files['activate.csh'] = ACTIVATE_CSH
+
+ files['activate_this.py'] = ACTIVATE_THIS
+ if hasattr(home_dir, 'decode'):
+ home_dir = home_dir.decode(sys.getfilesystemencoding())
+ vname = os.path.basename(home_dir)
+ for name, content in files.items():
+ content = content.replace('__VIRTUAL_PROMPT__', prompt or '')
+ content = content.replace('__VIRTUAL_WINPROMPT__', prompt or '(%s)' % vname)
+ content = content.replace('__VIRTUAL_ENV__', home_dir)
+ content = content.replace('__VIRTUAL_NAME__', vname)
+ content = content.replace('__BIN_NAME__', os.path.basename(bin_dir))
+ writefile(os.path.join(bin_dir, name), content)
+
+def install_distutils(home_dir):
+ distutils_path = change_prefix(distutils.__path__[0], home_dir)
+ mkdir(distutils_path)
+ ## FIXME: maybe this prefix setting should only be put in place if
+ ## there's a local distutils.cfg with a prefix setting?
+ home_dir = os.path.abspath(home_dir)
+ ## FIXME: this is breaking things, removing for now:
+ #distutils_cfg = DISTUTILS_CFG + "\n[install]\nprefix=%s\n" % home_dir
+ writefile(os.path.join(distutils_path, '__init__.py'), DISTUTILS_INIT)
+ writefile(os.path.join(distutils_path, 'distutils.cfg'), DISTUTILS_CFG, overwrite=False)
+
+def fix_local_scheme(home_dir):
+ """
+ Platforms that use the "posix_local" install scheme (like Ubuntu with
+ Python 2.7) need to be given an additional "local" location, sigh.
+ """
+ try:
+ import sysconfig
+ except ImportError:
+ pass
+ else:
+ if sysconfig._get_default_scheme() == 'posix_local':
+ local_path = os.path.join(home_dir, 'local')
+ if not os.path.exists(local_path):
+ os.mkdir(local_path)
+ for subdir_name in os.listdir(home_dir):
+ if subdir_name == 'local':
+ continue
+ os.symlink(os.path.abspath(os.path.join(home_dir, subdir_name)), \
+ os.path.join(local_path, subdir_name))
+
+def fix_lib64(lib_dir):
+ """
+ Some platforms (particularly Gentoo on x64) put things in lib64/pythonX.Y
+ instead of lib/pythonX.Y. If this is such a platform we'll just create a
+ symlink so lib64 points to lib
+ """
+ if [p for p in distutils.sysconfig.get_config_vars().values()
+ if isinstance(p, basestring) and 'lib64' in p]:
+ logger.debug('This system uses lib64; symlinking lib64 to lib')
+ assert os.path.basename(lib_dir) == 'python%s' % sys.version[:3], (
+ "Unexpected python lib dir: %r" % lib_dir)
+ lib_parent = os.path.dirname(lib_dir)
+ assert os.path.basename(lib_parent) == 'lib', (
+ "Unexpected parent dir: %r" % lib_parent)
+ copyfile(lib_parent, os.path.join(os.path.dirname(lib_parent), 'lib64'))
+
+def resolve_interpreter(exe):
+ """
+ If the executable given isn't an absolute path, search $PATH for the interpreter
+ """
+ if os.path.abspath(exe) != exe:
+ paths = os.environ.get('PATH', '').split(os.pathsep)
+ for path in paths:
+ if os.path.exists(os.path.join(path, exe)):
+ exe = os.path.join(path, exe)
+ break
+ if not os.path.exists(exe):
+ logger.fatal('The executable %s (from --python=%s) does not exist' % (exe, exe))
+ raise SystemExit(3)
+ if not is_executable(exe):
+ logger.fatal('The executable %s (from --python=%s) is not executable' % (exe, exe))
+ raise SystemExit(3)
+ return exe
+
+def is_executable(exe):
+ """Checks a file is executable"""
+ return os.access(exe, os.X_OK)
+
+############################################################
+## Relocating the environment:
+
+def make_environment_relocatable(home_dir):
+ """
+ Makes the already-existing environment use relative paths, and takes out
+ the #!-based environment selection in scripts.
+ """
+ home_dir, lib_dir, inc_dir, bin_dir = path_locations(home_dir)
+ activate_this = os.path.join(bin_dir, 'activate_this.py')
+ if not os.path.exists(activate_this):
+ logger.fatal(
+ 'The environment doesn\'t have a file %s -- please re-run virtualenv '
+ 'on this environment to update it' % activate_this)
+ fixup_scripts(home_dir)
+ fixup_pth_and_egg_link(home_dir)
+ ## FIXME: need to fix up distutils.cfg
+
+OK_ABS_SCRIPTS = ['python', 'python%s' % sys.version[:3],
+ 'activate', 'activate.bat', 'activate_this.py']
+
+def fixup_scripts(home_dir):
+ # This is what we expect at the top of scripts:
+ shebang = '#!%s/bin/python' % os.path.normcase(os.path.abspath(home_dir))
+ # This is what we'll put:
+ new_shebang = '#!/usr/bin/env python%s' % sys.version[:3]
+ activate = "import os; activate_this=os.path.join(os.path.dirname(os.path.realpath(__file__)), 'activate_this.py'); execfile(activate_this, dict(__file__=activate_this)); del os, activate_this"
+ if sys.platform == 'win32':
+ bin_suffix = 'Scripts'
+ else:
+ bin_suffix = 'bin'
+ bin_dir = os.path.join(home_dir, bin_suffix)
+ home_dir, lib_dir, inc_dir, bin_dir = path_locations(home_dir)
+ for filename in os.listdir(bin_dir):
+ filename = os.path.join(bin_dir, filename)
+ if not os.path.isfile(filename):
+ # ignore subdirs, e.g. .svn ones.
+ continue
+ f = open(filename, 'rb')
+ try:
+ try:
+ lines = f.read().decode('utf-8').splitlines()
+ except UnicodeDecodeError:
+ # This is probably a binary program instead
+ # of a script, so just ignore it.
+ continue
+ finally:
+ f.close()
+ if not lines:
+ logger.warn('Script %s is an empty file' % filename)
+ continue
+ if not lines[0].strip().startswith(shebang):
+ if os.path.basename(filename) in OK_ABS_SCRIPTS:
+ logger.debug('Cannot make script %s relative' % filename)
+ elif lines[0].strip() == new_shebang:
+ logger.info('Script %s has already been made relative' % filename)
+ else:
+ logger.warn('Script %s cannot be made relative (it\'s not a normal script that starts with %s)'
+ % (filename, shebang))
+ continue
+ logger.notify('Making script %s relative' % filename)
+ lines = [new_shebang+'\n', activate+'\n'] + lines[1:]
+ f = open(filename, 'wb')
+ f.write('\n'.join(lines).encode('utf-8'))
+ f.close()
+
+def fixup_pth_and_egg_link(home_dir, sys_path=None):
+ """Makes .pth and .egg-link files use relative paths"""
+ home_dir = os.path.normcase(os.path.abspath(home_dir))
+ if sys_path is None:
+ sys_path = sys.path
+ for path in sys_path:
+ if not path:
+ path = '.'
+ if not os.path.isdir(path):
+ continue
+ path = os.path.normcase(os.path.abspath(path))
+ if not path.startswith(home_dir):
+ logger.debug('Skipping system (non-environment) directory %s' % path)
+ continue
+ for filename in os.listdir(path):
+ filename = os.path.join(path, filename)
+ if filename.endswith('.pth'):
+ if not os.access(filename, os.W_OK):
+ logger.warn('Cannot write .pth file %s, skipping' % filename)
+ else:
+ fixup_pth_file(filename)
+ if filename.endswith('.egg-link'):
+ if not os.access(filename, os.W_OK):
+ logger.warn('Cannot write .egg-link file %s, skipping' % filename)
+ else:
+ fixup_egg_link(filename)
+
+def fixup_pth_file(filename):
+ lines = []
+ prev_lines = []
+ f = open(filename)
+ prev_lines = f.readlines()
+ f.close()
+ for line in prev_lines:
+ line = line.strip()
+ if (not line or line.startswith('#') or line.startswith('import ')
+ or os.path.abspath(line) != line):
+ lines.append(line)
+ else:
+ new_value = make_relative_path(filename, line)
+ if line != new_value:
+ logger.debug('Rewriting path %s as %s (in %s)' % (line, new_value, filename))
+ lines.append(new_value)
+ if lines == prev_lines:
+ logger.info('No changes to .pth file %s' % filename)
+ return
+ logger.notify('Making paths in .pth file %s relative' % filename)
+ f = open(filename, 'w')
+ f.write('\n'.join(lines) + '\n')
+ f.close()
+
+def fixup_egg_link(filename):
+ f = open(filename)
+ link = f.read().strip()
+ f.close()
+ if os.path.abspath(link) != link:
+ logger.debug('Link in %s already relative' % filename)
+ return
+ new_link = make_relative_path(filename, link)
+ logger.notify('Rewriting link %s in %s as %s' % (link, filename, new_link))
+ f = open(filename, 'w')
+ f.write(new_link)
+ f.close()
+
+def make_relative_path(source, dest, dest_is_directory=True):
+ """
+ Make a filename relative, where the filename is dest, and it is
+ being referred to from the filename source.
+
+ >>> make_relative_path('/usr/share/something/a-file.pth',
+ ... '/usr/share/another-place/src/Directory')
+ '../another-place/src/Directory'
+ >>> make_relative_path('/usr/share/something/a-file.pth',
+ ... '/home/user/src/Directory')
+ '../../../home/user/src/Directory'
+ >>> make_relative_path('/usr/share/a-file.pth', '/usr/share/')
+ './'
+ """
+ source = os.path.dirname(source)
+ if not dest_is_directory:
+ dest_filename = os.path.basename(dest)
+ dest = os.path.dirname(dest)
+ dest = os.path.normpath(os.path.abspath(dest))
+ source = os.path.normpath(os.path.abspath(source))
+ dest_parts = dest.strip(os.path.sep).split(os.path.sep)
+ source_parts = source.strip(os.path.sep).split(os.path.sep)
+ while dest_parts and source_parts and dest_parts[0] == source_parts[0]:
+ dest_parts.pop(0)
+ source_parts.pop(0)
+ full_parts = ['..']*len(source_parts) + dest_parts
+ if not dest_is_directory:
+ full_parts.append(dest_filename)
+ if not full_parts:
+ # Special case for the current directory (otherwise it'd be '')
+ return './'
+ return os.path.sep.join(full_parts)
+
+
+
+############################################################
+## Bootstrap script creation:
+
+def create_bootstrap_script(extra_text, python_version=''):
+ """
+ Creates a bootstrap script, which is like this script but with
+ extend_parser, adjust_options, and after_install hooks.
+
+ This returns a string that (written to disk of course) can be used
+ as a bootstrap script with your own customizations. The script
+ will be the standard virtualenv.py script, with your extra text
+ added (your extra text should be Python code).
+
+ If you include these functions, they will be called:
+
+ ``extend_parser(optparse_parser)``:
+ You can add or remove options from the parser here.
+
+ ``adjust_options(options, args)``:
+ You can change options here, or change the args (if you accept
+ different kinds of arguments, be sure you modify ``args`` so it is
+ only ``[DEST_DIR]``).
+
+ ``after_install(options, home_dir)``:
+
+ After everything is installed, this function is called. This
+ is probably the function you are most likely to use. An
+ example would be::
+
+ def after_install(options, home_dir):
+ subprocess.call([join(home_dir, 'bin', 'easy_install'),
+ 'MyPackage'])
+ subprocess.call([join(home_dir, 'bin', 'my-package-script'),
+ 'setup', home_dir])
+
+ This example immediately installs a package, and runs a setup
+ script from that package.
+
+ If you provide something like ``python_version='2.4'`` then the
+ script will start with ``#!/usr/bin/env python2.4`` instead of
+ ``#!/usr/bin/env python``. You can use this when the script must
+ be run with a particular Python version.
+ """
+ filename = __file__
+ if filename.endswith('.pyc'):
+ filename = filename[:-1]
+ f = open(filename, 'rb')
+ content = f.read()
+ f.close()
+ py_exe = 'python%s' % python_version
+ content = (('#!/usr/bin/env %s\n' % py_exe)
+ + '## WARNING: This file is generated\n'
+ + content)
+ return content.replace('##EXT' 'END##', extra_text)
+
+##EXTEND##
+
+def convert(s):
+ b = base64.b64decode(s.encode('ascii'))
+ return zlib.decompress(b).decode('utf-8')
+
+##file site.py
+SITE_PY = convert("""
+eJzFPf1z2zaWv/OvwMqTIZXKdD66nR2n7o2TOK333MTbpLO5dT1aSoIs1hTJEqRl7c3d337vAwAB
+kvLHpp3TdGKJBB4eHt43HtDRaHRcljJfiHWxaDIplEyq+UqUSb1SYllUol6l1WK/TKp6C0/n18mV
+VKIuhNqqGFvFQfD0Cz/BU/FplSqDAnxLmrpYJ3U6T7JsK9J1WVS1XIhFU6X5lUjztE6TLP0XtCjy
+WDz9cgyC01zAzLNUVuJGVgrgKlEsxfm2XhW5iJoS5/w8/nPycjwRal6lZQ0NKo0zUGSV1EEu5QLQ
+hJaNAlKmtdxXpZyny3RuG26KJluIMkvmUvzznzw1ahqGgSrWcrOSlRQ5IAMwJcAqEQ/4mlZiXixk
+LMRrOU9wAH7eEitgaBNcM4VkzAuRFfkVzCmXc6lUUm1FNGtqAkQoi0UBOKWAQZ1mWbApqms1hiWl
+9djAI5Ewe/iTYfaAeeL4fc4BHD/kwc95ejth2MA9CK5eMdtUcpneigTBwk95K+dT/SxKl2KRLpdA
+g7weY5OAEVAiS2cHJS3Ht3qFvjsgrCxXJjCGRJS5Mb+kHnFwWoskU8C2TYk0UoT5WzlLkxyokd/A
+cAARSBoMjbNIVW3HodmJAgBUuI41SMlaiWidpDkw64/JnND+e5ovio0aEwVgtZT4tVG1O/9ogADQ
+2iHAJMDFMqvZ5Fl6LbPtGBD4BNhXUjVZjQKxSCs5r4sqlYoAAGpbIW8B6YlIKqlJyJxp5HZC9Cea
+pDkuLAoYCjy+RJIs06umIgkTyxQ4F7ji3YefxNuT16fH7zWPGWAss1drwBmg0EI7OMEA4qBR1UFW
+gEDHwRn+EcligUJ2heMDXm2Dg3tXOohg7mXc7eMsOJBdL64eBuZYgzKhsQLq99/QZaJWQJ//uWe9
+g+B4F1Vo4vxtsypAJvNkLcUqYf5Czgi+1XC+i8t69Qq4QSGcGkilcHEQwRThAUlcmkVFLkUJLJal
+uRwHQKEZtfVXEVjhfZHv01p3OAEgVEEOL51nYxoxlzDRPqxXqC9M4y3NTDcJ7Dqvi4oUB/B/Pidd
+lCX5NeGoiKH420xepXmOCCEvBOFeSAOr6xQ4cRGLM2pFesE0EiFrL26JItEALyHTAU/K22RdZnLC
+4ou69W41QoPJWpi1zpjjoGVN6pVWrZ3qIO+9iD93uI7QrFeVBODNzBO6ZVFMxAx0NmFTJmsWr3pT
+EOcEA/JEnZAnqCX0xe9A0WOlmrW0L5FXQLMQQwXLIsuKDZDsMAiE2MNGxij7zAlv4R38C3Dx30zW
+81UQOCNZwBoUIr8LFAIBkyBzzdUaCY/bNCt3lUyas6YoqoWsaKiHEfuAEX9gY5xr8L6otVHj6eIq
+F+u0RpU00yYzZYuXhzXrx1c8b5gGWG5FNDNNWzqtcXpZuUpm0rgkM7lESdCL9MouO4wZDIxJtrgW
+a7Yy8A7IIlO2IMOKBZXOspbkBAAMFr4kT8smo0YKGUwkMNC6JPjrBE16oZ0lYG82ywEqJDbfc7A/
+gNu/QIw2qxToMwcIoGFQS8HyzdK6Qgeh1UeBb/RNfx4fOPV0qW0TD7lM0kxb+SQPTunhSVWR+M5l
+ib0mmhgKZpjX6Npd5UBHFPPRaBQExh3aKvO1UEFdbQ+BFYQZZzqdNSkavukUTb3+oQIeRTgDe91s
+OwsPNITp9B6o5HRZVsUaX9u5fQRlAmNhj2BPnJOWkewge5z4CsnnqvTSNEXb7bCzQD0UnP908u70
+88lHcSQuWpU26eqzSxjzJE+ArckiAFN1hm11GbRExZei7hPvwLwTU4A9o94kvjKpG+BdQP1T1dBr
+mMbcexmcvD9+fXYy/fnjyU/Tj6efTgBBsDMy2KMpo3lswGFUMQgHcOVCxdq+Br0e9OD18Uf7IJim
+alpuyy08AEMJLFxFMN+JCPHhVNvgaZovi3BMjX9lJ/yI1Yr2uC4Ov74UR0ci/DW5ScIAvJ62KS/i
+jyQAn7alhK41/IkKNQ6ChVyCsFxLFKnoKXmyY+4ARISWhbasvxZpbt4zH7lDkMRH1ANwmE7nWaIU
+Np5OQyAtdRj4QIeY3WGUkwg6llu361ijgp9KwlLk2GWC/wygmMyoH6LBKLpdTCMQsPU8UZJb0fSh
+33SKWmY6jfSAIH7E4+AiseIIhWmCWqZKwRMlXkGtM1NFhj8RPsotiQwGQ6jXcJF0sBPfJFkjVeRM
+CogYRR0yompMFXEQOBUR2M526cbjLjUNz0AzIF9WgN6rOpTDzx54KKBgTNiFoRlHS0wzxPSvHBsQ
+DuAkhqiglepAYX0mzk/OxctnL/bRAYEocWGp4zVHm5rmjbQPl7BaV7J2EOZe4YSEYezSZYmaEZ8e
+3g1zHduV6bPCUi9xJdfFjVwAtsjAziqLn+gNxNIwj3kCqwiamCw4Kz3j6SUYOfLsQVrQ2gP11gTF
+rL9Z+j0O32WuQHVwKEyk1nE6G6+yKm5SdA9mW/0SrBuoN7RxxhUJnIXzmAyNGGgI8FtzpNRGhqDA
+qoZdTMIbQaKGX7SqMCZwZ6hbL+nrdV5s8inHrkeoJqOxZV0ULM282KBdgj3xDuwGIFlAKNYSjaGA
+ky5QtvYBeZg+TBcoS9EAAALTrCjAcmCZ4IymyHEeDoswxq8ECW8l0cLfmCEoODLEcCDR29g+MFoC
+IcHkrIKzqkEzGcqaaQYDOyTxue4s5qDRB9ChYgyGLtLQuJGh38UhKGdx5iolpx/a0M+fPzPbqBVl
+RBCxGU4ajf6SzFtcbsEUpqATjA/F+RVigw24owCmUZo1xf5HUZTsP8F6nmvZBssN8Vhdl4cHB5vN
+Jtb5gKK6OlDLgz//5Ztv/vKMdeJiQfwD03GkRSfH4gN6hz5o/K2xQN+ZlevwY5r73EiwIkl+FDmP
+iN/3TbooxOH+2OpP5OLWsOK/xvkABTI1gzKVgbajFqMnav9J/FKNxBMRuW2jMXsS2qRaK+ZbXehR
+F2C7wdOYF01eh44iVeIrsG4QUy/krLkK7eCejTQ/YKoop5Hlgf3nl4iBzxmGr4wpnqKWILZAi++Q
+/idmm4T8Ga0hkLxoonrx7nZYixniLh4u79Y7dITGzDBVyB0oEX6TBwugbdyXHPxoZxTtnuOMmo9n
+CIylDwzzalcwQsEhXHAtJq7UOVyNPipI04ZVMygYVzWCgga3bsbU1uDIRoYIEr0bE57zwuoWQKdO
+rs9E9GYVoIU7Ts/adVnB8YSQB47Ec3oiwak97L17xkvbZBmlYDo86lGFAXsLjXa6AL6MDICJGFU/
+j7ilCSw+dBaF12AAWMFZG2SwZY+Z8I3rA472RgPs1LP6u3ozjYdA4CJFnD16EHRC+YhHqBRIUxn5
+PXexuCVuf7A7LQ4xlVkmEmm1Q7i6ymNQqO40TMs0R93rLFI8zwrwiq1WJEZq3/vOAkUu+HjImGkJ
+1GRoyeE0OiJvzxPAULfDhNdVg6kBN3OCGK1TRdYNybSCf8CtoIwEpY+AlgTNgnmolPkT+x1kzs5X
+f9nBHpbQyBBu011uSM9iaDjm/Z5AMur8CUhBDiTsCyO5jqwOMuAwZ4E84YbXcqd0E4xYgZw5FoTU
+DOBOL70AB5/EuGdBEoqQb2slS/GVGMHydUX1Ybr7d+VSkzaInAbkKuh8w5Gbi3DyEEedvITP0H5G
+gnY3ygI4eAYuj5uad9ncMK1Nk4Cz7ituixRoZMqcjMYuqpeGMG76909HTouWWGYQw1DeQN4mjBlp
+HNjl1qBhwQ0Yb827Y+nHbsYC+0ZhoV7I9S3Ef2GVqnmhQgxwe7kL96O5ok8bi+1ZOhvBH28BRuNL
+D5LMdP4Csyz/xiChBz0cgu5NFtMii6TapHlICkzT78hfmh4elpSekTv4SOHUAUwUc5QH7yoQENqs
+PABxQk0AUbkMlXb7+2DvnOLIwuXuI89tvjh8edkn7mRXhsd+hpfq5LauEoWrlfGisVDgavUNOCpd
+mFySb/V2o96OxjChKhREkeLDx88CCcGZ2E2yfdzUW4ZHbO6dk/cxqINeu5dcndkRuwAiqBWRUQ7C
+x3Pkw5F97OTumNgjgDyKYe5YFANJ88m/A+euhYIx9hfbHPNoXZWBH3j9zdfTgcyoi+Q3X4/uGaVD
+jCGxjzqeoB2ZygDE4LRNl0omGfkaTifKKuYt79g25ZgVOsV/mskuB5xO/Jj3xmS08HvNe4Gj+ewR
+PSDMLma/QrCqdH7rJkkzSsoDGvv7qOdMnM2pg2F8PEh3o4w5KfBYnk0GQyF18QwWJuTAftyfjvaL
+jk3udyAgNZ8yUX1U9vQGfLt/5G2qu3uHfajamBgeesaZ/hcDWsKb8ZBd/xINh5/fRRlYYB4NRkNk
+9xzt/+9ZPvtjJvnAqZht39/RMD0S0O81E9bjDE3r8XHHIA4tu2sCDbAHWIodHuAdHlp/aN7oWxo/
+i1WSEk9Rdz0VG9rrpzQnbtoAlAW7YANwcBn1jvGbpqp435dUYCmrfdzLnAgsczJOGFVP9cEcvJc1
+YmKbzSlt7BTFFENqJNSJYDuTsHXhh+VsVZj0kcxv0gr6gsKNwh8+/HgS9hlAD4OdhsG562i45OEm
+HOE+gmlDTZzwMX2YQo/p8u9LVTeK8AlqttNNclaTbdA++DlZE9IPr8E9yRlv75T3qDFYnq/k/Hoq
+ad8d2RS7OvnpN/gaMbHb8X7xlEqWVAEGM5lnDdKKfWAs3Vs2+Zy2KmoJro6us8W6G9pN50zcMkuu
+RESdF5gF0txIiaKbpNKOYFkVWNkpmnRxcJUuhPytSTKMsOVyCbjgPpJ+FfPwlAwSb7kggCv+lJw3
+VVpvgQSJKvQ2HNUOOA1nW55o5CHJOy5MQKwmOBQfcdr4ngm3MOQycbq/+YCTxBAYO5h9UuQueg7v
+82KKo06pQHbCSPW3yOlx0B2hAAAjAArzH411Es1/I+mVu9dHa+4SFbWkR0o36C/IGUMo0RiTDvyb
+fvqM6PLWDiyvdmN5dTeWV10srwaxvPKxvLobS1ckcGFt/shIwlAOqbvDMFis4qZ/eJiTZL7idlg4
+iQWSAFGUJtY1MsX1w16SibfaCAipbWfvlx62xScpV2RWBWejNUjkftxP0nG1qfx2OlMpi+7MUzHu
+7K4CHL/vQRxTndWMurO8LZI6iT25uMqKGYitRXfSApiIbi0Opy3zm+mME60dSzU6/69PP3x4j80R
+1MhUGlA3XEQ0LDiV6GlSXam+NLVxWAnsSC39mhjqpgHuPTDJxaPs8T9vqdgCGUdsqFigECV4AFQS
+ZZu5hUNh2HmuK4z0c2Zy3vc5EqO8HrWT2kGk4/Pzt8efjkeUfRv978gVGENbXzpcfEwL26Dvv7nN
+LcWxDwi1TjO1xs+dk0frliPut7EGbM+H7zx48RCDPRix+7P8QykFSwKEinUe9jGEenAM9EVhQo8+
+hhF7lXPuJhc7K/adI3uOi+KI/tAOQHcAf98RY4wpEEC7UJGJDNpgqqP0rXm9g6IO0Af6el8cgnVD
+r24k41PUTmLAAXQoa5vtdv+8LRM2ekrWr0++P31/dvr6/PjTD44LiK7ch48HL8TJj58FlWqgAWOf
+KMEqhRqLgsCwuKeExKKA/xrM/CyamvO10Ovt2ZneNFnjOREsHEabE8Nzriiy0Dh9xQlh+1CXAiFG
+mQ6QnAM5VDlDB3YwXlrzYRBV6OJiOuczQ2e10aGXPmhlDmTRFnMM0geNXVIwCK72gldUAl6bqLDi
+zTh9SGkAKW2jbY1GRum53s69sxVlNjq8nCV1hidtZ63oL0IX1/AyVmWWQiT3KrSypLthpUrLOPqh
+3WtmvIY0oNMdRtYNedY7sUCr9Srkuen+45bRfmsAw5bB3sK8c0mVGlS+jHVmIsRGvKkSylv4apde
+r4GCBcM9txoX0TBdCrNPILgWqxQCCODJFVhfjBMAQmcl/Nz8oZMdkAUWSoRv1ov9v4WaIH7rX34Z
+aF5X2f4/RAlRkOCqnnCAmG7jtxD4xDIWJx/ejUNGjqpkxd8arK0Hh4QSoI60UykRb2ZPIyWzpS71
+8PUBvtB+Ar3udK9kWenuw65xiBLwREXkNTxRhn4hVl5Z2BOcyrgDGo8NWMzw+J1bEWA+e+LjSmaZ
+LhY/fXt2Ar4jnmRACeItsBMYjvMluJut6+D4eGAHFO51w+sK2bhCF5bqHRax12wwaY0iR729Egm7
+TpQY7vfqZYGrJFUu2hFOm2GZWvwYWRnWwiwrs3anDVLYbUMUR5lhlpieV1RL6vME8DI9TTgkglgJ
+z0mYDDxv6KZ5bYoHs3QOehRULijUCQgJEhcPAxLnFTnnwItKmTNE8LDcVunVqsZ9Bugc0/kFbP7j
+8eez0/dU0//iZet1DzDnhCKBCddzHGG1HmY74ItbgYdcNZ0O8ax+hTBQ+8Cf7isuFDniAXr9OLGI
+f7qv+BDXkRMJ8gxAQTVlVzwwAHC6DclNKwuMq42D8eNW47WY+WAoF4lnRnTNhTu/Pifalh1TQnkf
+8/IRGzjLUtMwMp3d6rDuR89xWeKO0yIabgRvh2TLfGbQ9br3ZlcdmvvpSSGeJwWM+q39MUyhVq+p
+no7DbLu4hcJabWN/yZ1cqdNunqMoAxEjt/PYZbJhJaybMwd6Fc09YOJbja6RxEFVPvolH2kPw8PE
+ErsXp5iOdKKEjABmMqQ+ONOAD4UWARQIFeJGjuROxk9feHN0rMH9c9S6C2zjD6AIdVksHbcoKuBE
++PIbO478itBCPXooQsdTyWVe2JIt/GxW6FU+9+c4KAOUxESxq5L8SkYMa2JgfuUTe0cKlrStR+qL
+9HLIsIhTcE5vd3B4Xy6GN04Mah1G6LW7ltuuOvLJgw0GT2XcSTAffJVsQPeXTR3xSg6L/PBBtN1Q
+74eIhYDQVO+DRyGmY34Ld6xPC3iQGhoWeni/7diF5bUxjqy1j50DRqF9oT3YeQWhWa1oW8Y52Wd8
+UesFtAb3qDX5I/tU1+zY3wNHtpyckAXKg7sgvbmNdINOOmHEJ4f42GVKlentwRb9biFvZFaA6wVR
+HR48+NUePBjHNp0yWJL1xdidb8+3w7jRmxazQ3MyAj0zVcL6xbmsDxCdwYzPXZi1yOBS/6JDkiS/
+Ji/5zd9PJ+LN+5/g39fyA8RVeHJwIv4BaIg3RQXxJR99pTsJ8FBFzYFj0Sg8XkjQaKuCr29At+3c
+ozNui+jTHv4xD6spBRa4Vmu+MwRQ5AnScfDWTzBnGOC3OWTV8UaNpzi0KCP9Emmw+9wJntU40C3j
+Vb3O0F44WZJ2NS9GZ6dvTt5/PInrW+Rw83PkZFH82iicjt4jrnA/bCLsk3mDTy4dx/kHmZUDfrMO
+Os0ZFgw6RQhxSWkDTb6PIrHBRVJh5kCU20Uxj7ElsDwfm6s34EiPnfjyXkPvWVmEFY31LlrrzeNj
+oIb4pauIRtCQ+ug5UU9CKJnh+S1+HI+GTfFEUGob/jy93izczLg+iEMT7GLazjryu1tduGI6a3iW
+kwivI7sM5mxmliZqPZu7Z/Y+5EJfJwJajvY55DJpslrIHCSXgny61wE0vXvMjiWEWYXNGZ09ozRN
+tkm2yilCSpQY4agjOpqOGzKUMYQY/Mfkmu0Bnv8TDR8kBuiEKMVPhdNVNfMVSzCHRES9gcKDTZq/
+dOt5NIV5UI6Q560jC/NEt5ExupK1nj8/iMYXz9tKB8pKz71DtvMSrJ7LJnugOsunT5+OxH/c7/0w
+KnFWFNfglgHsQa/ljF7vsNx6cna1+p69eRMDP85X8gIeXFL23D5vckpN3tGVFkTavwZGiGsTWmY0
+7Tt2mZN2FW80cwvesNKW4+c8pUuDMLUkUdnqu5cw7WSkiVgSFEOYqHmahpymgPXYFg2ej8M0o+YX
+eQscnyKYCb7FHTIOtVfoYVItq+Uei86RGBHgEdWW8Wh0wJhOiAGe0/OtRnN6mqd1e7Tjmbt5qg/S
+1/YuIM1XItmgZJh5dIjhHLX0WLX1sIs7WdSLWIr5hZtw7MySX9+HO7A2SFqxXBpM4aFZpHkhq7kx
+p7hi6TytHTCmHcLhznQFElmfOBhAaQTqnazCwkq0ffsnuy4uph9oH3nfjKTLh2p7rRQnh5K8U2AY
+x+34lIayhLR8a76MYZT3lNbWnoA3lviTTqpiXb93+4V7xLDJ9a0WXL/RXnUBcOgmJasgLTt6OsK5
+vsvCZ6bdcRcFfihEJ9xu0qpukmyqL0+YosM2tRvrGk97NO3OQ5fWWwEnvwAPeF9X0YPjYKpskJ5Y
+BGtOSRyJpU5RxO5pL/9gVFmgl/eCfSXwKZAyi6k5o2ySSBeWXe3hT12z6ah4BPWVOVD0EJtgjrX0
+ToS405hQ0VM47la59lrhBos5tmA9725k8KghO7B8L95MsHunhfjuSETPJ+LPnUBsXm7xViYgw5NF
+/GQR+j4hdb04fNHauX7g24GwE8jLy0dPN0tnNL1wqPz8/r666BED0DXI7jKVi/0nCrFjnL8UqobS
+zms3p9KM8XT6nq260gez2+MqdCptBlHFplVojmoz/q8dxJz41nqID8ei0mALaA/0m8KXTvGhvXQN
+CxM1ev7KopRMhzbH8BtenALvNUFdodq5aaor7C3YgZyAPkbJW2Btw4Gg8BE8FNIlL7RoX3W2hf/I
+xeOi/V2biz0sv/n6LjxdAR88sTBAUI+YTqs/kKl2ssxjF+YB+/X389/Dee8uvns0lXSvYVphKIWF
+zKuE36BJbMpDm2owIolbQZFb3oaf+nrwTAyLI+qm+jq8a/rc/6656xaBnbnZ3e3N3T/75tJA993N
+L0M04DBPE+JBNeOtwA7rAleMJ7qoYDhlqT9IfrcTznSHVrgPjClhwAQosanG3mjNdTJ3v2OFzD5f
+7+oedRzU1Z1p985+djn+IYqWqwHwuT39TCUeC82B7DfSfV1TLhqcyqsrNU3wrrgpBRtU4NLzIo37
++o6u+pKJ2hqvEy9UARCGm3QpolttDIwBAQ3fWcv1Ic7NGYKGpipKpyxTpQvOIGkXF8DFnDmi/iYz
+yXWVo0xiwk81VVlBVDDSN5ty4cJQrWcL1CQy1om6NqibHhN90SUOwdUy5ngk56s40vCoA4TgU1PO
+tU1cqDyd2nfAL8/aY+DpxDKEzJu1rJK6vQLF3yZNxXfOCHQoFhfYSVW0ktnhFBex1PKHgxQmC+z3
+r7ST7QUZd5z9Hlut93C2oh46BfaYY+WO7THcnN7aK9Dcq3cWdGGua+Rts5b77LUvsBTmPi/SlTp3
+wG/1HUN8cyVnNtFNcPgI5N49kuaX51q1xk6KRcN55iqG/qUyeKqZbPHQXXE9LujfCtdx9O34vt6w
+zNILDXY0tlTUrtWg4mlHG7cRNVbS3RNR+9XSj4yoPfgPjKj1zX5gcDQ+Wh8M1k/fE3qzmnCvyWsZ
+AfpMgUi4s9e5ZM2YzMitRoawN70d2WtqWWc6R5yMmUCO7N+fRCD4Ojzllm5611WZcYciWl+66PH3
+Zx9eH58RLabnx2/+8/h7qlbB9HHHZj045ZAX+0ztfa8u1k0/6AqDocFbbAfuneTDHRpC731vc3YA
+wvBBnqEF7Soy9/WuDr0DEf1OgPjd0+5A3aWyByH3/DNdfO/WFXQKWAP9lKsNzS9ny9Y8MjsXLA7t
+zoR53yaTtYz2cm27Fs6p++urE+236psKd+QBx7b6lFYAc8jIXzaFbI4S2EQlOyrd/3kAlcziMSxz
+ywdI4Vw6t83RRXMMqvb/LwUVKLsE98HYYZzYG3+pHafLlb3KGvfC5jI2BPHOQY3683OFfSGzHVQI
+AlZ4+i41RsToP73BZLdjnyhxsU8nLvdR2VzaX7hm2sn9e4qbrrW9k0hx5QZvO0HjZZO5G6m2T68D
+OX+UnS+WTok/aL4DoHMrngrYG30mVoizrQghkNQbhlg1SHTUF4o5yKPddLA3tHom9nedx3PPownx
+fHfDRefIm+7xgnuoe3qoxpx6ciwwlq/tOmgnviPIvL0j6BIiz/nAPUV99y18vbl4fmiTrcjv+NpR
+JFRmM3IM+4VTpnbnxXdOd2KWakJ1TBizOcc0dYtLByr7BLtinF6t/o44yOz7MqSR9364yMf08C70
+HnUxtax3CFMS0RM1pmk5pxs07vbJuD/dVm31gfBJjQcA6alAgIVgerrRqZzbcvlr9ExHhbOGrgx1
+M+6hIxVUReNzBPcwvl+LX7c7nbB8UHdG0fTnBl0O1EsOws2+A7caeymR3SahO/WWD3a4AHxYdbj/
+8wf079d32e4v7vKrbauXgwek2JfFkkCslOiQyDyOwciA3oxIW2MduRF0vJ+jpaPLUO3ckC/Q8aMy
+Q7wQmAIMcman2gOwRiH4P2ts6wE=
+""")
+
+##file ez_setup.py
+EZ_SETUP_PY = convert("""
+eJzNWmtv49a1/a5fwSgwJGE0NN8PDzRFmkyBAYrcIo8CFx5XPk+LHYpUSWoctch/v+ucQ1KkZDrt
+RT6UwcQ2ebjPfq6195G+/upwanZlMZvP538sy6ZuKnKwatEcD01Z5rWVFXVD8pw0GRbNPkrrVB6t
+Z1I0VlNax1qM16qnlXUg7DN5EovaPLQPp7X192PdYAHLj1xYzS6rZzLLhXql2UEI2QuLZ5VgTVmd
+rOes2VlZs7ZIwS3CuX5BbajWNuXBKqXZqZN/dzebWbhkVe4t8c+tvm9l+0NZNUrL7VlLvW58a7m6
+sqwS/zhCHYtY9UGwTGbM+iKqGk5Qe59fXavfsYqXz0VeEj7bZ1VVVmurrLR3SGGRvBFVQRrRLzpb
+utabMqzipVWXFj1Z9fFwyE9Z8TRTxpLDoSoPVaZeLw8qCNoPj4+XFjw+2rPZT8pN2q9Mb6wkCqs6
+4vdamcKq7KDNa6OqtTw8VYQP42irZJi1zqtP9ey7D3/65uc//7T964cffvz4P99bG2vu2BFz3Xn/
+6Ocf/qz8qh7tmuZwd3t7OB0y2ySXXVZPt21S1Lc39S3+63e7nVs3ahe79e/9nf8wm+15uOWkIRD4
+Lx2xxfmNt9icum8PJ8/2bfH0tLizFknieYzI1HG90OFJkNA0jWgsvZBFImJksX5FStBJoXFKEhI4
+vghCx5OUJqEQvnTTwI39kNEJKd5YlzAK4zhMeUIinkgWBE7skJQ7sRd7PE1fl9LrEsAAknA3SrlH
+RRS5kvgeiUToiUAm3pRF/lgXSn2XOZLFfpqSyA/jNI1DRngqQ+JEbvKqlF4XPyEJw10eCcY9zwti
+6capjDmJolQSNiElGOsSeU4QEi8QPBCuoCyOpXD8lJBARDIW4atSzn5h1CNuEkKPhBMmJfW4C30c
+n/rUZcHLUthFvlBfejQM/ZRHiGss44DwOHU9CCKpk0xYxC7zBfZwweHJKOYe96QUbuA4qR8F0iPB
+RKSZ64yVYXCHR2jIfeJ4YRSEEeLDXD9xHBI7qfO6mF6bMOZ4ETFKaeLEscfClIQ+SQLfJyHnk54x
+YsJODBdBRFgCX6YxS9IwjD0RiiREOgqasPh1MVGvTSJQSURIJ4KDPCaiwA0gzYORcPhEtAEqY994
+lAiCGnZ9jvdRRl4iYkpCGhJoxMXrYs6R4pGfypQ6EBawwAvS2PEDLpgnmMO8yUi5Y99EAUsD6VMZ
+kxhZ6AuW+MKhHsIdByn1XhfT+4ZKknqu41COMHHUBCQJzn0EPgqcJJoQc4Ez0nGigMqIEI/G3IFa
+8GyAxHYSN2beVKAucCZyIzf1hGB+KINYIGpuxHhEXA9SvXhKygXOSDcBQAF8uUSqEC9MWQop0uUx
+jRM5gVbsAmeEI3gcRInH0jShksbwdOIgex3EPHangu2Pg0SokG4kOYdhYRi6QRK4LAZ+8TRJo3BK
+ygVaUYemru8SRqjvOXAGcC6WQcBCAEXsylel9BYhSST2jHggqfRRUVSmQcQcuAqoJ6YSJhhblCi0
+BvD7HuM0ZbFHmQwAX14kvYTIKbQKxxYJkUqeOFAHBYmMlb4ApocxAIMnbjQV6XBsEZHAKi7BKm7s
+uELAuTHIKaQMhEeiKZQJL2KUcF9GAISAMUKS2A2QONyPKWPc5yGfkBKNLULBJGD5xHUjMFGSBLEH
+EWDMMEhR2lPAGV2wGwsjIsOYwr/oHlANkQNDgsBHgYVkChuisUXUkwmJQw9kD9ilPkjaQai5CCVa
+idCfkBJfwJ2DGMmUcOaTyA1F6LohyhAtRQIInMyX+IIJSCLTMAALcGC5I2kUM+lKD2HAI2+qAuKx
+RQE4lgBvJVoGFGDgB67rSi4S38W/eEqX5KIbclQv5KXwSMrBHyoFAeCJ76jGynldSm8Ro8RPgA3o
+OYLEZ47KWWQbnM3ALJM0kIwtcmPPjQFyCHTKmRs6YeqQMKG+QJ2n4VSk07FF0J0FDpoZV3mYBmkk
+AiapcBLYypypSKcXyIAkQ2MHbvWThEdAJyKEEwG8WOQHU/1dK6W3SAqE1hchcWPqegxhYmHg0hjc
+C+YXU0ySjvmIEZSNKxVqEk9wAJOb+mC2mIaphx4HUn6dDSYCjDf1rKlOd2bg2pF6l2e0m7fQu8/E
+L0xg1Pio73xQI1G7Fg+H62ZcSGv7heQZun2xxa0ldNoWmAfXlhoAVnfagExa3X01M3bjgXmoLp5h
+tmgwLigR+kV7J34xdzHfdcsgp1351aaXct+JfjjLUxfmLkyD79+r6aRuuKgw1y1HK9Q1Vya1FrTz
+4Q2mMIIxjH9lWcu/lHWd0Xww/mGkw9/7P6zmV8JuejNHj1ajv5Q+4pesWXrmfoXgVoV2l3HoxXCo
+F7Xj1eZimFv3am0pqcVmMNCtMSluMapuytpmxwq/mWTqX+AiJ6eNG87aIGFs/ObYlHv4gWG6PGEU
+Lfhtb/bgpEDN9XvyGbHE8PwFriLKQXCeMu1Amp0Z5x9bpR+telcec66mWWJ8PZTWTebFcU9FZTU7
+0lgYhHvBWpaagAvlXUti6u2VOhZcvyKsx5EjHi010i6fdxnbdbsLaK2OJow8a3G7WNlQ0njpUW2p
+5AyOMXaiGh2QPGeYuek5EwRfIyNNgmuVixL+yCtB+OmsPvb4KAfqabfr7dqzCS2mabXU0qjQqrQO
+0ScWrCx4bXzTqXEgSBTlVHhElVXWZAhd8TQ4zzARb+0vC6HPE8zZCDd6wallrnz44vmI0rI9bBCt
+MH2WU5VH7CSMKqbOiLUXdU2ehDngOBfd46POl4pktbB+PNWN2H/4RfmrMIEoLNLgnjnZIFRBizJe
+paAyxpx62F2G6p/PpN4aFIL9G2tx+Py0rURdHism6oVCGLX9vuTHXNTqlGQAoJePTU2g6jjyoHXb
+cnVGEpVym3PRDOqy9dhFCXZlt74otDMGdEViw7OiapbOWm0yALkWqPud3g1Pd2h3zLdtA7PVwLxR
+MkyAAOyXskYO0g9fQPj+pQ6Qhg5pH13vMBJtt8m1nJ81fr+Zv2ldtXrXyh6qMBbwV7Py27KQecaa
+QRxgokFOBstluVzduw9DYhgmxX9KBPOfdufCmCiF5fvNTb3qy7wrb33K+akYc8GckWLRqGrrqwdw
+ok72dPm0J3mqkI5FgSy3rb/kAsnTLb+Sp8pLVTmwScCWTkOZVXWzBmGoSllAwqnLCuvtzwPlF/aF
+vE/Fp2L57bGqIA1IbwTcVBeUtgKhndNc2KR6qu+dh9fp7MWwfpchZzN6VBT7fdn8qQRwD3KI1PWs
+LcR8/OZ6WKv3F5X+oF75Gk7RXFB+HtHpMHsNr75UxL83uapSR6aOWPW7FyhUFy05U4CVl8w0IBos
+jQ1ZY86DdUPxX0qpBpDViX9Hqb/FqOqe2vWaTg3KP54ZcoIFS8N9HfUpCmHNkeRnI1pKGdNG94FC
+BWahHjJrh3zMTdJ23enGGkDX25sanfZNrRrt+bAWLg68TeJD7pAplM+sN+OGsCZfBLTfoAE3FPD3
+MiuWHWF0S424umJKnO6Kvwd3d420Qp/uddRd3dRLI3Z1p4rhmy9lphLoIIhix06dui+2EXqrS6ci
+hyDljbrzUl4+jVap1lvFZfyuurDSfiZVsVR+fvv7XebzkBYrW3CuX8ryG50S6nOSpfgiCvUHzDlA
+2dlO5AfV5X002TboNPpUQSui8l99krNUrpgB5dcWoGqmbu1RzoWAI/EK6lD1uQBd8awglmB4rWv9
+9hDWNSjbs3ZLoHHb0Zx3hMq8y2Z7NlsCEcWd8rAWsydsp5orXgrDNTuEF0o0z2X1ud10bR0MYZS0
+Ie2ncAopNErcAEwVisADTPfoegEknyuxrZxKtAQ0NMBe/Z5RRFKsr1JmALpX7ZPOsrWqpqvX0D/o
+ZG0yNUe2bVIuxOGd+bG86LTG2dnBsKa6eq63uKAyXXItPtj4WR5Esbxa9rX1A1r82+cqawA+iDH8
+q5trYPjntfog8FlFT3UArFJlCGhkZVUddXLk4kKYjvswPVTP3Qi9vsPE7mo/VJsauWGArcaP5Wqs
+sUERbY3BivX8mc7hTjywtR1m6O5fwuinRsC7SwjABnd6F5aXtViuriCibu600OHzls060IKCufql
+g63Zv3Mp/t4j05foQb6spxj7zLkfX/uIVHPsB3RL7aqOIF5qnS8+en6tbzajQo/VVxLPa14fJ/Rc
+7lx3WeOhYTQz6Jip0hhMCqzc72GoPWoLu8Mb0o5f3dXGSLs4BxdoP6/eqLOVh5VO02exqHRaC0vR
++G+mirJU+fmCq5Ta1xyCRccC897nZW+WyGsxiMawF7e329Zb2621wQDo2I7tLv7jrv9/AfAaXNUU
+TOsyF6jViUG46+NBJqZXv+rRK7Evv2i81ZEw33DQ8y6YowH05r+BuxfN92SX3RbVP8bNymDOGnY7
+16PfvzG+4ecrzfzkjPZya/H/ScnXyqwX/JtSrrL5pbrryu1hPKFrZzsrJD6sUuyPwDGdKerJyxmq
+dvmdHNCrrzU/+2W0pQ6gSvPl/Mertmi+7hBlDhB80kRUqcNeJCGapHNCz1cvCFwsf0A/Ne++jGMf
+TuOJcm6+ZnP9TRR7tWjHreOhZ6huiKnPAP2zfmqpIqHHLG/emnNhyHxSs+JJYfIwj6t2AlLdVneO
+3Is9u0R33ef+Wv2pVizPfbUW0rGhps1FRRfnZ/2xsnr3oT2Slh2tvngsLXu6M0OgIen7ufrjprrD
+vzXQAgNE22ualqzbyAb97uvl6qF/2a5hcU+eBzVWzOdmVjA0PXQMQoAhsulmBv39oU13134SjSlb
+dX85nKW3umfYbtu8713Sylhb2i3v2qaoc8C7S2P3pME8uIGedi1IxXbL+adi+P2fT8Xy/m+/PrxZ
+/TrXDcpqOMjotwdo9AJmg8r1N7BySygc+Gp+XaYdJhpV8f/7Oy3Y1s330l09YBDTjnyjn5qHGF7x
+6O7hZfMXz21OyLZB6lUfOGAGMzo/bjaL7VaV7Ha76D/1yJVEqKmr+L2nCbH7+959wDtv38JZplQG
+BDaonX65d/fwEjNqlDjLVIvM9X+XVxF7
+""")
+
+##file distribute_setup.py
+DISTRIBUTE_SETUP_PY = convert("""
+eJztG2tz2zbyu34FTh4PqYSi7TT3GM+pM2nj9DzNJZnYaT8kHhoiIYk1X+XDsvrrb3cBkCAJyc61
+dzM3c7qrIxGLxWLfuwCP/lTs6k2eTabT6Xd5Xld1yQsWxfBvvGxqweKsqnmS8DoGoMnliu3yhm15
+VrM6Z00lWCXqpqjzPKkAFkdLVvDwjq+FU8lBv9h57JemqgEgTJpIsHoTV5NVnCB6+AFIeCpg1VKE
+dV7u2DauNyyuPcaziPEoogm4IMLWecHylVxJ4z8/n0wYfFZlnhrUBzTO4rTIyxqpDTpqCb7/yJ2N
+dliKXxsgi3FWFSKMV3HI7kVZATOQhm6qh98BKsq3WZLzaJLGZZmXHstL4hLPGE9qUWYceKqBuh17
+tGgIUFHOqpwtd6xqiiLZxdl6gpvmRVHmRRnj9LxAYRA/bm+HO7i99SeTa2QX8TekhRGjYGUD3yvc
+SljGBW1PSZeoLNYlj0x5+qgUE8W8vNLfql37tY5Tob+vspTX4aYdEmmBFLS/eUk/Wwk1dYwqI0eT
+fD2Z1OXuvJNiFaP2yeFPVxcfg6vL64uJeAgFkH5Jzy+QxXJKC8EW7F2eCQObJrtZAgtDUVVSVSKx
+YoFU/iBMI/cZL9fVTE7BD/4EZC5s1xcPImxqvkyEN2PPaaiFK4FfZWag90PgqEvY2GLBTid7iT4C
+RQfmg2hAihFbgRQkQeyF/80fSuQR+7XJa1AmfNykIquB9StYPgNd7MDgEWIqwNyBmBTJdwDmmxdO
+t6QmCxEK3OasP6bwOPA/MG4YHw8bbHOmx9XUYccIOIJTMMMhtenPHQXEOviiVqxuhtLJK78qOFid
+C98+BD+/urz22IBp7Jkps9cXb159ensd/HTx8ery/TtYb3rq/8V/8XLaDn36+BYfb+q6OD85KXZF
+7EtR+Xm5PlFOsDqpwFGF4iQ66fzSyXRydXH96cP1+/dvr4I3r368eD1YKDw7m05MoA8//hBcvnvz
+Hsen0y+Tf4qaR7zm85+kOzpnZ/7p5B340XPDhCft6HE1uWrSlINVsAf4TP6Rp2JeAIX0e/KqAcpL
+8/tcpDxO5JO3cSiySoG+FtKBEF58AASBBPftaDKZkBorX+OCJ1jCvzNtA+IBYk5IyknuXQ7TYJ0W
+4CJhy9qb+OldhN/BU+M4uA1/y8vMdS46JKADx5XjqckSME+iYBsBIhD/WtThNlIYWi9BUGC7G5jj
+mlMJihMR0oX5eSGydhctTKD2obbYm+yHSV4JDC+dQa5zRSxuug0ELQD4E7l1IKrg9cb/BeAVYR4+
+TECbDFo/n97MxhuRWLqBjmHv8i3b5uWdyTENbVCphIZhaIzjsh1kr1vddmamO8nyuufAHB2xYTlH
+IXcGHqRb4Ap0FEI/4N+Cy2LbMoevUVNqXTGTE99YeIBFCIIW6HlZCi4atJ7xZX4v9KRVnAEemypI
+zZlpJV42MTwQ67UL/3laWeFLHiDr/q/T/wM6TTKkWJgxkKIF0XcthKHYCNsJQsq749Q+HZ//in+X
+6PtRbejRHH/Bn9JA9EQ1lDuQUU1rVymqJqn7ygNLSWBlg5rj4gGWrmi4W6XkMaSol+8pNXGd7/Mm
+iWgWcUraznqNtqKsIAKiVQ7rqnTYa7PaYMkroTdmPI5EwndqVWTlUA0UvNOFyflxNS92x5EP/0fe
+WRMJ+ByzjgoM6uoHRJxVDjpkeXh2M3s6e5RZAMHtXoyMe8/+99E6+OzhUqdXjzgcAqScDckHfyjK
+2j31WCd/lf326x4jyV/qqk8H6IDS7wWZhpT3oMZQO14MUqQBBxZGmmTlhtzBAlW8KS1MWJz92QPh
+BCt+JxbXZSNa75pyMvGqgcJsS8kz6ShfVnmChoq8mHRLGJoGIPiva3Jvy6tAckmgN3WKu3UAJkVZ
+W0VJLPI3zaMmERVWSl/a3TgdV4aAY0/c+2GIprdeH0Aq54ZXvK5LtwcIhhJERtC1JuE4W3HQnoXT
+UL8CHoIo59DVLi3EvrKmnSlz79/jLfYzr8cMX5Xp7rRjybeL6XO12sxC1nAXfXwqbf4+z1ZJHNb9
+pQVoiawdQvIm7gz8yVBwplaNeY/TIdRBRuJvSyh03RHE9Jo8O20rMnsORm/G/XZxDAUL1PooaH4P
+6TpVMl+y6RgftlJCnjk11pvK1AHzdoNtAuqvqLYAfCubDKOLzz4kAsRjxadbB5yleYmkhpiiaUJX
+cVnVHpgmoLFOdwDxTrscNv9k7MvxLfBfsi+Z+31TlrBKspOI2XE5A+Q9/y98rOIwcxirshRaXLsv
++mMiqSz2ARrIBiZn2PfngZ+4wSkYmamxk9/tK2a/xhqeFEP2WYxVr9tsBlZ9l9dv8iaLfrfRPkqm
+jcRRqnPIXQVhKXgtht4qwM2RBbZZFIarA1H698Ys+lgCl4pXygtDPfy6a/G15kpxtW0kgu0leUil
+C7U5FePjWnbuMqjkZVJ4q2i/ZdWGMrMltiPveRL3sGvLy5p0KUqwaE6m3HoFwoXtP0p6qWPS9iFB
+C2iKYLc9ftwy7HG44CPCjV5dZJEMm9ij5cw5cWY+u5U8ucUVe7k/+BdRCp1Ctv0uvYqIfLlH4mA7
+Xe2BOqxhnkXU6yw4BvqlWKG7wbZmWDc86TqutL8aK6na12L4jyQMvVhEQm1KqIKXFIUEtrlVv7lM
+sKyaGNZojZUGihe2ufX6twDVAVs/veTYxzJs/Rs6QCV92dQue7kqCpI9b7HI/I/fC2DpnhRcg6rs
+sgwRHexLtVYNax3kzRLt7Bx5/uo+j1GrC7TcqCWny3BGIb0tXlrrIR9fTT3cUt9lS6IUl9zR8BH7
+KHh0QrGVYYCB5AxIZ0swuTsPO+xbVEKMhtK1gCaHeVmCuyDrGyCD3ZJWa3uJ8ayjFgSvVVh/sCmH
+CUIZgj7waJBRSTYS0ZJZHptul9MRkEoLEFk3NvKZShKwliXFAAJ0iT6AB/yWcAeLmvBd55QkDHtJ
+yBKUjFUlCO66Au+1zB/cVZOF6M2UE6Rhc5zaqx579uxuOzuQFcvmf1efqOnaMF5rz3Ilnx9KmIew
+mDNDIW1LlpHa+ziXraRRm938FLyqRgPDlXxcBwQ9ft4u8gQcLSxg2j+vwGMXKl2wSHpCYtNNeMMB
+4Mn5/HDefhkq3dEa0RP9o9qslhnTfZhBVhFYkzo7pKn0pt4qRSeqAvQNLpqBB+4CPEBWdyH/Z4pt
+PLxrCvIWK5lYi0zuCCK7DkjkLcG3BQqH9giIeGZ6DeDGGHahl+44dAQ+DqftNPMsPa1XfQizXap2
+3WlDN+sDQmMp4OsJkE1ibAjIGRDFMp8zNwGGtnVswVK5Nc07eya4svkh0u2JIQZYz/Quxoj2TXio
+rNlmFZp2cUPeGzxWqEZ7lggysdWRGZ9ClHX8929f+8cVHmnh6aiPf0ad3Y+ITgY3DCS57ClKEjVO
+1eTF2hZ/urZRtQH9sCU2ze8hWQbTCMwOuVskPBQbUHahO9WDMB5X2Gscg/Wp/5TdQSDsNd8h8VJ7
+MObu168V1h09/4PpqL4QYDSC7aQA1eq02Vf/ujjXM/sxz7BjOMfiYOju9eIjb7kE6d+ZbFn1y6OO
+A12HlFJ489DcXHfAgMlIC0BOqAUiEfJINm9qTHrRe2z5rrM5XecMEzaDPR6Tqq/IH0hUzTc40Tlz
+ZTlAdtCDla6qF0FGk6Q/VDM8ZjmvVJ1txdGRb++4AabAhy7KY31qrMp0BJi3LBG1UzFU/Nb5DvnZ
+KpriN+qaa7bwvEHzT7Xw8SYCfjW4pzEckoeC6R2HDfvMCmRQ7ZreZoRlHNNteglOVTbuga2aWMWJ
+PW1056q7yBMZbQJnsJO+P97na4beeR+c9tV8Bel0e0SM6yumGAEMQdobK23burWRjvdYrgAGPBUD
+/5+mQESQL39xuwNHX/e6CygJoe6Ske2xLkPPuUm6v2ZKz+Wa5IJKWoqpx9ywRdiaObqxMHZBxKnd
+PfEITE5FKvfJpyayIuw2qiKxYUXq0Kbq/CAs8KWnc+6+qwKepO0rnN6AlJH/07wcO0Cr55HgB/zO
+0Id/j/KXkXw0q0uJWgd5OC2yuk8C2J8iSVbVbU60n1WGjHyY4AyTksFW6o3B0W4r6vFjW+mRYXTK
+hvJ6fH+PmdjQ0zwCPuvl823Q63K6IxVKIAKFd6hKMf6y5dd7FVRmwBc//DBHEWIIAXHK71+hoPEo
+hT0YZ/fFhKfGVcO3d7F1T7IPxKd3Ld/6jw6yYvaIaT/Kuf+KTRms6JUdSlvslYca1Pol+5RtRBtF
+s+9kH3NvOLOczCnM1KwNilKs4gdXe/ouuLRBjkKDOpSE+vveOO839oa/1YU6DfhZf4EoGYkHI2w+
+Pzu/abMoGvT0tTuRNakoubyQZ/ZOEFTeWJX51nxewl7lPQi5iWGCDpsAHD6sWdYVtplRiRcYRiQe
+S2OmzgslGZpZJHHtOrjOwpl9ng9O5wwWaPaZiylcwyMiSRWWhpIK64FrApopbxF+K/lj7yH1yK0+
+E+RzC5VfS2lHIzC3qUTp0NFCdzlWHRViG9fasbGt0s62GIbUyJGqDpX9KuR0oGicO+rrkTbb3Xsw
+fqhDdcS2wgGLCoEES5A3sltQSONWT5QLyZRKiBTPGczj0XGXhH5u0Vz6pYK6d4RsGG/IiEOYmMLk
+beVj1tY/0/c/yvNeTLbBK5bgjHrliT1xH2gLxXzEsCA3rjyu4tz1rhAjvmGr0jhIevXh8g8mfNYV
+gUOEoJB9ZTRvc5nvFpgliSzM7aI5YpGohbo1h8EbT+LbCIiaGg1z2PYYbjEkz9dDQ30233kwih65
+NGi3bodYVlG8oEMF6QtRIckXxg9EbFHm93EkIvn6Q7xS8OaLFpXRfIjUhbvU6w41dMfRrDj6gcNG
+mV0KChsw1BsSDIjkWYjtHuhYW+WNcKBlA/XH/hqll4aBVUo5VuZ1PbUlyyZ8kUUqaNCdsT2byuby
+Nl8nvB4daN/7+2hWqerJijTAYfOwlqaKceFzP0n7MiYLKYcTKEWiuy//RJ3rdyO+Igfdm4QeaD4P
+eNOfN24/m7rRHt2hWdP5snR/dNZr+PtMDEXbz/5/rzwH9NJpZyaMhnnCmyzcdClc92QYKT+qkd6e
+MbSxDcfWFr6RJCGo4NdvtEioIi5Yyss7PMvPGacDWN5NWDat8bSp3vk3N5gufHbmoXkjm7IzvGKT
+iLlqAczFA72/BDnzPOUZxO7IuTFCnMZ4etP2A7BpZiaYn/tvXNyw5+20icZB93OsL9O03DMuJVci
+WcnG+WLqTz2WCrw4UC0wpnQnM+oiNR0EKwh5zEiXAErgtmQt/gzlFSN9j1jvr7vQgD4Z3/XKtxlW
+1Wke4Vth0v9js58AClGmcVXRa1rdkZ1GEoMSUsMLZB5VPrvFDTjtxRB8RQuQrgQRMrpGDYQqDsBX
+mKx25KAnlqkpT4iIFF+5o8siwE8imRqAGg/22JUWg8Yud2wtaoXLnfVvUKiELMyLnfkbCjHI+NWN
+QMlQeZ1cAyjGd9cGTQ6APty0eYEWyygf0AMYm5PVpK0+YCXyhxBRFEivclbDqv898EtHmrAePepC
+S8VXAqUqBsf6HaTPC6hAI1et0Xdlmq4FccvHPwcB8T4Z9m1evvwb5S5hnIL4qGgC+k7/enpqJGPJ
+ylei1zil8rc5xUeB1ipYhdw3STYN3+zpsb8z94XHXhocQhvD+aJ0AcOZh3hezKzlQpgWBONjk0AC
++t3p1JBtiNSVmO0ApaTetR09jBDdid1CK6CPx/2gvkizgwQ4M48pbPLqsGYQZG500QNwtRbcWi2q
+LokDU7kh8wZKZ4z3iKRzQGtbQwu8z6DR2TlJOdwAcZ2MFd7ZGLCh88UnAIYb2NkBQFUgmBb7b9x6
+lSqKkxPgfgJV8Nm4AqYbxYPq2nZPgZAF0XLtghJOlWvBN9nwwpPQ4SDlMdXc9x7bc8mvCwSXh153
+JRW44NVOQWnnd/j6v4rxw5fbgLiY7r9g8hRQRR4ESGoQqHcpie42ap6d38wm/wIwBuVg
+""")
+
+##file activate.sh
+ACTIVATE_SH = convert("""
+eJytVVFvokAQfudXTLEP2pw1fW3jg01NNGm1KV4vd22zrDDIJrhrYJHay/33m0VEKGpyufIg7s63
+M9/OfDO0YBaKBAIRISzTRMMcIU3Qh0zoEOxEpbGHMBeyxz0t1lyjDRdBrJYw50l4YbVgo1LwuJRK
+Q5xKEBp8EaOno41l+bg7Be0O/LaAnhbEmKAGFfmAci1iJZcoNax5LPg8wiRHiQBeoCvBPmfT+zv2
+PH6afR/cs8fBbGTDG9yADlHmSPOY7f4haInA95WKdQ4s91JpeDQO5fZAnKTxczaaTkbTh+EhMqWx
+QWl/rEGsNJ2kV0cRySKleRGTUKWUVB81pT+vD3Dpw0cSfoMsFF4IIV8jcHqRyVPLpTHrkOu89IUr
+EoDHo4gkoBUsiAFVlP4FKjaLFSeNFEeTS4AfJBOV6sKshVwUbmpAkyA4N8kFL+RygQlkpDfum58N
+GO1QWNLFipij/yn1twOHit5V29UvZ8Seh0/OeDo5kPz8at24lp5jRXSuDlXPuWqUjYCNejlXJwtV
+mHcUtpCddTh53hM7I15EpA+2VNLHRMep6Rn8xK0FDkYB7ABnn6J3jWnXbLvQfyzqz61dxDFGVP1a
+o1Xasx7bsipU+zZjlSVjtlUkoXofq9FHlMZtDxaLCrrH2O14wiaDhyFj1wWs2qIl773iTbZohyza
+iD0TUQQBF5HZr6ISgzKKNZrD5UpvgO5FwoT2tgkIMec+tcYm45sO+fPytqGpBy75aufpTG/gmhRb
++u3AjQtC5l1l7QV1dBAcadt+7UhFGpXONprZRviAWtbY3dgZ3N4P2ePT9OFxdjJiruJSuLk7+31f
+x60HKiWc9eH9SBc04XuPGCVYce1SXlDyJcJrjfKr7ebSNpEaQVpg+l3wiAYOJZ9GCAxoR9JMWAiv
++IyoWBSfhOIIIoRar657vSzLLj9Q0xRZX9Kk6SUq0BmPsceNl179Mi8Vii65Pkj21XXf4MAlSy/t
+Exft7A8WX4/iVRkZprZfNK2/YFL/55T+9wm9m86Uhr8A0Hwt
+""")
+
+##file activate.fish
+ACTIVATE_FISH = convert("""
+eJydVm1v4jgQ/s6vmA1wBxUE7X2stJVYlVWR2lK13d6d9laRk0yIr8HmbIe0++tvnIQQB9pbXT5A
+Ys/LM55nZtyHx5RrSHiGsMm1gRAh1xhDwU0Kng8hFzMWGb5jBv2E69SDs0TJDdj3MxilxmzPZzP7
+pVPMMl+q9bjXh1eZQ8SEkAZULoAbiLnCyGSvvV6SC7IoBcS4Nw0wjcFbvJDcjiuTswzFDpiIQaHJ
+lQAjQUi1YRmUboC2uZJig8J4PaCnT5IaDcgsbm/CjinOwgx1KcUTMEhhTgV4g2B1fRk8Le8fv86v
+g7v545UHpZB9rKnp+gXsMhxLunIIpwVQxP/l9c/Hq9Xt1epm4R27bva6AJqN92G4YhbMG2i+LB+u
+grv71c3dY7B6WtzfLy9bePbp0taDTXSwJQJszUnnp0y57mvpPcrF7ZODyhswtd59+/jdgw+fwBNS
+xLSscksUPIDqwwNmCez3PpxGeyBYg6HE0YdcWBxcKczYzuVJi5Wu915vn5oWePCCoPUZBN5B7IgV
+MCi54ZDLG7TUZ0HweXkb3M5vFmSpFm/gthhBx0UrveoPpv9AJ9unIbQYdUoe21bKg2q48sPFGVwu
+H+afrxd1qvclaNlRFyh1EQ2sSccEuNAGWQwysfVpz1tPajUqbqJUnEcIJkWo6OXDaodK8ZiLdbmM
+L1wb+9H0D+pcyPSrX5u5kgWSygRYXCnJUi/KKcuU4cqsAyTKZBiissLc7NFwizvjxtieKBVCIdWz
+fzilzPaYyljZN0cGN1v7NnaIPNCGmVy3GKuJaQ6iVjE1Qfm+36hglErwmnAD8hu0dDy4uICBA8ZV
+pQr/q/+O0KFW2kjelu9Dgb9SDBsWV4F4x5CswgS0zBVlk5tDMP5bVtUGpslbm81Lu2sdKq7uNMGh
+MVQ4fy9xhogC1lS5guhISa0DlBWv0O8odT6/LP+4WZzDV6FzIkEqC0uolGZSZoMnlpxplmD2euaT
+O4hkTpPnbztDccey0bhjDaBIqaWQa0uwEtQEwtyU56i4fq54F9IE3ORR6mKriODM4XOYZwaVYLYz
+7SPbKkz4i7VkB6/Ot1upDE3znNqYKpM8raa0Bx8vfvntJ32UENsM4aI6gJL+jJwhxhh3jVIDOcpi
+m0r2hmEtS8XXXNBk71QCDXTBNhhPiHX2LtHkrVIlhoEshH/EZgdq53Eirqs5iFKMnkOmqZTtr3Xq
+djvPTWZT4S3NT5aVLgurMPUWI07BRVYqkQrmtCKohNY8qu9EdACoT6ki0a66XxVF4f9AQ3W38yO5
+mWmZmIIpnDFrbXakvKWeZhLwhvrbUH8fahhqD0YUcBDJjEBMQwiznE4y5QbHrbhHBOnUAYzb2tVN
+jJa65e+eE2Ya30E2GurxUP8ssA6e/wOnvo3V78d3vTcvMB3n7l3iX1JXWqk=
+""")
+
+##file activate.csh
+ACTIVATE_CSH = convert("""
+eJx9U11vmzAUffevOCVRu+UB9pws29Kl0iq1aVWllaZlcgxciiViItsQdb9+xiQp+dh4QOB7Pu49
+XHqY59IgkwVhVRmLmFAZSrGRNkdgykonhFiqSCRW1sJSmJg8wCDT5QrucRCyHn6WFRKhVGmhKwVp
+kUpNiS3emup3TY6XIn7DVNQyJUwlrgthJD6n/iCNv72uhCzCpFx9CRkThRQGKe08cWXJ9db/yh/u
+pvzl9mn+PLnjj5P5D1yM8QmXlzBkSdXwZ0H/BBc0mEo5FE5qI2jKhclHOOvy9HD/OO/6YO1mX9vx
+sY0H/tPIV0dtqel0V7iZvWyNg8XFcBA0ToEqVeqOdNUEQFvN41SumAv32VtJrakQNSmLWmgp4oJM
+yDoBHgoydtoEAs47r5wHHnUal5vbJ8oOI+9wI86vb2d8Nrm/4Xy4RZ8R85E4uTZPB5EZPnTaaAGu
+E59J8BE2J8XgrkbLeXMlVoQxznEYFYY8uFFdxsKQRx90Giwx9vSueHP1YNaUSFG4vTaErNSYuBOF
+lXiVyXa9Sy3JdClEyK1dD6Nos9mEf8iKlOpmqSNTZnYjNEWiUYn2pKNB3ttcLJ3HmYYXy6Un76f7
+r8rRsC1TpTJj7f19m5sUf/V3Ir+x/yjtLu8KjLX/CmN/AcVGUUo=
+""")
+
+##file activate.bat
+ACTIVATE_BAT = convert("""
+eJyFUkEKgzAQvAfyhz0YaL9QEWpRqlSjWGspFPZQTevFHOr/adQaU1GaUzI7Mzu7ZF89XhKkEJS8
+qxaKMMsvboQ+LxxE44VICSW1gEa2UFaibqoS0iyJ0xw2lIA6nX5AHCu1jpRsv5KRjknkac9VLVug
+sX9mtzxIeJDE/mg4OGp47qoLo3NHX2jsMB3AiDht5hryAUOEifoTdCXbSh7V0My2NMq/Xbh5MEjU
+ZT63gpgNT9lKOJ/CtHsvT99re3pX303kydn4HeyOeAg5cjf2EW1D6HOPkg9NGKhu
+""")
+
+##file deactivate.bat
+DEACTIVATE_BAT = convert("""
+eJxzSE3OyFfIT0vj4spMU0hJTcvMS01RiPf3cYkP8wwKCXX0iQ8I8vcNCFHQ4FIAguLUEgWIgK0q
+FlWqXJpcICVYpGzx2BAZ4uHv5+Hv6wq1BWINXBTdKriEKkI1DhW2QAfhttcxxANiFZCBbglQSJUL
+i2dASrm4rFz9XLgAwJNbyQ==
+""")
+
+##file activate.ps1
+ACTIVATE_PS = convert("""
+eJylWdmS40Z2fVeE/oHT6rCloNUEAXDThB6wAyQAEjsB29GBjdgXYiWgmC/zgz/Jv+AEWNVd3S2N
+xuOKYEUxM+/Jmzfvcm7W//zXf/+wUMOoXtyi1F9kbd0sHH/hFc2iLtrK9b3FrSqyxaVQwr8uhqJd
+uHaeg9mqzRdR8/13Pyy8qPLdJh0+LMhi0QCoXxYfFh9WtttEnd34H8p6/f1300KauwrULws39e18
+0ZaLNm9rgN/ZVf3h++/e124Vlc0vKsspHy+Yyi5+XbzPhijvCtduoiL/kA1ukWV27n0o7Sb8LIFj
+CvWR5GQgUJdp1Pw8TS9+rPy6SDv/+e3d+0+4qw8f3v20+PliV37efEYBAB9FTKC+RHn/Cfxn3rdv
+00Fube5O+iyCtHDs9BfPfz3q4sfFv9d91Ljhfy7ei0VO+nVTtdOkv/jpt0l2AX6iG1jXgKnnDuD4
+ke2k/i8fzzz5UedkVcP4pwF+Wvz2FJl+3vt598urXf5Y6LNA5WcFOP7r0sW7b9a+W/xcu0Xpv5zk
+Kfq3P9Dz9di/fCxS72MXVU1rpx9L4Bxl85Wmn5a+zP76Zuh3pL9ROWr87PN+//GHIl+oOtvn9XSU
+qH+p0gQBFnx1uV+JLH5O5zv+PXW+WepXVVHZT0+oQezkIATcIm+ivPV/z5J/+cYj3ir4w0Lx09vC
+e5n/y5/Y5LPPfdrqb88ga/PabxZRVfmp39l588m/6u+/e+OpP+dF7n1WZpJ9//Z4v372fDDz9eHB
+7Juvs/BLMHzrxL9+9twXpJfhd1/DrpQ5Euu/vlss3wp9HXC/54C/Ld69m6zwdx3tC0d8daSv0V8B
+n4b9YYF53sJelJV/ix6LZspw/sJtqyl5LJ5r/23htA1Imfm/gt9R7dqVB1LjhydAX4Gb+zksQF59
+9+P7H//U+376afFuvh2/T6P85Xr/5c8C6OXyFY4BGuN+EE0+GeR201b+wkkLN5mmBY5TfMw8ngqL
+CztXxCSXKMCYrRIElWkEJlEPYsSOeKBVZCAQTKBhApMwRFQzmCThE0YQu2CdEhgjbgmk9GluHpfR
+/hhwJCZhGI5jt5FsAkOrObVyE6g2y1snyhMGFlDY1x+BoHpCMulTj5JYWNAYJmnKpvLxXgmQ8az1
+4fUGxxcitMbbhDFcsiAItg04E+OSBIHTUYD1HI4FHH4kMREPknuYRMyhh3AARWMkfhCketqD1CWJ
+mTCo/nhUScoQcInB1hpFhIKoIXLo5jLpwFCgsnLCx1QlEMlz/iFEGqzH3vWYcpRcThgWnEKm0QcS
+rA8ek2a2IYYeowUanOZOlrbWSJUC4c7y2EMI3uJPMnMF/SSXdk6E495VLhzkWHps0rOhKwqk+xBI
+DhJirhdUCTamMfXz2Hy303hM4DFJ8QL21BcPBULR+gcdYxoeiDqOFSqpi5B5PUISfGg46gFZBPo4
+jdh8lueaWuVSMTURfbAUnLINr/QYuuYoMQV6l1aWxuZVTjlaLC14UzqZ+ziTGDzJzhiYoPLrt3uI
+tXkVR47kAo09lo5BD76CH51cTt1snVpMOttLhY93yxChCQPI4OBecS7++h4p4Bdn4H97bJongtPk
+s9gQnXku1vzsjjmX4/o4YUDkXkjHwDg5FXozU0fW4y5kyeYW0uJWlh536BKr0kMGjtzTkng6Ep62
+uTWnQtiIqKnEsx7e1hLtzlXs7Upw9TwEnp0t9yzCGgUJIZConx9OHJArLkRYW0dW42G9OeR5Nzwk
+yk1mX7du5RGHT7dka7N3AznmSif7y6tuKe2N1Al/1TUPRqH6E2GLVc27h9IptMLkCKQYRqPQJgzV
+2m6WLsSipS3v3b1/WmXEYY1meLEVIU/arOGVkyie7ZsH05ZKpjFW4cpY0YkjySpSExNG2TS8nnJx
+nrQmWh2WY3cP1eISP9wbaVK35ZXc60yC3VN/j9n7UFoK6zvjSTE2+Pvz6Mx322rnftfP8Y0XKIdv
+Qd7AfK0nexBTMqRiErvCMa3Hegpfjdh58glW2oNMsKeAX8x6YJLZs9K8/ozjJkWL+JmECMvhQ54x
+9rsTHwcoGrDi6Y4I+H7yY4/rJVPAbYymUH7C2D3uiUS3KQ1nrCAUkE1dJMneDQIJMQQx5SONxoEO
+OEn1/Ig1eBBUeEDRuOT2WGGGE4bNypBLFh2PeIg3bEbg44PHiqNDbGIQm50LW6MJU62JHCGBrmc9
+2F7WBJrrj1ssnTAK4sxwRgh5LLblhwNAclv3Gd+jC/etCfyfR8TMhcWQz8TBIbG8IIyAQ81w2n/C
+mHWAwRzxd3WoBY7BZnsqGOWrOCKwGkMMNfO0Kci/joZgEocLjNnzgcmdehPHJY0FudXgsr+v44TB
+I3jnMGnsK5veAhgi9iXGifkHMOC09Rh9cAw9sQ0asl6wKMk8mpzFYaaDSgG4F0wisQDDBRpjCINg
+FIxhlhQ31xdSkkk6odXZFpTYOQpOOgw9ugM2cDQ+2MYa7JsEirGBrOuxsQy5nPMRdYjsTJ/j1iNw
+FeSt1jY2+dd5yx1/pzZMOQXUIDcXeAzR7QlDRM8AMkUldXOmGmvYXPABjxqkYKO7VAY6JRU7kpXr
++Epu2BU3qFFXClFi27784LrDZsJwbNlDw0JzhZ6M0SMXE4iBHehCpHVkrQhpTFn2dsvsZYkiPEEB
+GSEAwdiur9LS1U6P2U9JhGp4hnFpJo4FfkdJHcwV6Q5dV1Q9uNeeu7rV8PAjwdFg9RLtroifOr0k
+uOiRTo/obNPhQIf42Fr4mtThWoSjitEdAmFW66UCe8WFjPk1YVNpL9srFbond7jrLg8tqAasIMpy
+zkH0SY/6zVAwJrEc14zt14YRXdY+fcJ4qOd2XKB0/Kghw1ovd11t2o+zjt+txndo1ZDZ2T+uMVHT
+VSXhedBAHoJIID9xm6wPQI3cXY+HR7vxtrJuCKh6kbXaW5KkVeJsdsjqsYsOwYSh0w5sMbu7LF8J
+5T7U6LJdiTx+ca7RKlulGgS5Z1JSU2Llt32cHFipkaurtBrvNX5UtvNZjkufZ/r1/XyLl6yOpytL
+Km8Fn+y4wkhlqZP5db0rooqy7xdL4wxzFVTX+6HaxuQJK5E5B1neSSovZ9ALB8091dDbbjVxhWNY
+Ve5hn1VnI9OF0wpvaRm7SZuC1IRczwC7GnkhPt3muHV1YxUJfo+uh1sYnJy+vI0ZwuPV2uqWJYUH
+bmBsi1zmFSxHrqwA+WIzLrHkwW4r+bad7xbOzJCnKIa3S3YvrzEBK1Dc0emzJW+SqysQfdEDorQG
+9ZJlbQzEHQV8naPaF440YXzJk/7vHGK2xwuP+Gc5xITxyiP+WQ4x18oXHjFzCBy9kir1EFTAm0Zq
+LYwS8MpiGhtfxiBRDXpxDWxk9g9Q2fzPPAhS6VFDAc/aiNGatUkPtZIStZFQ1qD0IlJa/5ZPAi5J
+ySp1ETDomZMnvgiysZSBfMikrSDte/K5lqV6iwC5q7YN9I1dBZXUytDJNqU74MJsUyNNLAPopWK3
+tzmLkCiDyl7WQnj9sm7Kd5kzgpoccdNeMw/6zPVB3pUwMgi4C7hj4AMFAf4G27oXH8NNT9zll/sK
+S6wVlQwazjxWKWy20ZzXb9ne8ngGalPBWSUSj9xkc1drsXkZ8oOyvYT3e0rnYsGwx85xZB9wKeKg
+cJKZnamYwiaMymZvzk6wtDUkxmdUg0mPad0YHtvzpjEfp2iMxvORhnx0kCVLf5Qa43WJsVoyfEyI
+pzmf8ruM6xBr7dnBgzyxpqXuUPYaKahOaz1LrxNkS/Q3Ae5AC+xl6NbxAqXXlzghZBZHmOrM6Y6Y
+ctAkltwlF7SKEsShjVh7QHuxMU0a08/eiu3x3M+07OijMcKFFltByXrpk8w+JNnZpnp3CfgjV1Ax
+gUYCnWwYow42I5wHCcTzLXK0hMZN2DrPM/zCSqe9jRSlJnr70BPE4+zrwbk/xVIDHy2FAQyHoomT
+Tt5jiM68nBQut35Y0qLclLiQrutxt/c0OlSqXAC8VrxW97lGoRWzhOnifE2zbF05W4xuyhg7JTUL
+aqJ7SWDywhjlal0b+NLTpERBgnPW0+Nw99X2Ws72gOL27iER9jgzj7Uu09JaZ3n+hmCjjvZpjNst
+vOWWTbuLrg+/1ltX8WpPauEDEvcunIgTxuMEHweWKCx2KQ9DU/UKdO/3za4Szm2iHYL+ss9AAttm
+gZHq2pkUXFbV+FiJCKrpBms18zH75vax5jSo7FNunrVWY3Chvd8KKnHdaTt/6ealwaA1x17yTlft
+8VBle3nAE+7R0MScC3MJofNCCkA9PGKBgGMYEwfB2QO5j8zUqa8F/EkWKCzGQJ5EZ05HTly1B01E
+z813G5BY++RZ2sxbQS8ZveGPJNabp5kXAeoign6Tlt5+L8i5ZquY9+S+KEUHkmYMRFBxRrHnbl2X
+rVemKnG+oB1yd9+zT+4c43jQ0wWmQRR6mTCkY1q3VG05Y120ZzKOMBe6Vy7I5Vz4ygPB3yY4G0FP
+8RxiMx985YJPXsgRU58EuHj75gygTzejP+W/zKGe78UQN3yOJ1aMQV9hFH+GAfLRsza84WlPLAI/
+9G/5JdcHftEfH+Y3/fHUG7/o8bv98dzzy3e8S+XCvgqB+VUf7sH0yDHpONdbRE8tAg9NWOzcTJ7q
+TuAxe/AJ07c1Rs9okJvl1/0G60qvbdDzz5zO0FuPFQIHNp9y9Bd1CufYVx7dB26mAxwa8GMNrN/U
+oGbNZ3EQ7inLzHy5tRg9AXJrN8cB59cCUBeCiVO7zKM0jU0MamhnRThkg/NMmBOGb6StNeD9tDfA
+7czsAWopDdnGoXUHtA+s/k0vNPkBcxEI13jVd/axp85va3LpwGggXXWw12Gwr/JGAH0b8CPboiZd
+QO1l0mk/UHukud4C+w5uRoNzpCmoW6GbgbMyaQNkga2pQINB18lOXOCJzSWPFOhZcwzdgrsQnne7
+nvjBi+7cP2BbtBeDOW5uOLGf3z94FasKIguOqJl+8ss/6Kumns4cuWbqq5592TN/RNIbn5Qo6qbi
+O4F0P9txxPAwagqPlftztO8cWBzdN/jz3b7GD6JHYP/Zp4ToAMaA74M+EGSft3hEGMuf8EwjnTk/
+nz/P7SLipB/ogQ6xNX0fDqNncMCfHqGLCMM0ZzFa+6lPJYQ5p81vW4HkCvidYf6kb+P/oB965g8K
+C6uR0rdjX1DNKc5pOSTquI8uQ6KXxYaKBn+30/09tK4kMpJPgUIQkbENEPbuezNPPje2Um83SgyX
+GTCJb6MnGVIpgncdQg1qz2bvPfxYD9fewCXDomx9S+HQJuX6W3VAL+v5WZMudRQZk9ZdOk6GIUtC
+PqEb/uwSIrtR7/edzqgEdtpEwq7p2J5OQV+RLrmtTvFwFpf03M/VrRyTZ73qVod7v7Jh2Dwe5J25
+JqFOU2qEu1sP+CRotklediycKfLjeIZzjJQsvKmiGSNQhxuJpKa+hoWUizaE1PuIRGzJqropwgVB
+oo1hr870MZLgnXF5ZIpr6mF0L8aSy2gVnTAuoB4WEd4d5NPVC9TMotYXERKlTcwQ2KiB/C48AEfH
+Qbyq4CN8xTFnTvf/ebOc3isnjD95s0QF0nx9s+y+zMmz782xL0SgEmRpA3x1w1Ff9/74xcxKEPdS
+IEFTz6GgU0+BK/UZ5Gwbl4gZwycxEw+Kqa5QmMkh4OzgzEVPnDAiAOGBFaBW4wkDmj1G4RyElKgj
+NlLCq8zsp085MNh/+R4t1Q8yxoSv8PUpTt7izZwf2BTHZZ3pIZpUIpuLkL1nNL6sYcHqcKm237wp
+T2+RCjgXweXd2Zp7ZM8W6dG5bZsqo0nrJBTx8EC0+CQQdzEGnabTnkzofu1pYkWl4E7XSniECdxy
+vLYavPMcL9LW5SToJFNnos+uqweOHriUZ1ntIYZUonc7ltEQ6oTRtwOHNwez2sVREskHN+bqG3ua
+eaEbJ8XpyO8CeD9QJc8nbLP2C2R3A437ISUNyt5Yd0TbDNcl11/DSsOzdbi/VhCC0KE6v1vqVNkq
+45ZnG6fiV2NwzInxCNth3BwL0+8814jE6+1W1EeWtpWbSZJOJNYXmWRXa7vLnAljE692eHjZ4y5u
+y1u63De0IzKca7As48Z3XshVF+3XiLNz0JIMh/JOpbiNLlMi672uO0wYzOCZjRxcxj3D+gVenGIE
+MvFUGGXuRps2RzMcgWIRolHXpGUP6sMsQt1hspUBnVKUn/WQj2u6j3SXd9Xz0QtEzoM7qTu5y7gR
+q9gNNsrlEMLdikBt9bFvBnfbUIh6voTw7eDsyTmPKUvF0bHqWLbHe3VRHyRZnNeSGKsB73q66Vsk
+taxWYmwz1tYVFG/vOQhlM0gUkyvIab3nv2caJ1udU1F3pDMty7stubTE4OJqm0i0ECfrJIkLtraC
+HwRWKzlqpfhEIqYH09eT9WrOhQyt8YEoyBlnXtAT37WHIQ03TIuEHbnRxZDdLun0iok9PUC79prU
+m5beZzfQUelEXnhzb/pIROKx3F7qCttYIFGh5dXNzFzID7u8vKykA8Uejf7XXz//S4nKvW//ofS/
+QastYw==
+""")
+
+##file distutils-init.py
+DISTUTILS_INIT = convert("""
+eJytV92L4zYQf/dfMU0ottuse7RvC6FQrg8Lxz2Ugz4si9HacqKuIxlJ2ST313dG8odkO9d7aGBB
+luZLv/nNjFacOqUtKJMIvzK3cXlhWgp5MDBsqK5SNYftsBAGpLLA4F1oe2Ytl+9wUvW55TswCi4c
+KibhbFDSglXQCFmDPXIwtm7FawLRbwtPzg2T9gf4gupKv4GS0N262w7V0NvpbCy8cvTo3eAus6C5
+ETU3ICQZX1hFTw/dzR6V/AW1RCN4/XAtbsVXqIXmlVX6liS4lOzEYY9QFB2zx6LfoSNjz1a0pqT9
+QOIfJWQ2E888NEVZNqLlZZnvIB0NpHkimlFdKn2iRRY7yGG/CCJb6Iz280d34SFXBS2yEYPNF0Q7
+yM7oCjpWvbEDQmnhRwOs6zjThpKE8HogwRAgraqYFZgGZvzmzVh+mgz9vskT3hruwyjdFcqyENJw
+bbMPO5jdzonxK68QKT7B57CMRRG5shRSWDTX3dI8LzRndZbnSWL1zfvriUmK4TcGWSnZiEPCrxXv
+bM+sP7VW2is2WgWXCO3sAu3Rzysz3FiNCA8WPyM4gb1JAAmCiyTZbhFjWx3h9SzauuRXC9MFoVbc
+yNTCm1QXOOIfIn/g1kGMhDUBN72hI5XCBQtIXQw8UEEdma6Jaz4vJIJ51Orc15hzzmu6TdFp3ogr
+Aof0c98tsw1SiaiWotHffk3XYCkqdToxWRfTFXqgpg2khcLluOHMVC0zZhLKIomesfSreUNNgbXi
+Ky9VRzwzkBneNoGQyyvGjbsFQqOZvpWIjqH281lJ/jireFgR3cPzSyTGWzQpDNIU+03Fs4XKLkhp
+/n0uFnuF6VphB44b3uWRneSbBoMSioqE8oeF0JY+qTvYfEK+bPLYdoR4McfYQ7wMZj39q0kfP8q+
+FfsymO0GzNlPh644Jje06ulqHpOEQqdJUfoidI2O4CWx4qOglLye6RrFQirpCRXvhoRqXH3sYdVJ
+AItvc+VUsLO2v2hVAWrNIfVGtkG351cUMNncbh/WdowtSPtCdkzYFv6mwYc9o2Jt68ud6wectBr8
+hYAulPSlgzH44YbV3ikjrulEaNJxt+/H3wZ7bXSXje/YY4tfVVrVmUstaDwwOBLMg6iduDB0lMVC
+UyzYx7Ab4kjCqdViEJmDcdk/SKbgsjYXgfMznUWcrtS4z4fmJ/XOM1LPk/iIpqass5XwNbdnLb1Y
+8h3ERXSWZI6rZJxKs1LBqVH65w0Oy4ra0CBYxEeuOMbDmV5GI6E0Ha/wgVTtkX0+OXvqsD02CKLf
+XHbeft85D7tTCMYy2Njp4DJP7gWJr6paVWXZ1+/6YXLv/iE0M90FktiI7yFJD9e7SOLhEkkaMTUO
+azq9i2woBNR0/0eoF1HFMf0H8ChxH/jgcB34GZIz3Qn4/vid+VEamQrOVqAPTrOfmD4MPdVh09tb
+8dLLjvh/61lEP4yW5vJaH4vHcevG8agXvzPGoOhhXNncpTr99PTHx6e/UvffFLaxUSjuSeP286Dw
+gtEMcW1xKr/he4/6IQ6FUXP+0gkioHY5iwC9Eyx3HKO7af0zPPe+XyLn7fAY78k4aiR387bCr5XT
+5C4rFgwLGfMvJuAMew==
+""")
+
+##file distutils.cfg
+DISTUTILS_CFG = convert("""
+eJxNj00KwkAMhfc9xYNuxe4Ft57AjYiUtDO1wXSmNJnK3N5pdSEEAu8nH6lxHVlRhtDHMPATA4uH
+xJ4EFmGbvfJiicSHFRzUSISMY6hq3GLCRLnIvSTnEefN0FIjw5tF0Hkk9Q5dRunBsVoyFi24aaLg
+9FDOlL0FPGluf4QjcInLlxd6f6rqkgPu/5nHLg0cXCscXoozRrP51DRT3j9QNl99AP53T2Q=
+""")
+
+##file activate_this.py
+ACTIVATE_THIS = convert("""
+eJyNU01v2zAMvetXEB4K21jmDOstQA4dMGCHbeihlyEIDMWmG62yJEiKE//7kXKdpN2KzYBt8euR
+fKSyLPs8wiEo8wh4wqZTGou4V6Hm0wJa1cSiTkJdr8+GsoTRHuCotBayiWqQEYGtMCgfD1KjGYBe
+5a3p0cRKiAe2NtLADikftnDco0ko/SFEVgEZ8aRC5GLux7i3BpSJ6J1H+i7A2CjiHq9z7JRZuuQq
+siwTIvpxJYCeuWaBpwZdhB+yxy/eWz+ZvVSU8C4E9FFZkyxFsvCT/ZzL8gcz9aXVE14Yyp2M+2W0
+y7n5mp0qN+avKXvbsyyzUqjeWR8hjGE+2iCE1W1tQ82hsCZN9UzlJr+/e/iab8WfqsmPI6pWeUPd
+FrMsd4H/55poeO9n54COhUs+sZNEzNtg/wanpjpuqHJaxs76HtZryI/K3H7KJ/KDIhqcbJ7kI4ar
+XL+sMgXnX0D+Te2Iy5xdP8yueSlQB/x/ED2BTAtyE3K4SYUN6AMNfbO63f4lBW3bUJPbTL+mjSxS
+PyRfJkZRgj+VbFv+EzHFi5pKwUEepa4JslMnwkowSRCXI+m5XvEOvtuBrxHdhLalG0JofYBok6qj
+YdN2dEngUlbC4PG60M1WEN0piu7Nq7on0mgyyUw3iV1etLo6r/81biWdQ9MWHFaePWZYaq+nmp+t
+s3az+sj7eA0jfgPfeoN1
+""")
+
+if __name__ == '__main__':
+ main()
+
+## TODO:
+## Copy python.exe.manifest
+## Monkeypatch distutils.sysconfig
diff --git a/make_disk_image.sh b/make_disk_image.sh
new file mode 100755
index 0000000..b6cda2c
--- /dev/null
+++ b/make_disk_image.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+#echo "Building app..."
+#python2.7 setup.py py2app
+echo "Building disk image..."
+imgName=`date '+mvc-%Y-%m-%d.dmg'`
+imgDirName="dist/img"
+imgPath="dist/$imgName"
+rm -rf $imgDirName $imgPath
+mkdir $imgDirName
+cp -r dist/Libre\ Video\ Converter.app $imgDirName/
+ln -s /Applications $imgDirName/Applications
+echo "Creating DMG file... "
+hdiutil create -srcfolder $imgDirName -volname mvc -format UDZO dist/mvc.tmp.dmg
+hdiutil convert -format UDZO -imagekey zlib-level=9 -o $imgPath dist/mvc.tmp.dmg
+rm dist/mvc.tmp.dmg
diff --git a/mvc/__init__.py b/mvc/__init__.py
new file mode 100644
index 0000000..94760ce
--- /dev/null
+++ b/mvc/__init__.py
@@ -0,0 +1,37 @@
+import os
+
+import multiprocessing
+from mvc import converter
+from mvc import conversion
+from mvc import signals
+from mvc import video
+
+VERSION = '3.0a'
+
+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/mvc/__init__.pyc b/mvc/__init__.pyc
new file mode 100644
index 0000000..3ee8029
--- /dev/null
+++ b/mvc/__init__.pyc
Binary files differ
diff --git a/mvc/__main__.py b/mvc/__main__.py
new file mode 100644
index 0000000..1992c4e
--- /dev/null
+++ b/mvc/__main__.py
@@ -0,0 +1,9 @@
+if __name__ == "__main__":
+ try:
+ from mvc.ui.widgets import Application
+ except ImportError:
+ from mvc.ui.console import Application
+ from mvc.widgets import app
+ from mvc.widgets import initialize
+ app.widgetapp = Application()
+ initialize(app.widgetapp)
diff --git a/mvc/basicconverters.py b/mvc/basicconverters.py
new file mode 100644
index 0000000..16347fe
--- /dev/null
+++ b/mvc/basicconverters.py
@@ -0,0 +1,132 @@
+import logging
+import re
+
+from mvc import converter
+
+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 '
+ '-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 '
+ '-ar 44100')
+
+class MP4(converter.FFmpegConverterInfo):
+ media_type = 'format'
+ extension = 'mp4'
+ parameters = ('-acodec aac -ab 96k -vcodec libx264 -preset slow '
+ '-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'
+
+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_hd = WebM_HD('WebM HD')
+webm_sd = WebM_SD('WebM SD')
+mp4 = MP4('MP4')
+theora = OggTheora('Ogg Theora')
+
+video_formats = ('Video', [webm_hd, webm_sd, 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/mvc/conversion.py b/mvc/conversion.py
new file mode 100644
index 0000000..c7aa883
--- /dev/null
+++ b/mvc/conversion.py
@@ -0,0 +1,313 @@
+import collections
+import errno
+import os
+import time
+import tempfile
+import threading
+import shutil
+import logging
+
+from mvc import execute
+from mvc.utils import line_reader
+from mvc.video import get_thumbnail_synchronous
+from mvc.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/mvc/conversion.pyc b/mvc/conversion.pyc
new file mode 100644
index 0000000..bcb424d
--- /dev/null
+++ b/mvc/conversion.pyc
Binary files differ
diff --git a/mvc/converter.py b/mvc/converter.py
new file mode 100644
index 0000000..ef3d5c5
--- /dev/null
+++ b/mvc/converter.py
@@ -0,0 +1,278 @@
+import json
+import logging
+import os
+import re
+import shutil
+
+from mvc import resources, settings, utils
+from mvc.utils import hms_to_seconds
+
+from mvc.qtfaststart import processor
+from mvc.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 mvc 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/mvc/converter.pyc b/mvc/converter.pyc
new file mode 100644
index 0000000..0c0ab1d
--- /dev/null
+++ b/mvc/converter.pyc
Binary files differ
diff --git a/mvc/errors.py b/mvc/errors.py
new file mode 100644
index 0000000..504948b
--- /dev/null
+++ b/mvc/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/mvc/errors.pyc b/mvc/errors.pyc
new file mode 100644
index 0000000..35c8eef
--- /dev/null
+++ b/mvc/errors.pyc
Binary files differ
diff --git a/mvc/execute.py b/mvc/execute.py
new file mode 100644
index 0000000..893d356
--- /dev/null
+++ b/mvc/execute.py
@@ -0,0 +1,49 @@
+"""execute.py -- Run executable programs.
+
+mvc.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/mvc/execute.pyc b/mvc/execute.pyc
new file mode 100644
index 0000000..5ac124c
--- /dev/null
+++ b/mvc/execute.pyc
Binary files differ
diff --git a/mvc/openfiles.py b/mvc/openfiles.py
new file mode 100644
index 0000000..1025719
--- /dev/null
+++ b/mvc/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])
+
+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/mvc/openfiles.pyc b/mvc/openfiles.pyc
new file mode 100644
index 0000000..bd00c5f
--- /dev/null
+++ b/mvc/openfiles.pyc
Binary files differ
diff --git a/mvc/osx/__init__.py b/mvc/osx/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/mvc/osx/__init__.py
diff --git a/mvc/osx/app_main.py b/mvc/osx/app_main.py
new file mode 100644
index 0000000..ef52ff6
--- /dev/null
+++ b/mvc/osx/app_main.py
@@ -0,0 +1,12 @@
+import os
+import sys
+
+from mvc.osx import autoupdate
+from mvc.widgets import app
+from mvc.widgets import initialize
+from mvc.ui.widgets import Application
+
+# run the app
+autoupdate.initialize()
+app.widgetapp = Application()
+initialize(app.widgetapp)
diff --git a/mvc/osx/autoupdate.py b/mvc/osx/autoupdate.py
new file mode 100644
index 0000000..7b17d47
--- /dev/null
+++ b/mvc/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/mvc/qtfaststart/__init__.py b/mvc/qtfaststart/__init__.py
new file mode 100644
index 0000000..f985b5c
--- /dev/null
+++ b/mvc/qtfaststart/__init__.py
@@ -0,0 +1 @@
+VERSION = "1.6"
diff --git a/mvc/qtfaststart/__init__.pyc b/mvc/qtfaststart/__init__.pyc
new file mode 100644
index 0000000..9ab2c4c
--- /dev/null
+++ b/mvc/qtfaststart/__init__.pyc
Binary files differ
diff --git a/mvc/qtfaststart/exceptions.py b/mvc/qtfaststart/exceptions.py
new file mode 100644
index 0000000..f0767e1
--- /dev/null
+++ b/mvc/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/mvc/qtfaststart/exceptions.pyc b/mvc/qtfaststart/exceptions.pyc
new file mode 100644
index 0000000..0e392e5
--- /dev/null
+++ b/mvc/qtfaststart/exceptions.pyc
Binary files differ
diff --git a/mvc/qtfaststart/processor.py b/mvc/qtfaststart/processor.py
new file mode 100755
index 0000000..df2a900
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/qtfaststart/processor.pyc b/mvc/qtfaststart/processor.pyc
new file mode 100644
index 0000000..73156b4
--- /dev/null
+++ b/mvc/qtfaststart/processor.pyc
Binary files differ
diff --git a/mvc/resources/__init__.py b/mvc/resources/__init__.py
new file mode 100644
index 0000000..005041d
--- /dev/null
+++ b/mvc/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/mvc/resources/__init__.pyc b/mvc/resources/__init__.pyc
new file mode 100644
index 0000000..165624c
--- /dev/null
+++ b/mvc/resources/__init__.pyc
Binary files differ
diff --git a/mvc/resources/converters/android.py b/mvc/resources/converters/android.py
new file mode 100644
index 0000000..ac2007d
--- /dev/null
+++ b/mvc/resources/converters/android.py
@@ -0,0 +1,61 @@
+from mvc.converter import FFmpegConverterInfo
+from mvc.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/mvc/resources/converters/apple.py b/mvc/resources/converters/apple.py
new file mode 100644
index 0000000..41fdd7a
--- /dev/null
+++ b/mvc/resources/converters/apple.py
@@ -0,0 +1,28 @@
+from mvc.converter import FFmpegConverterInfo
+from mvc.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/mvc/resources/converters/others.py b/mvc/resources/converters/others.py
new file mode 100644
index 0000000..a05030f
--- /dev/null
+++ b/mvc/resources/converters/others.py
@@ -0,0 +1,20 @@
+from mvc.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/mvc/resources/images/android-icon-off.png b/mvc/resources/images/android-icon-off.png
new file mode 100644
index 0000000..a20395c
--- /dev/null
+++ b/mvc/resources/images/android-icon-off.png
Binary files differ
diff --git a/mvc/resources/images/android-icon-on.png b/mvc/resources/images/android-icon-on.png
new file mode 100644
index 0000000..69bb858
--- /dev/null
+++ b/mvc/resources/images/android-icon-on.png
Binary files differ
diff --git a/mvc/resources/images/apple-icon-off.png b/mvc/resources/images/apple-icon-off.png
new file mode 100644
index 0000000..04cb85a
--- /dev/null
+++ b/mvc/resources/images/apple-icon-off.png
Binary files differ
diff --git a/mvc/resources/images/apple-icon-on.png b/mvc/resources/images/apple-icon-on.png
new file mode 100644
index 0000000..e151d89
--- /dev/null
+++ b/mvc/resources/images/apple-icon-on.png
Binary files differ
diff --git a/mvc/resources/images/arrow-down-off.png b/mvc/resources/images/arrow-down-off.png
new file mode 100644
index 0000000..f4c5f5d
--- /dev/null
+++ b/mvc/resources/images/arrow-down-off.png
Binary files differ
diff --git a/mvc/resources/images/arrow-down-on.png b/mvc/resources/images/arrow-down-on.png
new file mode 100644
index 0000000..07696e0
--- /dev/null
+++ b/mvc/resources/images/arrow-down-on.png
Binary files differ
diff --git a/mvc/resources/images/audio.png b/mvc/resources/images/audio.png
new file mode 100644
index 0000000..6a21e49
--- /dev/null
+++ b/mvc/resources/images/audio.png
Binary files differ
diff --git a/mvc/resources/images/clear-icon.png b/mvc/resources/images/clear-icon.png
new file mode 100644
index 0000000..5fe6b51
--- /dev/null
+++ b/mvc/resources/images/clear-icon.png
Binary files differ
diff --git a/mvc/resources/images/convert-button-off.png b/mvc/resources/images/convert-button-off.png
new file mode 100644
index 0000000..50db34e
--- /dev/null
+++ b/mvc/resources/images/convert-button-off.png
Binary files differ
diff --git a/mvc/resources/images/convert-button-on.png b/mvc/resources/images/convert-button-on.png
new file mode 100644
index 0000000..f6d492b
--- /dev/null
+++ b/mvc/resources/images/convert-button-on.png
Binary files differ
diff --git a/mvc/resources/images/convert-button-stop.png b/mvc/resources/images/convert-button-stop.png
new file mode 100644
index 0000000..98396da
--- /dev/null
+++ b/mvc/resources/images/convert-button-stop.png
Binary files differ
diff --git a/mvc/resources/images/converted_to-icon.png b/mvc/resources/images/converted_to-icon.png
new file mode 100644
index 0000000..47c7954
--- /dev/null
+++ b/mvc/resources/images/converted_to-icon.png
Binary files differ
diff --git a/mvc/resources/images/dropoff-icon-off.png b/mvc/resources/images/dropoff-icon-off.png
new file mode 100644
index 0000000..bb3d781
--- /dev/null
+++ b/mvc/resources/images/dropoff-icon-off.png
Binary files differ
diff --git a/mvc/resources/images/dropoff-icon-on.png b/mvc/resources/images/dropoff-icon-on.png
new file mode 100644
index 0000000..d1bf1e5
--- /dev/null
+++ b/mvc/resources/images/dropoff-icon-on.png
Binary files differ
diff --git a/mvc/resources/images/dropoff-icon-small-off.png b/mvc/resources/images/dropoff-icon-small-off.png
new file mode 100644
index 0000000..191b0df
--- /dev/null
+++ b/mvc/resources/images/dropoff-icon-small-off.png
Binary files differ
diff --git a/mvc/resources/images/dropoff-icon-small-on.png b/mvc/resources/images/dropoff-icon-small-on.png
new file mode 100644
index 0000000..2d23caa
--- /dev/null
+++ b/mvc/resources/images/dropoff-icon-small-on.png
Binary files differ
diff --git a/mvc/resources/images/error-icon.png b/mvc/resources/images/error-icon.png
new file mode 100644
index 0000000..1efcb57
--- /dev/null
+++ b/mvc/resources/images/error-icon.png
Binary files differ
diff --git a/mvc/resources/images/item-completed.png b/mvc/resources/images/item-completed.png
new file mode 100644
index 0000000..14dffc8
--- /dev/null
+++ b/mvc/resources/images/item-completed.png
Binary files differ
diff --git a/mvc/resources/images/item-delete-button-off.png b/mvc/resources/images/item-delete-button-off.png
new file mode 100644
index 0000000..b99dc7c
--- /dev/null
+++ b/mvc/resources/images/item-delete-button-off.png
Binary files differ
diff --git a/mvc/resources/images/item-delete-button-on.png b/mvc/resources/images/item-delete-button-on.png
new file mode 100644
index 0000000..0b71149
--- /dev/null
+++ b/mvc/resources/images/item-delete-button-on.png
Binary files differ
diff --git a/mvc/resources/images/item-error.png b/mvc/resources/images/item-error.png
new file mode 100644
index 0000000..0e5ff9f
--- /dev/null
+++ b/mvc/resources/images/item-error.png
Binary files differ
diff --git a/mvc/resources/images/mvc-logo.png b/mvc/resources/images/mvc-logo.png
new file mode 100644
index 0000000..be05fd3
--- /dev/null
+++ b/mvc/resources/images/mvc-logo.png
Binary files differ
diff --git a/mvc/resources/images/other-icon-off.png b/mvc/resources/images/other-icon-off.png
new file mode 100644
index 0000000..e2ed0f6
--- /dev/null
+++ b/mvc/resources/images/other-icon-off.png
Binary files differ
diff --git a/mvc/resources/images/other-icon-on.png b/mvc/resources/images/other-icon-on.png
new file mode 100644
index 0000000..e8748b6
--- /dev/null
+++ b/mvc/resources/images/other-icon-on.png
Binary files differ
diff --git a/mvc/resources/images/progressbar-base.png b/mvc/resources/images/progressbar-base.png
new file mode 100644
index 0000000..893bb8b
--- /dev/null
+++ b/mvc/resources/images/progressbar-base.png
Binary files differ
diff --git a/mvc/resources/images/queued-icon.png b/mvc/resources/images/queued-icon.png
new file mode 100644
index 0000000..cf77676
--- /dev/null
+++ b/mvc/resources/images/queued-icon.png
Binary files differ
diff --git a/mvc/resources/images/settings-base_center.png b/mvc/resources/images/settings-base_center.png
new file mode 100644
index 0000000..65f4017
--- /dev/null
+++ b/mvc/resources/images/settings-base_center.png
Binary files differ
diff --git a/mvc/resources/images/settings-base_left.png b/mvc/resources/images/settings-base_left.png
new file mode 100644
index 0000000..43a228d
--- /dev/null
+++ b/mvc/resources/images/settings-base_left.png
Binary files differ
diff --git a/mvc/resources/images/settings-base_right.png b/mvc/resources/images/settings-base_right.png
new file mode 100644
index 0000000..83de6a4
--- /dev/null
+++ b/mvc/resources/images/settings-base_right.png
Binary files differ
diff --git a/mvc/resources/images/settings-depth_center.png b/mvc/resources/images/settings-depth_center.png
new file mode 100644
index 0000000..04fe295
--- /dev/null
+++ b/mvc/resources/images/settings-depth_center.png
Binary files differ
diff --git a/mvc/resources/images/settings-depth_left.png b/mvc/resources/images/settings-depth_left.png
new file mode 100644
index 0000000..54390e0
--- /dev/null
+++ b/mvc/resources/images/settings-depth_left.png
Binary files differ
diff --git a/mvc/resources/images/settings-depth_right.png b/mvc/resources/images/settings-depth_right.png
new file mode 100644
index 0000000..a831090
--- /dev/null
+++ b/mvc/resources/images/settings-depth_right.png
Binary files differ
diff --git a/mvc/resources/images/settings-dropdown-bottom-bg.png b/mvc/resources/images/settings-dropdown-bottom-bg.png
new file mode 100644
index 0000000..3b062d0
--- /dev/null
+++ b/mvc/resources/images/settings-dropdown-bottom-bg.png
Binary files differ
diff --git a/mvc/resources/images/settings-icon-off.png b/mvc/resources/images/settings-icon-off.png
new file mode 100644
index 0000000..5fdb386
--- /dev/null
+++ b/mvc/resources/images/settings-icon-off.png
Binary files differ
diff --git a/mvc/resources/images/settings-icon-on.png b/mvc/resources/images/settings-icon-on.png
new file mode 100644
index 0000000..403f126
--- /dev/null
+++ b/mvc/resources/images/settings-icon-on.png
Binary files differ
diff --git a/mvc/resources/images/showfile-icon.png b/mvc/resources/images/showfile-icon.png
new file mode 100644
index 0000000..0fd2d6b
--- /dev/null
+++ b/mvc/resources/images/showfile-icon.png
Binary files differ
diff --git a/mvc/resources/nsis/modern-wizard.bmp b/mvc/resources/nsis/modern-wizard.bmp
new file mode 100644
index 0000000..d8ea8d9
--- /dev/null
+++ b/mvc/resources/nsis/modern-wizard.bmp
Binary files differ
diff --git a/mvc/resources/nsis/mvc-logo.ico b/mvc/resources/nsis/mvc-logo.ico
new file mode 100644
index 0000000..007a929
--- /dev/null
+++ b/mvc/resources/nsis/mvc-logo.ico
Binary files differ
diff --git a/mvc/resources/nsis/plugins/nsProcess.dll b/mvc/resources/nsis/plugins/nsProcess.dll
new file mode 100644
index 0000000..4355d4a
--- /dev/null
+++ b/mvc/resources/nsis/plugins/nsProcess.dll
Binary files differ
diff --git a/mvc/resources/nsis/plugins/nsProcess.nsh b/mvc/resources/nsis/plugins/nsProcess.nsh
new file mode 100644
index 0000000..76642e0
--- /dev/null
+++ b/mvc/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/mvc/resources/windows/README b/mvc/resources/windows/README
new file mode 100644
index 0000000..bcc603e
--- /dev/null
+++ b/mvc/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/mvc/resources/windows/gtkrc b/mvc/resources/windows/gtkrc
new file mode 100755
index 0000000..7add72c
--- /dev/null
+++ b/mvc/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/mvc/settings.py b/mvc/settings.py
new file mode 100644
index 0000000..4d7255c
--- /dev/null
+++ b/mvc/settings.py
@@ -0,0 +1,88 @@
+import logging
+import os
+import sys
+
+from mvc 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/mvc/settings.pyc b/mvc/settings.pyc
new file mode 100644
index 0000000..f97e675
--- /dev/null
+++ b/mvc/settings.pyc
Binary files differ
diff --git a/mvc/signals.py b/mvc/signals.py
new file mode 100644
index 0000000..6b09406
--- /dev/null
+++ b/mvc/signals.py
@@ -0,0 +1,301 @@
+# @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/mvc/signals.pyc b/mvc/signals.pyc
new file mode 100644
index 0000000..3370676
--- /dev/null
+++ b/mvc/signals.pyc
Binary files differ
diff --git a/mvc/ui/__init__.py b/mvc/ui/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/mvc/ui/__init__.py
diff --git a/mvc/ui/__init__.pyc b/mvc/ui/__init__.pyc
new file mode 100644
index 0000000..329825b
--- /dev/null
+++ b/mvc/ui/__init__.pyc
Binary files differ
diff --git a/mvc/ui/console.py b/mvc/ui/console.py
new file mode 100644
index 0000000..a9751ac
--- /dev/null
+++ b/mvc/ui/console.py
@@ -0,0 +1,120 @@
+import json
+import operator
+import optparse
+import time
+import sys
+
+import mvc
+from mvc.widgets import app
+from mvc.widgets import initialize
+
+parser = optparse.OptionParser(
+ usage='%prog [-l] [--list-converters] [-c <converter> <filenames..>]',
+ version='%prog ' + mvc.VERSION,
+ prog='python -m mvc.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(mvc.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/mvc/ui/widgets.py b/mvc/ui/widgets.py
new file mode 100644
index 0000000..67dffc8
--- /dev/null
+++ b/mvc/ui/widgets.py
@@ -0,0 +1,1540 @@
+import logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+import os
+import sys
+
+try:
+ import mvc
+except ImportError:
+ mvc_path = os.path.join(os.path.dirname(__file__), '..', '..')
+ sys.path.append(mvc_path)
+ import mvc
+
+import copy
+import tempfile
+import urllib
+import urlparse
+
+from mvc.widgets import (initialize, idle_add, mainloop_start, mainloop_stop,
+ attach_menubar, reveal_file, get_conversion_directory)
+from mvc.widgets import menus
+from mvc.widgets import widgetset
+from mvc.widgets import cellpack
+from mvc.widgets import widgetconst
+from mvc.widgets import widgetutil
+from mvc.widgets import app
+
+from mvc.converter import ConverterInfo
+from mvc.video import VideoFile
+from mvc.resources import image_path
+from mvc.utils import size_string, round_even, convert_path_for_subprocess
+from mvc 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(mvc.Application):
+ def __init__(self, simultaneous=None):
+ mvc.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
+
+ mvc.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/mvc/utils.py b/mvc/utils.py
new file mode 100644
index 0000000..e0a64f3
--- /dev/null
+++ b/mvc/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/mvc/utils.pyc b/mvc/utils.pyc
new file mode 100644
index 0000000..1c81b99
--- /dev/null
+++ b/mvc/utils.pyc
Binary files differ
diff --git a/mvc/video.py b/mvc/video.py
new file mode 100644
index 0000000..fb46df9
--- /dev/null
+++ b/mvc/video.py
@@ -0,0 +1,287 @@
+import logging
+import os
+import re
+import tempfile
+import threading
+
+from mvc import execute
+from mvc.widgets import idle_add
+from mvc.settings import get_ffmpeg_executable_path
+from mvc.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/mvc/video.pyc b/mvc/video.pyc
new file mode 100644
index 0000000..951dcbf
--- /dev/null
+++ b/mvc/video.pyc
Binary files differ
diff --git a/mvc/widgets/__init__.py b/mvc/widgets/__init__.py
new file mode 100644
index 0000000..23a6edc
--- /dev/null
+++ b/mvc/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/mvc/widgets/__init__.pyc b/mvc/widgets/__init__.pyc
new file mode 100644
index 0000000..d87eb5e
--- /dev/null
+++ b/mvc/widgets/__init__.pyc
Binary files differ
diff --git a/mvc/widgets/app.py b/mvc/widgets/app.py
new file mode 100644
index 0000000..531b745
--- /dev/null
+++ b/mvc/widgets/app.py
@@ -0,0 +1,4 @@
+# app.py
+
+widgetapp = None
+
diff --git a/mvc/widgets/app.pyc b/mvc/widgets/app.pyc
new file mode 100644
index 0000000..112624a
--- /dev/null
+++ b/mvc/widgets/app.pyc
Binary files differ
diff --git a/mvc/widgets/cellpack.py b/mvc/widgets/cellpack.py
new file mode 100644
index 0000000..1347f56
--- /dev/null
+++ b/mvc/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/mvc/widgets/cellpack.pyc b/mvc/widgets/cellpack.pyc
new file mode 100644
index 0000000..7e3fde1
--- /dev/null
+++ b/mvc/widgets/cellpack.pyc
Binary files differ
diff --git a/mvc/widgets/dialogs.py b/mvc/widgets/dialogs.py
new file mode 100644
index 0000000..3ccdcd7
--- /dev/null
+++ b/mvc/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 mvc.widgets import widgetset
+from mvc.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/mvc/widgets/gtk/__init__.py b/mvc/widgets/gtk/__init__.py
new file mode 100644
index 0000000..4f42a43
--- /dev/null
+++ b/mvc/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 mvc.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",
+ "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 mvc.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/mvc/widgets/gtk/__init__.pyc b/mvc/widgets/gtk/__init__.pyc
new file mode 100644
index 0000000..629b52d
--- /dev/null
+++ b/mvc/widgets/gtk/__init__.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/base.py b/mvc/widgets/gtk/base.py
new file mode 100644
index 0000000..e02db3f
--- /dev/null
+++ b/mvc/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 mvc 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/mvc/widgets/gtk/base.pyc b/mvc/widgets/gtk/base.pyc
new file mode 100644
index 0000000..df6c1f9
--- /dev/null
+++ b/mvc/widgets/gtk/base.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/const.py b/mvc/widgets/gtk/const.py
new file mode 100644
index 0000000..5e9ec05
--- /dev/null
+++ b/mvc/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/mvc/widgets/gtk/const.pyc b/mvc/widgets/gtk/const.pyc
new file mode 100644
index 0000000..fc565b6
--- /dev/null
+++ b/mvc/widgets/gtk/const.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/contextmenu.py b/mvc/widgets/gtk/contextmenu.py
new file mode 100644
index 0000000..cd5b6ba
--- /dev/null
+++ b/mvc/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/mvc/widgets/gtk/contextmenu.pyc b/mvc/widgets/gtk/contextmenu.pyc
new file mode 100644
index 0000000..5fabfa0
--- /dev/null
+++ b/mvc/widgets/gtk/contextmenu.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/controls.py b/mvc/widgets/gtk/controls.py
new file mode 100644
index 0000000..4367c1f
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/gtk/controls.pyc b/mvc/widgets/gtk/controls.pyc
new file mode 100644
index 0000000..a4a2179
--- /dev/null
+++ b/mvc/widgets/gtk/controls.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/customcontrols.py b/mvc/widgets/gtk/customcontrols.py
new file mode 100644
index 0000000..070cebd
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/gtk/customcontrols.pyc b/mvc/widgets/gtk/customcontrols.pyc
new file mode 100644
index 0000000..ff42ada
--- /dev/null
+++ b/mvc/widgets/gtk/customcontrols.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/drawing.py b/mvc/widgets/gtk/drawing.py
new file mode 100644
index 0000000..5888851
--- /dev/null
+++ b/mvc/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/mvc/widgets/gtk/drawing.pyc b/mvc/widgets/gtk/drawing.pyc
new file mode 100644
index 0000000..93075bd
--- /dev/null
+++ b/mvc/widgets/gtk/drawing.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/gtkmenus.py b/mvc/widgets/gtk/gtkmenus.py
new file mode 100644
index 0000000..926ba15
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/gtk/gtkmenus.pyc b/mvc/widgets/gtk/gtkmenus.pyc
new file mode 100644
index 0000000..6641484
--- /dev/null
+++ b/mvc/widgets/gtk/gtkmenus.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/keymap.py b/mvc/widgets/gtk/keymap.py
new file mode 100644
index 0000000..cf341ff
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/gtk/keymap.pyc b/mvc/widgets/gtk/keymap.pyc
new file mode 100644
index 0000000..435c36a
--- /dev/null
+++ b/mvc/widgets/gtk/keymap.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/layout.py b/mvc/widgets/gtk/layout.py
new file mode 100644
index 0000000..d887fcb
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/gtk/layout.pyc b/mvc/widgets/gtk/layout.pyc
new file mode 100644
index 0000000..08e1105
--- /dev/null
+++ b/mvc/widgets/gtk/layout.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/layoutmanager.py b/mvc/widgets/gtk/layoutmanager.py
new file mode 100644
index 0000000..fb60049
--- /dev/null
+++ b/mvc/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 mvc 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/mvc/widgets/gtk/layoutmanager.pyc b/mvc/widgets/gtk/layoutmanager.pyc
new file mode 100644
index 0000000..cbd65b8
--- /dev/null
+++ b/mvc/widgets/gtk/layoutmanager.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/simple.py b/mvc/widgets/gtk/simple.py
new file mode 100644
index 0000000..f0921e0
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/gtk/simple.pyc b/mvc/widgets/gtk/simple.pyc
new file mode 100644
index 0000000..ec7bc4c
--- /dev/null
+++ b/mvc/widgets/gtk/simple.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/tableview.py b/mvc/widgets/gtk/tableview.py
new file mode 100644
index 0000000..930270c
--- /dev/null
+++ b/mvc/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 mvc import signals
+from mvc.errors import (WidgetActionError, WidgetDomainError,
+ WidgetRangeError, WidgetNotReadyError)
+from mvc.widgets.tableselection import SelectionOwnerMixin
+from mvc.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/mvc/widgets/gtk/tableview.pyc b/mvc/widgets/gtk/tableview.pyc
new file mode 100644
index 0000000..a6e6afb
--- /dev/null
+++ b/mvc/widgets/gtk/tableview.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/tableviewcells.py b/mvc/widgets/gtk/tableviewcells.py
new file mode 100644
index 0000000..33ac6f8
--- /dev/null
+++ b/mvc/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 mvc import signals
+from mvc.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/mvc/widgets/gtk/tableviewcells.pyc b/mvc/widgets/gtk/tableviewcells.pyc
new file mode 100644
index 0000000..b9b7c51
--- /dev/null
+++ b/mvc/widgets/gtk/tableviewcells.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/weakconnect.py b/mvc/widgets/gtk/weakconnect.py
new file mode 100644
index 0000000..b8b9526
--- /dev/null
+++ b/mvc/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 mvc 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/mvc/widgets/gtk/weakconnect.pyc b/mvc/widgets/gtk/weakconnect.pyc
new file mode 100644
index 0000000..49133f8
--- /dev/null
+++ b/mvc/widgets/gtk/weakconnect.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/widgets.py b/mvc/widgets/gtk/widgets.py
new file mode 100644
index 0000000..6c4280d
--- /dev/null
+++ b/mvc/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/mvc/widgets/gtk/widgets.pyc b/mvc/widgets/gtk/widgets.pyc
new file mode 100644
index 0000000..e11de9e
--- /dev/null
+++ b/mvc/widgets/gtk/widgets.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/widgetset.py b/mvc/widgets/gtk/widgetset.py
new file mode 100644
index 0000000..c63855c
--- /dev/null
+++ b/mvc/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/mvc/widgets/gtk/widgetset.pyc b/mvc/widgets/gtk/widgetset.pyc
new file mode 100644
index 0000000..677beb9
--- /dev/null
+++ b/mvc/widgets/gtk/widgetset.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/window.py b/mvc/widgets/gtk/window.py
new file mode 100644
index 0000000..1f1cf04
--- /dev/null
+++ b/mvc/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 mvc import resources
+from mvc 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("mvc-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 mvc.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 = '3.0'
+ #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/mvc/widgets/gtk/window.pyc b/mvc/widgets/gtk/window.pyc
new file mode 100644
index 0000000..f8c7248
--- /dev/null
+++ b/mvc/widgets/gtk/window.pyc
Binary files differ
diff --git a/mvc/widgets/gtk/wrappermap.py b/mvc/widgets/gtk/wrappermap.py
new file mode 100644
index 0000000..c2b2aad
--- /dev/null
+++ b/mvc/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/mvc/widgets/gtk/wrappermap.pyc b/mvc/widgets/gtk/wrappermap.pyc
new file mode 100644
index 0000000..44a14a0
--- /dev/null
+++ b/mvc/widgets/gtk/wrappermap.pyc
Binary files differ
diff --git a/mvc/widgets/keyboard.py b/mvc/widgets/keyboard.py
new file mode 100644
index 0000000..6700de2
--- /dev/null
+++ b/mvc/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/mvc/widgets/keyboard.pyc b/mvc/widgets/keyboard.pyc
new file mode 100644
index 0000000..35c715c
--- /dev/null
+++ b/mvc/widgets/keyboard.pyc
Binary files differ
diff --git a/mvc/widgets/menus.py b/mvc/widgets/menus.py
new file mode 100644
index 0000000..62b0c68
--- /dev/null
+++ b/mvc/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 mvc import signals
+from mvc.widgets import widgetutil
+from mvc.widgets import widgetset
+from mvc.widgets import app
+
+from mvc.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/mvc/widgets/menus.pyc b/mvc/widgets/menus.pyc
new file mode 100644
index 0000000..0418ae9
--- /dev/null
+++ b/mvc/widgets/menus.pyc
Binary files differ
diff --git a/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib b/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib
new file mode 100644
index 0000000..b7fefd6
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib b/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib
new file mode 100644
index 0000000..963b444
--- /dev/null
+++ b/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib
Binary files differ
diff --git a/mvc/widgets/osx/__init__.py b/mvc/widgets/osx/__init__.py
new file mode 100644
index 0000000..f227b35
--- /dev/null
+++ b/mvc/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 mvc.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 mvc.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 mvc.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/mvc/widgets/osx/base.py b/mvc/widgets/osx/base.py
new file mode 100644
index 0000000..913b372
--- /dev/null
+++ b/mvc/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 mvc 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 mvc.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 mvc.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/mvc/widgets/osx/const.py b/mvc/widgets/osx/const.py
new file mode 100644
index 0000000..ae0da40
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/contextmenu.py b/mvc/widgets/osx/contextmenu.py
new file mode 100644
index 0000000..7a8fa55
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/control.py b/mvc/widgets/osx/control.py
new file mode 100644
index 0000000..ed6ea34
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/osx/customcontrol.py b/mvc/widgets/osx/customcontrol.py
new file mode 100644
index 0000000..d100f33
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/osx/drawing.py b/mvc/widgets/osx/drawing.py
new file mode 100644
index 0000000..aaad1e9
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/drawingwidgets.py b/mvc/widgets/osx/drawingwidgets.py
new file mode 100644
index 0000000..74e8232
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/fasttypes.c b/mvc/widgets/osx/fasttypes.c
new file mode 100644
index 0000000..72d3b5b
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/helpers.py b/mvc/widgets/osx/helpers.py
new file mode 100644
index 0000000..e4aa23a
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/layout.py b/mvc/widgets/osx/layout.py
new file mode 100644
index 0000000..0238975
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/osx/layoutmanager.py b/mvc/widgets/osx/layoutmanager.py
new file mode 100644
index 0000000..de4301b
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/osxmenus.py b/mvc/widgets/osx/osxmenus.py
new file mode 100644
index 0000000..32ca469
--- /dev/null
+++ b/mvc/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 mvc import signals
+from mvc.widgets import keyboard
+# import these names directly into our namespace for easy access
+from mvc.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/mvc/widgets/osx/rect.py b/mvc/widgets/osx/rect.py
new file mode 100644
index 0000000..3c8d448
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/simple.py b/mvc/widgets/osx/simple.py
new file mode 100644
index 0000000..1c12b06
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/osx/tablemodel.py b/mvc/widgets/osx/tablemodel.py
new file mode 100644
index 0000000..980b60b
--- /dev/null
+++ b/mvc/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 mvc import signals
+from mvc.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/mvc/widgets/osx/tableview.py b/mvc/widgets/osx/tableview.py
new file mode 100644
index 0000000..2d2256f
--- /dev/null
+++ b/mvc/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 mvc import signals
+from mvc import errors
+from mvc.widgets import widgetconst
+from mvc.widgets.tableselection import SelectionOwnerMixin
+from mvc.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/mvc/widgets/osx/utils.py b/mvc/widgets/osx/utils.py
new file mode 100644
index 0000000..c0c2d85
--- /dev/null
+++ b/mvc/widgets/osx/utils.py
@@ -0,0 +1,2 @@
+def filename_to_unicode(filename):
+ return filename.decode('utf8')
diff --git a/mvc/widgets/osx/viewport.py b/mvc/widgets/osx/viewport.py
new file mode 100644
index 0000000..e6564d4
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/widgetset.py b/mvc/widgets/osx/widgetset.py
new file mode 100644
index 0000000..1203566
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/widgetupdates.py b/mvc/widgets/osx/widgetupdates.py
new file mode 100644
index 0000000..30677c2
--- /dev/null
+++ b/mvc/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/mvc/widgets/osx/window.py b/mvc/widgets/osx/window.py
new file mode 100644
index 0000000..b959333
--- /dev/null
+++ b/mvc/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 mvc import signals
+from mvc.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/mvc/widgets/osx/wrappermap.py b/mvc/widgets/osx/wrappermap.py
new file mode 100644
index 0000000..624a496
--- /dev/null
+++ b/mvc/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/mvc/widgets/tablescroll.py b/mvc/widgets/tablescroll.py
new file mode 100644
index 0000000..841e62c
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/tablescroll.pyc b/mvc/widgets/tablescroll.pyc
new file mode 100644
index 0000000..36d2206
--- /dev/null
+++ b/mvc/widgets/tablescroll.pyc
Binary files differ
diff --git a/mvc/widgets/tableselection.py b/mvc/widgets/tableselection.py
new file mode 100644
index 0000000..d087d34
--- /dev/null
+++ b/mvc/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 mvc.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/mvc/widgets/tableselection.pyc b/mvc/widgets/tableselection.pyc
new file mode 100644
index 0000000..76c1f10
--- /dev/null
+++ b/mvc/widgets/tableselection.pyc
Binary files differ
diff --git a/mvc/widgets/widgetconst.py b/mvc/widgets/widgetconst.py
new file mode 100644
index 0000000..bbb513c
--- /dev/null
+++ b/mvc/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/mvc/widgets/widgetconst.pyc b/mvc/widgets/widgetconst.pyc
new file mode 100644
index 0000000..79fafc9
--- /dev/null
+++ b/mvc/widgets/widgetconst.pyc
Binary files differ
diff --git a/mvc/widgets/widgetutil.py b/mvc/widgets/widgetutil.py
new file mode 100644
index 0000000..5fb3db2
--- /dev/null
+++ b/mvc/widgets/widgetutil.py
@@ -0,0 +1,225 @@
+from math import pi as PI
+from mvc.widgets import widgetset
+from mvc.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/mvc/widgets/widgetutil.pyc b/mvc/widgets/widgetutil.pyc
new file mode 100644
index 0000000..f2953ef
--- /dev/null
+++ b/mvc/widgets/widgetutil.pyc
Binary files differ
diff --git a/mvc/windows/__init__.py b/mvc/windows/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/mvc/windows/__init__.py
diff --git a/mvc/windows/autoupdate.py b/mvc/windows/autoupdate.py
new file mode 100644
index 0000000..8cb5f0a
--- /dev/null
+++ b/mvc/windows/autoupdate.py
@@ -0,0 +1,101 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2012
+# 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.
+
+"""Autoupdate functionality """
+
+import ctypes
+import _winreg as winreg
+import logging
+
+winsparkle = ctypes.cdll.WinSparkle
+
+APPCAST_URL = 'http://miro-updates.participatoryculture.org/mvc-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 mvc:
+ return open_or_create_key(mvc, "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 mvc_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/mvc/windows/exe_main.py b/mvc/windows/exe_main.py
new file mode 100755
index 0000000..bd171d3
--- /dev/null
+++ b/mvc/windows/exe_main.py
@@ -0,0 +1,22 @@
+# before anything else, settup logging
+from mvc.windows import exelogging
+exelogging.setup_logging()
+
+import os
+import sys
+
+from mvc import settings
+from mvc.windows import autoupdate
+from mvc.widgets import app
+from mvc.widgets import initialize
+from mvc.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/mvc/windows/exelogging.py b/mvc/windows/exelogging.py
new file mode 100644
index 0000000..a90fbfc
--- /dev/null
+++ b/mvc/windows/exelogging.py
@@ -0,0 +1,91 @@
+"""mvc.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/mvc/windows/specialfolders.py b/mvc/windows/specialfolders.py
new file mode 100644
index 0000000..2e1e7c6
--- /dev/null
+++ b/mvc/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')
diff --git a/run-windows.sh b/run-windows.sh
new file mode 100755
index 0000000..728a876
--- /dev/null
+++ b/run-windows.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+if [ ! -e mvc-env ] ; then
+ echo "LVC virtualenv is not present. Run "
+ echo
+ echo " python helperscripts/windows-virtualenv/ mvc-env"
+ echo
+ echo "to build it"
+ exit 1
+fi
+
+mvc-env/Scripts/python.exe setup.py py2exe && ./dist/mvcdebug.exe
diff --git a/scripts/libre-video-converter.py b/scripts/libre-video-converter.py
new file mode 100644
index 0000000..2404a28
--- /dev/null
+++ b/scripts/libre-video-converter.py
@@ -0,0 +1,10 @@
+#!/usr/bin/python
+
+try:
+ from mvc.ui.widgets import Application
+except ImportError:
+ from mvc.ui.console import Application
+from mvc.widgets import app
+from mvc.widgets import initialize
+app.widgetapp = Application()
+initialize(app.widgetapp)
diff --git a/setup-files/linux/debian-precise/changelog b/setup-files/linux/debian-precise/changelog
new file mode 100644
index 0000000..d0c742b
--- /dev/null
+++ b/setup-files/linux/debian-precise/changelog
@@ -0,0 +1,5 @@
+mirovideoconverter (3.0.2-1ubuntu1~ppa1~precise) precise; urgency=low
+
+ * Initial packaging
+
+ -- Ben Dean-Kawamura <ben@pculture.org> Thu, 27 Dec 2012 13:10:14 -0500
diff --git a/setup-files/linux/debian-precise/compat b/setup-files/linux/debian-precise/compat
new file mode 100644
index 0000000..7f8f011
--- /dev/null
+++ b/setup-files/linux/debian-precise/compat
@@ -0,0 +1 @@
+7
diff --git a/setup-files/linux/debian-precise/control b/setup-files/linux/debian-precise/control
new file mode 100644
index 0000000..c0bbda1
--- /dev/null
+++ b/setup-files/linux/debian-precise/control
@@ -0,0 +1,17 @@
+Source: librevideoconverter
+Maintainer: Jesús Eduardo <heckyel@openmailbox.org>
+Section: python
+Priority: optional
+Build-Depends: python-setuptools (>= 0.6b3),
+ python-all (>= 2.7),
+ debhelper (>= 7.4.3)
+Standards-Version: 3.9.3
+
+Package: mirovideoconverter
+Architecture: all
+Depends: ${misc:Depends},
+ ${python:Depends},
+ python-gtk2,
+ ffmpeg,
+ ffmpeg2theora
+Description: Simple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter.
diff --git a/setup-files/linux/debian-precise/copyright b/setup-files/linux/debian-precise/copyright
new file mode 100644
index 0000000..b5f4d88
--- /dev/null
+++ b/setup-files/linux/debian-precise/copyright
@@ -0,0 +1,38 @@
+This package was made for the PCF Ubuntu PPA by Ben Dean-Kawamura
+<ben@pculture.org> on Thu, 27 Dec 2012 13:10:14 -0500
+
+The current maintainer is Jesús Eduardo <heckyel@openmailbox.org>
+
+It was downloaded from:
+
+ https://notabug.org/Heckyel/LibreVideoConverter
+
+-----
+
+Files: *
+Copyright: © 2017 Jesús Eduardo (Heckyel)
+License: GPL-3+ | other
+ 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.
+
+On Debian systems, the complete text of the GNU General
+
+Files: debian/*
+Copyright: © 2017 Jesús Eduardo (Heckyel)
+License: GPL-3+ | other
+ 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.
+
+On Debian systems, the complete text of the GNU General
+Public License can be found in `/usr/share/common-licenses/GPL'.
+Public License can be found in `/usr/share/common-licenses/GPL'.
diff --git a/setup-files/linux/debian-precise/rules b/setup-files/linux/debian-precise/rules
new file mode 100755
index 0000000..1c69663
--- /dev/null
+++ b/setup-files/linux/debian-precise/rules
@@ -0,0 +1,7 @@
+#!/usr/bin/make -f
+
+# This file was automatically generated by stdeb 0.6.0+git at
+# Thu, 27 Dec 2012 13:08:13 -0500
+
+%:
+ dh $@ --with python2 --buildsystem=python_distutils
diff --git a/setup-files/linux/debian-precise/source/format b/setup-files/linux/debian-precise/source/format
new file mode 100644
index 0000000..163aaf8
--- /dev/null
+++ b/setup-files/linux/debian-precise/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/setup-files/linux/debian-quantal/changelog b/setup-files/linux/debian-quantal/changelog
new file mode 100644
index 0000000..77fb6fa
--- /dev/null
+++ b/setup-files/linux/debian-quantal/changelog
@@ -0,0 +1,5 @@
+librevideoconverter (3.0.2-1ubuntu1~ppa1~quantal) quantal; urgency=low
+
+ * Initial packaging
+
+ -- Jesús Eduardo <heckyel@openmailbox.org> Thu, 27 Dec 2017 13:10:14 -0500
diff --git a/setup-files/linux/debian-quantal/compat b/setup-files/linux/debian-quantal/compat
new file mode 100644
index 0000000..7f8f011
--- /dev/null
+++ b/setup-files/linux/debian-quantal/compat
@@ -0,0 +1 @@
+7
diff --git a/setup-files/linux/debian-quantal/control b/setup-files/linux/debian-quantal/control
new file mode 100644
index 0000000..fbe150e
--- /dev/null
+++ b/setup-files/linux/debian-quantal/control
@@ -0,0 +1,17 @@
+Source: librevideoconverter
+Maintainer: Jesús Eduardo <heckyel@openmailbox.org>
+Section: python
+Priority: optional
+Build-Depends: python-setuptools (>= 0.6b3),
+ python-all (>= 2.7),
+ debhelper (>= 7.4.3)
+Standards-Version: 3.9.3
+
+Package: librevideoconverter
+Architecture: all
+Depends: ${misc:Depends},
+ ${python:Depends},
+ python-gtk2,
+ ffmpeg
+ ffmpeg2theora
+Description: Simple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter.
diff --git a/setup-files/linux/debian-quantal/copyright b/setup-files/linux/debian-quantal/copyright
new file mode 100644
index 0000000..c7d1a25
--- /dev/null
+++ b/setup-files/linux/debian-quantal/copyright
@@ -0,0 +1,38 @@
+This package was made for the PCF Ubuntu PPA by Ben Dean-Kawamura
+<ben@pculture.org> on Thu, 27 Dec 2012 13:10:14 -0500
+
+The current maintainer is Jesús Eduardo <heckyel@openmailbox.org>
+
+It was downloaded from:
+
+ https://notabug.org/Heckyel/LibreVideoConverter
+
+-----
+
+Files: *
+Copyright: © 2017 Jesus Eduardo (Heckyel)
+License: GPL-2+ | other
+ 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.
+
+On Debian systems, the complete text of the GNU General
+
+Files: debian/*
+Copyright: © 2017 Jesus Eduardo (Heckyel)
+License: GPL-2+ | other
+ 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.
+
+On Debian systems, the complete text of the GNU General
+Public License can be found in `/usr/share/common-licenses/GPL'.
+Public License can be found in `/usr/share/common-licenses/GPL'.
diff --git a/setup-files/linux/debian-quantal/rules b/setup-files/linux/debian-quantal/rules
new file mode 100755
index 0000000..1c69663
--- /dev/null
+++ b/setup-files/linux/debian-quantal/rules
@@ -0,0 +1,7 @@
+#!/usr/bin/make -f
+
+# This file was automatically generated by stdeb 0.6.0+git at
+# Thu, 27 Dec 2012 13:08:13 -0500
+
+%:
+ dh $@ --with python2 --buildsystem=python_distutils
diff --git a/setup-files/linux/debian-quantal/source/format b/setup-files/linux/debian-quantal/source/format
new file mode 100644
index 0000000..163aaf8
--- /dev/null
+++ b/setup-files/linux/debian-quantal/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/setup-files/linux/icons/hicolor/16x16/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/16x16/apps/librevideoconverter.png
new file mode 100644
index 0000000..da270ee
--- /dev/null
+++ b/setup-files/linux/icons/hicolor/16x16/apps/librevideoconverter.png
Binary files differ
diff --git a/setup-files/linux/icons/hicolor/22x22/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/22x22/apps/librevideoconverter.png
new file mode 100644
index 0000000..fbf0467
--- /dev/null
+++ b/setup-files/linux/icons/hicolor/22x22/apps/librevideoconverter.png
Binary files differ
diff --git a/setup-files/linux/icons/hicolor/32x32/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/32x32/apps/librevideoconverter.png
new file mode 100644
index 0000000..c042992
--- /dev/null
+++ b/setup-files/linux/icons/hicolor/32x32/apps/librevideoconverter.png
Binary files differ
diff --git a/setup-files/linux/icons/hicolor/48x48/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/48x48/apps/librevideoconverter.png
new file mode 100644
index 0000000..24b7f8d
--- /dev/null
+++ b/setup-files/linux/icons/hicolor/48x48/apps/librevideoconverter.png
Binary files differ
diff --git a/setup-files/linux/librevideoconverter.desktop b/setup-files/linux/librevideoconverter.desktop
new file mode 100644
index 0000000..349bcb2
--- /dev/null
+++ b/setup-files/linux/librevideoconverter.desktop
@@ -0,0 +1,11 @@
+[Desktop Entry]
+Type=Application
+Name=Libre Video Converter
+GenericName=Media player
+Comment=A siple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter.
+Comment[es]=Un convertidor simple de vídeo para los formatos Webm (vp8), Ogg Theora, MP4 y otros, basado en Miro Video Converter.
+Icon=librevideoconverter
+TryExec=libre-video-converter.py
+Exec=libre-video-converter.py %F
+Terminal=false
+Categories=AudioVideo;AudioVideoEditing; \ No newline at end of file
diff --git a/setup-files/linux/setup.py b/setup-files/linux/setup.py
new file mode 100644
index 0000000..841e86a
--- /dev/null
+++ b/setup-files/linux/setup.py
@@ -0,0 +1,95 @@
+import glob
+import os
+import shutil
+import subprocess
+import sys
+from distutils.cmd import Command
+from setuptools import setup
+
+if sys.version < '2.7':
+ raise RuntimeError('LVC requires Python 2.7')
+
+def icon_data_files():
+ sizes = [16, 22, 32, 48]
+ data_files = []
+ for size in sizes:
+ d = os.path.join("icons", "hicolor", "%sx%s" % (size, size), "apps")
+ source = os.path.join(SETUP_DIR, d, "librevideoconverter.png")
+ dest = os.path.join("/usr/share/", d)
+ data_files.append((dest, [source]))
+
+ return data_files
+
+def application_data_files():
+ return [
+ ('/usr/share/applications',
+ [os.path.join(SETUP_DIR, 'librevideoconverter.desktop')]),
+ ]
+
+def data_files():
+ return application_data_files() + icon_data_files()
+
+class sdist_deb(Command):
+ description = ("Build a debian source package.")
+ user_options = [
+ ('dist-dir=', 'd',
+ "directory to put the source distribution archive(s) in "
+ "[default: dist]"),
+ ]
+
+ def initialize_options(self):
+ self.dist_dir = None
+
+ def finalize_options(self):
+ if self.dist_dir is None:
+ self.dist_dir = 'dist'
+ self.dist_dir = os.path.abspath(self.dist_dir)
+
+ def run(self):
+ self.run_command("sdist")
+ self.setup_dirs()
+ for debian_dir in glob.glob(os.path.join(SETUP_DIR, 'debian-*')):
+ self.build_for_release(debian_dir)
+ os.chdir(self.orig_dir)
+ print
+ print "debian source build complete"
+ print "files are in %s" % self.work_dir
+
+ def build_for_release(self, debian_dir):
+ os.chdir(self.work_dir)
+ source_tree = os.path.join(self.work_dir,
+ 'librevideoconverter-%s' % VERSION)
+ if os.path.exists(source_tree):
+ shutil.rmtree(source_tree)
+ self.extract_tarball()
+ self.copy_debian_directory(debian_dir)
+ os.chdir('librevideoconverter-%s' % VERSION)
+ subprocess.check_call(['dpkg-buildpackage', '-S'])
+
+ def setup_dirs(self):
+ self.orig_dir = os.getcwd()
+ self.work_dir = os.path.join(self.dist_dir, 'deb')
+ if os.path.exists(self.work_dir):
+ shutil.rmtree(self.work_dir)
+ os.makedirs(self.work_dir)
+
+ def extract_tarball(self):
+ tarball = os.path.join(self.dist_dir,
+ "librevideoconverter-%s.tar.gz" % VERSION)
+ subprocess.check_call(["tar", "zxf", tarball])
+ shutil.copyfile(tarball,
+ "librevideoconverter_%s.orig.tar.gz" % VERSION)
+
+ def copy_debian_directory(self, debian_dir):
+ dest = os.path.join(self.work_dir,
+ 'librevideoconverter-%s/debian' % VERSION)
+ shutil.copytree(debian_dir, dest)
+
+setup(
+ cmdclass={
+ 'sdist_deb': sdist_deb,
+ },
+ data_files=data_files(),
+ scripts=['scripts/libre-video-converter.py'],
+ **SETUP_ARGS
+)
diff --git a/setup-files/osx/Info.plist b/setup-files/osx/Info.plist
new file mode 100644
index 0000000..bf674a1
--- /dev/null
+++ b/setup-files/osx/Info.plist
@@ -0,0 +1,406 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDocumentTypes</key>
+ <array>
+ <!--
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>miro</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeMIMETypes</key>
+ <array>
+ <string>application/x-miro</string>
+ </array>
+ <key>CFBundleTypeName</key>
+ <string>Miro One Click Subscribe Content</string>
+ <key>CFBundleTypeOSTypes</key>
+ <array>
+ <string>TEXT</string>
+ </array>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>democracy</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeMIMETypes</key>
+ <array>
+ <string>application/x-democracy</string>
+ </array>
+ <key>CFBundleTypeName</key>
+ <string>Democracy One Click Subscribe Content</string>
+ <key>CFBundleTypeOSTypes</key>
+ <array>
+ <string>TEXT</string>
+ </array>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>rdf</string>
+ <string>rss</string>
+ <string>atom</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string>check.icns</string>
+ <key>CFBundleTypeMIMETypes</key>
+ <array>
+ <string>application/rdf+xml</string>
+ <string>application/rss+xml</string>
+ <string>application/atom+xml</string>
+ </array>
+ <key>CFBundleTypeName</key>
+ <string>Syndicated Content</string>
+ <key>CFBundleTypeOSTypes</key>
+ <array>
+ <string>TEXT</string>
+ </array>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>torrent</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeMIMETypes</key>
+ <array>
+ <string>application/x-bittorrent</string>
+ </array>
+ <key>CFBundleTypeName</key>
+ <string>BitTorrent Metainfo</string>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>avi</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>AVI container</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>mov</string>
+ <string>moov</string>
+ <string>qt</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Apple QuickTime container</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>3gp</string>
+ <string>3g2</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>3GPP Movie</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>asf</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Advanced Streaming Format</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>flv</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Flash Video Movie</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>webm</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>WebM Video Movie</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>gvi</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Google Video</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>wmv</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Windows Media Video</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>mpg</string>
+ <string>mpeg</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>multiplexed MPEG-1/2</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>mp4</string>
+ <string>m4v</string>
+ <string>m4a</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>MPEG-4 File</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>ogg</string>
+ <string>ogm</string>
+ <string>ogv</string>
+ <string>oga</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>OGG Media File</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>mkv</string>
+ <string>mka</string>
+ <string>mks</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Matroska File</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>mp3</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>MPEG-1 Audio Layer 3</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>m4a</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Audio only MPEG 4</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>wma</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Windows Media Audio</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>mka</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Matroska Audio</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>rmvb</string>
+ <string>rm</string>
+ <string>ram</string>
+ <string>ra</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>RealMedia</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>divx</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>DivX Video</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ <dict>
+ <key>CFBundleTypeExtensions</key>
+ <array>
+ <string>flac</string>
+ </array>
+ <key>CFBundleTypeIconFile</key>
+ <string></string>
+ <key>CFBundleTypeName</key>
+ <string>Free Lossless Audio Codec</string>
+ <key>CFBundleTypeRole</key>
+ <string>Viewer</string>
+ </dict>
+ -->
+ </array>
+ <key>CFBundleGetInfoString</key>
+ <string>$appVersion ($appRevision), $copyright $publisher</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.participatoryculture.$shortAppName</string>
+ <key>CFBundleExecutable</key>
+ <string>$shortAppName</string>
+ <key>CFBundleName</key>
+ <string>$shortAppName</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$appVersion</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <!--
+ <dict>
+ <key>CFBundleURLTypes</key>
+ <key>CFBundleURLName</key>
+ <string>BitTorrent Magnet URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>magnet</string>
+ </array>
+ </dict>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>Miro URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>miro</string>
+ </array>
+ </dict>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>Democracy URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>democracy</string>
+ </array>
+ </dict>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>RSS URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>feed</string>
+ </array>
+ </dict>
+ <dict>
+ <key>CFBundleURLName</key>
+ <string>HTTP URL</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>http</string>
+ <string>https</string>
+ </array>
+ </dict>
+ -->
+ </array>
+ <key>CFBundleVersion</key>
+ <string>$appVersion</string>
+ <key>LSMinimumSystemVersion</key>
+ <string>10.6</string>
+ <key>NSHumanReadableCopyright</key>
+ <string>$copyright, $publisher</string>
+ <key>SUEnableAutomaticChecks</key>
+ <false/>
+ <key>SUAllowsAutomaticUpdates</key>
+ <false/>
+ <key>SUFeedURL</key>
+ <string></string>
+ <key>SUScheduledCheckInterval</key>
+ <integer>0</integer>
+ <key>LSApplicationCategoryType</key>
+ <string>public.app-category.video</string>
+</dict>
+</plist>
diff --git a/setup-files/osx/mvc3.icns b/setup-files/osx/mvc3.icns
new file mode 100644
index 0000000..a56fd4d
--- /dev/null
+++ b/setup-files/osx/mvc3.icns
Binary files differ
diff --git a/setup-files/osx/mvc3_definition.plist b/setup-files/osx/mvc3_definition.plist
new file mode 100644
index 0000000..e14b95a
--- /dev/null
+++ b/setup-files/osx/mvc3_definition.plist
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>arch</key>
+ <array>
+ <string>i386</string>
+ <string>x86_64</string>
+ </array>
+ <key>os</key>
+ <array>
+ <string>10.6</string>
+ </array>
+</dict>
+</plist>
diff --git a/setup-files/osx/setup.py b/setup-files/osx/setup.py
new file mode 100644
index 0000000..cddca4c
--- /dev/null
+++ b/setup-files/osx/setup.py
@@ -0,0 +1,123 @@
+"""
+This is a setup.py script generated by py2applet
+
+Usage:
+ python2.7 setup.py py2app
+"""
+import sys
+if sys.version < '2.7':
+ raise RuntimeError('MVC requires Python 2.7')
+import glob
+import os
+import plistlib
+import shutil
+import subprocess
+
+from setuptools import setup
+from setuptools.extension import Extension
+
+from distutils.file_util import copy_file
+from distutils.dir_util import mkpath
+
+from py2app.build_app import py2app as py2app_cmd
+
+APP = ['mvc/osx/app_main.py']
+DATA_FILES = ['mvc/widgets/osx/Resources-Widgets/MainMenu.nib']
+OPTIONS = {
+ 'iconfile': os.path.join(SETUP_DIR, 'mvc3.icns'),
+ 'excludes': ['mvc.widgets.gtk'],
+ 'includes': ['mvc.widgets.osx.fasttypes'],
+ 'packages': ['mvc', 'mvc.widgets', 'mvc.widgets.osx', 'mvc.ui',
+ 'mvc.qtfaststart', 'mvc.resources']
+}
+
+# this should work if run from build.sh
+BKIT_DIR = os.environ['BKIT_PATH']
+
+def copy_binaries(source, target, binaries):
+ mkpath(target)
+ for mem in binaries:
+ src = os.path.join(BKIT_DIR, source, mem)
+ if os.path.islink(src):
+ dst = os.path.join(target, mem)
+ linkto = os.readlink(src)
+ if os.path.exists(dst):
+ os.remove(dst)
+ os.symlink(linkto, dst)
+ else:
+ copy_file(src, target, update=True)
+
+def extract_tarball(tar_file, target_directory):
+ subprocess.check_call(["tar", "-C", target_directory, "-zxf", tar_file])
+
+class py2app_mvc(py2app_cmd):
+ def run(self):
+ py2app_cmd.run(self)
+ self.setup_directories()
+ self.copy_ffmpeg()
+ self.copy_sparkle()
+ self.copy_update_public_key()
+ self.delete_site_py()
+
+ def setup_directories(self):
+ self.bundle_root = os.path.join(self.dist_dir,
+ 'Miro Video Converter.app/Contents')
+ self.helpers_root = os.path.join(self.bundle_root, 'Helpers')
+ self.frameworks_root = os.path.join(self.bundle_root, 'Frameworks')
+ self.resources_root = os.path.join(self.bundle_root, 'Resources')
+ self.python_lib_root = os.path.join(self.resources_root, 'lib',
+ 'python2.7')
+
+ if os.path.exists(self.helpers_root):
+ shutil.rmtree(self.helpers_root)
+ os.mkdir(self.helpers_root)
+
+ def copy_ffmpeg(self):
+ ffmpeg_files = ["ffmpeg"]
+ lib_paths = glob.glob(os.path.join(BKIT_DIR, "ffmpeg", "bin", "*.dylib"))
+ ffmpeg_files.extend(os.path.basename(p) for p in lib_paths)
+ copy_binaries('ffmpeg/bin/', self.helpers_root, ffmpeg_files)
+
+ def copy_sparkle(self):
+ tarball = os.path.join(BKIT_DIR, "frameworks", "sparkle.1.5b6.tar.gz")
+ extract_tarball(tarball, self.frameworks_root)
+
+ def copy_update_public_key(self):
+ src = os.path.join(SETUP_DIR, "sparkle-keys", "dsa_pub.pem")
+ target = os.path.join(self.resources_root, 'dsa_pub.pem')
+ copy_file(src, target, update=True)
+
+ def delete_site_py(self):
+ """Delete the site.py symlink.
+
+ This causes issues with codesigning on 10.8 -- possibly because it's a
+ symlink that links to a location outside the resources dir. In any
+ case, it's not needed, so let's just nuke it.
+ """
+ os.unlink(os.path.join(self.python_lib_root, 'site.py'))
+
+plist = plistlib.readPlist(os.path.join(SETUP_DIR, 'Info.plist'))
+plist['NSHumanReadableCopyright'] = 'Copyright (C) Participatory Culture Foundation'
+plist['CFBundleGetInfoString'] = 'Miro Video Converter'
+plist['CFBundleIdentifier'] = 'org.participatoryculture.MiroVideoConverter'
+plist['CFBundleShortVersionString'] = '3.0'
+plist['CFBundleExecutable'] = 'Miro Video Converter'
+plist['CFBundleName'] = 'Miro Video Converter'
+plist['CFBundleVersion'] = '3.0'
+plist['SUFeedURL'] = ('http://miro-updates.participatoryculture.org/'
+ 'mvc-appcast-osx.xml')
+plist['SUPublicDSAKeyFile'] = 'dsa_pub.pem'
+
+OPTIONS['plist'] = plist
+
+setup(
+ app=APP,
+ data_files=DATA_FILES,
+ options={'py2app': OPTIONS},
+ setup_requires=['py2app'],
+ cmdclass={'py2app': py2app_mvc},
+ ext_modules=[
+ Extension("mvc.widgets.osx.fasttypes",
+ [os.path.join(ROOT_DIR, 'mvc', 'widgets', 'osx', 'fasttypes.c')])],
+ **SETUP_ARGS
+ )
diff --git a/setup-files/osx/sparkle-keys/dsa_pub.pem b/setup-files/osx/sparkle-keys/dsa_pub.pem
new file mode 100644
index 0000000..190e191
--- /dev/null
+++ b/setup-files/osx/sparkle-keys/dsa_pub.pem
@@ -0,0 +1,20 @@
+-----BEGIN PUBLIC KEY-----
+MIIDOzCCAi4GByqGSM44BAEwggIhAoIBAQD1EH4NkdNMtS7VHod9Fz96NGxa0Zg8
+DrW4/GTJ5VgauPaRX1HzC2C1urCc05wW1J1sGXT+PupcL5+u+cmSHrVMtR6w90Hg
+eZCHiDZHpLJ14tsAB1SZZ6krEZOfQ1Bx5ON3/0qeeXSFgmVku/qzMogvya3O4kYF
+GrBqWCAsTymCUw9vbq3TuPEo22VTLJdl9uM4FqDczfQgRenH31buj349lLEfWxjk
+qkh4Jd+rm48cqGmWSGi7GB63Ydet+5RmStJnEOGMC4LuYYBHHKG+OmgZ9fN6aryf
+9zWSjgouvdbPmJCa5fT/wxUUdxerK3gHvEZeOcZo61Lyqt1D9lgcLjAhAhUAqO1/
+r+wkLSrL+yOCIA0CSJtGMz0CggEBAKCmWZpJNRLwyRmLfBmgNdaz7yNTG1XnRymk
+Ogrnj3ht69qQ1KA2qKLl4n03vIiJT3gCv3RcT2zklnIS1rszplKhhvZuH/6fDC3q
+SP2EAyKrDQQmqEPghSCFfDtB+qIf/0/VcYJDBZB2WBQr9qa2SUwoqsC/18CBQo+H
+YZQTfd92vqAspoNElKymJGqlbJarcDoSpkVbB304pGZcrKBCFapZ3f36dQpUwOcQ
+UmDGakKN9pDCvaqTOcKkzC8Q/4c4c1jfOtOblDdzafFMj8KPcFOlWXsifuk/DAA8
+yDRSGICjTfof0qpAgVeQFD6TJ9ng3XrVbc6TMhUsm16xKs/z4w0DggEFAAKCAQBw
+Ouduhl50dvpm34IRrs3zitcIeDkmen5wKRun26xup65TOHFmAzJxg1H8ZPaa1QSl
+b7ZtiHJggiHEXvjNdx065tz6MIxDEmZBoGf0J9at3AbjXGPf6ZHh/v0evlgPzqq3
+zVCtutPya1Dvu8HfnPmetLN+5haSekCTHRRakJ2JL5LhTe/BhRWgVg4YcFBfuomG
+m0OGTHVnp5C/vI6f28AKxFK5DVu7SEoyqPjHIxT0eoHDKae0z+I4Nb+XfObmwnQi
+ORmm8oFccTcf1+N5zwSIpemiFVu6M6NzNPJfKuF06AYZWJDIMpRQbAuLFzSLgzEq
+Hon4DPjscgbd7IWPu4yK
+-----END PUBLIC KEY-----
diff --git a/setup-files/windows/mvc.nsi b/setup-files/windows/mvc.nsi
new file mode 100755
index 0000000..638d421
--- /dev/null
+++ b/setup-files/windows/mvc.nsi
@@ -0,0 +1,195 @@
+; Passed in from command line:
+!define CONFIG_VERSION "0.8.0"
+
+; TODO: Add MIROBAR_EXE
+!define CONFIG_PROJECT_URL "http://www.mirovideoconverter.com/"
+!define CONFIG_SHORT_APP_NAME "MVC"
+!define CONFIG_LONG_APP_NAME "Libre Video Converter"
+!define CONFIG_PUBLISHER "Participatory Culture Foundation"
+!define CONFIG_ICON "mvc-logo.ico"
+!define CONFIG_EXECUTABLE "mvc.exe"
+!define CONFIG_OUTPUT_FILE "MiroVideoConverter.exe"
+
+!define INST_KEY "Software\${CONFIG_PUBLISHER}\${CONFIG_LONG_APP_NAME}"
+!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${CONFIG_LONG_APP_NAME}"
+
+!define UNINSTALL_SHORTCUT "Uninstall ${CONFIG_LONG_APP_NAME}.lnk"
+!define MUI_ICON "${CONFIG_ICON}"
+!define MUI_UNICON "${CONFIG_ICON}"
+
+;INCLUDES
+!addplugindir "${CONFIG_PLUGIN_DIR}"
+!addincludedir "${CONFIG_PLUGIN_DIR}"
+
+!include "MUI2.nsh"
+!include "nsProcess.nsh"
+
+!define PRODUCT_NAME "${CONFIG_LONG_APP_NAME}"
+
+;GENERAL SETTINGS
+Name "${CONFIG_LONG_APP_NAME}"
+OutFile "${CONFIG_OUTPUT_FILE}"
+InstallDir "$PROGRAMFILES\${CONFIG_PUBLISHER}\${CONFIG_LONG_APP_NAME}"
+InstallDirRegKey HKLM "${INST_KEY}" "Install_Dir"
+SetCompressor lzma
+
+SetOverwrite on
+CRCCheck on
+
+Icon "${CONFIG_ICON}"
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Macros
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+Function LaunchLink
+ SetShellVarContext all
+ ExecShell "" "$INSTDIR\${CONFIG_EXECUTABLE}"
+FunctionEnd
+
+Function CreateDesktopShortcut
+ CreateShortCut "$DESKTOP\${CONFIG_LONG_APP_NAME}.lnk" \
+ "$INSTDIR\${CONFIG_EXECUTABLE}" "" "$INSTDIR\${CONFIG_ICON}"
+FunctionEnd
+
+Function .onInit
+TestRunning:
+ ${nsProcess::FindProcess} ${CONFIG_EXECUTABLE} $R0
+ StrCmp $R0 0 0 NotRunning
+ MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \
+ "It looks like you're already running ${CONFIG_LONG_APP_NAME}.$\n\
+ Please shut it down before continuing." \
+ IDOK TestRunning
+ Goto TestRunning
+NotRunning:
+
+FunctionEnd
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Sections ;;
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+Section "-${CONFIG_LONG_APP_NAME}" COM2
+ SectionIn RO
+ ClearErrors
+ SetShellVarContext all
+
+ SetOutPath "$INSTDIR"
+
+ File ${CONFIG_ICON}
+ File *.pyd
+ File *.dll
+ File library.zip
+ File ${CONFIG_EXECUTABLE}
+ SetOutPath "$INSTDIR\ffmpeg"
+ File /r ffmpeg\*.*
+ # SetOutPath "$INSTDIR\avconv"
+ # File /r avconv\*.*
+ SetOutPath "$INSTDIR\resources\converters"
+ File resources\converters\*.*
+ SetOutPath "$INSTDIR\resources\images"
+ File resources\images\*.*
+ SetOutPath "$INSTDIR\etc\gtk-2.0"
+ File etc\gtk-2.0\gtkrc
+ SetOutPath "$INSTDIR\lib\gtk-2.0\2.10.0\engines"
+ File lib\gtk-2.0\2.10.0\engines\libclearlooks.dll
+
+ IfErrors 0 files_ok
+
+ MessageBox MB_OK|MB_ICONEXCLAMATION "Installation failed. An error occured writing to the ${CONFIG_LONG_APP_NAME} Folder."
+ Quit
+files_ok:
+ CreateDirectory "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}"
+ CreateShortCut "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${CONFIG_LONG_APP_NAME}.lnk" \
+ "$INSTDIR\${CONFIG_EXECUTABLE}" "" "$INSTDIR\${CONFIG_ICON}"
+ CreateShortCut "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${UNINSTALL_SHORTCUT}" \
+ "$INSTDIR\uninstall.exe"
+
+SectionEnd
+
+Section -Post
+ WriteUninstaller "$INSTDIR\uninstall.exe"
+ WriteRegStr HKLM "${INST_KEY}" "InstallDir" $INSTDIR
+ WriteRegStr HKLM "${INST_KEY}" "Version" "${CONFIG_VERSION}"
+ WriteRegStr HKLM "${INST_KEY}" "" "$INSTDIR\${CONFIG_EXECUTABLE}"
+
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "$(^Name)"
+ WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${CONFIG_EXECUTABLE}"
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${CONFIG_VERSION}"
+ WriteRegStr HKLM "${UNINST_KEY}" "URLInfoAbout" "${CONFIG_PROJECT_URL}"
+ WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${CONFIG_PUBLISHER}"
+
+SectionEnd
+
+Section "Uninstall" SEC91
+
+ SetShellVarContext all
+
+ Delete "$INSTDIR\uninstall.exe"
+ Delete "$DESKTOP\Libre Video Converter.lnk"
+ Delete "$INSTDIR\${CONFIG_ICON}"
+ Delete "$INSTDIR\*.pyd"
+ Delete "$INSTDIR\*.dll"
+ Delete "$INSTDIR\library.zip"
+ Delete "$INSTDIR\${CONFIG_EXECUTABLE}"
+ Delete "$INSTDIR\resources\converters\*.*"
+ Delete "$INSTDIR\resources\images\*.*"
+ Delete "$INSTDIR\etc\gtk-2.0\gtkrc"
+ Delete "$INSTDIR\lib\gtk-2.0\2.10.0\engines\libclearlooks.dll"
+ # RMDir /r "$INSTDIR\avconv"
+ RMDir /r "$INSTDIR\ffmpeg"
+ RMDir "$INSTDIR\lib\gtk-2.0\2.10.0\engines\"
+ RMDir "$INSTDIR\lib\gtk-2.0\2.10.0\"
+ RMDir "$INSTDIR\lib\gtk-2.0\"
+ RMDir "$INSTDIR\lib\"
+ RMDir "$INSTDIR\etc\gtk-2.0"
+ RMDir "$INSTDIR\etc"
+ RMDir "$INSTDIR\resources\converters"
+ RMDir "$INSTDIR\resources\images"
+ RMDir "$INSTDIR\resources"
+ RMDir "$INSTDIR"
+ RMDIR "$PROGRAMFILES\${CONFIG_PUBLISHER}"
+
+ RMDir "$PROGRAMFILES\${CONFIG_PUBLISHER}"
+
+ ; Remove Start Menu shortcuts
+ Delete "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${UNINSTALL_SHORTCUT}"
+ Delete "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${CONFIG_LONG_APP_NAME}.lnk"
+ RMDir "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}"
+
+ SetAutoClose true
+SectionEnd
+
+;PAGE SETUP
+!define MUI_ABORTWARNING ;a confirmation message should be displayed if the user clicks cancel
+
+!define MUI_WELCOMEFINISHPAGE_BITMAP "modern-wizard.bmp"
+!insertmacro MUI_PAGE_WELCOME ;welcome page
+!insertmacro MUI_PAGE_INSTFILES ;install files page
+; Finish page
+!define MUI_FINISHPAGE_RUN
+!define MUI_FINISHPAGE_TITLE "${CONFIG_LONG_APP_NAME} has been installed!"
+!define MUI_FINISHPAGE_TITLE_3LINES
+!define MUI_FINISHPAGE_RUN_TEXT "Run ${CONFIG_LONG_APP_NAME}"
+!define MUI_FINISHPAGE_RUN_FUNCTION "LaunchLink"
+!define MUI_FINISHPAGE_LINK "${CONFIG_PUBLISHER} homepage."
+!define MUI_FINISHPAGE_LINK_LOCATION "${CONFIG_PROJECT_URL}"
+!define MUI_FINISHPAGE_NOREBOOTSUPPORT
+; hijack the showreadme checkbox and use it for the desktop shortcut
+!define MUI_FINISHPAGE_SHOWREADME ""
+!define MUI_FINISHPAGE_SHOWREADME_CHECKED
+!define MUI_FINISHPAGE_SHOWREADME_TEXT "Create Desktop Shortcut"
+!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut
+!insertmacro MUI_PAGE_FINISH
+
+; Uninstaller pages
+!insertmacro MUI_UNPAGE_CONFIRM
+
+!insertmacro MUI_UNPAGE_INSTFILES
+!define MUI_UNWELCOMEFINISHPAGE_BITMAP "modern-wizard.bmp"
+!insertmacro MUI_UNPAGE_FINISH
+
+;LANGUAGE FILES
+!define MUI_LANGSTRINGS
+!insertmacro MUI_LANGUAGE "English"
diff --git a/setup-files/windows/setup.py b/setup-files/windows/setup.py
new file mode 100644
index 0000000..663acd9
--- /dev/null
+++ b/setup-files/windows/setup.py
@@ -0,0 +1,146 @@
+"""setup-windows.py -- setup script for windows
+
+Usage:
+
+"""
+from distutils import log
+from distutils.core import Command, setup
+from glob import glob
+import itertools
+import os
+import subprocess
+import sys
+
+import py2exe
+
+from mvc import resources
+
+env_path = os.path.abspath(os.path.dirname(os.path.dirname(sys.executable)))
+nsis_path = os.path.join(env_path, 'nsis-2.46', 'makensis.exe')
+scripts_path = os.path.join(env_path, 'Scripts')
+
+packages = [
+ 'mvc',
+ 'mvc.widgets',
+ 'mvc.widgets.gtk',
+ 'mvc.ui',
+ 'mvc.resources',
+ 'mvc.windows',
+ 'mvc.qtfaststart',
+]
+
+def resources_dir():
+ return os.path.dirname(resources.__file__)
+
+def resource_data_files(subdir, globspec='*.*'):
+ dest_dir = os.path.join("resources", subdir)
+ dir_contents = glob(os.path.join(resources_dir(), subdir, globspec))
+ return [(dest_dir, dir_contents)]
+
+def ffmpeg_data_files():
+ ffmpeg_dir = os.path.join(env_path, 'ffmpeg')
+ return [
+ ('ffmpeg',
+ [os.path.join(ffmpeg_dir, 'ffmpeg.exe')]),
+ #('ffmpeg/presets',
+ #glob(os.path.join(ffmpeg_dir, 'presets', '*.ffpreset'))),
+ ]
+
+def winsparkle_data_files():
+ winsparkle_dll = os.path.join(env_path, 'WinSparkle-0.3',
+ "WinSparkle.dll")
+ return [
+ ('', [winsparkle_dll]),
+ ]
+
+def gtk_theme_data_files():
+ engine_path = os.path.join(env_path, 'gtk2-themes-2009-09-07-win32_bin',
+ 'lib', 'gtk-2.0', '2.10.0', 'engines', 'libclearlooks.dll')
+ gtkrc_path = os.path.join(resources_dir(), 'windows', 'gtkrc')
+ return [
+ ('etc/gtk-2.0', [gtkrc_path]),
+ ('lib/gtk-2.0/2.10.0/engines', [engine_path])
+ ]
+
+def avconv_data_files():
+ avconv_dir = os.path.join(env_path, 'avconv')
+ return [
+ ('avconv',
+ glob(os.path.join(avconv_dir, '*.*'))),
+ ]
+
+def data_files():
+ return list(itertools.chain(
+ resource_data_files("images"),
+ resource_data_files("converters", "*.py"),
+ ffmpeg_data_files(),
+ winsparkle_data_files(),
+ gtk_theme_data_files(),
+ #avconv_data_files(),
+ ))
+
+def gtk_includes():
+ return ['gtk', 'gobject', 'atk', 'pango', 'pangocairo', 'gio']
+
+def py2exe_includes():
+ return gtk_includes()
+
+class bdist_nsis(Command):
+ description = "create MVC installer using NSIS"
+ user_options = [
+ ]
+
+ def initialize_options(self):
+ pass
+
+ def finalize_options(self):
+ pass
+
+ def run(self):
+ self.run_command('py2exe')
+ self.dist_dir = self.get_finalized_command('py2exe').dist_dir
+
+ log.info("building installer")
+
+ nsis_source = os.path.join(SETUP_DIR, 'mvc.nsi')
+ self.copy_file(nsis_source, self.dist_dir)
+ for nsis_file in glob(os.path.join(resources_dir(), 'nsis', '*.*')):
+ self.copy_file(nsis_file, self.dist_dir)
+
+ plugins_dir = os.path.join(resources_dir(), 'nsis', 'plugins')
+ script_path = os.path.join(self.dist_dir, 'mvc.nsi')
+ nsis_defines = {
+ 'CONFIG_PLUGIN_DIR': plugins_dir,
+ }
+ cmd_line = [nsis_path]
+ for name, value in nsis_defines.items():
+ cmd_line.append("/D%s=%s" % (name, value))
+ cmd_line.append(script_path)
+
+ if subprocess.call(cmd_line) != 0:
+ print "ERROR creating the 1 stage installer, quitting"
+ return
+setup(
+ windows=[
+ {'script': 'mvc/windows/exe_main.py',
+ 'dest_base': 'mvc',
+ 'company_name': 'Participatory Culture Foundation',
+ },
+ ],
+ console=[
+ {'script': 'mvc/windows/exe_main.py',
+ 'dest_base': 'mvcdebug',
+ 'company_name': 'Participatory Culture Foundation',
+ },
+ ],
+ data_files=data_files(),
+ cmdclass={
+ 'bdist_nsis': bdist_nsis,
+ },
+ options={
+ 'py2exe': {
+ 'includes': py2exe_includes(),
+ },
+ },
+ **SETUP_ARGS
+)
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..dc8d6ee
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,53 @@
+import os
+import sys
+
+version = '3.0.2'
+
+# platform-independent arguments for setup()
+setup_args = {
+ 'name': 'librevideoconverter',
+ 'description': 'Simple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter',
+ 'author': 'Jesus Eduardo (Heckyel)',
+ 'author_email': 'heckyel@openmailbox.org',
+ 'url': 'https://notabug.org/Heckyel/LibreVideoConverter',
+ 'license': 'GPL',
+ 'version': version,
+ 'packages': [
+ 'mvc',
+ 'mvc.osx',
+ 'mvc.qtfaststart',
+ 'mvc.resources',
+ 'mvc.ui',
+ 'mvc.widgets',
+ 'mvc.widgets.gtk',
+ 'mvc.widgets.osx',
+ 'mvc.windows',
+ ],
+ 'package_data': {
+ 'mvc.resources': [
+ 'converters/*.py',
+ 'images/*.*',
+ ],
+ },
+}
+
+if sys.platform.startswith("linux"):
+ platform = 'linux'
+elif sys.platform.startswith("win32"):
+ platform = 'windows'
+elif sys.platform.startswith("darwin"):
+ platform = 'osx'
+else:
+ sys.stderr.write("Unknown platform: %s" % sys.platform)
+
+root_dir = os.path.abspath(os.path.dirname(__file__))
+setup_dir = os.path.join(root_dir, 'setup-files', platform)
+
+script_vars = {
+ 'VERSION': version,
+ 'ROOT_DIR': root_dir,
+ 'SETUP_DIR': setup_dir,
+ 'SETUP_ARGS': setup_args,
+}
+
+execfile(os.path.join(setup_dir, 'setup.py'), script_vars)
diff --git a/sign.sh b/sign.sh
new file mode 100644
index 0000000..b1eb0ad
--- /dev/null
+++ b/sign.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+
+for i in "dist/Libre Video Converter.app/Contents/Helpers"/*
+do
+ codesign -fs \
+ 'Fredom Operating System: Heckyel | 2017' \
+ "${i}"
+done
+
+# Fix up the python framework installation from py2app: it doesn't
+# copy over all the files that constitutes it to be a valid framework.
+pushd "dist/Libre Video Converter.app/Contents/Frameworks/Python.framework"
+ln -sf Versions/Current/Python Python
+ln -sf Versions/Current/Resources Resources
+ln -sf 2.7 Versions/Current
+popd
+
+codesign -fs \
+ '3rd Party Mac Developer Application: Participatory Culture Foundation' \
+ "dist/Libre Video Converter.app/Contents/Frameworks/Python.framework/Versions/2.7"
+
+codesign -fs \
+ '3rd Party Mac Developer Application: Participatory Culture Foundation' \
+ "dist/Libre Video Converter.app/Contents/Frameworks/Sparkle.framework/Versions/A"
+
+codesign -fs \
+ '3rd Party Mac Developer Application: Participatory Culture Foundation' \
+ "dist/Libre Video Converter.app"
diff --git a/test/base.py b/test/base.py
new file mode 100644
index 0000000..397274e
--- /dev/null
+++ b/test/base.py
@@ -0,0 +1,7 @@
+import os.path
+import unittest
+
+class Test(unittest.TestCase):
+
+ def setUp(self):
+ self.testdata_dir = os.path.join(os.path.dirname(__file__), 'testdata')
diff --git a/test/mock.py b/test/mock.py
new file mode 100644
index 0000000..93b90d8
--- /dev/null
+++ b/test/mock.py
@@ -0,0 +1,2356 @@
+# mock.py
+# Test tools for mocking and patching.
+# Copyright (C) 2007-2012 Michael Foord & the mock team
+# E-mail: fuzzyman AT voidspace DOT org DOT uk
+
+# mock 1.0
+# http://www.voidspace.org.uk/python/mock/
+
+# Released subject to the BSD License
+# Please see http://www.voidspace.org.uk/python/license.shtml
+
+# Scripts maintained at http://www.voidspace.org.uk/python/index.shtml
+# Comments, suggestions and bug reports welcome.
+
+
+__all__ = (
+ 'Mock',
+ 'MagicMock',
+ 'patch',
+ 'sentinel',
+ 'DEFAULT',
+ 'ANY',
+ 'call',
+ 'create_autospec',
+ 'FILTER_DIR',
+ 'NonCallableMock',
+ 'NonCallableMagicMock',
+ 'mock_open',
+ 'PropertyMock',
+)
+
+
+__version__ = '1.0b1'
+
+
+import pprint
+import sys
+
+try:
+ import inspect
+except ImportError:
+ # for alternative platforms that
+ # may not have inspect
+ inspect = None
+
+try:
+ from functools import wraps
+except ImportError:
+ # Python 2.4 compatibility
+ def wraps(original):
+ def inner(f):
+ f.__name__ = original.__name__
+ f.__doc__ = original.__doc__
+ f.__module__ = original.__module__
+ return f
+ return inner
+
+try:
+ unicode
+except NameError:
+ # Python 3
+ basestring = unicode = str
+
+try:
+ long
+except NameError:
+ # Python 3
+ long = int
+
+try:
+ BaseException
+except NameError:
+ # Python 2.4 compatibility
+ BaseException = Exception
+
+try:
+ next
+except NameError:
+ def next(obj):
+ return obj.next()
+
+
+BaseExceptions = (BaseException,)
+if 'java' in sys.platform:
+ # jython
+ import java
+ BaseExceptions = (BaseException, java.lang.Throwable)
+
+try:
+ _isidentifier = str.isidentifier
+except AttributeError:
+ # Python 2.X
+ import keyword
+ import re
+ regex = re.compile(r'^[a-z_][a-z0-9_]*$', re.I)
+ def _isidentifier(string):
+ if string in keyword.kwlist:
+ return False
+ return regex.match(string)
+
+
+inPy3k = sys.version_info[0] == 3
+
+# Needed to work around Python 3 bug where use of "super" interferes with
+# defining __class__ as a descriptor
+_super = super
+
+self = 'im_self'
+builtin = '__builtin__'
+if inPy3k:
+ self = '__self__'
+ builtin = 'builtins'
+
+FILTER_DIR = True
+
+
+def _is_instance_mock(obj):
+ # can't use isinstance on Mock objects because they override __class__
+ # The base class for all mocks is NonCallableMock
+ return issubclass(type(obj), NonCallableMock)
+
+
+def _is_exception(obj):
+ return (
+ isinstance(obj, BaseExceptions) or
+ isinstance(obj, ClassTypes) and issubclass(obj, BaseExceptions)
+ )
+
+
+class _slotted(object):
+ __slots__ = ['a']
+
+
+DescriptorTypes = (
+ type(_slotted.a),
+ property,
+)
+
+
+def _getsignature(func, skipfirst, instance=False):
+ if inspect is None:
+ raise ImportError('inspect module not available')
+
+ if isinstance(func, ClassTypes) and not instance:
+ try:
+ func = func.__init__
+ except AttributeError:
+ return
+ skipfirst = True
+ elif not isinstance(func, FunctionTypes):
+ # for classes where instance is True we end up here too
+ try:
+ func = func.__call__
+ except AttributeError:
+ return
+
+ if inPy3k:
+ try:
+ argspec = inspect.getfullargspec(func)
+ except TypeError:
+ # C function / method, possibly inherited object().__init__
+ return
+ regargs, varargs, varkw, defaults, kwonly, kwonlydef, ann = argspec
+ else:
+ try:
+ regargs, varargs, varkwargs, defaults = inspect.getargspec(func)
+ except TypeError:
+ # C function / method, possibly inherited object().__init__
+ return
+
+ # instance methods and classmethods need to lose the self argument
+ if getattr(func, self, None) is not None:
+ regargs = regargs[1:]
+ if skipfirst:
+ # this condition and the above one are never both True - why?
+ regargs = regargs[1:]
+
+ if inPy3k:
+ signature = inspect.formatargspec(
+ regargs, varargs, varkw, defaults,
+ kwonly, kwonlydef, ann, formatvalue=lambda value: "")
+ else:
+ signature = inspect.formatargspec(
+ regargs, varargs, varkwargs, defaults,
+ formatvalue=lambda value: "")
+ return signature[1:-1], func
+
+
+def _check_signature(func, mock, skipfirst, instance=False):
+ if not _callable(func):
+ return
+
+ result = _getsignature(func, skipfirst, instance)
+ if result is None:
+ return
+ signature, func = result
+
+ # can't use self because "self" is common as an argument name
+ # unfortunately even not in the first place
+ src = "lambda _mock_self, %s: None" % signature
+ checksig = eval(src, {})
+ _copy_func_details(func, checksig)
+ type(mock)._mock_check_sig = checksig
+
+
+def _copy_func_details(func, funcopy):
+ funcopy.__name__ = func.__name__
+ funcopy.__doc__ = func.__doc__
+ #funcopy.__dict__.update(func.__dict__)
+ funcopy.__module__ = func.__module__
+ if not inPy3k:
+ funcopy.func_defaults = func.func_defaults
+ return
+ funcopy.__defaults__ = func.__defaults__
+ funcopy.__kwdefaults__ = func.__kwdefaults__
+
+
+def _callable(obj):
+ if isinstance(obj, ClassTypes):
+ return True
+ if getattr(obj, '__call__', None) is not None:
+ return True
+ return False
+
+
+def _is_list(obj):
+ # checks for list or tuples
+ # XXXX badly named!
+ return type(obj) in (list, tuple)
+
+
+def _instance_callable(obj):
+ """Given an object, return True if the object is callable.
+ For classes, return True if instances would be callable."""
+ if not isinstance(obj, ClassTypes):
+ # already an instance
+ return getattr(obj, '__call__', None) is not None
+
+ klass = obj
+ # uses __bases__ instead of __mro__ so that we work with old style classes
+ if klass.__dict__.get('__call__') is not None:
+ return True
+
+ for base in klass.__bases__:
+ if _instance_callable(base):
+ return True
+ return False
+
+
+def _set_signature(mock, original, instance=False):
+ # creates a function with signature (*args, **kwargs) that delegates to a
+ # mock. It still does signature checking by calling a lambda with the same
+ # signature as the original.
+ if not _callable(original):
+ return
+
+ skipfirst = isinstance(original, ClassTypes)
+ result = _getsignature(original, skipfirst, instance)
+ if result is None:
+ # was a C function (e.g. object().__init__ ) that can't be mocked
+ return
+
+ signature, func = result
+
+ src = "lambda %s: None" % signature
+ checksig = eval(src, {})
+ _copy_func_details(func, checksig)
+
+ name = original.__name__
+ if not _isidentifier(name):
+ name = 'funcopy'
+ context = {'_checksig_': checksig, 'mock': mock}
+ src = """def %s(*args, **kwargs):
+ _checksig_(*args, **kwargs)
+ return mock(*args, **kwargs)""" % name
+ exec (src, context)
+ funcopy = context[name]
+ _setup_func(funcopy, mock)
+ return funcopy
+
+
+def _setup_func(funcopy, mock):
+ funcopy.mock = mock
+
+ # can't use isinstance with mocks
+ if not _is_instance_mock(mock):
+ return
+
+ def assert_called_with(*args, **kwargs):
+ return mock.assert_called_with(*args, **kwargs)
+ def assert_called_once_with(*args, **kwargs):
+ return mock.assert_called_once_with(*args, **kwargs)
+ def assert_has_calls(*args, **kwargs):
+ return mock.assert_has_calls(*args, **kwargs)
+ def assert_any_call(*args, **kwargs):
+ return mock.assert_any_call(*args, **kwargs)
+ def reset_mock():
+ funcopy.method_calls = _CallList()
+ funcopy.mock_calls = _CallList()
+ mock.reset_mock()
+ ret = funcopy.return_value
+ if _is_instance_mock(ret) and not ret is mock:
+ ret.reset_mock()
+
+ funcopy.called = False
+ funcopy.call_count = 0
+ funcopy.call_args = None
+ funcopy.call_args_list = _CallList()
+ funcopy.method_calls = _CallList()
+ funcopy.mock_calls = _CallList()
+
+ funcopy.return_value = mock.return_value
+ funcopy.side_effect = mock.side_effect
+ funcopy._mock_children = mock._mock_children
+
+ funcopy.assert_called_with = assert_called_with
+ funcopy.assert_called_once_with = assert_called_once_with
+ funcopy.assert_has_calls = assert_has_calls
+ funcopy.assert_any_call = assert_any_call
+ funcopy.reset_mock = reset_mock
+
+ mock._mock_delegate = funcopy
+
+
+def _is_magic(name):
+ return '__%s__' % name[2:-2] == name
+
+
+class _SentinelObject(object):
+ "A unique, named, sentinel object."
+ def __init__(self, name):
+ self.name = name
+
+ def __repr__(self):
+ return 'sentinel.%s' % self.name
+
+
+class _Sentinel(object):
+ """Access attributes to return a named object, usable as a sentinel."""
+ def __init__(self):
+ self._sentinels = {}
+
+ def __getattr__(self, name):
+ if name == '__bases__':
+ # Without this help(mock) raises an exception
+ raise AttributeError
+ return self._sentinels.setdefault(name, _SentinelObject(name))
+
+
+sentinel = _Sentinel()
+
+DEFAULT = sentinel.DEFAULT
+_missing = sentinel.MISSING
+_deleted = sentinel.DELETED
+
+
+class OldStyleClass:
+ pass
+ClassType = type(OldStyleClass)
+
+
+def _copy(value):
+ if type(value) in (dict, list, tuple, set):
+ return type(value)(value)
+ return value
+
+
+ClassTypes = (type,)
+if not inPy3k:
+ ClassTypes = (type, ClassType)
+
+_allowed_names = set(
+ [
+ 'return_value', '_mock_return_value', 'side_effect',
+ '_mock_side_effect', '_mock_parent', '_mock_new_parent',
+ '_mock_name', '_mock_new_name'
+ ]
+)
+
+
+def _delegating_property(name):
+ _allowed_names.add(name)
+ _the_name = '_mock_' + name
+ def _get(self, name=name, _the_name=_the_name):
+ sig = self._mock_delegate
+ if sig is None:
+ return getattr(self, _the_name)
+ return getattr(sig, name)
+ def _set(self, value, name=name, _the_name=_the_name):
+ sig = self._mock_delegate
+ if sig is None:
+ self.__dict__[_the_name] = value
+ else:
+ setattr(sig, name, value)
+
+ return property(_get, _set)
+
+
+
+class _CallList(list):
+
+ def __contains__(self, value):
+ if not isinstance(value, list):
+ return list.__contains__(self, value)
+ len_value = len(value)
+ len_self = len(self)
+ if len_value > len_self:
+ return False
+
+ for i in range(0, len_self - len_value + 1):
+ sub_list = self[i:i+len_value]
+ if sub_list == value:
+ return True
+ return False
+
+ def __repr__(self):
+ return pprint.pformat(list(self))
+
+
+def _check_and_set_parent(parent, value, name, new_name):
+ if not _is_instance_mock(value):
+ return False
+ if ((value._mock_name or value._mock_new_name) or
+ (value._mock_parent is not None) or
+ (value._mock_new_parent is not None)):
+ return False
+
+ _parent = parent
+ while _parent is not None:
+ # setting a mock (value) as a child or return value of itself
+ # should not modify the mock
+ if _parent is value:
+ return False
+ _parent = _parent._mock_new_parent
+
+ if new_name:
+ value._mock_new_parent = parent
+ value._mock_new_name = new_name
+ if name:
+ value._mock_parent = parent
+ value._mock_name = name
+ return True
+
+
+
+class Base(object):
+ _mock_return_value = DEFAULT
+ _mock_side_effect = None
+ def __init__(self, *args, **kwargs):
+ pass
+
+
+
+class NonCallableMock(Base):
+ """A non-callable version of `Mock`"""
+
+ def __new__(cls, *args, **kw):
+ # every instance has its own class
+ # so we can create magic methods on the
+ # class without stomping on other mocks
+ new = type(cls.__name__, (cls,), {'__doc__': cls.__doc__})
+ instance = object.__new__(new)
+ return instance
+
+
+ def __init__(
+ self, spec=None, wraps=None, name=None, spec_set=None,
+ parent=None, _spec_state=None, _new_name='', _new_parent=None,
+ **kwargs
+ ):
+ if _new_parent is None:
+ _new_parent = parent
+
+ __dict__ = self.__dict__
+ __dict__['_mock_parent'] = parent
+ __dict__['_mock_name'] = name
+ __dict__['_mock_new_name'] = _new_name
+ __dict__['_mock_new_parent'] = _new_parent
+
+ if spec_set is not None:
+ spec = spec_set
+ spec_set = True
+
+ self._mock_add_spec(spec, spec_set)
+
+ __dict__['_mock_children'] = {}
+ __dict__['_mock_wraps'] = wraps
+ __dict__['_mock_delegate'] = None
+
+ __dict__['_mock_called'] = False
+ __dict__['_mock_call_args'] = None
+ __dict__['_mock_call_count'] = 0
+ __dict__['_mock_call_args_list'] = _CallList()
+ __dict__['_mock_mock_calls'] = _CallList()
+
+ __dict__['method_calls'] = _CallList()
+
+ if kwargs:
+ self.configure_mock(**kwargs)
+
+ _super(NonCallableMock, self).__init__(
+ spec, wraps, name, spec_set, parent,
+ _spec_state
+ )
+
+
+ def attach_mock(self, mock, attribute):
+ """
+ Attach a mock as an attribute of this one, replacing its name and
+ parent. Calls to the attached mock will be recorded in the
+ `method_calls` and `mock_calls` attributes of this one."""
+ mock._mock_parent = None
+ mock._mock_new_parent = None
+ mock._mock_name = ''
+ mock._mock_new_name = None
+
+ setattr(self, attribute, mock)
+
+
+ def mock_add_spec(self, spec, spec_set=False):
+ """Add a spec to a mock. `spec` can either be an object or a
+ list of strings. Only attributes on the `spec` can be fetched as
+ attributes from the mock.
+
+ If `spec_set` is True then only attributes on the spec can be set."""
+ self._mock_add_spec(spec, spec_set)
+
+
+ def _mock_add_spec(self, spec, spec_set):
+ _spec_class = None
+
+ if spec is not None and not _is_list(spec):
+ if isinstance(spec, ClassTypes):
+ _spec_class = spec
+ else:
+ _spec_class = _get_class(spec)
+
+ spec = dir(spec)
+
+ __dict__ = self.__dict__
+ __dict__['_spec_class'] = _spec_class
+ __dict__['_spec_set'] = spec_set
+ __dict__['_mock_methods'] = spec
+
+
+ def __get_return_value(self):
+ ret = self._mock_return_value
+ if self._mock_delegate is not None:
+ ret = self._mock_delegate.return_value
+
+ if ret is DEFAULT:
+ ret = self._get_child_mock(
+ _new_parent=self, _new_name='()'
+ )
+ self.return_value = ret
+ return ret
+
+
+ def __set_return_value(self, value):
+ if self._mock_delegate is not None:
+ self._mock_delegate.return_value = value
+ else:
+ self._mock_return_value = value
+ _check_and_set_parent(self, value, None, '()')
+
+ __return_value_doc = "The value to be returned when the mock is called."
+ return_value = property(__get_return_value, __set_return_value,
+ __return_value_doc)
+
+
+ @property
+ def __class__(self):
+ if self._spec_class is None:
+ return type(self)
+ return self._spec_class
+
+ called = _delegating_property('called')
+ call_count = _delegating_property('call_count')
+ call_args = _delegating_property('call_args')
+ call_args_list = _delegating_property('call_args_list')
+ mock_calls = _delegating_property('mock_calls')
+
+
+ def __get_side_effect(self):
+ sig = self._mock_delegate
+ if sig is None:
+ return self._mock_side_effect
+ return sig.side_effect
+
+ def __set_side_effect(self, value):
+ value = _try_iter(value)
+ sig = self._mock_delegate
+ if sig is None:
+ self._mock_side_effect = value
+ else:
+ sig.side_effect = value
+
+ side_effect = property(__get_side_effect, __set_side_effect)
+
+
+ def reset_mock(self):
+ "Restore the mock object to its initial state."
+ self.called = False
+ self.call_args = None
+ self.call_count = 0
+ self.mock_calls = _CallList()
+ self.call_args_list = _CallList()
+ self.method_calls = _CallList()
+
+ for child in self._mock_children.values():
+ if isinstance(child, _SpecState):
+ continue
+ child.reset_mock()
+
+ ret = self._mock_return_value
+ if _is_instance_mock(ret) and ret is not self:
+ ret.reset_mock()
+
+
+ def configure_mock(self, **kwargs):
+ """Set attributes on the mock through keyword arguments.
+
+ Attributes plus return values and side effects can be set on child
+ mocks using standard dot notation and unpacking a dictionary in the
+ method call:
+
+ >>> attrs = {'method.return_value': 3, 'other.side_effect': KeyError}
+ >>> mock.configure_mock(**attrs)"""
+ for arg, val in sorted(kwargs.items(),
+ # we sort on the number of dots so that
+ # attributes are set before we set attributes on
+ # attributes
+ key=lambda entry: entry[0].count('.')):
+ args = arg.split('.')
+ final = args.pop()
+ obj = self
+ for entry in args:
+ obj = getattr(obj, entry)
+ setattr(obj, final, val)
+
+
+ def __getattr__(self, name):
+ if name == '_mock_methods':
+ raise AttributeError(name)
+ elif self._mock_methods is not None:
+ if name not in self._mock_methods or name in _all_magics:
+ raise AttributeError("Mock object has no attribute %r" % name)
+ elif _is_magic(name):
+ raise AttributeError(name)
+
+ result = self._mock_children.get(name)
+ if result is _deleted:
+ raise AttributeError(name)
+ elif result is None:
+ wraps = None
+ if self._mock_wraps is not None:
+ # XXXX should we get the attribute without triggering code
+ # execution?
+ wraps = getattr(self._mock_wraps, name)
+
+ result = self._get_child_mock(
+ parent=self, name=name, wraps=wraps, _new_name=name,
+ _new_parent=self
+ )
+ self._mock_children[name] = result
+
+ elif isinstance(result, _SpecState):
+ result = create_autospec(
+ result.spec, result.spec_set, result.instance,
+ result.parent, result.name
+ )
+ self._mock_children[name] = result
+
+ return result
+
+
+ def __repr__(self):
+ _name_list = [self._mock_new_name]
+ _parent = self._mock_new_parent
+ last = self
+
+ dot = '.'
+ if _name_list == ['()']:
+ dot = ''
+ seen = set()
+ while _parent is not None:
+ last = _parent
+
+ _name_list.append(_parent._mock_new_name + dot)
+ dot = '.'
+ if _parent._mock_new_name == '()':
+ dot = ''
+
+ _parent = _parent._mock_new_parent
+
+ # use ids here so as not to call __hash__ on the mocks
+ if id(_parent) in seen:
+ break
+ seen.add(id(_parent))
+
+ _name_list = list(reversed(_name_list))
+ _first = last._mock_name or 'mock'
+ if len(_name_list) > 1:
+ if _name_list[1] not in ('()', '().'):
+ _first += '.'
+ _name_list[0] = _first
+ name = ''.join(_name_list)
+
+ name_string = ''
+ if name not in ('mock', 'mock.'):
+ name_string = ' name=%r' % name
+
+ spec_string = ''
+ if self._spec_class is not None:
+ spec_string = ' spec=%r'
+ if self._spec_set:
+ spec_string = ' spec_set=%r'
+ spec_string = spec_string % self._spec_class.__name__
+ return "<%s%s%s id='%s'>" % (
+ type(self).__name__,
+ name_string,
+ spec_string,
+ id(self)
+ )
+
+
+ def __dir__(self):
+ """Filter the output of `dir(mock)` to only useful members.
+ XXXX
+ """
+ extras = self._mock_methods or []
+ from_type = dir(type(self))
+ from_dict = list(self.__dict__)
+
+ if FILTER_DIR:
+ from_type = [e for e in from_type if not e.startswith('_')]
+ from_dict = [e for e in from_dict if not e.startswith('_') or
+ _is_magic(e)]
+ return sorted(set(extras + from_type + from_dict +
+ list(self._mock_children)))
+
+
+ def __setattr__(self, name, value):
+ if name in _allowed_names:
+ # property setters go through here
+ return object.__setattr__(self, name, value)
+ elif (self._spec_set and self._mock_methods is not None and
+ name not in self._mock_methods and
+ name not in self.__dict__):
+ raise AttributeError("Mock object has no attribute '%s'" % name)
+ elif name in _unsupported_magics:
+ msg = 'Attempting to set unsupported magic method %r.' % name
+ raise AttributeError(msg)
+ elif name in _all_magics:
+ if self._mock_methods is not None and name not in self._mock_methods:
+ raise AttributeError("Mock object has no attribute '%s'" % name)
+
+ if not _is_instance_mock(value):
+ setattr(type(self), name, _get_method(name, value))
+ original = value
+ value = lambda *args, **kw: original(self, *args, **kw)
+ else:
+ # only set _new_name and not name so that mock_calls is tracked
+ # but not method calls
+ _check_and_set_parent(self, value, None, name)
+ setattr(type(self), name, value)
+ self._mock_children[name] = value
+ elif name == '__class__':
+ self._spec_class = value
+ return
+ else:
+ if _check_and_set_parent(self, value, name, name):
+ self._mock_children[name] = value
+ return object.__setattr__(self, name, value)
+
+
+ def __delattr__(self, name):
+ if name in _all_magics and name in type(self).__dict__:
+ delattr(type(self), name)
+ if name not in self.__dict__:
+ # for magic methods that are still MagicProxy objects and
+ # not set on the instance itself
+ return
+
+ if name in self.__dict__:
+ object.__delattr__(self, name)
+
+ obj = self._mock_children.get(name, _missing)
+ if obj is _deleted:
+ raise AttributeError(name)
+ if obj is not _missing:
+ del self._mock_children[name]
+ self._mock_children[name] = _deleted
+
+
+
+ def _format_mock_call_signature(self, args, kwargs):
+ name = self._mock_name or 'mock'
+ return _format_call_signature(name, args, kwargs)
+
+
+ def _format_mock_failure_message(self, args, kwargs):
+ message = 'Expected call: %s\nActual call: %s'
+ expected_string = self._format_mock_call_signature(args, kwargs)
+ call_args = self.call_args
+ if len(call_args) == 3:
+ call_args = call_args[1:]
+ actual_string = self._format_mock_call_signature(*call_args)
+ return message % (expected_string, actual_string)
+
+
+ def assert_called_with(_mock_self, *args, **kwargs):
+ """assert that the mock was called with the specified arguments.
+
+ Raises an AssertionError if the args and keyword args passed in are
+ different to the last call to the mock."""
+ self = _mock_self
+ if self.call_args is None:
+ expected = self._format_mock_call_signature(args, kwargs)
+ raise AssertionError('Expected call: %s\nNot called' % (expected,))
+
+ if self.call_args != (args, kwargs):
+ msg = self._format_mock_failure_message(args, kwargs)
+ raise AssertionError(msg)
+
+
+ def assert_called_once_with(_mock_self, *args, **kwargs):
+ """assert that the mock was called exactly once and with the specified
+ arguments."""
+ self = _mock_self
+ if not self.call_count == 1:
+ msg = ("Expected to be called once. Called %s times." %
+ self.call_count)
+ raise AssertionError(msg)
+ return self.assert_called_with(*args, **kwargs)
+
+
+ def assert_has_calls(self, calls, any_order=False):
+ """assert the mock has been called with the specified calls.
+ The `mock_calls` list is checked for the calls.
+
+ If `any_order` is False (the default) then the calls must be
+ sequential. There can be extra calls before or after the
+ specified calls.
+
+ If `any_order` is True then the calls can be in any order, but
+ they must all appear in `mock_calls`."""
+ if not any_order:
+ if calls not in self.mock_calls:
+ raise AssertionError(
+ 'Calls not found.\nExpected: %r\n'
+ 'Actual: %r' % (calls, self.mock_calls)
+ )
+ return
+
+ all_calls = list(self.mock_calls)
+
+ not_found = []
+ for kall in calls:
+ try:
+ all_calls.remove(kall)
+ except ValueError:
+ not_found.append(kall)
+ if not_found:
+ raise AssertionError(
+ '%r not all found in call list' % (tuple(not_found),)
+ )
+
+
+ def assert_any_call(self, *args, **kwargs):
+ """assert the mock has been called with the specified arguments.
+
+ The assert passes if the mock has *ever* been called, unlike
+ `assert_called_with` and `assert_called_once_with` that only pass if
+ the call is the most recent one."""
+ kall = call(*args, **kwargs)
+ if kall not in self.call_args_list:
+ expected_string = self._format_mock_call_signature(args, kwargs)
+ raise AssertionError(
+ '%s call not found' % expected_string
+ )
+
+
+ def _get_child_mock(self, **kw):
+ """Create the child mocks for attributes and return value.
+ By default child mocks will be the same type as the parent.
+ Subclasses of Mock may want to override this to customize the way
+ child mocks are made.
+
+ For non-callable mocks the callable variant will be used (rather than
+ any custom subclass)."""
+ _type = type(self)
+ if not issubclass(_type, CallableMixin):
+ if issubclass(_type, NonCallableMagicMock):
+ klass = MagicMock
+ elif issubclass(_type, NonCallableMock) :
+ klass = Mock
+ else:
+ klass = _type.__mro__[1]
+ return klass(**kw)
+
+
+
+def _try_iter(obj):
+ if obj is None:
+ return obj
+ if _is_exception(obj):
+ return obj
+ if _callable(obj):
+ return obj
+ try:
+ return iter(obj)
+ except TypeError:
+ # XXXX backwards compatibility
+ # but this will blow up on first call - so maybe we should fail early?
+ return obj
+
+
+
+class CallableMixin(Base):
+
+ def __init__(self, spec=None, side_effect=None, return_value=DEFAULT,
+ wraps=None, name=None, spec_set=None, parent=None,
+ _spec_state=None, _new_name='', _new_parent=None, **kwargs):
+ self.__dict__['_mock_return_value'] = return_value
+
+ _super(CallableMixin, self).__init__(
+ spec, wraps, name, spec_set, parent,
+ _spec_state, _new_name, _new_parent, **kwargs
+ )
+
+ self.side_effect = side_effect
+
+
+ def _mock_check_sig(self, *args, **kwargs):
+ # stub method that can be replaced with one with a specific signature
+ pass
+
+
+ def __call__(_mock_self, *args, **kwargs):
+ # can't use self in-case a function / method we are mocking uses self
+ # in the signature
+ _mock_self._mock_check_sig(*args, **kwargs)
+ return _mock_self._mock_call(*args, **kwargs)
+
+
+ def _mock_call(_mock_self, *args, **kwargs):
+ self = _mock_self
+ self.called = True
+ self.call_count += 1
+ self.call_args = _Call((args, kwargs), two=True)
+ self.call_args_list.append(_Call((args, kwargs), two=True))
+
+ _new_name = self._mock_new_name
+ _new_parent = self._mock_new_parent
+ self.mock_calls.append(_Call(('', args, kwargs)))
+
+ seen = set()
+ skip_next_dot = _new_name == '()'
+ do_method_calls = self._mock_parent is not None
+ name = self._mock_name
+ while _new_parent is not None:
+ this_mock_call = _Call((_new_name, args, kwargs))
+ if _new_parent._mock_new_name:
+ dot = '.'
+ if skip_next_dot:
+ dot = ''
+
+ skip_next_dot = False
+ if _new_parent._mock_new_name == '()':
+ skip_next_dot = True
+
+ _new_name = _new_parent._mock_new_name + dot + _new_name
+
+ if do_method_calls:
+ if _new_name == name:
+ this_method_call = this_mock_call
+ else:
+ this_method_call = _Call((name, args, kwargs))
+ _new_parent.method_calls.append(this_method_call)
+
+ do_method_calls = _new_parent._mock_parent is not None
+ if do_method_calls:
+ name = _new_parent._mock_name + '.' + name
+
+ _new_parent.mock_calls.append(this_mock_call)
+ _new_parent = _new_parent._mock_new_parent
+
+ # use ids here so as not to call __hash__ on the mocks
+ _new_parent_id = id(_new_parent)
+ if _new_parent_id in seen:
+ break
+ seen.add(_new_parent_id)
+
+ ret_val = DEFAULT
+ effect = self.side_effect
+ if effect is not None:
+ if _is_exception(effect):
+ raise effect
+
+ if not _callable(effect):
+ result = next(effect)
+ if _is_exception(result):
+ raise result
+ return result
+
+ ret_val = effect(*args, **kwargs)
+ if ret_val is DEFAULT:
+ ret_val = self.return_value
+
+ if (self._mock_wraps is not None and
+ self._mock_return_value is DEFAULT):
+ return self._mock_wraps(*args, **kwargs)
+ if ret_val is DEFAULT:
+ ret_val = self.return_value
+ return ret_val
+
+
+
+class Mock(CallableMixin, NonCallableMock):
+ """
+ Create a new `Mock` object. `Mock` takes several optional arguments
+ that specify the behaviour of the Mock object:
+
+ * `spec`: This can be either a list of strings or an existing object (a
+ class or instance) that acts as the specification for the mock object. If
+ you pass in an object then a list of strings is formed by calling dir on
+ the object (excluding unsupported magic attributes and methods). Accessing
+ any attribute not in this list will raise an `AttributeError`.
+
+ If `spec` is an object (rather than a list of strings) then
+ `mock.__class__` returns the class of the spec object. This allows mocks
+ to pass `isinstance` tests.
+
+ * `spec_set`: A stricter variant of `spec`. If used, attempting to *set*
+ or get an attribute on the mock that isn't on the object passed as
+ `spec_set` will raise an `AttributeError`.
+
+ * `side_effect`: A function to be called whenever the Mock is called. See
+ the `side_effect` attribute. Useful for raising exceptions or
+ dynamically changing return values. The function is called with the same
+ arguments as the mock, and unless it returns `DEFAULT`, the return
+ value of this function is used as the return value.
+
+ Alternatively `side_effect` can be an exception class or instance. In
+ this case the exception will be raised when the mock is called.
+
+ If `side_effect` is an iterable then each call to the mock will return
+ the next value from the iterable. If any of the members of the iterable
+ are exceptions they will be raised instead of returned.
+
+ * `return_value`: The value returned when the mock is called. By default
+ this is a new Mock (created on first access). See the
+ `return_value` attribute.
+
+ * `wraps`: Item for the mock object to wrap. If `wraps` is not None then
+ calling the Mock will pass the call through to the wrapped object
+ (returning the real result). Attribute access on the mock will return a
+ Mock object that wraps the corresponding attribute of the wrapped object
+ (so attempting to access an attribute that doesn't exist will raise an
+ `AttributeError`).
+
+ If the mock has an explicit `return_value` set then calls are not passed
+ to the wrapped object and the `return_value` is returned instead.
+
+ * `name`: If the mock has a name then it will be used in the repr of the
+ mock. This can be useful for debugging. The name is propagated to child
+ mocks.
+
+ Mocks can also be called with arbitrary keyword arguments. These will be
+ used to set attributes on the mock after it is created.
+ """
+
+
+
+def _dot_lookup(thing, comp, import_path):
+ try:
+ return getattr(thing, comp)
+ except AttributeError:
+ __import__(import_path)
+ return getattr(thing, comp)
+
+
+def _importer(target):
+ components = target.split('.')
+ import_path = components.pop(0)
+ thing = __import__(import_path)
+
+ for comp in components:
+ import_path += ".%s" % comp
+ thing = _dot_lookup(thing, comp, import_path)
+ return thing
+
+
+def _is_started(patcher):
+ # XXXX horrible
+ return hasattr(patcher, 'is_local')
+
+
+class _patch(object):
+
+ attribute_name = None
+ _active_patches = set()
+
+ def __init__(
+ self, getter, attribute, new, spec, create,
+ spec_set, autospec, new_callable, kwargs
+ ):
+ if new_callable is not None:
+ if new is not DEFAULT:
+ raise ValueError(
+ "Cannot use 'new' and 'new_callable' together"
+ )
+ if autospec is not None:
+ raise ValueError(
+ "Cannot use 'autospec' and 'new_callable' together"
+ )
+
+ self.getter = getter
+ self.attribute = attribute
+ self.new = new
+ self.new_callable = new_callable
+ self.spec = spec
+ self.create = create
+ self.has_local = False
+ self.spec_set = spec_set
+ self.autospec = autospec
+ self.kwargs = kwargs
+ self.additional_patchers = []
+
+
+ def copy(self):
+ patcher = _patch(
+ self.getter, self.attribute, self.new, self.spec,
+ self.create, self.spec_set,
+ self.autospec, self.new_callable, self.kwargs
+ )
+ patcher.attribute_name = self.attribute_name
+ patcher.additional_patchers = [
+ p.copy() for p in self.additional_patchers
+ ]
+ return patcher
+
+
+ def __call__(self, func):
+ if isinstance(func, ClassTypes):
+ return self.decorate_class(func)
+ return self.decorate_callable(func)
+
+
+ def decorate_class(self, klass):
+ for attr in dir(klass):
+ if not attr.startswith(patch.TEST_PREFIX):
+ continue
+
+ attr_value = getattr(klass, attr)
+ if not hasattr(attr_value, "__call__"):
+ continue
+
+ patcher = self.copy()
+ setattr(klass, attr, patcher(attr_value))
+ return klass
+
+
+ def decorate_callable(self, func):
+ if hasattr(func, 'patchings'):
+ func.patchings.append(self)
+ return func
+
+ @wraps(func)
+ def patched(*args, **keywargs):
+ # don't use a with here (backwards compatability with Python 2.4)
+ extra_args = []
+ entered_patchers = []
+
+ # can't use try...except...finally because of Python 2.4
+ # compatibility
+ exc_info = tuple()
+ try:
+ try:
+ for patching in patched.patchings:
+ arg = patching.__enter__()
+ entered_patchers.append(patching)
+ if patching.attribute_name is not None:
+ keywargs.update(arg)
+ elif patching.new is DEFAULT:
+ extra_args.append(arg)
+
+ args += tuple(extra_args)
+ return func(*args, **keywargs)
+ except:
+ if (patching not in entered_patchers and
+ _is_started(patching)):
+ # the patcher may have been started, but an exception
+ # raised whilst entering one of its additional_patchers
+ entered_patchers.append(patching)
+ # Pass the exception to __exit__
+ exc_info = sys.exc_info()
+ # re-raise the exception
+ raise
+ finally:
+ for patching in reversed(entered_patchers):
+ patching.__exit__(*exc_info)
+
+ patched.patchings = [self]
+ if hasattr(func, 'func_code'):
+ # not in Python 3
+ patched.compat_co_firstlineno = getattr(
+ func, "compat_co_firstlineno",
+ func.func_code.co_firstlineno
+ )
+ return patched
+
+
+ def get_original(self):
+ target = self.getter()
+ name = self.attribute
+
+ original = DEFAULT
+ local = False
+
+ try:
+ original = target.__dict__[name]
+ except (AttributeError, KeyError):
+ original = getattr(target, name, DEFAULT)
+ else:
+ local = True
+
+ if not self.create and original is DEFAULT:
+ raise AttributeError(
+ "%s does not have the attribute %r" % (target, name)
+ )
+ return original, local
+
+
+ def __enter__(self):
+ """Perform the patch."""
+ new, spec, spec_set = self.new, self.spec, self.spec_set
+ autospec, kwargs = self.autospec, self.kwargs
+ new_callable = self.new_callable
+ self.target = self.getter()
+
+ # normalise False to None
+ if spec is False:
+ spec = None
+ if spec_set is False:
+ spec_set = None
+ if autospec is False:
+ autospec = None
+
+ if spec is not None and autospec is not None:
+ raise TypeError("Can't specify spec and autospec")
+ if ((spec is not None or autospec is not None) and
+ spec_set not in (True, None)):
+ raise TypeError("Can't provide explicit spec_set *and* spec or autospec")
+
+ original, local = self.get_original()
+
+ if new is DEFAULT and autospec is None:
+ inherit = False
+ if spec is True:
+ # set spec to the object we are replacing
+ spec = original
+ if spec_set is True:
+ spec_set = original
+ spec = None
+ elif spec is not None:
+ if spec_set is True:
+ spec_set = spec
+ spec = None
+ elif spec_set is True:
+ spec_set = original
+
+ if spec is not None or spec_set is not None:
+ if original is DEFAULT:
+ raise TypeError("Can't use 'spec' with create=True")
+ if isinstance(original, ClassTypes):
+ # If we're patching out a class and there is a spec
+ inherit = True
+
+ Klass = MagicMock
+ _kwargs = {}
+ if new_callable is not None:
+ Klass = new_callable
+ elif spec is not None or spec_set is not None:
+ this_spec = spec
+ if spec_set is not None:
+ this_spec = spec_set
+ if _is_list(this_spec):
+ not_callable = '__call__' not in this_spec
+ else:
+ not_callable = not _callable(this_spec)
+ if not_callable:
+ Klass = NonCallableMagicMock
+
+ if spec is not None:
+ _kwargs['spec'] = spec
+ if spec_set is not None:
+ _kwargs['spec_set'] = spec_set
+
+ # add a name to mocks
+ if (isinstance(Klass, type) and
+ issubclass(Klass, NonCallableMock) and self.attribute):
+ _kwargs['name'] = self.attribute
+
+ _kwargs.update(kwargs)
+ new = Klass(**_kwargs)
+
+ if inherit and _is_instance_mock(new):
+ # we can only tell if the instance should be callable if the
+ # spec is not a list
+ this_spec = spec
+ if spec_set is not None:
+ this_spec = spec_set
+ if (not _is_list(this_spec) and not
+ _instance_callable(this_spec)):
+ Klass = NonCallableMagicMock
+
+ _kwargs.pop('name')
+ new.return_value = Klass(_new_parent=new, _new_name='()',
+ **_kwargs)
+ elif autospec is not None:
+ # spec is ignored, new *must* be default, spec_set is treated
+ # as a boolean. Should we check spec is not None and that spec_set
+ # is a bool?
+ if new is not DEFAULT:
+ raise TypeError(
+ "autospec creates the mock for you. Can't specify "
+ "autospec and new."
+ )
+ if original is DEFAULT:
+ raise TypeError("Can't use 'autospec' with create=True")
+ spec_set = bool(spec_set)
+ if autospec is True:
+ autospec = original
+
+ new = create_autospec(autospec, spec_set=spec_set,
+ _name=self.attribute, **kwargs)
+ elif kwargs:
+ # can't set keyword args when we aren't creating the mock
+ # XXXX If new is a Mock we could call new.configure_mock(**kwargs)
+ raise TypeError("Can't pass kwargs to a mock we aren't creating")
+
+ new_attr = new
+
+ self.temp_original = original
+ self.is_local = local
+ setattr(self.target, self.attribute, new_attr)
+ if self.attribute_name is not None:
+ extra_args = {}
+ if self.new is DEFAULT:
+ extra_args[self.attribute_name] = new
+ for patching in self.additional_patchers:
+ arg = patching.__enter__()
+ if patching.new is DEFAULT:
+ extra_args.update(arg)
+ return extra_args
+
+ return new
+
+
+ def __exit__(self, *exc_info):
+ """Undo the patch."""
+ if not _is_started(self):
+ raise RuntimeError('stop called on unstarted patcher')
+
+ if self.is_local and self.temp_original is not DEFAULT:
+ setattr(self.target, self.attribute, self.temp_original)
+ else:
+ delattr(self.target, self.attribute)
+ if not self.create and not hasattr(self.target, self.attribute):
+ # needed for proxy objects like django settings
+ setattr(self.target, self.attribute, self.temp_original)
+
+ del self.temp_original
+ del self.is_local
+ del self.target
+ for patcher in reversed(self.additional_patchers):
+ if _is_started(patcher):
+ patcher.__exit__(*exc_info)
+
+
+ def start(self):
+ """Activate a patch, returning any created mock."""
+ result = self.__enter__()
+ self._active_patches.add(self)
+ return result
+
+
+ def stop(self):
+ """Stop an active patch."""
+ self._active_patches.discard(self)
+ return self.__exit__()
+
+
+
+def _get_target(target):
+ try:
+ target, attribute = target.rsplit('.', 1)
+ except (TypeError, ValueError):
+ raise TypeError("Need a valid target to patch. You supplied: %r" %
+ (target,))
+ getter = lambda: _importer(target)
+ return getter, attribute
+
+
+def _patch_object(
+ target, attribute, new=DEFAULT, spec=None,
+ create=False, spec_set=None, autospec=None,
+ new_callable=None, **kwargs
+ ):
+ """
+ patch.object(target, attribute, new=DEFAULT, spec=None, create=False,
+ spec_set=None, autospec=None, new_callable=None, **kwargs)
+
+ patch the named member (`attribute`) on an object (`target`) with a mock
+ object.
+
+ `patch.object` can be used as a decorator, class decorator or a context
+ manager. Arguments `new`, `spec`, `create`, `spec_set`,
+ `autospec` and `new_callable` have the same meaning as for `patch`. Like
+ `patch`, `patch.object` takes arbitrary keyword arguments for configuring
+ the mock object it creates.
+
+ When used as a class decorator `patch.object` honours `patch.TEST_PREFIX`
+ for choosing which methods to wrap.
+ """
+ getter = lambda: target
+ return _patch(
+ getter, attribute, new, spec, create,
+ spec_set, autospec, new_callable, kwargs
+ )
+
+
+def _patch_multiple(target, spec=None, create=False, spec_set=None,
+ autospec=None, new_callable=None, **kwargs):
+ """Perform multiple patches in a single call. It takes the object to be
+ patched (either as an object or a string to fetch the object by importing)
+ and keyword arguments for the patches::
+
+ with patch.multiple(settings, FIRST_PATCH='one', SECOND_PATCH='two'):
+ ...
+
+ Use `DEFAULT` as the value if you want `patch.multiple` to create
+ mocks for you. In this case the created mocks are passed into a decorated
+ function by keyword, and a dictionary is returned when `patch.multiple` is
+ used as a context manager.
+
+ `patch.multiple` can be used as a decorator, class decorator or a context
+ manager. The arguments `spec`, `spec_set`, `create`,
+ `autospec` and `new_callable` have the same meaning as for `patch`. These
+ arguments will be applied to *all* patches done by `patch.multiple`.
+
+ When used as a class decorator `patch.multiple` honours `patch.TEST_PREFIX`
+ for choosing which methods to wrap.
+ """
+ if type(target) in (unicode, str):
+ getter = lambda: _importer(target)
+ else:
+ getter = lambda: target
+
+ if not kwargs:
+ raise ValueError(
+ 'Must supply at least one keyword argument with patch.multiple'
+ )
+ # need to wrap in a list for python 3, where items is a view
+ items = list(kwargs.items())
+ attribute, new = items[0]
+ patcher = _patch(
+ getter, attribute, new, spec, create, spec_set,
+ autospec, new_callable, {}
+ )
+ patcher.attribute_name = attribute
+ for attribute, new in items[1:]:
+ this_patcher = _patch(
+ getter, attribute, new, spec, create, spec_set,
+ autospec, new_callable, {}
+ )
+ this_patcher.attribute_name = attribute
+ patcher.additional_patchers.append(this_patcher)
+ return patcher
+
+
+def patch(
+ target, new=DEFAULT, spec=None, create=False,
+ spec_set=None, autospec=None, new_callable=None, **kwargs
+ ):
+ """
+ `patch` acts as a function decorator, class decorator or a context
+ manager. Inside the body of the function or with statement, the `target`
+ is patched with a `new` object. When the function/with statement exits
+ the patch is undone.
+
+ If `new` is omitted, then the target is replaced with a
+ `MagicMock`. If `patch` is used as a decorator and `new` is
+ omitted, the created mock is passed in as an extra argument to the
+ decorated function. If `patch` is used as a context manager the created
+ mock is returned by the context manager.
+
+ `target` should be a string in the form `'package.module.ClassName'`. The
+ `target` is imported and the specified object replaced with the `new`
+ object, so the `target` must be importable from the environment you are
+ calling `patch` from. The target is imported when the decorated function
+ is executed, not at decoration time.
+
+ The `spec` and `spec_set` keyword arguments are passed to the `MagicMock`
+ if patch is creating one for you.
+
+ In addition you can pass `spec=True` or `spec_set=True`, which causes
+ patch to pass in the object being mocked as the spec/spec_set object.
+
+ `new_callable` allows you to specify a different class, or callable object,
+ that will be called to create the `new` object. By default `MagicMock` is
+ used.
+
+ A more powerful form of `spec` is `autospec`. If you set `autospec=True`
+ then the mock with be created with a spec from the object being replaced.
+ All attributes of the mock will also have the spec of the corresponding
+ attribute of the object being replaced. Methods and functions being
+ mocked will have their arguments checked and will raise a `TypeError` if
+ they are called with the wrong signature. For mocks replacing a class,
+ their return value (the 'instance') will have the same spec as the class.
+
+ Instead of `autospec=True` you can pass `autospec=some_object` to use an
+ arbitrary object as the spec instead of the one being replaced.
+
+ By default `patch` will fail to replace attributes that don't exist. If
+ you pass in `create=True`, and the attribute doesn't exist, patch will
+ create the attribute for you when the patched function is called, and
+ delete it again afterwards. This is useful for writing tests against
+ attributes that your production code creates at runtime. It is off by by
+ default because it can be dangerous. With it switched on you can write
+ passing tests against APIs that don't actually exist!
+
+ Patch can be used as a `TestCase` class decorator. It works by
+ decorating each test method in the class. This reduces the boilerplate
+ code when your test methods share a common patchings set. `patch` finds
+ tests by looking for method names that start with `patch.TEST_PREFIX`.
+ By default this is `test`, which matches the way `unittest` finds tests.
+ You can specify an alternative prefix by setting `patch.TEST_PREFIX`.
+
+ Patch can be used as a context manager, with the with statement. Here the
+ patching applies to the indented block after the with statement. If you
+ use "as" then the patched object will be bound to the name after the
+ "as"; very useful if `patch` is creating a mock object for you.
+
+ `patch` takes arbitrary keyword arguments. These will be passed to
+ the `Mock` (or `new_callable`) on construction.
+
+ `patch.dict(...)`, `patch.multiple(...)` and `patch.object(...)` are
+ available for alternate use-cases.
+ """
+ getter, attribute = _get_target(target)
+ return _patch(
+ getter, attribute, new, spec, create,
+ spec_set, autospec, new_callable, kwargs
+ )
+
+
+class _patch_dict(object):
+ """
+ Patch a dictionary, or dictionary like object, and restore the dictionary
+ to its original state after the test.
+
+ `in_dict` can be a dictionary or a mapping like container. If it is a
+ mapping then it must at least support getting, setting and deleting items
+ plus iterating over keys.
+
+ `in_dict` can also be a string specifying the name of the dictionary, which
+ will then be fetched by importing it.
+
+ `values` can be a dictionary of values to set in the dictionary. `values`
+ can also be an iterable of `(key, value)` pairs.
+
+ If `clear` is True then the dictionary will be cleared before the new
+ values are set.
+
+ `patch.dict` can also be called with arbitrary keyword arguments to set
+ values in the dictionary::
+
+ with patch.dict('sys.modules', mymodule=Mock(), other_module=Mock()):
+ ...
+
+ `patch.dict` can be used as a context manager, decorator or class
+ decorator. When used as a class decorator `patch.dict` honours
+ `patch.TEST_PREFIX` for choosing which methods to wrap.
+ """
+
+ def __init__(self, in_dict, values=(), clear=False, **kwargs):
+ if isinstance(in_dict, basestring):
+ in_dict = _importer(in_dict)
+ self.in_dict = in_dict
+ # support any argument supported by dict(...) constructor
+ self.values = dict(values)
+ self.values.update(kwargs)
+ self.clear = clear
+ self._original = None
+
+
+ def __call__(self, f):
+ if isinstance(f, ClassTypes):
+ return self.decorate_class(f)
+ @wraps(f)
+ def _inner(*args, **kw):
+ self._patch_dict()
+ try:
+ return f(*args, **kw)
+ finally:
+ self._unpatch_dict()
+
+ return _inner
+
+
+ def decorate_class(self, klass):
+ for attr in dir(klass):
+ attr_value = getattr(klass, attr)
+ if (attr.startswith(patch.TEST_PREFIX) and
+ hasattr(attr_value, "__call__")):
+ decorator = _patch_dict(self.in_dict, self.values, self.clear)
+ decorated = decorator(attr_value)
+ setattr(klass, attr, decorated)
+ return klass
+
+
+ def __enter__(self):
+ """Patch the dict."""
+ self._patch_dict()
+
+
+ def _patch_dict(self):
+ values = self.values
+ in_dict = self.in_dict
+ clear = self.clear
+
+ try:
+ original = in_dict.copy()
+ except AttributeError:
+ # dict like object with no copy method
+ # must support iteration over keys
+ original = {}
+ for key in in_dict:
+ original[key] = in_dict[key]
+ self._original = original
+
+ if clear:
+ _clear_dict(in_dict)
+
+ try:
+ in_dict.update(values)
+ except AttributeError:
+ # dict like object with no update method
+ for key in values:
+ in_dict[key] = values[key]
+
+
+ def _unpatch_dict(self):
+ in_dict = self.in_dict
+ original = self._original
+
+ _clear_dict(in_dict)
+
+ try:
+ in_dict.update(original)
+ except AttributeError:
+ for key in original:
+ in_dict[key] = original[key]
+
+
+ def __exit__(self, *args):
+ """Unpatch the dict."""
+ self._unpatch_dict()
+ return False
+
+ start = __enter__
+ stop = __exit__
+
+
+def _clear_dict(in_dict):
+ try:
+ in_dict.clear()
+ except AttributeError:
+ keys = list(in_dict)
+ for key in keys:
+ del in_dict[key]
+
+
+def _patch_stopall():
+ """Stop all active patches."""
+ for patch in list(_patch._active_patches):
+ patch.stop()
+
+
+patch.object = _patch_object
+patch.dict = _patch_dict
+patch.multiple = _patch_multiple
+patch.stopall = _patch_stopall
+patch.TEST_PREFIX = 'test'
+
+magic_methods = (
+ "lt le gt ge eq ne "
+ "getitem setitem delitem "
+ "len contains iter "
+ "hash str sizeof "
+ "enter exit "
+ "divmod neg pos abs invert "
+ "complex int float index "
+ "trunc floor ceil "
+)
+
+numerics = "add sub mul div floordiv mod lshift rshift and xor or pow "
+inplace = ' '.join('i%s' % n for n in numerics.split())
+right = ' '.join('r%s' % n for n in numerics.split())
+extra = ''
+if inPy3k:
+ extra = 'bool next '
+else:
+ extra = 'unicode long nonzero oct hex truediv rtruediv '
+
+# not including __prepare__, __instancecheck__, __subclasscheck__
+# (as they are metaclass methods)
+# __del__ is not supported at all as it causes problems if it exists
+
+_non_defaults = set('__%s__' % method for method in [
+ 'cmp', 'getslice', 'setslice', 'coerce', 'subclasses',
+ 'format', 'get', 'set', 'delete', 'reversed',
+ 'missing', 'reduce', 'reduce_ex', 'getinitargs',
+ 'getnewargs', 'getstate', 'setstate', 'getformat',
+ 'setformat', 'repr', 'dir'
+])
+
+
+def _get_method(name, func):
+ "Turns a callable object (like a mock) into a real function"
+ def method(self, *args, **kw):
+ return func(self, *args, **kw)
+ method.__name__ = name
+ return method
+
+
+_magics = set(
+ '__%s__' % method for method in
+ ' '.join([magic_methods, numerics, inplace, right, extra]).split()
+)
+
+_all_magics = _magics | _non_defaults
+
+_unsupported_magics = set([
+ '__getattr__', '__setattr__',
+ '__init__', '__new__', '__prepare__'
+ '__instancecheck__', '__subclasscheck__',
+ '__del__'
+])
+
+_calculate_return_value = {
+ '__hash__': lambda self: object.__hash__(self),
+ '__str__': lambda self: object.__str__(self),
+ '__sizeof__': lambda self: object.__sizeof__(self),
+ '__unicode__': lambda self: unicode(object.__str__(self)),
+}
+
+_return_values = {
+ '__lt__': NotImplemented,
+ '__gt__': NotImplemented,
+ '__le__': NotImplemented,
+ '__ge__': NotImplemented,
+ '__int__': 1,
+ '__contains__': False,
+ '__len__': 0,
+ '__exit__': False,
+ '__complex__': 1j,
+ '__float__': 1.0,
+ '__bool__': True,
+ '__nonzero__': True,
+ '__oct__': '1',
+ '__hex__': '0x1',
+ '__long__': long(1),
+ '__index__': 1,
+}
+
+
+def _get_eq(self):
+ def __eq__(other):
+ ret_val = self.__eq__._mock_return_value
+ if ret_val is not DEFAULT:
+ return ret_val
+ return self is other
+ return __eq__
+
+def _get_ne(self):
+ def __ne__(other):
+ if self.__ne__._mock_return_value is not DEFAULT:
+ return DEFAULT
+ return self is not other
+ return __ne__
+
+def _get_iter(self):
+ def __iter__():
+ ret_val = self.__iter__._mock_return_value
+ if ret_val is DEFAULT:
+ return iter([])
+ # if ret_val was already an iterator, then calling iter on it should
+ # return the iterator unchanged
+ return iter(ret_val)
+ return __iter__
+
+_side_effect_methods = {
+ '__eq__': _get_eq,
+ '__ne__': _get_ne,
+ '__iter__': _get_iter,
+}
+
+
+
+def _set_return_value(mock, method, name):
+ fixed = _return_values.get(name, DEFAULT)
+ if fixed is not DEFAULT:
+ method.return_value = fixed
+ return
+
+ return_calulator = _calculate_return_value.get(name)
+ if return_calulator is not None:
+ try:
+ return_value = return_calulator(mock)
+ except AttributeError:
+ # XXXX why do we return AttributeError here?
+ # set it as a side_effect instead?
+ return_value = AttributeError(name)
+ method.return_value = return_value
+ return
+
+ side_effector = _side_effect_methods.get(name)
+ if side_effector is not None:
+ method.side_effect = side_effector(mock)
+
+
+
+class MagicMixin(object):
+ def __init__(self, *args, **kw):
+ _super(MagicMixin, self).__init__(*args, **kw)
+ self._mock_set_magics()
+
+
+ def _mock_set_magics(self):
+ these_magics = _magics
+
+ if self._mock_methods is not None:
+ these_magics = _magics.intersection(self._mock_methods)
+
+ remove_magics = set()
+ remove_magics = _magics - these_magics
+
+ for entry in remove_magics:
+ if entry in type(self).__dict__:
+ # remove unneeded magic methods
+ delattr(self, entry)
+
+ # don't overwrite existing attributes if called a second time
+ these_magics = these_magics - set(type(self).__dict__)
+
+ _type = type(self)
+ for entry in these_magics:
+ setattr(_type, entry, MagicProxy(entry, self))
+
+
+
+class NonCallableMagicMock(MagicMixin, NonCallableMock):
+ """A version of `MagicMock` that isn't callable."""
+ def mock_add_spec(self, spec, spec_set=False):
+ """Add a spec to a mock. `spec` can either be an object or a
+ list of strings. Only attributes on the `spec` can be fetched as
+ attributes from the mock.
+
+ If `spec_set` is True then only attributes on the spec can be set."""
+ self._mock_add_spec(spec, spec_set)
+ self._mock_set_magics()
+
+
+
+class MagicMock(MagicMixin, Mock):
+ """
+ MagicMock is a subclass of Mock with default implementations
+ of most of the magic methods. You can use MagicMock without having to
+ configure the magic methods yourself.
+
+ If you use the `spec` or `spec_set` arguments then *only* magic
+ methods that exist in the spec will be created.
+
+ Attributes and the return value of a `MagicMock` will also be `MagicMocks`.
+ """
+ def mock_add_spec(self, spec, spec_set=False):
+ """Add a spec to a mock. `spec` can either be an object or a
+ list of strings. Only attributes on the `spec` can be fetched as
+ attributes from the mock.
+
+ If `spec_set` is True then only attributes on the spec can be set."""
+ self._mock_add_spec(spec, spec_set)
+ self._mock_set_magics()
+
+
+
+class MagicProxy(object):
+ def __init__(self, name, parent):
+ self.name = name
+ self.parent = parent
+
+ def __call__(self, *args, **kwargs):
+ m = self.create_mock()
+ return m(*args, **kwargs)
+
+ def create_mock(self):
+ entry = self.name
+ parent = self.parent
+ m = parent._get_child_mock(name=entry, _new_name=entry,
+ _new_parent=parent)
+ setattr(parent, entry, m)
+ _set_return_value(parent, m, entry)
+ return m
+
+ def __get__(self, obj, _type=None):
+ return self.create_mock()
+
+
+
+class _ANY(object):
+ "A helper object that compares equal to everything."
+
+ def __eq__(self, other):
+ return True
+
+ def __ne__(self, other):
+ return False
+
+ def __repr__(self):
+ return '<ANY>'
+
+ANY = _ANY()
+
+
+
+def _format_call_signature(name, args, kwargs):
+ message = '%s(%%s)' % name
+ formatted_args = ''
+ args_string = ', '.join([repr(arg) for arg in args])
+ kwargs_string = ', '.join([
+ '%s=%r' % (key, value) for key, value in kwargs.items()
+ ])
+ if args_string:
+ formatted_args = args_string
+ if kwargs_string:
+ if formatted_args:
+ formatted_args += ', '
+ formatted_args += kwargs_string
+
+ return message % formatted_args
+
+
+
+class _Call(tuple):
+ """
+ A tuple for holding the results of a call to a mock, either in the form
+ `(args, kwargs)` or `(name, args, kwargs)`.
+
+ If args or kwargs are empty then a call tuple will compare equal to
+ a tuple without those values. This makes comparisons less verbose::
+
+ _Call(('name', (), {})) == ('name',)
+ _Call(('name', (1,), {})) == ('name', (1,))
+ _Call(((), {'a': 'b'})) == ({'a': 'b'},)
+
+ The `_Call` object provides a useful shortcut for comparing with call::
+
+ _Call(((1, 2), {'a': 3})) == call(1, 2, a=3)
+ _Call(('foo', (1, 2), {'a': 3})) == call.foo(1, 2, a=3)
+
+ If the _Call has no name then it will match any name.
+ """
+ def __new__(cls, value=(), name=None, parent=None, two=False,
+ from_kall=True):
+ name = ''
+ args = ()
+ kwargs = {}
+ _len = len(value)
+ if _len == 3:
+ name, args, kwargs = value
+ elif _len == 2:
+ first, second = value
+ if isinstance(first, basestring):
+ name = first
+ if isinstance(second, tuple):
+ args = second
+ else:
+ kwargs = second
+ else:
+ args, kwargs = first, second
+ elif _len == 1:
+ value, = value
+ if isinstance(value, basestring):
+ name = value
+ elif isinstance(value, tuple):
+ args = value
+ else:
+ kwargs = value
+
+ if two:
+ return tuple.__new__(cls, (args, kwargs))
+
+ return tuple.__new__(cls, (name, args, kwargs))
+
+
+ def __init__(self, value=(), name=None, parent=None, two=False,
+ from_kall=True):
+ self.name = name
+ self.parent = parent
+ self.from_kall = from_kall
+
+
+ def __eq__(self, other):
+ if other is ANY:
+ return True
+ try:
+ len_other = len(other)
+ except TypeError:
+ return False
+
+ self_name = ''
+ if len(self) == 2:
+ self_args, self_kwargs = self
+ else:
+ self_name, self_args, self_kwargs = self
+
+ other_name = ''
+ if len_other == 0:
+ other_args, other_kwargs = (), {}
+ elif len_other == 3:
+ other_name, other_args, other_kwargs = other
+ elif len_other == 1:
+ value, = other
+ if isinstance(value, tuple):
+ other_args = value
+ other_kwargs = {}
+ elif isinstance(value, basestring):
+ other_name = value
+ other_args, other_kwargs = (), {}
+ else:
+ other_args = ()
+ other_kwargs = value
+ else:
+ # len 2
+ # could be (name, args) or (name, kwargs) or (args, kwargs)
+ first, second = other
+ if isinstance(first, basestring):
+ other_name = first
+ if isinstance(second, tuple):
+ other_args, other_kwargs = second, {}
+ else:
+ other_args, other_kwargs = (), second
+ else:
+ other_args, other_kwargs = first, second
+
+ if self_name and other_name != self_name:
+ return False
+
+ # this order is important for ANY to work!
+ return (other_args, other_kwargs) == (self_args, self_kwargs)
+
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+
+ def __call__(self, *args, **kwargs):
+ if self.name is None:
+ return _Call(('', args, kwargs), name='()')
+
+ name = self.name + '()'
+ return _Call((self.name, args, kwargs), name=name, parent=self)
+
+
+ def __getattr__(self, attr):
+ if self.name is None:
+ return _Call(name=attr, from_kall=False)
+ name = '%s.%s' % (self.name, attr)
+ return _Call(name=name, parent=self, from_kall=False)
+
+
+ def __repr__(self):
+ if not self.from_kall:
+ name = self.name or 'call'
+ if name.startswith('()'):
+ name = 'call%s' % name
+ return name
+
+ if len(self) == 2:
+ name = 'call'
+ args, kwargs = self
+ else:
+ name, args, kwargs = self
+ if not name:
+ name = 'call'
+ elif not name.startswith('()'):
+ name = 'call.%s' % name
+ else:
+ name = 'call%s' % name
+ return _format_call_signature(name, args, kwargs)
+
+
+ def call_list(self):
+ """For a call object that represents multiple calls, `call_list`
+ returns a list of all the intermediate calls as well as the
+ final call."""
+ vals = []
+ thing = self
+ while thing is not None:
+ if thing.from_kall:
+ vals.append(thing)
+ thing = thing.parent
+ return _CallList(reversed(vals))
+
+
+call = _Call(from_kall=False)
+
+
+
+def create_autospec(spec, spec_set=False, instance=False, _parent=None,
+ _name=None, **kwargs):
+ """Create a mock object using another object as a spec. Attributes on the
+ mock will use the corresponding attribute on the `spec` object as their
+ spec.
+
+ Functions or methods being mocked will have their arguments checked
+ to check that they are called with the correct signature.
+
+ If `spec_set` is True then attempting to set attributes that don't exist
+ on the spec object will raise an `AttributeError`.
+
+ If a class is used as a spec then the return value of the mock (the
+ instance of the class) will have the same spec. You can use a class as the
+ spec for an instance object by passing `instance=True`. The returned mock
+ will only be callable if instances of the mock are callable.
+
+ `create_autospec` also takes arbitrary keyword arguments that are passed to
+ the constructor of the created mock."""
+ if _is_list(spec):
+ # can't pass a list instance to the mock constructor as it will be
+ # interpreted as a list of strings
+ spec = type(spec)
+
+ is_type = isinstance(spec, ClassTypes)
+
+ _kwargs = {'spec': spec}
+ if spec_set:
+ _kwargs = {'spec_set': spec}
+ elif spec is None:
+ # None we mock with a normal mock without a spec
+ _kwargs = {}
+
+ _kwargs.update(kwargs)
+
+ Klass = MagicMock
+ if type(spec) in DescriptorTypes:
+ # descriptors don't have a spec
+ # because we don't know what type they return
+ _kwargs = {}
+ elif not _callable(spec):
+ Klass = NonCallableMagicMock
+ elif is_type and instance and not _instance_callable(spec):
+ Klass = NonCallableMagicMock
+
+ _new_name = _name
+ if _parent is None:
+ # for a top level object no _new_name should be set
+ _new_name = ''
+
+ mock = Klass(parent=_parent, _new_parent=_parent, _new_name=_new_name,
+ name=_name, **_kwargs)
+
+ if isinstance(spec, FunctionTypes):
+ # should only happen at the top level because we don't
+ # recurse for functions
+ mock = _set_signature(mock, spec)
+ else:
+ _check_signature(spec, mock, is_type, instance)
+
+ if _parent is not None and not instance:
+ _parent._mock_children[_name] = mock
+
+ if is_type and not instance and 'return_value' not in kwargs:
+ mock.return_value = create_autospec(spec, spec_set, instance=True,
+ _name='()', _parent=mock)
+
+ for entry in dir(spec):
+ if _is_magic(entry):
+ # MagicMock already does the useful magic methods for us
+ continue
+
+ if isinstance(spec, FunctionTypes) and entry in FunctionAttributes:
+ # allow a mock to actually be a function
+ continue
+
+ # XXXX do we need a better way of getting attributes without
+ # triggering code execution (?) Probably not - we need the actual
+ # object to mock it so we would rather trigger a property than mock
+ # the property descriptor. Likewise we want to mock out dynamically
+ # provided attributes.
+ # XXXX what about attributes that raise exceptions other than
+ # AttributeError on being fetched?
+ # we could be resilient against it, or catch and propagate the
+ # exception when the attribute is fetched from the mock
+ try:
+ original = getattr(spec, entry)
+ except AttributeError:
+ continue
+
+ kwargs = {'spec': original}
+ if spec_set:
+ kwargs = {'spec_set': original}
+
+ if not isinstance(original, FunctionTypes):
+ new = _SpecState(original, spec_set, mock, entry, instance)
+ mock._mock_children[entry] = new
+ else:
+ parent = mock
+ if isinstance(spec, FunctionTypes):
+ parent = mock.mock
+
+ new = MagicMock(parent=parent, name=entry, _new_name=entry,
+ _new_parent=parent, **kwargs)
+ mock._mock_children[entry] = new
+ skipfirst = _must_skip(spec, entry, is_type)
+ _check_signature(original, new, skipfirst=skipfirst)
+
+ # so functions created with _set_signature become instance attributes,
+ # *plus* their underlying mock exists in _mock_children of the parent
+ # mock. Adding to _mock_children may be unnecessary where we are also
+ # setting as an instance attribute?
+ if isinstance(new, FunctionTypes):
+ setattr(mock, entry, new)
+
+ return mock
+
+
+def _must_skip(spec, entry, is_type):
+ if not isinstance(spec, ClassTypes):
+ if entry in getattr(spec, '__dict__', {}):
+ # instance attribute - shouldn't skip
+ return False
+ spec = spec.__class__
+ if not hasattr(spec, '__mro__'):
+ # old style class: can't have descriptors anyway
+ return is_type
+
+ for klass in spec.__mro__:
+ result = klass.__dict__.get(entry, DEFAULT)
+ if result is DEFAULT:
+ continue
+ if isinstance(result, (staticmethod, classmethod)):
+ return False
+ return is_type
+
+ # shouldn't get here unless function is a dynamically provided attribute
+ # XXXX untested behaviour
+ return is_type
+
+
+def _get_class(obj):
+ try:
+ return obj.__class__
+ except AttributeError:
+ # in Python 2, _sre.SRE_Pattern objects have no __class__
+ return type(obj)
+
+
+class _SpecState(object):
+
+ def __init__(self, spec, spec_set=False, parent=None,
+ name=None, ids=None, instance=False):
+ self.spec = spec
+ self.ids = ids
+ self.spec_set = spec_set
+ self.parent = parent
+ self.instance = instance
+ self.name = name
+
+
+FunctionTypes = (
+ # python function
+ type(create_autospec),
+ # instance method
+ type(ANY.__eq__),
+ # unbound method
+ type(_ANY.__eq__),
+)
+
+FunctionAttributes = set([
+ 'func_closure',
+ 'func_code',
+ 'func_defaults',
+ 'func_dict',
+ 'func_doc',
+ 'func_globals',
+ 'func_name',
+])
+
+
+file_spec = None
+
+
+def mock_open(mock=None, read_data=''):
+ """
+ A helper function to create a mock to replace the use of `open`. It works
+ for `open` called directly or used as a context manager.
+
+ The `mock` argument is the mock object to configure. If `None` (the
+ default) then a `MagicMock` will be created for you, with the API limited
+ to methods or attributes available on standard file handles.
+
+ `read_data` is a string for the `read` method of the file handle to return.
+ This is an empty string by default.
+ """
+ global file_spec
+ if file_spec is None:
+ # set on first use
+ if inPy3k:
+ import _io
+ file_spec = list(set(dir(_io.TextIOWrapper)).union(set(dir(_io.BytesIO))))
+ else:
+ file_spec = file
+
+ if mock is None:
+ mock = MagicMock(name='open', spec=open)
+
+ handle = MagicMock(spec=file_spec)
+ handle.write.return_value = None
+ handle.__enter__.return_value = handle
+ handle.read.return_value = read_data
+
+ mock.return_value = handle
+ return mock
+
+
+class PropertyMock(Mock):
+ """
+ A mock intended to be used as a property, or other descriptor, on a class.
+ `PropertyMock` provides `__get__` and `__set__` methods so you can specify
+ a return value when it is fetched.
+
+ Fetching a `PropertyMock` instance from an object calls the mock, with
+ no args. Setting it calls the mock with the value being set.
+ """
+ def _get_child_mock(self, **kwargs):
+ return MagicMock(**kwargs)
+
+ def __get__(self, obj, obj_type):
+ return self()
+ def __set__(self, obj, val):
+ self(val)
diff --git a/test/runtests.py b/test/runtests.py
new file mode 100644
index 0000000..7664487
--- /dev/null
+++ b/test/runtests.py
@@ -0,0 +1,17 @@
+try:
+ import mvc
+except ImportError:
+ import os.path, sys
+ mvc_path = os.path.join(os.path.dirname(__file__), '..')
+ sys.path.append(mvc_path)
+
+from test_video import *
+from test_converter import *
+from test_conversion import *
+from test_utils import *
+
+if __name__ == "__main__":
+ import unittest
+ from mvc.widgets import initialize
+ initialize(None)
+ unittest.main()
diff --git a/test/test_conversion.py b/test/test_conversion.py
new file mode 100644
index 0000000..41bbf27
--- /dev/null
+++ b/test/test_conversion.py
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+import json
+import os.path
+import shutil
+import sys
+import tempfile
+import time
+
+from mvc import video
+from mvc import converter
+from mvc import conversion
+
+import base
+
+
+class FakeConverterInfo(converter.ConverterInfo):
+
+ extension = 'fake'
+
+ def get_executable(self):
+ return sys.executable
+
+ def get_arguments(self, video, output):
+ return ['-u', os.path.join(
+ os.path.dirname(__file__), 'testdata', 'fake_converter.py'),
+ video.filename, output]
+
+ def process_status_line(self, video, line):
+ return json.loads(line)
+
+
+class ConversionManagerTest(base.Test):
+
+ def setUp(self):
+ base.Test.setUp(self)
+ self.converter = FakeConverterInfo('Fake')
+ self.manager = conversion.ConversionManager()
+ self.temp_dir = tempfile.mkdtemp()
+ self.changes = []
+
+ def tearDown(self):
+ base.Test.tearDown(self)
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+ def changed(self, conversion):
+ self.changes.append(
+ {'status': conversion.status,
+ 'duration': conversion.duration,
+ 'progress': conversion.progress,
+ 'eta': conversion.eta
+ })
+
+ def spin(self, timeout):
+ finish_by = time.time() + timeout
+ while time.time() < finish_by and self.manager.running:
+ self.manager.check_notifications()
+ time.sleep(0.1)
+
+ def start_conversion(self, filename, timeout=3):
+ vf = video.VideoFile(filename)
+ c = self.manager.start_conversion(vf, self.converter)
+ # XXX HACK: for test harness change the output directory to be the
+ # same as the input file (so we can nuke in one go)
+ c.output_dir = os.path.dirname(filename)
+ c.output = os.path.join(c.output_dir,
+ self.converter.get_output_filename(vf))
+ c.listen(self.changed)
+ self.assertTrue(self.manager.running)
+ self.assertTrue(c in self.manager.in_progress)
+ self.spin(timeout)
+ self.assertFalse(self.manager.running)
+ self.assertFalse(os.path.exists(c.temp_output))
+ return c
+
+ def test_initial(self):
+ self.assertEqual(self.manager.notify_queue, set())
+ self.assertEqual(self.manager.in_progress, set())
+ self.assertFalse(self.manager.running)
+
+ def test_conversion(self):
+ filename = os.path.join(self.temp_dir, 'webm-0.webm')
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename)
+ c = self.start_conversion(filename)
+ self.assertEqual(c.status, 'finished')
+ self.assertEqual(c.progress, c.duration)
+ self.assertEqual(c.progress_percent, 1.0)
+ self.assertTrue(os.path.exists(c.output))
+ self.assertEqual(file(c.output).read(), 'blank')
+ self.assertEqual(self.changes, [
+ {'status': 'converting', 'duration': 5.0, 'eta': 5.0,
+ 'progress': 0.0},
+ {'status': 'converting', 'duration': 5.0, 'eta': 4.0,
+ 'progress': 1.0},
+ {'status': 'converting', 'duration': 5.0, 'eta': 3.0,
+ 'progress': 2.0},
+ {'status': 'converting', 'duration': 5.0, 'eta': 2.0,
+ 'progress': 3.0},
+ {'status': 'converting', 'duration': 5.0, 'eta': 1.0,
+ 'progress': 4.0},
+ {'status': 'finished', 'duration': 5.0, 'eta': 0.0,
+ 'progress': 5.0}
+ ])
+
+ def test_conversion_with_error(self):
+ filename = os.path.join(self.temp_dir, 'error.webm')
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename)
+ c = self.start_conversion(filename)
+ self.assertFalse(os.path.exists(c.output))
+ self.assertEqual(c.status, 'failed')
+ self.assertEqual(c.error, 'test error')
+
+ def test_conversion_with_missing_executable(self):
+ missing = sys.executable + '.does-not-exist'
+ self.converter.get_executable = lambda: missing
+ filename = os.path.join(self.temp_dir, 'webm-0.webm')
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename)
+ c = self.start_conversion(filename)
+ self.assertEqual(c.status, 'failed')
+ self.assertEqual(c.error, '%r does not exist' % missing)
+ self.assertFalse(os.path.exists(c.output))
+
+ def test_multiple_simultaneous_conversions(self):
+ filename = os.path.join(self.temp_dir, 'webm-0.webm')
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename)
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename + '2')
+ vf = video.VideoFile(filename)
+ vf2 = video.VideoFile(filename + '2')
+ c = self.manager.start_conversion(vf, self.converter)
+ c2 = self.manager.start_conversion(vf2, self.converter)
+ self.assertEqual(len(self.manager.in_progress), 2)
+ self.spin(3) # if they're linear, it should take < 5s
+ self.assertEqual(c.status, 'finished')
+ self.assertEqual(c2.status, 'finished')
+
+ def test_limit_simultaneous_conversions(self):
+ self.manager.simultaneous = 1
+ filename = os.path.join(self.temp_dir, 'webm-0.webm')
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename)
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename + '2')
+ vf = video.VideoFile(filename)
+ vf2 = video.VideoFile(filename + '2')
+ c = self.manager.start_conversion(vf, self.converter)
+ c2 = self.manager.start_conversion(vf2, self.converter)
+ self.assertEqual(len(self.manager.in_progress), 1)
+ self.assertEqual(len(self.manager.waiting), 1)
+ self.spin(5)
+ self.assertEqual(c.status, 'finished')
+ self.assertEqual(c2.status, 'finished')
+
+ def test_unicode_characters(self):
+ for filename in (
+ u'"TAKE2\'s" REHEARSAL човен поўны вуграмі',
+ u'ところで早け',
+ u'Lputefartøavål'):
+ full_filename = os.path.join(self.temp_dir, filename)
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ full_filename)
+ c = self.start_conversion(full_filename)
+ self.assertEqual(c.status, 'finished')
+ self.assertTrue(filename in c.output, '%r not in %r' % (
+ filename, c.output))
+
+ def test_overwrite(self):
+ filename = os.path.join(self.temp_dir, 'webm-0.webm')
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename)
+ c = self.start_conversion(filename)
+ self.assertEqual(c.status, 'finished')
+ c2 = self.start_conversion(filename)
+ self.assertEqual(c2.status, 'finished')
+ self.assertEqual(c2.output, c.output)
+
+ def test_stop(self):
+ filename = os.path.join(self.temp_dir, 'webm-0.webm')
+ shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'),
+ filename)
+ vf = video.VideoFile(filename)
+ c = self.manager.start_conversion(vf, self.converter)
+ time.sleep(0.5)
+ c.stop()
+ self.spin(1)
+ self.assertEqual(c.status, 'canceled')
+ self.assertEqual(c.error, 'manually stopped')
diff --git a/test/test_converter.py b/test/test_converter.py
new file mode 100644
index 0000000..736b5b0
--- /dev/null
+++ b/test/test_converter.py
@@ -0,0 +1,1330 @@
+import argparse
+import os.path
+
+from mvc.video import VideoFile
+from mvc import converter
+from mvc import settings
+
+import base
+import mock
+
+def make_ffmpeg_arg_parser():
+ """Make an args.ArgumentParser for ffmpeg args that we use
+ """
+ parser = argparse.ArgumentParser(argument_default=argparse.SUPPRESS)
+ # command line options that require an argument after
+ arguments = [
+ "-ab",
+ "-ac",
+ "-acodec",
+ "-aq",
+ "-ar",
+ "-b",
+ "-b:v",
+ "-bufsize",
+ "-crf",
+ "-cpu-used",
+ "-deadline",
+ "-f",
+ '-g',
+ "-i",
+ "-lag-in-frames",
+ "-level",
+ "-maxrate",
+ "-preset",
+ "-profile:v",
+ "-r",
+ "-s",
+ "-slices",
+ "-strict",
+ "-threads",
+ "-qmin",
+ "-qmax",
+ "-vb",
+ "-vcodec",
+ "-vprofile",
+ ]
+ # arguments that set flags
+ flags = [
+ '-vn',
+ ]
+ for name in arguments:
+ parser.add_argument(name)
+ for name in flags:
+ parser.add_argument(name, action='store_const', const=True)
+
+ parser.add_argument("output_file")
+ return parser
+
+class TestConverterInfo(converter.ConverterInfo):
+ media_type = 'video'
+ extension = 'test'
+ bitrate = 10000
+
+ def get_executable(self):
+ return '/bin/true'
+
+ def get_arguments(self, video, output):
+ return [video.filename, output]
+
+ def process_status_line(self, line):
+ return {'finished': True}
+
+
+TEST_CONVERTER = TestConverterInfo('Test Converter')
+
+
+class ConverterManagerTest(base.Test):
+
+ def setUp(self):
+ base.Test.setUp(self)
+ self.manager = converter.ConverterManager()
+
+ def test_startup(self):
+ self.manager.startup()
+ self.assertTrue(self.manager.converters)
+
+ def test_add_converter(self):
+ self.manager.add_converter(TEST_CONVERTER)
+ self.assertEqual(len(self.manager.converters), 1)
+
+ def test_list_converters(self):
+ self.manager.add_converter(TEST_CONVERTER)
+ self.assertEqual(list(self.manager.list_converters()),
+ [TEST_CONVERTER])
+
+ def test_get_by_id(self):
+ self.manager.add_converter(TEST_CONVERTER)
+ self.assertEqual(self.manager.get_by_id('testconverter'),
+ TEST_CONVERTER)
+ self.assertRaises(KeyError, self.manager.get_by_id,
+ 'doesnotexist')
+
+
+class ConverterInfoTest(base.Test):
+
+ def setUp(self):
+ base.Test.setUp(self)
+ self.converter_info = TEST_CONVERTER
+ self.video = VideoFile(os.path.join(self.testdata_dir, 'mp4-0.mp4'))
+
+ def test_identifer(self):
+ self.assertEqual(self.converter_info.identifier,
+ 'testconverter')
+
+ def test_get_output_filename(self):
+ self.assertEqual(self.converter_info.get_output_filename(self.video),
+ 'mp4-0.testconverter.test')
+
+ def test_get_output_size_guess(self):
+ self.assertEqual(self.converter_info.get_output_size_guess(self.video),
+ self.video.duration * self.converter_info.bitrate / 8)
+
+
+class ConverterInfoTestMixin(object):
+
+ def setUp(self):
+ self.video = VideoFile(os.path.join(self.testdata_dir, 'mp4-0.mp4'))
+
+ def assertStatusLineOutput(self, line, **output):
+ if not output:
+ output = None
+ self.assertEqual(self.converter_info.process_status_line(self.video,
+ line),
+ output)
+
+ def test_get_executable(self):
+ self.assertTrue(self.converter_info.get_executable())
+
+ def test_get_arguments(self):
+ output = os.path.join(self.testdata_dir, 'output.mp4')
+ arguments = self.converter_info.get_arguments(self.video, output)
+
+ self.assertTrue(arguments)
+ self.assertTrue(self.video.filename in arguments)
+ self.assertTrue(output in arguments)
+
+class FFmpegConverterInfoTest(ConverterInfoTestMixin, base.Test):
+
+ def setUp(self):
+ base.Test.setUp(self)
+ ConverterInfoTestMixin.setUp(self)
+ self.converter_info = converter.FFmpegConverterInfo('FFmpeg Test',
+ 1024, 768)
+ self.converter_info.parameters = '{ssize}'
+
+ def run_get_target_size(self, (src_width, src_height),
+ (dest_width, dest_height),
+ dont_upsize=True):
+ """Create a converter run get_target_size() on a video.
+ """
+ mock_video = mock.Mock(width=src_width, height=src_height)
+ converter_info = converter.FFmpegConverterInfo(
+ 'FFmpeg Test', dest_width, dest_height)
+ converter_info.dont_upsize = dont_upsize
+ return converter_info.get_target_size(mock_video)
+
+ def test_get_target_size(self):
+ self.assertEqual(self.run_get_target_size((1024, 768), (640, 480)),
+ (640, 480))
+
+ def test_get_target_size_rescale(self):
+ # Test get_target_size() rescaling an image. It should ensure that
+ # both dimensions fit inside the target image, and that the aspect
+ # ratio is unchanged.
+ self.assertEqual(self.run_get_target_size((1024, 768), (800, 500)),
+ (666, 500))
+
+ def test_get_target_size_dont_upsize(self):
+ # Test that get_target_size only upsizes when dont_upsize is True
+ self.assertEqual(self.run_get_target_size((640, 480), (800, 600)),
+ (640, 480))
+ self.assertEqual(self.run_get_target_size((640, 480), (800, 600),
+ dont_upsize=False),
+ (800, 600))
+
+ def test_process_status_line_nothing(self):
+ self.assertStatusLineOutput(
+ ' built on Mar 31 2012 09:58:16 with gcc 4.6.3')
+
+
+ def test_process_status_line_duration(self):
+ self.assertStatusLineOutput(
+ ' Duration: 00:00:01.07, start: 0.000000, bitrate: 128 kb/s',
+ duration=1.07)
+
+ def test_process_status_line_progress(self):
+ self.assertStatusLineOutput(
+ 'size= 2697kB time=00:02:52.59 bitrate= 128.0kbits/s ',
+ progress=172.59)
+
+ def test_process_status_line_progress_with_frame(self):
+ self.assertStatusLineOutput(
+ 'frame= 257 fps= 45 q=27.0 size= 1033kB time=00:00:08.70 '
+ 'bitrate= 971.4kbits/s ',
+ progress=8.7)
+
+ def test_process_status_line_finished(self):
+ self.assertStatusLineOutput(
+ 'frame=16238 fps= 37 q=-1.0 Lsize= 110266kB time=00:11:16.50 '
+ 'bitrate=1335.3kbits/s dup=16 drop=0',
+ finished=True)
+
+ def test_process_status_line_error(self):
+ line = ('Error while opening encoder for output stream #0:1 - '
+ 'maybe incorrect parameters such as bit_rate, rate, width or '
+ 'height')
+ self.assertStatusLineOutput(line,
+ finished=True,
+ error=line)
+
+ def test_process_status_line_unknown(self):
+ # XXX haven't actually seen this line
+ line = 'Unknown error'
+ self.assertStatusLineOutput(line,
+ finished=True,
+ error=line)
+
+ def test_process_status_line_error_decoding(self):
+ # XXX haven't actually seen this line
+ line = 'Error while decoding stream: something'
+ self.assertStatusLineOutput(line)
+
+class TestConverterDefinitions(base.Test):
+ def setUp(self):
+ base.Test.setUp(self)
+ self.manager = converter.ConverterManager()
+ self.manager.startup()
+ self.input_path = os.path.join(self.testdata_dir, 'mp4-0.mp4')
+ self.output_path = os.path.join(self.testdata_dir, 'output.mp4')
+
+ def get_converter_arguments(self, converter_obj):
+ """Given a converter, get the arguments to that converter
+
+ :returns: dict of arguments that were set
+ """
+
+ output_path = self.output_path
+ video_file = mock.Mock()
+ # Note: we purposely use weird width/height values here to ensure that
+ # they are different from the default size
+ video_file.width = 542
+ video_file.height = 320
+ video_file.filename = self.input_path
+ video_file.container = '#container_name#'
+ video_file.audio_only = False
+
+ cmdline_args = converter_obj.get_arguments(video_file, output_path)
+ return vars(make_ffmpeg_arg_parser().parse_args(cmdline_args))
+
+
+
+ parser = make_ffmpeg_arg_parser()
+ args = vars(parser.parse_args(cmdline_args))
+ return dict((k, args[k]) for k in args
+ if args[k] != parser.get_default(k))
+
+ def check_ffmpeg_arguments(self, converter_id, correct_arguments):
+ """Check the arguments of a ffmpeg-based converter."""
+ converter = self.manager.converters[converter_id]
+ self.assertEquals(converter.get_executable(),
+ settings.get_ffmpeg_executable_path())
+
+ self.assertEquals(self.get_converter_arguments(converter),
+ correct_arguments)
+
+ def check_size(self, converter_id, width, height):
+ converter = self.manager.converters[converter_id]
+ self.assertEquals(converter.width, width)
+ self.assertEquals(converter.height, height)
+ self.assertEquals(converter.dont_upsize, True)
+ self.assertEquals(converter.audio_only, False)
+
+ def check_uses_input_size(self, converter_id):
+ converter = self.manager.converters[converter_id]
+ self.assertEquals(converter.width, None)
+ self.assertEquals(converter.height, None)
+ self.assertEquals(converter.dont_upsize, True)
+ self.assertEquals(converter.audio_only, False)
+
+ def check_audio_only(self, converter_id):
+ converter = self.manager.converters[converter_id]
+ self.assertEquals(converter.audio_only, True)
+
+ def test_all_converters_checked(self):
+ for converter_id in self.manager.converters.keys():
+ if not hasattr(self, "test_%s" % converter_id):
+ raise AssertionError("No test for converter: %s" %
+ converter_id)
+
+ def test_droid(self):
+ self.check_ffmpeg_arguments('droid', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('droid', 854, 480)
+
+ def test_proresingest720p(self):
+ self.check_ffmpeg_arguments('proresingest720p', {
+ 'acodec': 'pcm_s16be',
+ 'ar': '48000',
+ 'f': 'mov',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'profile:v': '2',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'prores',
+ })
+ self.check_size('proresingest720p', 1080, 720)
+
+ def test_droidx2(self):
+ self.check_ffmpeg_arguments('droidx2', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('droidx2', 1280, 720)
+
+ def test_sensation(self):
+ self.check_ffmpeg_arguments('sensation', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('sensation', 960, 540)
+
+ def test_avcintra1080p(self):
+ self.check_ffmpeg_arguments('avcintra1080p', {
+ 'acodec': 'pcm_s16be',
+ 'ar': '48000',
+ 'f': 'mov',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'profile:v': '2',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'prores',
+ })
+ self.check_size('avcintra1080p', 1920, 1080)
+
+ def test_mp4(self):
+ self.check_ffmpeg_arguments('mp4', {
+ 'ab': '96k',
+ 'acodec': 'aac',
+ 'crf': '22',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'libx264',
+ })
+ self.check_uses_input_size('mp4')
+
+ def test_ipodtouch4(self):
+ self.check_ffmpeg_arguments('ipodtouch4', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('ipodtouch4', 960, 640)
+
+ def test_mp3(self):
+ self.check_ffmpeg_arguments('mp3', {
+ 'ac': '2',
+ 'f': 'mp3',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'strict': 'experimental',
+ })
+ self.check_audio_only('mp3')
+
+ def test_proresingest1080p(self):
+ self.check_ffmpeg_arguments('proresingest1080p', {
+ 'acodec': 'pcm_s16be',
+ 'ar': '48000',
+ 'f': 'mov',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'profile:v': '2',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'prores',
+ })
+ self.check_size('proresingest1080p', 1920, 1080)
+
+ def test_galaxyinfuse(self):
+ self.check_ffmpeg_arguments('galaxyinfuse', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxyinfuse', 1280, 800)
+
+ def test_ipodnanoclassic(self):
+ self.check_ffmpeg_arguments('ipodnanoclassic', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('ipodnanoclassic', 480, 320)
+
+ def test_oggvorbis(self):
+ self.check_ffmpeg_arguments('oggvorbis', {
+ 'acodec': 'libvorbis',
+ 'aq': '60',
+ 'f': 'ogg',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'strict': 'experimental',
+ 'vn': True,
+ })
+ self.check_audio_only('oggvorbis')
+
+ def test_wildfire(self):
+ self.check_ffmpeg_arguments('wildfire', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '320x188',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('wildfire', 320, 240)
+
+ def test_ipad(self):
+ self.check_ffmpeg_arguments('ipad', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('ipad', 1024, 768)
+
+ def test_galaxyadmire(self):
+ self.check_ffmpeg_arguments('galaxyadmire', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxyadmire', 480, 320)
+
+ def test_droidincredible(self):
+ self.check_ffmpeg_arguments('droidincredible', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('droidincredible', 800, 480)
+
+ def test_sameformat(self):
+ self.check_ffmpeg_arguments('sameformat', {
+ 'acodec': 'copy',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'copy',
+ 'f': '#container_name#',
+ })
+ self.check_uses_input_size('sameformat')
+
+ def test_zio(self):
+ self.check_ffmpeg_arguments('zio', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('zio', 800, 480)
+
+ def test_galaxycharge(self):
+ self.check_ffmpeg_arguments('galaxycharge', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxycharge', 800, 480)
+
+ def test_large1080p(self):
+ self.check_ffmpeg_arguments('large1080p', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('large1080p', 1920, 1080)
+
+ def test_appletv(self):
+ self.check_ffmpeg_arguments('appletv', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('appletv', 1280, 720)
+
+ def test_playstationportable(self):
+ self.check_ffmpeg_arguments('playstationportable', {
+ 'ab': '64000',
+ 'ar': '24000',
+ 'b': '512000',
+ 'f': 'psp',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'r': '29.97',
+ 's': '320x188',
+ 'strict': 'experimental',
+ })
+ self.check_size('playstationportable', 320, 240)
+
+ def test_rezound(self):
+ self.check_ffmpeg_arguments('rezound', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('rezound', 1280, 720)
+
+ def test_large720p(self):
+ self.check_ffmpeg_arguments('large720p', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('large720p', 1280, 720)
+
+ def test_iphone(self):
+ self.check_ffmpeg_arguments('iphone', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('iphone', 640, 480)
+
+ def test_galaxyy(self):
+ self.check_ffmpeg_arguments('galaxyy', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '320x188',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxyy', 320, 240)
+
+ def test_webmhd(self):
+ self.check_ffmpeg_arguments('webmhd', {
+ 'ab': '112k',
+ 'acodec': 'libvorbis',
+ 'ar': '44100',
+ 'b:v': '2M',
+ 'cpu_used': '0',
+ 'deadline': 'good',
+ 'f': 'webm',
+ 'g': '120',
+ 'i': self.input_path,
+ 'lag_in_frames': '16',
+ 'output_file': self.output_path,
+ 'qmax': '51',
+ 'qmin': '11',
+ 's': '542x320',
+ 'slices': '4',
+ 'strict': 'experimental',
+ 'vcodec': 'libvpx',
+ 'vprofile': '0',
+ })
+ self.check_size('webmhd', 1080, 720)
+
+ def test_galaxytab101(self):
+ self.check_ffmpeg_arguments('galaxytab101', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxytab101', 1280, 800)
+
+ def test_galaxynexus(self):
+ self.check_ffmpeg_arguments('galaxynexus', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxynexus', 1280, 720)
+
+ def test_galaxysiii(self):
+ self.check_ffmpeg_arguments('galaxysiii', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxysiii', 1280, 720)
+
+ def test_desire(self):
+ self.check_ffmpeg_arguments('desire', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('desire', 800, 480)
+
+ def test_galaxynoteii(self):
+ self.check_ffmpeg_arguments('galaxynoteii', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxynoteii', 1920, 1080)
+
+ def test_thunderbolt(self):
+ self.check_ffmpeg_arguments('thunderbolt', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('thunderbolt', 800, 480)
+
+ def test_xoom(self):
+ self.check_ffmpeg_arguments('xoom', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('xoom', 1280, 800)
+
+ def test_normal800x480(self):
+ self.check_ffmpeg_arguments('normal800x480', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('normal800x480', 800, 480)
+
+ def test_galaxyepic(self):
+ self.check_ffmpeg_arguments('galaxyepic', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxyepic', 800, 480)
+
+ def test_avcintra720p(self):
+ self.check_ffmpeg_arguments('avcintra720p', {
+ 'acodec': 'pcm_s16be',
+ 'ar': '48000',
+ 'f': 'mov',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'profile:v': '2',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'prores',
+ })
+ self.check_size('avcintra720p', 1080, 720)
+
+ def test_dnxhd720p(self):
+ self.check_ffmpeg_arguments('dnxhd720p', {
+ 'acodec': 'pcm_s16be',
+ 'ar': '48000',
+ 'b:v': '175M',
+ 'f': 'mov',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'r': '23.976',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'dnxhd',
+ })
+ self.check_size('dnxhd720p', 1080, 720)
+
+ def test_iphone5(self):
+ self.check_ffmpeg_arguments('iphone5', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('iphone5', 1920, 1080)
+
+ def test_webmsd(self):
+ self.check_ffmpeg_arguments('webmsd', {
+ 'ab': '112k',
+ 'acodec': 'libvorbis',
+ 'ar': '44100',
+ 'b:v': '768k',
+ 'cpu_used': '0',
+ 'deadline': 'good',
+ 'f': 'webm',
+ 'g': '120',
+ 'i': self.input_path,
+ 'lag_in_frames': '16',
+ 'output_file': self.output_path,
+ 'qmax': '53',
+ 'qmin': '0',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'libvpx',
+ 'vprofile': '0',
+ })
+ self.check_size('webmsd', 720, 480)
+
+ def test_galaxymini(self):
+ self.check_ffmpeg_arguments('galaxymini', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '320x188',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxymini', 320, 240)
+
+ def test_onex(self):
+ self.check_ffmpeg_arguments('onex', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('onex', 1280, 720)
+
+ def test_oggtheora(self):
+ self.check_ffmpeg_arguments('oggtheora', {
+ 'acodec': 'libvorbis',
+ 'aq': '60',
+ 'f': 'ogg',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'libtheora',
+ })
+ self.check_uses_input_size('oggtheora')
+
+ def test_ipad3(self):
+ self.check_ffmpeg_arguments('ipad3', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('ipad3', 1920, 1080)
+
+ def test_galaxyssiisplus(self):
+ self.check_ffmpeg_arguments('galaxyssiisplus', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxyssiisplus', 800, 480)
+
+ def test_kindlefire(self):
+ self.check_ffmpeg_arguments('kindlefire', {
+ 'ab': '96k',
+ 'acodec': 'aac',
+ 'crf': '22',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('kindlefire', 1224, 600)
+
+ def test_galaxyace(self):
+ self.check_ffmpeg_arguments('galaxyace', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxyace', 480, 320)
+
+ def test_dnxhd1080p(self):
+ self.check_ffmpeg_arguments('dnxhd1080p', {
+ 'acodec': 'pcm_s16be',
+ 'ar': '48000',
+ 'b:v': '175M',
+ 'f': 'mov',
+ 'i': self.input_path,
+ 'output_file': self.output_path,
+ 'r': '23.976',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'vcodec': 'dnxhd',
+ })
+ self.check_size('dnxhd1080p', 1920, 1080)
+
+ def test_small480x320(self):
+ self.check_ffmpeg_arguments('small480x320', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('small480x320', 480, 320)
+
+ def test_iphone4(self):
+ self.check_ffmpeg_arguments('iphone4', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('iphone4', 960, 640)
+
+ def test_razr(self):
+ self.check_ffmpeg_arguments('razr', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('razr', 960, 540)
+
+ def test_ipodtouch(self):
+ self.check_ffmpeg_arguments('ipodtouch', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('ipodtouch', 640, 480)
+
+ def test_galaxytab(self):
+ self.check_ffmpeg_arguments('galaxytab', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('galaxytab', 1024, 600)
+
+ def test_appleuniversal(self):
+ self.check_ffmpeg_arguments('appleuniversal', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vb': '1200k',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('appleuniversal', 1280, 720)
+
+ def test_evo4g(self):
+ self.check_ffmpeg_arguments('evo4g', {
+ 'ab': '160k',
+ 'ac': '2',
+ 'acodec': 'aac',
+ 'bufsize': '10000000',
+ 'f': 'mp4',
+ 'i': self.input_path,
+ 'level': '30',
+ 'maxrate': '10000000',
+ 'output_file': self.output_path,
+ 'preset': 'slow',
+ 'profile:v': 'baseline',
+ 's': '542x320',
+ 'strict': 'experimental',
+ 'threads': '0',
+ 'vcodec': 'libx264',
+ })
+ self.check_size('evo4g', 800, 480)
diff --git a/test/test_utils.py b/test/test_utils.py
new file mode 100644
index 0000000..1eb16c6
--- /dev/null
+++ b/test/test_utils.py
@@ -0,0 +1,48 @@
+from StringIO import StringIO
+
+from mvc import utils
+
+import base
+
+class UtilsTest(base.Test):
+
+ def test_hms_to_seconds(self):
+ self.assertEqual(utils.hms_to_seconds(3, 2, 1),
+ 3 * 3600 +
+ 2 * 60 +
+ 1)
+
+ def test_hms_to_seconds_floats(self):
+ self.assertEqual(utils.hms_to_seconds(3.0, 2.0, 1.5),
+ 3 * 3600 +
+ 2 * 60 +
+ 1.5)
+
+ def test_round_even(self):
+ self.assertEqual(utils.round_even(-1), 0)
+ self.assertEqual(utils.round_even(0), 0)
+ self.assertEqual(utils.round_even(1), 0)
+ self.assertEqual(utils.round_even(2), 2)
+ self.assertEqual(utils.round_even(2.5), 2)
+ self.assertEqual(utils.round_even(3), 2)
+
+ def test_rescale_video(self):
+ target = (1024, 768)
+ self.assertEqual(utils.rescale_video(target, target),
+ target)
+ self.assertEqual(utils.rescale_video((512, 384), target), # small
+ (512, 384))
+ self.assertEqual(utils.rescale_video((2048, 1536), target), # big
+ target)
+ self.assertEqual(utils.rescale_video((1400, 768), target,
+ dont_upsize=False), # widescreen
+ (1024, 560))
+
+ def test_line_reader(self):
+ lines = """line1
+line2
+line3\rline4\r
+line5"""
+ expected = ['line1', 'line2', 'line3', 'line4', 'line5']
+ self.assertEqual(list(utils.line_reader(StringIO(lines))), expected)
+
diff --git a/test/test_video.py b/test/test_video.py
new file mode 100644
index 0000000..821066f
--- /dev/null
+++ b/test/test_video.py
@@ -0,0 +1,258 @@
+import os, os.path
+import tempfile
+import threading
+import unittest
+
+import mock
+
+from mvc import video
+import base
+
+class GetMediaInfoTest(base.Test):
+
+ def assertClose(self, output, expected):
+ diff = output - expected
+ self.assertTrue(diff ** 2 < 0.04, # abs(diff) < 0.2
+ "%s != %s" % (output, expected))
+
+ def assertEqualOutput(self, filename, expected):
+ full_path = os.path.join(self.testdata_dir, filename)
+ try:
+ output = video.get_media_info(full_path)
+ except Exception, e:
+ raise AssertionError(
+ 'Error parsing %r\nException: %r\nOutput: %s' % (
+ filename, e, video.get_ffmpeg_output(full_path)))
+ duration_output = output.pop('duration', None)
+ duration_expected = expected.pop('duration', None)
+ if duration_output is not None and duration_expected is not None:
+ self.assertClose(duration_output, duration_expected)
+ else:
+ # put them back in, let assertEqual handle the difference
+ output['duration'] = duration_output
+ expected['duration'] = duration_expected
+ self.assertEqual(output, expected)
+
+ def test_mp3_0(self):
+ self.assertEqualOutput('mp3-0.mp3',
+ {'container': 'mp3',
+ 'audio_codec': 'mp3',
+ 'title': 'Invisible Walls',
+ 'artist': 'Revolution Void',
+ 'album': 'Increase The Dosage',
+ 'track': '1',
+ 'genre': 'Blues',
+ 'duration': 1.07})
+
+ def test_mp3_1(self):
+ self.assertEqualOutput('mp3-1.mp3',
+ {'container': 'mp3',
+ 'audio_codec': 'mp3',
+ 'title': 'Race Lieu',
+ 'artist': 'Ckz',
+ 'album': 'The Heart EP',
+ 'track': '2/5',
+ 'duration': 1.07})
+
+ def test_mp3_2(self):
+ self.assertEqualOutput('mp3-2.mp3',
+ {'container': 'mp3',
+ 'audio_codec': 'mp3',
+ 'artist': 'This American Life',
+ 'genre': 'Podcast',
+ 'title': '#426: Tough Room 2011',
+ 'duration': 1.09})
+
+ def test_theora(self):
+ self.assertEqualOutput('theora.ogv',
+ {'container': 'ogg',
+ 'video_codec': 'theora',
+ 'audio_codec': 'vorbis',
+ 'width': 400,
+ 'height': 304,
+ 'duration': 5.0})
+
+ def test_theora_with_ogg_extension(self):
+ self.assertEqualOutput('theora_with_ogg_extension.ogg',
+ {'container': 'ogg',
+ 'video_codec': 'theora',
+ 'width': 320,
+ 'height': 240,
+ 'duration': 0.1})
+
+ def test_webm_0(self):
+ self.assertEqualOutput('webm-0.webm',
+ {'container': ['matroska', 'webm'],
+ 'video_codec': 'vp8',
+ 'width': 1920,
+ 'height': 912,
+ 'duration': 0.43})
+
+ def test_mp4_0(self):
+ self.assertEqualOutput('mp4-0.mp4',
+ {'container': ['mov',
+ 'mp4',
+ 'm4a',
+ '3gp',
+ '3g2',
+ 'mj2',
+ 'isom',
+ 'mp41'],
+ 'video_codec': 'h264',
+ 'audio_codec': 'aac',
+ 'width': 640,
+ 'height': 480,
+ 'title': 'Africa: Cash for Climate Change?',
+ 'duration': 312.37})
+
+ def test_nuls(self):
+ self.assertEqualOutput('nuls.mp3',
+ {'container': 'mp3',
+ 'title': 'Invisible'})
+
+ @unittest.skip('inconsistent parsing of DRMed files')
+ def test_drm(self):
+ self.assertEqualOutput('drm.m4v',
+ {'container': ['mov',
+ 'mp4',
+ 'm4a',
+ '3gp',
+ '3g2',
+ 'mj2',
+ 'M4V',
+ 'M4V ',
+ 'mp42',
+ 'isom'],
+ 'video_codec': 'none',
+ 'audio_codec': 'aac',
+ 'has_drm': ['audio', 'video'],
+ 'width': 640,
+ 'height': 480,
+ 'title': 'Thinkers',
+ 'artist': 'The Most Extreme',
+ 'album': 'The Most Extreme',
+ 'track': '10',
+ 'genre': 'Nonfiction',
+ 'duration': 2668.8})
+
+
+
+class GetThumbnailTest(base.Test):
+
+ def setUp(self):
+ base.Test.setUp(self)
+ self.video_path = os.path.join(self.testdata_dir,
+ 'theora.ogv')
+ self.temp_path = tempfile.NamedTemporaryFile(
+ suffix='.png')
+
+ def generate_thumbnail(self, width, height):
+ completion = mock.Mock()
+ with mock.patch('mvc.video.idle_add') as mock_idle_add:
+ with mock.patch('threading.Thread') as mock_thread:
+ video.get_thumbnail(self.video_path, width, height,
+ self.temp_path.name, completion,
+ skip=0)
+ # get_thumbnail() creates a thread to create the thumbnail.
+ # Run the function for that thread now.
+ mock_thread.call_args[1]['target']()
+ self.assertEquals(mock_idle_add.call_count, 1)
+ # At the end of the thread it uses add_idle() to call the
+ # completion function. Run that now.
+ mock_idle_add.call_args[0][0]()
+ # Now when we call get_thumbnail() it should return
+ # immediately with the thumbnail
+ path = completion.call_args[0][0]
+ self.assertNotEquals(path, None)
+ return video.VideoFile(path)
+
+ def test_original_size(self):
+ thumbnail = self.generate_thumbnail(-1, -1)
+ self.assertEqual(thumbnail.width, 400)
+ self.assertEqual(thumbnail.height, 304)
+
+ def test_height_resize(self):
+ thumbnail = self.generate_thumbnail(200, -1)
+ self.assertEqual(thumbnail.width, 200)
+ self.assertEqual(thumbnail.height, 152)
+
+ def test_width_resize(self):
+ thumbnail = self.generate_thumbnail(-1, 152)
+ self.assertEqual(thumbnail.width, 200)
+ self.assertEqual(thumbnail.height, 152)
+
+ def test_both_resize(self):
+ thumbnail = self.generate_thumbnail(100, 100)
+ self.assertEqual(thumbnail.width, 100)
+ self.assertEqual(thumbnail.height, 100)
+
+class VideoFileTest(base.Test):
+
+ def setUp(self):
+ base.Test.setUp(self)
+ self.video_path = os.path.join(self.testdata_dir,
+ 'theora.ogv')
+ self.video = video.VideoFile(self.video_path)
+ self.video.thumbnails = {}
+
+ def get_thumbnail_from_video(self, **kwargs):
+ """Run Video.get_thumbnail()
+
+ This method uses mock to intercept the threading and idle_add calls and
+ just runs the code in the current thread
+ """
+ completion = mock.Mock()
+ with mock.patch('mvc.video.idle_add') as mock_idle_add:
+ with mock.patch('threading.Thread') as mock_thread:
+ initial_rv = self.video.get_thumbnail(completion, **kwargs)
+ if initial_rv is not None:
+ # we already had a thumbnail and didn't have to do
+ # anything synchrously
+ return video.VideoFile(initial_rv)
+ # We don't already have a thumbnail, so get_thumbnail()
+ # created a thread to create it. Run the function for that
+ # thread.
+ mock_thread.call_args[1]['target']()
+ self.assertEquals(mock_idle_add.call_count, 1)
+ # At the end of the thread it uses add_idle() to call the
+ # completion function. Run that now.
+ mock_idle_add.call_args[0][0]()
+ # Now when we call get_thumbnail() it should return
+ # immediately with the thumbnail
+ path = self.video.get_thumbnail(completion, **kwargs)
+ self.assertNotEquals(path, None)
+ return video.VideoFile(path)
+
+ def test_get_thumbnail_original_size(self):
+ thumbnail = self.get_thumbnail_from_video()
+ self.assertEqual(thumbnail.width, 400)
+ self.assertEqual(thumbnail.height, 304)
+
+ def test_get_thumbnail_scaled_width(self):
+ thumbnail = self.get_thumbnail_from_video(width=200)
+ self.assertEqual(thumbnail.width, 200)
+ self.assertEqual(thumbnail.height, 152)
+
+ def test_get_thumbnail_scaled_height(self):
+ thumbnail = self.get_thumbnail_from_video(height=152)
+ self.assertEqual(thumbnail.width, 200)
+ self.assertEqual(thumbnail.height, 152)
+
+ def test_get_thumbnail_scaled_both(self):
+ thumbnail = self.get_thumbnail_from_video(width=100, height=100)
+ self.assertEqual(thumbnail.width, 100)
+ self.assertEqual(thumbnail.height, 100)
+
+ def test_get_thumbnail_cache(self):
+ thumbnail = self.get_thumbnail_from_video()
+ thumbnail2 = self.get_thumbnail_from_video()
+ self.assertEqual(thumbnail.filename,
+ thumbnail2.filename)
+
+ def test_get_thumbnail_audio(self):
+ audio_path = os.path.join(self.testdata_dir, 'mp3-0.mp3')
+ audio = video.VideoFile(audio_path)
+ def complete():
+ pass
+ self.assertEqual(audio.get_thumbnail(complete), None)
+ self.assertEqual(audio.get_thumbnail(complete, 90, 70), None)
diff --git a/test/testdata/drm.m4v b/test/testdata/drm.m4v
new file mode 100644
index 0000000..e09787c
--- /dev/null
+++ b/test/testdata/drm.m4v
Binary files differ
diff --git a/test/testdata/fake_converter.py b/test/testdata/fake_converter.py
new file mode 100644
index 0000000..f9ef102
--- /dev/null
+++ b/test/testdata/fake_converter.py
@@ -0,0 +1,31 @@
+import time
+import sys
+import os
+import json
+
+filename, output = sys.argv[1:3]
+if 'error' in filename:
+ print json.dumps({'finished': True, 'error': 'test error'})
+ sys.exit(1)
+
+if os.path.exists(output):
+ print json.dumps({'finished': True,
+ 'error': '%r existed when we started' % (
+ output,)})
+ sys.exit(1)
+
+time.sleep(0.5)
+RANGE = 5
+for i in range(RANGE):
+ print json.dumps({
+ 'filename': filename,
+ 'output': output,
+ 'duration': RANGE,
+ 'progress': i,
+ 'eta': RANGE - i
+ })
+ time.sleep(0.1)
+
+with file(output, 'w') as f:
+ f.write('blank')
+print json.dumps({'finished': True})
diff --git a/test/testdata/mp3-0.mp3 b/test/testdata/mp3-0.mp3
new file mode 100644
index 0000000..3676cb0
--- /dev/null
+++ b/test/testdata/mp3-0.mp3
Binary files differ
diff --git a/test/testdata/mp3-1.mp3 b/test/testdata/mp3-1.mp3
new file mode 100644
index 0000000..4a17206
--- /dev/null
+++ b/test/testdata/mp3-1.mp3
Binary files differ
diff --git a/test/testdata/mp3-2.mp3 b/test/testdata/mp3-2.mp3
new file mode 100644
index 0000000..c7db703
--- /dev/null
+++ b/test/testdata/mp3-2.mp3
Binary files differ
diff --git a/test/testdata/mp4-0.mp4 b/test/testdata/mp4-0.mp4
new file mode 100644
index 0000000..e91af55
--- /dev/null
+++ b/test/testdata/mp4-0.mp4
Binary files differ
diff --git a/test/testdata/nuls.mp3 b/test/testdata/nuls.mp3
new file mode 100644
index 0000000..7cbad18
--- /dev/null
+++ b/test/testdata/nuls.mp3
Binary files differ
diff --git a/test/testdata/theora.ogv b/test/testdata/theora.ogv
new file mode 100644
index 0000000..395db96
--- /dev/null
+++ b/test/testdata/theora.ogv
Binary files differ
diff --git a/test/testdata/theora_with_ogg_extension.ogg b/test/testdata/theora_with_ogg_extension.ogg
new file mode 100644
index 0000000..6693756
--- /dev/null
+++ b/test/testdata/theora_with_ogg_extension.ogg
Binary files differ
diff --git a/test/testdata/webm-0.webm b/test/testdata/webm-0.webm
new file mode 100644
index 0000000..7f472c9
--- /dev/null
+++ b/test/testdata/webm-0.webm
Binary files differ
diff --git a/test/uitests.sikuli/config.py b/test/uitests.sikuli/config.py
new file mode 100644
index 0000000..342bbdc
--- /dev/null
+++ b/test/uitests.sikuli/config.py
@@ -0,0 +1,47 @@
+#config.py
+import os
+import time
+from sikuli.Sikuli import *
+
+
+def set_image_dirs():
+ """Set the Sikuli image path for the os specific image directory and the main Image dir.
+
+ """
+ os_imgs = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Images_"+get_os_name())
+ imgs = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Images")
+
+ dir_list = [imgs, os_imgs]
+ #Add the image dirs to the sikuli search path if it is not in there already
+ for d in dir_list:
+ if d not in list(getImagePath()):
+ addImagePath(d)
+
+def get_os_name():
+ """Returns the os string for the SUT
+ """
+ if "MAC" in str(Env.getOS()):
+ return "osx"
+ elif "WINDOWS" in str(Env.getOS()):
+ return "win"
+ elif "LINUX" in str(Env.getOS()):
+ return "lin"
+ else:
+ print ("I don't know how to handle platform '%s'", Env.getOS())
+
+
+def launch_cmd():
+ """Returns the launch path for the application.
+
+ launch is an os specific command
+ """
+ if get_os_name() == "osx":
+ launch_cmd = "/Applications/Miro Video Converter.app"
+ elif get_os_name() == "win":
+ launch_cmd = os.path.join(os.getenv("PROGRAMFILES"),"Participatory Culture Foundation","Miro Video Converter","MiroConverter.exe")
+ else:
+ print get_os_name()
+ print launch_cmd
+ return launch_cmd
+
+
diff --git a/test/uitests.sikuli/datafiles.py b/test/uitests.sikuli/datafiles.py
new file mode 100644
index 0000000..4db8b27
--- /dev/null
+++ b/test/uitests.sikuli/datafiles.py
@@ -0,0 +1,170 @@
+# Default Device Conversion Parameters
+import os
+
+class TestData(object):
+ _UNITTESTFILES = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)),"..","..","..",'testdata'))
+ _SIKTESTFILES = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)),'testdata'))
+
+ _FILES = {
+ 'mp3-0.mp3': {
+ 'testdir': _UNITTESTFILES,
+ 'container': 'mp3',
+ 'audio_codec': 'mp3',
+ 'title': 'Invisible Walls',
+ 'artist': 'Revolution Void',
+ 'album': 'Increase The Dosage',
+ 'track': '1',
+ 'genre': 'Blues',
+ 'duration': 1.07
+ },
+ 'mp3-1.mp3':
+ {
+ 'testdir': _UNITTESTFILES,
+ 'container': 'mp3',
+ 'audio_codec': 'mp3',
+ 'title': 'Race Lieu',
+ 'artist': 'Ckz',
+ 'album': 'The Heart EP',
+ 'track': '2/5',
+ 'duration': 1.07
+ },
+
+ 'mp3-2.mp3':
+ {
+ 'testdir': _UNITTESTFILES,
+ 'container': 'mp3',
+ 'audio_codec': 'mp3',
+ 'artist': 'This American Life',
+ 'genre': 'Podcast',
+ 'title': '#426: Tough Room 2011',
+ 'duration': 1.09
+ },
+
+ 'theora_with_ogg_extension.ogg':
+ {
+ 'testdir': _UNITTESTFILES,
+ 'container': 'ogg',
+ 'video_codec': 'theora',
+ 'width': 320,
+ 'height': 240,
+ 'duration': 0.1},
+
+ 'webm-0.webm':
+ {'testdir': _UNITTESTFILES,
+ 'container': ['matroska', 'webm'],
+ 'video_codec': 'vp8',
+ 'width': 1920,
+ 'height': 912,
+ 'duration': 0.43},
+
+ 'mp4-0.mp4':
+ {'testdir': _UNITTESTFILES,
+ 'container': ['mov',
+ 'mp4',
+ 'm4a',
+ '3gp',
+ '3g2',
+ 'mj2',
+ 'isom',
+ 'mp41'],
+ 'video_codec': 'h264',
+ 'audio_codec': 'aac',
+ 'width': 640,
+ 'height': 480,
+ 'title': 'Africa: Cash for Climate Change?',
+ 'duration': 312.37},
+
+
+ 'nuls.mp3':
+ {
+ 'testdir': _UNITTESTFILES,
+ 'container': 'mp3',
+ 'title': 'Invisible'},
+
+ 'drm.m4v':
+ {
+ 'testdir': _UNITTESTFILES,
+ 'container': ['mov',
+ 'mp4',
+ 'm4a',
+ '3gp',
+ '3g2',
+ 'mj2',
+ 'M4V',
+ 'mp42',
+ 'isom'],
+ 'video_codec': 'none',
+ 'audio_codec': 'aac',
+ 'has_drm': ['audio', 'video'],
+ 'width': 640,
+ 'height': 480,
+ 'title': 'Thinkers',
+ 'artist': 'The Most Extreme',
+ 'album': 'The Most Extreme',
+ 'track': '10',
+ 'genre': 'Nonfiction',
+ 'duration': 2668.8},
+
+ 'baby_block.m4v':
+ {
+ 'testdir': _SIKTESTFILES,
+ 'container': 'm4v',
+ 'video_codec': 'h264',
+ 'audio_codec': 'aac',
+ 'width': 960,
+ 'height': 540,
+ },
+ 'fake_video.mp4': # this is a fake mp4 file it is a pdf file renamed to an mp4 extension and should fail conversion
+ {
+ 'testdir': _SIKTESTFILES,
+ 'container': 'mp4',
+ 'video_codec': None,
+ 'audio_codec': None,
+ 'width': None,
+ 'height': None,
+ },
+ 'story_stuff.mov':
+ {'testdir': _SIKTESTFILES,
+ 'container': 'mov',
+ 'video_codec': 'h264',
+ 'audio_codec': 'mp3',
+ 'width': 320,
+ 'height': 180,
+ }
+
+ }
+
+ def testfile_attr(self, testfile, default):
+ try:
+ return self._FILES[testfile][default]
+ except:
+ return None
+
+ def directory_list(self, testdir):
+ files_list = []
+ for k, v in self._FILES.iteritems():
+ if v.has_key('testdir') and testdir in v['testdir']:
+ files_list.append(k)
+ return files_list
+
+ def test_data(self, many=True, new=False):
+ """Grab a subset of the test files.
+
+ Default selection is to use the unittest files,
+ but, if I need extra files, getting them from the sikuli test files dir.
+ """
+ DEFAULT_UNITTESTFILES = ['mp4-0.mp4', 'webm-0.webm']
+ DEFAULT_SIKTESTFILES = ['baby_block.m4v', 'story_styff.mov']
+ if new:
+ TESTFILES = DEFAULT_SIKTESTFILES
+ else:
+ TESTFILES = DEFAULT_UNITTESTFILES
+
+ DATADIR = self.testfile_attr(TESTFILES[0], 'testdir')
+
+ if not many:
+ TESTFILES = TESTFILES[:1]
+
+ print TESTFILES
+ return DATADIR, TESTFILES
+
diff --git a/test/uitests.sikuli/devices.py b/test/uitests.sikuli/devices.py
new file mode 100644
index 0000000..6c4f998
--- /dev/null
+++ b/test/uitests.sikuli/devices.py
@@ -0,0 +1,109 @@
+# Default Device Conversion Parameters
+
+_DEVICES = {
+ 'Xoom': {
+ 'group': 'Android',
+ 'width': '1280',
+ 'height': '800',
+ },
+
+ 'Droid': {'group': 'Android',
+ 'width': '854',
+ 'height': '480',
+ },
+
+ 'G2': {
+ 'group': 'Android',
+ 'width': '800',
+ 'height': '480',
+ },
+
+ 'Dream': {
+ 'group': 'Android',
+ 'width': '480',
+ 'height': '320',
+ },
+
+ 'Galaxy Tab': {
+ 'group': 'Android',
+ 'width': '1024',
+ 'height': '800',
+ },
+
+ 'Epic': {
+ 'group': 'Android',
+ 'width': '800',
+ 'height': '480',
+ },
+
+ 'KindleFire': {
+ 'group': 'Other',
+ 'width': '1024',
+ 'height': '600',
+ },
+ 'Playstation': {
+ 'group': 'Other',
+ 'width': '320',
+ 'height': '480',
+ },
+
+ 'iPhone': {
+ 'group': 'Apple',
+ 'width': '480',
+ 'height': '320',
+ },
+ 'iPhone 4': {
+ 'group': 'Apple',
+ 'width': '640',
+ 'height': '480',
+ },
+ 'iPad': {
+ 'group': 'Apple',
+ 'width': '1024',
+ 'height': '768',
+ },
+ 'Apple Universal': {
+ 'group': 'Apple',
+ 'width': '1280',
+ 'height': '720',
+ },
+ 'MP4': {
+ 'group': 'Format',
+ 'width': None,
+ 'height': None,
+ },
+ 'MP3': {
+ 'group': 'Format',
+ 'width': None,
+ 'height': None,
+ },
+ 'Ogg Theora': {
+ 'group': 'Format',
+ 'width': None,
+ 'height': None,
+ },
+ 'Ogg Vorbis': {
+ 'group': 'Format',
+ 'width': None,
+ 'height': None,
+ },
+ 'WebM': {
+ 'group': 'Format',
+ 'width': None,
+ 'height': None,
+ }
+ }
+
+
+def dev_attr(device, default):
+ return _DEVICES[device][default]
+
+def devices(group):
+ device_list = []
+ for k, v in _DEVICES.iteritems():
+ if group in v['group']:
+ device_list.append(k)
+ return device_list
+
+# Dream, Magic, Eris, Hero Cliq are all the same
+# iphone, ipod touch, ipod nano, ipod classic are all the same
diff --git a/test/uitests.sikuli/mvc_steps.py b/test/uitests.sikuli/mvc_steps.py
new file mode 100644
index 0000000..0d00cc1
--- /dev/null
+++ b/test/uitests.sikuli/mvc_steps.py
@@ -0,0 +1,228 @@
+# -*- coding: utf-8 -*-
+from lettuce import step
+from lettuce import world
+import datafiles
+import devices
+
+data = datafiles.TestData()
+DEFAULT_DEVICE = "iPad"
+
+def test_data(many=False, new=False):
+ UNITTESTFILES = ['mp4-0.mp4', 'webm-0.webm', 'drm.m4v']
+ SIKTESTFILES = ['baby_block.m4v', 'story_styff.mov']
+ if new:
+ TESTFILES = SIKTESTFILES
+ else:
+ TESTFILES = UNITTESTFILES
+
+ DATADIR = data.testfile_attr(TESTFILES[0], 'testdir')
+
+ if not many:
+ TESTFILES = TESTFILES[:1]
+
+ print TESTFILES
+ return DATADIR, TESTFILES
+
+def device_group(option):
+ menu_group = devices.dev_attr(option, 'group')
+ return menu_group
+
+def device_output(option):
+ dev_output_format = devices.dev_attr(option, 'container')
+ return device_output_format
+
+
+@step('I browse for (?:a|several)( new)? file(s)?')
+def browse_for_files(step, new, several): # file or files determines 1 or many
+ datadir, testfiles = test_data(several, new)
+ print testfiles
+ world.mvc.browse_for_files(datadir, testfiles)
+
+@step('The( new)? file(s)? (?:is|are) added to the list')
+def files_added_to_the_list(step, new, several):
+ _, testfiles = test_data(several, new)
+ for t in testfiles:
+ assert world.mvc.verify_file_in_list(t)
+
+@step('I browse to a directory of files')
+def add_a_directory(step):
+ datadir, _ = test_data(many=True)
+ world.mvc.add_directory_of_files(datadir)
+
+@step(u'When I drag (?:a|several)( new)? file(s)? to the drop zone')
+def drag_to_the_drop_zone(step, new, several):
+ datadir, testfiles = test_data(several, new)
+ world.mvc.drag_and_drop_files(datadir, testfiles)
+
+
+@step('Given I have files in the list')
+def given_i_have_some_files(step):
+ step.given('I browse for several files')
+
+@step('I start conversion')
+def start_conversion(step):
+ world.mvc.start_conversions()
+
+@step('I remove "([^"]*)" from the list')
+def when_i_remove_it_from_the_list(step, items):
+ if items == "it":
+ _, testfile = test_data()
+ elif items == "them":
+ _, testfile = test_data(True, False)
+ else:
+ testfile = items.split(', ')
+ world.mvc.remove_files(testfile)
+
+@step('"([^"]*)" is not in the list')
+def not_in_the_list(step, items):
+ if items == "it":
+ _, testfile = test_data()
+ assert False, world.mvc.verify_file_in_list(testfile)
+
+@step('I remove each of them from the list')
+def i_remove_each_of_them_from_the_list(step):
+ assert False, 'This step must be implemented'
+
+@step(u'Then the list of files is empty')
+def then_the_list_of_files_is_empty(step):
+ assert False, 'This step must be implemented'
+
+@step('I have converted (?:a|some) file(s)?')
+def have_converted_file(step, amount):
+ if amount == None:
+ browse_file = ('I browse for a file') #file or files determines 1 or many
+ else:
+ browse_file = ('I browse for some files')
+ step.given(browse_file)
+ step.given('I choose the "test_default" device option')
+ step.given('I start conversion')
+
+
+@step('I clear finished conversions')
+def clear_finished_conversions(step, testfiles):
+ world.mvc.clear_finished_files()
+
+
+@step('I (?:convert|have converted) "(.*?)" to "(.*?)"')
+def convert_file_to_format(step, filename, device):
+ datadir = data.testfile_attr(filename, 'testdir')
+ world.mvc.browse_for_files(datadir, [filename])
+ world.mvc.choose_device_conversion(device)
+ world.mvc.start_conversions()
+
+
+
+@step('the "(.*?)" (?:is|are) removed')
+def file_is_removed(step, testfile):
+ if testfile == "file":
+ _, testfile = test_data()
+ assert world.mvc.verify_file_not_in_list(testfile)
+
+@step('And I have some conversions in progress')
+def and_i_have_some_conversions_in_progress(step):
+ assert False, 'This step must be implemented'
+
+@step('the completed files are removed')
+def completed_files_are_removed(step):
+ assert world.mvc.verify_completed_removed()
+
+@step('the in-progress conversions remain')
+def and_the_in_progress_conversions_remain(step):
+ assert world.mvc.verify_in_progress()
+
+@step('"(.*?)" is a failed conversion')
+def have_failed_conversion(step, item):
+ assert verify_failed(self, item)
+
+@step('the failed conversions are removed')
+def failed_conversions_removed(step):
+ assert world.mvc.verify_failed_removed()
+
+@step('I choose the custom size option')
+def change_custom_size(step):
+ world.mvc.choose_custom_size('on', '150', '175')
+ assert world.mvc.verify_test_img('_custom_size_test')
+
+@step('I choose the aspect ratio')
+def when_i_choose_the_aspect_ratio(step):
+ assert False, 'This step must be implemented'
+
+@step('I choose the "([^"]*)" (?:device|format) option')
+def choose_conversion_format(step, device):
+ if device == 'test_default':
+ device = DEFAULT_DEVICE
+ world.mvc.choose_device_conversion(device)
+
+
+@step('I open the custom pulldown')
+def open_custom_pulldown(step):
+ world.mvc.open_custom_menu()
+
+@step('I verify "([^"]*)" and "([^"]*)" size setting entry')
+def verify_the_size_value(step, width, height):
+ assert False, 'This step must be implemented'
+
+@step('I verify the "([^"]*)" (?:device|format)( not)? selected')
+def verify_format_selection_for_device(self, device, removed):
+ if removed:
+ assert False, world.mvc.verify_device_format_selected(device)
+ else:
+ assert world.mvc.verify_device_format_selected(device)
+
+
+@step('the menu is reset')
+def menu_is_reset(step):
+ assert False, 'This step must be implemented'
+
+@step(u'Then there should be some smart way to make sure that the size and aspect ratio values are not conflicting')
+def then_there_should_be_some_smart_way_to_make_sure_that_the_size_and_aspect_ratio_values_are_not_conflicting(step):
+ assert False, 'This step must be implemented'
+
+@step(u'And therefore if you have a size selected, and then select an aspect ratio, a valid size should be calculated based on the chosen width and the size value should be updated.')
+def and_therefore_if_you_have_a_size_selected_and_then_select_an_aspect_ratio_a_valid_size_should_be_calculated_based_on_the_chosen_width_and_the_size_value_should_be_updated(step):
+ assert False, 'This step must be implemented'
+
+
+
+@step(u'When I restart mvc')
+def when_i_restart_mvc(step):
+ assert False, 'This step must be implemented'
+
+@step('I have Send to iTunes checked')
+def and_i_have_send_to_itunes_checked(step):
+ assert False, 'This step must be implemented'
+
+@step('the file is added to my iTunes library')
+def file_added_to_itunes(step):
+ assert False, 'This step must be implemented'
+
+@step(u'And I have the (default)? output directory specified')
+def and_i_have_the_output_directory_specified(step, default):
+ assert False, 'This step must be implemented'
+
+@step('the output file is in the (specified|default) directory')
+def output_file_specified_directory(step):
+ assert False, 'This step must be implemented'
+
+
+@step(u'Then is named with the file name (or even better item title) as the base')
+def then_is_named_with_the_file_name_or_even_better_item_title_as_the_base(step):
+ assert False, 'This step must be implemented'
+
+@step(u'And the output container is the extension')
+def and_the_output_container_is_the_extension(step):
+ assert False, 'This step must be implemented'
+
+@step(u'Then the output file is resized correctly')
+def then_the_output_file_is_resized_correctly(step):
+ assert False, 'This step must be implemented'
+
+
+@step(u'When I view the ffmpeg output')
+def when_i_view_the_ffmpeg_output(step):
+ assert False, 'This step must be implemented'
+
+@step('the ffmpeg output is displayed in a text window')
+def then_the_ffmpeg_output_is_displayed_in_a_text_window(step):
+ assert False, 'This step must be implemented'
+
diff --git a/test/uitests.sikuli/mvcgui.py b/test/uitests.sikuli/mvcgui.py
new file mode 100644
index 0000000..b76e41d
--- /dev/null
+++ b/test/uitests.sikuli/mvcgui.py
@@ -0,0 +1,368 @@
+from sikuli.Sikuli import *
+import devices
+import config
+
+
+class MVCGui(object):
+
+# ** APP UI IMAGES **
+
+# ADD FILES
+ _INITIAL_DROP_ZONE = 'fresh_start_dropzone.png'
+ _CHOOSE_FILES = 'choose_a_file.png'
+ _DROP_ZONE = 'file_drop_zone.png'
+ _CONVERSIONS_FINISHED = 'conversions_finished.png'
+
+
+# BIG BOTTOM BUTTONS
+ _START_CONVERSION = 'convert_now.png'
+ _STOP_CONVERSION = 'stop_all.png'
+ _RESET = 'clear_and_start_over.png'
+
+# INDIVIDUAL FILE OPTIONS
+ #processing
+ _IN_PROGRESS = 'progress_bar.png'
+ _DELETE_FILE = 'delete_icon.png'
+ _CLEAR_FINISHED = 'clear_finished.png'
+ _CONVERSION_COMPLETE = 'completed.png'
+ _CONVERSION_FAILED = 'failed.png'
+ _PAUSE = 'pause_button.png'
+ _RESUME = 'resume_button.png'
+ _QUEUED = 'queued.png'
+ _ERROR = 'error_icon.png'
+ _SHOW_FILE = 'show_file.png'
+
+# CONVERSION OPTIONS SECTION
+ _SEND_ITUNES = 'send_to_itunes.png'
+ _APPLE_MENU = 'apple_dropdown.png'
+ _ANDROID_MENU = 'android_dropdown.png'
+ _OTHER_MENU = 'other_dropdown.png'
+ _CUSTOM_MENU = 'custom_menu.png'
+
+# CUSTOM MENU OPTIONS
+ _PREFS_CHECKBOX_CHECKED = 'checkbox_checked.png'
+ _PREFS_CHECKBOX_UNCHECKED = 'checkbox_unchecked.png'
+ _SAVE_TO_OPTION = 'save_to_pulldown.png'
+ _OUTPUT_DIRECTORY = 'default_dir_selected.png'
+ _SAVE_TO_DEFAULT = 'save_to_default_selected.png'
+ _CONVERT_TO_OPTION = 'convert_to_menu.png'
+ _CUSTOM_SIZE = 'custom_size.png'
+ _WIDTH = 'custom_width.png'
+ _HEIGHT = 'custom_height.png'
+ _UPSIZE = 'dont_upsize'
+ _ASPECT = 'custom_aspect.png'
+ _ASPECT_43 = '43_aspect.png'
+ _ASPECT_32 = '32_aspect.png'
+ _ASPECT_169 = '169_aspect.png'
+
+# SELECTED CONVERSION OPTION
+ _APPLE_SELECTED = 'apple_selected.png'
+ _ANDROID_SELECTED = 'android_selected.png'
+
+# SYSTEM UI
+ _SYS_TEXT_ENTRY_BUTTON = 'type_a_filename.png'
+
+# TEST IMAGES for VERIFICATION
+ _custom_size_test = '150x175size.png'
+
+ def __init__(self):
+ '''
+ Constructor
+ '''
+ config.set_image_dirs()
+ self.os_name = config.get_os_name()
+# CMD or CTRL Key
+ if self.os_name == 'osx':
+ self.CMDCTRL = Key.CMD
+ else:
+ self.CMDCTRL = Key.CTRL
+
+ def mvc_focus(self):
+ App.focus("Libre Video Converter")
+
+ def mvc_quit(self):
+ App.close("Libre Video Converter")
+
+ def item_region(self, item):
+ find(item)
+ reg = Region.getLastMatch()
+ item_reg = Region(reg.x - 30, reg.y - 30, 400, 50)
+ return(item_reg)
+
+ def choose_directory(self, dirname):
+ self.type_a_path(dirname)
+
+ def type_a_path(self, dirname):
+ if config.get_os_name() == "win":
+ if not exists("Location",5):
+ click(self.SYS_TEXT_ENTRY_BUTTON)
+ time.sleep(2)
+ type(dirname +"\n")
+ type(Key.ENTER)
+
+ def browse_for_files(self, dirname, testdata):
+ click(Pattern(self._CHOOSE_FILES))
+ time.sleep(2) #osx freaks out if you start typing too fast
+ self.type_a_path(dirname)
+ keyDown(self.CMDCTRL)
+ for f in testdata:
+ click(f)
+ keyUp(self.CMDCTRL)
+
+ def add_directory_of_files(self, dirname):
+ click(self._CHOOSE_FILES)
+ self.choose_directory(dirname)
+ type(Key.ENTER)
+
+ def drag_and_drop_files(self, dirname, testdata):
+ click(self._CHOOSE_FILES)
+ y = getLastMatch() # y is drop destination
+ type(dirname)
+ type(Key.ENTER)
+ keyDown(self.CMDCTRL)
+ for f in testdata:
+ find(f)
+ x = getLastMatch() # the drag start is the last file we find and select
+ click(getLastMatch())
+ dragDrop(x, y)
+ keyUp(self.CMDCTRL)
+ type(Key.ESC) #close the file browser dialog
+
+
+ def remove_files(self, *items):
+ for item in items:
+ r = self.item_region(item)
+ r.click(self._DELETE_FILE)
+ assert r.waitVanish(item)
+
+ def start_conversions(self):
+ click(self._START_CONVERSION)
+
+ def stop_conversions(self):
+ click(self._STOP_CONVERSION)
+
+ def clear_and_start_over(self):
+ click(self._RESET)
+
+ def pause_conversions(self, *items):
+ for item in items:
+ r = self.item_region(item)
+ r.click(self._PAUSE)
+ assert r.exists(self._RESUME)
+
+ def clear_finished_files(self, items, wait=30):
+ """Clears out the completed individual conversions.
+
+ """
+ for item in items:
+ r = self.item_region(item)
+ r.exists(self._CLEAR_FINISHED, wait)
+ click(r.getLastMatch())
+ assert waitVanish(item, 2)
+
+ def show_file(self, item):
+ for item in items:
+ r = self.item_region(item)
+ r.click(self._SHOW_FILE)
+ # FIXME Verify the file is there and close the window
+
+
+ def choose_device_conversion(self, device):
+ device_group = devices.dev_attr(device, 'group')
+ menu_img = getattr(self, "".join(["_",device_group.upper(),"_","MENU"]))
+ click(menu_img)
+ click(device)
+
+
+ def open_custom_menu(self):
+ if not exists(self._OUTPUT_DIRECTORY):
+ click(self._CUSTOM_MENU)
+
+ def choose_save_location(self, location='default'):
+ self.open_custom_menu()
+ if location == 'default' and not exists(self._SAVE_TO_DEFAULT):
+ click(self._SAVE_TO_OPTION)
+ click(self._SAVE_TO_DEFAULT)
+ else:
+ click(self._SAVE_TO_OPTION)
+ self.choose_directory(location)
+
+ def set_pref_checkbox(self, option, setting):
+ """Check or uncheck the box for a preference setting.
+ Valid values are ['on' and 'off']
+
+ """
+ valid_settings = ['on', 'off']
+ if setting not in valid_settings:
+ print("valid setting value not proviced, must be 'on' or 'off'")
+ #CHECK THE BOX
+ pref_image = getattr(self, "".join(["_",option]))
+ find(pref_image)
+ reg = Region(getLastMatch())
+ box = Region(reg.getX()-15, sr_loc.getY()-10, pref_reg.getW(), 30) #location of associated checkbox
+ if setting == "off":
+ if box.exists(self._PREFS_CHECKBOX_CHECKED):
+ click(box.getLastMatch())
+ if setting == "on":
+ if box.exists(self._PREFS_CHECKBOX_NOT_CHECKED):
+ click(box.getLastMatch())
+
+ def choose_custom_size(self, setting, width=None, height=None):
+ self.open_custom_menu(self)
+ if not width or not height:
+ setting = 'off'
+ self.set_pref_checkbox(self._CUSTOM_SIZE, setting)
+ if setting == 'on':
+ type(Key.TAB)
+ type(width)
+ type(Key.TAB)
+ type(height)
+
+ def choose_dont_upsize(self, setting):
+ self.open_custom_menu(self)
+ self.set_pref_checkbox(self._UPSIZE, setting)
+
+ def choose_aspect_ratio(self, setting, ratio=None):
+ self.open_custom_menu(self)
+ if ratio == None:
+ setting = 'off'
+ self.set_pref_checkbox(self._ASPECT, setting)
+ if setting == 'on':
+ ratio_img = getattr(self, "".join(["_", "ASPECT", ratio]))
+ click(ratio_img)
+
+ def choose_format(self, output):
+ self.open_custom_menu(self)
+ mouseMove(self._CONVERT_TO_OPTION.right(30))
+ mouseDown(Button.LEFT)
+ mouseMove(output)
+ mouseUp(Button.LEFT)
+
+ def choose_itunes(self, setting):
+ self.set_pref_checkbox(self._SEND_ITUNES, setting)
+
+
+ def remove_queued_conversions(self):
+ while exists(self._QUEUED):
+ qreg = Region(getLastMatch())
+ q_item = Region(reg.getX()+30, sr_loc.getY()-20, 500, 30)
+ q_item.click(self._DELETE_FILE)
+
+ def verify_device_format_selected(self, device):
+ device_group = devices.dev_attr(device, 'group')
+ if device_group == 'Format':
+ if exists(device):
+ return True
+ else:
+ if exists(device) and exists("MP4"): #all devices are mp4 by default
+ return True
+
+
+ def verify_size(self, item, width, height):
+ self.show_ffmpeg_output(item)
+ expected_size_parameter = "-s "+width+"x"+height
+ type(self.CMDCTRL, 'f')
+ type('-s '+width+'x'+'height')
+ type(self.CMDCTRL, 'c') #copy the ffmpeg size command to the clipboard
+ size_param = Env.getClipboard()
+ if size_param == expected_size_parameter:
+ return True
+
+
+
+ def verify_device_size_default(self, width, height):
+ self.open_custom_menu()
+ self.set_pref_checkbox(self._CUSTOM_SIZE, setting)
+ click('Width')
+ type(self.CMDCTRL)
+ displayed_width = Env.getClipboard()
+ click('Height')
+ type(self.CMDCTRL)
+ displayed_height = Env.getClipboard()
+ if displayed_height == height and displayed_width == width:
+ return True
+
+
+ def verify_converting(self, item):
+ r = self.item_region(item)
+ if r.exists(self._IN_PROGRESS):
+ return True
+
+ def verify_paused(self, item):
+ r = self.item_region(item)
+ if r.exists(self._RESUME):
+ return True
+
+ def verify_completed(self, item, wait=10):
+ """Verify an individual conversion has completed.
+
+ """
+ r = self.item_region(item)
+ if r.exists(self._CONVERSION_COMPLETE, wait):
+ return True
+
+ def verify_completed_removed(self):
+ if not exists(self._CONVERSION_COMPLETE):
+ return True
+
+ def verify_conversions_finished(self):
+ """This verifies the entire group of conversions are complete.
+
+ """
+ if exists(self._CONVERSIONS_FINISHED):
+ return True
+
+ def verify_failed(self, item, wait=20):
+ r = self.item_region(item)
+ try:
+ r.exists(self._CONVERSION_FAILED, wait)
+ return True
+ except:
+ return False
+
+ def verify_failed_removed(self):
+ if not exists(self._CONVERSION_FAILED):
+ return True
+
+ def verify_file_in_list(self, item):
+ r = self.item_region(item)
+ if r.exists(item):
+ return True
+
+ def verify_file_not_in_list(self, item):
+ if not exists(item):
+ return True
+
+ def verify_queued(self, item):
+ r = self.item_region(item)
+ if r.exists(self._QUEUED):
+ return True
+
+ def verify_in_progress(self, item=None):
+ if item:
+ r = self.item_region(item)
+ if r.exists(self._IN_PROGRESS): return True
+ else:
+ if exists(self._IN_PROGRESS): return True
+
+ def verify_itunes(self, item):
+ pass
+
+ def verify_output_dir(self, item, directory):
+ r = self.item_region(item)
+ r.click(self._SHOW_FILE)
+ type(Key.ESC)
+
+ #FIXME need to get what the output name is going to be.
+ output_file = os.path.join(directory, item)
+ if os.path.isfile(output_file):
+ return True
+
+
+ def show_ffmpeg_output(self, item):
+ r = self.item_region(item)
+ self.verify_completed(item, 30)
+
+ r.click(self._SHOW_FFMPEG)
+ if exists("STARTING CONVERSION"):
+ return True
diff --git a/test/uitests.sikuli/nose.cfg b/test/uitests.sikuli/nose.cfg
new file mode 100644
index 0000000..9e9472c
--- /dev/null
+++ b/test/uitests.sikuli/nose.cfg
@@ -0,0 +1,6 @@
+[nosetests]
+with-xunit=0
+xunit-file=tests.sikuli/nosetests.xml
+nocapture=0
+where=tests.sikuli
+
diff --git a/test/uitests.sikuli/readme.md b/test/uitests.sikuli/readme.md
new file mode 100644
index 0000000..3996931
--- /dev/null
+++ b/test/uitests.sikuli/readme.md
@@ -0,0 +1,19 @@
+Libre Video Converter 3
+======================
+
+<img src="http://cl.ly/ECBE/o"/></img>
+
+MVC3 has a complete UI overhaul designed to maintain the simplicity of previous versions but also provide
+users with batch processing options and give users greater control over their converted files.
+
+
+This directory holds the UI tests for mvc that can be run like this:
+
+1. install Sikuli from http://sikuli.org
+2. install nose (pip install nose)
+3. set 2 environment variables:
+ - export SIKULI_HOME = *path to sikuli-script.jar*
+ - export PYTHON_PKGS = *path to where nose packages live* (because jython is annoying and it's the only way I can get it to import)
+
+i4. cd ../ (on dir level above the tests.sikuli directory
+5. java -jar $SIKULI_HOME/sikuli-script.jar uitests.sikuli
diff --git a/test/uitests.sikuli/test_android_conversions.py b/test/uitests.sikuli/test_android_conversions.py
new file mode 100644
index 0000000..5023da6
--- /dev/null
+++ b/test/uitests.sikuli/test_android_conversions.py
@@ -0,0 +1,66 @@
+#!/usr/bin/python
+
+import devices
+from sikuli.Sikuli import *
+import devices
+import config
+from mvcgui import MVCGui
+import datafiles
+data = datafiles.TestData()
+
+def test_android_conversions():
+ """Scenario: test each android conversion option.
+
+ """
+ device_list = devices.devices('Android')
+ for x in device_list:
+ yield convert_to_format, x
+
+
+def test_android_size_output_default():
+ """Scenario: the output format and size are defaults when device selected.
+
+ """
+ device_list = devices.devices('Android')
+ datadir, testfiles = data.test_data(many=True, new=True)
+ mvc = MVCGui()
+ mvc.mvc_focus()
+ mvc.browse_for_files(datadir, testfiles)
+
+ for x in device_list:
+ yield device_defaults, x, mvc
+
+
+def device_defaults(device_output, mvc):
+ print device_output
+ mvc.choose_device_conversion(device_output)
+ width = device.device_attr(device_output, 'width')
+ height = device.device_attr(device_output, 'height')
+ default_format = 'MP4'
+ assert mvc.verify_device_format_selected(device_output)
+ assert mvc.verify_device_size_default(str(width), str(height))
+
+
+
+def convert_to_format(device_output):
+ """Scenario: Test items are converted to the specified format.
+ """
+ print device_output
+ mvc = MVCGui()
+ mvc.mvc_focus()
+ expected_failures = ['fake_video.mp4']
+ datadir, testfiles = data.test_data(many=True, new=True)
+ mvc.browse_for_files(datadir, testfiles)
+ output_dir = tempfile.mkdtemp()
+ mvc.choose_save_location(output_dir)
+ mvc.choose_device_conversion("device_output")
+ mvc.start_conversions()
+ for item in testfiles:
+ if item in expected_failures:
+ mvc.verify_failed(item, 120)
+ else:
+ mvc.verify_completed(item, 120)
+ mvc.clear_finished_files(item)
+ mvc.clear_and_start_over()
+
+
diff --git a/test/uitests.sikuli/test_apple_conversions.py b/test/uitests.sikuli/test_apple_conversions.py
new file mode 100644
index 0000000..f0702bd
--- /dev/null
+++ b/test/uitests.sikuli/test_apple_conversions.py
@@ -0,0 +1,63 @@
+#!/usr/bin/python
+
+import devices
+from sikuli.Sikuli import *
+import devices
+import config
+from mvcgui import MVCGui
+import datafiles
+
+data = datafiles.TestData()
+
+def test_apple_conversions():
+ """Scenario: test each android conversion option.
+
+ """
+ device_list = devices.devices('Apple')
+ for x in device_list:
+ yield convert_to_format, x
+
+
+def test_apple_size_output_default():
+ """Scenario: the output format and size are defaults when device selected.
+
+ """
+ device_list = devices.devices('Android')
+ datadir, testfiles = data.test_data(many=True, new=True)
+ mvc = MVCGui()
+ mvc.mvc_focus()
+ mvc.browse_for_files(datadir, testfiles)
+
+ for x in device_list:
+ yield device_defaults, x, mvc
+
+
+def device_defaults(device_output, mvc):
+ print device_output
+ mvc.choose_device_conversion(device_output)
+ width = device.device_attr(device_output, 'width')
+ height = device.device_attr(device_output, 'height')
+ default_format = 'MP4'
+ assert mvc.verify_device_format_selected(device_output)
+ assert mvc.verify_device_size_default(str(width), str(height))
+
+def convert_to_format(device_output):
+ print device_output
+ expected_failures = ['fake_video.mp4']
+ mvc = MVCGui()
+ mvc.mvc_focus()
+ datadir, testfiles = data.test_data(many=True, new=True)
+ mvc.browse_for_files(datadir, testfiles)
+ output_dir = tempfile.mkdtemp()
+ mvc.choose_save_location(output_dir)
+ mvc.choose_device_conversion("device_output")
+ mvc.start_conversions()
+ for item in testfiles:
+ if item in expected_failures:
+ mvc.verify_failed(item, 120)
+ else:
+ mvc.verify_completed(item, 120)
+ mvc.clear_finished_files(item)
+ mvc.clear_and_start_over()
+
+
diff --git a/test/uitests.sikuli/test_choose_files.py b/test/uitests.sikuli/test_choose_files.py
new file mode 100644
index 0000000..7b86622
--- /dev/null
+++ b/test/uitests.sikuli/test_choose_files.py
@@ -0,0 +1,166 @@
+import sys
+import os
+import tempfile
+import shutil
+import unittest
+from mvcgui import MVCGui
+import datafiles
+import devices
+
+data = datafiles.TestData()
+
+
+class Test_Choose_Files(unittest.TestCase):
+ """Add files to the conversion list either via browse or drag-n-drop.
+
+ """
+
+ def setUp(self):
+ """
+ setup app for tests
+
+ """
+ self.mvc = MVCGui()
+ self.mvc.mvc_focus()
+ print "starting test: ", self.shortDescription()
+ self.output_dir = tempfile.mkdtemp()
+ self.mvc.choose_save_location(self.output_dir)
+
+
+
+ def test_browse_for_a_file(self):
+ """Scenario: Browse for a single file.
+
+ When I browse for a file
+ Then the file is added to the list
+ """
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=False)
+ mvc.browse_for_files(datadir, testfiles)
+ item = testfiles[0]
+ assert mvc.verify_file_in_list(item)
+
+
+
+
+ def test_choose_several_files(self):
+ """Scenario: Browse for several files.
+
+ When I browse for several files
+ Then the files are added to the list
+ """
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=True)
+ mvc.browse_for_files(datadir, testfiles)
+ for t in testfiles:
+ assert mvc.verify_file_in_list(t)
+
+ def skip_test_choose_a_directory_files(self):
+ """Scenario: Choose a directory of files.
+
+ When I browse to a directory of files
+ Then the files are added to the list
+ """
+
+ def test_drag_a_file_to_drop_zone(self):
+ """Scenario: Drag a single file to drop zone.
+
+ When I drag a file to the drop zone
+ Then the file is added to the list
+ """
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=False)
+ mvc.drag_and_drop_files(datadir, testfiles)
+ item = testfiles[0]
+ assert mvc.verify_file_in_list(item)
+
+ def test_drag_and_drop_multiple_files(self):
+ """Scenario: Drag multiple files.
+
+ When I drag several files to the drop zone
+ Then the files are added to the list
+ """
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=True)
+ mvc.drag_and_drop_files(datadir, testfiles)
+ for t in testfiles:
+ assert mvc.verify_file_in_list(t)
+
+ def test_drag_more_files_to_drop_zone(self):
+ """Scenario: Drag additional files to the existing list.
+
+ Given I have files in the list
+ When I drag a new file to the drop zone
+ Then the new file is added to the list
+ """
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=True)
+ mvc.browse_for_files(datadir, testfiles)
+ moredatadir, moretestfiles = data.test_data(many=False, new=True)
+ item = testfiles[0]
+ mvc.drag_and_drop_files(moredatadir, item)
+ assert mvc.verify_file_in_list(item)
+
+ def test_browse_for_more_files_and_add_them(self):
+ """Scenario: Choose additional files and add to the existing list.
+
+ Given I have files in the list of files
+ When I browse for several new files
+ Then the new files are added to the list
+ """
+
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=True)
+ mvc.browse_for_files(datadir, testfiles)
+ moredatadir, moretestfiles = data.test_data(many=False, new=True)
+ item = testfiles[0]
+ mvc.browse_for_files(moredatadir, item)
+ assert mvc.verify_file_in_list(item)
+
+
+ def test_drag_more_file_while_converting(self):
+ """Scenario: Drag additional files to the existing list with conversions in progress.
+
+ Given I have files in the list
+ And I start conversion
+ When I drag a new file to the drop zone
+ Then the new file is added to the list and is converted
+ """
+
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=True)
+ mvc.browse_for_files(datadir, testfiles)
+ mvc.choose_device_conversion("iPad")
+ mvc.start_conversion()
+
+ moredatadir, moretestfiles = data.test_data(many=False, new=True)
+ item = testfiles[0]
+ mvc.drag_and_drop_files(moredatadir, item)
+ assert mvc.verify_file_in_list(item)
+ assert mvc.verify_completed(item, 60)
+
+ def test_browse_more_files_while_converting(self):
+ """Scenario: Choose additional files and add to list with conversions in progress.
+
+ Given I have files in the list
+ And I start conversion
+ When I browse for several new files
+ Then the new files are added to the list
+ """
+
+ mvc = MVCGui()
+ datadir, testfiles = data.test_data(many=True)
+ mvc.browse_for_files(datadir, testfiles)
+ mvc.choose_device_conversion("iPad")
+ mvc.start_conversion()
+
+ moredatadir, moretestfiles = data.test_data(many=False, new=True)
+ item = testfiles[0]
+ mvc.browse_for_files(moredatadir, item)
+ assert mvc.verify_file_in_list(item)
+ assert mvc.verify_completed(item, 60)
+
+ def tearDown(self):
+ shutil.rmtree(self.output_dir)
+ self.mvc_quit()
+
diff --git a/test/uitests.sikuli/test_clear_finished_conversions.py b/test/uitests.sikuli/test_clear_finished_conversions.py
new file mode 100644
index 0000000..360d282
--- /dev/null
+++ b/test/uitests.sikuli/test_clear_finished_conversions.py
@@ -0,0 +1,91 @@
+#!/usr/bin/python
+
+import sys
+import os
+import tempfile
+import shutil
+import unittest
+from mvcgui import MVCGui
+import datafiles
+import devices
+
+data = datafiles.TestData()
+
+
+class Test_Clear_Finished_Conversions(unittest.TestCase):
+ """Feature: Removed completed conversions from the list.
+
+ When and item has completed or failed convsion
+ I want to remove it from the list
+ """
+
+ def setUp(self):
+ """
+ Each tests assumes that I there are files that have been converted.
+
+ """
+ self.mvc = MVCGui()
+ self.mvc.mvc_focus()
+ print "starting test: ", self.shortDescription()
+ datadir, testfiles = data.test_data()
+ self.mvc.browse_for_files(datadir, testfiles)
+ self.output_dir = tempfile.mkdtemp()
+ self.mvc.choose_save_location(self.output_dir)
+
+ def test_clear_finished_conversions(self):
+ """Feature: Clear a finished conversions.
+
+ Given I have converted a file
+ When I clear finished conversions
+ Then the file is removed
+ """
+ mvc = MVCGui()
+ _, testfiles = data.test_data(many=True)
+ mvc.start_conversions()
+ assert mvc.clear_finished_conversions(testfiles)
+
+
+
+ def test_clear_finished_item_with_in_progress(self):
+ """Scenario: Clear finished conversions while others are in progress.
+
+ Given I have converted a file
+ And I have some conversions in progress
+ When I clear finished conversions
+ Then the completed files are removed
+ And the in-progress conversions remain
+ """
+ _, testfiles = data.test_data(many=True)
+ item = 'slow_conversion.mkv'
+ item_dir = data.testfile_attr(item, 'testdir')
+ mvc = MVCGui()
+ mvc.browse_for_files(item_dir, item)
+ mvc.start_conversions()
+ mvc.clear_finished_conversions(testfiles)
+ assert mvc.verify_converting(item)
+
+
+
+ def test_clear_finished_after_conversion_errors(self):
+ """Scenario: Clear finished conversions after conversion errors.
+
+ Given I convert several files and 1 that will fail
+ When I clear finished conversions
+ Then the completed files are removed
+ And the failed conversions are removed
+ """
+ _, testfiles = data.test_data(many=True)
+ item = 'fake_video.mp4'
+ item_dir = data.testfile_attr(item, 'testdir')
+ mvc = MVCGui()
+ mvc.browse_for_files(item_dir, item)
+ mvc.start_conversions()
+ mvc.verify_conversions_finished()
+ mvc.clear_and_start_over()
+ assert mvc.verify_file_not_in_list(testfiles[0])
+ assert mvc.verify_file_not_in_list(item)
+
+ def tearDown(self):
+ self.mvc.mvc_quit()
+ shutil.rmtree(self.output_dir)
+
diff --git a/test/uitests.sikuli/test_conversions.py b/test/uitests.sikuli/test_conversions.py
new file mode 100644
index 0000000..740b61a
--- /dev/null
+++ b/test/uitests.sikuli/test_conversions.py
@@ -0,0 +1,196 @@
+#!/usr/bin/python
+
+import sys
+import os
+import tempfile
+import shutil
+import unittest
+from mvcgui import MVCGui
+import datafiles
+import devices
+
+data = datafiles.TestData()
+
+
+class Test_Conversions(unittest.TestCase):
+ """For any completed conversion
+ I want to be able to locate and play the file
+ And it should be formatted as I have specified
+ """
+
+ def setUp(self):
+ """
+ Each tests assumes that I there are files in the list ready to be converted to some format.
+
+ """
+ self.mvc = MVCGui()
+ self.mvc.mvc_focus()
+ print "starting test: ", self.shortDescription()
+ datadir, testfiles = data.test_data(many=True)
+ self.mvc.browse_for_files(datadir, testfiles)
+ self.output_dir = tempfile.mkdtemp()
+ self.mvc.choose_save_location(self.output_dir)
+
+
+ def test_send_file_to_itunes(self):
+ """Scenario: Send to iTunes.
+
+ Given I have "Send to iTunes" checked
+ When I convert the an apple format
+ Then the file is added to my iTunes library
+ """
+ item = "mp4-0.mp4"
+ mvc = MVCGui()
+ mvc.choose_device_conversion("iPad")
+ mvc.choose_itunes()
+ mvc.start_conversions()
+ mvc.verify_completed(item, 30)
+ assert mvc.verify_itunes(item)
+
+
+ def test_verify_custom_output_directory(self):
+ """Scenario: File in specific output location.
+
+ Given I have set the output directory to "directory"
+ When I convert a file
+ Then the output file is in the specified directory
+ """
+
+ custom_output_dir = os.path.join(os.getenv("HOME"),"Desktop")
+ item = "mp4-0.mp4"
+ mvc.mvcGui()
+ mvc.choose_device_conversion("KindleFire")
+ mvc.choose_save_location(custom_output_dir)
+ mvc.start_conversions()
+ mvc.verify_completed(item, 30)
+ assert mvc.verify_output_dir(self, item, custom_output_dir)
+
+ def test_file_in_default_location(self):
+ """Scenario: File in default output location.
+
+ Given I have set the output directory to "default"
+ When I convert a file
+ Then the output file is in the default directory
+ """
+
+ datadir, testfile = data.test_data()
+ item = testfile[0]
+ mvc.mvcGui()
+ mvc.choose_device_conversion("Galaxy Tab")
+ mvc.choose_save_location('default')
+ mvc.start_conversions()
+ mvc.verify_completed(item, 30)
+ assert mvc.verify_output_dir(self, item, datadir)
+
+ def test_output_file_name_in_default_dir(self):
+ """Scenario: Output file name when saved in default (same) directory.
+
+ When I convert a file
+ Then it is named with the file name (or even better item title) as the base
+ And the output container is the extension
+ """
+ self.fail('I do not know the planned naming convention yet')
+
+ def test_output_file_name_in_custom_dir(self):
+ """Scenario: Output file name when saved in default (same) directory.
+
+ When I convert a file
+ Then it is named with the file name (or even better item title) as the base
+ And the output container is the extension
+ """
+ self.fail('I do not know the planned naminig convention yet')
+
+ def test_output_video_no_upsize(self):
+ datadir, testfile = data.test_data()
+ item = testfile[0] #mp4-0.mp4 is smaller than the Apple Universal Setting
+ mvc.mvcGui()
+ mvc.choose_device_conversion("Apple Universal")
+ mvc.choose_dont_upsize('on')
+ mvc.start_conversion()
+ assert mvc.verify_size(os.path.join(datadir, item), width, height)
+
+
+ """Scenario: Output file video size.
+
+ When I convert a file to "format"
+ And Don't Upsize is selected
+ Then the output file dimensions are not changed if the input file is smaller than the device
+ """
+
+ ##This test is best covered more completely in unittests to verify that we resize according to device sizes
+ item = "mp4-0.mp4" #mp4-0.mp4 is smaller than the Apple Universal Setting
+ mvc.mvcGui()
+ mvc.choose_device_conversion("Apple Universal")
+ mvc.choose_dont_upsize('on')
+ mvc.start_conversion()
+ assert mvc.verify_size(os.path.join(self.output_dir, item), width, height)
+
+
+
+ def test_output_video_upsize(self):
+ """Scenario: Output file video size.
+
+ When I convert a file to "format"
+ And Don't Upsize is NOT selected
+ The the output file dimensions are changed to match the device spec.
+ """
+
+##This test is best covered more completely in unittests to verify that we resize according to device sizes
+
+ item = "mp4-0.mp4" #mp4-0.mp4 is smaller than the Apple Universal Setting
+ mvc.mvcGui()
+ mvc.choose_device_conversion("Apple Universal")
+ mvc.choose_dont_upsize('off')
+ mvc.start_conversion()
+ assert mvc.verify_size(os.path.join(self.output_dir, item), width, height)
+
+ def test_completed_conversions_display(self):
+ """Scenario: File displays as completed.
+
+ When I convert a file
+ Then the file displays as completed
+ """
+ item = "mp4-0.mp4"
+ mvc.mvcGui()
+ mvc.choose_device_conversion("Xoom")
+ mvc.choose_save_location(custom_output_dir)
+ mvc.start_conversions()
+ assert mvc.verify_completed(item, 30)
+
+
+ def test_failed_conversion_display(self):
+ """Scenario: File fails conversion.
+ When I convert a "file" to "format"
+ And the file conversion fails
+ Then the file displays as failed.
+ """
+ item = 'fake_video.mp4'
+ item_dir = data.testfile_attr(item, 'testdir')
+ mvc.mvcGui()
+ mvc.browse_for_files(item_dir, item)
+ mvc.choose_device_conversion("iPhone")
+ mvc.start_conversion()
+ assert mvc.verify_failed(item)
+
+
+ def test_ffmpeg_log_output_on_failure(self):
+ """Scenario: Show ffmpeg output.
+
+ Given I convert a file
+ When I view the ffmpeg output
+ Then the ffmpeg output is displayed in a text window
+ """
+ item = 'fake_video.mp4'
+ item_dir = data.testfile_attr(item, 'testdir')
+ mvc.mvcGui()
+ mvc.browse_for_files(item_dir, item)
+ mvc.choose_device_conversion("iPhone")
+ mvc.start_conversion()
+ mvc.verify_failed(item)
+ assert mvc.show_ffmpeg_output(item)
+
+
+ def tearDown(self):
+ shutil.rmtree(self.output_dir)
+ self.mvc_quit()
+
diff --git a/test/uitests.sikuli/test_other_conversions.py b/test/uitests.sikuli/test_other_conversions.py
new file mode 100644
index 0000000..3299616
--- /dev/null
+++ b/test/uitests.sikuli/test_other_conversions.py
@@ -0,0 +1,39 @@
+#!/usr/bin/python
+
+import devices
+from sikuli.Sikuli import *
+import devices
+import config
+from mvcgui import MVCGui
+import datafiles
+
+data = datafiles.TestData()
+
+def test_other_conversions():
+ """Scenario: test other output conversion options.
+
+ """
+ device_list = devices.devices('Other')
+ for x in device_list:
+ yield convert_to_format, x
+
+def convert_to_format(device_output):
+ print device_output
+ expected_failures = ['fake_video.mp4']
+ mvc = MVCGui()
+ mvc.mvc_focus()
+ datadir, testfiles = data.test_data(many=True, new=True)
+ mvc.browse_for_files(datadir, testfiles)
+ output_dir = tempfile.mkdtemp()
+ mvc.choose_save_location(output_dir)
+ mvc.choose_device_conversion("device_output")
+ mvc.start_conversions()
+ for item in testfiles:
+ if item in expected_failures:
+ mvc.verify_failed(item, 120)
+ else:
+ mvc.verify_completed(item, 120)
+ mvc.clear_finished_files(item)
+ mvc.clear_and_start_over()
+
+
diff --git a/test/uitests.sikuli/test_output_settings.py b/test/uitests.sikuli/test_output_settings.py
new file mode 100644
index 0000000..4c65cb0
--- /dev/null
+++ b/test/uitests.sikuli/test_output_settings.py
@@ -0,0 +1,74 @@
+#!/usr/bin/python
+
+import sys
+import os
+import tempfile
+import shutil
+import unittest
+from mvcgui import MVCGui
+import datafiles
+import devices
+
+data = datafiles.TestData()
+
+
+class Test_Custom_Settings(unittest.TestCase):
+ """Features: users can specify custom format, size and aspect ration.
+
+ """
+ def setUp(self):
+ """
+ Each tests assumes that I there are files in the list ready to be converted to some format.
+
+ """
+ self.mvc = MVCGui()
+ self.mvc.mvc_focus()
+ print "starting test: ", self.shortDescription()
+ datadir, testfiles = data.test_data(many=True)
+ self.mvc.browse_for_files(datadir, testfiles)
+ self.output_dir = tempfile.mkdtemp()
+ self.mvc.choose_save_location(self.output_dir)
+
+ def choose_custom_size(self):
+ """Scenario: Choose custom size.
+
+ When I enter a custom size option
+ Then the conversion uses that setting."""
+ mvc = MVCGui()
+ _, testfiles = data.test_data()
+ item = testfiles[0]
+ w = '360'
+ h = '180'
+
+ mvc.choose_custom_size(self, 'on', width=w, height=h)
+ mvc.mvc.choose_device_conversion('WebM')
+ mvc.start_conversions()
+ assert mvc.verify_size(item, width=w, height=h)
+
+
+ def choose_aspect_ration(self):
+ """Scenario: Choose a device, then choose a custom aspect ratio.
+
+ Given I choose a device option
+ When I set the "aspect ratio"
+ Then I'm not really sure what will happen
+ """
+ self.fail('need to know how to test this')
+
+ def choose_device_then_change_size(self):
+ """Scenario: Choose a device, then choose a custom size.
+
+ When I choose a device
+ And I change size
+ Then the selected size is used in the conversion
+ """
+ mvc = MVCGui()
+ _, testfiles = data.test_data()
+ item = testfiles[0]
+ w = '240'
+ h = '180'
+ mvc.choose_device_conversion('Galaxy Tab')
+ mvc.choose_custom_size(self, 'on', width=w, height=h)
+ mvc.start_conversions()
+ assert mvc.verify_size(item, width=w, height=h)
+
diff --git a/test/uitests.sikuli/test_remove_files.py b/test/uitests.sikuli/test_remove_files.py
new file mode 100644
index 0000000..e55f3b6
--- /dev/null
+++ b/test/uitests.sikuli/test_remove_files.py
@@ -0,0 +1,96 @@
+import sys
+import os
+import tempfile
+import shutil
+import unittest
+from mvcgui import MVCGui
+import datafiles
+import devices
+
+data = datafiles.TestData()
+
+
+
+class Test_Remove_Files(unittest.TestCase):
+ """Remove files from the conversion list
+
+ """
+
+ def setUp(self):
+ """
+ setup app for tests
+
+ """
+ mvc = MVCGui()
+ mvc.mvc_focus()
+ print "starting test: ", self.shortDescription()
+ datadir, testfiles = data.test_data()
+ mvc.browse_for_files(datadir, testfiles)
+
+ def test_remove_a_file(self):
+ """Scenario: Remove a file from the list of files.
+
+ Given I have files in the list
+ When I remove it from the list
+ Then it is not in the list
+ """
+
+ mvc.mvcGui()
+ _, testfiles = data.test_data(many=False)
+ item = testfiles[0]
+ assert mvc.remove_files(item)
+
+ def test_remove_all_files(self):
+ """Scenario: Remove all the files from the list.
+
+ Given I have files in the list
+ When I remove them from the list
+ Then the list of files is empty
+ """
+ mvc.mvcGui()
+ _, testfiles = data.test_data()
+ assert mvc.remove_files(testfiles)
+
+ def test_remove_from_list_with_in_progress_conversions(self):
+ """Scenario: Remove a file from the list of files with conversions in progress.
+
+ Given I have files in the list
+ And I start conversion
+ When I remove it from the list
+ Then it is not in the list
+ """
+
+ item = 'slow_conversion.mkv'
+ item_dir = data.testfile_attr(item, 'testdir')
+ mvc.mvcGui()
+
+ mvc.browse_for_files(item_dir, item)
+ mvc.choose_device_conversion("WebM")
+ mvc.start_conversion()
+
+ _, origtestfiles = test_data()
+ mvc.remove_files(origtestfiles[1])
+ assert mvc.verify_file_in_list(item)
+ assert mvc.verify_completed(item, 160)
+
+ def test_remove_last_queued_file_with_in_progress_conversions(self):
+ """Scenario: Remove the last queued file from the list with conversions in progress.
+
+ Given I have lots of files files in the list
+ And I start conversion
+ When I remove the queued file from the list
+ Then the in_progress conversions finished.
+ """
+ item = 'slow_conversion.mkv'
+ item_dir = data.testfile_attr(item, 'testdir')
+ mvc.mvcGui()
+
+ mvc.browse_for_files(item_dir, item)
+ mvc.choose_device_conversion("Theora")
+ mvc.start_conversion()
+ mvc.remove_queued_conversions()
+ assert mvc.verify_conversions_finished()
+
+
+
+
diff --git a/test/uitests.sikuli/testdata/baby_block.m4v b/test/uitests.sikuli/testdata/baby_block.m4v
new file mode 100644
index 0000000..d567b7a
--- /dev/null
+++ b/test/uitests.sikuli/testdata/baby_block.m4v
Binary files differ
diff --git a/test/uitests.sikuli/testdata/fake_video.mp4 b/test/uitests.sikuli/testdata/fake_video.mp4
new file mode 100644
index 0000000..68bf481
--- /dev/null
+++ b/test/uitests.sikuli/testdata/fake_video.mp4
Binary files differ
diff --git a/test/uitests.sikuli/testdata/story_stuff.mov b/test/uitests.sikuli/testdata/story_stuff.mov
new file mode 100644
index 0000000..23fae74
--- /dev/null
+++ b/test/uitests.sikuli/testdata/story_stuff.mov
Binary files differ
diff --git a/test/uitests.sikuli/uitests.py b/test/uitests.sikuli/uitests.py
new file mode 100644
index 0000000..f58a1ed
--- /dev/null
+++ b/test/uitests.sikuli/uitests.py
@@ -0,0 +1,13 @@
+import os
+import sys
+sys.path.append(os.getenv('PYTHON_PKGS'))
+import nose
+import nose.config
+
+
+myconfig = os.path.join(os.getcwd(), 'tests.sikuli', 'nose.cfg')
+nose.config.config_files = [myconfig]
+c = nose.config.Config()
+c.configure()
+nose.run()
+