Coverage for src/robotide/editor/customsourceeditor.py: 14%
280 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-06 10:40 +0100
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-06 10:40 +0100
1#!/usr/bin/env python
2# -*- encoding: utf-8 -*-
3#
4# Created by Hélio Guilherme <helioxentric@gmail.com>
5# Copyright 2016- Robot Framework Foundation
6#
7# Licensed under the Apache License, Version 2.0 (the "License");
8# you may not use this file except in compliance with the License.
9# You may obtain a copy of the License at
10#
11# http://www.apache.org/licenses/LICENSE-2.0
12#
13# Unless required by applicable law or agreed to in writing, software
14# distributed under the License is distributed on an "AS IS" BASIS,
15# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16# See the License for the specific language governing permissions and
17# limitations under the License.
20import os 1ab
21import sys 1ab
23import wx 1ab
24import wx.stc as stc 1ab
25from robotide.editor.pythoneditor import PythonSTC 1ab
26from wx import Colour 1ab
27from ..widgets import ImageProvider 1ab
29# ---------------------------------------------------------------------------
30# This is how you pre-establish a file filter so that the dialog
31# only shows the extension(s) you want it to.
32wildcard = "All files (*.*)|*.*|" \ 1ab
33 "JASON file (*.json)|*.json|" \
34 "Python source (*.py)|*.py|" \
35 "Robot Framework (*.robot)|*.robot|" \
36 "Robot Framework (*.txt)|*.txt|" \
37 "YAML file (*.yaml)|*.yaml"
38# ----------------------------------------------------------------------
40class SourceCodeEditor(PythonSTC): 1ab
41 def __init__(self, parent, options, style=wx.BORDER_NONE): 1ab
42 PythonSTC.__init__(self, parent, -1, options, style=style)
43 self.SetUpEditor()
45 # Some methods to make it compatible with how the wxTextCtrl is used
46 def SetValue(self, value): 1ab
47 val = self.GetReadOnly()
48 self.SetReadOnly(False)
49 self.SetText(value)
50 self.EmptyUndoBuffer()
51 self.SetSavePoint()
52 self.SetReadOnly(val)
54 def SetEditable(self, val): 1ab
55 self.SetReadOnly(not val)
57 def IsModified(self): 1ab
58 return self.GetModify()
60 def Clear(self): 1ab
61 self.ClearAll()
63 def SetInsertionPoint(self, pos): 1ab
64 self.SetCurrentPos(pos)
65 self.SetAnchor(pos)
67 def ShowPosition(self, pos): 1ab
68 line = self.LineFromPosition(pos)
69 # self.EnsureVisible(line)
70 self.GotoLine(line)
72 def GetLastPosition(self): 1ab
73 return self.GetLength()
75 def GetPositionFromLine(self, line): 1ab
76 return self.PositionFromLine(line)
78 def GetRange(self, start, end): 1ab
79 return self.GetTextRange(start, end)
81 def GetSelection(self): 1ab
82 return self.GetAnchor(), self.GetCurrentPos()
84 def SetSelection(self, start, end): 1ab
85 self.SetSelectionStart(start)
86 self.SetSelectionEnd(end)
88 def SelectLine(self, line): 1ab
89 start = self.PositionFromLine(line)
90 end = self.GetLineEndPosition(line)
91 self.SetSelection(start, end)
93 def SetUpEditor(self): 1ab
94 """
95 This method carries out the work of setting up the Code editor.
96 It's seperate so as not to clutter up the init code.
97 """
98 import keyword
100 self.SetLexer(stc.STC_LEX_PYTHON)
101 self.SetKeyWords(0, " ".join(keyword.kwlist))
103 # Enable folding
104 self.SetProperty("fold", "1")
106 # Highlight tab/space mixing (shouldn't be any)
107 self.SetProperty("tab.timmy.whinge.level", "1")
109 # Set left and right margins
110 self.SetMargins(2, 2)
112 # Set up the numbers in the margin for margin #1
113 self.SetMarginType(1, wx.stc.STC_MARGIN_NUMBER)
114 # Reasonable value for, say, 4-5 digits using a mono font (40 pix)
115 self.SetMarginWidth(1, 40)
117 # Indentation and tab stuff
118 self.SetIndent(4) # Proscribed indent size for wx
119 self.SetIndentationGuides(True) # Show indent guides
120 self.SetBackSpaceUnIndents(True) # Backspace unindents rather than delete 1 space
121 self.SetTabIndents(True) # Tab key indents
122 self.SetTabWidth(4) # Proscribed tab size for wx
123 self.SetUseTabs(False) # Use spaces rather than tabs, or TabTimmy will complain!
124 # White space
125 self.SetViewWhiteSpace(False) # Don't view white space
127 # EOL: Since we are loading/saving ourselves, and the
128 # strings will always have \n's in them, set the STC to
129 # edit them that way.
130 self.SetEOLMode(wx.stc.STC_EOL_LF)
131 self.SetViewEOL(False)
133 # No right-edge mode indicator
134 self.SetEdgeMode(stc.STC_EDGE_NONE)
136 # Set up a margin to hold fold markers
137 self.SetMarginType(2, stc.STC_MARGIN_SYMBOL)
138 self.SetMarginMask(2, stc.STC_MASK_FOLDERS)
139 self.SetMarginSensitive(2, True)
140 self.SetMarginWidth(2, 12)
142 # Global default style
143 if wx.Platform == '__WXMSW__':
144 self.StyleSetSpec(stc.STC_STYLE_DEFAULT, 'fore:#000000,back:#FFFFFF,face:Space Mono') # Courier New
145 elif wx.Platform == '__WXMAC__':
146 # DEBUG: if this looks fine on Linux too, remove the Mac-specific case
147 # and use this whenever OS != MSW.
148 self.StyleSetSpec(stc.STC_STYLE_DEFAULT,
149 'fore:#000000,back:#FFFFFF,face:Monaco')
150 else:
151 # print("DEBUG: Setup on Linux")
152 defsize = wx.SystemSettings.GetFont(wx.SYS_ANSI_FIXED_FONT).GetPointSize()
153 # Courier, Space Mono, Source Pro Mono,
154 self.StyleSetSpec(stc.STC_STYLE_DEFAULT, 'fore:#000000,back:#FFFFFF,face:Hack,size:%d' % defsize)
155 """
156 self.StyleSetBackground(stc.STC_STYLE_DEFAULT, Colour(200, 222, 40))
157 self.StyleSetForeground(stc.STC_STYLE_DEFAULT, Colour(7, 0, 70))
158 """
159 # Clear styles and revert to default.
160 self.StyleClearAll()
162 # Following style specs only indicate differences from default.
163 # The rest remains unchanged.
165 # Line numbers in margin
166 self.StyleSetSpec(wx.stc.STC_STYLE_LINENUMBER, 'fore:#000000,back:#99A9C2')
167 # Highlighted brace
168 self.StyleSetSpec(wx.stc.STC_STYLE_BRACELIGHT, 'fore:#00009D,back:#FFFF00')
169 # Unmatched brace
170 self.StyleSetSpec(wx.stc.STC_STYLE_BRACEBAD, 'fore:#00009D,back:#FF0000')
171 # Indentation guide
172 self.StyleSetSpec(wx.stc.STC_STYLE_INDENTGUIDE, "fore:#CDCDCD")
174 # Python styles
175 self.StyleSetSpec(wx.stc.STC_P_DEFAULT, 'fore:#000000')
176 # Comments
177 self.StyleSetSpec(wx.stc.STC_P_COMMENTLINE, 'fore:#008000,back:#F0FFF0')
178 self.StyleSetSpec(wx.stc.STC_P_COMMENTBLOCK, 'fore:#008000,back:#F0FFF0')
179 # Numbers
180 self.StyleSetSpec(wx.stc.STC_P_NUMBER, 'fore:#008080')
181 # Strings and characters
182 self.StyleSetSpec(wx.stc.STC_P_STRING, 'fore:#800080')
183 self.StyleSetSpec(wx.stc.STC_P_CHARACTER, 'fore:#800080')
184 # Keywords
185 self.StyleSetSpec(wx.stc.STC_P_WORD, 'fore:#000080,bold')
186 # Triple quotes
187 self.StyleSetSpec(wx.stc.STC_P_TRIPLE, 'fore:#800080,back:#FFFFEA')
188 self.StyleSetSpec(wx.stc.STC_P_TRIPLEDOUBLE, 'fore:#800080,back:#FFFFEA')
189 # Class names
190 self.StyleSetSpec(wx.stc.STC_P_CLASSNAME, 'fore:#0000FF,bold')
191 # Function names
192 self.StyleSetSpec(wx.stc.STC_P_DEFNAME, 'fore:#008080,bold')
193 # Operators
194 self.StyleSetSpec(wx.stc.STC_P_OPERATOR, 'fore:#800000,bold')
195 # Identifiers. I leave this as not bold because everything seems
196 # to be an identifier if it doesn't match the above criterae
197 self.StyleSetSpec(wx.stc.STC_P_IDENTIFIER, 'fore:#000000')
199 # Caret color
200 self.SetCaretForeground("BLUE")
201 # Selection background
202 # self.SetSelBackground(1, '#66CCFF')
203 """
204 self.SetBackgroundColour(Colour(200, 222, 40))
205 self.SetForegroundColour(Colour(7, 0, 70))
206 """
208 self.SetSelBackground(True, wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT))
209 self.SetSelForeground(True, wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHTTEXT))
211 def RegisterModifiedEvent(self, event_handler): 1ab
212 self.Bind(wx.stc.EVT_STC_CHANGE, event_handler)
215# ---------------------------------------------------------------------------
216# Constants for module versions
218modOriginal = 0 1ab
219modModified = 1 1ab
220modDefault = modOriginal 1ab
222# ---------------------------------------------------------------------------
225def is_utf8_strict(data): 1ab
226 try:
227 decoded = data.decode('UTF-8')
228 except UnicodeDecodeError:
229 return False
230 else:
231 for ch in decoded:
232 if 0xD800 <= ord(ch) <= 0xDFFF:
233 return False
234 return True
237class CodeEditorPanel(wx.Panel): 1ab
238 """Panel for the 'Code Editor' tab"""
239 def __init__(self, parent, main_frame, filepath=None): 1ab
240 self.log = sys.stdout # From FileDialog
241 self.path = filepath
242 self.parent = parent
243 wx.Panel.__init__(self, parent, size=(1, 1))
244 self.mainFrame = main_frame
245 self.editor = SourceCodeEditor(self, options={'tab markers':True, 'fold symbols':2})
246 self.editor.RegisterModifiedEvent(self.on_code_modified)
247 parent.SetName(f'Code Editor: {filepath}')
248 """
249 self.SetBackgroundColour(Colour(200, 222, 40))
250 self.SetOwnBackgroundColour(Colour(200, 222, 40))
251 self.SetForegroundColour(Colour(7, 0, 70))
252 self.SetOwnForegroundColour(Colour(7, 0, 70))
253 """
255 self.btnSave = wx.Button(self, -1, "Save Changes")
256 self.btnSave.Enable(False)
257 self.btnSave.Bind(wx.EVT_BUTTON, self.on_save)
259 # From FileDialog
260 self.btnOpen = wx.Button(self, -1, "Open...")
261 self.btnOpen.Bind(wx.EVT_BUTTON, self.on_button)
263 self.btnSaveAs = wx.Button(self, -1, "Save as...")
264 self.btnSaveAs.Bind(wx.EVT_BUTTON, self.on_button2)
265 self.controlBox = wx.BoxSizer(wx.HORIZONTAL)
266 self.controlBox.Add(self.btnSave, 0, wx.RIGHT, 5)
267 self.controlBox.Add(self.btnOpen, 0, wx.RIGHT, 5)
268 self.controlBox.Add(self.btnSaveAs, 0)
270 self.box = wx.BoxSizer(wx.VERTICAL)
271 self.box.Add(self.controlBox, 0, wx.EXPAND)
272 self.box.Add(wx.StaticLine(self), 0, wx.EXPAND)
273 self.box.Add(self.editor, 1, wx.EXPAND)
275 self.box.Fit(self)
276 self.SetSizer(self.box)
277 if self.path:
278 # print("DEBUG: path is init = %s" % self.path)
279 self.LoadFile(self.path)
281 def LoadFile(self, filepath): 1ab
282 # Open
283 f = open(filepath, "rb")
284 try:
285 source = f.read()
286 finally:
287 f.close()
288 self.LoadSource(source)
290 def LoadSource(self, source): 1ab
291 self.editor.Clear()
292 self.editor.SetTextRaw(source) # DEBUG SetValue
293 self.JumpToLine(0)
294 self.btnSave.Enable(False)
296 def JumpToLine(self, line, highlight=False): 1ab
297 self.editor.GotoLine(line)
298 self.editor.SetFocus()
299 if highlight:
300 self.editor.SelectLine(line)
302 def on_code_modified(self, event): 1ab
303 __ = event
304 self.btnSave.Enable(self.editor.IsModified())
306 def on_save(self, event, filepath=None): 1ab
307 if self.path is None:
308 self.path = "noname"
309 self.on_button2(event)
310 return
311 if filepath:
312 if filepath != self.path and os.path.isfile(filepath):
313 overwrite_msg = "You are about to overwrite an existing file\n" + \
314 "Do you want to continue?"
315 dlg = wx.MessageDialog(self, overwrite_msg, "Editor Writer",
316 wx.YES_NO | wx.NO_DEFAULT | wx.ICON_EXCLAMATION)
317 dlg.SetBackgroundColour(Colour(200, 222, 40))
318 dlg.SetForegroundColour(Colour(7, 0, 70))
319 result = dlg.ShowModal()
320 if result == wx.ID_NO:
321 return
322 dlg.Destroy()
323 self.path = filepath
325 # Save
326 f = open(self.path, "wb")
327 source = self.editor.GetTextRaw()
328 # print("DEBUG: Test is Unicode %s",isUTF8Strict(source))
329 if is_utf8_strict(source):
330 try:
331 f.write(source)
332 # print("DEBUG: Saved as Unicode")
333 finally:
334 f.close()
335 else:
336 # print("DEBUG: there were problems with source not being Unicode.")
337 # Attempt to isolate the problematic bytes
338 # DEBUG bytearray(source)
339 try:
340 chunksize = 1024
341 for c in range(0, len(source), chunksize):
342 data = [chr(int(x, base=2)) for x in source[c:c + chunksize]]
343 f.write(b''.join(data))
344 finally:
345 f.close()
347 def on_button(self, evt): 1ab
348 _ = evt
349 # Create the dialog. In this case the current directory is forced as the starting
350 # directory for the dialog, and no default file name is forced. This can easilly
351 # be changed in your program. This is an 'open' dialog, and allows multitple
352 # file selections as well.
353 #
354 # Finally, if the directory is changed in the process of getting files, this
355 # dialog is set up to change the current working directory to the path chosen.
356 if self.path:
357 cwd = os.path.dirname(self.path)
358 else:
359 cwd = os.getcwd()
360 dlg = wx.FileDialog(
361 self, message="Choose a file",
362 defaultDir=cwd,
363 defaultFile="",
364 wildcard=wildcard,
365 style=wx.FD_OPEN | wx.FD_CHANGE_DIR | wx.FD_FILE_MUST_EXIST | wx.FD_PREVIEW) # wx.FD_MULTIPLE |
367 # Show the dialog and retrieve the user response. If it is the OK response,
368 # process the data.
369 if dlg.ShowModal() == wx.ID_OK:
370 # This returns a Python list of files that were selected.
371 paths = dlg.GetPaths()
373 # self.log.WriteText('You selected %d files:' % len(paths))
374 # DEBUG self.log.write('You selected %d files:' % len(paths))
376 filepath = paths[-1] # just get the last one
377 # Open
378 f = open(filepath, "rb")
379 try:
380 source = f.read()
381 finally:
382 f.close()
384 # store the new path
385 self.path = filepath
386 # self.log.write('%s\n' % source)
387 self.LoadSource(source) # Just the last file
388 self.parent.SetTitle(filepath)
389 # Compare this with the debug above; did we change working dirs?
390 # self.log.WriteText("CWD: %s\n" % os.getcwd())
391 # self.log.write("CWD: %s\n" % os.getcwd())
393 # Destroy the dialog. Don't do this until you are done with it!
394 # BAD things can happen otherwise!
395 dlg.Destroy()
397 def on_button2(self, evt): 1ab
398 # Create the dialog. In this case the current directory is forced as the starting
399 # directory for the dialog, and no default file name is forced. This can easilly
400 # be changed in your program. This is an 'save' dialog.
401 #
402 # Unlike the 'open dialog' example found elsewhere, this example does NOT
403 # force the current working directory to change if the user chooses a different
404 # directory than the one initially set.
405 fname = ""
406 if self.path:
407 cwd = os.path.dirname(self.path)
408 fname = os.path.basename(self.path)
409 else:
410 cwd = os.getcwd()
411 self.path = "noname"
412 dlg = wx.FileDialog(
413 self, message="Save file as ...", defaultDir=cwd,
414 defaultFile=fname, wildcard=wildcard, style=wx.FD_SAVE
415 ) # | wx.FD_OVERWRITE_PROMPT
417 # This sets the default filter that the user will initially see. Otherwise,
418 # the first filter in the list will be used by default.
419 # dlg.SetFilterIndex(2)
421 # Show the dialog and retrieve the user response. If it is the OK response,
422 # process the data.
423 if dlg.ShowModal() == wx.ID_OK:
424 filepath = dlg.GetPath()
425 # Normally, at this point you would save your data using the file and path
426 # data that the user provided to you, but since we didn't actually start
427 # with any data to work with, that would be difficult.
428 #
429 # The code to do so would be similar to this, assuming 'data' contains
430 # the data you want to save:
431 #
432 # fp = file(path, 'w') # Create file anew
433 # fp.write(data)
434 # fp.close()
435 #
436 # You might want to add some error checking :-)
437 #
438 # store the new path
439 # self.path = path
440 self.on_save(evt, filepath)
441 # Note that the current working dir didn't change. This is good since
442 # that's the way we set it up.
443 # self.log.WriteText("CWD: %s\n" % os.getcwd())
444 # self.log.write("CWD: %s\n" % os.getcwd())
446 # Destroy the dialog. Don't do this until you are done with it!
447 # BAD things can happen otherwise!
448 dlg.Destroy()
451# ---------------------------------------------------------------------------
453def opj(filepath): 1ab
454 """Convert paths to the platform-specific separator"""
455 st = os.path.join(*tuple(filepath.split('/')))
456 # HACK: on Linux, a leading / gets lost...
457 if filepath.startswith('/'):
458 st = '/' + st
459 return st
462def get_data_dir(): 1ab
463 """
464 Return the standard location on this platform for application data
465 """
466 sp = wx.StandardPaths.Get()
467 return sp.GetUserDataDir()
470def get_modified_directory(): 1ab
471 """
472 Returns the directory where modified versions of the Code files
473 are stored
474 """
475 return os.path.join(get_data_dir(), "modified")
478def get_modified_filename(name): 1ab
479 """
480 Returns the filename of the modified version of the specified Code
481 """
482 if not name.endswith(".py"):
483 name = name + ".py"
484 return os.path.join(get_modified_directory(), name)
487def get_original_filename(name): 1ab
488 """
489 Returns the filename of the original version of the specified Code
490 """
491 if not name.endswith(".py"):
492 name = name + ".py"
494 if os.path.isfile(name):
495 return name
497 original_dir = os.getcwd()
498 list_dir = os.listdir(original_dir)
499 # Loop over the content of the Code directory
500 for item in list_dir:
501 if not os.path.isdir(item):
502 # Not a directory, continue
503 continue
504 dir_file = os.listdir(item)
505 # See if a file called "name" is there
506 if name in dir_file:
507 return os.path.join(item, name)
509 # We must return a string...
510 return ""
513def does_modified_exist(name): 1ab
514 """Returns whether the specified Code has a modified copy"""
515 if os.path.exists(get_modified_filename(name)):
516 return True
517 else:
518 return False
521def get_config(): 1ab
522 if not os.path.exists(get_data_dir()):
523 os.makedirs(get_data_dir())
525 config = wx.FileConfig(
526 localFilename=os.path.join(get_data_dir(), "options"))
527 return config
530_platformNames = ["wxMSW", "wxGTK", "wxMac"] 1ab
533def main(filepath, frame=None): 1ab
534 __name__ = f'Code Editor: {filepath}'
535 app = wx.App()
536 app.SetAppDisplayName(__name__)
537 if frame is None:
538 frame = wx.Frame(None)
539 CodeEditorPanel(frame, None, filepath)
540 image_provider = ImageProvider()
541 frame.SetTitle(filepath)
542 frame.SetSize((800, 600))
543 frame.SetIcon(wx.Icon(image_provider.RIDE_ICON))
544 frame.CenterOnScreen()
545 frame.Show(True)
546 app.MainLoop()
547# ----------------------------------------------------------------------------
548# ----------------------------------------------------------------------------
549# ----------------------------------------------------------------------------
552if __name__ == '__main__' and __package__ is None: 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true1ab
553 from os import path
554 sys.path.append(path.dirname(path.dirname(path.abspath(__file__))))
555 path = None
556 try:
557 if sys.argv[1]:
558 path = sys.argv[1]
559 except IndexError:
560 pass
561 finally:
562 main(path)
563# ----------------------------------------------------------------------------