diff -r 96d016f78726 Lib/idlelib/querydialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Lib/idlelib/querydialog.py Mon Aug 17 12:35:32 2015 -0700 @@ -0,0 +1,161 @@ +from tkinter import * +from tkinter.font import Font +from tkinter import ttk + + +def askinteger(**kw): + if 'min' in kw: + min = kw['min'] + del kw['min'] + else: + min = None + if 'max' in kw: + max = kw['max'] + del kw['max'] + else: + max = None + kw['validatecmd'] = lambda s:validate_int(s, min=min, max=max) + d = QueryDialog(**kw) + return d.result + + +def askstring(**kw): + d = QueryDialog(**kw) + return d.result + + +class QueryDialog(Toplevel): + + def __init__(self, parent=None, prompt=None, initial_value=None, + title=None, validatecmd=None, oklabel=None, use_ttk=True): + Toplevel.__init__(self, parent) + self.parent = parent + self.validatecmd = validatecmd + self.result = None + self.wm_withdraw() + if parent is not None and parent.winfo_viewable(): + self.wm_transient(parent) + windowingsystem = self.tk.call('tk', 'windowingsystem') + if windowingsystem == 'aqua': + try: + self.tk.call('::tk::unsupported::MacWindowStyle', 'style', + self._w, 'moveableModal', '') + except: + pass + if title is not None: + self.title(title) + if use_ttk: + frm = ttk.Frame(self, padding=10) + else: + frm = Frame(self, padx=10, pady=10) + frm.grid(column=0, row=0, sticky='news') + frm.grid_columnconfigure(0, weight=1) + + if prompt is not None: + if use_ttk: + w = ttk.Label(frm, text=prompt, justify=LEFT) + else: + w = Label(frm, text=prompt, justify=LEFT) + w.grid(column=0, row=0, columnspan=3, padx=5, sticky=W) + + if use_ttk: + self.entry = ttk.Entry(frm, width=30) + else: + self.entry = Entry(frm, width=30) + self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E, + pady=[10,0]) + if initial_value is not None: + self.entry.insert(0, initial_value) + self.entry.select_range(0, END) + self.entry.bind('', self._clearerrmsg) + if use_ttk: + self.errmsg = ttk.Label(frm, text=' ', foreground='red', + font=Font(name='TkCaptionFont', + exists=True, root=parent)) + else: + self.errmsg = Label(frm, text=' ', foreground='red') + self.errmsg.grid(column=0, row=2, columnspan=3, padx=5, sticky=W+E) + + if oklabel is None: + oklabel = 'OK' + if use_ttk: + w = ttk.Button(frm, text=oklabel, command=self._ok, default=ACTIVE) + else: + w = Button(frm, text=oklabel, command=self._ok, default=ACTIVE) + w.grid(column=1, row=99, padx=5) + if use_ttk: + w = ttk.Button(frm, text="Cancel", command=self._cancel) + else: + w = Button(frm, text="Cancel", command=self._cancel) + w.grid(column=2, row=99, padx=5) + self.bind("", self._ok) + self.bind("", self._cancel) + if windowingsystem == 'aqua': + self.bind("", self._ok) + self.bind("", self._cancel) + + if parent is not None: + self.geometry("+%d+%d" % (parent.winfo_rootx()+50, + parent.winfo_rooty()+50)) + self.protocol("WM_DELETE_WINDOW", self._cancel) + self.update_idletasks() + self.wm_resizable(False, False) + self.wm_deiconify() + self.entry.focus_set() + self.wait_visibility() + self.grab_set() + self.wait_window(self) + + def _clearerrmsg(self, event=None): + self.errmsg['text'] = ' ' + + def _ok(self, event=None): + s = self.entry.get().strip() + errmsg = self.get_validation_error(s) + if errmsg is not None: + self.errmsg['text'] = errmsg + self.entry.focus_set() # put focus back + return + self.result = s + self.withdraw() + self.update_idletasks() + self.destroy() + + def _cancel(self, event=None): + self.destroy() + + def destroy(self): + if self.parent is not None: + self.parent.focus_set() + Toplevel.destroy(self) + + def get_validation_error(self, s): + if self.validatecmd is not None: + try: + self.validatecmd(s) + except ValueError as e: + return e.args[0] + return None + + +def validate_int(s, min=None, max=None): + try: + v = int(s) + except ValueError: + raise ValueError("Must be an integer") + if min is not None and max is not None: + if v < min or v > max: + raise ValueError("Must be between " + str(min) + + " and " + str(max)) + if min is not None and v < min: + raise ValueError("Must be at least "+str(min)) + if max is not None and v > max: + raise ValueError("Must be no larger than "+str(max)) + + + +if __name__ == '__main__': + + root = Tk() + val = askinteger(parent=root, prompt='Number', title='Gimme', min=5, max=9) + print(str(val)) diff -r 96d016f78726 Lib/idlelib/tkextras.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Lib/idlelib/tkextras.py Mon Aug 17 12:35:32 2015 -0700 @@ -0,0 +1,100 @@ +""" +Module tkextras -- helpers that extend Tkinter in ways useful to IDLE +""" + +from tkinter import * +import sys +import platform + + +class IdleSpinbox(Spinbox): + """ + A ttk::spinbox was added in Tk 8.5.9; use it if present, otherwise + use a spinbox. Note the two have different options and methods, so this + only works for the basics. + """ + def __init__(self, master=None, cnf={}, **kw): + hasTtkSpinbox = master and master.tk.call('info', 'commands', + 'ttk::spinbox') + base = 'ttk::spinbox' if hasTtkSpinbox else 'spinbox' + Widget.__init__(self, master, base, cnf, kw) + + +def need_sizegrip(): + """ + Older versions of OS X in particular require a ttk::sizegrip widget + at the bottom right corner of the window. This is no longer the case + in more recent versions. + """ + if sys.platform == 'darwin': + v, _, _ = platform.mac_ver() + major, minor = v.split('.')[:2] + if (int(major) == 10 and int(minor) < 7): + return True + return False + + +def windowingsystem(): + """ + Approximation of the 'tk windowingsystem' call, based on guessing + platform. We don't actually call it because we don't necessarily have + a Tk window handle available. + """ + if sys.platform == 'darwin': + return 'aqua' + elif sys.platform == 'win32': + return 'win32' + else: + return 'x11' + + +def clickable_cursor(): + "Return an appropriate cursor for when things are clickable, e.g. links" + if sys.platform == 'darwin': + return 'pointinghand' + else: + return 'hand2' + + +class VerticalScrolledFrame(ttk.Frame): + """A pure Tkinter vertically scrollable frame. + + * Use the 'interior' attribute to place widgets inside the scrollable frame + * Construct and pack/place/grid normally + * This frame only allows vertical scrolling + """ + def __init__(self, parent, *args, **kw): + ttk.Frame.__init__(self, parent, *args, **kw) + + # create a canvas object and a vertical scrollbar for scrolling it + vscrollbar = ttk.Scrollbar(self, orient=VERTICAL) + vscrollbar.pack(fill=Y, side=RIGHT, expand=FALSE) + canvas = Canvas(self, bd=0, highlightthickness=0, + yscrollcommand=vscrollbar.set) + canvas.pack(side=LEFT, fill=BOTH, expand=TRUE) + vscrollbar.config(command=canvas.yview) + + # reset the view + canvas.xview_moveto(0) + canvas.yview_moveto(0) + + # create a frame inside the canvas which will be scrolled with it + self.interior = interior = ttk.Frame(canvas) + interior_id = canvas.create_window(0, 0, window=interior, anchor=NW) + + # track changes to the canvas and frame width and sync them, + # also updating the scrollbar + def _configure_interior(event): + # update the scrollbars to match the size of the inner frame + size = (interior.winfo_reqwidth(), interior.winfo_reqheight()) + canvas.config(scrollregion="0 0 %s %s" % size) + + interior.bind('', _configure_interior) + + def _configure_canvas(event): + if interior.winfo_reqwidth() != canvas.winfo_width(): + # update the inner frame's width to fill the canvas + canvas.itemconfigure(interior_id, width=canvas.winfo_width()) + canvas.bind('', _configure_canvas) + + return diff -r 96d016f78726 Lib/idlelib/uifactory.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Lib/idlelib/uifactory.py Mon Aug 17 12:35:32 2015 -0700 @@ -0,0 +1,133 @@ +"""Create IDLE user interface components, factoring in any backwards + compatibility constraints, e.g. Tk8.4 vs. Tk8.5. + + We also keep track of the non-editor windows running in the application, + so there is some overlap/coordination with FileList.py +""" + +from tkinter import * + + +def initialize(root, avoid_ttk=False): + "Should be called before using rest of this module." + global _inst + _inst = _UIFactory(root, avoid_ttk) + + +def open_about(): + "Open the application's about dialog." + _inst.open_about() + + +def open_preferences(editor_window): + """Open the preferences dialog, as requested by the given editor window. + + Note that we use the editor window only when starting, to position the + preferences dialog, as well as retrieve the file list that the editor + window is part of. The provided editor window may be destroyed while + the preferences dialog is still active (e.g. if the dialog is not modal). + """ + _inst.open_preferences(editor_window) + + +def askstring(parent=None, prompt=None, initial_value=None, title=None, + oklabel=None, validatecmd=None): + "Post a modal dialog which returns a string or None." + return _inst.askstring(parent=parent, prompt=prompt, oklabel=oklabel, + initial_value=initial_value, title=title, + validatecmd=validatecmd) + +def askinteger(parent=None, prompt=None, initial_value=None, title=None, + oklabel=None, min=None, max=None): + "Post a modal dialog which returns an integer or None." + return _inst.askinteger(parent=parent, prompt=prompt, oklabel=oklabel, + initial_value=initial_value, title=title, min=min, max=max) + + +def using_ttk(): + return _inst.using_ttk() + + +def other_windows_open(): + "Return True if any of the non-editor windows we manage is open." + return _inst.other_windows_open() + + +def set_allclosed_callback(cmd): + "We'll notify when the last window we manage closes; used by FileList" + _inst.set_allclosed_callback(cmd) + + +class _UIFactory(object): + def __init__(self, root, avoid_ttk=False): + self.root = root + self.using_ttk = False + self.windows = {} + self.allclosed_callback = None + if not avoid_ttk and TkVersion >= 8.5: + try: + from tkinter import ttk + self.using_ttk = True + except: + pass + + def open_about(self): + if 'about' not in self.windows.keys(): + from idlelib.aboutDialog import AboutDialog + self.windows['about'] = AboutDialog(self.root, 'About IDLE', + must_be_modal=False, + destroy_callback=lambda: self._destroyed('about')) + self.windows['about'].lift() + + def open_preferences(self, editor_window): + if 'prefs' not in self.windows.keys(): + # Later on, we'll do something like: + # if self.using_ttk: + # ... + # else: + # ... + from idlelib.configDialog import ConfigDialog + self.windows['prefs'] = ConfigDialog(editor_window, + 'Settings', must_be_modal=False, + destroy_callback=lambda: self._destroyed('prefs')) + self.windows['prefs'].lift() + + def askstring(self, parent=None, prompt=None, initial_value=None, + oklabel=None, title=None, validatecmd=None): + from idlelib.querydialog import askstring + if parent is None: + parent = self.root + return askstring(parent=parent, prompt=prompt, title=title, + initial_value=initial_value, validatecmd=validatecmd, + oklabel=oklabel, use_ttk=self.using_ttk) + + def askinteger(self, parent=None, prompt=None, initial_value=None, + oklabel=None, title=None, min=None, max=None): + from idlelib.querydialog import askinteger + if parent is None: + parent = self.root + return askinteger(parent=parent, prompt=prompt, title=title, + initial_value=initial_value, min=min, max=max, + oklabel=oklabel, use_ttk=self.using_ttk) + + def _destroyed(self, key): + if key in self.windows.keys(): + del self.windows[key] + if not self.other_windows_open() and self.allclosed_callback: + self.allclosed_callback() + + def using_ttk(self): + return self.using_ttk + + def other_windows_open(self): + return len(self.windows) > 0 + + def set_allclosed_callback(self, cmd): + self.allclosed_callback = cmd + + +_inst = None + +if __name__ == '__main__': + root = Tk() + initialize(root) diff -r 96d016f78726 Lib/idlelib/uipreferences.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Lib/idlelib/uipreferences.py Mon Aug 17 12:35:32 2015 -0700 @@ -0,0 +1,921 @@ +""" +IDLE Preferences dialog + +TODO (as of August 17/2015): +- keys pane +- error checking for help sources + + +NOTES: + +Just want to provide a bit of a heads up in terms of how the code in here +is structured, vs. how it was done in configDialog.py. + +Most notably, things are separated out more: + 1. There is a class which handles pending (not yet applied) changes, + i.e. 'changedItems'. + 2. There is a (short) class for the main dialog. + 3. There is a base class for preference panes, with some common utilities. + 4. Each pane has its own class. + +This keeps things together more, and makes it easier to use simpler instance +variable names without worrying about stomping on something else. + +Second, while the old code notified all the active editor windows when +preferences were applied (requiring knowledge of editor window objects), +we now have an 'observer' object that gets passed in that we notify. +In practice, this is the global FileList object. + +Third, a lot of the boilerplate to do with creating and managing TkVar +instances, and tying them to configuration options has been replaced by +a 'register_prefvar' call which takes care of all the housekeeping. + +I won't comment much here on the actual UI differences, beyond noting that +things were adjusted so that the use of further dialogs (e.g. for editing +themes or help sources) was minimized. + +I did want to draw attention to how the new querydialog can handle custom +validations while the dialog is still posted, without adding additional error +dialogs. Search for 'validate_theme' for an example. +""" + +import os +from tkinter import * +from tkinter import ttk +import tkinter.messagebox as messagebox +import tkinter.colorchooser as colorchooser +import tkinter.filedialog as filedialog +import tkinter.font as tkFont +from idlelib.configHandler import idleConf +from idlelib import macosxSupport +from idlelib import tkextras +from idlelib import uifactory + + +class PreferencesChanger(object): + """ + Maintain sets of preferences that are in the process of being + changed, but don't push them to the configuration system until we're + told to apply all changes. + + A possible extension of this is to have all (or select) changes + immediately be pushed. + + Much of this logic should probably be pushed into configHandler.py + """ + def __init__(self, observer=None): + self.observer = observer + self.reset() + + def change(self, type_, section, item, value): + if section not in self.changedItems[type_]: + self.changedItems[type_][section] = {} + self.changedItems[type_][section][item] = str(value) + + def get_pending_change(self, type_, section, item): + "Note: can return an exception if no pending change was made" + return self.changedItems[type_][section][item] + + def cancel_pending_change(self, type_, section, item=None): + """Note: will not generate errors if the section or item is not + present, but we do expect the type to be there""" + if section in self.changedItems[type_]: + if item is not None: + if item in self.changedItems[type_][section]: + del(self.changedItems[type_][section][item]) + else: + del(self.changedItems[type_][section]) + + def reset(self): + self.changedItems = {'main':{}, 'highlight':{}, 'keys':{}, + 'extensions':{}} + + def save_changes(self): + "Dynamically apply configuration changes" + if self.observer: + self.observer.configuration_will_change() + self.SaveAllChangedConfigs() + if self.observer: + self.observer.configuration_changed() + + def SetUserValue(self, type_, section, item, value): + if idleConf.defaultCfg[type_].has_option(section, item): + if idleConf.defaultCfg[type_].Get(section, item) == value: + # the setting equals a default, remove it from user cfg + return idleConf.userCfg[type_].RemoveOption(section, item) + # if we got here set the option + return idleConf.userCfg[type_].SetOption(section, item, value) + + def SaveAllChangedConfigs(self): + "Save configuration changes to the user config file." + idleConf.userCfg['main'].Save() + for configType in self.changedItems: + cfgTypeHasChanges = False + for section in self.changedItems[configType]: + if section == 'HelpFiles': + # this section gets completely replaced + idleConf.userCfg['main'].remove_section('HelpFiles') + cfgTypeHasChanges = True + for item in self.changedItems[configType][section]: + value = self.changedItems[configType][section][item] + if self.SetUserValue(configType, section, item, value): + cfgTypeHasChanges = True + if cfgTypeHasChanges: + idleConf.userCfg[configType].Save() + for configType in ['keys', 'highlight']: + # save these even if unchanged! + idleConf.userCfg[configType].Save() + self.reset() # clear the changed items dict + + +class PreferencesDialog(Toplevel): + """ + IDLE preferences dialog, providing a user interface for modifying + configuration settings. + + Consists of multiple preference panes, organized via a tabbed interface. + + Our preference_changed() routine should be called whenever a + preference has been changed in a pane. + """ + def __init__(self, parent, observer=None, destroy_callback=None): + Toplevel.__init__(self, parent) + self.parent = parent + self.observer = observer + self.destroy_callback = destroy_callback + self.panes = [] + self.pref_changer = PreferencesChanger(observer) + self.wm_withdraw() + self.title('Preferences') + self.protocol("WM_DELETE_WINDOW", self.close) + self.config(borderwidth=0) + self.outerframe = ttk.Frame(self) + self.outerframe.grid(column=0, row=0, sticky='NWES') + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + self.outerframe.grid_columnconfigure(0, weight=1) + self.outerframe.grid_rowconfigure(0, weight=1) + tabs = self.tabs = ttk.Notebook(self.outerframe) + tabs.grid(column=0, row=0, sticky='NWES') + if tkextras.windowingsystem() != 'aqua': + tabs.grid_configure(pady=[10,10]) + self.add_panes(tabs) + buttons = ttk.Frame(self.outerframe, padding=[0,0,10,10]) + self.w_ok = ttk.Button(buttons, text='OK', command=self.Ok) + self.w_apply = ttk.Button(buttons, text='Apply', command=self.Apply) + self.w_cancel = ttk.Button(buttons, text='Cancel', command=self.Cancel) + buttons.grid(column=0, row=99, sticky='SE') + self.w_ok.grid(column=0, row=0, padx=5) + self.w_apply.grid(column=1, row=0, padx=5) + self.w_cancel.grid(column=2, row=0, padx=5) + self.wm_deiconify() + + def add_pane(self, pane, label): + self.panes.append(pane) + self.tabs.add(pane, text=label) + + def add_panes(self, parent): + self.add_pane(FontsPane(parent, self), 'Fonts/Tabs') + self.add_pane(ThemesPane(parent, self), 'Themes') + self.add_pane(KeysPane(parent, self), 'Keys') + self.add_pane(GeneralPane(parent, self), 'General') + self.add_pane(ExtensionsPane(parent, self), 'Extensions') + + def preference_changed(self, type_, section, item, value): + self.pref_changer.change(type_, section, item, value) + + def get_pending_change(self, type_, section, item): + return self.pref_changer.get_pending_change(type_, section, item) + + def cancel_pending_change(self, type_, section, item=None): + return self.pref_changer.cancel_pending_change(type_, section, item) + + def Ok(self): + self.Apply() + self.close() + + def Apply(self): + self.pref_changer.save_changes() + + def Cancel(self): + self.close() + + def close(self): + if self.destroy_callback: + self.destroy_callback() + self.destroy() + + +class PreferencesPane(ttk.Frame): + """ + Base class for an individual preference pane. + + Provide common utilities (e.g. managing preferences and variables + attached to preferences). + """ + def __init__(self, parent, owner): + ttk.Frame.__init__(self, parent) + self.owner = owner + self.parent = parent + + def register_prefvar(self, type_, sec, item, varclass=StringVar, **kw): + v = varclass(self.parent) + if 'value' in kw: + v.set(kw['value']) + else: + v.set(idleConf.GetOption(type_, sec, item, **kw)) + v.trace_variable('w', lambda var, idx, op: + self.prefvar_changed(v, type_, sec, item)) + return v + + def prefvar_changed(self, v, type_, sec, item): + self.owner.preference_changed(type_, sec, item, v.get()) + + def preference_changed(self, type_, sec, item, value): + self.owner.preference_changed(type_, sec, item, value) + + def get_pending_change(self, type_, sec, item): + return self.owner.get_pending_change(type_, sec, item) + + def cancel_pending_change(self, type_, sec, item=None): + self.owner.cancel_pending_change(type_, sec, item) + + +class FontsPane(PreferencesPane): + """ + Preference pane for modifying the font and tab indent used by all + editor windows. + """ + def __init__(self, parent, owner): + PreferencesPane.__init__(self, parent, owner) + + # font family + config_font = idleConf.GetFont(self, 'main', 'EditorWindow') + config_family = config_font[0].lower() + self.family_v = self.register_prefvar('main', 'EditorWindow', + 'font', value=config_family) + self.family_v = StringVar(parent) + self.family_v.set(config_family) + self.family = Listbox(self, height=5, takefocus=FALSE, + exportselection=FALSE, activestyle='none') + fonts = list(tkFont.families(self)) + fonts.sort() + for font in fonts: + self.family.insert(END, font) + if font.lower() == config_family: + self.family.select_set(END) + self.family.select_anchor(END) + self.family.see(ANCHOR) + self.family.bind('<>', self.family_changed) + scroll_family = ttk.Scrollbar(self, command=self.family.yview) + self.family.config(yscrollcommand=scroll_family.set) + self.family.grid(column=0, columnspan=3, row=0, rowspan=2, + sticky='NWES') + scroll_family.grid(column=3, row=0, rowspan=2, sticky='NS') + + # font size + config_size = config_font[1] + self.size_v = self.register_prefvar('main', 'EditorWindow', + 'font-size', value=config_size) + lbl_size = ttk.Label(self, text='Font Size:') + sizes = ['7', '8', '9', '10', '11', '12', '13', '14', '16', '18', + '20', '22'] + self.size = ttk.Combobox(self, textvariable=self.size_v, + values=sizes, width=4, state='readonly') + lbl_size.grid(column=0, row=2, sticky='SE') + self.size.grid(column=1, row=2, sticky='SW', padx=[5,0]) + + # font weight + config_bold = config_font[2] == 'bold' + self.bold_v = self.register_prefvar('main', 'EditorWindow', + 'font-bold', varclass=BooleanVar, value=config_bold) + bold = ttk.Checkbutton(self, variable=self.bold_v, onvalue=1, + offvalue=0, text='Bold', command=self.update_font) + bold.grid(column=2, row=2, sticky='S') + + # font sample + frame_sample = ttk.Frame(self, relief=SOLID, borderwidth=1) + self.sample = ttk.Label(frame_sample, anchor=CENTER, + text='AaBbCcDdEe\nFfGgHhIiJjK\n1234567890\n#:+=(){}[]') + frame_sample.grid(column=4, row=0, sticky='NWES', padx=[10,0]) + frame_sample.columnconfigure(0, weight=1) + frame_sample.rowconfigure(0, weight=1) + self.sample.grid(column=0, row=0, sticky='WE') + + # indentation width + self.indent_v = self.register_prefvar('main', 'Indent', 'num-spaces', + default=4, type='int', varclass=IntVar) + lbl_indent = ttk.Label(self, text='Indent:') + info_indent = ttk.Label(self, justify=LEFT, + text='Python Standard: 4 Spaces!') + self.indent = tkextras.IdleSpinbox(self, textvariable=self.indent_v, + from_=2, to=16, width=3) + lbl_indent.grid(column=0, row=3, sticky='SE') + self.indent.grid(column=1, row=3, sticky='SW', padx=[5,0]) + info_indent.grid(column=2, columnspan=3, row=3, sticky='SW') + + self.configure(padding=6) + self.grid_columnconfigure(1, weight=1) + self.grid_columnconfigure(4, weight=3) + self.grid_rowconfigure(0, weight=2) + self.grid_rowconfigure(1, weight=1) + self.grid_rowconfigure(2, pad=5) + self.grid_rowconfigure(3, pad=10) + + def family_changed(self, event): + self.family_v.set(self.family.get( + self.family.curselection()).lower()) + self.update_font() + + def update_font(self, event=None): + newFont = (self.family_v.get(), self.size_v.get(), + tkFont.BOLD if self.bold_v.get() else tkFont.NORMAL) + self.sample['font'] = newFont + self.prefvar_changed(self.family_v, 'main', 'EditorWindow', 'font') + self.prefvar_changed(self.size_v, 'main', 'EditorWindow', 'font-size') + self.prefvar_changed(self.bold_v, 'main', 'EditorWindow', 'font-bold') + + +class ThemesPane(PreferencesPane): + """ + Preference pane for choosing between highlighting "themes", + which control the color different Python syntax elements are displayed + in within the editor. + + Built-in themes are available, and users can add their own, modifying + the individual colors used for each type of element. + """ + def __init__(self, parent, owner): + PreferencesPane.__init__(self, parent, owner) + + # list of themes, and current selection + self.theme_v = self.register_prefvar('main', 'Theme', 'name') + self.themes = Listbox(self, exportselection=FALSE, activestyle='none') + self.default_themes = idleConf.GetSectionList('default', 'highlight') + self.themes.bind('<>', self.theme_changed) + self.themes.grid(column=0, row=1, columnspan=2, rowspan=5, + sticky='nwes') + scroll = ttk.Scrollbar(self, command=self.themes.yview) + self.themes.yscrollcommand = scroll.set + scroll.grid(column=2, row=1, rowspan=5, sticky='ns', padx=[0,10]) + + # new and delete theme buttons + self.new_button = ttk.Button(self, text='New...', + command=self.newtheme) + self.new_button.grid(column=0, row=6) + self.delete_button = ttk.Button(self, text='Delete...', + command=self.delete_theme) + self.delete_button.grid(column=1, row=6, sticky=W, padx=[0,20]) + + # highlight sample + self.load_element_data() + self.sample = Text( + self, relief=SOLID, borderwidth=1, wrap=NONE, + font='TkFixedFont', cursor=tkextras.clickable_cursor(), + width=21, height=12, takefocus=FALSE, highlightthickness=0) + self.sample.bind('', lambda e: 'break') + self.sample.bind('', lambda e: 'break') + for txTa in self.sample_text_tags: + self.sample.insert(END, txTa[0], txTa[1]) + for element in self.elements: + self.sample.tag_bind(self.elements[element][0], '<1>', + lambda ev,element=element:self.element_v.set(element)) + self.sample.config(state=DISABLED) + self.sample.grid(column=3, row=1, columnspan=2, rowspan=2, + sticky='nwe', padx=[0,10]) + + # theme element we're viewing or changing; can be changed either with + # the combobox we use here, or clicking the element in the sample + self.element_v = StringVar(parent) + self.element_v.set('Normal Text') + self.element_v.trace_variable('w', lambda v,i,o:self.update_colors()) + elementNames = list(self.elements.keys()) + elementNames.sort(key=lambda x: self.elements[x][1]) + self.element = ttk.Combobox(self, state='readonly', + textvariable=self.element_v, values=elementNames, + width=20, exportselection=False) + # by default comboboxes highlight their selection; change that: + self.element.bind('<>', + lambda ev:self.element.selection_clear()) + self.element.grid(column=3, row=4, columnspan=2, pady=[5,0]) + + # color wells to view/change foreground and background element colors + self.foregroundWell = Frame(self, width=20, height=20, borderwidth=3, + relief='ridge', background='black', + cursor=tkextras.clickable_cursor()) + self.foregroundWell.bind('<1>', + lambda ev: self.change_color(foreground=True)) + self.foregroundWell.grid(column=4, row=5, pady=5, sticky='w', padx=6) + ttk.Label(self, text='Foreground:').grid(column=3, row=5, + sticky='e', padx=[20,0]) + self.backgroundWell = Frame(self, width=20, height=20, borderwidth=3, + relief='ridge', background='white', + cursor=tkextras.clickable_cursor()) + self.backgroundWell.bind('<1>', + lambda ev: self.change_color(foreground=False)) + self.backgroundWell.grid(column=4, row=6, pady=5, sticky='w', padx=6) + ttk.Label(self, text='Background:').grid(column=3, row=6, + sticky='e', padx=[20,0]) + self.configure(padding=6) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(4, weight=1) + self.grid_rowconfigure(8, weight=10) + self.rebuild_themes_list() + + def rebuild_themes_list(self): + "Update the listbox containing the available themes" + self.themes.delete(0, END) + for t in sorted(self.all_theme_names()): + self.themes.insert(END, t) + if t == self.theme_v.get(): + self.themes.select_set(END, END) + self.theme_changed() + + def theme_changed(self, event=None): + "Current theme (from listbox) changed; adjust state and UI to match." + theme = self.themes.get(self.themes.curselection()) + if theme in self.default_themes: + self.delete_button['state'] = 'disabled' + else: + self.delete_button['state'] = 'normal' + self.theme_v.set(theme) + self.update_colors() + + def change_color(self, foreground=True): + "Called when a color well is clicked on." + if self.theme_v.get() in self.default_themes: + self.newtheme(tried_modifying_builtin=True) + else: + title = 'Foreground' if foreground else 'Background' + title += ' for %s' % self.element_v.get() + color = self.current_color(self.theme_v.get(), + self.element_v.get(), 'fg' if foreground else 'bg') + newcolor = colorchooser.askcolor(parent=self, title=title, + initialcolor=color)[1] + if newcolor is not None: + fgbg = '-foreground' if foreground else '-background' + self.preference_changed('highlight', self.theme_v.get(), + self.elements[self.element_v.get()][0]+fgbg, newcolor) + self.update_colors() + + def update_colors(self): + """"When a theme, color or the active element changes, update the + colors in the UI, both the sample and also the color wells, to match + the new state.""" + theme = self.theme_v.get() + for element in self.elements: + elt = self.elements[element][0] + fg = self.current_color(self.theme_v.get(), element, 'fg') + bg = self.current_color(self.theme_v.get(), element, 'bg') + if elt == 'cursor': + bg = idleConf.GetHighlight(theme, 'normal', fgBg='bg') + self.sample.tag_config(elt, background=bg, foreground=fg) + if element == self.element_v.get(): + self.foregroundWell.configure(background=fg) + self.backgroundWell.configure(background=bg) + + def all_theme_names(self): + themelist = self.default_themes[:] + userlist = idleConf.GetSectionList('user', 'highlight') + if userlist: + themelist.extend(userlist) + return themelist + + def current_color(self, theme, element, fgBg): + """Retrieve the current color of a theme element, factoring in we may + have some unsaved changes.""" + elt = self.elements[element][0] + pref = elt + '-foreground' if fgBg == 'fg' else elt + '-background' + try: + c = self.get_pending_change('highlight', theme, pref) + except KeyError: + c = idleConf.GetHighlight(theme, elt, fgBg) + return c + + def newtheme(self, tried_modifying_builtin=False): + "Ask for the name of a new theme, and create it." + cur = self.theme_v.get() + prompt = '' + if tried_modifying_builtin: + prompt = '"' + cur + '" is built-in and cannot be modified.\n' + prompt += 'Create a new theme based on it instead?\n\n' + prompt += 'Name for new theme:' + new_theme = self.suggested_themename(cur) + def validate_theme(s): + if not s: + raise ValueError('Cannot be blank') + if len(s) > 30: + raise ValueError('Cannot be longer than 30 characters') + if s in self.all_theme_names(): + raise ValueError('Name already used') + new_theme = uifactory.askstring(parent=self, prompt=prompt, + title='Create New Theme', initial_value=new_theme, + oklabel='Create', validatecmd=validate_theme) + if new_theme is not None: + if not idleConf.userCfg['highlight'].has_section(new_theme): + idleConf.userCfg['highlight'].add_section(new_theme) + for element in self.elements: + idleConf.userCfg['highlight'].SetOption(new_theme, + self.elements[element][0]+'-foreground', + self.current_color(cur, element, 'fg')) + idleConf.userCfg['highlight'].SetOption(new_theme, + self.elements[element][0]+'-background', + self.current_color(cur, element, 'bg')) + self.theme_v.set(new_theme) + self.rebuild_themes_list() + + def suggested_themename(self, basename): + """Suggest a new name for a theme based on another theme. + This will be of the form "original Copy" or "original Copy 2", + taking into account the original may already be a copy.""" + copycount = 1 + name = basename[:] + match = re.search('^(.*) Copy( [0-9]+)?$', basename) + if match: + name = match.group(1) + num = match.group(2) + if num is not None: + copycount = int(num.strip()) + while True: + possiblename = name + ' Copy' + if copycount > 1: + possiblename += ' '+str(copycount) + if possiblename not in self.all_theme_names(): + return possiblename + copycount += 1 + + def delete_theme(self): + theme = self.theme_v.get() + if theme not in self.default_themes: # shouldn't happen + delmsg = 'Are you sure you wish to delete the theme %r ?' + if messagebox.askyesno('Delete Theme', delmsg % theme, + parent=self): + idleConf.userCfg['highlight'].remove_section(theme) + self.cancel_pending_change('highlight', theme) + idleConf.userCfg['highlight'].Save() + self.theme_v.set(self.default_themes[0]) + self.rebuild_themes_list() + + def load_element_data(self): + self.elements = { + 'Normal Text':('normal', '00'), + 'Python Keywords':('keyword', '01'), + 'Python Definitions':('definition', '02'), + 'Python Builtins':('builtin', '03'), + 'Python Comments':('comment', '04'), + 'Python Strings':('string', '05'), + 'Selected Text':('hilite', '06'), + 'Found Text':('hit', '07'), + 'Cursor':('cursor', '08'), + 'Error Text':('error', '09'), + 'Shell Normal Text':('console', '10'), + 'Shell Stdout Text':('stdout', '11'), + 'Shell Stderr Text':('stderr', '12'), + } + self.sample_text_tags = ( + ('#you can click here', 'comment'), ('\n', 'normal'), + ('#to choose items', 'comment'), ('\n', 'normal'), + ('def', 'keyword'), (' ', 'normal'), + ('func', 'definition'), ('(param):\n ', 'normal'), + ('"""string"""', 'string'), ('\n var0 = ', 'normal'), + ("'string'", 'string'), ('\n var1 = ', 'normal'), + ("'selected'", 'hilite'), ('\n var2 = ', 'normal'), + ("'found'", 'hit'), ('\n var3 = ', 'normal'), + ('list', 'builtin'), ('(', 'normal'), + ('None', 'keyword'), (')\n\n', 'normal'), + (' error ', 'error'), (' ', 'normal'), + ('cursor |', 'cursor'), ('\n ', 'normal'), + ('shell', 'console'), (' ', 'normal'), + ('stdout', 'stdout'), (' ', 'normal'), + ('stderr', 'stderr'), ('\n', 'normal')) + + +class KeysPane(PreferencesPane): + """ + TODO + """ + def __init__(self, parent, owner): + PreferencesPane.__init__(self, parent, owner) + ttk.Label(self, text='Keys').grid() + + +class GeneralPane(PreferencesPane): + """ + Preference pane for (generally) random miscellaneous things that + don't fit elsewhere. + + Of note regarding the extra help sources: unlike with themes and + keys, where we do commit changes to preferences when we add or + delete items, for help sources everything is kept in the pending + changes storage until changes are applied. Therefore, we only + query the preferences store when first loading the dialog, and + our own 'helplist' becomes effectively the reference copy while + editing. + """ + def __init__(self, parent, owner): + PreferencesPane.__init__(self, parent, owner) + + self.userHelpBrowser = BooleanVar(parent) + self.helpBrowser = StringVar(parent) + + self.configure(padding=10) + self.grid_rowconfigure(0, pad=5) + self.grid_rowconfigure(1, pad=5) + self.grid_rowconfigure(2, pad=5) + self.grid_columnconfigure(15, weight=1) + + # shell vs. edit + self.editor_startup_v = self.register_prefvar('main', 'General', + 'editor-on-startup', varclass=IntVar) + open_editor = ttk.Radiobutton(self, text='Editor', + variable=self.editor_startup_v, value=1) + open_shell = ttk.Radiobutton(self, text='Shell', + variable=self.editor_startup_v, value=0) + startup_l = ttk.Label(self, text='Window to open at startup:') + startup_l.grid(column=0, row=0, sticky=E, padx=[0,10], columnspan=2) + open_editor.grid(column=11, row=0, columnspan=2, sticky=W) + open_shell.grid(column=13, row=0, columnspan=2, sticky=W) + + # prompt to save unsaved files before running + self.autosave_v = self.register_prefvar('main', 'General', 'autosave', + varclass=IntVar) + autosave = ttk.Checkbutton(self, text='Prompt to save', + variable=self.autosave_v, onvalue=1, offvalue=0) + autosave_l = ttk.Label(self, text='Before running unsaved files:') + autosave_l.grid(column=0, row=1, sticky=E, padx=[0,10], columnspan=2) + autosave.grid(column=11, row=1, columnspan=4, sticky=W) + + # window size + self.width_v = self.register_prefvar('main', 'EditorWindow', 'width') + self.height_v = self.register_prefvar('main', 'EditorWindow', 'height') + winsize_l = ttk.Label(self, text='Initial window size (characters):') + width = ttk.Entry(self, width=3, textvariable=self.width_v) + width_l = ttk.Label(self, text='Width:') + height = ttk.Entry(self, width=3, textvariable=self.height_v) + height_l = ttk.Label(self, text='Height:') + winsize_l.grid(column=0, row=2, sticky=E, padx=[0,10], columnspan=2) + width_l.grid(column=11, row=2, sticky=W) + width.grid(column=12, row=2, sticky=W) + height_l.grid(column=13, row=2, sticky=W) + height.grid(column=14, row=2, sticky=W) + + # help sources list + ttk.Separator(self).grid(column=0, row=4, sticky=EW, columnspan=20, + pady=10) + help_l = ttk.Label(self, text='Additional Help Sources:') + self.cur_helpidx = None + self.helpitems = idleConf.GetAllExtraHelpSourcesList() + self.helpsrc = Listbox(self, height=5, exportselection=FALSE, + activestyle='none') + for item in self.helpitems: + self.helpsrc.insert(END, item[0]) + self.helpsrc.bind('<>', self.helpsrc_changed) + help_l.grid(column=0, row=5, sticky=W, columnspan=2) + self.helpsrc.grid(column=0, row=6, sticky='nsew', rowspan=5, + columnspan=2) + scroll = ttk.Scrollbar(self, command=self.helpsrc.yview) + self.helpsrc.yscrollcommand = scroll.set + scroll.grid(column=2, row=6, rowspan=5, sticky='ns', padx=[0,10]) + + # details for help source + menuname_l = ttk.Label(self, text='Label for menu:') + self.menuname_v = StringVar(self) + self.menuname = ttk.Entry(self, textvariable=self.menuname_v) + url_l = ttk.Label(self, text="URL (or 'Browse' for file):") + self.url_v = StringVar(self) + self.url = ttk.Entry(self, textvariable=self.url_v) + self.menuname_v.trace_variable('w', lambda v, i, o: + self.help_details_changed()) + self.url_v.trace_variable('w', lambda v, i, o: + self.help_details_changed()) + self.browse = ttk.Button(self, text='Browse', command=self.browsehelp) + menuname_l.grid(column=11, row=6, columnspan=5, sticky=W) + self.menuname.grid(column=11, row=7, columnspan=5, sticky=EW) + url_l.grid(column=11, row=8, columnspan=5, sticky=W, pady=[6,0]) + self.url.grid(column=11, row=9, columnspan=5, sticky=EW) + self.browse.grid(column=16, row=9, sticky=W) + + # buttons to add and delete + self.new_button = ttk.Button(self, text='New', command=self.newhelp) + self.del_button = ttk.Button(self, text='Delete', state='disabled', + command=self.deletehelp) + self.new_button.grid(column=0, row=20) + self.del_button.grid(column=1, row=20, padx=[0,10]) + + self.configure(padding=6) + + def helpsrc_changed(self, event=None): + if self.helpsrc.curselection() != (): + idx = self.cur_helpidx = self.helpsrc.curselection()[0] + self.menuname_v.set(self.helpitems[idx][0]) + self.url_v.set(self.helpitems[idx][1]) + self.menuname.selection_clear() + self.url.selection_clear() + state = 'normal' + else: + self.cur_helpidx = None + self.menuname_v.set('') + self.url_v.set('') + state = 'disabled' + self.del_button['state'] = state + self.browse['state'] = state + self.url['state'] = state + self.menuname['state'] = state + + def newhelp(self): + self.helpitems.append(('Help', '')) + self.helpsrc.insert(END, 'Help') + self.helpsrc.selection_clear(0, END) + self.helpsrc.selection_set(END, END) + self.helpsrc_changed() + self.menuname.selection_range(0, END) + self.menuname.icursor(END) + self.menuname.focus() + self.update_preferences() + + def deletehelp(self): + if self.cur_helpidx is not None: + delmsg = ('Are you sure you wish to remove this source ' + + 'from the Help menu?') + if messagebox.askyesno('Delete Source', delmsg, parent=self): + del(self.helpitems[self.cur_helpidx]) + self.helpsrc.delete(self.cur_helpidx) + self.helpsrc.selection_clear(0, END) + self.helpsrc_changed() + self.update_preferences() + + def help_details_changed(self): + if self.cur_helpidx is not None: + self.helpitems[self.cur_helpidx] = (self.menuname_v.get(), + self.url_v.get()) + self.helpsrc.delete(self.cur_helpidx) + self.helpsrc.insert(self.cur_helpidx, self.menuname_v.get()) + self.helpsrc.selection_clear(0, END) + self.helpsrc.selection_set(self.cur_helpidx, self.cur_helpidx) + self.update_preferences() + + def browsehelp(self): + filetypes = [ + ("HTML Files", "*.htm *.html", "TEXT"), + ("PDF Files", "*.pdf", "TEXT"), + ("Windows Help Files", "*.chm"), + ("Text Files", "*.txt", "TEXT"), + ("All Files", "*")] + path = self.url_v.get() + if path: + dir, base = os.path.split(path) + else: + base = None + if sys.platform[:3] == 'win': + dir = os.path.join(os.path.dirname(sys.executable), 'Doc') + if not os.path.isdir(dir): + dir = os.getcwd() + else: + dir = os.getcwd() + opendialog = filedialog.Open(parent=self, filetypes=filetypes) + file = opendialog.show(initialdir=dir, initialfile=base) + if file: + self.url_v.set(file) + + def update_preferences(self): + "Rebuild pending changes to preferences to match our internal list." + self.cancel_pending_change('main', 'HelpFiles') + for num in range(1, len(self.helpitems) + 1): + self.preference_changed('main', 'HelpFiles', str(num), + ';'.join(self.helpitems[num-1][:2])) + + + +class ExtensionsPane(PreferencesPane): + def __init__(self, parent, owner): + PreferencesPane.__init__(self, parent, owner) + + self.defaultCfg = idleConf.defaultCfg['extensions'] + self.userCfg = idleConf.userCfg['extensions'] + self.load_extensions() + self.extension_names = StringVar(self) + self.rowconfigure(0, weight=1) + self.columnconfigure(2, weight=1) + self.extension_list = Listbox(self, listvariable=self.extension_names, + selectmode='browse') + self.extension_list.bind('<>', self.extension_selected) + scroll = ttk.Scrollbar(self, command=self.extension_list.yview) + self.extension_list.yscrollcommand = scroll.set + self.details_frame = ttk.LabelFrame(self, width=275, height=250) + self.extension_list.grid(column=0, row=0, sticky='nws') + scroll.grid(column=1, row=0, sticky='ns') + self.details_frame.grid(column=2, row=0, sticky='nsew', padx=[10,0]) + self.details_frame.grid_propagate(0) + self.config_frame = {} + self.current_extension = None + ext_names = '' + for ext_name in sorted(self.extensions): + self.create_extension_frame(ext_name) + ext_names = ext_names + '{' + ext_name + '} ' + self.extension_names.set(ext_names) + self.extension_list.selection_set(0) + self.extension_selected(None) + self.configure(padding=6) + + def extension_selected(self, event): + newsel = self.extension_list.curselection() + if newsel: + newsel = self.extension_list.get(newsel) + if newsel is None or newsel != self.current_extension: + if self.current_extension: + self.details_frame.config(text='') + self.config_frame[self.current_extension].grid_forget() + self.current_extension = None + if newsel: + self.details_frame.config(text=newsel) + self.config_frame[newsel].grid(column=0, row=0, sticky='nw') + self.current_extension = newsel + + def create_extension_frame(self, ext_name): + """Create a frame holding the widgets to configure one extension""" + f = ttk.Frame(self.details_frame, padding=10) + self.config_frame[ext_name] = f + entry_area = f + # create an entry for each configuration option + for row, opt in enumerate(self.extensions[ext_name]): + # create a row with a label and entry/checkbutton + label = ttk.Label(entry_area, text=opt['name']) + label.grid(row=row, column=0, sticky=NW) + var = opt['var'] + if opt['type'] == 'bool': + Checkbutton(entry_area, textvariable=var, variable=var, + onvalue='True', offvalue='False', + indicatoron=FALSE, selectcolor='', width=8 + ).grid(row=row, column=1, sticky=W, padx=7) + elif opt['type'] == 'int': + ttk.Entry(entry_area, textvariable=var, validate='key', + validatecommand=(self.is_int, '%P') + ).grid(row=row, column=1, sticky=NSEW, padx=7) + + else: + ttk.Entry(entry_area, textvariable=var + ).grid(row=row, column=1, sticky=NSEW, padx=7) + return + + def is_int(s): + "Return 's is blank or represents an int'" + if not s: + return True + try: + int(s) + return True + except ValueError: + return False + + def load_extensions(self): + "Fill self.extensions with data from the default and user configs." + self.extensions = {} + for ext_name in idleConf.GetExtensions(active_only=False): + self.extensions[ext_name] = [] + + for ext_name in self.extensions: + opt_list = sorted(self.defaultCfg.GetOptionList(ext_name)) + + # bring 'enable' options to the beginning of the list + enables = [opt_name for opt_name in opt_list + if opt_name.startswith('enable')] + for opt_name in enables: + opt_list.remove(opt_name) + opt_list = enables + opt_list + + for opt_name in opt_list: + def_str = self.defaultCfg.Get( + ext_name, opt_name, raw=True) + try: + def_obj = {'True':True, 'False':False}[def_str] + opt_type = 'bool' + except KeyError: + try: + def_obj = int(def_str) + opt_type = 'int' + except ValueError: + def_obj = def_str + opt_type = None + try: + value = self.userCfg.Get( + ext_name, opt_name, type=opt_type, raw=True, + default=def_obj) + except ValueError: # Need this until .Get fixed + value = def_obj # bad values overwritten by entry + var = StringVar(self) + var.set(str(value)) + + self.extensions[ext_name].append({'name': opt_name, + 'type': opt_type, + 'default': def_str, + 'value': value, + 'var': var, + }) + + +if __name__ == '__main__': + root = Tk() + uifactory.initialize(root) + root.wm_withdraw() + dlg = PreferencesDialog(parent=root, destroy_callback=sys.exit) + root.mainloop()