From d99ccf36a2ff6c47a4ab3ac61a6be749e1edad46 Mon Sep 17 00:00:00 2001 From: CitadelStationBot Date: Mon, 26 Jun 2017 22:06:15 -0500 Subject: [PATCH] Replaces old midi2piano with a new version --- tools/midi2piano/midi2piano.sln | 30 - tools/midi2piano/midi2piano/Form1.Designer.cs | 135 - tools/midi2piano/midi2piano/Form1.cs | 298 -- tools/midi2piano/midi2piano/Form1.resx | 126 - .../midi2piano/Properties/AssemblyInfo.cs | 34 - .../midi2piano/Sanford.Multimedia.Midi.dll | Bin 114688 -> 0 bytes .../x86/Release/Sanford.Multimedia.Midi.dll | Bin 114688 -> 0 bytes .../midi2piano/bin/x86/Release/midi2piano.exe | Bin 12800 -> 0 bytes tools/midi2piano/midi2piano/midi2piano.csproj | 83 - tools/midi2piano2016/README.txt | 31 + tools/midi2piano2016/easygui/__init__.py | 2492 +++++++++++++++++ tools/midi2piano2016/midi/__init__.py | 1 + tools/midi2piano2016/midi/midi.py | 1648 +++++++++++ tools/midi2piano2016/midi2piano.py | 307 ++ tools/midi2piano2016/pyperclip/__init__.py | 103 + tools/midi2piano2016/pyperclip/clipboards.py | 134 + tools/midi2piano2016/pyperclip/exceptions.py | 11 + tools/midi2piano2016/pyperclip/windows.py | 151 + 18 files changed, 4878 insertions(+), 706 deletions(-) delete mode 100644 tools/midi2piano/midi2piano.sln delete mode 100644 tools/midi2piano/midi2piano/Form1.Designer.cs delete mode 100644 tools/midi2piano/midi2piano/Form1.cs delete mode 100644 tools/midi2piano/midi2piano/Form1.resx delete mode 100644 tools/midi2piano/midi2piano/Properties/AssemblyInfo.cs delete mode 100644 tools/midi2piano/midi2piano/Sanford.Multimedia.Midi.dll delete mode 100644 tools/midi2piano/midi2piano/bin/x86/Release/Sanford.Multimedia.Midi.dll delete mode 100644 tools/midi2piano/midi2piano/bin/x86/Release/midi2piano.exe delete mode 100644 tools/midi2piano/midi2piano/midi2piano.csproj create mode 100644 tools/midi2piano2016/README.txt create mode 100644 tools/midi2piano2016/easygui/__init__.py create mode 100644 tools/midi2piano2016/midi/__init__.py create mode 100644 tools/midi2piano2016/midi/midi.py create mode 100644 tools/midi2piano2016/midi2piano.py create mode 100644 tools/midi2piano2016/pyperclip/__init__.py create mode 100644 tools/midi2piano2016/pyperclip/clipboards.py create mode 100644 tools/midi2piano2016/pyperclip/exceptions.py create mode 100644 tools/midi2piano2016/pyperclip/windows.py diff --git a/tools/midi2piano/midi2piano.sln b/tools/midi2piano/midi2piano.sln deleted file mode 100644 index 7636dd66d8..0000000000 --- a/tools/midi2piano/midi2piano.sln +++ /dev/null @@ -1,30 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 11.00 -# Visual C# Express 2010 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "midi2piano", "midi2piano\midi2piano.csproj", "{68C84B61-F710-491C-BEE8-5E362C167897}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|Mixed Platforms = Debug|Mixed Platforms - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|Mixed Platforms = Release|Mixed Platforms - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {68C84B61-F710-491C-BEE8-5E362C167897}.Debug|Any CPU.ActiveCfg = Debug|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Debug|Mixed Platforms.Build.0 = Debug|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Debug|x86.ActiveCfg = Debug|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Debug|x86.Build.0 = Debug|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Release|Any CPU.ActiveCfg = Release|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Release|Mixed Platforms.ActiveCfg = Release|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Release|Mixed Platforms.Build.0 = Release|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Release|x86.ActiveCfg = Release|x86 - {68C84B61-F710-491C-BEE8-5E362C167897}.Release|x86.Build.0 = Release|x86 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/tools/midi2piano/midi2piano/Form1.Designer.cs b/tools/midi2piano/midi2piano/Form1.Designer.cs deleted file mode 100644 index bac3a20630..0000000000 --- a/tools/midi2piano/midi2piano/Form1.Designer.cs +++ /dev/null @@ -1,135 +0,0 @@ -namespace midi2piano -{ - partial class Form1 - { - /// - /// Required designer variable. - /// - private System.ComponentModel.IContainer components = null; - - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - private void InitializeComponent() - { - this.OutputTxt = new System.Windows.Forms.TextBox(); - this.menuStrip1 = new System.Windows.Forms.MenuStrip(); - this.fileToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.importMIDIToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.exitToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.copyToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.importDlg = new System.Windows.Forms.OpenFileDialog(); - this.halpToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.menuStrip1.SuspendLayout(); - this.SuspendLayout(); - // - // OutputTxt - // - this.OutputTxt.Dock = System.Windows.Forms.DockStyle.Fill; - this.OutputTxt.Location = new System.Drawing.Point(0, 24); - this.OutputTxt.Multiline = true; - this.OutputTxt.Name = "OutputTxt"; - this.OutputTxt.ReadOnly = true; - this.OutputTxt.ScrollBars = System.Windows.Forms.ScrollBars.Both; - this.OutputTxt.Size = new System.Drawing.Size(284, 240); - this.OutputTxt.TabIndex = 0; - // - // menuStrip1 - // - this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.fileToolStripMenuItem, - this.copyToolStripMenuItem, - this.halpToolStripMenuItem}); - this.menuStrip1.Location = new System.Drawing.Point(0, 0); - this.menuStrip1.Name = "menuStrip1"; - this.menuStrip1.Size = new System.Drawing.Size(284, 24); - this.menuStrip1.TabIndex = 1; - this.menuStrip1.Text = "menuStrip1"; - // - // fileToolStripMenuItem - // - this.fileToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.importMIDIToolStripMenuItem, - this.exitToolStripMenuItem}); - this.fileToolStripMenuItem.Name = "fileToolStripMenuItem"; - this.fileToolStripMenuItem.Size = new System.Drawing.Size(37, 20); - this.fileToolStripMenuItem.Text = "&File"; - // - // importMIDIToolStripMenuItem - // - this.importMIDIToolStripMenuItem.Name = "importMIDIToolStripMenuItem"; - this.importMIDIToolStripMenuItem.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.I))); - this.importMIDIToolStripMenuItem.Size = new System.Drawing.Size(184, 22); - this.importMIDIToolStripMenuItem.Text = "&Import MIDI..."; - this.importMIDIToolStripMenuItem.Click += new System.EventHandler(this.importMIDIToolStripMenuItem_Click); - // - // exitToolStripMenuItem - // - this.exitToolStripMenuItem.Name = "exitToolStripMenuItem"; - this.exitToolStripMenuItem.Size = new System.Drawing.Size(184, 22); - this.exitToolStripMenuItem.Text = "E&xit"; - this.exitToolStripMenuItem.Click += new System.EventHandler(this.exitToolStripMenuItem_Click); - // - // copyToolStripMenuItem - // - this.copyToolStripMenuItem.Name = "copyToolStripMenuItem"; - this.copyToolStripMenuItem.Size = new System.Drawing.Size(47, 20); - this.copyToolStripMenuItem.Text = "&Copy"; - this.copyToolStripMenuItem.Click += new System.EventHandler(this.copyToolStripMenuItem_Click); - // - // importDlg - // - this.importDlg.Filter = "MIDI File|*.midi;*.mid"; - // - // halpToolStripMenuItem - // - this.halpToolStripMenuItem.Name = "halpToolStripMenuItem"; - this.halpToolStripMenuItem.Size = new System.Drawing.Size(44, 20); - this.halpToolStripMenuItem.Text = "&Halp"; - this.halpToolStripMenuItem.Click += new System.EventHandler(this.halpToolStripMenuItem_Click); - // - // Form1 - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(284, 264); - this.Controls.Add(this.OutputTxt); - this.Controls.Add(this.menuStrip1); - this.MainMenuStrip = this.menuStrip1; - this.Name = "Form1"; - this.Text = "MIDI2Piano"; - this.menuStrip1.ResumeLayout(false); - this.menuStrip1.PerformLayout(); - this.ResumeLayout(false); - this.PerformLayout(); - - } - - #endregion - - private System.Windows.Forms.TextBox OutputTxt; - private System.Windows.Forms.MenuStrip menuStrip1; - private System.Windows.Forms.ToolStripMenuItem fileToolStripMenuItem; - private System.Windows.Forms.ToolStripMenuItem importMIDIToolStripMenuItem; - private System.Windows.Forms.ToolStripMenuItem exitToolStripMenuItem; - private System.Windows.Forms.OpenFileDialog importDlg; - private System.Windows.Forms.ToolStripMenuItem copyToolStripMenuItem; - private System.Windows.Forms.ToolStripMenuItem halpToolStripMenuItem; - } -} \ No newline at end of file diff --git a/tools/midi2piano/midi2piano/Form1.cs b/tools/midi2piano/midi2piano/Form1.cs deleted file mode 100644 index 727b61f750..0000000000 --- a/tools/midi2piano/midi2piano/Form1.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Drawing; -using System.Linq; -using System.Text; -using System.Windows.Forms; - -using Sanford.Multimedia; -using Sanford.Multimedia.Midi; - -namespace midi2piano -{ - public partial class Form1 : Form - { - [STAThread] - public static void Main() - { - Application.EnableVisualStyles(); - Application.Run(new Form1()); - } - - public Form1() - { - InitializeComponent(); - } - - private void exitToolStripMenuItem_Click(object sender, EventArgs e) - { - Close(); - } - - struct PNote - { - public float Length; - public string Note; - - public PNote(float length, string note) - { - Length = length; - Note = note; - } - - public static readonly PNote Default = new PNote(0, ""); - } - - private void importMIDIToolStripMenuItem_Click(object sender, EventArgs e) - { - if (importDlg.ShowDialog(this) - == System.Windows.Forms.DialogResult.Cancel) - return; - - List notes = new List(); - PNote curNote = PNote.Default; - float tempo = 1; - float timeSig = 4f; - - // first, we pull midi data - Sequence s = new Sequence(importDlg.FileName); - - // quickly see if there's a piano track first - // and get the tempo as well - int piano = -1; - for (int it = 0; it < s.Count; it++) - { - Track t = s[it]; - foreach (MidiEvent me in t.Iterator()) - { - switch (me.MidiMessage.MessageType) - { - case MessageType.Channel: - { - ChannelMessage m = (ChannelMessage)me.MidiMessage; - if (m.Command == ChannelCommand.ProgramChange) - if ((GeneralMidiInstrument)m.Data1 == GeneralMidiInstrument.AcousticGrandPiano) - { - piano = it; - } - } - break; - case MessageType.Meta: - { - MetaMessage m = (MetaMessage)me.MidiMessage; - if (m.MetaType == MetaType.Tempo) - tempo = (new TempoChangeBuilder(m)).Tempo; - else if (m.MetaType == MetaType.TimeSignature) - timeSig = new TimeSignatureBuilder(m).Denominator; - } - break; - } - if (piano >= 0) - break; - } - if (piano >= 0) - break; - } - - // didn't find one, so just try 0th track anyway - if (piano == -1) - piano = 0; - - // now, pull all notes (and tempo) - // and make sure it's a channel that has content - for (int it = piano; it < s.Count; it++) - { - Track t = s[it]; - - int delta = 0; - foreach (MidiEvent me in t.Iterator()) - { - delta += me.DeltaTicks; - - switch (me.MidiMessage.MessageType) - { - case MessageType.Channel: - { - ChannelMessage m = (ChannelMessage)me.MidiMessage; - switch (m.Command) - { - case ChannelCommand.NoteOn: - if (curNote.Note != "") - { - curNote.Length = delta / 1000F; - delta = 0; - notes.Add(curNote); - } - - curNote.Note = note2Piano(m.Data1); - break; - } - } - break; - case MessageType.Meta: - { - MetaMessage m = (MetaMessage)me.MidiMessage; - if (m.MetaType == MetaType.Tempo) - tempo = (new TempoChangeBuilder(m)).Tempo; - } - break; - } - } - - // make sure we get last note - if (curNote.Note != "") - { - curNote.Length = delta / 1000F; - notes.Add(curNote); - } - - // we found a track with content! - if (notes.Count > 0) - break; - } - - // compress redundant accidentals/octaves - char[] notemods = new char[7]; - int[] noteocts = new int[7]; - for (int i = 0; i < 7; i++) - { - notemods[i] = 'n'; - noteocts[i] = 3; - } - for (int i = 0; i < notes.Count; i++) - { - string noteStr = notes[i].Note; - int cur_note = noteStr[0] - 0x41; - char mod = noteStr[1]; - int oct = int.Parse(noteStr.Substring(2)); - - noteStr = noteStr.Substring(0, 1); - if (mod != notemods[cur_note]) - { - noteStr += new string(mod, 1); - notemods[cur_note] = mod; - } - if (oct != noteocts[cur_note]) - { - noteStr += oct.ToString(); - noteocts[cur_note] = oct; - } - - notes[i] = new PNote(notes[i].Length, noteStr); - } - - // now, we find what the "beat" length should be, - // by counting numbers of times for each length, and finding statistical mode - Dictionary scores = new Dictionary(); - foreach (PNote n in notes) - { - if (n.Length != 0) - if (scores.Keys.Contains(n.Length)) - scores[n.Length]++; - else - scores.Add(n.Length, 1); - } - float winner = 1; - int score = 0; - foreach (KeyValuePair kv in scores) - { - if (kv.Value > score) - { - winner = kv.Key; - score = kv.Value; - } - } - // realign all of them to match beat length - for (int i = 0; i < notes.Count; i++) - { - notes[i] = new PNote(notes[i].Length / winner, notes[i].Note); - } - - // compress chords down - for (int i = 0; i < notes.Count; i++) - { - if (notes[i].Length == 0 && i < notes.Count - 1) - { - notes[i + 1] = new PNote(notes[i + 1].Length, notes[i].Note + "-" + notes[i + 1].Note); - notes.RemoveAt(i); - i--; - } - } - - // add in time - for (int i = 0; i < notes.Count; i++) - { - float len = notes[i].Length; - notes[i] = new PNote(len, notes[i].Note + (len != 1 ? "/" + (1 / len).ToString("0.##") : "")); - } - - // what is the bpm, anyway? - int rpm = (int)(28800000 / tempo / winner); // 60 * 1,000,000 * .48 the .48 is because note lengths for some reason midi makes the beat note be .48 long - - // now, output! - string line = ""; - string output = ""; - int lineCount = 1; - foreach (PNote n in notes) - { - if (line.Length + n.Note.Length + 1 > 51) - { - output += line.Substring(0, line.Length - 1) + "\r\n"; - line = ""; - if (lineCount == 50) - break; - lineCount++; - } - line += n.Note + ","; - } - if (line.Length > 0) - output += line.Substring(0, line.Length - 1); - OutputTxt.Text = "BPM: " + rpm.ToString() + "\r\n" + output; - OutputTxt.SelectAll(); - } - - public enum NoteNames - { - C = 0, - D = 2, - E = 4, - F = 5, - G = 7, - A = 9, - B = 11 - } - - string note2Piano(int n) - { - string name, arg, octave; - name = Enum.GetName(typeof(NoteNames), (NoteNames)(n % 12)); - if (name == null) - { - name = Enum.GetName(typeof(NoteNames), (NoteNames)((n + 1) % 12)); - arg = "b"; - } - else - { - arg = "n"; - } - octave = (n / 12 - 1).ToString(); - - return name + arg + octave; - } - - private void copyToolStripMenuItem_Click(object sender, EventArgs e) - { - OutputTxt.SelectAll(); - OutputTxt.Copy(); - } - - private void halpToolStripMenuItem_Click(object sender, EventArgs e) - { - MessageBox.Show(this, - "This program prefers MIDIs that have a single track, otherwise it picks the first piano track it finds, else the first track. Songs with odd tempos may have their BPM's calculated wrong.", - "Halp", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - } -} diff --git a/tools/midi2piano/midi2piano/Form1.resx b/tools/midi2piano/midi2piano/Form1.resx deleted file mode 100644 index b76de494d0..0000000000 --- a/tools/midi2piano/midi2piano/Form1.resx +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - 17, 17 - - - 132, 17 - - \ No newline at end of file diff --git a/tools/midi2piano/midi2piano/Properties/AssemblyInfo.cs b/tools/midi2piano/midi2piano/Properties/AssemblyInfo.cs deleted file mode 100644 index f968b01889..0000000000 --- a/tools/midi2piano/midi2piano/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("midi2piano")] -[assembly: AssemblyProduct("midi2piano")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyCopyright("Copyright © 2011")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. Only Windows -// assemblies support COM. -[assembly: ComVisible(false)] - -// On Windows, the following GUID is for the ID of the typelib if this -// project is exposed to COM. On other platforms, it unique identifies the -// title storage container when deploying this assembly to the device. -[assembly: Guid("9752c562-edc1-40da-8fa1-619df747e0f3")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -[assembly: AssemblyVersion("1.0.0.0")] diff --git a/tools/midi2piano/midi2piano/Sanford.Multimedia.Midi.dll b/tools/midi2piano/midi2piano/Sanford.Multimedia.Midi.dll deleted file mode 100644 index 28a707657b35010a90b5a4f1bbae7a029031c70f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114688 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 gIS3Hg(O&8y5InN}4_>?7-#BRab?E - - - Debug - x86 - WinExe - Properties - midi2piano - midi2piano - v4.0 - Client - - - true - full - false - bin\x86\Debug - DEBUG;TRACE;WINDOWS - prompt - 4 - true - false - x86 - - - pdbonly - true - bin\x86\Release - TRACE;WINDOWS - prompt - 4 - true - false - x86 - - - - - - - False - - - False - .\Sanford.Multimedia.Midi.dll - - - False - - - - - - False - - - 4.0 - False - - - 4.0 - False - - - False - - - - - Form - - - Form1.cs - - - - - - Form1.cs - - - - \ No newline at end of file diff --git a/tools/midi2piano2016/README.txt b/tools/midi2piano2016/README.txt new file mode 100644 index 0000000000..e8d18981f0 --- /dev/null +++ b/tools/midi2piano2016/README.txt @@ -0,0 +1,31 @@ +This is a remake of 2013 midi2piano tool. Previous version should be considered obsolete. + +Requirements: + Python 3 + +Simply run midi2piano.py and choose midi file you want to convert. +The "sheet music" will be copied to the clipboard. + +There are some constants defined at the top of midi2piano.py. +Change their value if needed. + +LINE_LENGTH_LIM - max length of line allowed in the sheet music +LINES_LIMIT - max amount of lines allowed in the sheet music. Extra lines will be cropped. + +OVERALL_IMPORT_LIM - max amount of characters allowed in the sheet music. + + +You can also transpose music if you need to +OCTAVE_TRANSPOSE - amount of octaves you melody will be shifted by +FLOAT_PRECISION - read comment + +Additional notes: +1. Unlike previous midi2piano, this tool optimizes sheet music to fit more in less lines. +2. If two notes are less than 50 ms apart, they are chorded. BYOND works in 1/10th of a second so 50 ms is time quanta. +4. MIDI event set_tempo is NOT supported. If your MIDI file uses set_tempo to change BPM significantly, consider using some other midi file. + +This tool is considered final. + + +Made by EditorRUS/Delta Epsilon from Animus Station, ss13.ru +Contact me in Discord if you find any major issues: DeltaEpsilon#7787 \ No newline at end of file diff --git a/tools/midi2piano2016/easygui/__init__.py b/tools/midi2piano2016/easygui/__init__.py new file mode 100644 index 0000000000..2aa3b3b9aa --- /dev/null +++ b/tools/midi2piano2016/easygui/__init__.py @@ -0,0 +1,2492 @@ +""" +@version: 0.96(2010-08-29) + +@note: +ABOUT EASYGUI + +EasyGui provides an easy-to-use interface for simple GUI interaction +with a user. It does not require the programmer to know anything about +tkinter, frames, widgets, callbacks or lambda. All GUI interactions are +invoked by simple function calls that return results. + +@note: +WARNING about using EasyGui with IDLE + +You may encounter problems using IDLE to run programs that use EasyGui. Try it +and find out. EasyGui is a collection of Tkinter routines that run their own +event loops. IDLE is also a Tkinter application, with its own event loop. The +two may conflict, with unpredictable results. If you find that you have +problems, try running your EasyGui program outside of IDLE. + +Note that EasyGui requires Tk release 8.0 or greater. + +@note: +LICENSE INFORMATION + +EasyGui version 0.96 + +Copyright (c) 2010, Stephen Raymond Ferg + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + + 3. The name of the author may not be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +@note: +ABOUT THE EASYGUI LICENSE + +This license is what is generally known as the "modified BSD license", +aka "revised BSD", "new BSD", "3-clause BSD". +See http://www.opensource.org/licenses/bsd-license.php + +This license is GPL-compatible. +See http://en.wikipedia.org/wiki/License_compatibility +See http://www.gnu.org/licenses/license-list.html#GPLCompatibleLicenses + +The BSD License is less restrictive than GPL. +It allows software released under the license to be incorporated into proprietary products. +Works based on the software may be released under a proprietary license or as closed source software. +http://en.wikipedia.org/wiki/BSD_licenses#3-clause_license_.28.22New_BSD_License.22.29 + +""" +egversion = __doc__.split()[1] + +__all__ = ['ynbox' + , 'ccbox' + , 'boolbox' + , 'indexbox' + , 'msgbox' + , 'buttonbox' + , 'integerbox' + , 'multenterbox' + , 'enterbox' + , 'exceptionbox' + , 'choicebox' + , 'codebox' + , 'textbox' + , 'diropenbox' + , 'fileopenbox' + , 'filesavebox' + , 'passwordbox' + , 'multpasswordbox' + , 'multchoicebox' + , 'abouteasygui' + , 'egversion' + , 'egdemo' + , 'EgStore' + ] + +import sys, os +import string +import pickle +import traceback + + +#-------------------------------------------------- +# check python version and take appropriate action +#-------------------------------------------------- +""" +From the python documentation: + +sys.hexversion contains the version number encoded as a single integer. This is +guaranteed to increase with each version, including proper support for non- +production releases. For example, to test that the Python interpreter is at +least version 1.5.2, use: + +if sys.hexversion >= 0x010502F0: + # use some advanced feature + ... +else: + # use an alternative implementation or warn the user + ... +""" + + +if sys.hexversion >= 0x020600F0: + runningPython26 = True +else: + runningPython26 = False + +if sys.hexversion >= 0x030000F0: + runningPython3 = True +else: + runningPython3 = False + +try: + from PIL import Image as PILImage + from PIL import ImageTk as PILImageTk + PILisLoaded = True +except: + PILisLoaded = False + + +if runningPython3: + from tkinter import * + import tkinter.filedialog as tk_FileDialog + from io import StringIO +else: + from Tkinter import * + import tkFileDialog as tk_FileDialog + from StringIO import StringIO + +def write(*args): + args = [str(arg) for arg in args] + args = " ".join(args) + sys.stdout.write(args) + +def writeln(*args): + write(*args) + sys.stdout.write("\n") + +say = writeln + + +if TkVersion < 8.0 : + stars = "*"*75 + writeln("""\n\n\n""" + stars + """ +You are running Tk version: """ + str(TkVersion) + """ +You must be using Tk version 8.0 or greater to use EasyGui. +Terminating. +""" + stars + """\n\n\n""") + sys.exit(0) + +def dq(s): + return '"%s"' % s + +rootWindowPosition = "+300+200" + +PROPORTIONAL_FONT_FAMILY = ("MS", "Sans", "Serif") +MONOSPACE_FONT_FAMILY = ("Courier") + +PROPORTIONAL_FONT_SIZE = 10 +MONOSPACE_FONT_SIZE = 9 #a little smaller, because it it more legible at a smaller size +TEXT_ENTRY_FONT_SIZE = 12 # a little larger makes it easier to see + +#STANDARD_SELECTION_EVENTS = ["Return", "Button-1"] +STANDARD_SELECTION_EVENTS = ["Return", "Button-1", "space"] + +# Initialize some global variables that will be reset later +__choiceboxMultipleSelect = None +__widgetTexts = None +__replyButtonText = None +__choiceboxResults = None +__firstWidget = None +__enterboxText = None +__enterboxDefaultText="" +__multenterboxText = "" +choiceboxChoices = None +choiceboxWidget = None +entryWidget = None +boxRoot = None +ImageErrorMsg = ( + "\n\n---------------------------------------------\n" + "Error: %s\n%s") +#------------------------------------------------------------------- +# various boxes built on top of the basic buttonbox +#----------------------------------------------------------------------- + +#----------------------------------------------------------------------- +# ynbox +#----------------------------------------------------------------------- +def ynbox(msg="Shall I continue?" + , title=" " + , choices=("Yes", "No") + , image=None + ): + """ + Display a msgbox with choices of Yes and No. + + The default is "Yes". + + The returned value is calculated this way:: + if the first choice ("Yes") is chosen, or if the dialog is cancelled: + return 1 + else: + return 0 + + If invoked without a msg argument, displays a generic request for a confirmation + that the user wishes to continue. So it can be used this way:: + if ynbox(): pass # continue + else: sys.exit(0) # exit the program + + @arg msg: the msg to be displayed. + @arg title: the window title + @arg choices: a list or tuple of the choices to be displayed + """ + return boolbox(msg, title, choices, image=image) + + +#----------------------------------------------------------------------- +# ccbox +#----------------------------------------------------------------------- +def ccbox(msg="Shall I continue?" + , title=" " + , choices=("Continue", "Cancel") + , image=None + ): + """ + Display a msgbox with choices of Continue and Cancel. + + The default is "Continue". + + The returned value is calculated this way:: + if the first choice ("Continue") is chosen, or if the dialog is cancelled: + return 1 + else: + return 0 + + If invoked without a msg argument, displays a generic request for a confirmation + that the user wishes to continue. So it can be used this way:: + + if ccbox(): + pass # continue + else: + sys.exit(0) # exit the program + + @arg msg: the msg to be displayed. + @arg title: the window title + @arg choices: a list or tuple of the choices to be displayed + """ + return boolbox(msg, title, choices, image=image) + + +#----------------------------------------------------------------------- +# boolbox +#----------------------------------------------------------------------- +def boolbox(msg="Shall I continue?" + , title=" " + , choices=("Yes","No") + , image=None + ): + """ + Display a boolean msgbox. + + The default is the first choice. + + The returned value is calculated this way:: + if the first choice is chosen, or if the dialog is cancelled: + returns 1 + else: + returns 0 + """ + reply = buttonbox(msg=msg, choices=choices, title=title, image=image) + if reply == choices[0]: return 1 + else: return 0 + + +#----------------------------------------------------------------------- +# indexbox +#----------------------------------------------------------------------- +def indexbox(msg="Shall I continue?" + , title=" " + , choices=("Yes","No") + , image=None + ): + """ + Display a buttonbox with the specified choices. + Return the index of the choice selected. + """ + reply = buttonbox(msg=msg, choices=choices, title=title, image=image) + index = -1 + for choice in choices: + index = index + 1 + if reply == choice: return index + raise AssertionError( + "There is a program logic error in the EasyGui code for indexbox.") + + +#----------------------------------------------------------------------- +# msgbox +#----------------------------------------------------------------------- +def msgbox(msg="(Your message goes here)", title=" ", ok_button="OK",image=None,root=None): + """ + Display a messagebox + """ + if type(ok_button) != type("OK"): + raise AssertionError("The 'ok_button' argument to msgbox must be a string.") + + return buttonbox(msg=msg, title=title, choices=[ok_button], image=image,root=root) + + +#------------------------------------------------------------------- +# buttonbox +#------------------------------------------------------------------- +def buttonbox(msg="",title=" " + ,choices=("Button1", "Button2", "Button3") + , image=None + , root=None + ): + """ + Display a msg, a title, and a set of buttons. + The buttons are defined by the members of the choices list. + Return the text of the button that the user selected. + + @arg msg: the msg to be displayed. + @arg title: the window title + @arg choices: a list or tuple of the choices to be displayed + """ + global boxRoot, __replyButtonText, __widgetTexts, buttonsFrame + + + # Initialize __replyButtonText to the first choice. + # This is what will be used if the window is closed by the close button. + __replyButtonText = choices[0] + + if root: + root.withdraw() + boxRoot = Toplevel(master=root) + boxRoot.withdraw() + else: + boxRoot = Tk() + boxRoot.withdraw() + + boxRoot.protocol('WM_DELETE_WINDOW', denyWindowManagerClose ) + boxRoot.title(title) + boxRoot.iconname('Dialog') + boxRoot.geometry(rootWindowPosition) + boxRoot.minsize(400, 100) + + # ------------- define the messageFrame --------------------------------- + messageFrame = Frame(master=boxRoot) + messageFrame.pack(side=TOP, fill=BOTH) + + # ------------- define the imageFrame --------------------------------- + tk_Image = None + if image: + imageFilename = os.path.normpath(image) + junk,ext = os.path.splitext(imageFilename) + + if os.path.exists(imageFilename): + if ext.lower() in [".gif", ".pgm", ".ppm"]: + tk_Image = PhotoImage(master=boxRoot, file=imageFilename) + else: + if PILisLoaded: + try: + pil_Image = PILImage.open(imageFilename) + tk_Image = PILImageTk.PhotoImage(pil_Image, master=boxRoot) + except: + msg += ImageErrorMsg % (imageFilename, + "\nThe Python Imaging Library (PIL) could not convert this file to a displayable image." + "\n\nPIL reports:\n" + exception_format()) + + else: # PIL is not loaded + msg += ImageErrorMsg % (imageFilename, + "\nI could not import the Python Imaging Library (PIL) to display the image.\n\n" + "You may need to install PIL\n" + "(http://www.pythonware.com/products/pil/)\n" + "to display " + ext + " image files.") + + else: + msg += ImageErrorMsg % (imageFilename, "\nImage file not found.") + + if tk_Image: + imageFrame = Frame(master=boxRoot) + imageFrame.pack(side=TOP, fill=BOTH) + label = Label(imageFrame,image=tk_Image) + label.image = tk_Image # keep a reference! + label.pack(side=TOP, expand=YES, fill=X, padx='1m', pady='1m') + + # ------------- define the buttonsFrame --------------------------------- + buttonsFrame = Frame(master=boxRoot) + buttonsFrame.pack(side=TOP, fill=BOTH) + + # -------------------- place the widgets in the frames ----------------------- + messageWidget = Message(messageFrame, text=msg, width=400) + messageWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,PROPORTIONAL_FONT_SIZE)) + messageWidget.pack(side=TOP, expand=YES, fill=X, padx='3m', pady='3m') + + __put_buttons_in_buttonframe(choices) + + # -------------- the action begins ----------- + # put the focus on the first button + __firstWidget.focus_force() + + boxRoot.deiconify() + boxRoot.mainloop() + boxRoot.destroy() + if root: root.deiconify() + return __replyButtonText + + +#------------------------------------------------------------------- +# integerbox +#------------------------------------------------------------------- +def integerbox(msg="" + , title=" " + , default="" + , lowerbound=0 + , upperbound=99 + , image = None + , root = None + , **invalidKeywordArguments + ): + """ + Show a box in which a user can enter an integer. + + In addition to arguments for msg and title, this function accepts + integer arguments for "default", "lowerbound", and "upperbound". + + The default argument may be None. + + When the user enters some text, the text is checked to verify that it + can be converted to an integer between the lowerbound and upperbound. + + If it can be, the integer (not the text) is returned. + + If it cannot, then an error msg is displayed, and the integerbox is + redisplayed. + + If the user cancels the operation, None is returned. + + NOTE that the "argLowerBound" and "argUpperBound" arguments are no longer + supported. They have been replaced by "upperbound" and "lowerbound". + """ + if "argLowerBound" in invalidKeywordArguments: + raise AssertionError( + "\nintegerbox no longer supports the 'argLowerBound' argument.\n" + + "Use 'lowerbound' instead.\n\n") + if "argUpperBound" in invalidKeywordArguments: + raise AssertionError( + "\nintegerbox no longer supports the 'argUpperBound' argument.\n" + + "Use 'upperbound' instead.\n\n") + + if default != "": + if type(default) != type(1): + raise AssertionError( + "integerbox received a non-integer value for " + + "default of " + dq(str(default)) , "Error") + + if type(lowerbound) != type(1): + raise AssertionError( + "integerbox received a non-integer value for " + + "lowerbound of " + dq(str(lowerbound)) , "Error") + + if type(upperbound) != type(1): + raise AssertionError( + "integerbox received a non-integer value for " + + "upperbound of " + dq(str(upperbound)) , "Error") + + if msg == "": + msg = ("Enter an integer between " + str(lowerbound) + + " and " + + str(upperbound) + ) + + while 1: + reply = enterbox(msg, title, str(default), image=image, root=root) + if reply == None: return None + + try: + reply = int(reply) + except: + msgbox ("The value that you entered:\n\t%s\nis not an integer." % dq(str(reply)) + , "Error") + continue + + if reply < lowerbound: + msgbox ("The value that you entered is less than the lower bound of " + + str(lowerbound) + ".", "Error") + continue + + if reply > upperbound: + msgbox ("The value that you entered is greater than the upper bound of " + + str(upperbound) + ".", "Error") + continue + + # reply has passed all validation checks. + # It is an integer between the specified bounds. + return reply + +#------------------------------------------------------------------- +# multenterbox +#------------------------------------------------------------------- +def multenterbox(msg="Fill in values for the fields." + , title=" " + , fields=() + , values=() + ): + r""" + Show screen with multiple data entry fields. + + If there are fewer values than names, the list of values is padded with + empty strings until the number of values is the same as the number of names. + + If there are more values than names, the list of values + is truncated so that there are as many values as names. + + Returns a list of the values of the fields, + or None if the user cancels the operation. + + Here is some example code, that shows how values returned from + multenterbox can be checked for validity before they are accepted:: + ---------------------------------------------------------------------- + msg = "Enter your personal information" + title = "Credit Card Application" + fieldNames = ["Name","Street Address","City","State","ZipCode"] + fieldValues = [] # we start with blanks for the values + fieldValues = multenterbox(msg,title, fieldNames) + + # make sure that none of the fields was left blank + while 1: + if fieldValues == None: break + errmsg = "" + for i in range(len(fieldNames)): + if fieldValues[i].strip() == "": + errmsg += ('"%s" is a required field.\n\n' % fieldNames[i]) + if errmsg == "": + break # no problems found + fieldValues = multenterbox(errmsg, title, fieldNames, fieldValues) + + writeln("Reply was: %s" % str(fieldValues)) + ---------------------------------------------------------------------- + + @arg msg: the msg to be displayed. + @arg title: the window title + @arg fields: a list of fieldnames. + @arg values: a list of field values + """ + return __multfillablebox(msg,title,fields,values,None) + + +#----------------------------------------------------------------------- +# multpasswordbox +#----------------------------------------------------------------------- +def multpasswordbox(msg="Fill in values for the fields." + , title=" " + , fields=tuple() + ,values=tuple() + ): + r""" + Same interface as multenterbox. But in multpassword box, + the last of the fields is assumed to be a password, and + is masked with asterisks. + + Example + ======= + + Here is some example code, that shows how values returned from + multpasswordbox can be checked for validity before they are accepted:: + msg = "Enter logon information" + title = "Demo of multpasswordbox" + fieldNames = ["Server ID", "User ID", "Password"] + fieldValues = [] # we start with blanks for the values + fieldValues = multpasswordbox(msg,title, fieldNames) + + # make sure that none of the fields was left blank + while 1: + if fieldValues == None: break + errmsg = "" + for i in range(len(fieldNames)): + if fieldValues[i].strip() == "": + errmsg = errmsg + ('"%s" is a required field.\n\n' % fieldNames[i]) + if errmsg == "": break # no problems found + fieldValues = multpasswordbox(errmsg, title, fieldNames, fieldValues) + + writeln("Reply was: %s" % str(fieldValues)) + """ + return __multfillablebox(msg,title,fields,values,"*") + +def bindArrows(widget): + widget.bind("", tabRight) + widget.bind("" , tabLeft) + + widget.bind("",tabRight) + widget.bind("" , tabLeft) + +def tabRight(event): + boxRoot.event_generate("") + +def tabLeft(event): + boxRoot.event_generate("") + +#----------------------------------------------------------------------- +# __multfillablebox +#----------------------------------------------------------------------- +def __multfillablebox(msg="Fill in values for the fields." + , title=" " + , fields=() + , values=() + , mask = None + ): + global boxRoot, __multenterboxText, __multenterboxDefaultText, cancelButton, entryWidget, okButton + + choices = ["OK", "Cancel"] + if len(fields) == 0: return None + + fields = list(fields[:]) # convert possible tuples to a list + values = list(values[:]) # convert possible tuples to a list + + if len(values) == len(fields): pass + elif len(values) > len(fields): + fields = fields[0:len(values)] + else: + while len(values) < len(fields): + values.append("") + + boxRoot = Tk() + + boxRoot.protocol('WM_DELETE_WINDOW', denyWindowManagerClose ) + boxRoot.title(title) + boxRoot.iconname('Dialog') + boxRoot.geometry(rootWindowPosition) + boxRoot.bind("", __multenterboxCancel) + + # -------------------- put subframes in the boxRoot -------------------- + messageFrame = Frame(master=boxRoot) + messageFrame.pack(side=TOP, fill=BOTH) + + #-------------------- the msg widget ---------------------------- + messageWidget = Message(messageFrame, width="4.5i", text=msg) + messageWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,PROPORTIONAL_FONT_SIZE)) + messageWidget.pack(side=RIGHT, expand=1, fill=BOTH, padx='3m', pady='3m') + + global entryWidgets + entryWidgets = [] + + lastWidgetIndex = len(fields) - 1 + + for widgetIndex in range(len(fields)): + argFieldName = fields[widgetIndex] + argFieldValue = values[widgetIndex] + entryFrame = Frame(master=boxRoot) + entryFrame.pack(side=TOP, fill=BOTH) + + # --------- entryWidget ---------------------------------------------- + labelWidget = Label(entryFrame, text=argFieldName) + labelWidget.pack(side=LEFT) + + entryWidget = Entry(entryFrame, width=40,highlightthickness=2) + entryWidgets.append(entryWidget) + entryWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,TEXT_ENTRY_FONT_SIZE)) + entryWidget.pack(side=RIGHT, padx="3m") + + bindArrows(entryWidget) + + entryWidget.bind("", __multenterboxGetText) + entryWidget.bind("", __multenterboxCancel) + + # for the last entryWidget, if this is a multpasswordbox, + # show the contents as just asterisks + if widgetIndex == lastWidgetIndex: + if mask: + entryWidgets[widgetIndex].configure(show=mask) + + # put text into the entryWidget + entryWidgets[widgetIndex].insert(0,argFieldValue) + widgetIndex += 1 + + # ------------------ ok button ------------------------------- + buttonsFrame = Frame(master=boxRoot) + buttonsFrame.pack(side=BOTTOM, fill=BOTH) + + okButton = Button(buttonsFrame, takefocus=1, text="OK") + bindArrows(okButton) + okButton.pack(expand=1, side=LEFT, padx='3m', pady='3m', ipadx='2m', ipady='1m') + + # for the commandButton, bind activation events to the activation event handler + commandButton = okButton + handler = __multenterboxGetText + for selectionEvent in STANDARD_SELECTION_EVENTS: + commandButton.bind("<%s>" % selectionEvent, handler) + + + # ------------------ cancel button ------------------------------- + cancelButton = Button(buttonsFrame, takefocus=1, text="Cancel") + bindArrows(cancelButton) + cancelButton.pack(expand=1, side=RIGHT, padx='3m', pady='3m', ipadx='2m', ipady='1m') + + # for the commandButton, bind activation events to the activation event handler + commandButton = cancelButton + handler = __multenterboxCancel + for selectionEvent in STANDARD_SELECTION_EVENTS: + commandButton.bind("<%s>" % selectionEvent, handler) + + + # ------------------- time for action! ----------------- + entryWidgets[0].focus_force() # put the focus on the entryWidget + boxRoot.mainloop() # run it! + + # -------- after the run has completed ---------------------------------- + boxRoot.destroy() # button_click didn't destroy boxRoot, so we do it now + return __multenterboxText + + +#----------------------------------------------------------------------- +# __multenterboxGetText +#----------------------------------------------------------------------- +def __multenterboxGetText(event): + global __multenterboxText + + __multenterboxText = [] + for entryWidget in entryWidgets: + __multenterboxText.append(entryWidget.get()) + boxRoot.quit() + + +def __multenterboxCancel(event): + global __multenterboxText + __multenterboxText = None + boxRoot.quit() + + +#------------------------------------------------------------------- +# enterbox +#------------------------------------------------------------------- +def enterbox(msg="Enter something." + , title=" " + , default="" + , strip=True + , image=None + , root=None + ): + """ + Show a box in which a user can enter some text. + + You may optionally specify some default text, which will appear in the + enterbox when it is displayed. + + Returns the text that the user entered, or None if he cancels the operation. + + By default, enterbox strips its result (i.e. removes leading and trailing + whitespace). (If you want it not to strip, use keyword argument: strip=False.) + This makes it easier to test the results of the call:: + + reply = enterbox(....) + if reply: + ... + else: + ... + """ + result = __fillablebox(msg, title, default=default, mask=None,image=image,root=root) + if result and strip: + result = result.strip() + return result + + +def passwordbox(msg="Enter your password." + , title=" " + , default="" + , image=None + , root=None + ): + """ + Show a box in which a user can enter a password. + The text is masked with asterisks, so the password is not displayed. + Returns the text that the user entered, or None if he cancels the operation. + """ + return __fillablebox(msg, title, default, mask="*",image=image,root=root) + + +def __fillablebox(msg + , title="" + , default="" + , mask=None + , image=None + , root=None + ): + """ + Show a box in which a user can enter some text. + You may optionally specify some default text, which will appear in the + enterbox when it is displayed. + Returns the text that the user entered, or None if he cancels the operation. + """ + + global boxRoot, __enterboxText, __enterboxDefaultText + global cancelButton, entryWidget, okButton + + if title == None: title == "" + if default == None: default = "" + __enterboxDefaultText = default + __enterboxText = __enterboxDefaultText + + if root: + root.withdraw() + boxRoot = Toplevel(master=root) + boxRoot.withdraw() + else: + boxRoot = Tk() + boxRoot.withdraw() + + boxRoot.protocol('WM_DELETE_WINDOW', denyWindowManagerClose ) + boxRoot.title(title) + boxRoot.iconname('Dialog') + boxRoot.geometry(rootWindowPosition) + boxRoot.bind("", __enterboxCancel) + + # ------------- define the messageFrame --------------------------------- + messageFrame = Frame(master=boxRoot) + messageFrame.pack(side=TOP, fill=BOTH) + + # ------------- define the imageFrame --------------------------------- + tk_Image = None + if image: + imageFilename = os.path.normpath(image) + junk,ext = os.path.splitext(imageFilename) + + if os.path.exists(imageFilename): + if ext.lower() in [".gif", ".pgm", ".ppm"]: + tk_Image = PhotoImage(master=boxRoot, file=imageFilename) + else: + if PILisLoaded: + try: + pil_Image = PILImage.open(imageFilename) + tk_Image = PILImageTk.PhotoImage(pil_Image, master=boxRoot) + except: + msg += ImageErrorMsg % (imageFilename, + "\nThe Python Imaging Library (PIL) could not convert this file to a displayable image." + "\n\nPIL reports:\n" + exception_format()) + + else: # PIL is not loaded + msg += ImageErrorMsg % (imageFilename, + "\nI could not import the Python Imaging Library (PIL) to display the image.\n\n" + "You may need to install PIL\n" + "(http://www.pythonware.com/products/pil/)\n" + "to display " + ext + " image files.") + + else: + msg += ImageErrorMsg % (imageFilename, "\nImage file not found.") + + if tk_Image: + imageFrame = Frame(master=boxRoot) + imageFrame.pack(side=TOP, fill=BOTH) + label = Label(imageFrame,image=tk_Image) + label.image = tk_Image # keep a reference! + label.pack(side=TOP, expand=YES, fill=X, padx='1m', pady='1m') + + # ------------- define the buttonsFrame --------------------------------- + buttonsFrame = Frame(master=boxRoot) + buttonsFrame.pack(side=TOP, fill=BOTH) + + + # ------------- define the entryFrame --------------------------------- + entryFrame = Frame(master=boxRoot) + entryFrame.pack(side=TOP, fill=BOTH) + + # ------------- define the buttonsFrame --------------------------------- + buttonsFrame = Frame(master=boxRoot) + buttonsFrame.pack(side=TOP, fill=BOTH) + + #-------------------- the msg widget ---------------------------- + messageWidget = Message(messageFrame, width="4.5i", text=msg) + messageWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,PROPORTIONAL_FONT_SIZE)) + messageWidget.pack(side=RIGHT, expand=1, fill=BOTH, padx='3m', pady='3m') + + # --------- entryWidget ---------------------------------------------- + entryWidget = Entry(entryFrame, width=40) + bindArrows(entryWidget) + entryWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,TEXT_ENTRY_FONT_SIZE)) + if mask: + entryWidget.configure(show=mask) + entryWidget.pack(side=LEFT, padx="3m") + entryWidget.bind("", __enterboxGetText) + entryWidget.bind("", __enterboxCancel) + # put text into the entryWidget + entryWidget.insert(0,__enterboxDefaultText) + + # ------------------ ok button ------------------------------- + okButton = Button(buttonsFrame, takefocus=1, text="OK") + bindArrows(okButton) + okButton.pack(expand=1, side=LEFT, padx='3m', pady='3m', ipadx='2m', ipady='1m') + + # for the commandButton, bind activation events to the activation event handler + commandButton = okButton + handler = __enterboxGetText + for selectionEvent in STANDARD_SELECTION_EVENTS: + commandButton.bind("<%s>" % selectionEvent, handler) + + + # ------------------ cancel button ------------------------------- + cancelButton = Button(buttonsFrame, takefocus=1, text="Cancel") + bindArrows(cancelButton) + cancelButton.pack(expand=1, side=RIGHT, padx='3m', pady='3m', ipadx='2m', ipady='1m') + + # for the commandButton, bind activation events to the activation event handler + commandButton = cancelButton + handler = __enterboxCancel + for selectionEvent in STANDARD_SELECTION_EVENTS: + commandButton.bind("<%s>" % selectionEvent, handler) + + # ------------------- time for action! ----------------- + entryWidget.focus_force() # put the focus on the entryWidget + boxRoot.deiconify() + boxRoot.mainloop() # run it! + + # -------- after the run has completed ---------------------------------- + if root: root.deiconify() + boxRoot.destroy() # button_click didn't destroy boxRoot, so we do it now + return __enterboxText + + +def __enterboxGetText(event): + global __enterboxText + + __enterboxText = entryWidget.get() + boxRoot.quit() + + +def __enterboxRestore(event): + global entryWidget + + entryWidget.delete(0,len(entryWidget.get())) + entryWidget.insert(0, __enterboxDefaultText) + + +def __enterboxCancel(event): + global __enterboxText + + __enterboxText = None + boxRoot.quit() + +def denyWindowManagerClose(): + """ don't allow WindowManager close + """ + x = Tk() + x.withdraw() + x.bell() + x.destroy() + + + +#------------------------------------------------------------------- +# multchoicebox +#------------------------------------------------------------------- +def multchoicebox(msg="Pick as many items as you like." + , title=" " + , choices=() + , **kwargs + ): + """ + Present the user with a list of choices. + allow him to select multiple items and return them in a list. + if the user doesn't choose anything from the list, return the empty list. + return None if he cancelled selection. + + @arg msg: the msg to be displayed. + @arg title: the window title + @arg choices: a list or tuple of the choices to be displayed + """ + if len(choices) == 0: choices = ["Program logic error - no choices were specified."] + + global __choiceboxMultipleSelect + __choiceboxMultipleSelect = 1 + return __choicebox(msg, title, choices) + + +#----------------------------------------------------------------------- +# choicebox +#----------------------------------------------------------------------- +def choicebox(msg="Pick something." + , title=" " + , choices=() + ): + """ + Present the user with a list of choices. + return the choice that he selects. + return None if he cancels the selection selection. + + @arg msg: the msg to be displayed. + @arg title: the window title + @arg choices: a list or tuple of the choices to be displayed + """ + if len(choices) == 0: choices = ["Program logic error - no choices were specified."] + + global __choiceboxMultipleSelect + __choiceboxMultipleSelect = 0 + return __choicebox(msg,title,choices) + + +#----------------------------------------------------------------------- +# __choicebox +#----------------------------------------------------------------------- +def __choicebox(msg + , title + , choices + ): + """ + internal routine to support choicebox() and multchoicebox() + """ + global boxRoot, __choiceboxResults, choiceboxWidget, defaultText + global choiceboxWidget, choiceboxChoices + #------------------------------------------------------------------- + # If choices is a tuple, we make it a list so we can sort it. + # If choices is already a list, we make a new list, so that when + # we sort the choices, we don't affect the list object that we + # were given. + #------------------------------------------------------------------- + choices = list(choices[:]) + if len(choices) == 0: + choices = ["Program logic error - no choices were specified."] + defaultButtons = ["OK", "Cancel"] + + # make sure all choices are strings + for index in range(len(choices)): + choices[index] = str(choices[index]) + + lines_to_show = min(len(choices), 20) + lines_to_show = 20 + + if title == None: title = "" + + # Initialize __choiceboxResults + # This is the value that will be returned if the user clicks the close icon + __choiceboxResults = None + + boxRoot = Tk() + boxRoot.protocol('WM_DELETE_WINDOW', denyWindowManagerClose ) + screen_width = boxRoot.winfo_screenwidth() + screen_height = boxRoot.winfo_screenheight() + root_width = int((screen_width * 0.8)) + root_height = int((screen_height * 0.5)) + root_xpos = int((screen_width * 0.1)) + root_ypos = int((screen_height * 0.05)) + + boxRoot.title(title) + boxRoot.iconname('Dialog') + rootWindowPosition = "+0+0" + boxRoot.geometry(rootWindowPosition) + boxRoot.expand=NO + boxRoot.minsize(root_width, root_height) + rootWindowPosition = "+" + str(root_xpos) + "+" + str(root_ypos) + boxRoot.geometry(rootWindowPosition) + + # ---------------- put the frames in the window ----------------------------------------- + message_and_buttonsFrame = Frame(master=boxRoot) + message_and_buttonsFrame.pack(side=TOP, fill=X, expand=NO) + + messageFrame = Frame(message_and_buttonsFrame) + messageFrame.pack(side=LEFT, fill=X, expand=YES) + #messageFrame.pack(side=TOP, fill=X, expand=YES) + + buttonsFrame = Frame(message_and_buttonsFrame) + buttonsFrame.pack(side=RIGHT, expand=NO, pady=0) + #buttonsFrame.pack(side=TOP, expand=YES, pady=0) + + choiceboxFrame = Frame(master=boxRoot) + choiceboxFrame.pack(side=BOTTOM, fill=BOTH, expand=YES) + + # -------------------------- put the widgets in the frames ------------------------------ + + # ---------- put a msg widget in the msg frame------------------- + messageWidget = Message(messageFrame, anchor=NW, text=msg, width=int(root_width * 0.9)) + messageWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,PROPORTIONAL_FONT_SIZE)) + messageWidget.pack(side=LEFT, expand=YES, fill=BOTH, padx='1m', pady='1m') + + # -------- put the choiceboxWidget in the choiceboxFrame --------------------------- + choiceboxWidget = Listbox(choiceboxFrame + , height=lines_to_show + , borderwidth="1m" + , relief="flat" + , bg="white" + ) + + if __choiceboxMultipleSelect: + choiceboxWidget.configure(selectmode=MULTIPLE) + + choiceboxWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,PROPORTIONAL_FONT_SIZE)) + + # add a vertical scrollbar to the frame + rightScrollbar = Scrollbar(choiceboxFrame, orient=VERTICAL, command=choiceboxWidget.yview) + choiceboxWidget.configure(yscrollcommand = rightScrollbar.set) + + # add a horizontal scrollbar to the frame + bottomScrollbar = Scrollbar(choiceboxFrame, orient=HORIZONTAL, command=choiceboxWidget.xview) + choiceboxWidget.configure(xscrollcommand = bottomScrollbar.set) + + # pack the Listbox and the scrollbars. Note that although we must define + # the textArea first, we must pack it last, so that the bottomScrollbar will + # be located properly. + + bottomScrollbar.pack(side=BOTTOM, fill = X) + rightScrollbar.pack(side=RIGHT, fill = Y) + + choiceboxWidget.pack(side=LEFT, padx="1m", pady="1m", expand=YES, fill=BOTH) + + #--------------------------------------------------- + # sort the choices + # eliminate duplicates + # put the choices into the choiceboxWidget + #--------------------------------------------------- + for index in range(len(choices)): + choices[index] = str(choices[index]) + + if runningPython3: + choices.sort(key=str.lower) + else: + choices.sort( lambda x,y: cmp(x.lower(), y.lower())) # case-insensitive sort + + lastInserted = None + choiceboxChoices = [] + for choice in choices: + if choice == lastInserted: pass + else: + choiceboxWidget.insert(END, choice) + choiceboxChoices.append(choice) + lastInserted = choice + + boxRoot.bind('', KeyboardListener) + + # put the buttons in the buttonsFrame + if len(choices) > 0: + okButton = Button(buttonsFrame, takefocus=YES, text="OK", height=1, width=6) + bindArrows(okButton) + okButton.pack(expand=NO, side=TOP, padx='2m', pady='1m', ipady="1m", ipadx="2m") + + # for the commandButton, bind activation events to the activation event handler + commandButton = okButton + handler = __choiceboxGetChoice + for selectionEvent in STANDARD_SELECTION_EVENTS: + commandButton.bind("<%s>" % selectionEvent, handler) + + # now bind the keyboard events + choiceboxWidget.bind("", __choiceboxGetChoice) + choiceboxWidget.bind("", __choiceboxGetChoice) + else: + # now bind the keyboard events + choiceboxWidget.bind("", __choiceboxCancel) + choiceboxWidget.bind("", __choiceboxCancel) + + cancelButton = Button(buttonsFrame, takefocus=YES, text="Cancel", height=1, width=6) + bindArrows(cancelButton) + cancelButton.pack(expand=NO, side=BOTTOM, padx='2m', pady='1m', ipady="1m", ipadx="2m") + + # for the commandButton, bind activation events to the activation event handler + commandButton = cancelButton + handler = __choiceboxCancel + for selectionEvent in STANDARD_SELECTION_EVENTS: + commandButton.bind("<%s>" % selectionEvent, handler) + + + # add special buttons for multiple select features + if len(choices) > 0 and __choiceboxMultipleSelect: + selectionButtonsFrame = Frame(messageFrame) + selectionButtonsFrame.pack(side=RIGHT, fill=Y, expand=NO) + + selectAllButton = Button(selectionButtonsFrame, text="Select All", height=1, width=6) + bindArrows(selectAllButton) + + selectAllButton.bind("",__choiceboxSelectAll) + selectAllButton.pack(expand=NO, side=TOP, padx='2m', pady='1m', ipady="1m", ipadx="2m") + + clearAllButton = Button(selectionButtonsFrame, text="Clear All", height=1, width=6) + bindArrows(clearAllButton) + clearAllButton.bind("",__choiceboxClearAll) + clearAllButton.pack(expand=NO, side=TOP, padx='2m', pady='1m', ipady="1m", ipadx="2m") + + + # -------------------- bind some keyboard events ---------------------------- + boxRoot.bind("", __choiceboxCancel) + + # --------------------- the action begins ----------------------------------- + # put the focus on the choiceboxWidget, and the select highlight on the first item + choiceboxWidget.select_set(0) + choiceboxWidget.focus_force() + + # --- run it! ----- + boxRoot.mainloop() + + boxRoot.destroy() + return __choiceboxResults + + +def __choiceboxGetChoice(event): + global boxRoot, __choiceboxResults, choiceboxWidget + + if __choiceboxMultipleSelect: + __choiceboxResults = [choiceboxWidget.get(index) for index in choiceboxWidget.curselection()] + + else: + choice_index = choiceboxWidget.curselection() + __choiceboxResults = choiceboxWidget.get(choice_index) + + # writeln("Debugging> mouse-event=", event, " event.type=", event.type) + # writeln("Debugging> choice=", choice_index, __choiceboxResults) + boxRoot.quit() + + +def __choiceboxSelectAll(event): + global choiceboxWidget, choiceboxChoices + + choiceboxWidget.selection_set(0, len(choiceboxChoices)-1) + +def __choiceboxClearAll(event): + global choiceboxWidget, choiceboxChoices + + choiceboxWidget.selection_clear(0, len(choiceboxChoices)-1) + + + +def __choiceboxCancel(event): + global boxRoot, __choiceboxResults + + __choiceboxResults = None + boxRoot.quit() + + +def KeyboardListener(event): + global choiceboxChoices, choiceboxWidget + key = event.keysym + if len(key) <= 1: + if key in string.printable: + # Find the key in the list. + # before we clear the list, remember the selected member + try: + start_n = int(choiceboxWidget.curselection()[0]) + except IndexError: + start_n = -1 + + ## clear the selection. + choiceboxWidget.selection_clear(0, 'end') + + ## start from previous selection +1 + for n in range(start_n+1, len(choiceboxChoices)): + item = choiceboxChoices[n] + if item[0].lower() == key.lower(): + choiceboxWidget.selection_set(first=n) + choiceboxWidget.see(n) + return + else: + # has not found it so loop from top + for n in range(len(choiceboxChoices)): + item = choiceboxChoices[n] + if item[0].lower() == key.lower(): + choiceboxWidget.selection_set(first = n) + choiceboxWidget.see(n) + return + + # nothing matched -- we'll look for the next logical choice + for n in range(len(choiceboxChoices)): + item = choiceboxChoices[n] + if item[0].lower() > key.lower(): + if n > 0: + choiceboxWidget.selection_set(first = (n-1)) + else: + choiceboxWidget.selection_set(first = 0) + choiceboxWidget.see(n) + return + + # still no match (nothing was greater than the key) + # we set the selection to the first item in the list + lastIndex = len(choiceboxChoices)-1 + choiceboxWidget.selection_set(first = lastIndex) + choiceboxWidget.see(lastIndex) + return + +#----------------------------------------------------------------------- +# exception_format +#----------------------------------------------------------------------- +def exception_format(): + """ + Convert exception info into a string suitable for display. + """ + return "".join(traceback.format_exception( + sys.exc_info()[0] + , sys.exc_info()[1] + , sys.exc_info()[2] + )) + +#----------------------------------------------------------------------- +# exceptionbox +#----------------------------------------------------------------------- +def exceptionbox(msg=None, title=None): + """ + Display a box that gives information about + an exception that has just been raised. + + The caller may optionally pass in a title for the window, or a + msg to accompany the error information. + + Note that you do not need to (and cannot) pass an exception object + as an argument. The latest exception will automatically be used. + """ + if title == None: title = "Error Report" + if msg == None: + msg = "An error (exception) has occurred in the program." + + codebox(msg, title, exception_format()) + +#------------------------------------------------------------------- +# codebox +#------------------------------------------------------------------- + +def codebox(msg="" + , title=" " + , text="" + ): + """ + Display some text in a monospaced font, with no line wrapping. + This function is suitable for displaying code and text that is + formatted using spaces. + + The text parameter should be a string, or a list or tuple of lines to be + displayed in the textbox. + """ + return textbox(msg, title, text, codebox=1 ) + +#------------------------------------------------------------------- +# textbox +#------------------------------------------------------------------- +def textbox(msg="" + , title=" " + , text="" + , codebox=0 + ): + """ + Display some text in a proportional font with line wrapping at word breaks. + This function is suitable for displaying general written text. + + The text parameter should be a string, or a list or tuple of lines to be + displayed in the textbox. + """ + + if msg == None: msg = "" + if title == None: title = "" + + global boxRoot, __replyButtonText, __widgetTexts, buttonsFrame + global rootWindowPosition + choices = ["OK"] + __replyButtonText = choices[0] + + + boxRoot = Tk() + + boxRoot.protocol('WM_DELETE_WINDOW', denyWindowManagerClose ) + + screen_width = boxRoot.winfo_screenwidth() + screen_height = boxRoot.winfo_screenheight() + root_width = int((screen_width * 0.8)) + root_height = int((screen_height * 0.5)) + root_xpos = int((screen_width * 0.1)) + root_ypos = int((screen_height * 0.05)) + + boxRoot.title(title) + boxRoot.iconname('Dialog') + rootWindowPosition = "+0+0" + boxRoot.geometry(rootWindowPosition) + boxRoot.expand=NO + boxRoot.minsize(root_width, root_height) + rootWindowPosition = "+" + str(root_xpos) + "+" + str(root_ypos) + boxRoot.geometry(rootWindowPosition) + + mainframe = Frame(master=boxRoot) + mainframe.pack(side=TOP, fill=BOTH, expand=YES) + + # ---- put frames in the window ----------------------------------- + # we pack the textboxFrame first, so it will expand first + textboxFrame = Frame(mainframe, borderwidth=3) + textboxFrame.pack(side=BOTTOM , fill=BOTH, expand=YES) + + message_and_buttonsFrame = Frame(mainframe) + message_and_buttonsFrame.pack(side=TOP, fill=X, expand=NO) + + messageFrame = Frame(message_and_buttonsFrame) + messageFrame.pack(side=LEFT, fill=X, expand=YES) + + buttonsFrame = Frame(message_and_buttonsFrame) + buttonsFrame.pack(side=RIGHT, expand=NO) + + # -------------------- put widgets in the frames -------------------- + + # put a textArea in the top frame + if codebox: + character_width = int((root_width * 0.6) / MONOSPACE_FONT_SIZE) + textArea = Text(textboxFrame,height=25,width=character_width, padx="2m", pady="1m") + textArea.configure(wrap=NONE) + textArea.configure(font=(MONOSPACE_FONT_FAMILY, MONOSPACE_FONT_SIZE)) + + else: + character_width = int((root_width * 0.6) / MONOSPACE_FONT_SIZE) + textArea = Text( + textboxFrame + , height=25 + , width=character_width + , padx="2m" + , pady="1m" + ) + textArea.configure(wrap=WORD) + textArea.configure(font=(PROPORTIONAL_FONT_FAMILY,PROPORTIONAL_FONT_SIZE)) + + + # some simple keybindings for scrolling + mainframe.bind("" , textArea.yview_scroll( 1,PAGES)) + mainframe.bind("", textArea.yview_scroll(-1,PAGES)) + + mainframe.bind("", textArea.xview_scroll( 1,PAGES)) + mainframe.bind("" , textArea.xview_scroll(-1,PAGES)) + + mainframe.bind("", textArea.yview_scroll( 1,UNITS)) + mainframe.bind("" , textArea.yview_scroll(-1,UNITS)) + + + # add a vertical scrollbar to the frame + rightScrollbar = Scrollbar(textboxFrame, orient=VERTICAL, command=textArea.yview) + textArea.configure(yscrollcommand = rightScrollbar.set) + + # add a horizontal scrollbar to the frame + bottomScrollbar = Scrollbar(textboxFrame, orient=HORIZONTAL, command=textArea.xview) + textArea.configure(xscrollcommand = bottomScrollbar.set) + + # pack the textArea and the scrollbars. Note that although we must define + # the textArea first, we must pack it last, so that the bottomScrollbar will + # be located properly. + + # Note that we need a bottom scrollbar only for code. + # Text will be displayed with wordwrap, so we don't need to have a horizontal + # scroll for it. + if codebox: + bottomScrollbar.pack(side=BOTTOM, fill=X) + rightScrollbar.pack(side=RIGHT, fill=Y) + + textArea.pack(side=LEFT, fill=BOTH, expand=YES) + + + # ---------- put a msg widget in the msg frame------------------- + messageWidget = Message(messageFrame, anchor=NW, text=msg, width=int(root_width * 0.9)) + messageWidget.configure(font=(PROPORTIONAL_FONT_FAMILY,PROPORTIONAL_FONT_SIZE)) + messageWidget.pack(side=LEFT, expand=YES, fill=BOTH, padx='1m', pady='1m') + + # put the buttons in the buttonsFrame + okButton = Button(buttonsFrame, takefocus=YES, text="OK", height=1, width=6) + okButton.pack(expand=NO, side=TOP, padx='2m', pady='1m', ipady="1m", ipadx="2m") + + # for the commandButton, bind activation events to the activation event handler + commandButton = okButton + handler = __textboxOK + for selectionEvent in ["Return","Button-1","Escape"]: + commandButton.bind("<%s>" % selectionEvent, handler) + + + # ----------------- the action begins ---------------------------------------- + try: + # load the text into the textArea + if type(text) == type("abc"): pass + else: + try: + text = "".join(text) # convert a list or a tuple to a string + except: + msgbox("Exception when trying to convert "+ str(type(text)) + " to text in textArea") + sys.exit(16) + textArea.insert(END,text, "normal") + + except: + msgbox("Exception when trying to load the textArea.") + sys.exit(16) + + try: + okButton.focus_force() + except: + msgbox("Exception when trying to put focus on okButton.") + sys.exit(16) + + boxRoot.mainloop() + + # this line MUST go before the line that destroys boxRoot + areaText = textArea.get(0.0,END) + boxRoot.destroy() + return areaText # return __replyButtonText + +#------------------------------------------------------------------- +# __textboxOK +#------------------------------------------------------------------- +def __textboxOK(event): + global boxRoot + boxRoot.quit() + + + +#------------------------------------------------------------------- +# diropenbox +#------------------------------------------------------------------- +def diropenbox(msg=None + , title=None + , default=None + ): + """ + A dialog to get a directory name. + Note that the msg argument, if specified, is ignored. + + Returns the name of a directory, or None if user chose to cancel. + + If the "default" argument specifies a directory name, and that + directory exists, then the dialog box will start with that directory. + """ + title=getFileDialogTitle(msg,title) + localRoot = Tk() + localRoot.withdraw() + if not default: default = None + f = tk_FileDialog.askdirectory( + parent=localRoot + , title=title + , initialdir=default + , initialfile=None + ) + localRoot.destroy() + if not f: return None + return os.path.normpath(f) + + + +#------------------------------------------------------------------- +# getFileDialogTitle +#------------------------------------------------------------------- +def getFileDialogTitle(msg + , title + ): + if msg and title: return "%s - %s" % (title,msg) + if msg and not title: return str(msg) + if title and not msg: return str(title) + return None # no message and no title + +#------------------------------------------------------------------- +# class FileTypeObject for use with fileopenbox +#------------------------------------------------------------------- +class FileTypeObject: + def __init__(self,filemask): + if len(filemask) == 0: + raise AssertionError('Filetype argument is empty.') + + self.masks = [] + + if type(filemask) == type("abc"): # a string + self.initializeFromString(filemask) + + elif type(filemask) == type([]): # a list + if len(filemask) < 2: + raise AssertionError('Invalid filemask.\n' + +'List contains less than 2 members: "%s"' % filemask) + else: + self.name = filemask[-1] + self.masks = list(filemask[:-1] ) + else: + raise AssertionError('Invalid filemask: "%s"' % filemask) + + def __eq__(self,other): + if self.name == other.name: return True + return False + + def add(self,other): + for mask in other.masks: + if mask in self.masks: pass + else: self.masks.append(mask) + + def toTuple(self): + return (self.name,tuple(self.masks)) + + def isAll(self): + if self.name == "All files": return True + return False + + def initializeFromString(self, filemask): + # remove everything except the extension from the filemask + self.ext = os.path.splitext(filemask)[1] + if self.ext == "" : self.ext = ".*" + if self.ext == ".": self.ext = ".*" + self.name = self.getName() + self.masks = ["*" + self.ext] + + def getName(self): + e = self.ext + if e == ".*" : return "All files" + if e == ".txt": return "Text files" + if e == ".py" : return "Python files" + if e == ".pyc" : return "Python files" + if e == ".xls": return "Excel files" + if e.startswith("."): + return e[1:].upper() + " files" + return e.upper() + " files" + + +#------------------------------------------------------------------- +# fileopenbox +#------------------------------------------------------------------- +def fileopenbox(msg=None + , title=None + , default="*" + , filetypes=None + ): + """ + A dialog to get a file name. + + About the "default" argument + ============================ + The "default" argument specifies a filepath that (normally) + contains one or more wildcards. + fileopenbox will display only files that match the default filepath. + If omitted, defaults to "*" (all files in the current directory). + + WINDOWS EXAMPLE:: + ...default="c:/myjunk/*.py" + will open in directory c:\myjunk\ and show all Python files. + + WINDOWS EXAMPLE:: + ...default="c:/myjunk/test*.py" + will open in directory c:\myjunk\ and show all Python files + whose names begin with "test". + + + Note that on Windows, fileopenbox automatically changes the path + separator to the Windows path separator (backslash). + + About the "filetypes" argument + ============================== + If specified, it should contain a list of items, + where each item is either:: + - a string containing a filemask # e.g. "*.txt" + - a list of strings, where all of the strings except the last one + are filemasks (each beginning with "*.", + such as "*.txt" for text files, "*.py" for Python files, etc.). + and the last string contains a filetype description + + EXAMPLE:: + filetypes = ["*.css", ["*.htm", "*.html", "HTML files"] ] + + NOTE THAT + ========= + + If the filetypes list does not contain ("All files","*"), + it will be added. + + If the filetypes list does not contain a filemask that includes + the extension of the "default" argument, it will be added. + For example, if default="*abc.py" + and no filetypes argument was specified, then + "*.py" will automatically be added to the filetypes argument. + + @rtype: string or None + @return: the name of a file, or None if user chose to cancel + + @arg msg: the msg to be displayed. + @arg title: the window title + @arg default: filepath with wildcards + @arg filetypes: filemasks that a user can choose, e.g. "*.txt" + """ + localRoot = Tk() + localRoot.withdraw() + + initialbase, initialfile, initialdir, filetypes = fileboxSetup(default,filetypes) + + #------------------------------------------------------------ + # if initialfile contains no wildcards; we don't want an + # initial file. It won't be used anyway. + # Also: if initialbase is simply "*", we don't want an + # initialfile; it is not doing any useful work. + #------------------------------------------------------------ + if (initialfile.find("*") < 0) and (initialfile.find("?") < 0): + initialfile = None + elif initialbase == "*": + initialfile = None + + f = tk_FileDialog.askopenfilename(parent=localRoot + , title=getFileDialogTitle(msg,title) + , initialdir=initialdir + , initialfile=initialfile + , filetypes=filetypes + ) + + localRoot.destroy() + + if not f: return None + return os.path.normpath(f) + + +#------------------------------------------------------------------- +# filesavebox +#------------------------------------------------------------------- +def filesavebox(msg=None + , title=None + , default="" + , filetypes=None + ): + """ + A file to get the name of a file to save. + Returns the name of a file, or None if user chose to cancel. + + The "default" argument should contain a filename (i.e. the + current name of the file to be saved). It may also be empty, + or contain a filemask that includes wildcards. + + The "filetypes" argument works like the "filetypes" argument to + fileopenbox. + """ + + localRoot = Tk() + localRoot.withdraw() + + initialbase, initialfile, initialdir, filetypes = fileboxSetup(default,filetypes) + + f = tk_FileDialog.asksaveasfilename(parent=localRoot + , title=getFileDialogTitle(msg,title) + , initialfile=initialfile + , initialdir=initialdir + , filetypes=filetypes + ) + localRoot.destroy() + if not f: return None + return os.path.normpath(f) + + +#------------------------------------------------------------------- +# +# fileboxSetup +# +#------------------------------------------------------------------- +def fileboxSetup(default,filetypes): + if not default: default = os.path.join(".","*") + initialdir, initialfile = os.path.split(default) + if not initialdir : initialdir = "." + if not initialfile: initialfile = "*" + initialbase, initialext = os.path.splitext(initialfile) + initialFileTypeObject = FileTypeObject(initialfile) + + allFileTypeObject = FileTypeObject("*") + ALL_filetypes_was_specified = False + + if not filetypes: filetypes= [] + filetypeObjects = [] + + for filemask in filetypes: + fto = FileTypeObject(filemask) + + if fto.isAll(): + ALL_filetypes_was_specified = True # remember this + + if fto == initialFileTypeObject: + initialFileTypeObject.add(fto) # add fto to initialFileTypeObject + else: + filetypeObjects.append(fto) + + #------------------------------------------------------------------ + # make sure that the list of filetypes includes the ALL FILES type. + #------------------------------------------------------------------ + if ALL_filetypes_was_specified: + pass + elif allFileTypeObject == initialFileTypeObject: + pass + else: + filetypeObjects.insert(0,allFileTypeObject) + #------------------------------------------------------------------ + # Make sure that the list includes the initialFileTypeObject + # in the position in the list that will make it the default. + # This changed between Python version 2.5 and 2.6 + #------------------------------------------------------------------ + if len(filetypeObjects) == 0: + filetypeObjects.append(initialFileTypeObject) + + if initialFileTypeObject in (filetypeObjects[0], filetypeObjects[-1]): + pass + else: + if runningPython26: + filetypeObjects.append(initialFileTypeObject) + else: + filetypeObjects.insert(0,initialFileTypeObject) + + filetypes = [fto.toTuple() for fto in filetypeObjects] + + return initialbase, initialfile, initialdir, filetypes + +#------------------------------------------------------------------- +# utility routines +#------------------------------------------------------------------- +# These routines are used by several other functions in the EasyGui module. + +def __buttonEvent(event): + """ + Handle an event that is generated by a person clicking a button. + """ + global boxRoot, __widgetTexts, __replyButtonText + __replyButtonText = __widgetTexts[event.widget] + boxRoot.quit() # quit the main loop + + +def __put_buttons_in_buttonframe(choices): + """Put the buttons in the buttons frame + """ + global __widgetTexts, __firstWidget, buttonsFrame + + __firstWidget = None + __widgetTexts = {} + + i = 0 + + for buttonText in choices: + tempButton = Button(buttonsFrame, takefocus=1, text=buttonText) + bindArrows(tempButton) + tempButton.pack(expand=YES, side=LEFT, padx='1m', pady='1m', ipadx='2m', ipady='1m') + + # remember the text associated with this widget + __widgetTexts[tempButton] = buttonText + + # remember the first widget, so we can put the focus there + if i == 0: + __firstWidget = tempButton + i = 1 + + # for the commandButton, bind activation events to the activation event handler + commandButton = tempButton + handler = __buttonEvent + for selectionEvent in STANDARD_SELECTION_EVENTS: + commandButton.bind("<%s>" % selectionEvent, handler) + +#----------------------------------------------------------------------- +# +# class EgStore +# +#----------------------------------------------------------------------- +class EgStore: + r""" +A class to support persistent storage. + +You can use EgStore to support the storage and retrieval +of user settings for an EasyGui application. + + +# Example A +#----------------------------------------------------------------------- +# define a class named Settings as a subclass of EgStore +#----------------------------------------------------------------------- +class Settings(EgStore): +:: + def __init__(self, filename): # filename is required + #------------------------------------------------- + # Specify default/initial values for variables that + # this particular application wants to remember. + #------------------------------------------------- + self.userId = "" + self.targetServer = "" + + #------------------------------------------------- + # For subclasses of EgStore, these must be + # the last two statements in __init__ + #------------------------------------------------- + self.filename = filename # this is required + self.restore() # restore values from the storage file if possible + + + +# Example B +#----------------------------------------------------------------------- +# create settings, a persistent Settings object +#----------------------------------------------------------------------- +settingsFile = "myApp_settings.txt" +settings = Settings(settingsFile) + +user = "obama_barak" +server = "whitehouse1" +settings.userId = user +settings.targetServer = server +settings.store() # persist the settings + +# run code that gets a new value for userId, and persist the settings +user = "biden_joe" +settings.userId = user +settings.store() + + +# Example C +#----------------------------------------------------------------------- +# recover the Settings instance, change an attribute, and store it again. +#----------------------------------------------------------------------- +settings = Settings(settingsFile) +settings.userId = "vanrossum_g" +settings.store() + +""" + def __init__(self, filename): # obtaining filename is required + self.filename = None + raise NotImplementedError() + + def restore(self): + """ + Set the values of whatever attributes are recoverable + from the pickle file. + + Populate the attributes (the __dict__) of the EgStore object + from the attributes (the __dict__) of the pickled object. + + If the pickled object has attributes that have been initialized + in the EgStore object, then those attributes of the EgStore object + will be replaced by the values of the corresponding attributes + in the pickled object. + + If the pickled object is missing some attributes that have + been initialized in the EgStore object, then those attributes + of the EgStore object will retain the values that they were + initialized with. + + If the pickled object has some attributes that were not + initialized in the EgStore object, then those attributes + will be ignored. + + IN SUMMARY: + + After the recover() operation, the EgStore object will have all, + and only, the attributes that it had when it was initialized. + + Where possible, those attributes will have values recovered + from the pickled object. + """ + if not os.path.exists(self.filename): return self + if not os.path.isfile(self.filename): return self + + try: + f = open(self.filename,"rb") + unpickledObject = pickle.load(f) + f.close() + + for key in list(self.__dict__.keys()): + default = self.__dict__[key] + self.__dict__[key] = unpickledObject.__dict__.get(key,default) + except: + pass + + return self + + def store(self): + """ + Save the attributes of the EgStore object to a pickle file. + Note that if the directory for the pickle file does not already exist, + the store operation will fail. + """ + f = open(self.filename, "wb") + pickle.dump(self, f) + f.close() + + + def kill(self): + """ + Delete my persistent file (i.e. pickle file), if it exists. + """ + if os.path.isfile(self.filename): + os.remove(self.filename) + return + + def __str__(self): + """ + return my contents as a string in an easy-to-read format. + """ + # find the length of the longest attribute name + longest_key_length = 0 + keys = [] + for key in self.__dict__.keys(): + keys.append(key) + longest_key_length = max(longest_key_length, len(key)) + + keys.sort() # sort the attribute names + lines = [] + for key in keys: + value = self.__dict__[key] + key = key.ljust(longest_key_length) + lines.append("%s : %s\n" % (key,repr(value)) ) + return "".join(lines) # return a string showing the attributes + + + + +#----------------------------------------------------------------------- +# +# test/demo easygui +# +#----------------------------------------------------------------------- +def egdemo(): + """ + Run the EasyGui demo. + """ + # clear the console + writeln("\n" * 100) + + intro_message = ("Pick the kind of box that you wish to demo.\n" + + "\n * Python version " + sys.version + + "\n * EasyGui version " + egversion + + "\n * Tk version " + str(TkVersion) + ) + + #========================================== END DEMONSTRATION DATA + + + while 1: # do forever + choices = [ + "msgbox", + "buttonbox", + "buttonbox(image) -- a buttonbox that displays an image", + "choicebox", + "multchoicebox", + "textbox", + "ynbox", + "ccbox", + "enterbox", + "enterbox(image) -- an enterbox that displays an image", + "exceptionbox", + "codebox", + "integerbox", + "boolbox", + "indexbox", + "filesavebox", + "fileopenbox", + "passwordbox", + "multenterbox", + "multpasswordbox", + "diropenbox", + "About EasyGui", + " Help" + ] + choice = choicebox(msg=intro_message + , title="EasyGui " + egversion + , choices=choices) + + if not choice: return + + reply = choice.split() + + if reply[0] == "msgbox": + reply = msgbox("short msg", "This is a long title") + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "About": + reply = abouteasygui() + + elif reply[0] == "Help": + _demo_help() + + elif reply[0] == "buttonbox": + reply = buttonbox() + writeln("Reply was: %s" % repr(reply)) + + title = "Demo of Buttonbox with many, many buttons!" + msg = "This buttonbox shows what happens when you specify too many buttons." + reply = buttonbox(msg=msg, title=title, choices=choices) + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "buttonbox(image)": + _demo_buttonbox_with_image() + + elif reply[0] == "boolbox": + reply = boolbox() + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "enterbox": + image = "python_and_check_logo.gif" + message = "Enter the name of your best friend."\ + "\n(Result will be stripped.)" + reply = enterbox(message, "Love!", " Suzy Smith ") + writeln("Reply was: %s" % repr(reply)) + + message = "Enter the name of your best friend."\ + "\n(Result will NOT be stripped.)" + reply = enterbox(message, "Love!", " Suzy Smith ",strip=False) + writeln("Reply was: %s" % repr(reply)) + + reply = enterbox("Enter the name of your worst enemy:", "Hate!") + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "enterbox(image)": + image = "python_and_check_logo.gif" + message = "What kind of snake is this?" + reply = enterbox(message, "Quiz",image=image) + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "exceptionbox": + try: + thisWillCauseADivideByZeroException = 1/0 + except: + exceptionbox() + + elif reply[0] == "integerbox": + reply = integerbox( + "Enter a number between 3 and 333", + "Demo: integerbox WITH a default value", + 222, 3, 333) + writeln("Reply was: %s" % repr(reply)) + + reply = integerbox( + "Enter a number between 0 and 99", + "Demo: integerbox WITHOUT a default value" + ) + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "diropenbox" : _demo_diropenbox() + elif reply[0] == "fileopenbox": _demo_fileopenbox() + elif reply[0] == "filesavebox": _demo_filesavebox() + + elif reply[0] == "indexbox": + title = reply[0] + msg = "Demo of " + reply[0] + choices = ["Choice1", "Choice2", "Choice3", "Choice4"] + reply = indexbox(msg, title, choices) + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "passwordbox": + reply = passwordbox("Demo of password box WITHOUT default" + + "\n\nEnter your secret password", "Member Logon") + writeln("Reply was: %s" % str(reply)) + + reply = passwordbox("Demo of password box WITH default" + + "\n\nEnter your secret password", "Member Logon", "alfie") + writeln("Reply was: %s" % str(reply)) + + elif reply[0] == "multenterbox": + msg = "Enter your personal information" + title = "Credit Card Application" + fieldNames = ["Name","Street Address","City","State","ZipCode"] + fieldValues = [] # we start with blanks for the values + fieldValues = multenterbox(msg,title, fieldNames) + + # make sure that none of the fields was left blank + while 1: + if fieldValues == None: break + errmsg = "" + for i in range(len(fieldNames)): + if fieldValues[i].strip() == "": + errmsg = errmsg + ('"%s" is a required field.\n\n' % fieldNames[i]) + if errmsg == "": break # no problems found + fieldValues = multenterbox(errmsg, title, fieldNames, fieldValues) + + writeln("Reply was: %s" % str(fieldValues)) + + elif reply[0] == "multpasswordbox": + msg = "Enter logon information" + title = "Demo of multpasswordbox" + fieldNames = ["Server ID", "User ID", "Password"] + fieldValues = [] # we start with blanks for the values + fieldValues = multpasswordbox(msg,title, fieldNames) + + # make sure that none of the fields was left blank + while 1: + if fieldValues == None: break + errmsg = "" + for i in range(len(fieldNames)): + if fieldValues[i].strip() == "": + errmsg = errmsg + ('"%s" is a required field.\n\n' % fieldNames[i]) + if errmsg == "": break # no problems found + fieldValues = multpasswordbox(errmsg, title, fieldNames, fieldValues) + + writeln("Reply was: %s" % str(fieldValues)) + + elif reply[0] == "ynbox": + title = "Demo of ynbox" + msg = "Were you expecting the Spanish Inquisition?" + reply = ynbox(msg, title) + writeln("Reply was: %s" % repr(reply)) + if reply: + msgbox("NOBODY expects the Spanish Inquisition!", "Wrong!") + + elif reply[0] == "ccbox": + title = "Demo of ccbox" + reply = ccbox(msg,title) + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "choicebox": + title = "Demo of choicebox" + longchoice = "This is an example of a very long option which you may or may not wish to choose."*2 + listChoices = ["nnn", "ddd", "eee", "fff", "aaa", longchoice + , "aaa", "bbb", "ccc", "ggg", "hhh", "iii", "jjj", "kkk", "LLL", "mmm" , "nnn", "ooo", "ppp", "qqq", "rrr", "sss", "ttt", "uuu", "vvv"] + + msg = "Pick something. " + ("A wrapable sentence of text ?! "*30) + "\nA separate line of text."*6 + reply = choicebox(msg=msg, choices=listChoices) + writeln("Reply was: %s" % repr(reply)) + + msg = "Pick something. " + reply = choicebox(msg=msg, title=title, choices=listChoices) + writeln("Reply was: %s" % repr(reply)) + + msg = "Pick something. " + reply = choicebox(msg="The list of choices is empty!", choices=[]) + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "multchoicebox": + listChoices = ["aaa", "bbb", "ccc", "ggg", "hhh", "iii", "jjj", "kkk" + , "LLL", "mmm" , "nnn", "ooo", "ppp", "qqq" + , "rrr", "sss", "ttt", "uuu", "vvv"] + + msg = "Pick as many choices as you wish." + reply = multchoicebox(msg,"Demo of multchoicebox", listChoices) + writeln("Reply was: %s" % repr(reply)) + + elif reply[0] == "textbox": _demo_textbox(reply[0]) + elif reply[0] == "codebox": _demo_codebox(reply[0]) + + else: + msgbox("Choice\n\n" + choice + "\n\nis not recognized", "Program Logic Error") + return + + +def _demo_textbox(reply): + text_snippet = ((\ +"""It was the best of times, and it was the worst of times. The rich ate cake, and the poor had cake recommended to them, but wished only for enough cash to buy bread. The time was ripe for revolution! """ \ +*5)+"\n\n")*10 + title = "Demo of textbox" + msg = "Here is some sample text. " * 16 + reply = textbox(msg, title, text_snippet) + writeln("Reply was: %s" % str(reply)) + +def _demo_codebox(reply): + code_snippet = ("dafsdfa dasflkj pp[oadsij asdfp;ij asdfpjkop asdfpok asdfpok asdfpok"*3) +"\n"+\ +"""# here is some dummy Python code +for someItem in myListOfStuff: + do something(someItem) + do something() + do something() + if somethingElse(someItem): + doSomethingEvenMoreInteresting() + +"""*16 + msg = "Here is some sample code. " * 16 + reply = codebox(msg, "Code Sample", code_snippet) + writeln("Reply was: %s" % repr(reply)) + + +def _demo_buttonbox_with_image(): + + msg = "Do you like this picture?\nIt is " + choices = ["Yes","No","No opinion"] + + for image in [ + "python_and_check_logo.gif" + ,"python_and_check_logo.jpg" + ,"python_and_check_logo.png" + ,"zzzzz.gif"]: + + reply=buttonbox(msg + image,image=image,choices=choices) + writeln("Reply was: %s" % repr(reply)) + + +def _demo_help(): + savedStdout = sys.stdout # save the sys.stdout file object + sys.stdout = capturedOutput = StringIO() + help("easygui") + sys.stdout = savedStdout # restore the sys.stdout file object + codebox("EasyGui Help",text=capturedOutput.getvalue()) + +def _demo_filesavebox(): + filename = "myNewFile.txt" + title = "File SaveAs" + msg ="Save file as:" + + f = filesavebox(msg,title,default=filename) + writeln("You chose to save file: %s" % f) + +def _demo_diropenbox(): + title = "Demo of diropenbox" + msg = "Pick the directory that you wish to open." + d = diropenbox(msg, title) + writeln("You chose directory...: %s" % d) + + d = diropenbox(msg, title,default="./") + writeln("You chose directory...: %s" % d) + + d = diropenbox(msg, title,default="c:/") + writeln("You chose directory...: %s" % d) + + +def _demo_fileopenbox(): + msg = "Python files" + title = "Open files" + default="*.py" + f = fileopenbox(msg,title,default=default) + writeln("You chose to open file: %s" % f) + + default="./*.gif" + filetypes = ["*.jpg",["*.zip","*.tgs","*.gz", "Archive files"],["*.htm", "*.html","HTML files"]] + f = fileopenbox(msg,title,default=default,filetypes=filetypes) + writeln("You chose to open file: %s" % f) + + """#deadcode -- testing ---------------------------------------- + f = fileopenbox(None,None,default=default) + writeln("You chose to open file: %s" % f) + + f = fileopenbox(None,title,default=default) + writeln("You chose to open file: %s" % f) + + f = fileopenbox(msg,None,default=default) + writeln("You chose to open file: %s" % f) + + f = fileopenbox(default=default) + writeln("You chose to open file: %s" % f) + + f = fileopenbox(default=None) + writeln("You chose to open file: %s" % f) + #----------------------------------------------------deadcode """ + + +def _dummy(): + pass + +EASYGUI_ABOUT_INFORMATION = ''' +======================================================================== +0.96(2010-08-29) +======================================================================== +This version fixes some problems with version independence. + +BUG FIXES +------------------------------------------------------ + * A statement with Python 2.x-style exception-handling syntax raised + a syntax error when running under Python 3.x. + Thanks to David Williams for reporting this problem. + + * Under some circumstances, PIL was unable to display non-gif images + that it should have been able to display. + The cause appears to be non-version-independent import syntax. + PIL modules are now imported with a version-independent syntax. + Thanks to Horst Jens for reporting this problem. + +LICENSE CHANGE +------------------------------------------------------ +Starting with this version, EasyGui is licensed under what is generally known as +the "modified BSD license" (aka "revised BSD", "new BSD", "3-clause BSD"). +This license is GPL-compatible but less restrictive than GPL. +Earlier versions were licensed under the Creative Commons Attribution License 2.0. + + +======================================================================== +0.95(2010-06-12) +======================================================================== + +ENHANCEMENTS +------------------------------------------------------ + * Previous versions of EasyGui could display only .gif image files using the + msgbox "image" argument. This version can now display all image-file formats + supported by PIL the Python Imaging Library) if PIL is installed. + If msgbox is asked to open a non-gif image file, it attempts to import + PIL and to use PIL to convert the image file to a displayable format. + If PIL cannot be imported (probably because PIL is not installed) + EasyGui displays an error message saying that PIL must be installed in order + to display the image file. + + Note that + http://www.pythonware.com/products/pil/ + says that PIL doesn't yet support Python 3.x. + + +======================================================================== +0.94(2010-06-06) +======================================================================== + +ENHANCEMENTS +------------------------------------------------------ + * The codebox and textbox functions now return the contents of the box, rather + than simply the name of the button ("Yes"). This makes it possible to use + codebox and textbox as data-entry widgets. A big "thank you!" to Dominic + Comtois for requesting this feature, patiently explaining his requirement, + and helping to discover the tkinter techniques to implement it. + + NOTE THAT in theory this change breaks backward compatibility. But because + (in previous versions of EasyGui) the value returned by codebox and textbox + was meaningless, no application should have been checking it. So in actual + practice, this change should not break backward compatibility. + + * Added support for SPACEBAR to command buttons. Now, when keyboard + focus is on a command button, a press of the SPACEBAR will act like + a press of the ENTER key; it will activate the command button. + + * Added support for keyboard navigation with the arrow keys (up,down,left,right) + to the fields and buttons in enterbox, multenterbox and multpasswordbox, + and to the buttons in choicebox and all buttonboxes. + + * added highlightthickness=2 to entry fields in multenterbox and + multpasswordbox. Now it is easier to tell which entry field has + keyboard focus. + + +BUG FIXES +------------------------------------------------------ + * In EgStore, the pickle file is now opened with "rb" and "wb" rather than + with "r" and "w". This change is necessary for compatibility with Python 3+. + Thanks to Marshall Mattingly for reporting this problem and providing the fix. + + * In integerbox, the actual argument names did not match the names described + in the docstring. Thanks to Daniel Zingaro of at University of Toronto for + reporting this problem. + + * In integerbox, the "argLowerBound" and "argUpperBound" arguments have been + renamed to "lowerbound" and "upperbound" and the docstring has been corrected. + + NOTE THAT THIS CHANGE TO THE ARGUMENT-NAMES BREAKS BACKWARD COMPATIBILITY. + If argLowerBound or argUpperBound are used, an AssertionError with an + explanatory error message is raised. + + * In choicebox, the signature to choicebox incorrectly showed choicebox as + accepting a "buttons" argument. The signature has been fixed. + + +======================================================================== +0.93(2009-07-07) +======================================================================== + +ENHANCEMENTS +------------------------------------------------------ + + * Added exceptionbox to display stack trace of exceptions + + * modified names of some font-related constants to make it + easier to customize them + + +======================================================================== +0.92(2009-06-22) +======================================================================== + +ENHANCEMENTS +------------------------------------------------------ + + * Added EgStore class to to provide basic easy-to-use persistence. + +BUG FIXES +------------------------------------------------------ + + * Fixed a bug that was preventing Linux users from copying text out of + a textbox and a codebox. This was not a problem for Windows users. + +''' + +def abouteasygui(): + """ + shows the easygui revision history + """ + codebox("About EasyGui\n"+egversion,"EasyGui",EASYGUI_ABOUT_INFORMATION) + return None + + + +if __name__ == '__main__': + if True: + egdemo() + else: + # test the new root feature + root = Tk() + msg = """This is a test of a main Tk() window in which we will place an easygui msgbox. + It will be an interesting experiment.\n\n""" + messageWidget = Message(root, text=msg, width=1000) + messageWidget.pack(side=TOP, expand=YES, fill=X, padx='3m', pady='3m') + messageWidget = Message(root, text=msg, width=1000) + messageWidget.pack(side=TOP, expand=YES, fill=X, padx='3m', pady='3m') + + + msgbox("this is a test of passing in boxRoot", root=root) + msgbox("this is a second test of passing in boxRoot", root=root) + + reply = enterbox("Enter something", root=root) + writeln("You wrote:", reply) + + reply = enterbox("Enter something else", root=root) + writeln("You wrote:", reply) + root.destroy() diff --git a/tools/midi2piano2016/midi/__init__.py b/tools/midi2piano2016/midi/__init__.py new file mode 100644 index 0000000000..f9dff3f378 --- /dev/null +++ b/tools/midi2piano2016/midi/__init__.py @@ -0,0 +1 @@ +from midi.midi import * diff --git a/tools/midi2piano2016/midi/midi.py b/tools/midi2piano2016/midi/midi.py new file mode 100644 index 0000000000..07e8151efc --- /dev/null +++ b/tools/midi2piano2016/midi/midi.py @@ -0,0 +1,1648 @@ +#! /usr/bin/python3 +# unsupported 20091104 ... +# ['set_sequence_number', dtime, sequence] +# ['raw_data', dtime, raw] +r''' +This module offers functions: concatenate_scores(), grep(), +merge_scores(), mix_scores(), midi2opus(), midi2score(), opus2midi(), +opus2score(), play_score(), score2midi(), score2opus(), score2stats(), +score_type(), segment(), timeshift() and to_millisecs(), +where "midi" means the MIDI-file bytes (as can be put in a .mid file, +or piped into aplaymidi), and "opus" and "score" are list-structures +as inspired by Sean Burke's MIDI-Perl CPAN module. + +Download MIDI.py from http://www.pjb.com.au/midi/free/MIDI.py +and put it in your PYTHONPATH. MIDI.py depends on Python3. + +There is also a call-compatible translation into Lua of this +module: see http://www.pjb.com.au/comp/lua/MIDI.html + +The "opus" is a direct translation of the midi-file-events, where +the times are delta-times, in ticks, since the previous event. + +The "score" is more human-centric; it uses absolute times, and +combines the separate note_on and note_off events into one "note" +event, with a duration: + ['note', start_time, duration, channel, note, velocity] # in a "score" + + EVENTS (in an "opus" structure) + ['note_off', dtime, channel, note, velocity] # in an "opus" + ['note_on', dtime, channel, note, velocity] # in an "opus" + ['key_after_touch', dtime, channel, note, velocity] + ['control_change', dtime, channel, controller(0-127), value(0-127)] + ['patch_change', dtime, channel, patch] + ['channel_after_touch', dtime, channel, velocity] + ['pitch_wheel_change', dtime, channel, pitch_wheel] + ['text_event', dtime, text] + ['copyright_text_event', dtime, text] + ['track_name', dtime, text] + ['instrument_name', dtime, text] + ['lyric', dtime, text] + ['marker', dtime, text] + ['cue_point', dtime, text] + ['text_event_08', dtime, text] + ['text_event_09', dtime, text] + ['text_event_0a', dtime, text] + ['text_event_0b', dtime, text] + ['text_event_0c', dtime, text] + ['text_event_0d', dtime, text] + ['text_event_0e', dtime, text] + ['text_event_0f', dtime, text] + ['end_track', dtime] + ['set_tempo', dtime, tempo] + ['smpte_offset', dtime, hr, mn, se, fr, ff] + ['time_signature', dtime, nn, dd, cc, bb] + ['key_signature', dtime, sf, mi] + ['sequencer_specific', dtime, raw] + ['raw_meta_event', dtime, command(0-255), raw] + ['sysex_f0', dtime, raw] + ['sysex_f7', dtime, raw] + ['song_position', dtime, song_pos] + ['song_select', dtime, song_number] + ['tune_request', dtime] + + DATA TYPES + channel = a value 0 to 15 + controller = 0 to 127 (see http://www.pjb.com.au/muscript/gm.html#cc ) + dtime = time measured in "ticks", 0 to 268435455 + velocity = a value 0 (soft) to 127 (loud) + note = a value 0 to 127 (middle-C is 60) + patch = 0 to 127 (see http://www.pjb.com.au/muscript/gm.html ) + pitch_wheel = a value -8192 to 8191 (0x1FFF) + raw = 0 or more bytes of binary data + sequence_number = a value 0 to 65,535 (0xFFFF) + song_pos = a value 0 to 16,383 (0x3FFF) + song_number = a value 0 to 127 + tempo = microseconds per crochet (quarter-note), 0 to 16777215 + text = a string of 0 or more bytes of of ASCII text + ticks = the number of ticks per crochet (quarter-note) + + GOING THROUGH A SCORE WITHIN A PYTHON PROGRAM + channels = {2,3,5,8,13} + itrack = 1 # skip 1st element which is ticks + while itrack < len(score): + for event in score[itrack]: + if event[0] == 'note': # for example, + pass # do something to all notes + # or, to work on events in only particular channels... + channel_index = MIDI.Event2channelindex.get(event[0], False) + if channel_index and (event[channel_index] in channels): + pass # do something to channels 2,3,5,8 and 13 + itrack += 1 + +''' + +import sys, struct, os, copy +# sys.stdout = os.fdopen(sys.stdout.fileno(), 'wb') +Version = '6.2' +VersionDate = '20150101' +# 20150101 6.2 all text events can be 8-bit; let user get the right encoding +# 20141231 6.1 fix _some_text_event; sequencer_specific data can be 8-bit +# 20141230 6.0 synth_specific data can be 8-bit +# 20120504 5.9 add the contents of mid_opus_tracks() +# 20120208 5.8 fix num_notes_by_channel() ; should be a dict +# 20120129 5.7 _encode handles empty tracks; score2stats num_notes_by_channel +# 20111111 5.6 fix patch 45 and 46 in Number2patch, should be Harp +# 20110129 5.5 add mix_opus_tracks() and event2alsaseq() +# 20110126 5.4 "previous message repeated N times" to save space on stderr +# 20110125 5.2 opus2score terminates unended notes at the end of the track +# 20110124 5.1 the warnings in midi2opus display track_num +# 21110122 5.0 if garbage, midi2opus returns the opus so far +# 21110119 4.9 non-ascii chars stripped out of the text_events +# 21110110 4.8 note_on with velocity=0 treated as a note-off +# 21110108 4.6 unknown F-series event correctly eats just one byte +# 21011010 4.2 segment() uses start_time, end_time named params +# 21011005 4.1 timeshift() must not pad the set_tempo command +# 21011003 4.0 pitch2note_event must be chapitch2note_event +# 21010918 3.9 set_sequence_number supported, FWIW +# 20100913 3.7 many small bugfixes; passes all tests +# 20100910 3.6 concatenate_scores enforce ticks=1000, just like merge_scores +# 20100908 3.5 minor bugs fixed in score2stats +# 20091104 3.4 tune_request now supported +# 20091104 3.3 fixed bug in decoding song_position and song_select +# 20091104 3.2 unsupported: set_sequence_number tune_request raw_data +# 20091101 3.1 document how to traverse a score within Python +# 20091021 3.0 fixed bug in score2stats detecting GM-mode = 0 +# 20091020 2.9 score2stats reports GM-mode and bank msb,lsb events +# 20091019 2.8 in merge_scores, channel 9 must remain channel 9 (in GM) +# 20091018 2.7 handles empty tracks gracefully +# 20091015 2.6 grep() selects channels +# 20091010 2.5 merge_scores reassigns channels to avoid conflicts +# 20091010 2.4 fixed bug in to_millisecs which now only does opusses +# 20091010 2.3 score2stats returns channels & patch_changes, by_track & total +# 20091010 2.2 score2stats() returns also pitches and percussion dicts +# 20091010 2.1 bugs: >= not > in segment, to notice patch_change at time 0 +# 20091010 2.0 bugs: spurious pop(0) ( in _decode sysex +# 20091008 1.9 bugs: ISO decoding in sysex; str( not int( in note-off warning +# 20091008 1.8 add concatenate_scores() +# 20091006 1.7 score2stats() measures nticks and ticks_per_quarter +# 20091004 1.6 first mix_scores() and merge_scores() +# 20090424 1.5 timeshift() bugfix: earliest only sees events after from_time +# 20090330 1.4 timeshift() has also a from_time argument +# 20090322 1.3 timeshift() has also a start_time argument +# 20090319 1.2 add segment() and timeshift() +# 20090301 1.1 add to_millisecs() + +_previous_warning = '' # 5.4 +_previous_times = 0 # 5.4 +#------------------------------- Encoding stuff -------------------------- + +def opus2midi(opus=[]): + r'''The argument is a list: the first item in the list is the "ticks" +parameter, the others are the tracks. Each track is a list +of midi-events, and each event is itself a list; see above. +opus2midi() returns a bytestring of the MIDI, which can then be +written either to a file opened in binary mode (mode='wb'), +or to stdout by means of: sys.stdout.buffer.write() + +my_opus = [ + 96, + [ # track 0: + ['patch_change', 0, 1, 8], # and these are the events... + ['note_on', 5, 1, 25, 96], + ['note_off', 96, 1, 25, 0], + ['note_on', 0, 1, 29, 96], + ['note_off', 96, 1, 29, 0], + ], # end of track 0 +] +my_midi = opus2midi(my_opus) +sys.stdout.buffer.write(my_midi) +''' + if len(opus) < 2: + opus=[1000, [],] + tracks = copy.deepcopy(opus) + ticks = int(tracks.pop(0)) + ntracks = len(tracks) + if ntracks == 1: + format = 0 + else: + format = 1 + + my_midi = b"MThd\x00\x00\x00\x06"+struct.pack('>HHH',format,ntracks,ticks) + for track in tracks: + events = _encode(track) + my_midi += b'MTrk' + struct.pack('>I',len(events)) + events + _clean_up_warnings() + return my_midi + + +def score2opus(score=None): + r''' +The argument is a list: the first item in the list is the "ticks" +parameter, the others are the tracks. Each track is a list +of score-events, and each event is itself a list. A score-event +is similar to an opus-event (see above), except that in a score: + 1) the times are expressed as an absolute number of ticks + from the track's start time + 2) the pairs of 'note_on' and 'note_off' events in an "opus" + are abstracted into a single 'note' event in a "score": + ['note', start_time, duration, channel, pitch, velocity] +score2opus() returns a list specifying the equivalent "opus". + +my_score = [ + 96, + [ # track 0: + ['patch_change', 0, 1, 8], + ['note', 5, 96, 1, 25, 96], + ['note', 101, 96, 1, 29, 96] + ], # end of track 0 +] +my_opus = score2opus(my_score) +''' + if len(score) < 2: + score=[1000, [],] + tracks = copy.deepcopy(score) + ticks = int(tracks.pop(0)) + opus_tracks = [] + for scoretrack in tracks: + time2events = dict([]) + for scoreevent in scoretrack: + if scoreevent[0] == 'note': + note_on_event = ['note_on',scoreevent[1], + scoreevent[3],scoreevent[4],scoreevent[5]] + note_off_event = ['note_off',scoreevent[1]+scoreevent[2], + scoreevent[3],scoreevent[4],scoreevent[5]] + if time2events.get(note_on_event[1]): + time2events[note_on_event[1]].append(note_on_event) + else: + time2events[note_on_event[1]] = [note_on_event,] + if time2events.get(note_off_event[1]): + time2events[note_off_event[1]].append(note_off_event) + else: + time2events[note_off_event[1]] = [note_off_event,] + continue + if time2events.get(scoreevent[1]): + time2events[scoreevent[1]].append(scoreevent) + else: + time2events[scoreevent[1]] = [scoreevent,] + + sorted_times = [] # list of keys + for k in time2events.keys(): + sorted_times.append(k) + sorted_times.sort() + + sorted_events = [] # once-flattened list of values sorted by key + for time in sorted_times: + sorted_events.extend(time2events[time]) + + abs_time = 0 + for event in sorted_events: # convert abs times => delta times + delta_time = event[1] - abs_time + abs_time = event[1] + event[1] = delta_time + opus_tracks.append(sorted_events) + opus_tracks.insert(0,ticks) + _clean_up_warnings() + return opus_tracks + +def score2midi(score=None): + r''' +Translates a "score" into MIDI, using score2opus() then opus2midi() +''' + return opus2midi(score2opus(score)) + +#--------------------------- Decoding stuff ------------------------ + +def midi2opus(midi=b''): + r'''Translates MIDI into a "opus". For a description of the +"opus" format, see opus2midi() +''' + my_midi=bytearray(midi) + if len(my_midi) < 4: + _clean_up_warnings() + return [1000,[],] + id = bytes(my_midi[0:4]) + if id != b'MThd': + _warn("midi2opus: midi starts with "+str(id)+" instead of 'MThd'") + _clean_up_warnings() + return [1000,[],] + [length, format, tracks_expected, ticks] = struct.unpack( + '>IHHH', bytes(my_midi[4:14])) + if length != 6: + _warn("midi2opus: midi header length was "+str(length)+" instead of 6") + _clean_up_warnings() + return [1000,[],] + my_opus = [ticks,] + my_midi = my_midi[14:] + track_num = 1 # 5.1 + while len(my_midi) >= 8: + track_type = bytes(my_midi[0:4]) + if track_type != b'MTrk': + _warn('midi2opus: Warning: track #'+str(track_num)+' type is '+str(track_type)+" instead of b'MTrk'") + [track_length] = struct.unpack('>I', my_midi[4:8]) + my_midi = my_midi[8:] + if track_length > len(my_midi): + _warn('midi2opus: track #'+str(track_num)+' length '+str(track_length)+' is too large') + _clean_up_warnings() + return my_opus # 5.0 + my_midi_track = my_midi[0:track_length] + my_track = _decode(my_midi_track) + my_opus.append(my_track) + my_midi = my_midi[track_length:] + track_num += 1 # 5.1 + _clean_up_warnings() + return my_opus + +def opus2score(opus=[]): + r'''For a description of the "opus" and "score" formats, +see opus2midi() and score2opus(). +''' + if len(opus) < 2: + _clean_up_warnings() + return [1000,[],] + tracks = copy.deepcopy(opus) # couple of slices probably quicker... + ticks = int(tracks.pop(0)) + score = [ticks,] + for opus_track in tracks: + ticks_so_far = 0 + score_track = [] + chapitch2note_on_events = dict([]) # 4.0 + for opus_event in opus_track: + ticks_so_far += opus_event[1] + if opus_event[0] == 'note_off' or (opus_event[0] == 'note_on' and opus_event[4] == 0): # 4.8 + cha = opus_event[2] + pitch = opus_event[3] + key = cha*128 + pitch + if chapitch2note_on_events.get(key): + new_event = chapitch2note_on_events[key].pop(0) + new_event[2] = ticks_so_far - new_event[1] + score_track.append(new_event) + elif pitch > 127: + _warn('opus2score: note_off with no note_on, bad pitch='+str(pitch)) + else: + _warn('opus2score: note_off with no note_on cha='+str(cha)+' pitch='+str(pitch)) + elif opus_event[0] == 'note_on': + cha = opus_event[2] + pitch = opus_event[3] + key = cha*128 + pitch + new_event = ['note',ticks_so_far,0,cha,pitch, opus_event[4]] + if chapitch2note_on_events.get(key): + chapitch2note_on_events[key].append(new_event) + else: + chapitch2note_on_events[key] = [new_event,] + else: + opus_event[1] = ticks_so_far + score_track.append(opus_event) + # check for unterminated notes (Oisín) -- 5.2 + for chapitch in chapitch2note_on_events: + note_on_events = chapitch2note_on_events[chapitch] + for new_e in note_on_events: + new_e[2] = ticks_so_far - new_e[1] + score_track.append(new_e) + _warn("opus2score: note_on with no note_off cha="+str(new_e[3])+' pitch='+str(new_e[4])+'; adding note_off at end') + score.append(score_track) + _clean_up_warnings() + return score + +def midi2score(midi=b''): + r''' +Translates MIDI into a "score", using midi2opus() then opus2score() +''' + return opus2score(midi2opus(midi)) + +def midi2ms_score(midi=b''): + r''' +Translates MIDI into a "score" with one beat per second and one +tick per millisecond, using midi2opus() then to_millisecs() +then opus2score() +''' + return opus2score(to_millisecs(midi2opus(midi))) + +#------------------------ Other Transformations --------------------- + +def to_millisecs(old_opus=None): + r'''Recallibrates all the times in an "opus" to use one beat +per second and one tick per millisecond. This makes it +hard to retrieve any information about beats or barlines, +but it does make it easy to mix different scores together. +''' + if old_opus == None: + return [1000,[],] + try: + old_tpq = int(old_opus[0]) + except IndexError: # 5.0 + _warn('to_millisecs: the opus '+str(type(old_opus))+' has no elements') + return [1000,[],] + new_opus = [1000,] + millisec_per_old_tick = 1000.0 / old_tpq # float: will be rounded later + itrack = 1 + while itrack < len(old_opus): + millisec_so_far = 0.0 + previous_millisec_so_far = 0.0 + new_track = [['set_tempo',0,1000000],] # new "crochet" is 1 sec + for old_event in old_opus[itrack]: + if old_event[0] == 'note': + raise TypeError('to_millisecs needs an opus, not a score') + new_event = copy.deepcopy(old_event) + millisec_so_far += (millisec_per_old_tick * old_event[1]) + new_event[1] = round(millisec_so_far - previous_millisec_so_far) + if old_event[0] == 'set_tempo': + millisec_per_old_tick = old_event[2] / (1000.0 * old_tpq) + else: + previous_millisec_so_far = millisec_so_far + new_track.append(new_event) + new_opus.append(new_track) + itrack += 1 + _clean_up_warnings() + return new_opus + +def event2alsaseq(event=None): # 5.5 + r'''Converts an event into the format needed by the alsaseq module, +http://pp.com.mx/python/alsaseq +The type of track (opus or score) is autodetected. +''' + pass + +def grep(score=None, channels=None): + r'''Returns a "score" containing only the channels specified +''' + if score == None: + return [1000,[],] + ticks = score[0] + new_score = [ticks,] + if channels == None: + return new_score + channels = set(channels) + global Event2channelindex + itrack = 1 + while itrack < len(score): + new_score.append([]) + for event in score[itrack]: + channel_index = Event2channelindex.get(event[0], False) + if channel_index: + if event[channel_index] in channels: + new_score[itrack].append(event) + else: + new_score[itrack].append(event) + itrack += 1 + return new_score + +def play_score(score=None): + r'''Converts the "score" to midi, and feeds it into 'aplaymidi -' +''' + if score == None: + return + import subprocess + pipe = subprocess.Popen(['aplaymidi','-'], stdin=subprocess.PIPE) + if score_type(score) == 'opus': + pipe.stdin.write(opus2midi(score)) + else: + pipe.stdin.write(score2midi(score)) + pipe.stdin.close() + +def timeshift(score=None, shift=None, start_time=None, from_time=0, tracks={0,1,2,3,4,5,6,7,8,10,12,13,14,15}): + r'''Returns a "score" shifted in time by "shift" ticks, or shifted +so that the first event starts at "start_time" ticks. + +If "from_time" is specified, only those events in the score +that begin after it are shifted. If "start_time" is less than +"from_time" (or "shift" is negative), then the intermediate +notes are deleted, though patch-change events are preserved. + +If "tracks" are specified, then only those tracks get shifted. +"tracks" can be a list, tuple or set; it gets converted to set +internally. + +It is deprecated to specify both "shift" and "start_time". +If this does happen, timeshift() will print a warning to +stderr and ignore the "shift" argument. + +If "shift" is negative and sufficiently large that it would +leave some event with a negative tick-value, then the score +is shifted so that the first event occurs at time 0. This +also occurs if "start_time" is negative, and is also the +default if neither "shift" nor "start_time" are specified. +''' + #_warn('tracks='+str(tracks)) + if score == None or len(score) < 2: + return [1000, [],] + new_score = [score[0],] + my_type = score_type(score) + if my_type == '': + return new_score + if my_type == 'opus': + _warn("timeshift: opus format is not supported\n") + # _clean_up_scores() 6.2; doesn't exist! what was it supposed to do? + return new_score + if not (shift == None) and not (start_time == None): + _warn("timeshift: shift and start_time specified: ignoring shift\n") + shift = None + if shift == None: + if (start_time == None) or (start_time < 0): + start_time = 0 + # shift = start_time - from_time + + i = 1 # ignore first element (ticks) + tracks = set(tracks) # defend against tuples and lists + earliest = 1000000000 + if not (start_time == None) or shift < 0: # first find the earliest event + while i < len(score): + if len(tracks) and not ((i-1) in tracks): + i += 1 + continue + for event in score[i]: + if event[1] < from_time: + continue # just inspect the to_be_shifted events + if event[1] < earliest: + earliest = event[1] + i += 1 + if earliest > 999999999: + earliest = 0 + if shift == None: + shift = start_time - earliest + elif (earliest + shift) < 0: + start_time = 0 + shift = 0 - earliest + + i = 1 # ignore first element (ticks) + while i < len(score): + if len(tracks) == 0 or not ((i-1) in tracks): # 3.8 + new_score.append(score[i]) + i += 1 + continue + new_track = [] + for event in score[i]: + new_event = list(event) + #if new_event[1] == 0 and shift > 0 and new_event[0] != 'note': + # pass + #elif new_event[1] >= from_time: + if new_event[1] >= from_time: + # 4.1 must not rightshift set_tempo + if new_event[0] != 'set_tempo' or shift<0: + new_event[1] += shift + elif (shift < 0) and (new_event[1] >= (from_time+shift)): + continue + new_track.append(new_event) + if len(new_track) > 0: + new_score.append(new_track) + i += 1 + _clean_up_warnings() + return new_score + +def segment(score=None, start_time=None, end_time=None, start=0, end=100000000, + tracks={0,1,2,3,4,5,6,7,8,10,11,12,13,14,15}): + r'''Returns a "score" which is a segment of the one supplied +as the argument, beginning at "start_time" ticks and ending +at "end_time" ticks (or at the end if "end_time" is not supplied). +If the set "tracks" is specified, only those tracks will +be returned. +''' + if score == None or len(score) < 2: + return [1000, [],] + if start_time == None: # as of 4.2 start_time is recommended + start_time = start # start is legacy usage + if end_time == None: # likewise + end_time = end + new_score = [score[0],] + my_type = score_type(score) + if my_type == '': + return new_score + if my_type == 'opus': + # more difficult (disconnecting note_on's from their note_off's)... + _warn("segment: opus format is not supported\n") + _clean_up_warnings() + return new_score + i = 1 # ignore first element (ticks); we count in ticks anyway + tracks = set(tracks) # defend against tuples and lists + while i < len(score): + if len(tracks) and not ((i-1) in tracks): + i += 1 + continue + new_track = [] + channel2patch_num = {} # keep most recent patch change before start + channel2patch_time = {} + set_tempo_num = 1000000 # keep most recent tempo change before start + set_tempo_time = 0 + earliest_note_time = end_time + for event in score[i]: + if event[0] == 'patch_change': + patch_time = channel2patch_time.get(event[2]) or 0 + if (event[1] < start_time) and (event[1] >= patch_time): # 2.0 + channel2patch_num[event[2]] = event[3] + channel2patch_time[event[2]] = event[1] + if event[0] == 'set_tempo': + if (event[1] < start_time) and (event[1] >= set_tempo_time): + set_tempo_num = event[2] + set_tempo_time = event[1] + if (event[1] >= start_time) and (event[1] <= end_time): + new_track.append(event) + if (event[0] == 'note') and (event[1] < earliest_note_time): + earliest_note_time = event[1] + if len(new_track) > 0: + for c in channel2patch_num: + new_track.append(['patch_change',start_time,c,channel2patch_num[c]]) + new_track.append(['set_tempo', start_time, set_tempo_num]) + new_score.append(new_track) + i += 1 + _clean_up_warnings() + return new_score + +def score_type(opus_or_score=None): + r'''Returns a string, either 'opus' or 'score' or '' +''' + if opus_or_score == None or str(type(opus_or_score)).find('list')<0 or len(opus_or_score) < 2: + return '' + i = 1 # ignore first element + while i < len(opus_or_score): + for event in opus_or_score[i]: + if event[0] == 'note': + return 'score' + elif event[0] == 'note_on': + return 'opus' + i += 1 + return '' + +def concatenate_scores(scores): + r'''Concatenates a list of scores into one score. +If the scores differ in their "ticks" parameter, +they will all get converted to millisecond-tick format. +''' + # the deepcopys are needed if the input_score's are refs to the same obj + # e.g. if invoked by midisox's repeat() + input_scores = _consistentise_ticks(scores) # 3.7 + output_score = copy.deepcopy(input_scores[0]) + for input_score in input_scores[1:]: + output_stats = score2stats(output_score) + delta_ticks = output_stats['nticks'] + itrack = 1 + while itrack < len(input_score): + if itrack >= len(output_score): # new output track if doesn't exist + output_score.append([]) + for event in input_score[itrack]: + output_score[itrack].append(copy.deepcopy(event)) + output_score[itrack][-1][1] += delta_ticks + itrack += 1 + return output_score + +def merge_scores(scores): + r'''Merges a list of scores into one score. A merged score comprises +all of the tracks from all of the input scores; un-merging is possible +by selecting just some of the tracks. If the scores differ in their +"ticks" parameter, they will all get converted to millisecond-tick +format. merge_scores attempts to resolve channel-conflicts, +but there are of course only 15 available channels... +''' + input_scores = _consistentise_ticks(scores) # 3.6 + output_score = [1000] + channels_so_far = set() + all_channels = {0,1,2,3,4,5,6,7,8,10,11,12,13,14,15} + global Event2channelindex + for input_score in input_scores: + new_channels = set(score2stats(input_score).get('channels_total', [])) + new_channels.discard(9) # 2.8 cha9 must remain cha9 (in GM) + for channel in channels_so_far & new_channels: + # consistently choose lowest avaiable, to ease testing + free_channels = list(all_channels - (channels_so_far|new_channels)) + if len(free_channels) > 0: + free_channels.sort() + free_channel = free_channels[0] + else: + free_channel = None + break + itrack = 1 + while itrack < len(input_score): + for input_event in input_score[itrack]: + channel_index=Event2channelindex.get(input_event[0],False) + if channel_index and input_event[channel_index]==channel: + input_event[channel_index] = free_channel + itrack += 1 + channels_so_far.add(free_channel) + + channels_so_far |= new_channels + output_score.extend(input_score[1:]) + return output_score + +def _ticks(event): + return event[1] +def mix_opus_tracks(input_tracks): # 5.5 + r'''Mixes an array of tracks into one track. A mixed track +cannot be un-mixed. It is assumed that the tracks share the same +ticks parameter and the same tempo. +Mixing score-tracks is trivial (just insert all events into one array). +Mixing opus-tracks is only slightly harder, but it's common enough +that a dedicated function is useful. +''' + output_score = [1000, []] + for input_track in input_tracks: # 5.8 + input_score = opus2score([1000, input_track]) + for event in input_score[1]: + output_score[1].append(event) + output_score[1].sort(key=_ticks) + output_opus = score2opus(output_score) + return output_opus[1] + +def mix_scores(scores): + r'''Mixes a list of scores into one one-track score. +A mixed score cannot be un-mixed. Hopefully the scores +have no undesirable channel-conflicts between them. +If the scores differ in their "ticks" parameter, +they will all get converted to millisecond-tick format. +''' + input_scores = _consistentise_ticks(scores) # 3.6 + output_score = [1000, []] + for input_score in input_scores: + for input_track in input_score[1:]: + output_score[1].extend(input_track) + return output_score + +def score2stats(opus_or_score=None): + r'''Returns a dict of some basic stats about the score, like +bank_select (list of tuples (msb,lsb)), +channels_by_track (list of lists), channels_total (set), +general_midi_mode (list), +ntracks, nticks, patch_changes_by_track (list of dicts), +num_notes_by_channel (list of numbers), +patch_changes_total (set), +percussion (dict histogram of channel 9 events), +pitches (dict histogram of pitches on channels other than 9), +pitch_range_by_track (list, by track, of two-member-tuples), +pitch_range_sum (sum over tracks of the pitch_ranges), +''' + bank_select_msb = -1 + bank_select_lsb = -1 + bank_select = [] + channels_by_track = [] + channels_total = set([]) + general_midi_mode = [] + num_notes_by_channel = dict([]) + patches_used_by_track = [] + patches_used_total = set([]) + patch_changes_by_track = [] + patch_changes_total = set([]) + percussion = dict([]) # histogram of channel 9 "pitches" + pitches = dict([]) # histogram of pitch-occurrences channels 0-8,10-15 + pitch_range_sum = 0 # u pitch-ranges of each track + pitch_range_by_track = [] + is_a_score = True + if opus_or_score == None: + return {'bank_select':[], 'channels_by_track':[], 'channels_total':[], + 'general_midi_mode':[], 'ntracks':0, 'nticks':0, + 'num_notes_by_channel':dict([]), + 'patch_changes_by_track':[], 'patch_changes_total':[], + 'percussion':{}, 'pitches':{}, 'pitch_range_by_track':[], + 'ticks_per_quarter':0, 'pitch_range_sum':0} + ticks_per_quarter = opus_or_score[0] + i = 1 # ignore first element, which is ticks + nticks = 0 + while i < len(opus_or_score): + highest_pitch = 0 + lowest_pitch = 128 + channels_this_track = set([]) + patch_changes_this_track = dict({}) + for event in opus_or_score[i]: + if event[0] == 'note': + num_notes_by_channel[event[3]] = num_notes_by_channel.get(event[3],0) + 1 + if event[3] == 9: + percussion[event[4]] = percussion.get(event[4],0) + 1 + else: + pitches[event[4]] = pitches.get(event[4],0) + 1 + if event[4] > highest_pitch: + highest_pitch = event[4] + if event[4] < lowest_pitch: + lowest_pitch = event[4] + channels_this_track.add(event[3]) + channels_total.add(event[3]) + finish_time = event[1] + event[2] + if finish_time > nticks: + nticks = finish_time + elif event[0] == 'note_off' or (event[0] == 'note_on' and event[4] == 0): # 4.8 + finish_time = event[1] + if finish_time > nticks: + nticks = finish_time + elif event[0] == 'note_on': + is_a_score = False + num_notes_by_channel[event[2]] = num_notes_by_channel.get(event[2],0) + 1 + if event[2] == 9: + percussion[event[3]] = percussion.get(event[3],0) + 1 + else: + pitches[event[3]] = pitches.get(event[3],0) + 1 + if event[3] > highest_pitch: + highest_pitch = event[3] + if event[3] < lowest_pitch: + lowest_pitch = event[3] + channels_this_track.add(event[2]) + channels_total.add(event[2]) + elif event[0] == 'patch_change': + patch_changes_this_track[event[2]] = event[3] + patch_changes_total.add(event[3]) + elif event[0] == 'control_change': + if event[3] == 0: # bank select MSB + bank_select_msb = event[4] + elif event[3] == 32: # bank select LSB + bank_select_lsb = event[4] + if bank_select_msb >= 0 and bank_select_lsb >= 0: + bank_select.append((bank_select_msb,bank_select_lsb)) + bank_select_msb = -1 + bank_select_lsb = -1 + elif event[0] == 'sysex_f0': + if _sysex2midimode.get(event[2], -1) >= 0: + general_midi_mode.append(_sysex2midimode.get(event[2])) + if is_a_score: + if event[1] > nticks: + nticks = event[1] + else: + nticks += event[1] + if lowest_pitch == 128: + lowest_pitch = 0 + channels_by_track.append(channels_this_track) + patch_changes_by_track.append(patch_changes_this_track) + pitch_range_by_track.append((lowest_pitch,highest_pitch)) + pitch_range_sum += (highest_pitch-lowest_pitch) + i += 1 + + return {'bank_select':bank_select, + 'channels_by_track':channels_by_track, + 'channels_total':channels_total, + 'general_midi_mode':general_midi_mode, + 'ntracks':len(opus_or_score)-1, + 'nticks':nticks, + 'num_notes_by_channel':num_notes_by_channel, + 'patch_changes_by_track':patch_changes_by_track, + 'patch_changes_total':patch_changes_total, + 'percussion':percussion, + 'pitches':pitches, + 'pitch_range_by_track':pitch_range_by_track, + 'pitch_range_sum':pitch_range_sum, + 'ticks_per_quarter':ticks_per_quarter} + +#----------------------------- Event stuff -------------------------- + +_sysex2midimode = { + "\x7E\x7F\x09\x01\xF7": 1, + "\x7E\x7F\x09\x02\xF7": 0, + "\x7E\x7F\x09\x03\xF7": 2, +} + +# Some public-access tuples: +MIDI_events = tuple('''note_off note_on key_after_touch +control_change patch_change channel_after_touch +pitch_wheel_change'''.split()) + +Text_events = tuple('''text_event copyright_text_event +track_name instrument_name lyric marker cue_point text_event_08 +text_event_09 text_event_0a text_event_0b text_event_0c +text_event_0d text_event_0e text_event_0f'''.split()) + +Nontext_meta_events = tuple('''end_track set_tempo +smpte_offset time_signature key_signature sequencer_specific +raw_meta_event sysex_f0 sysex_f7 song_position song_select +tune_request'''.split()) +# unsupported: raw_data + +# Actually, 'tune_request' is is F-series event, not strictly a meta-event... +Meta_events = Text_events + Nontext_meta_events +All_events = MIDI_events + Meta_events + +# And three dictionaries: +Number2patch = { # General MIDI patch numbers: +0:'Acoustic Grand', +1:'Bright Acoustic', +2:'Electric Grand', +3:'Honky-Tonk', +4:'Electric Piano 1', +5:'Electric Piano 2', +6:'Harpsichord', +7:'Clav', +8:'Celesta', +9:'Glockenspiel', +10:'Music Box', +11:'Vibraphone', +12:'Marimba', +13:'Xylophone', +14:'Tubular Bells', +15:'Dulcimer', +16:'Drawbar Organ', +17:'Percussive Organ', +18:'Rock Organ', +19:'Church Organ', +20:'Reed Organ', +21:'Accordion', +22:'Harmonica', +23:'Tango Accordion', +24:'Acoustic Guitar(nylon)', +25:'Acoustic Guitar(steel)', +26:'Electric Guitar(jazz)', +27:'Electric Guitar(clean)', +28:'Electric Guitar(muted)', +29:'Overdriven Guitar', +30:'Distortion Guitar', +31:'Guitar Harmonics', +32:'Acoustic Bass', +33:'Electric Bass(finger)', +34:'Electric Bass(pick)', +35:'Fretless Bass', +36:'Slap Bass 1', +37:'Slap Bass 2', +38:'Synth Bass 1', +39:'Synth Bass 2', +40:'Violin', +41:'Viola', +42:'Cello', +43:'Contrabass', +44:'Tremolo Strings', +45:'Pizzicato Strings', +46:'Orchestral Harp', +47:'Timpani', +48:'String Ensemble 1', +49:'String Ensemble 2', +50:'SynthStrings 1', +51:'SynthStrings 2', +52:'Choir Aahs', +53:'Voice Oohs', +54:'Synth Voice', +55:'Orchestra Hit', +56:'Trumpet', +57:'Trombone', +58:'Tuba', +59:'Muted Trumpet', +60:'French Horn', +61:'Brass Section', +62:'SynthBrass 1', +63:'SynthBrass 2', +64:'Soprano Sax', +65:'Alto Sax', +66:'Tenor Sax', +67:'Baritone Sax', +68:'Oboe', +69:'English Horn', +70:'Bassoon', +71:'Clarinet', +72:'Piccolo', +73:'Flute', +74:'Recorder', +75:'Pan Flute', +76:'Blown Bottle', +77:'Skakuhachi', +78:'Whistle', +79:'Ocarina', +80:'Lead 1 (square)', +81:'Lead 2 (sawtooth)', +82:'Lead 3 (calliope)', +83:'Lead 4 (chiff)', +84:'Lead 5 (charang)', +85:'Lead 6 (voice)', +86:'Lead 7 (fifths)', +87:'Lead 8 (bass+lead)', +88:'Pad 1 (new age)', +89:'Pad 2 (warm)', +90:'Pad 3 (polysynth)', +91:'Pad 4 (choir)', +92:'Pad 5 (bowed)', +93:'Pad 6 (metallic)', +94:'Pad 7 (halo)', +95:'Pad 8 (sweep)', +96:'FX 1 (rain)', +97:'FX 2 (soundtrack)', +98:'FX 3 (crystal)', +99:'FX 4 (atmosphere)', +100:'FX 5 (brightness)', +101:'FX 6 (goblins)', +102:'FX 7 (echoes)', +103:'FX 8 (sci-fi)', +104:'Sitar', +105:'Banjo', +106:'Shamisen', +107:'Koto', +108:'Kalimba', +109:'Bagpipe', +110:'Fiddle', +111:'Shanai', +112:'Tinkle Bell', +113:'Agogo', +114:'Steel Drums', +115:'Woodblock', +116:'Taiko Drum', +117:'Melodic Tom', +118:'Synth Drum', +119:'Reverse Cymbal', +120:'Guitar Fret Noise', +121:'Breath Noise', +122:'Seashore', +123:'Bird Tweet', +124:'Telephone Ring', +125:'Helicopter', +126:'Applause', +127:'Gunshot', +} +Notenum2percussion = { # General MIDI Percussion (on Channel 9): +35:'Acoustic Bass Drum', +36:'Bass Drum 1', +37:'Side Stick', +38:'Acoustic Snare', +39:'Hand Clap', +40:'Electric Snare', +41:'Low Floor Tom', +42:'Closed Hi-Hat', +43:'High Floor Tom', +44:'Pedal Hi-Hat', +45:'Low Tom', +46:'Open Hi-Hat', +47:'Low-Mid Tom', +48:'Hi-Mid Tom', +49:'Crash Cymbal 1', +50:'High Tom', +51:'Ride Cymbal 1', +52:'Chinese Cymbal', +53:'Ride Bell', +54:'Tambourine', +55:'Splash Cymbal', +56:'Cowbell', +57:'Crash Cymbal 2', +58:'Vibraslap', +59:'Ride Cymbal 2', +60:'Hi Bongo', +61:'Low Bongo', +62:'Mute Hi Conga', +63:'Open Hi Conga', +64:'Low Conga', +65:'High Timbale', +66:'Low Timbale', +67:'High Agogo', +68:'Low Agogo', +69:'Cabasa', +70:'Maracas', +71:'Short Whistle', +72:'Long Whistle', +73:'Short Guiro', +74:'Long Guiro', +75:'Claves', +76:'Hi Wood Block', +77:'Low Wood Block', +78:'Mute Cuica', +79:'Open Cuica', +80:'Mute Triangle', +81:'Open Triangle', +} + +Event2channelindex = { 'note':3, 'note_off':2, 'note_on':2, + 'key_after_touch':2, 'control_change':2, 'patch_change':2, + 'channel_after_touch':2, 'pitch_wheel_change':2 +} + +################################################################ +# The code below this line is full of frightening things, all to +# do with the actual encoding and decoding of binary MIDI data. + +def _twobytes2int(byte_a): + r'''decode a 16 bit quantity from two bytes,''' + return (byte_a[1] | (byte_a[0] << 8)) + +def _int2twobytes(int_16bit): + r'''encode a 16 bit quantity into two bytes,''' + return bytes([(int_16bit>>8) & 0xFF, int_16bit & 0xFF]) + +def _read_14_bit(byte_a): + r'''decode a 14 bit quantity from two bytes,''' + return (byte_a[0] | (byte_a[1] << 7)) + +def _write_14_bit(int_14bit): + r'''encode a 14 bit quantity into two bytes,''' + return bytes([int_14bit & 0x7F, (int_14bit>>7) & 0x7F]) + +def _ber_compressed_int(integer): + r'''BER compressed integer (not an ASN.1 BER, see perlpacktut for +details). Its bytes represent an unsigned integer in base 128, +most significant digit first, with as few digits as possible. +Bit eight (the high bit) is set on each byte except the last. +''' + ber = bytearray(b'') + seven_bits = 0x7F & integer + ber.insert(0, seven_bits) # XXX surely should convert to a char ? + integer >>= 7 + while integer > 0: + seven_bits = 0x7F & integer + ber.insert(0, 0x80|seven_bits) # XXX surely should convert to a char ? + integer >>= 7 + return ber + +def _unshift_ber_int(ba): + r'''Given a bytearray, returns a tuple of (the ber-integer at the +start, and the remainder of the bytearray). +''' + byte = ba.pop(0) + integer = 0 + while True: + integer += (byte & 0x7F) + if not (byte & 0x80): + return ((integer, ba)) + if not len(ba): + _warn('_unshift_ber_int: no end-of-integer found') + return ((0, ba)) + byte = ba.pop(0) + integer <<= 7 + +def _clean_up_warnings(): # 5.4 + # Call this before returning from any publicly callable function + # whenever there's a possibility that a warning might have been printed + # by the function, or by any private functions it might have called. + global _previous_times + global _previous_warning + if _previous_times > 1: + print(' previous message repeated '+str(_previous_times)+' times', file=sys.stderr) + elif _previous_times > 0: + print(' previous message repeated', file=sys.stderr) + _previous_times = 0 + _previous_warning = '' + +def _warn(s=''): + global _previous_times + global _previous_warning + if s == _previous_warning: # 5.4 + _previous_times = _previous_times + 1 + else: + _clean_up_warnings() + print(str(s), file=sys.stderr) + _previous_warning = s + +def _some_text_event(which_kind=0x01, text='some_text'): + # if which_kind == 0x7F: # 6.1 sequencer_specific data can be 8-bit + data = bytes(text, encoding='ISO-8859-1') # 6.2 and also text data! + # else: data = bytes(text, encoding='ascii') + return b'\xFF'+bytes((which_kind,))+_ber_compressed_int(len(data))+data + +def _consistentise_ticks(scores): # 3.6 + # used by mix_scores, merge_scores, concatenate_scores + if len(scores) == 1: + return copy.deepcopy(scores) + are_consistent = True + ticks = scores[0][0] + iscore = 1 + while iscore < len(scores): + if scores[iscore][0] != ticks: + are_consistent = False + break + iscore += 1 + if are_consistent: + return copy.deepcopy(scores) + new_scores = [] + iscore = 0 + while iscore < len(scores): + score = scores[iscore] + new_scores.append(opus2score(to_millisecs(score2opus(score)))) + iscore += 1 + return new_scores + + +########################################################################### + +def _decode(trackdata=b'', exclude=None, include=None, + event_callback=None, exclusive_event_callback=None, no_eot_magic=False): + r'''Decodes MIDI track data into an opus-style list of events. +The options: + 'exclude' is a list of event types which will be ignored SHOULD BE A SET + 'include' (and no exclude), makes exclude a list + of all possible events, /minus/ what include specifies + 'event_callback' is a coderef + 'exclusive_event_callback' is a coderef +''' + trackdata = bytearray(trackdata) + if exclude == None: + exclude = [] + if include == None: + include = [] + if include and not exclude: + exclude = All_events + include = set(include) + exclude = set(exclude) + + # Pointer = 0; not used here; we eat through the bytearray instead. + event_code = -1; # used for running status + event_count = 0; + events = [] + + while(len(trackdata)): + # loop while there's anything to analyze ... + eot = False # When True, the event registrar aborts this loop + event_count += 1 + + E = [] + # E for events - we'll feed it to the event registrar at the end. + + # Slice off the delta time code, and analyze it + [time, remainder] = _unshift_ber_int(trackdata) + + # Now let's see what we can make of the command + first_byte = trackdata.pop(0) & 0xFF + + if (first_byte < 0xF0): # It's a MIDI event + if (first_byte & 0x80): + event_code = first_byte + else: + # It wants running status; use last event_code value + trackdata.insert(0, first_byte) + if (event_code == -1): + _warn("Running status not set; Aborting track.") + return [] + + command = event_code & 0xF0 + channel = event_code & 0x0F + + if (command == 0xF6): # 0-byte argument + pass + elif (command == 0xC0 or command == 0xD0): # 1-byte argument + parameter = trackdata.pop(0) # could be B + else: # 2-byte argument could be BB or 14-bit + parameter = (trackdata.pop(0), trackdata.pop(0)) + + ################################################################# + # MIDI events + + if (command == 0x80): + if 'note_off' in exclude: + continue + E = ['note_off', time, channel, parameter[0], parameter[1]] + elif (command == 0x90): + if 'note_on' in exclude: + continue + E = ['note_on', time, channel, parameter[0], parameter[1]] + elif (command == 0xA0): + if 'key_after_touch' in exclude: + continue + E = ['key_after_touch',time,channel,parameter[0],parameter[1]] + elif (command == 0xB0): + if 'control_change' in exclude: + continue + E = ['control_change',time,channel,parameter[0],parameter[1]] + elif (command == 0xC0): + if 'patch_change' in exclude: + continue + E = ['patch_change', time, channel, parameter] + elif (command == 0xD0): + if 'channel_after_touch' in exclude: + continue + E = ['channel_after_touch', time, channel, parameter] + elif (command == 0xE0): + if 'pitch_wheel_change' in exclude: + continue + E = ['pitch_wheel_change', time, channel, + _read_14_bit(parameter)-0x2000] + else: + _warn("Shouldn't get here; command="+hex(command)) + + elif (first_byte == 0xFF): # It's a Meta-Event! ################## + #[command, length, remainder] = + # unpack("xCwa*", substr(trackdata, $Pointer, 6)); + #Pointer += 6 - len(remainder); + # # Move past JUST the length-encoded. + command = trackdata.pop(0) & 0xFF + [length, trackdata] = _unshift_ber_int(trackdata) + if (command == 0x00): + if (length == 2): + E = ['set_sequence_number',time,_twobytes2int(trackdata)] + else: + _warn('set_sequence_number: length must be 2, not '+str(length)) + E = ['set_sequence_number', time, 0] + + elif command >= 0x01 and command <= 0x0f: # Text events + # 6.2 take it in bytes; let the user get the right encoding. + # text_str = trackdata[0:length].decode('ascii','ignore') + text_str = trackdata[0:length].decode('ISO-8859-1') + # Defined text events + if (command == 0x01): + E = ['text_event', time, text_str] + elif (command == 0x02): + E = ['copyright_text_event', time, text_str] + elif (command == 0x03): + E = ['track_name', time, text_str] + elif (command == 0x04): + E = ['instrument_name', time, text_str] + elif (command == 0x05): + E = ['lyric', time, text_str] + elif (command == 0x06): + E = ['marker', time, text_str] + elif (command == 0x07): + E = ['cue_point', time, text_str] + # Reserved but apparently unassigned text events + elif (command == 0x08): + E = ['text_event_08', time, text_str] + elif (command == 0x09): + E = ['text_event_09', time, text_str] + elif (command == 0x0a): + E = ['text_event_0a', time, text_str] + elif (command == 0x0b): + E = ['text_event_0b', time, text_str] + elif (command == 0x0c): + E = ['text_event_0c', time, text_str] + elif (command == 0x0d): + E = ['text_event_0d', time, text_str] + elif (command == 0x0e): + E = ['text_event_0e', time, text_str] + elif (command == 0x0f): + E = ['text_event_0f', time, text_str] + + # Now the sticky events ------------------------------------- + elif (command == 0x2F): + E = ['end_track', time] + # The code for handling this, oddly, comes LATER, + # in the event registrar. + elif (command == 0x51): # DTime, Microseconds/Crochet + if length != 3: + _warn('set_tempo event, but length='+str(length)) + E = ['set_tempo', time, + struct.unpack(">I", b'\x00'+trackdata[0:3])[0]] + elif (command == 0x54): + if length != 5: # DTime, HR, MN, SE, FR, FF + _warn('smpte_offset event, but length='+str(length)) + E = ['smpte_offset',time] + list(struct.unpack(">BBBBB",trackdata[0:5])) + elif (command == 0x58): + if length != 4: # DTime, NN, DD, CC, BB + _warn('time_signature event, but length='+str(length)) + E = ['time_signature', time]+list(trackdata[0:4]) + elif (command == 0x59): + if length != 2: # DTime, SF(signed), MI + _warn('key_signature event, but length='+str(length)) + E = ['key_signature',time] + list(struct.unpack(">bB",trackdata[0:2])) + elif (command == 0x7F): + E = ['sequencer_specific',time, + trackdata[0:length].decode('ISO-8859-1')] # 6.0 + else: + E = ['raw_meta_event', time, command, + trackdata[0:length].decode('ISO-8859-1')] # 6.0 + #"[uninterpretable meta-event command of length length]" + # DTime, Command, Binary Data + # It's uninterpretable; record it as raw_data. + + # Pointer += length; # Now move Pointer + trackdata = trackdata[length:] + + ###################################################################### + elif (first_byte == 0xF0 or first_byte == 0xF7): + # Note that sysexes in MIDI /files/ are different than sysexes + # in MIDI transmissions!! The vast majority of system exclusive + # messages will just use the F0 format. For instance, the + # transmitted message F0 43 12 00 07 F7 would be stored in a + # MIDI file as F0 05 43 12 00 07 F7. As mentioned above, it is + # required to include the F7 at the end so that the reader of the + # MIDI file knows that it has read the entire message. (But the F7 + # is omitted if this is a non-final block in a multiblock sysex; + # but the F7 (if there) is counted in the message's declared + # length, so we don't have to think about it anyway.) + #command = trackdata.pop(0) + [length, trackdata] = _unshift_ber_int(trackdata) + if first_byte == 0xF0: + # 20091008 added ISO-8859-1 to get an 8-bit str + E = ['sysex_f0', time, trackdata[0:length].decode('ISO-8859-1')] + else: + E = ['sysex_f7', time, trackdata[0:length].decode('ISO-8859-1')] + trackdata = trackdata[length:] + + ###################################################################### + # Now, the MIDI file spec says: + # = + + # = + # = | | + # I know that, on the wire, can include note_on, + # note_off, and all the other 8x to Ex events, AND Fx events + # other than F0, F7, and FF -- namely, , + # , and . + # + # Whether these can occur in MIDI files is not clear specified + # from the MIDI file spec. So, I'm going to assume that + # they CAN, in practice, occur. I don't know whether it's + # proper for you to actually emit these into a MIDI file. + + elif (first_byte == 0xF2): # DTime, Beats + # ::= F2 + E = ['song_position', time, _read_14_bit(trackdata[:2])] + trackdata = trackdata[2:] + + elif (first_byte == 0xF3): # ::= F3 + # E = ['song_select', time, struct.unpack('>B',trackdata.pop(0))[0]] + E = ['song_select', time, trackdata[0]] + trackdata = trackdata[1:] + # DTime, Thing (what?! song number? whatever ...) + + elif (first_byte == 0xF6): # DTime + E = ['tune_request', time] + # What would a tune request be doing in a MIDI /file/? + + ######################################################### + # ADD MORE META-EVENTS HERE. TODO: + # f1 -- MTC Quarter Frame Message. One data byte follows + # the Status; it's the time code value, from 0 to 127. + # f8 -- MIDI clock. no data. + # fa -- MIDI start. no data. + # fb -- MIDI continue. no data. + # fc -- MIDI stop. no data. + # fe -- Active sense. no data. + # f4 f5 f9 fd -- unallocated + + r''' + elif (first_byte > 0xF0) { # Some unknown kinda F-series event #### + # Here we only produce a one-byte piece of raw data. + # But the encoder for 'raw_data' accepts any length of it. + E = [ 'raw_data', + time, substr(trackdata,Pointer,1) ] + # DTime and the Data (in this case, the one Event-byte) + ++Pointer; # itself + +''' + elif first_byte > 0xF0: # Some unknown F-series event + # Here we only produce a one-byte piece of raw data. + E = ['raw_data', time, trackdata[0].decode('ISO-8859-1')] + trackdata = trackdata[1:] + else: # Fallthru. + _warn("Aborting track. Command-byte first_byte="+hex(first_byte)) + break + # End of the big if-group + + + ###################################################################### + # THE EVENT REGISTRAR... + if E and (E[0] == 'end_track'): + # This is the code for exceptional handling of the EOT event. + eot = True + if not no_eot_magic: + if E[1] > 0: # a null text-event to carry the delta-time + E = ['text_event', E[1], ''] + else: + E = [] # EOT with a delta-time of 0; ignore it. + + if E and not (E[0] in exclude): + #if ( $exclusive_event_callback ): + # &{ $exclusive_event_callback }( @E ); + #else: + # &{ $event_callback }( @E ) if $event_callback; + events.append(E) + if eot: + break + + # End of the big "Event" while-block + + return events + + +########################################################################### +def _encode(events_lol, unknown_callback=None, never_add_eot=False, + no_eot_magic=False, no_running_status=False): + # encode an event structure, presumably for writing to a file + # Calling format: + # $data_r = MIDI::Event::encode( \@event_lol, { options } ); + # Takes a REFERENCE to an event structure (a LoL) + # Returns an (unblessed) REFERENCE to track data. + + # If you want to use this to encode a /single/ event, + # you still have to do it as a reference to an event structure (a LoL) + # that just happens to have just one event. I.e., + # encode( [ $event ] ) or encode( [ [ 'note_on', 100, 5, 42, 64] ] ) + # If you're doing this, consider the never_add_eot track option, as in + # print MIDI ${ encode( [ $event], { 'never_add_eot' => 1} ) }; + + data = [] # what I'll store the chunks of byte-data in + + # This is so my end_track magic won't corrupt the original + events = copy.deepcopy(events_lol) + + if not never_add_eot: + # One way or another, tack on an 'end_track' + if events: + last = events[-1] + if not (last[0] == 'end_track'): # no end_track already + if (last[0] == 'text_event' and len(last[2]) == 0): + # 0-length text event at track-end. + if no_eot_magic: + # Exceptional case: don't mess with track-final + # 0-length text_events; just peg on an end_track + events.append(['end_track', 0]) + else: + # NORMAL CASE: replace with an end_track, leaving DTime + last[0] = 'end_track' + else: + # last event was neither 0-length text_event nor end_track + events.append(['end_track', 0]) + else: # an eventless track! + events = [['end_track', 0],] + + # maybe_running_status = not no_running_status # unused? 4.7 + last_status = -1 + + for event_r in (events): + E = copy.deepcopy(event_r) + # otherwise the shifting'd corrupt the original + if not E: + continue + + event = E.pop(0) + if not len(event): + continue + + dtime = int(E.pop(0)) + # print('event='+str(event)+' dtime='+str(dtime)) + + event_data = '' + + if ( # MIDI events -- eligible for running status + event == 'note_on' + or event == 'note_off' + or event == 'control_change' + or event == 'key_after_touch' + or event == 'patch_change' + or event == 'channel_after_touch' + or event == 'pitch_wheel_change' ): + + # This block is where we spend most of the time. Gotta be tight. + if (event == 'note_off'): + status = 0x80 | (int(E[0]) & 0x0F) + parameters = struct.pack('>BB', int(E[1])&0x7F, int(E[2])&0x7F) + elif (event == 'note_on'): + status = 0x90 | (int(E[0]) & 0x0F) + parameters = struct.pack('>BB', int(E[1])&0x7F, int(E[2])&0x7F) + elif (event == 'key_after_touch'): + status = 0xA0 | (int(E[0]) & 0x0F) + parameters = struct.pack('>BB', int(E[1])&0x7F, int(E[2])&0x7F) + elif (event == 'control_change'): + status = 0xB0 | (int(E[0]) & 0x0F) + parameters = struct.pack('>BB', int(E[1])&0xFF, int(E[2])&0xFF) + elif (event == 'patch_change'): + status = 0xC0 | (int(E[0]) & 0x0F) + parameters = struct.pack('>B', int(E[1]) & 0xFF) + elif (event == 'channel_after_touch'): + status = 0xD0 | (int(E[0]) & 0x0F) + parameters = struct.pack('>B', int(E[1]) & 0xFF) + elif (event == 'pitch_wheel_change'): + status = 0xE0 | (int(E[0]) & 0x0F) + parameters = _write_14_bit(int(E[1]) + 0x2000) + else: + _warn("BADASS FREAKOUT ERROR 31415!") + + # And now the encoding + # w = BER compressed integer (not ASN.1 BER, see perlpacktut for + # details). Its bytes represent an unsigned integer in base 128, + # most significant digit first, with as few digits as possible. + # Bit eight (the high bit) is set on each byte except the last. + + data.append(_ber_compressed_int(dtime)) + if (status != last_status) or no_running_status: + data.append(struct.pack('>B', status)) + data.append(parameters) + + last_status = status + continue + else: + # Not a MIDI event. + # All the code in this block could be more efficient, + # but this is not where the code needs to be tight. + # print "zaz $event\n"; + last_status = -1 + + if event == 'raw_meta_event': + event_data = _some_text_event(int(E[0]), E[1]) + elif (event == 'set_sequence_number'): # 3.9 + event_data = b'\xFF\x00\x02'+_int2twobytes(E[0]) + + # Text meta-events... + # a case for a dict, I think (pjb) ... + elif (event == 'text_event'): + event_data = _some_text_event(0x01, E[0]) + elif (event == 'copyright_text_event'): + event_data = _some_text_event(0x02, E[0]) + elif (event == 'track_name'): + event_data = _some_text_event(0x03, E[0]) + elif (event == 'instrument_name'): + event_data = _some_text_event(0x04, E[0]) + elif (event == 'lyric'): + event_data = _some_text_event(0x05, E[0]) + elif (event == 'marker'): + event_data = _some_text_event(0x06, E[0]) + elif (event == 'cue_point'): + event_data = _some_text_event(0x07, E[0]) + elif (event == 'text_event_08'): + event_data = _some_text_event(0x08, E[0]) + elif (event == 'text_event_09'): + event_data = _some_text_event(0x09, E[0]) + elif (event == 'text_event_0a'): + event_data = _some_text_event(0x0A, E[0]) + elif (event == 'text_event_0b'): + event_data = _some_text_event(0x0B, E[0]) + elif (event == 'text_event_0c'): + event_data = _some_text_event(0x0C, E[0]) + elif (event == 'text_event_0d'): + event_data = _some_text_event(0x0D, E[0]) + elif (event == 'text_event_0e'): + event_data = _some_text_event(0x0E, E[0]) + elif (event == 'text_event_0f'): + event_data = _some_text_event(0x0F, E[0]) + # End of text meta-events + + elif (event == 'end_track'): + event_data = b"\xFF\x2F\x00" + + elif (event == 'set_tempo'): + #event_data = struct.pack(">BBwa*", 0xFF, 0x51, 3, + # substr( struct.pack('>I', E[0]), 1, 3)) + event_data = b'\xFF\x51\x03'+struct.pack('>I',E[0])[1:] + elif (event == 'smpte_offset'): + # event_data = struct.pack(">BBwBBBBB", 0xFF, 0x54, 5, E[0:5] ) + event_data = struct.pack(">BBBbBBBB", 0xFF,0x54,0x05,E[0],E[1],E[2],E[3],E[4]) + elif (event == 'time_signature'): + # event_data = struct.pack(">BBwBBBB", 0xFF, 0x58, 4, E[0:4] ) + event_data = struct.pack(">BBBbBBB", 0xFF, 0x58, 0x04, E[0],E[1],E[2],E[3]) + elif (event == 'key_signature'): + event_data = struct.pack(">BBBbB", 0xFF, 0x59, 0x02, E[0],E[1]) + elif (event == 'sequencer_specific'): + # event_data = struct.pack(">BBwa*", 0xFF,0x7F, len(E[0]), E[0]) + event_data = _some_text_event(0x7F, E[0]) + # End of Meta-events + + # Other Things... + elif (event == 'sysex_f0'): + #event_data = struct.pack(">Bwa*", 0xF0, len(E[0]), E[0]) + #B=bitstring w=BER-compressed-integer a=null-padded-ascii-str + event_data = bytearray(b'\xF0')+_ber_compressed_int(len(E[0]))+bytearray(bytes(E[0],encoding='ISO-8859-1')) + elif (event == 'sysex_f7'): + #event_data = struct.pack(">Bwa*", 0xF7, len(E[0]), E[0]) + event_data = bytearray(b'\xF7')+_ber_compressed_int(len(E[0]))+bytearray(bytes(E[0],encoding='ISO-8859-1')) + + elif (event == 'song_position'): + event_data = b"\xF2" + _write_14_bit( E[0] ) + elif (event == 'song_select'): + event_data = struct.pack('>BB', 0xF3, E[0] ) + elif (event == 'tune_request'): + event_data = b"\xF6" + elif (event == 'raw_data'): + _warn("_encode: raw_data event not supported") + # event_data = E[0] + continue + # End of Other Stuff + + else: + # The Big Fallthru + if unknown_callback: + # push(@data, &{ $unknown_callback }( @$event_r )) + pass + else: + _warn("Unknown event: "+str(event)) + # To surpress complaint here, just set + # 'unknown_callback' => sub { return () } + continue + + #print "Event $event encoded part 2\n" + if str(type(event_data)).find('str') >= 0: + event_data = bytearray(event_data.encode('Latin1', 'ignore')) + if len(event_data): # how could $event_data be empty + # data.append(struct.pack('>wa*', dtime, event_data)) + # print(' event_data='+str(event_data)) + data.append(_ber_compressed_int(dtime)+event_data) + + return b''.join(data) + diff --git a/tools/midi2piano2016/midi2piano.py b/tools/midi2piano2016/midi2piano.py new file mode 100644 index 0000000000..aac02eaaeb --- /dev/null +++ b/tools/midi2piano2016/midi2piano.py @@ -0,0 +1,307 @@ +""" +This module allows user to convert MIDI melodies to SS13 sheet music ready +for copy-and-paste +""" +from functools import reduce +import midi as mi +import easygui as egui +import pyperclip as pclip + +LINE_LENGTH_LIM = 50 +LINES_LIMIT = 200 +OVERALL_IMPORT_LIM = 12000 +END_OF_LINE_CHAR = """ +""" # BYOND can't parse \n and I am forced to define my own NEWLINE char + +OCTAVE_TRANSPOSE = 0 # Change here to transpose melodies by octaves +FLOAT_PRECISION = 2 # Change here to allow more or less numbers after dot in floats + +OCTAVE_KEYS = 12 +HIGHEST_OCTAVE = 8 + +""" +class Meta(): + version = 1.0 + integer = 1 + anti_integer = -1 + maximum = 1000 + epsilon = 0.51 + delta_epsilon = -0.1 + integral = [] + tensor = [[],[],[]] + o_complexity = epsilon**2 + random_variance = 0.01 +""" + +# UTILITY FUNCTIONS +def condition(event): + """ + This function check if given MIDI event is meaningful + """ + if event[0] == 'track_name' and event[2] == 'Drums': # Percussion + return False + if event[0] == 'note': # Only thing that matters + return True + return False + +def notenum2string(num, accidentals, octaves): + """ + This function converts given notenum to SS13 note according to previous + runs expressed using _accidentals_ and _octaves_ + """ + names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + convert_table = {1:0, 3:1, 6:2, 8:3, 10:4} + inclusion_table = {0:0, 2:1, 5:2, 7:3, 9:4} + + num += OCTAVE_KEYS * OCTAVE_TRANSPOSE + octave = int(num / OCTAVE_KEYS) + if octave < 1 or octave > HIGHEST_OCTAVE: + return ["", accidentals, octaves] + + accidentals = accidentals.copy() + octaves = octaves.copy() + + output_octaves = list(octaves) + name_indx = num % OCTAVE_KEYS + + accidental = (len(names[name_indx]) == 2) + output_octaves[name_indx] = octave + add_n = False + + if accidental: + accidentals[convert_table[name_indx]] = True + else: + if name_indx in inclusion_table: + add_n = accidentals[inclusion_table[name_indx]] + accidentals[inclusion_table[name_indx]] = False + + return [ + ( + names[name_indx]+ + ("n" if add_n else "")+ + str((octave if octave != octaves[name_indx] else "")) + ), + accidentals, + output_octaves + ] + +def dur2mod(dur, bpm_mod=1.0): + """ + This functions returns float representation of duration ready to be + added to the note after / + """ + mod = bpm_mod / dur + mod = round(mod, FLOAT_PRECISION) + return str(mod).rstrip('0').rstrip('.') +# END OF UTILITY FUNCTIONS + +# CONVERSION FUNCTIONS +def obtain_midi_file(): + """ + Asks user to select MIDI and returns this file opened in binary mode for reading + """ + file = egui.fileopenbox(msg='Choose MIDI file to convert', + title='MIDI file selection', + filetypes=[['*.mid', 'MID files']]) + if not file: + return None + file = open(file, mode='rb').read() + return file + +def midi2score_without_ticks(midi_file): + """ + Transforms aforementioned file into a score, truncates it and returns it + """ + opus = mi.midi2opus(midi_file) + opus = mi.to_millisecs(opus) + score = mi.opus2score(opus) + return score[1:] # Ticks don't matter anymore, it is always 1000 + +def filter_events_from_score(score): + """ + Filters out irrevelant events and returns new score + """ + return list(map( # For each score track + lambda score_track: list(filter( # Filter irrevelant events + condition, + score_track + )), + score + )) + +def filter_empty_tracks(score): + """ + Filters out empty tracks and returns new score + """ + return list(filter( + lambda score_track: score_track, + score)) + + +def filter_start_time_and_note_num(score): + """ + Recreates score with only note numbers and start time of each note and returns new score + """ + return list(map( + lambda score_track: list(map( + lambda event: [event[1], event[4]], + score_track)), + score)) + +def merge_events(score): + """Merges all tracks together and returns new score""" + return list(reduce( + lambda lst1, lst2: lst1+lst2, + score)) + +def sort_score_by_event_times(score): + """Sorts events by start time and returns new score""" + return list(map( + lambda index: score[index], + sorted( + list(range(len(score))), + key=lambda indx: score[indx][0]) + )) + +def convert_into_delta_times(score): + """ + Transform start_time into delta_time and returns new score + """ + return list(map( + lambda super_event: ( + [ + super_event[1][0]-super_event[0][0], + super_event[0][1] + ]), # [ [1, 2], [3, 4] ] -> [ [2, 2] ] + zip(score[:-1], score[1:]) # Shifted association. [1, 2, 3] -> [ (1, 2), (2, 3) ] + ))+[[1000, score[-1][1]]] # Add 1 second note to the end + +def perform_roundation(score): + """ + Rounds delta times to the nearest multiple of 100 ms as BYOND can't + process duration less than that and returns new score + """ + return list(map( + lambda event: [100*round(event[0]/100), event[1]], + score)) + +def obtain_common_duration(score): + """ + Returns the most frequent duration throughout the whole melody + """ + # Parse durations and filter out 0s + durs = list(filter(lambda x: x, list(map(lambda event: event[0], score)))) + unique_durs = [] + for dur in durs: + if dur not in unique_durs: + unique_durs.append(dur) + # How many such durations occur throughout the melody? + counter = [durs.count(dur) for dur in unique_durs] + highest_counter = max(counter) # Highest counter + dur_n_count = list(zip(durs, counter)) + dur_n_count = list(filter(lambda e: e[1] == highest_counter, dur_n_count)) + return dur_n_count[0][0] # Will be there + +def reduce_score_to_chords(score): + """ + Reforms score into a chord-duration list: + [[chord_notes], duration_of_chord] + and returns it + """ + new_score = [] + new_chord = [[], 0] + # [ [chord notes], duration of chord ] + for event in score: + new_chord[0].append(event[1]) # Append new note to the chord + if event[0] == 0: + continue # Add new notes to the chord until non-zero duration is hit + new_chord[1] = event[0] # This is the duration of chord + new_score.append(new_chord) # Append chord to the list + new_chord = [[], 0] # Reset the chord + return new_score + +def obtain_sheet_music(score, most_frequent_dur): + """ + Returns unformated sheet music from score + """ + result = "" + + octaves = [3 for i in range(12)] + accidentals = [False for i in range(7)] + for event in score: + for note_indx in range(len(event[0])): + data = notenum2string(event[0][note_indx], accidentals, octaves) + result += data[0] + accidentals = data[1] + octaves = data[2] + if note_indx != len(event[0])-1: + result += '-' + + if event[1] != most_frequent_dur: # Quarters are default + result += '/' + result += dur2mod(event[1], most_frequent_dur) + result += ',' + + return result + +def explode_sheet_music(sheet_music): + """ + Splits unformatted sheet music into formated lines of LINE_LEN_LIM + and such and returns a list of such lines + """ + split_music = sheet_music.split(',') + split_music = list(map(lambda note: note+',', split_music)) + split_list = [] + counter = 0 + line_counter = 1 + for note in split_music: + if line_counter > LINES_LIMIT-1: + break + if counter+len(note) > LINE_LENGTH_LIM-2: + last_note_num = len(split_list)-1 + split_list[last_note_num] = split_list[last_note_num].rstrip(',') + split_list[last_note_num] += END_OF_LINE_CHAR + counter = 0 + line_counter += 1 + split_list.append(note) + counter += len(note) + + return split_list + +def finalize_sheet_music(split_music, most_frequent_dur): + """ + Recreates sheet music from exploded sheet music, truncates it and returns it + """ + sheet_music = "" + for note in split_music: + sheet_music += note + sheet_music = sheet_music.rstrip(',') # Trim the last , + sheet_music = "BPM: " + str(int(60000 / most_frequent_dur)) + END_OF_LINE_CHAR + sheet_music + return sheet_music[:min(len(sheet_music), OVERALL_IMPORT_LIM)] +# END OF CONVERSION FUNCTIONS + +def main_cycle(): + """ + Activate the script + """ + while True: + midi_file = obtain_midi_file() + if not midi_file: + return # Cancel + score = midi2score_without_ticks(midi_file) + score = filter_events_from_score(score) + score = filter_start_time_and_note_num(score) + score = filter_empty_tracks(score) + score = merge_events(score) + score = sort_score_by_event_times(score) + score = convert_into_delta_times(score) + score = perform_roundation(score) + most_frequent_dur = obtain_common_duration(score) + score = reduce_score_to_chords(score) + sheet_music = obtain_sheet_music(score, most_frequent_dur) + split_music = explode_sheet_music(sheet_music) + sheet_music = finalize_sheet_music(split_music, most_frequent_dur) + + pclip.copy(sheet_music) + +main_cycle() diff --git a/tools/midi2piano2016/pyperclip/__init__.py b/tools/midi2piano2016/pyperclip/__init__.py new file mode 100644 index 0000000000..a3db1b3574 --- /dev/null +++ b/tools/midi2piano2016/pyperclip/__init__.py @@ -0,0 +1,103 @@ +""" +Pyperclip + +A cross-platform clipboard module for Python. (only handles plain text for now) +By Al Sweigart al@inventwithpython.com +BSD License + +Usage: + import pyperclip + pyperclip.copy('The text to be copied to the clipboard.') + spam = pyperclip.paste() + + if not pyperclip.copy: + print("Copy functionality unavailable!") + +On Windows, no additional modules are needed. +On Mac, the module uses pbcopy and pbpaste, which should come with the os. +On Linux, install xclip or xsel via package manager. For example, in Debian: +sudo apt-get install xclip + +Otherwise on Linux, you will need the gtk or PyQt4 modules installed. + +gtk and PyQt4 modules are not available for Python 3, +and this module does not work with PyGObject yet. +""" +__version__ = '1.5.27' + +import platform +import os +import subprocess +from .clipboards import (init_osx_clipboard, + init_gtk_clipboard, init_qt_clipboard, + init_xclip_clipboard, init_xsel_clipboard, + init_klipper_clipboard, init_no_clipboard) +from .windows import init_windows_clipboard + +# `import PyQt4` sys.exit()s if DISPLAY is not in the environment. +# Thus, we need to detect the presence of $DISPLAY manually +# and not load PyQt4 if it is absent. +HAS_DISPLAY = os.getenv("DISPLAY", False) +CHECK_CMD = "where" if platform.system() == "Windows" else "which" + + +def _executable_exists(name): + return subprocess.call([CHECK_CMD, name], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0 + + +def determine_clipboard(): + # Determine the OS/platform and set + # the copy() and paste() functions accordingly. + if 'cygwin' in platform.system().lower(): + # FIXME: pyperclip currently does not support Cygwin, + # see https://github.com/asweigart/pyperclip/issues/55 + pass + elif os.name == 'nt' or platform.system() == 'Windows': + return init_windows_clipboard() + if os.name == 'mac' or platform.system() == 'Darwin': + return init_osx_clipboard() + if HAS_DISPLAY: + # Determine which command/module is installed, if any. + try: + import gtk # check if gtk is installed + except ImportError: + pass + else: + return init_gtk_clipboard() + + try: + import PyQt4 # check if PyQt4 is installed + except ImportError: + pass + else: + return init_qt_clipboard() + + if _executable_exists("xclip"): + return init_xclip_clipboard() + if _executable_exists("xsel"): + return init_xsel_clipboard() + if _executable_exists("klipper") and _executable_exists("qdbus"): + return init_klipper_clipboard() + + return init_no_clipboard() + + +def set_clipboard(clipboard): + global copy, paste + + clipboard_types = {'osx': init_osx_clipboard, + 'gtk': init_gtk_clipboard, + 'qt': init_qt_clipboard, + 'xclip': init_xclip_clipboard, + 'xsel': init_xsel_clipboard, + 'klipper': init_klipper_clipboard, + 'windows': init_windows_clipboard, + 'no': init_no_clipboard} + + copy, paste = clipboard_types[clipboard]() + + +copy, paste = determine_clipboard() + +__all__ = ["copy", "paste"] diff --git a/tools/midi2piano2016/pyperclip/clipboards.py b/tools/midi2piano2016/pyperclip/clipboards.py new file mode 100644 index 0000000000..5eac945628 --- /dev/null +++ b/tools/midi2piano2016/pyperclip/clipboards.py @@ -0,0 +1,134 @@ +import sys +import subprocess +from .exceptions import PyperclipException + +EXCEPT_MSG = """ + Pyperclip could not find a copy/paste mechanism for your system. + For more information, please visit https://pyperclip.readthedocs.org """ +PY2 = sys.version_info[0] == 2 +text_type = unicode if PY2 else str + + +def init_osx_clipboard(): + def copy_osx(text): + p = subprocess.Popen(['pbcopy', 'w'], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=text.encode('utf-8')) + + def paste_osx(): + p = subprocess.Popen(['pbpaste', 'r'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + return stdout.decode('utf-8') + + return copy_osx, paste_osx + + +def init_gtk_clipboard(): + import gtk + + def copy_gtk(text): + global cb + cb = gtk.Clipboard() + cb.set_text(text) + cb.store() + + def paste_gtk(): + clipboardContents = gtk.Clipboard().wait_for_text() + # for python 2, returns None if the clipboard is blank. + if clipboardContents is None: + return '' + else: + return clipboardContents + + return copy_gtk, paste_gtk + + +def init_qt_clipboard(): + # $DISPLAY should exist + from PyQt4.QtGui import QApplication + + app = QApplication([]) + + def copy_qt(text): + cb = app.clipboard() + cb.setText(text) + + def paste_qt(): + cb = app.clipboard() + return text_type(cb.text()) + + return copy_qt, paste_qt + + +def init_xclip_clipboard(): + def copy_xclip(text): + p = subprocess.Popen(['xclip', '-selection', 'c'], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=text.encode('utf-8')) + + def paste_xclip(): + p = subprocess.Popen(['xclip', '-selection', 'c', '-o'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + return stdout.decode('utf-8') + + return copy_xclip, paste_xclip + + +def init_xsel_clipboard(): + def copy_xsel(text): + p = subprocess.Popen(['xsel', '-b', '-i'], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=text.encode('utf-8')) + + def paste_xsel(): + p = subprocess.Popen(['xsel', '-b', '-o'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + return stdout.decode('utf-8') + + return copy_xsel, paste_xsel + + +def init_klipper_clipboard(): + def copy_klipper(text): + p = subprocess.Popen( + ['qdbus', 'org.kde.klipper', '/klipper', 'setClipboardContents', + text.encode('utf-8')], + stdin=subprocess.PIPE, close_fds=True) + p.communicate(input=None) + + def paste_klipper(): + p = subprocess.Popen( + ['qdbus', 'org.kde.klipper', '/klipper', 'getClipboardContents'], + stdout=subprocess.PIPE, close_fds=True) + stdout, stderr = p.communicate() + + # Workaround for https://bugs.kde.org/show_bug.cgi?id=342874 + # TODO: https://github.com/asweigart/pyperclip/issues/43 + clipboardContents = stdout.decode('utf-8') + # even if blank, Klipper will append a newline at the end + assert len(clipboardContents) > 0 + # make sure that newline is there + assert clipboardContents.endswith('\n') + if clipboardContents.endswith('\n'): + clipboardContents = clipboardContents[:-1] + return clipboardContents + + return copy_klipper, paste_klipper + + +def init_no_clipboard(): + class ClipboardUnavailable(object): + def __call__(self, *args, **kwargs): + raise PyperclipException(EXCEPT_MSG) + + if PY2: + def __nonzero__(self): + return False + else: + def __bool__(self): + return False + + return ClipboardUnavailable(), ClipboardUnavailable() diff --git a/tools/midi2piano2016/pyperclip/exceptions.py b/tools/midi2piano2016/pyperclip/exceptions.py new file mode 100644 index 0000000000..c5ba3e75d2 --- /dev/null +++ b/tools/midi2piano2016/pyperclip/exceptions.py @@ -0,0 +1,11 @@ +import ctypes + + +class PyperclipException(RuntimeError): + pass + + +class PyperclipWindowsException(PyperclipException): + def __init__(self, message): + message += " (%s)" % ctypes.WinError() + super(PyperclipWindowsException, self).__init__(message) diff --git a/tools/midi2piano2016/pyperclip/windows.py b/tools/midi2piano2016/pyperclip/windows.py new file mode 100644 index 0000000000..a12932a575 --- /dev/null +++ b/tools/midi2piano2016/pyperclip/windows.py @@ -0,0 +1,151 @@ +""" +This module implements clipboard handling on Windows using ctypes. +""" +import time +import contextlib +import ctypes +from ctypes import c_size_t, sizeof, c_wchar_p, get_errno, c_wchar +from .exceptions import PyperclipWindowsException + + +class CheckedCall(object): + def __init__(self, f): + super(CheckedCall, self).__setattr__("f", f) + + def __call__(self, *args): + ret = self.f(*args) + if not ret and get_errno(): + raise PyperclipWindowsException("Error calling " + self.f.__name__) + return ret + + def __setattr__(self, key, value): + setattr(self.f, key, value) + + +def init_windows_clipboard(): + from ctypes.wintypes import (HGLOBAL, LPVOID, DWORD, LPCSTR, INT, HWND, + HINSTANCE, HMENU, BOOL, UINT, HANDLE) + + windll = ctypes.windll + + safeCreateWindowExA = CheckedCall(windll.user32.CreateWindowExA) + safeCreateWindowExA.argtypes = [DWORD, LPCSTR, LPCSTR, DWORD, INT, INT, + INT, INT, HWND, HMENU, HINSTANCE, LPVOID] + safeCreateWindowExA.restype = HWND + + safeDestroyWindow = CheckedCall(windll.user32.DestroyWindow) + safeDestroyWindow.argtypes = [HWND] + safeDestroyWindow.restype = BOOL + + OpenClipboard = windll.user32.OpenClipboard + OpenClipboard.argtypes = [HWND] + OpenClipboard.restype = BOOL + + safeCloseClipboard = CheckedCall(windll.user32.CloseClipboard) + safeCloseClipboard.argtypes = [] + safeCloseClipboard.restype = BOOL + + safeEmptyClipboard = CheckedCall(windll.user32.EmptyClipboard) + safeEmptyClipboard.argtypes = [] + safeEmptyClipboard.restype = BOOL + + safeGetClipboardData = CheckedCall(windll.user32.GetClipboardData) + safeGetClipboardData.argtypes = [UINT] + safeGetClipboardData.restype = HANDLE + + safeSetClipboardData = CheckedCall(windll.user32.SetClipboardData) + safeSetClipboardData.argtypes = [UINT, HANDLE] + safeSetClipboardData.restype = HANDLE + + safeGlobalAlloc = CheckedCall(windll.kernel32.GlobalAlloc) + safeGlobalAlloc.argtypes = [UINT, c_size_t] + safeGlobalAlloc.restype = HGLOBAL + + safeGlobalLock = CheckedCall(windll.kernel32.GlobalLock) + safeGlobalLock.argtypes = [HGLOBAL] + safeGlobalLock.restype = LPVOID + + safeGlobalUnlock = CheckedCall(windll.kernel32.GlobalUnlock) + safeGlobalUnlock.argtypes = [HGLOBAL] + safeGlobalUnlock.restype = BOOL + + GMEM_MOVEABLE = 0x0002 + CF_UNICODETEXT = 13 + + @contextlib.contextmanager + def window(): + """ + Context that provides a valid Windows hwnd. + """ + # we really just need the hwnd, so setting "STATIC" + # as predefined lpClass is just fine. + hwnd = safeCreateWindowExA(0, b"STATIC", None, 0, 0, 0, 0, 0, + None, None, None, None) + try: + yield hwnd + finally: + safeDestroyWindow(hwnd) + + @contextlib.contextmanager + def clipboard(hwnd): + """ + Context manager that opens the clipboard and prevents + other applications from modifying the clipboard content. + """ + # We may not get the clipboard handle immediately because + # some other application is accessing it (?) + # We try for at least 500ms to get the clipboard. + t = time.time() + 0.5 + success = False + while time.time() < t: + success = OpenClipboard(hwnd) + if success: + break + time.sleep(0.01) + if not success: + raise PyperclipWindowsException("Error calling OpenClipboard") + + try: + yield + finally: + safeCloseClipboard() + + def copy_windows(text): + # This function is heavily based on + # http://msdn.com/ms649016#_win32_Copying_Information_to_the_Clipboard + with window() as hwnd: + # http://msdn.com/ms649048 + # If an application calls OpenClipboard with hwnd set to NULL, + # EmptyClipboard sets the clipboard owner to NULL; + # this causes SetClipboardData to fail. + # => We need a valid hwnd to copy something. + with clipboard(hwnd): + safeEmptyClipboard() + + if text: + # http://msdn.com/ms649051 + # If the hMem parameter identifies a memory object, + # the object must have been allocated using the + # function with the GMEM_MOVEABLE flag. + count = len(text) + 1 + handle = safeGlobalAlloc(GMEM_MOVEABLE, + count * sizeof(c_wchar)) + locked_handle = safeGlobalLock(handle) + + ctypes.memmove(c_wchar_p(locked_handle), c_wchar_p(text), count * sizeof(c_wchar)) + + safeGlobalUnlock(handle) + safeSetClipboardData(CF_UNICODETEXT, handle) + + def paste_windows(): + with clipboard(None): + handle = safeGetClipboardData(CF_UNICODETEXT) + if not handle: + # GetClipboardData may return NULL with errno == NO_ERROR + # if the clipboard is empty. + # (Also, it may return a handle to an empty buffer, + # but technically that's not empty) + return "" + return c_wchar_p(handle).value + + return copy_windows, paste_windows