1import textwrap
2import tkinter as tk
3from tkinter import font as tk_font
4from tkinter import ttk
5
6from thonny import get_workbench, tktextext, ui_utils
7from thonny.codeview import CodeView
8from thonny.config_ui import ConfigurationPage
9from thonny.languages import tr
10from thonny.shell import BaseShellText
11from thonny.ui_utils import create_string_var, scrollbar_style
12
13
14class ThemeAndFontConfigurationPage(ConfigurationPage):
15    def __init__(self, master):
16
17        ConfigurationPage.__init__(self, master)
18
19        self._init_themes()
20        self._init_fonts()
21        self._init_previews()
22
23        self.columnconfigure(2, weight=1)
24        self.columnconfigure(4, weight=1)
25
26        self.rowconfigure(31, weight=1)
27        self.rowconfigure(21, weight=1)
28
29    def _init_themes(self):
30        self._original_ui_theme = get_workbench().get_option("view.ui_theme")
31        self._original_syntax_theme = get_workbench().get_option("view.syntax_theme")
32
33        self._ui_theme_variable = create_string_var(
34            self._original_ui_theme, modification_listener=self._update_appearance
35        )
36        self._syntax_theme_variable = create_string_var(
37            self._original_syntax_theme, modification_listener=self._update_appearance
38        )
39
40        ttk.Label(self, text=tr("UI theme")).grid(
41            row=1, column=1, sticky="w", pady=(0, 10), padx=(0, 5)
42        )
43        self._ui_theme_combo = ttk.Combobox(
44            self,
45            exportselection=False,
46            textvariable=self._ui_theme_variable,
47            state="readonly",
48            height=15,
49            values=get_workbench().get_usable_ui_theme_names(),
50        )
51        self._ui_theme_combo.grid(row=1, column=2, sticky="nwe", pady=(0, 5))
52
53        ttk.Label(self, text=tr("Syntax theme")).grid(
54            row=2, column=1, sticky="w", pady=(0, 10), padx=(0, 5)
55        )
56        self._syntax_theme_combo = ttk.Combobox(
57            self,
58            exportselection=False,
59            textvariable=self._syntax_theme_variable,
60            state="readonly",
61            height=15,
62            values=get_workbench().get_syntax_theme_names(),
63        )
64        self._syntax_theme_combo.grid(row=2, column=2, sticky="nwe", pady=(0, 5))
65
66    def _init_fonts(self):
67        self._original_editor_family = get_workbench().get_option("view.editor_font_family")
68        self._original_editor_size = get_workbench().get_option("view.editor_font_size")
69        self._original_io_family = get_workbench().get_option("view.io_font_family")
70        self._original_io_size = get_workbench().get_option("view.io_font_size")
71
72        self._editor_family_variable = create_string_var(
73            self._original_editor_family, modification_listener=self._update_appearance
74        )
75        self._editor_size_variable = create_string_var(
76            self._original_editor_size, modification_listener=self._update_appearance
77        )
78        self._io_family_variable = create_string_var(
79            self._original_io_family, modification_listener=self._update_appearance
80        )
81        self._io_size_variable = create_string_var(
82            self._original_io_size, modification_listener=self._update_appearance
83        )
84
85        ttk.Label(self, text=tr("Editor font")).grid(
86            row=1, column=3, sticky="w", pady=(0, 5), padx=(25, 5)
87        )
88        editor_family_combo = ttk.Combobox(
89            self,
90            exportselection=False,
91            state="readonly",
92            height=15,
93            textvariable=self._editor_family_variable,
94            values=self._get_families_to_show(),
95        )
96        editor_family_combo.grid(row=1, column=4, sticky="nwe", pady=(0, 5))
97        editor_size_combo = ttk.Combobox(
98            self,
99            width=4,
100            exportselection=False,
101            textvariable=self._editor_size_variable,
102            state="readonly",
103            height=15,
104            values=[str(x) for x in range(3, 73)],
105        )
106        editor_size_combo.grid(row=1, column=5, sticky="nwe", pady=(0, 5), padx=(5, 0))
107
108        ttk.Label(self, text=tr("IO font")).grid(
109            row=2, column=3, sticky="w", pady=(0, 5), padx=(25, 5)
110        )
111        io_family_combo = ttk.Combobox(
112            self,
113            exportselection=False,
114            state="readonly",
115            height=15,
116            textvariable=self._io_family_variable,
117            values=self._get_families_to_show(),
118        )
119        io_family_combo.grid(row=2, column=4, sticky="nwe", pady=(0, 5))
120
121        io_size_combo = ttk.Combobox(
122            self,
123            width=4,
124            exportselection=False,
125            textvariable=self._io_size_variable,
126            state="readonly",
127            height=15,
128            values=[str(x) for x in range(3, 73)],
129        )
130        io_size_combo.grid(row=2, column=5, sticky="nwe", pady=(0, 5), padx=(5, 0))
131
132    def _init_previews(self):
133        ttk.Label(self, text=tr("Preview")).grid(
134            row=20, column=1, sticky="w", pady=(5, 2), columnspan=5
135        )
136        self._preview_codeview = CodeView(
137            self, height=6, font="EditorFont", relief="groove", borderwidth=1, line_numbers=True
138        )
139
140        self._preview_codeview.set_content(
141            textwrap.dedent(
142                """
143            def foo(bar):
144                if bar is None: # """
145                + tr("This is a comment")
146                + """
147                    print('"""
148                + tr("The answer is")
149                + """', 33)
150
151            """
152                + tr("unclosed_string")
153                + ''' = "'''
154                + tr("blah, blah")
155                + "\n"
156            ).strip()
157        )
158        self._preview_codeview.grid(row=21, column=1, columnspan=5, sticky=tk.NSEW)
159
160        self._shell_preview = tktextext.TextFrame(
161            self,
162            text_class=BaseShellText,
163            height=4,
164            vertical_scrollbar_style=scrollbar_style("Vertical"),
165            horizontal_scrollbar_style=scrollbar_style("Horizontal"),
166            horizontal_scrollbar_class=ui_utils.AutoScrollbar,
167            relief="groove",
168            borderwidth=1,
169            font="EditorFont",
170        )
171        self._shell_preview.grid(row=31, column=1, columnspan=5, sticky=tk.NSEW, pady=(5, 5))
172        self._shell_preview.text.set_read_only(True)
173        self._insert_shell_text()
174
175        ttk.Label(
176            self,
177            text=tr("NB! Some style elements change only after restarting Thonny!"),
178            font="BoldTkDefaultFont",
179        ).grid(row=40, column=1, columnspan=5, sticky="w", pady=(5, 0))
180
181    def apply(self):
182        # don't do anything, as preview already did the thing
183        return
184
185    def cancel(self):
186        if (
187            getattr(self._editor_family_variable, "modified")
188            or getattr(self._editor_size_variable, "modified")
189            or getattr(self._ui_theme_variable, "modified")
190            or getattr(self._syntax_theme_variable, "modified")
191        ):
192            get_workbench().set_option("view.ui_theme", self._original_ui_theme)
193            get_workbench().set_option("view.syntax_theme", self._original_syntax_theme)
194            get_workbench().set_option("view.editor_font_size", self._original_editor_size)
195            get_workbench().set_option("view.editor_font_family", self._original_editor_family)
196            get_workbench().set_option("view.io_font_size", self._original_io_size)
197            get_workbench().set_option("view.io_font_family", self._original_io_family)
198            get_workbench().reload_themes()
199            get_workbench().update_fonts()
200
201    def _update_appearance(self):
202        get_workbench().set_option("view.ui_theme", self._ui_theme_variable.get())
203        get_workbench().set_option("view.syntax_theme", self._syntax_theme_variable.get())
204        get_workbench().set_option("view.editor_font_size", int(self._editor_size_variable.get()))
205        get_workbench().set_option("view.editor_font_family", self._editor_family_variable.get())
206        get_workbench().set_option("view.io_font_size", int(self._io_size_variable.get()))
207        get_workbench().set_option("view.io_font_family", self._io_family_variable.get())
208        get_workbench().reload_themes()
209        get_workbench().update_fonts()
210
211    def _insert_shell_text(self):
212        text = self._shell_preview.text
213        text._insert_prompt()
214        text.direct_insert("end", "%Run demo.py\n", ("magic", "before_io"))
215        text.tag_add("before_io", "1.0", "1.0 lineend")
216        text.direct_insert("end", tr("Enter an integer") + ": ", ("io", "stdout"))
217        text.direct_insert("end", "2.5\n", ("io", "stdin"))
218        text.direct_insert(
219            "end", "ValueError: invalid literal for int() with base 10: '2.5'\n", ("io", "stderr")
220        )
221
222    def _get_families_to_show(self):
223        # In Linux, families may contain duplicates (actually different fonts get same names)
224        return sorted(set(filter(lambda name: name[0].isalpha(), tk_font.families())))
225
226
227def load_plugin() -> None:
228    get_workbench().add_configuration_page(
229        "theme", tr("Theme & Font"), ThemeAndFontConfigurationPage, 40
230    )
231